기본 개념: 소프트웨어 공학에서 컴포넌트는 독립적인 기능을 수행하며 교체 가능한 '표준화된 소프트웨어 모듈(부품)'을 의미합니다. 독립성(Independency)과 교체 가능성(Replaceability)이 핵심입니다.
Vue 컴포넌트: 웹페이지를 구성하는 독립적이고 재사용 가능한 부품 단위입니다.
컴포넌트들은 상하/좌우 관계를 형성하며 트리 구조로 배치됩니다.
<script setup> 내부에서 import하면 <template> 영역에서 내장 태그처럼 바로 쓸 수 있습니다.
표기법은 PascalCase(<BaseButton/>)와 kebab-case(<base-button></base-button>) 둘 다 지원합니다.
<script setup>
import BaseButton from './components/BaseButton.vue'
</script>
<template>
<div class="box">
<h3>컴포넌트 조립 테스트</h3>
<hr />
<!-- 파스칼케이스 표기 -->
<BaseButton />
<!-- 케밥케이스 표기 -->
<base-button></base-button>
</div>
</template>
app.component()를 사용해 전역으로 등록하며, 체이닝(Chaining) 형태로 연결해서 등록할 수도 있습니다.
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import BaseButton from './components/BaseButton.vue'
import BaseInput from './components/BaseInput.vue'
const app = createApp(App)
// app.component('태그이름', 컴포넌트변수)
app.component('BaseButton', BaseButton)
app.component('BaseInput', BaseInput)
// 체이닝 형태 작성 방식:
// app.component('BaseButton', BaseButton).component('BaseInput', BaseInput)
app.mount('#app')
| 구분 | 지역 등록 (Local Registration) | 전역 등록 (Global Registration) |
|---|---|---|
| 등록 위치 | 해당 컴포넌트의 <script setup> 내부 | main.js 파일의 Vue 인스턴스 생성 시점 |
| 사용 범위 | import한 해당 파일의 <template> 내부 | 애플리케이션 전체 어디서든 import 없이 사용 가능 |
| 주요 특징 | 사용하지 않는 컴포넌트는 트리쉐이킹(최적화)됨 | 자주 사용하는 공통 UI 컴포넌트(버튼, 인풋 등)에 적합 |
Application: 앱 생성 및 전역 설정 함수 (createApp, app.config.*)Reactive State: 반응형 상태 생성 및 제어 함수 (ref, reactive, readonly, toRefs 등)Computed & Watchers: 데이터 계산 및 변경 감시 함수 (computed, watch, watchEffect)Lifecycle Hooks: 생명주기 단계별 실행 콜백 함수 (setup, onMounted, onUpdated, onUnmounted 등)Component Composition: 컴포넌트 구성 및 통신 함수 (defineProps, defineEmits, defineExpose 등)Rendering: Virtual DOM 및 렌더링 제어 함수 (h, nextTick 등)Dependency Injection: 하위 컴포넌트로 데이터 직통 전달 함수 (provide, inject)| 단계 | 컴포넌트 상태 | 주요 작업 및 상세 내용 |
|---|---|---|
| 1. 생성 (Creation) | JS 메모리상에만 존재 (HTML 미부착) | 반응형 변수(ref, reactive), computed, watch 초기화 |
| 2. 부착 (Mounting) | 실제 DOM(HTML)에 화면이 붙은 상태 | DOM 요소 접근 가능, 백엔드 API 호출/초기 데이터 로딩 |
| 3. 갱신 (Updating) | 반응형 데이터 변경으로 화면 리렌더링 | 리렌더링 직후 변경된 HTML 크기/스크롤 위치 계산 |
| 4. 소멸 (Unmounting) | v-if="false" 등으로 화면에서 제거 및 파괴 | 메모리 누수 방지를 위한 타이머(setInterval) 해제, 이벤트 리스너 제거 |
setup(): 컴포넌트 생성 단계. 가장 먼저 실행되며 반응형 변수 및 함수를 초기화합니다.onMounted(): DOM에 화면이 부착된 직후 호출됩니다. API 호출이나 DOM 직접 조작에 가장 적합한 시점입니다.onUpdated(): 데이터가 변경되어 DOM 리렌더링이 완료된 직후 호출됩니다.onUnmounted(): 컴포넌트가 DOM에서 완전 파괴된 후 호출됩니다. 타이머나 이벤트 리스너를 정리(Clean-up)하는 용도로 사용합니다.<script setup>
import { ref, onMounted, onUpdated, onUnmounted } from 'vue'
const count = ref(0)
let timerId = null
// 1. 생성 단계: <script setup> 본문 코드 실행 시점
console.log('1. [setup] 컴포넌트가 메모리에 생성되었습니다. (DOM 접근 불가)')
// 2. 부착 단계: 화면에 HTML 요소가 정상적으로 올라간 시점
onMounted(() => {
console.log('2. [onMounted] 화면에 완벽히 부착되었습니다! (API 호출/DOM 조작 적기)')
// 3초마다 count를 1씩 증가시키는 타이머 가동
timerId = setInterval(() => {
count.value++
}, 3000)
})
// 3. 갱신 단계: count 변수 변경으로 화면이 리렌더링될 때마다 실행
onUpdated(() => {
console.log(`3. [onUpdated] 데이터가 변경되어 화면을 새로 그렸습니다. (현재 count: ${count.value})`)
})
// 4. 소멸 단계: 컴포넌트가 화면에서 완전히 파괴되어 사라질 때 실행
onUnmounted(() => {
// 백그라운드에서 타이머가 계속 도는 메모리 누수를 막기 위해 청소 작업 진행
clearInterval(timerId)
console.log('4. [onUnmounted] 컴포넌트가 소멸했습니다. 타이머 청소 완료!')
})
</script>
Vue 3의 모든 컴포넌트 연동은 데이터는 위에서 아래로(Props), 이벤트는 아래에서 위로(Emits) 흐르는 단방향 데이터 흐름 구조를 따릅니다.
| 구분 | Props (하행선) | Emits (상행선) |
|---|---|---|
| 개념 정의 | 부모가 자식에게 전달하는 반응형 데이터 | 자식이 부모에게 보고하는 이벤트 |
| 흐름 방향 | 부모 → 자식 (위에서 아래로) | 자식 → 부모 (아래에서 위로) |
| 데이터 권한 | 읽기 전용 (Read-only), 자식 측 수정 불가 | 부모에게 상태 변경 요청 및 값 전달 가능 |
| 컴파일러 매크로 | defineProps({ ... }) | defineEmits([ ... ]) |
| 부모 측 바인딩 | 자식 태그 속성에 콜론(:)으로 주입 | 자식 태그 이벤트에 골뱅이(@)로 청취 |
Compiler Macro(컴파일러 매크로)란?
defineProps(), defineEmits(), defineExpose() 같은 함수들이 이에 해당합니다.<script setup> 내부에서만 즉시 사용할 수 있습니다.<script setup> 내에서 바로 사용할 수 있는 컴파일러 매크로(Compiler Macro)입니다.| 구분 | 배열 형식 (['title', 'count']) | 객체 형식 ({ title: String, likes: Number }) |
|---|---|---|
| 장점 | 코드가 간결하고 빠르게 선언 가능 | 데이터 타입 검증, 필수 값 여부, 기본값 지정 가능 |
| 단점 | 기본값 및 타입 검증 불가능 | 배열 형식보다 코드가 다소 길어짐 |
| 추천 | 간단한 프로토타입 개발 시 | 실무 환경 및 견고한 컴포넌트 작성 시 (권장) |
// 배열 형식
const props = defineProps(['title', 'count'])
// 객체 형식
defineProps({
// 1. 타입만 간단히 지정하는 경우
title: String,
// 2. 필수 값과 기본값까지 꼼꼼하게 지정하는 경우
likes: {
type: Number,
required: true // 부모가 이 값을 안 넘기면 에러발생.
},
status: {
type: String,
default: '대기 중' // 부모가 값을 안 주면 이 값이 기본으로 세팅.
}
})
<template> 영역에서 Props 사용하기
props. 접두사 없이 선언한 속성명(변수명)을 그대로 사용할 수 있습니다.<template>
<h1>{{ title }}</h1>
<p>좋아요: {{ likes }}</p>
</template>
<script setup> 영역에서 Props 사용하기
defineProps()가 반환하는 객체를 props라는 변수에 할당받은 후 점 표기법(props.속성명)을 통해 값에 접근해야 합니다.<script setup>
// 1. defineProps 결과를 'props' 변수에 할당
const props = defineProps({
title: String,
likes: Number
})
// 2. JS 함수 및 로직 내부에서 접근할 때는 'props.'을 붙여서 호출
const checkPopularity = () => {
if (props.likes > 100) {
console.log(`${props.title}은 인기 게시글입니다.`)
}
}
</script>
핵심 요약
| 구분 | <template> 영역 | <script setup> 영역 |
|---|---|---|
| 접근 방식 | 변수명 직접 사용 | props.변수명 형태 사용 |
| 작성 형태 | {{ title }}, {{ likes }} | props.title, props.likes |
| 접두사 필요 여부 | 불필요 | 필수 (props.) |
자식 컴포넌트 (ChildComponent.vue)
부모 컴포넌트로부터 전달받은 message 데이터를 받아 화면에 출력합니다.
<script setup>
// 부모 컴포넌트가 전달할 Props의 타입(String)을 정의
const props = defineProps({
message: String
});
</script>
<template>
<!-- <template> 태그 내부에서는 props. 생략 후 변수명(message)으로 바로 접근 가능 -->
<div>{{ message }}</div>
</template>
부모 컴포넌트 (ParentComponent.vue)
반응형 데이터(ref)를 생성하고 자식 컴포넌트를 불러와 데이터 속성을 바인딩(:message)하여 전달합니다.
<script setup>
import { ref } from 'vue';
// 자식 컴포넌트 불러오기
import ChildComponent from './ChildComponent.vue';
// 자식에게 전달할 반응형 문자열 데이터 선언
const parentMessage = ref('안녕하세요, 자식 컴포넌트!');
</script>
<template>
<div>
<!--
:message - 자식 컴포넌트의 props 이름
"parentMessage" - 부모가 전달할 반응형 데이터 변수명
-->
<ChildComponent :message="parentMessage" />
</div>
</template>
HTML 표준은 대소문자를 구분하지 못하기 때문에 Vue에서는 JavaScript와 HTML 환경에 맞춘 자동 매핑 규격을 따릅니다.
Script 및 Props 정의 (JS): camelCase 사용 (cityName, areaCode)
Template 속성 주입 (HTML): kebab-case 사용 (:city-name="selectCityName", :area-code="areaCode")
부모 컴포넌트 (ParentComponent.vue)
<script setup>
import { ref } from 'vue'
// 컴포넌트 파일 가져올 때는 파스칼 케이스(PascalCase)
import WeatherCard from './components/WeatherCard.vue'
// 내부 반응형 데이터는 카멜 케이스(camelCase)
const selectCityName = ref('수원시 영통구')
const areaCode = ref(103)
</script>
<template>
<div>
<WeatherCard
:city-name="selectCityName"
:area-code="areaCode"
/>
</div>
</template>
자식 컴포넌트 (ChildComponent.vue)
<script setup>
// defineProps 내부 규격 선언은 카멜 케이스(camelCase)
defineProps({
cityName: String,
areaCode: Number
})
</script>
<template>
<div class="card">
<p>지역명: {{ cityName }}</p>
<p>지역 코드: {{ areaCode }}</p>
</div>
</template>
부모가 잘못된 데이터를 넘겼을 때 콘솔 경고를 띄워 에러 추적을 용이하게 만듭니다.
cityName: StringareaId: [String, Number]required): required: true 지정 시 부모가 해당 속성을 누락하면 경고 출력default): 값이 넘어오지 않았을 때 사용할 기본값 설정validator): 함수 형태로 세부 조건 조건문 작성 (예: return value >= 0 && value <= 100)주의: 배열(Array)과 객체(Object)의 기본값(default)은 참조 타입 메모리 공유 문제를 방지하기 위해 반드시 화살표 함수 형태로 반환해야 합니다. (예: default: () => [])
| 유형 | defineProps 기입 법 | 예시 데이터 | 주요 주의사항 |
|---|---|---|---|
| 문자열 / 숫자 / 논리 | String, Number, Boolean | "서울", 25, true | 기본 데이터 타입 |
| 배열 (Array) | Array | ['서울', '수원'] | default 지정 시 반드시 팩토리 함수 사용 (() => []) |
| 객체 (Object) | Object | { name: '수원' } | default 지정 시 반드시 팩토리 함수 사용 (() => ({})) |
| 기타 | Function, Date 등 | 콜백 함수, 날짜 객체 등 | 함수 및 비동기 객체 검증 지원 |
defineProps({
// 1. 문자열 (String)
cityName: String,
// 2. 숫자 (Number)
temperature: Number,
// 3. 논리형 (Boolean)
isActive: {
type: Boolean,
default: false
},
// 4. 배열 (Array)
weeklyForecast: {
type: Array,
// 중요: 배열의 기본값은 무조건'새 바구니를 구워내는 화살표 함수' 형태로!
default: () => []
},
// 5. 객체 (Object)
coordinates: {
type: Object,
// 중요: 객체의 기본값도 무조건 화살표 함수 형태로 반환!
default: () => ({ lat: 37.5, lng: 126.9 })
}
})
자식 컴포넌트 (ChildComponent.vue)
<template>에서는 정의된 변수명을 그대로 바로 조회하며 형태를 사용합니다.
<script setup>
// Props 정의 및 반환 객체를 props 변수에 할당
const props = defineProps({
cityName: String,
temperature: Number,
isActive: {
type: Boolean,
default: false
},
// 배열/객체는 함수 형태로 default 반환
weeklyForecast: {
type: Array,
default: () => []
}
})
// Script 내에서 사용할 때는 'props.' 접두사 필수
const checkStatus = () => {
console.log(`${props.cityName}의 현재 기온: ${props.temperature}도`)
}
</script>
<template>
<!-- Template 내에서는 props. 생략하고 직접 참조 가능 -->
<div>
<h3>{{ cityName }}</h3>
<p>기온: {{ temperature }}°C</p>
<p>활성화 상태: {{ isActive }}</p>
</div>
</template>
부모 컴포넌트 (ParentComponent.vue)
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const selectCityName = ref('수원시 영통구')
const currentTemp = ref(24)
</script>
<template>
<div>
<!-- camelCase 속성은 HTML 상에서 kebab-case로 작성 -->
<ChildComponent
:city-name="selectCityName"
:temperature="currentTemp"
/>
</div>
</template>
<script setup> 안에서 즉시 호출 가능합니다.emit('이벤트명', 데이터) 형태로 호출하며, 첫 번째 인자에는 이벤트 식별자(문자열), 두 번째 인자부터는 부모에게 전달할 데이터(Payload)를 실어 보냅니다.select-city, update-request)로 작성하는 것이 관례입니다.Props로 받은 데이터를 자식에서 출력하고 자식의 클릭/요청에 의해 부모의 상태(State)가 변경되는 종합 코드 구조입니다.
자식 컴포넌트 (PropEmitsChild.vue)
<script setup>
// 1. 부모에게 물려받을 Props 정의 (타입 및 필수 검사)
const props = defineProps({
parentData: {
type: String,
required: true
}
})
// 2. 부모에게 송신할 커스텀 이벤트 식별자 등록 (관례상 변수명은 'emit'으로 지정)
const emit = defineEmits(['update-request'])
// 3. 버튼 클릭 시 페이로드를 실어 상위로 이벤트를 디스패치(발송)하는 함수
const sendNotification = () => {
const payload = 'Child에서 가공한 새로운 데이터'
emit('update-request', payload) // 첫 번째 인자: 이벤트명, 두 번째 인자: 데이터
}
</script>
<template>
<div class="child-container">
<h2>하위 컴포넌트 (Child)</h2>
<p>수신된 Props 데이터: <strong>{{ parentData }}</strong></p>
<br />
<button @click="sendNotification">상위 컴포넌트로 갱신 요청 (Emit)</button>
</div>
</template>
부모 컴포넌트 (PropEmitsParent.vue)
<script setup>
import { ref } from 'vue'
import PropEmitsChild from './PropEmitsChild.vue'
// 1. 부모의 로컬 반응형 상태 정의
const message = ref('Parent 초기 메시지')
// 2. 하위 컴포넌트의 커스텀 이벤트를 수신했을 때 실행될 핸들러 함수
// 하위 컴포넌트가 emit으로 보낸 payload가 인자(newValue)로 자동 주입됩니다.
const handleUpdateRequest = (newValue) => {
message.value = newValue
}
</script>
<template>
<div class="practice-section">
<h2>Props & Emits</h2>
<div class="parent-container">
<h2>상위 컴포넌트 (Parent)</h2>
<p>현재 로컬 데이터(State): <strong>{{ message }}</strong></p>
<br />
<!--
:parent-data - Props 전달 (데이터 하행)
@update-request - Emit 이벤트 수신 및 핸들러 연결 (이벤트 상행)
-->
<PropEmitsChild
:parent-data="message"
@update-request="handleUpdateRequest"
/>
</div>
</div>
</template>
조상 컴포넌트 (GrandParent.vue)
provide('키_이름', 전달할 값) 형식으로 데이터를 등록합니다.
<script setup>
import { ref, provide } from 'vue' // provide 함수 모듈 import
const themeColor = ref('dark-mode')
// 주입할 Key 이름과 실제 값(Value)을 등록
provide('globalTheme', themeColor)
</script>
최하위 컴포넌트 (GrandChild.vue)
inject('키_이름') 형식으로 데이터를 직접 인젝션(주입)받아 사용합니다.
<script setup>
import { inject } from 'vue' // inject 함수 모듈 import
// 조상이 provide로 등록했던 Key 이름을 지정하여 값 추출
const theme = inject('globalTheme')
</script>
provide()는 선언하는 위치와 목적에 따라 컴포넌트 내 주입 방식과 앱 전역 주입 방식으로 나뉩니다.
| 구분 | 컴포넌트 내 주입 (provide) | 앱 전역 주입 (app.provide) |
|---|---|---|
| 작성 위치 | 특정 .vue 파일의 <script setup> 내부 | main.js (또는 main.ts) 파일 |
| 구문 예시 | provide('key', value) | app.provide('key', value) |
| 전달 범위 | 해당 컴포넌트와 그 하위 자식 컴포넌트들 | 앱 전체의 모든 컴포넌트 |
| 주요 용도 | 특정 컴포넌트 트리 내 데이터 공유 (반응성 유지) | 앱 전역 설정, 전역 상태 및 인스턴스 공유 |
개념: 자식 컴포넌트의 특정 구역을 <slot> 영역으로 비워두고 부모 컴포넌트가 자식을 사용할 때 그 내부에 넣어줄 HTML 마크업 및 템플릿 콘텐츠를 동적으로 주입하여 렌더링하는 기능입니다.
Props vs Slot 차이점: Props는 부모가 자식에게 데이터 값을 주입할 때 사용하고 Slot은 HTML 태그나 디자인 레이아웃 자체를 주입할 때 사용합니다.
별도의 속성 없이 단순하게 <slot> 태그만 사용하는 기본 형태입니다. 부모가 전달한 콘텐츠가 지정된 슬롯 위치에 대체되어 들어갑니다.
<!-- 자식: SlotDefaultChild.vue -->
<template>
<div class="base-card">
<slot>
<p>기본 콘텐츠 영역입니다. (부모가 안 보낼 때 표시)</p>
</slot>
</div>
</template>
<!-- 부모: SlotDefaultParent.vue -->
<template>
<SlotDefaultChild>
<p>단순한 텍스트 문장을 주입합니다.</p>
</SlotDefaultChild>
</template>
컴포넌트 내에 여러 개의 Slot 영역이 필요한 경우 name 속성을 부여하여 각 슬롯의 위치를 다르게 지정합니다.
<slot name="슬롯이름"></slot><template v-slot:슬롯이름> 또는 약어인 <template #슬롯이름>을 사용하여 원하는 위치에 코드를 끼워 넣습니다.<!-- 자식: SlotNamedChild.vue -->
<template>
<div class="base-card">
<header>
<slot name="header"></slot> <!-- 'header' 이름 지정 -->
</header>
<main>
<slot></slot> <!-- name이 없으면 Default Slot이 됨 -->
</main>
</div>
</template>
<!-- 부모: SlotNamedParent.vue -->
<template>
<SlotNamedChild>
<!-- #header (v-slot:header 축약형) 영역으로 주입 -->
<template #header>
<h3>Child 주입 제목</h3>
</template>
<!-- Default Slot으로 주입 -->
<p>본문 내용이 이 자리에 배치됩니다.</p>
</SlotNamedChild>
</template>
자식 컴포넌트 내부에서 다루는 로컬 데이터를 부모 컴포넌트의 슬롯 마크업 영역으로 전달받아 사용할 때 사용합니다.
<slot :속성명="변수명"></slot> 형태로 부모에게 넘겨줄 데이터를 속성으로 바인딩합니다.v-slot="변수주머니"를 지정해 데이터를 전달받은 뒤, 템플릿 내부에서 변수주머니.속성명 형태로 꺼내 사용합니다.<!-- 자식: SlotScopedChild.vue -->
<script setup>
import { ref } from 'vue'
const message = ref('현재 서버 상태 정상')
const userCount = ref(150)
</script>
<template>
<div class="base-card">
<!-- 자식의 message와 userCount 데이터를 슬롯 속성으로 전달 -->
<slot :text="message" :count="userCount">
<p>부모가 마크업을 주입하지 않았을 때의 디폴트 화면</p>
</slot>
</div>
</template>
<!-- 부모: SlotScopedParent.vue -->
<template>
<!-- v-slot="slotBag"을 통해 자식이 바인딩한 데이터 객체를 수신 -->
<SlotScopedChild v-slot="slotBag">
<div class="display-panel">
<p>알림 메시지: {{ slotBag.text }}</p>
<p>접속자 수: {{ slotBag.count }}명</p>
</div>
</SlotScopedChild>
</template>