컴포넌트 기초

컴포넌트 기초

화면이 커지면 한 파일에 모든 걸 담기 어려워져요. Vue 컴포넌트를 쓰면 UI를 독립적이고 재사용 가능한 조각으로 나누고, 각 조각을 따로 생각할 수 있게 됩니다. 앱은 보통 중첩된 컴포넌트 트리로 구성되고, 루트 컴포넌트에서 여러 자식·손자 컴포넌트로 가지를 뻗어나가지요.

출처: Vue.js 공식 문서 - Components Basics

컴포넌트 정의하기

빌드 도구를 쓸 때는 각 Vue 컴포넌트를 .vue 확장자의 전용 파일, 즉 단일 파일 컴포넌트(SFC)로 정의하는 게 일반적이에요.

<script setup>
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button @click="count++">You clicked me {{ count }} times.</button>
</template>

빌드 도구를 쓰지 않을 때는 Vue 전용 옵션을 담은 평범한 JavaScript 객체로도 컴포넌트를 정의할 수 있어요. 템플릿은 JavaScript 문자열로 인라인하고, Vue가 그 자리에서 컴파일하게 됩니다. 또는 요소를 가리키는 ID 선택자(보통 네이티브 <template> 요소)를 써서 그 내용을 템플릿 원본으로 삼을 수도 있어요.

import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    return { count }
  },
  template: `
    <button @click="count++">
      You clicked me {{ count }} times.
    </button>`
  // in-DOM 템플릿을 쓰려면:
  // template: '#my-template-element'
}

위 예시는 컴포넌트 하나를 정의해 .js 파일의 기본(default) export로 내보냈는데, named export를 쓰면 같은 파일에서 여러 컴포넌트를 내보낼 수도 있어요.

컴포넌트 사용하기

자식 컴포넌트를 쓰려면 부모 컴포넌트에서 import 해야 해요. 카운터 컴포넌트를 ButtonCounter.vue 파일에 넣었다고 가정하면, 그 파일 이름이 컴포넌트의 default export와 연결됩니다.

<script setup>
import ButtonCounter from './ButtonCounter.vue'
</script>

<template>
  <h1>Here is a child component!</h1>
  <ButtonCounter />
</template>

<script setup>에서는 import한 컴포넌트가 템플릿에 자동으로 노출됩니다. 특정 앱의 모든 컴포넌트에서 import 없이 쓰도록 전역 등록할 수도 있지만, 전역 vs 로컬 등록의 장단점은 컴포넌트 등록 문서에서 다뤄요.

컴포넌트는 원하는 만큼 재사용할 수 있습니다.

<h1>Here are many child components!</h1>
<ButtonCounter />
<ButtonCounter />
<ButtonCounter />

버튼을 클릭하면 각각 자기만의 별도 count를 유지하는 걸 볼 수 있을 거예요. 컴포넌트를 쓸 때마다 새로운 인스턴스가 생성되기 때문이에요.

SFC에서는 네이티브 HTML 요소와 구분하도록 자식 컴포넌트 태그 이름을 PascalCase로 쓰는 걸 권장합니다. HTML 태그 이름은 대소문자를 구분하지 않지만, Vue SFC는 컴파일되는 형식이라 대소문자를 구분하는 태그 이름을 쓸 수 있어요. />로 태그를 닫는 것도 가능합니다.

Props 전달하기

블로그를 만든다고 해볼게요. 블로그 포스트를 나타내는 컴포넌트가 필요한데, 모든 포스트가 같은 시각적 레이아웃을 공유하되 내용은 달라야 합니다. 이런 컴포넌트가 유용하려면 표시할 특정 포스트의 제목·내용 같은 데이터를 넘길 수 있어야 하는데, 그게 바로 props예요.

Props는 컴포넌트에 등록할 수 있는 커스텀 속성입니다. 블로그 포스트 컴포넌트에 제목을 넘기려면, 이 컴포넌트가 받을 props 목록에 그것을 선언해야 해요. <script setup>에서는 defineProps 매크로로 선언합니다.

<script setup>
defineProps(['title'])
</script>

<template>
  <h4>{{ title }}</h4>
</template>

defineProps<script setup> 안에서만 쓸 수 있는 컴파일 타임 매크로라서 명시적으로 import 할 필요가 없어요. 선언한 props는 자동으로 템플릿에 노출되고, defineProps는 컴포넌트에 전달된 모든 props를 담은 객체를 반환해서 JavaScript에서도 접근할 수 있습니다.

const props = defineProps(['title'])
console.log(props.title)

<script setup>을 쓰지 않을 때는 props 옵션으로 선언하고, props 객체가 setup()의 첫 번째 인자로 전달돼요.

export default {
  props: ['title'],
  setup(props) {
    console.log(props.title)
  }
}

컴포넌트는 props를 몇 개든 가질 수 있고, 기본적으로 어떤 값이든 어떤 prop에 전달할 수 있습니다. 선언하고 나면 커스텀 속성으로 데이터를 넘기면 되지요.

<BlogPost title="My journey with Vue" />
<BlogPost title="Blogging with Vue" />
<BlogPost title="Why Vue is so fun" />

전형적인 앱에서는 부모 컴포넌트에 포스트 배열이 있고, v-for로 포스트마다 컴포넌트를 렌더링하게 돼요.

const posts = ref([
  { id: 1, title: 'My journey with Vue' },
  { id: 2, title: 'Blogging with Vue' },
  { id: 3, title: 'Why Vue is so fun' }
])
<BlogPost
  v-for="post in posts"
  :key="post.id"
  :title="post.title"
 />

여기서 :title="post.title"처럼 v-bind 문법으로 동적 prop 값을 전달하는 걸 볼 수 있어요. 미리 렌더링할 정확한 내용을 모를 때 특히 유용합니다.

이벤트 듣기

<BlogPost>를 개발하다 보면 부모에게 어떤 내용을 알려줘야 할 때가 생겨요. 예를 들어 블로그 포스트의 글자만 키우는 접근성 기능을 넣는다고 해볼게요. 부모에 postFontSize ref를 추가하고, 템플릿에서 모든 포스트의 글자 크기를 제어할 수 있게 합니다.

const posts = ref([
  /* ... */
])

const postFontSize = ref(1)
<div :style="{ fontSize: postFontSize + 'em' }">
  <BlogPost
    v-for="post in posts"
    :key="post.id"
    :title="post.title"
   />
</div>

이제 <BlogPost> 컴포넌트 템플릿에 "Enlarge text" 버튼을 추가해 볼게요.

<!-- <script> 생략 -->
<template>
  <div class="blog-post">
    <h4>{{ title }}</h4>
    <button>Enlarge text</button>
  </div>
</template>

버튼은 아직 아무것도 안 해요. 버튼을 클릭하면 모든 포스트의 글자가 커지도록 부모에게 알리고 싶은 거죠. 이 문제를 위해 컴포넌트는 커스텀 이벤트 시스템을 제공합니다. 부모는 네이티브 DOM 이벤트처럼 v-on 또는 @로 자식 컴포넌트의 이벤트를 들을 수 있어요.

<BlogPost
  ...
  @enlarge-text="postFontSize += 0.1"
 />

자식 컴포넌트는 내장 $emit 메서드를 호출해 이벤트 이름을 넘기면 자기 자신이 이벤트를 발생시킵니다.

<!-- <script> 생략 -->
<template>
  <div class="blog-post">
    <h4>{{ title }}</h4>
    <button @click="$emit('enlarge-text')">Enlarge text</button>
  </div>
</template>

@enlarge-text="postFontSize += 0.1" 리스너 덕분에 부모가 이벤트를 받아 postFontSize 값을 갱신하게 됩니다.

발생시키는 이벤트는 선택적으로 defineEmits 매크로로 선언할 수 있어요. 이렇게 하면 컴포넌트가 발생시키는 모든 이벤트가 문서화되고, 선택적으로 검증도 됩니다. 또 Vue가 그것들을 자식 컴포넌트 루트 요소의 네이티브 리스너로 암묵 적용하는 것도 막아 줍니다.

<script setup>
defineProps(['title'])
defineEmits(['enlarge-text'])
</script>

defineProps와 마찬가지로 defineEmits<script setup>에서만 쓸 수 있고 import가 필요 없어요. $emit 메서드와 같은 emit 함수를 반환해서, $emit에 직접 접근할 수 없는 <script setup> 부분에서 이벤트를 발생시킬 때 씁니다.

<script setup>
const emit = defineEmits(['enlarge-text'])

emit('enlarge-text')
</script>

슬롯으로 콘텐츠 배포하기

HTML 요소처럼 컴포넌트에 콘텐츠를 넘기면 유용한 경우가 많아요.

<AlertBox>
  Something bad happened.
</AlertBox>

이건 Vue의 커스텀 <slot> 요소로 구현할 수 있습니다. <slot>은 콘텐츠가 들어갈 자리를 표시하는 플레이스홀더 역할을 해요. 그걸로 끝입니다!

<template>
  <div class="alert-box">
    <strong>This is an Error for Demo Purposes</strong>
    <slot />
  </div>
</template>

<style scoped>
.alert-box {
  /* ... */
}
</style>

동적 컴포넌트

탭 인터페이스처럼 컴포넌트를 동적으로 전환해야 할 때가 있어요. 이건 Vue의 <component> 요소와 특별한 is 속성으로 가능합니다.

<!-- currentTab 값이 바뀌면 컴포넌트가 바뀌어요. -->
<component :is="tabs[currentTab]"></component>

is에 전달하는 값은 등록된 컴포넌트의 이름 문자열이거나, 실제로 import한 컴포넌트 객체일 수 있어요. is 속성으로 일반 HTML 요소를 만들 수도 있습니다. <component :is="...">로 여러 컴포넌트를 전환할 때, 전환해 나가는 컴포넌트는 언마운트됩니다. 비활성 컴포넌트를 살아 있게 유지하고 싶으면 내장 <KeepAlive> 컴포넌트를 쓰면 됩니다.

in-DOM 템플릿 파싱 주의점

템플릿을 DOM에 직접 작성하면, 브라우저의 네이티브 HTML 파싱 동작 때문에 몇 가지 제약이 생겨요. 이 제약은 템플릿을 DOM에 직접 쓸 때만 적용되며, SFC·인라인 템플릿 문자열·<script type="text/x-template"> 같은 문자열 템플릿에는 해당하지 않아요.

  • 대소문자 구분 없음 — HTML 태그와 속성 이름은 대소문자를 구분하지 않아서, 브라우저가 대문자를 소문자로 해석합니다. 그래서 in-DOM 템플릿에서는 PascalCase 컴포넌트 이름·camelCase prop 이름·v-on 이벤트 이름을 모두 kebab-case(하이픈 구분)로 써야 해요.

    // JavaScript 에서는 camelCase
    const BlogPost = {
      props: ['postTitle'],
      emits: ['updatePost'],
      template: `
        <h3>{{ postTitle }}</h3>
      `
    }
    
    <!-- HTML 에서는 kebab-case -->
    <blog-post post-title="hello!" @update-post="onUpdatePost"></blog-post>
    
  • 자체 닫힘 태그 — Vue 템플릿 파서는 />를 태그 종료 신호로 처리하지만, in-DOM 템플릿에서는 반드시 명시적인 닫힘 태그를 써야 해요. HTML 명세는 <input>·<img> 같은 소수의 요소에만 닫힘 태그 생략을 허용하거든요. 닫힘 태그를 생략하면 네이티브 HTML 파서가 시작 태그가 끝나지 않았다고 생각합니다.

    <!-- 이렇게 쓰면 -->
    <my-component />
    
    <!-- 실제론 이렇게 파싱돼요 -->
    <my-component></my-component>
    
  • 요소 배치 제약<ul>, <ol>, <table>, <select> 같은 요소는 내부에 올 수 있는 요소에 제약이 있고, <li>, <tr>, <option> 같은 요소는 특정 요소 안에서만 나타날 수 있어요. 예를 들어 <table> 안에 커스텀 컴포넌트를 직접 넣으면 잘못된 콘텐츠로 취급돼 렌더링 오류가 납니다.

    <table>
      <tr is="vue:blog-post-row"></tr>
    </table>
    

    네이티브 HTML 요소에 쓸 때 is 값은 Vue 컴포넌트로 해석되도록 vue: 접두사를 붙여야 해요.

여기까지가 컴포넌트 기초예요. 컴포넌트를 더 깊이 배우고 싶다면 Props·이벤트·슬롯 등 관련 문서로 이어서 보시면 됩니다.

더 알아보기