Vue.js 컴포넌트 시스템 심층 분석: 생성, 통신, 그리고 동적 렌더링

1. 컴포넌트 아키텍처의 핵심

1.1 컴포넌트의 본질

Vue.js에서 컴포넌트는 재사용 가능한 UI 조각을 캡슐화하는 핵심 메커니즘입니다. 기본적으로 커스텀 HTML 요소로 동작하며, Vue 컴파일러가 이를 인식하여 특정 동작을 부여합니다. 모든 컴포넌트는 근본적으로 Vue 인스턴스이므로, 루트 인스턴스와 유사한 라이프사이클 훅과 옵션 객체를 공유합니다.

컴포넌트를 독립적인 웹 페이지처럼 구상할 수 있지만, iframe과 달리 외부 환경과 단절되지 않습니다. 오히려 호스트 페이지의 컨텍스트와 조화를 이루며, 데이터와 이벤트를 통해 긴밀하게 상호작용합니다. 일반적으로 애플리케이션은 헤더, 사이드바, 콘텐츠 영역과 같이 중첩된 컴포넌트 트리 구조로 설계됩니다.

예를 들어, 복잡한 대시보드에서 '위젯'을 여러 번 재사용해야 한다고 가정해 봅시다. 매번 동일한 마크업과 로직을 복제하는 대신, 단일 컴포넌트로 추상화한 후 프로퍼티를 통해 각 인스턴스의 동작을 제어하는 것이 효율적입니다.

<dashboard-widget type="chart" />

1.2 컴포넌트 등록과 정의

컴포넌트를 생성하는 과정은 등록(Registration)이라고 부릅니다. 전역 범위에 컴포넌트를 정의하려면 Vue.component를 사용합니다.

Vue.component('component-name', {
    // 컴포넌트 옵션
})

W3C 표준에 따라 커스텀 요소 이름은 소문자로 작성하고 하이픈을 포함하는 것이 권장됩니다.

데이터 옵션의 독립성

컴포넌트의 data는 반드시 함수여야 합니다. 이는 각 컴포넌트 인스턴스가 독립적인 데이터 스코프를 유지하여, 여러 인스턴스가 동일한 데이터를 참조하는 부작용을 방지하기 위함입니다.

Vue.component('score-tracker', {
  data: function () {
    return {
      points: 0
    }
  },
  template: '<button @click="points++">현재 점수: {{ points }}</button>'
})

템플릿 제약 사항

컴포넌트 템플릿은 반드시 단일 루트 요소를 가져야 합니다. 템플릿 정의 방식에는 문자열 리터럴, 단일 파일 컴포넌트(.vue), 인라인 템플릿, script type="text/x-template" 등이 있습니다.

전역 및 지역 등록

Vue.component로 등록된 컴포넌트는 전역에서 사용 가능하지만, 특정 인스턴스에서만 사용될 컴포넌트는 components 옵션을 통해 지역적으로 등록하는 것이 번들 크기와 메모리 관리 측면에서 유리합니다.

const ProfileCard = { /* ... */ }
const SettingsPanel = { /* ... */ }

new Vue({
  el: '#app',
  components: {
    'profile-card': ProfileCard,
    'settings-panel': SettingsPanel
  }
})

2. 컴포넌트 간 데이터 흐름과 통신

컴포넌트 트리가 형성되면, 부모와 자식 간의 데이터 교환이 필수적입니다. Vue는 단방향 데이터 흐름을 원칙으로 하며, 명시적인 인터페이스를 통해 통신을 관리합니다.

2.1 Props를 통한 하향식 데이터 전달

자식 컴포넌트는 props 옵션을 통해 부모로부터 데이터를 수신할 수 있습니다.

Vue.component('user-greeting', {
  props: ['userName'],
  template: '<span>안녕하세요, {{ userName }}님!</span>'
})

DOM 템플릿을 사용할 때 HTML은 대소문자를 구분하지 않으므로, 카멜케이스(camelCase)로 정의된 프로퍼티는 케밥케이스(kebab-case)로 변환하여 전달해야 합니다.

객체 프로퍼티 일괄 전달

v-bind를 사용하면 객체의 모든 키-값 쌍을 개별 프로퍼티로 자동 매핑할 수 있습니다.

<article-card v-bind="articleData"></article-card>

프로퍼티 유효성 검사

안정적인 컴포넌트 설계를 위해 props에 타입, 필수 여부, 기본값, 커스텀 검증 로직을 명시할 수 있습니다.

Vue.component('data-grid', {
  props: {
    rowCount: Number,
    status: {
      type: String,
      required: true
    },
    config: {
      type: Object,
      default: function () {
        return { sortable: false }
      }
    },
    role: {
      validator: function (val) {
        return ['admin', 'editor', 'viewer'].includes(val)
      }
    }
  }
})

2.2 이벤트를 통한 상향식 메시지 전송

자식 컴포넌트에서 발생한 상태를 부모에게 알리기 위해서는 커스텀 이벤트를 사용합니다. 자식은 $emit을 호출하고, 부모는 v-on(또는 @)으로 이를 청취합니다.

Vue.component('vote-button', {
  template: '<button @click="castVote">투표하기</button>',
  methods: {
    castVote: function () {
      this.$emit('voted', { timestamp: Date.now() })
    }
  }
})

부모 컴포넌트에서는 이벤트 페이로드를 메서드의 인자로 받거나 $event 변수를 통해 접근할 수 있습니다.

컴포넌트에서의 v-model 구현

커스텀 입력 컴포넌트에서 v-model을 지원하려면 value 프로퍼티를 바인딩하고, input 이벤트를 통해 변경된 값을 $emit해야 합니다.

Vue.component('search-field', {
  props: ['value'],
  template: `
    <input
      :value="value"
      @input="$emit('input', $event.target.value)"
    >
  `
})

3. 슬롯(Slot)을 활용한 콘텐츠 분배

슬롯은 부모 컴포넌트가 자식 컴포넌트의 특정 영역에 마크업을 주입할 수 있게 해주는 콘텐츠 분배 API입니다.

3.1 기본 슬롯과 폴백 콘텐츠

단순한 <slot></slot> 태그는 부모가 전달한 콘텐츠로 대체됩니다. 슬롯 내부에 기본 콘텐츠를 작성해두면, 부모가 아무것도 전달하지 않았을 때 폴백(fallback)으로 표시됩니다.

<button type="button">
  <slot>기본 버튼 텍스트</slot>
</button>

3.2 범위가 지정된 명명된 슬롯

복잡한 레이아웃 컴포넌트에서는 여러 개의 슬롯이 필요할 수 있습니다. name 속성을 사용하여 슬롯을 구분하고, 부모에서는 slot 속성(또는 v-slot)을 통해 해당 위치에 콘텐츠를 배치합니다.

<div class="layout">
  <header><slot name="top-bar"></slot></header>
  <main><slot></slot></main>
</div>

4. 동적 컴포넌트와 상태 보존

4.1 is 속성을 통한 동적 전환

<component> 요소와 is 속성을 결합하면, 변수 값에 따라 렌더링할 컴포넌트를 동적으로 전환할 수 있습니다. 이는 탭 UI나 마법사(Wizard) 패턴 구현에 유용합니다.

<component :is="activeTabComponent"></component>

4.2 keep-alive를 활용한 인스턴스 캐싱

동적 컴포넌트가 전환될 때 기본적으로 이전 인스턴스는 소멸됩니다. <keep-alive>로 감싸면 비활성 상태의 컴포넌트 인스턴스를 메모리에 캐싱하여, 재방문 시 상태를 복원하고 불필요한 렌더링을 방지합니다.

<keep-alive>
  <component :is="currentView"></component>
</keep-alive>

4.3 컴포넌트 옵션 객체 직접 바인딩

is 속성에는 문자열 이름뿐만 아니라 컴포넌트 옵션 객체 자체를 직접 바인딩할 수도 있습니다.

const viewConfig = {
  template: '<p>동적으로 주입된 뷰입니다.</p>'
}

// 템플릿에서
// <component :is="viewConfig"></component>

5. 고급 컴포넌트 패턴 및 주의사항

5.1 DOM 템플릿 파싱 제약

<table>, <ul>, <select>와 같은 특정 HTML 요소는 자식 요소에 대한 엄격한 규칙을 가지고 있습니다. 이러한 요소 내부에 커스텀 컴포넌트를 직접 배치하면 브라우저가 이를 밖으로 배출해 버리는 문제가 발생합니다. 이를 해결하기 위해 is 속성을 사용하여 네이티브 요소인 것처럼 위장해야 합니다.

<table>
  <tr is="custom-table-row"></tr>
</table>

5.2 비-Prop 속성과 클래스 병합

컴포넌트가 props로 선언하지 않은 속성을 전달받으면, 해당 속성은 컴포넌트의 루트 요소에 자동으로 상속됩니다. 특히 classstyle의 경우, 루트 요소의 기존 값과 지능적으로 병합됩니다.

5.3 컴포넌트 루트의 네이티브 이벤트 청취

컴포넌트 자체에서 발생하는 커스텀 이벤트가 아닌, 루트 DOM 요소의 네이티브 이벤트(예: click, focus)를 직접 청취하려면 .native 수식어를 사용해야 합니다.

<custom-input @focus.native="handleFocus"></custom-input>

5.4 .sync 수식어를 통한 양방향 바인딩

특정 프로퍼티에 대해 부모와 자식 간의 양방향 데이터 흐름이 필요할 때, update:propName 패턴의 이벤트를 발생시키고 부모에서 이를 청취하는 번거로움을 .sync 수식어로 단순화할 수 있습니다.

// 자식 컴포넌트 내부
this.$emit('update:visibility', false)

// 부모 템플릿
<modal-dialog :visibility.sync="isModalOpen"></modal-dialog>

태그: Vue.js ComponentArchitecture props EventEmit Slot

7월 25일 12:54에 게시됨