재사용 가능한 로직 설계와 의존성 주입으로 확장성 있는 구조를 만든다.
고급 컴포저블과 설계 패턴
입문편에서 use 접두사로 시작하는 컴포저블 함수를 만들고, onMounted·onUnmounted와 함께 라이프사이클 로직을 묶는 방법을 익혔습니다. 심화편에서는 한 단계 더 나아가 컴포저블을 설계하는 시각으로 접근합니다. 인자와 반환값의 규약을 어떻게 정할지, 트리를 가로지르는 의존성은 어떻게 주입할지, 컴포저블의 생명주기를 어떻게 제어할지, 그리고 전역 스토어 없이도 상태를 안전하게 공유하는 방법까지 다룹니다.
단순히 "로직을 함수로 빼는 것"에서 벗어나, 실무 규모의 애플리케이션에서 유지보수하기 쉬운 구조를 설계하는 능력을 키우는 것이 이 레슨의 목적입니다.
학습 목표
- 컴포저블 설계 원칙 — 인자·반환 규약과 부수효과 관리 패턴을 이해한다.
- provide / inject와 InjectionKey 로 타입 안전한 의존성 주입을 구현한다.
- Renderless 컴포넌트와 스코프드 슬롯으로 로직과 렌더링을 분리한다.
- effectScope 로 컴포저블의 반응형 효과 생명주기를 직접 제어한다.
- 상황에 맞게 컴포저블과 전역 스토어 중 적절한 상태 공유 전략을 선택한다.
컴포저블 설계 원칙: 인자·반환 규약과 부수효과 관리
컴포저블의 인자는 단순 값뿐 아니라 Ref나 MaybeRef 형태로도 받을 수 있어야 반응성을 보존합니다. toValue() 유틸리티(Vue 3.3+)는 인자가 ref이든 getter 함수이든 일반 값이든 모두 동일하게 꺼내 씁니다.
// composables/useFetch.ts
import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue'
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const isFetching = ref(false)
watchEffect(async () => {
// toValue는 ref, getter, 원시값 모두 처리한다
const resolvedUrl = toValue(url)
data.value = null
error.value = null
isFetching.value = true
try {
const res = await fetch(resolvedUrl)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (e) {
error.value = e as Error
} finally {
isFetching.value = false
}
})
return { data, error, isFetching }
}
반환값 규약도 중요합니다. 반드시 반응형 값(ref 또는 reactive) 또는 함수만 반환하고, 내부 구현 세부 사항은 노출하지 않습니다.
// ✅ 반응형 ref를 반환 — 호출자가 reactivity를 얻는다
export function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
// ...
return { width, height }
}
// ❌ 일반 값을 반환 — 스냅샷이라 반응성이 끊긴다
export function useWindowSizeBad() {
const width = window.innerWidth // 반응성 없음
const height = window.innerHeight
return { width, height }
}
부수효과(side effect) 관리는 컴포저블 설계의 핵심입니다. 컴포저블 안에서 이벤트 리스너·타이머·외부 구독을 만들면 반드시 onUnmounted나 watchEffect의 클린업 콜백을 통해 정리해야 합니다.
// composables/useEventListener.ts
import { watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { toValue } from 'vue'
export function useEventListener<K extends keyof WindowEventMap>(
target: MaybeRefOrGetter<EventTarget | null>,
event: K,
handler: (e: WindowEventMap[K]) => void,
options?: AddEventListenerOptions
) {
// target이 바뀌면(또는 언마운트되면) 등록했던 바로 그 요소에서 해제한다.
watch(
() => toValue(target),
(el, _prev, onCleanup) => {
el?.addEventListener(event, handler as EventListener, options)
// el을 클로저로 잡아 두므로, target이 다른 요소로 바뀌어도
// 등록한 그 요소에서 정확히 removeEventListener가 호출된다.
onCleanup(() => el?.removeEventListener(event, handler as EventListener, options))
},
{ immediate: true }
)
}
💡 TIP — 부수효과를 직접
watchEffect안에서 시작할 경우,watchEffect가 전달하는onCleanup콜백으로 정리하면 watch가 재실행되기 전에도 이전 효과가 자동으로 정리됩니다.
watchEffect((onCleanup) => {
const socket = new WebSocket(toValue(url))
onCleanup(() => socket.close()) // 재실행 전·언마운트 시 자동 호출
})
provide / inject와 InjectionKey로 타입 안전한 의존성 주입
provide / inject는 Props 드릴링 없이 컴포넌트 트리 어디서나 값을 공유하는 Vue의 의존성 주입(DI) 시스템입니다. 그러나 문자열 키를 그대로 쓰면 타입 정보가 사라집니다. InjectionKey 제네릭 심볼을 사용하면 provide와 inject 양쪽에서 타입이 자동으로 맞춰집니다.
// injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface UserStore {
id: string
name: string
role: 'admin' | 'viewer'
}
// Symbol + InjectionKey로 타입 안전한 키 정의
export const UserStoreKey: InjectionKey<Ref<UserStore>> = Symbol('UserStore')
부모(또는 루트) 컴포넌트에서 provide:
<!-- App.vue -->
<script setup lang="ts">
import { ref, provide } from 'vue'
import { UserStoreKey, type UserStore } from './injection-keys'
const user = ref<UserStore>({ id: '1', name: '홍길동', role: 'admin' })
provide(UserStoreKey, user)
</script>
하위 컴포넌트에서 inject — 키 덕분에 반환 타입이 자동으로 Ref<UserStore> | undefined가 됩니다:
<!-- DeepChildComponent.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import { UserStoreKey } from './injection-keys'
const user = inject(UserStoreKey) // 타입: Ref<UserStore> | undefined
// 기본값을 제공하면 undefined를 제거할 수 있다
const userWithDefault = inject(UserStoreKey, ref({ id: '', name: 'Guest', role: 'viewer' as const }))
</script>
⚠️ 주의 —
inject는 반드시setup()또는<script setup>최상위에서 동기적으로 호출해야 합니다. 조건문이나 비동기 코드 내부에서 호출하면 Vue가 현재 컴포넌트 인스턴스를 찾지 못해 경고와 함께undefined를 반환합니다.
컴포저블과 provide / inject를 조합하면 모듈 수준에서 일관된 DI 인터페이스를 만들 수 있습니다:
// composables/useTheme.ts
import { provide, inject, ref, type InjectionKey, type Ref } from 'vue'
type Theme = 'light' | 'dark'
const ThemeKey: InjectionKey<{ theme: Ref<Theme>; toggle: () => void }> =
Symbol('Theme')
// 루트에서 한 번만 호출 — 값을 provide한다
export function provideTheme() {
const theme = ref<Theme>('light')
const toggle = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide(ThemeKey, { theme, toggle })
return { theme, toggle }
}
// 하위 어디서나 호출 — 값을 inject한다
export function useTheme() {
const ctx = inject(ThemeKey)
if (!ctx) throw new Error('useTheme()는 provideTheme() 하위에서 사용해야 합니다.')
return ctx
}
Renderless 컴포넌트와 스코프드 슬롯 기반 로직 공유
**Renderless 컴포넌트(Headless Component)**는 UI를 직접 렌더링하지 않고 로직만 제공하는 컴포넌트입니다. 스코프드 슬롯을 통해 로직의 결과물(상태·함수)을 호출자에게 넘겨주고, 어떻게 보여줄지는 전적으로 호출자가 결정합니다.
<!-- components/MouseTracker.vue — Renderless 컴포넌트 -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
function update(e: MouseEvent) {
x.value = e.clientX
y.value = e.clientY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>
<template>
<!-- 슬롯에 상태를 주입하고 자신은 아무것도 렌더링하지 않는다 -->
<slot :x="x" :y="y" />
</template>
사용하는 쪽에서는 원하는 방식으로 자유롭게 렌더링합니다:
<!-- App.vue -->
<template>
<MouseTracker v-slot="{ x, y }">
<div class="cursor-badge">{{ x }}, {{ y }}</div>
</MouseTracker>
<!-- 같은 컴포넌트를 다른 방식으로도 사용 가능 -->
<MouseTracker v-slot="{ x, y }">
<canvas :style="{ left: x + 'px', top: y + 'px' }" />
</MouseTracker>
</template>
💡 TIP — 현대 Vue 개발에서는 이 패턴보다 컴포저블 함수가 더 선호됩니다. 하지만 Renderless 컴포넌트는 로직을 템플릿 세계에 자연스럽게 표현하거나, Options API 기반 레거시 코드베이스에서 로직을 공유할 때 여전히 유용합니다.
컴포저블과 Renderless 컴포넌트를 조합하면 더 유연한 구조를 만들 수 있습니다:
<!-- components/FetchWrapper.vue -->
<script setup lang="ts">
import { useFetch } from '../composables/useFetch'
const props = defineProps<{ url: string }>()
const { data, error, isFetching } = useFetch<unknown>(() => props.url)
</script>
<template>
<slot
:data="data"
:error="error"
:is-fetching="isFetching"
/>
</template>
effectScope로 컴포저블 생명주기·정리 제어
컴포저블 내부의 watch, watchEffect, computed는 일반적으로 현재 컴포넌트 인스턴스에 묶입니다. 그런데 컴포넌트 인스턴스 바깥 — 예컨대 모듈 전역 상태나 플러그인 — 에서 반응형 효과를 만들면 자동으로 정리되지 않습니다. 이때 effectScope 를 사용합니다.
effectScope는 그 안에서 생성된 모든 반응형 효과(computed, watch, watchEffect)를 하나의 그룹으로 관리하고, stop()을 호출하는 순간 전부 정리합니다.
// composables/useGlobalMouse.ts
import { effectScope, ref, onScopeDispose } from 'vue'
// 앱 전체에서 공유하는 싱글턴 마우스 트래커
let scope: ReturnType<typeof effectScope> | null = null
let x = ref(0)
let y = ref(0)
let subscriberCount = 0
function onMouseMove(e: MouseEvent) {
x.value = e.clientX
y.value = e.clientY
}
export function useGlobalMouse() {
subscriberCount++
if (!scope) {
scope = effectScope(true) // true = 독립(detached) 스코프
// 주의: scope.stop()은 스코프 내 반응형 효과(computed/watch/watchEffect)와
// onScopeDispose 콜백만 정리한다. addEventListener 같은 임의의 부수효과는
// scope.stop()이 자동으로 제거하지 않으므로 직접 removeEventListener로 해제해야 한다.
window.addEventListener('mousemove', onMouseMove)
}
// 마지막 구독자가 언마운트될 때 스코프와 리스너를 정리
onScopeDispose(() => {
subscriberCount--
if (subscriberCount === 0) {
window.removeEventListener('mousemove', onMouseMove) // 부수효과는 수동 해제
scope?.stop() // 스코프 내 반응형 효과·onScopeDispose 콜백 정리
scope = null
}
})
return { x, y }
}
effectScope(true)의 true 인자는 독립(detached) 스코프를 만듭니다. 일반 스코프는 현재 활성 스코프의 자식이 되어 부모가 정리될 때 같이 정리되지만, 독립 스코프는 명시적으로 stop()을 호출하기 전까지 살아 있습니다.
import { effectScope, computed, watchEffect } from 'vue'
const scope = effectScope()
scope.run(() => {
const doubled = computed(() => count.value * 2) // ✅ 스코프에 묶임
watchEffect(() => console.log(doubled.value)) // ✅ 스코프에 묶임
})
// 나중에 모두 한번에 정리
scope.stop()
⚠️ 주의 —
effectScope바깥에서 생성된computed나watch는stop()호출 대상이 아닙니다. 반드시scope.run()안에서 생성해야 합니다.
onScopeDispose 는 현재 활성 스코프(컴포넌트 스코프든 effectScope든)가 정리될 때 실행되는 콜백을 등록합니다. 컴포저블 안에서 onUnmounted 대신 onScopeDispose를 쓰면, 컴포넌트 바깥의 effectScope 내에서도 정리 로직이 올바르게 동작합니다.
VueUse로 보는 실전 컴포저블 패턴 분석
VueUse는 200개 이상의 컴포저블을 모은 표준 라이브러리로, 실전 컴포저블 설계의 좋은 참고서입니다. 핵심 패턴 세 가지를 살펴봅니다.
1. MaybeRefOrGetter 패턴 — 인자 유연성
import { toValue, watchEffect, type MaybeRefOrGetter } from 'vue'
// VueUse의 useTitle 단순화 버전
export function useTitle(newTitle: MaybeRefOrGetter<string>) {
watchEffect(() => {
document.title = toValue(newTitle)
})
}
// 사용 — 세 가지 형태 모두 동작
useTitle('홈') // 일반 문자열
useTitle(pageTitle) // Ref<string>
useTitle(() => `${user.name}의 페이지`) // getter 함수
2. 설정 가능한 옵션 객체 패턴 — 확장성
interface UseScrollOptions {
throttle?: number
onStop?: (e: Event) => void
}
export function useScroll(
target: MaybeRefOrGetter<HTMLElement | Window | null>,
options: UseScrollOptions = {}
) {
const x = ref(0)
const y = ref(0)
const { throttle = 0, onStop } = options
// ... 구현
return { x, y }
}
3. 정지·재시작 제어 패턴 — 수동 생명주기
// VueUse의 useIntervalFn 단순화 버전
export function useIntervalFn(fn: () => void, interval = 1000) {
let timer: ReturnType<typeof setInterval> | null = null
function start() {
if (!timer) timer = setInterval(fn, interval)
}
function stop() {
if (timer) { clearInterval(timer); timer = null }
}
onMounted(start)
onUnmounted(stop)
return { start, stop, isActive: computed(() => timer !== null) }
}
이 세 패턴의 조합이 VueUse 컴포저블의 공통 구조입니다. 자신만의 라이브러리를 만들 때도 이 틀을 따르면 일관성과 재사용성이 높아집니다.
상태 공유 전략: 컴포저블 vs 전역 스토어 선택 기준
컴포저블과 Pinia 같은 전역 스토어는 모두 상태를 공유하는 수단이지만, 적합한 상황이 다릅니다.
| 기준 | 컴포저블 | 전역 스토어 (Pinia) |
|---|---|---|
| 상태 범위 | 호출 당 독립 인스턴스 | 앱 전체 싱글턴 |
| 데브툴 지원 | 없음 | Vue DevTools 완전 지원 |
| 서버사이드 렌더링 | 모듈 수준 공유 시 위험 | 요청별 인스턴스 생성 가능 |
| 설정 복잡도 | 없음 | 스토어 정의 필요 |
| 적합한 상황 | 로컬/위젯 단위 상태 | 앱 전역 공유 상태 |
컴포저블로 싱글턴 상태를 공유하는 패턴도 있습니다. ref를 모듈 스코프에 두면 모든 호출자가 같은 인스턴스를 참조합니다:
// composables/useGlobalNotifications.ts
import { ref, readonly } from 'vue'
// 모듈 스코프 — 앱 전체에서 하나의 인스턴스만 존재
const notifications = ref<{ id: number; message: string }[]>([])
let nextId = 0
export function useGlobalNotifications() {
function add(message: string) {
notifications.value.push({ id: nextId++, message })
}
function remove(id: number) {
notifications.value = notifications.value.filter(n => n.id !== id)
}
// readonly로 외부에서 직접 변경 방지
return {
notifications: readonly(notifications),
add,
remove,
}
}
⚠️ 주의 — 모듈 스코프에 상태를 두면 SSR에서 여러 사용자의 요청이 같은 상태를 공유하는 심각한 버그가 생깁니다. SSR 프로젝트에서는 Pinia처럼 요청마다 새 인스턴스를 만드는 방식을 사용하세요.
결정 기준을 한 문장으로 정리하면 다음과 같습니다:
- 특정 컴포넌트 트리나 인스턴스에 국한된 상태 → 컴포저블
- 앱 전체에서 단 하나만 존재해야 하는 상태, DevTools로 추적이 필요한 상태 → Pinia 스토어
요약
- 컴포저블 인자는
MaybeRefOrGetter+toValue()로 받아 반응성을 보존하고, 반환값은 항상 ref/reactive나 함수여야 한다. InjectionKey<T>심볼을 사용하면provide / inject에서 타입 안전성을 확보할 수 있다.- Renderless 컴포넌트는 로직과 UI를 스코프드 슬롯으로 분리하며, 현대 프로젝트에서는 컴포저블이 더 선호된다.
effectScope(true)와onScopeDispose로 컴포넌트 바깥의 반응형 효과 생명주기를 명시적으로 관리한다.- 로컬·위젯 단위 상태는 컴포저블, 앱 전역 공유 상태·DevTools 디버깅이 필요한 상태는 Pinia 스토어가 적합하다.
- SSR 환경에서는 모듈 스코프 싱글턴 패턴을 피하고 요청별 인스턴스를 생성하는 전략을 택해야 한다.
연습문제
-
useFetch컴포저블을 작성하되,url인자가string,Ref<string>,() => string세 가지 형태 모두 지원하도록MaybeRefOrGetter와toValue()를 활용하세요. 기본 반환값으로data,error,isFetching을 포함해야 합니다. -
InjectionKey<T>를 사용해{ locale: Ref<string>; setLocale: (l: string) => void }타입의 다국어 컨텍스트를 provide/inject하는provideI18n/useI18n컴포저블 쌍을 만드세요.useI18n은 provide 없이 호출되면 명확한 에러를 던져야 합니다. -
effectScope(true)를 사용해 컴포넌트 바깥에서 1초마다Date.now()를 갱신하는 싱글턴 컴포저블useNow()를 만드세요. 마지막 구독자가 언마운트되면onScopeDispose로 타이머를 정리해야 합니다. -
상품 목록 위젯에서 "현재 선택된 상품 ID"를 상태로 관리한다고 가정하세요. 이 상태를 컴포저블로 관리해야 하는지, Pinia 스토어로 관리해야 하는지 근거를 들어 설명하고, 컴포저블로 구현한다면 어떻게 작성할지 코드로 보여주세요.
힌트 — 1번:
watchEffect내부에서toValue(url)를 호출하면 url이 ref일 때 자동으로 추적됩니다. 3번:onScopeDispose는 컴포넌트onUnmounted와 달리 effectScope 종료 시에도 동작합니다.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Vue.js 심화” 강좌에 대한 댓글입니다.