Vue 3 컴포넌트 아키텍처와 데이터 흐름 완전 가이드

1. 컴포넌트 정의 및 재사용성 (전역과 지역 등록)

Vue에서 컴포넌트는 UI를 재사용 가능한 단위로 분리하는 핵심 메커니즘입니다. 컴포넌트는 사용 범위에 따라 지역(Local) 또는 전역(Global)으로 등록할 수 있습니다. 지역 컴포넌트는 일반적으로 파스칼 케이스(PascalCase)로 명명하며, 전역 컴포넌트는 케밥 케이스(kebab-case)로 명명하는 것이 관례입니다.

// 지역 컴포넌트 정의: 등록한 앱 인스턴스 내에서만 사용 가능
const ScoreTracker = {
  data() {
    return {
      score: 10
    }
  },
  template: `<button @click="score += 5">Current Score: {{ score }}</button>`
}

const app = Vue.createApp({
  components: { ScoreTracker },
  template: `
    <div>
      <ScoreTracker />
      
    </div>
  `
})

// 전역 컴포넌트 정의: 앱 내 모든 곳에서 사용 가능
app.component('click-counter', {
  data() {
    return {
      clicks: 0
    }
  },
  template: `<button @click="clicks++">Total Clicks: {{ clicks }}</button>`
})

app.mount('#app')

2. Props 기반 데이터 전달 및 유효성 검증

2.1 데이터 전달 방식

부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달할 때는 props를 사용합니다. 정적 값을 전달할 수도 있고, v-bind(또는 :)를 사용하여 동적으로 반응형 데이터를 바인딩할 수도 있습니다.

const app = Vue.createApp({
  data() {
    return { userAge: 28 }
  },
  template: `<UserProfile :age="userAge" />`
})

app.component('UserProfile', {
  props: ['age'],
  template: `<p>User Age: {{ age }}</p>`
})

app.mount('#app')

2.2 Props 유효성 검증

컴포넌트의 안정성을 높이기 위해 props의 타입, 기본값, 필수 여부 및 커스텀 검증 로직을 명시할 수 있습니다.

  • 지원 타입: String, Number, Boolean, Array, Object, Function, Symbol
app.component('UserProfile', {
  props: {
    age: {
      type: Number,
      default: 18,
      required: true,
      validator: (value) => {
        return value >= 0 && value <= 120
      }
    }
  },
  template: `<p>User Age: {{ age }}</p>`
})

3. 단방향 데이터 흐름(One-Way Data Flow)의 원칙

3.1 객체 속성 일괄 바인딩

여러 개의 속성을 한 번에 전달해야 할 경우, v-bind에 객체를 바인딩하면 객체의 모든 키가 개별 prop으로 매핑됩니다.

const app = Vue.createApp({
  data() {
    return {
      themeConfig: {
        primaryColor: '#3498db',
        fontSize: 16,
        isDarkMode: false
      }
    }
  },
  template: `<ThemeApplier v-bind="themeConfig" />`
})

app.component('ThemeApplier', {
  props: ['primaryColor', 'fontSize', 'isDarkMode'],
  template: `<div>Color: {{ primaryColor }}, Size: {{ fontSize }}</div>`
})

app.mount('#app')

3.2 단방향 데이터 흐름과 대소문자 규칙

HTML 템플릿에서는 kebab-case로 속성을 전달하고, 자식 컴포넌트의 props에서는 camelCase로 수신합니다. 단방향 데이터 흐름의 핵심은 자식 컴포넌트가 부모로부터 전달받은 prop을 직접 수정해서는 안 된다는 것입니다. 이를 통해 컴포넌트 간의 데이터 결합도를 낮추고 상태 관리를 예측 가능하게 만듭니다. 수정이 필요하다면 datacomputed를 사용하여 지역 복사본을 만들어야 합니다.

app.component('ScoreTracker', {
  props: ['initialScore'],
  data() {
    return {
      localScore: this.initialScore
    }
  },
  template: `<button @click="localScore += 1">Score: {{ localScore }}</button>`
})

4. Non-Props 속성과 $attrs 활용

부모 컴포넌트에서 전달한 속성 중 자식 컴포넌트의 props로 선언되지 않은 속성(Non-Props Attributes)은 기본적으로 자식 컴포넌트의 최상위 루트 DOM 요소에 자동으로 상속됩니다. 이는 클래스나 data-* 속성을 적용할 때 유용합니다.

4.1 속성 상속 차단 및 재배치

inheritAttrs: false를 사용하여 자동 상속을 막고, $attrs를 통해 특정 내부 요소에 수동으로 적용할 수 있습니다.

app.component('BaseInput', {
  inheritAttrs: false,
  template: `
    <div class="input-wrapper">
      <label>Enter Value:</label>
      <input v-bind="$attrs" />
    </div>
  `
})

// 사용 예시: <BaseInput type="email" placeholder="Email" aria-label="Email Input" />

자식 컴포넌트가 여러 개의 루트 노드를 가질 경우, v-bind="$attrs"를 사용하여 속성을 적용할 타겟 요소를 명시적으로 지정해야 합니다. 스크립트 내에서는 this.$attrs 객체를 통해 Non-Props 속성에 접근할 수 있습니다.

5. 사용자 정의 이벤트를 통한 컴포넌트 간 통신

자식 컴포넌트에서 부모 컴포넌트로 데이터를 전달하거나 상태를 변경해야 할 때는 this.$emit()을 사용하여 사용자 정의 이벤트를 발생시킵니다. 부모는 v-on(또는 @)을 통해 해당 이벤트를 수신합니다.

const app = Vue.createApp({
  data() {
    return { totalScore: 0 }
  },
  methods: {
    handleScoreUpdate(points) {
      this.totalScore += points
    }
  },
  template: `<ScoreTracker :score="totalScore" @add-points="handleScoreUpdate" />`
})

app.component('ScoreTracker', {
  props: ['score'],
  methods: {
    addPoints() {
      this.$emit('add-points', 5)
    }
  },
  template: `
    <div>
      <p>Total: {{ score }}</p>
      <button @click="addPoints">Add 5 Points</button>
    </div>
  `
})

app.mount('#app')

6. v-model을 활용한 고급 양방향 바인딩

6.1 기본 v-model과 커스텀 이벤트

Vue 3에서 v-model은 기본적으로 modelValue라는 propupdate:modelValue 이벤트를 사용합니다.

app.component('CustomCounter', {
  props: ['modelValue'],
  methods: {
    increment() {
      this.$emit('update:modelValue', this.modelValue + 10)
    }
  },
  template: `<button @click="increment">Value: {{ modelValue }}</button>`
})

6.2 다중 v-model 바인딩

v-model:argument 형식을 사용하면 하나의 컴포넌트에 여러 개의 양방향 바인딩을 적용할 수 있습니다.

app.component('PriceInput', {
  props: ['price'],
  methods: {
    applyDiscount() {
      this.$emit('update:price', this.price * 0.9)
    }
  },
  template: `<button @click="applyDiscount">Discounted: {{ price }}</button>`
})
// 사용 예시: <PriceInput v-model:price="itemPrice" />

6.3 v-model 수정자(Modifiers) 처리

modelModifiers prop을 통해 부모가 전달한 v-model 수정자를 감지하고 커스텀 로직을 적용할 수 있습니다.

app.component('TextInput', {
  props: {
    modelValue: String,
    modelModifiers: { default: () => ({}) }
  },
  methods: {
    handleInput(e) {
      let value = e.target.value
      if (this.modelModifiers.uppercase) {
        value = value.toUpperCase()
      }
      this.$emit('update:modelValue', value)
    }
  },
  template: `<input :value="modelValue" @input="handleInput" />`
})

7. 슬롯(Slot)과 이름 있는 슬롯을 통한 콘텐츠 주입

7.1 슬롯의 데이터 작용 범위와 기본값

슬롯 내부에서 사용되는 변수는 해당 슬롯을 정의한 부모 템플릿의 데이터 스코프를 따릅니다. 자식 컴포넌트는 <slot> 태그 사이에 fallback 콘텐츠를 배치하여 기본값을 제공할 수 있습니다.

7.2 이름 있는 슬롯과 약어

복잡한 레이아웃을 구현할 때 name 속성을 사용하여 여러 개의 슬롯을 구분할 수 있습니다. 템플릿에서는 v-slot: 또는 # 약어를 사용합니다.

const app = Vue.createApp({
  template: `
    <DashboardLayout>
      <template #header>
        <h1>Dashboard Header</h1>
      </template>
      <template #sidebar>
        <nav>Navigation Links</nav>
      </template>
    </DashboardLayout>
  `
})

app.component('DashboardLayout', {
  template: `
    <div class="layout">
      <header><slot name="header" /></header>
      <aside><slot name="sidebar" /></aside>
      <main><slot /></main>
    </div>
  `
})

app.mount('#app')

8. 범위가 지정된 슬롯(Scoped Slots)

자식 컴포넌트 내부의 데이터를 부모 컴포넌트의 슬롯 템플릿에서 사용해야 할 때, <slot>에 속성을 바인딩하여 데이터를 외부로 노출할 수 있습니다.

const app = Vue.createApp({
  template: `
    <DataGrid>
      <template #default="{ rowData }">
        <div class="custom-cell">{{ rowData.name }} - {{ rowData.status }}</div>
      </template>
    </DataGrid>
  `
})

app.component('DataGrid', {
  data() {
    return {
      items: [
        { id: 1, name: 'Server A', status: 'Online' },
        { id: 2, name: 'Server B', status: 'Offline' }
      ]
    }
  },
  template: `
    <ul>
      <li v-for="item in items" :key="item.id">
        <slot :rowData="item"></slot>
      </li>
    </ul>
  `
})

app.mount('#app')

9. 동적 및 비동기 컴포넌트 패턴

9.1 동적 컴포넌트와 상태 캐싱

<component :is="...">를 사용하면 데이터에 따라 렌더링할 컴포넌트를 동적으로 전환할 수 있습니다. <KeepAlive>로 감싸면 전환 시 컴포넌트 인스턴스가 파괴되지 않고 메모리에 캐싱되어 상태를 유지합니다.

const app = Vue.createApp({
  data() {
    return { activeTab: 'TabA' }
  },
  template: `
    <div>
      <button @click="activeTab = activeTab === 'TabA' ? 'TabB' : 'TabA'">Toggle</button>
      <KeepAlive>
        <component :is="activeTab" />
      </KeepAlive>
    </div>
  `
})

app.component('TabA', { template: `<div>Content of Tab A</div>` })
app.component('TabB', { template: `<div>Content of Tab B</div>` })

app.mount('#app')

9.2 비동기 컴포넌트 로딩

defineAsyncComponent를 사용하면 컴포넌트가 실제로 필요해질 때까지 로딩을 지연시켜 초기 번들 크기를 최적화할 수 있습니다.

const HeavyChart = Vue.defineAsyncComponent(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        template: `<div>Heavy Chart Rendered</div>`
      })
    }, 1500)
  })
})

app.component('HeavyChart', HeavyChart)

10. 핵심 디렉티브 및 인스턴스 API 심화

10.1 v-once 디렉티브

v-once가 적용된 요소는 초기 렌더링 한 번만 수행되며, 이후 반응형 데이터가 변경되어도 다시 렌더링되지 않아 성능을 최적화합니다.

app.component('StaticHeader', {
  data() { return { timestamp: Date.now() } },
  template: `<h1 v-once>Generated at: {{ timestamp }}</h1>`
})

10.2 ref를 통한 DOM 및 컴포넌트 인스턴스 접근

템플릿의 요소나 자식 컴포넌트에 ref 속성을 할당하면, 마운트 후 this.$refs를 통해 해당 인스턴스나 DOM 노드에 직접 접근할 수 있습니다.

app.component('SearchBar', {
  mounted() {
    this.$refs.searchInput.focus()
  },
  template: `<input ref="searchInput" type="text" placeholder="Search..." />`
})

10.3 provideinject를 활용한 의존성 주입

깊이 중첩된 컴포넌트 트리에서 Props Drilling을 피하기 위해 provide로 데이터를 제공하고, 하위 컴포넌트에서 inject로 이를 주입받을 수 있습니다.

const app = Vue.createApp({
  data() {
    return { globalTheme: 'dark' }
  },
  provide() {
    return {
      theme: this.globalTheme
    }
  },
  template: `<DeepChild />`
})

app.component('DeepChild', {
  inject: ['theme'],
  template: `<div class="panel" :class="theme">Current Theme: {{ theme }}</div>`
})

app.mount('#app')

태그: vue3 ComponentArchitecture props emit slots

9월 11일 01:14에 게시됨