앞서 우리는 Vue에서 상태와 계산, 부수 효과를 어떻게 다루는지 살펴봤습니다.
그렇다면 이번엔, Vue의 화면을 구성하는 가장 기본 단위인 “컴포넌트”에 대해 알아보겠습니다.
Vue에서의 컴포넌트(component)는 화면을 구성하는 독립적이고 재사용 가능한 UI 단위입니다.
예를 들어, 하나의 버튼, 입력창, 게시글, 상품 카드 등...
우리가 화면에서 보는 거의 모든 UI 조각은 컴포넌트로 나뉠 수 있습니다.
Vue에서는 각 컴포넌트가 HTML, JavaScript, CSS가 결합된 하나의 파일로 구성되어,
기능별로 화면을 나누고 관리할 수 있도록 설계되어 있습니다.
예를 들어, 어떤 제품에 대한 이미지와 설명을 담고있는 ProductCard 라는 컴포넌트를 생각해봅시다.
<!-- 예: ProductCard.vue -->
<template>
<div class="card">
<img :src="product.image" />
<p>{{ product.name }}</p>
</div>
</template>
<script setup>
defineProps(['product'])
</script>
<style scoped>
.card { /* ... */ }
</style>
defineProps가 무엇인지는 추후에 설명될 것이고.. 우선은 product라는 이름의 상품 객체를 가져왔다고 생각하시면 됩니다.
그런데 우리가 전통적인 웹 개발을 배울 때,
HTML, CSS, JavaScript는 각각의 책임이 다르기 때문에 분리해야 한다고 배웠습니다.
그래서 우리는 .html, .css, .js 파일을 따로 만들고
<link>, <script> 태그로 연결하며 작업했죠.
Vue의 .vue 컴포넌트는 구조, 스타일, 동작이 모두 들어있는 파일입니다.
<template> <!-- 구조 -->
<button>Click me</button>
</template>
<script setup> <!-- 동작 -->
console.log('버튼 컴포넌트')
</script>
<style scoped> <!-- 스타일 -->
button { color: red; }
</style>
이걸 처음 보면 누구나 “책임이 섞인 것처럼 보이는데?” 라는 의문을 가집니다.
하지만 Vue는 기능 단위로 책임을 나누는 방식을 취하고 있습니다.
Vue는 하나의 UI 기능(= 컴포넌트) 을 만들기 위해 필요한 구조, 스타일, 동작을
하나의 파일에 모아 관리함으로써,
오히려 코드의 응집도를 높이고, 유지보수와 협업에 더 유리한 구조를 만들어줍니다.
앞서 본 것처럼 Vue는 하나의 컴포넌트 파일 안에
template (구조)
script (동작)
style (스타일)
이 세 가지를 한 곳에 모아서 정의합니다.
이러한 구조를 SFC (Single File Component) 라고 부릅니다.
말 그대로, "하나의 파일로 구성된 컴포넌트"라는 뜻이죠.
SFC는 .vue 확장자를 가지며, Vue 프로젝트에서 가장 일반적이고 권장되는 컴포넌트 작성 방식입니다.
Vue에서는 컴포넌트 이름을 기능 단위로 의미 있게 짓는 것이 좋습니다.
PostItem.vue → 개별 게시글 카드BaseButton.vue → 프로젝트 전역 버튼 컴포넌트AppHeader.vue → 페이지 상단 바대부분 PascalCase (대문자로 시작하는 카멜 표기법)를 사용하며,
템플릿에서는 자동으로 kebab-case로 인식됩니다. (<post-item />)
이제 이 규칙에 따라 실제 예제를 컴포넌트로 나눠보며 적용해볼까요?
간단한 장바구니를 구현해봅시다.
<template>
<div>
<h2>전체 상품</h2>
<ul>
<li v-for="product in products" :key="product.name">
{{ product.name }} - {{ product.price }}원
<button @click="addToCart(product)">장바구니에 담기</button>
</li>
</ul>
<h2>장바구니</h2>
<ul>
<li v-for="item in cart" :key="item.name">
{{ item.name }} - {{ item.quantity }}개
<button @click="increase(item)">+</button>
<button @click="decrease(item)">-</button>
<button @click="remove(item)">삭제</button>
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
const products = ref([
{ name: '사과', price: 1000 },
{ name: '바나나', price: 1500 }
])
const cart = ref([])
const addToCart = (product) => {
const found = cart.value.find(item => item.name === product.name)
if (found) {
found.quantity++
} else {
cart.value.push({ name: product.name, quantity: 1 })
}
}
const increase = (item) => {
item.quantity++
}
const decrease = (item) => {
if (item.quantity > 1) item.quantity--
}
const remove = (item) => {
cart.value = cart.value.filter(i => i.name !== item.name)
}
</script>

장바구니가 잘 동작하네요.
현재 모든 코드가 한 파일에 들어가 있으니, 이제 코드를 보기 좋게 두 개의 컴포넌트로 나눠보려고 합니다.
ProductList.vue: 전체 상품 목록을 보여주는 컴포넌트
Cart.vue: 장바구니 목록을 보여주는 컴포넌트
ProductList는 products가 필요하다. 맞는 말인가요?
그렇겠죠. v-for로 상품을 출력해야 하니까요.
<!-- ProductList.vue -->
<template>
<div>
<h2>전체 상품</h2>
<ul>
<li v-for="product in products" :key="product.name">
{{ product.name }} - {{ product.price }}원
<button @click="addToCart(product)">장바구니에 담기</button>
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
const products = ref([
{ name: '사과', price: 1000 },
{ name: '바나나', price: 1500 }
])
const cart = ref([])
const addToCart = (product) => {
const found = cart.value.find(item => item.name === product.name)
if (found) {
found.quantity++
} else {
cart.value.push({ name: product.name, quantity: 1 })
}
console.log('addToCart 호출!!')
}
</script>
Cart.vue도 장바구니 항목을 출력하려면
상품 이름과 수량을 알고 있어야 합니다.
즉, Cart.vue도 어떤 상품이 있는지 알아야 하고
상품 이름이나 가격에 접근할 수 있어야 합니다.
<template>
<div>
<h2>장바구니</h2>
<ul>
<li v-for="item in cart" :key="item.name">
{{ item.name }} - {{ item.quantity }}개
<button @click="increase(item)">+</button>
<button @click="decrease(item)">-</button>
<button @click="remove(item)">삭제</button>
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
const cart = ref([
{ name: '사과', quantity: 1 }
])
const increase = (item) => {
item.quantity++
console.log('[Cart] 수량 증가:', item)
}
const decrease = (item) => {
if (item.quantity > 1) item.quantity--
}
const remove = (item) => {
cart.value = cart.value.filter(i => i.name !== item.name)
}
</script>
그럼 담기 버튼을 눌렀을 때 Cart는 알 수 있을까요?
ProductList.vue에서 버튼을 눌렀다고 해서
Cart.vue는 그걸 알 수 있을까요?
당연히 아닙니다.
둘은 완전히 독립적인 컴포넌트입니다.
그냥 이름이 같고 파일명이 비슷할 뿐,
서로 아무런 연결도 되어 있지 않습니다.
실제로 확인해봅시다.
<!-- App.vue -->
<template>
<ProductList />
<Cart />
</template>
<script setup>
import ProductList from './components/ProductList.vue'
import Cart from './components/Cart.vue'
</script>
이런 식으로 각각을 화면에 올려놨다고 해서,
ProductList.vue 안에서 addToCart()를 실행하면
Cart.vue가 자동으로 바뀔까요?

addToCart는 계속해서 실행되지만, 실제 장바구니는 전혀 변하지 않는 것을 볼 수 있습니다.
products는 ProductList 내부에서 선언됨
cart는 Cart 내부에서 선언됨
둘 다 서로 다른 상태를 가지고 있으니, 동기화가 일어나지 않음
어떻게 해야 할까요?
지금 구조대로라면 ProductList에서도 cart를 관리해야 하고,
Cart에서도 cart를 또 따로 관리해야 합니다.
그럼 반대로, 부모인 App.vue에서 데이터를 한 곳에서 관리한다면 어떨까요?
App.vue가 cart 상태를 관리하고,
ProductList는 장바구니에 상품을 추가할 수 있게 하고,
Cart는 그 상태를 읽어서 화면에 보여주기만 하면 됩니다.
이 구조라면 서로 다른 컴포넌트도 같은 데이터를 참조하게 되고,
역할도 명확히 나눌 수 있게 됩니다.
props와 emitprops: 부모 → 자식에게 데이터를 전달
emit: 자식 → 부모에게 이벤트를 알림
이 두 가지 개념이 있어야
부모에서 상태를 관리하면서도,
자식 컴포넌트에서 그 상태를 자연스럽게 쓰고, 변경 요청을 보낼 수 있습니다.
이제 다음 글에서는
이 props와 emit이 실제로 어떻게 동작하는지
앞선 장바구니 예제를 기반으로 리팩터링하면서 하나씩 살펴보겠습니다.