IT기술/뷰.js (vue.js)

Vue.js 컴포넌트 완벽 가이드: 재사용 가능한 UI 구성 요소 만들기

후스파 2025. 7. 8. 07:07
반응형

Vue.js는 컴포넌트 기반의 프레임워크로, UI를 재사용 가능한 컴포넌트로 구성할 수 있습니다.
이번 섹션에서는 Vue 컴포넌트를 정의하고 사용하는 기본적인 방법을 살펴보겠습니다.


컴포넌트 기본 개념

컴포넌트란?

컴포넌트는 Vue.js 애플리케이션의 기본 구성 단위로, 재사용 가능한 Vue 인스턴스입니다. 컴포넌트를 사용하면 복잡한 UI를 독립적이고 재사용 가능한 작은 조각들로 나눌 수 있어 코드의 유지보수성과 확장성을 크게 향상시킬 수 있습니다.

컴포넌트의 장점

  • 재사용성: 한 번 작성한 컴포넌트를 여러 곳에서 사용
  • 유지보수성: 독립적인 컴포넌트로 인한 쉬운 수정과 관리
  • 테스트 용이성: 작은 단위로 나뉜 컴포넌트의 개별 테스트
  • 협업 효율성: 팀원 간 컴포넌트 단위 작업 분담

컴포넌트 정의하기

Vue 컴포넌트는 Vue.component 또는 export default를 사용하여 정의할 수 있습니다. 아래는 간단한 컴포넌트 예제입니다.

Single File Component (SFC) 방식

<!-- MyComponent.vue -->
<template>
  <div class="my-component">
    <h1 class="title">{{ title }}</h1>
    <p class="content">{{ content }}</p>
    <div class="meta">
      <span class="author">작성자: {{ author }}</span>
      <span class="date">{{ formattedDate }}</span>
    </div>
  </div>
</template>

<script>
export default {
  name: 'MyComponent',
  props: {
    title: {
      type: String,
      required: true
    },
    content: {
      type: String,
      default: '내용이 없습니다.'
    },
    author: {
      type: String,
      default: '익명'
    },
    date: {
      type: Date,
      default: () => new Date()
    }
  },
  computed: {
    formattedDate() {
      return this.date.toLocaleDateString('ko-KR');
    }
  }
};
</script>

<style scoped>
.my-component {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 20px;
  margin: 10px 0;
  background-color: #f9f9f9;
}

.title {
  color: #333;
  margin-bottom: 10px;
}

.content {
  line-height: 1.6;
  color: #666;
}

.meta {
  margin-top: 15px;
  font-size: 0.9em;
  color: #999;
}

.meta span {
  margin-right: 15px;
}
</style>

Composition API 방식

<!-- MyComponentComposition.vue -->
<template>
  <div class="my-component">
    <h1>{{ title }}</h1>
    <p>{{ content }}</p>
    <button @click="incrementCounter">클릭 수: {{ counter }}</button>
  </div>
</template>

<script setup>
import { ref, computed, defineProps, defineEmits } from 'vue'

// Props 정의
const props = defineProps({
  title: {
    type: String,
    required: true
  },
  content: {
    type: String,
    default: '기본 내용'
  }
})

// Emits 정의
const emit = defineEmits(['counter-updated'])

// 반응형 데이터
const counter = ref(0)

// 계산된 속성
const displayTitle = computed(() => {
  return `${props.title} (${counter.value})`
})

// 메서드
const incrementCounter = () => {
  counter.value++
  emit('counter-updated', counter.value)
}
</script>

컴포넌트 사용하기

정의한 컴포넌트를 다른 컴포넌트에서 사용할 수 있습니다. import 문을 사용하여 컴포넌트를 가져온 후, 템플릿에서 사용할 수 있습니다.

Options API에서 컴포넌트 사용

<!-- App.vue -->
<template>
  <div id="app">
    <h1>Vue 컴포넌트 예제</h1>
    
    <!-- 기본 사용법 -->
    <MyComponent 
      title="첫 번째 글" 
      content="이것은 첫 번째 컴포넌트입니다." 
      author="김개발"
      :date="new Date('2025-01-01')"
    />
    
    <!-- 동적 데이터 바인딩 -->
    <MyComponent 
      v-for="post in posts"
      :key="post.id"
      :title="post.title"
      :content="post.content"
      :author="post.author"
      :date="post.date"
    />
    
    <!-- 이벤트 리스닝 -->
    <MyComponentComposition
      title="카운터 컴포넌트"
      content="버튼을 클릭해보세요!"
      @counter-updated="handleCounterUpdate"
    />
  </div>
</template>

<script>
import MyComponent from './components/MyComponent.vue'
import MyComponentComposition from './components/MyComponentComposition.vue'

export default {
  name: 'App',
  components: {
    MyComponent,
    MyComponentComposition
  },
  data() {
    return {
      posts: [
        {
          id: 1,
          title: '두 번째 글',
          content: '동적으로 렌더링된 컴포넌트입니다.',
          author: '이프론트',
          date: new Date('2025-01-02')
        },
        {
          id: 2,
          title: '세 번째 글',
          content: 'v-for로 반복 렌더링됩니다.',
          author: '박백엔드',
          date: new Date('2025-01-03')
        }
      ]
    }
  },
  methods: {
    handleCounterUpdate(count) {
      console.log(`카운터가 ${count}로 업데이트되었습니다.`)
    }
  }
}
</script>

Composition API에서 컴포넌트 사용

<template>
  <div>
    <MyComponent 
      v-for="item in items"
      :key="item.id"
      :title="item.title"
      :content="item.content"
      @counter-updated="handleUpdate"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'

const items = ref([
  { id: 1, title: '제목 1', content: '내용 1' },
  { id: 2, title: '제목 2', content: '내용 2' }
])

const handleUpdate = (count) => {
  console.log('업데이트된 카운트:', count)
}
</script>

Props 전달하기

Props는 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달하는 방법입니다.

Props 검증과 기본값

<script>
export default {
  props: {
    // 기본 타입 검증
    title: String,
    
    // 필수 props
    id: {
      type: Number,
      required: true
    },
    
    // 기본값이 있는 props
    content: {
      type: String,
      default: '기본 내용입니다.'
    },
    
    // 객체나 배열의 기본값은 팩토리 함수로
    tags: {
      type: Array,
      default: () => []
    },
    
    // 커스텀 검증 함수
    status: {
      type: String,
      validator: (value) => {
        return ['draft', 'published', 'archived'].includes(value)
      }
    },
    
    // 여러 타입 허용
    value: [String, Number],
    
    // 객체 타입
    user: {
      type: Object,
      default: () => ({
        name: '익명',
        role: 'guest'
      })
    }
  }
}
</script>

Props 사용 예제

<!-- BlogPost.vue -->
<template>
  <article class="blog-post">
    <header>
      <h2>{{ title }}</h2>
      <div class="meta">
        <span>{{ author }}</span>
        <time>{{ publishedAt }}</time>
        <span class="status" :class="status">{{ status }}</span>
      </div>
    </header>
    
    <div class="content">
      {{ content }}
    </div>
    
    <footer>
      <div class="tags">
        <span 
          v-for="tag in tags" 
          :key="tag" 
          class="tag"
        >
          #{{ tag }}
        </span>
      </div>
      
      <div class="actions">
        <button @click="like">좋아요 ({{ likes }})</button>
        <button @click="share">공유</button>
      </div>
    </footer>
  </article>
</template>

<script>
export default {
  name: 'BlogPost',
  props: {
    title: {
      type: String,
      required: true
    },
    content: {
      type: String,
      required: true
    },
    author: {
      type: String,
      default: '익명'
    },
    publishedAt: {
      type: String,
      required: true
    },
    tags: {
      type: Array,
      default: () => []
    },
    status: {
      type: String,
      default: 'draft',
      validator: (value) => ['draft', 'published', 'archived'].includes(value)
    },
    initialLikes: {
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      likes: this.initialLikes
    }
  },
  methods: {
    like() {
      this.likes++
      this.$emit('liked', { title: this.title, likes: this.likes })
    },
    share() {
      this.$emit('shared', this.title)
    }
  }
}
</script>

이벤트 청취하기

자식 컴포넌트에서 부모 컴포넌트로 이벤트를 전달할 수 있습니다. this.$emit을 사용하여 이벤트를 발생시킵니다.

자식 컴포넌트에서 이벤트 발생

<!-- ChildComponent.vue -->
<template>
  <div class="child-component">
    <input v-model="inputValue" @input="handleInput" />
    <button @click="sendMessage">메시지 전송</button>
    <button @click="sendData">데이터 전송</button>
  </div>
</template>

<script>
export default {
  name: 'ChildComponent',
  emits: ['message-sent', 'data-updated', 'input-changed'],
  data() {
    return {
      inputValue: '',
      internalData: { count: 0 }
    }
  },
  methods: {
    sendMessage() {
      this.$emit('message-sent', 'Hello from ChildComponent!')
    },
    
    sendData() {
      this.internalData.count++
      this.$emit('data-updated', {
        ...this.internalData,
        timestamp: new Date()
      })
    },
    
    handleInput() {
      this.$emit('input-changed', this.inputValue)
    }
  }
}
</script>

부모 컴포넌트에서 이벤트 청취

<template>
  <div>
    <h1>부모 컴포넌트</h1>
    
    <ChildComponent 
      @message-sent="handleMessage"
      @data-updated="handleDataUpdate"
      @input-changed="handleInputChange"
    />
    
    <div class="output">
      <p>마지막 메시지: {{ lastMessage }}</p>
      <p>데이터 업데이트 횟수: {{ updateCount }}</p>
      <p>현재 입력값: {{ currentInput }}</p>
    </div>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue'

export default {
  components: {
    ChildComponent
  },
  data() {
    return {
      lastMessage: '',
      updateCount: 0,
      currentInput: ''
    }
  },
  methods: {
    handleMessage(message) {
      this.lastMessage = message
      console.log('받은 메시지:', message)
    },
    
    handleDataUpdate(data) {
      this.updateCount = data.count
      console.log('데이터 업데이트:', data)
    },
    
    handleInputChange(value) {
      this.currentInput = value
    }
  }
}
</script>

슬롯이 있는 컨텐츠 배포

슬롯을 사용하면 부모 컴포넌트에서 자식 컴포넌트의 특정 부분에 콘텐츠를 삽입할 수 있습니다.

기본 슬롯

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header">기본 헤더</slot>
    </div>
    
    <div class="card-body">
      <slot>기본 내용</slot>
    </div>
    
    <div class="card-footer">
      <slot name="footer">
        <button>기본 버튼</button>
      </slot>
    </div>
  </div>
</template>

슬롯 사용 예제

<template>
  <div>
    <!-- 기본 슬롯만 사용 -->
    <Card>
      <p>이것은 카드의 내용입니다.</p>
    </Card>
    
    <!-- 명명된 슬롯 사용 -->
    <Card>
      <template #header>
        <h2>커스텀 헤더</h2>
      </template>
      
      <p>커스텀 내용입니다.</p>
      
      <template #footer>
        <button @click="save">저장</button>
        <button @click="cancel">취소</button>
      </template>
    </Card>
  </div>
</template>

스코프드 슬롯

<!-- TodoList.vue -->
<template>
  <div class="todo-list">
    <div 
      v-for="todo in todos" 
      :key="todo.id"
      class="todo-item"
    >
      <slot 
        :todo="todo" 
        :index="index"
        :toggle="toggleTodo"
        :remove="removeTodo"
      >
        <!-- 기본 렌더링 -->
        <span :class="{ completed: todo.completed }">
          {{ todo.text }}
        </span>
      </slot>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    todos: {
      type: Array,
      required: true
    }
  },
  methods: {
    toggleTodo(id) {
      this.$emit('toggle', id)
    },
    removeTodo(id) {
      this.$emit('remove', id)
    }
  }
}
</script>
<!-- 스코프드 슬롯 사용 -->
<template>
  <TodoList :todos="todos" @toggle="handleToggle" @remove="handleRemove">
    <template #default="{ todo, toggle, remove }">
      <div class="custom-todo">
        <input 
          type="checkbox" 
          :checked="todo.completed"
          @change="toggle(todo.id)"
        />
        <span>{{ todo.text }}</span>
        <button @click="remove(todo.id)">삭제</button>
      </div>
    </template>
  </TodoList>
</template>

동적 컴포넌트

동적 컴포넌트를 사용하면 component 태그와 v-bind:is를 통해 런타임에 컴포넌트를 동적으로 변경할 수 있습니다.

<template>
  <div>
    <div class="tab-buttons">
      <button 
        v-for="tab in tabs"
        :key="tab.name"
        :class="{ active: currentTab === tab.name }"
        @click="currentTab = tab.name"
      >
        {{ tab.label }}
      </button>
    </div>
    
    <!-- 동적 컴포넌트 -->
    <component 
      :is="currentComponent"
      :data="currentData"
      @data-changed="handleDataChange"
    />
    
    <!-- keep-alive로 컴포넌트 상태 유지 -->
    <keep-alive>
      <component 
        :is="currentComponent"
        :data="currentData"
      />
    </keep-alive>
  </div>
</template>

<script>
import HomeTab from './tabs/HomeTab.vue'
import ProfileTab from './tabs/ProfileTab.vue'
import SettingsTab from './tabs/SettingsTab.vue'

export default {
  components: {
    HomeTab,
    ProfileTab,
    SettingsTab
  },
  data() {
    return {
      currentTab: 'home',
      tabs: [
        { name: 'home', label: '홈', component: 'HomeTab' },
        { name: 'profile', label: '프로필', component: 'ProfileTab' },
        { name: 'settings', label: '설정', component: 'SettingsTab' }
      ],
      tabData: {
        home: { message: '홈 페이지입니다.' },
        profile: { user: { name: '사용자', email: 'user@example.com' } },
        settings: { theme: 'dark', notifications: true }
      }
    }
  },
  computed: {
    currentComponent() {
      const tab = this.tabs.find(tab => tab.name === this.currentTab)
      return tab ? tab.component : 'HomeTab'
    },
    currentData() {
      return this.tabData[this.currentTab] || {}
    }
  },
  methods: {
    handleDataChange(newData) {
      this.tabData[this.currentTab] = { ...this.tabData[this.currentTab], ...newData }
    }
  }
}
</script>

DOM 템플릿 파싱 주의 사항

Vue 템플릿은 HTML을 기반으로 하지만, Vue의 템플릿 문법이 포함되어 있기 때문에 DOM 템플릿 파싱 시 몇 가지 주의할 점이 있습니다.

HTML 제약 사항

<!-- 잘못된 사용 (HTML 제약) -->
<table>
  <my-row></my-row> <!-- table 내부에서 유효하지 않음 -->
</table>

<select>
  <my-option></my-option> <!-- select 내부에서 유효하지 않음 -->
</select>

<!-- 올바른 사용 (is 속성 활용) -->
<table>
  <tr is="vue:my-row"></tr>
</table>

<select>
  <option is="vue:my-option"></option>
</select>

대소문자 구분

<!-- camelCase는 DOM에서 작동하지 않음 -->
<my-component myProp="value"></my-component>

<!-- kebab-case 사용 -->
<my-component my-prop="value"></my-component>

자체 닫는 태그

<!-- DOM 템플릿에서는 자체 닫는 태그 사용 불가 -->
<my-component />

<!-- 명시적으로 닫는 태그 사용 -->
<my-component></my-component>

결론

Vue.js의 컴포넌트 시스템은 재사용성과 유지보수성을 높이는 데 큰 도움이 됩니다. 컴포넌트를 정의하고 사용하는 기본 방법, props와 이벤트, 슬롯, 동적 컴포넌트 등을 이해하고 활용하면, 더 효과적으로 Vue 애플리케이션을 개발할 수 있습니다.
핵심 포인트:

  • Single File Component (SFC) 방식으로 구조화된 컴포넌트 개발
  • Props 검증과 기본값 설정으로 안정적인 데이터 전달
  • $emit과 이벤트 청취로 부모-자식 간 통신
  • 슬롯과 스코프드 슬롯으로 유연한 컨텐츠 배포
  • 동적 컴포넌트와 keep-alive로 런타임 컴포넌트 제어
  • DOM 템플릿 제약사항 이해하여 호환성 확보

컴포넌트 기반 개발을 통해 확장 가능하고 유지보수하기 쉬운 Vue.js 애플리케이션을 구축할 수 있습니다.

반응형