Vue 3 + TypeScript 패턴 가이드
기반: Vue Pivottable 프로젝트 코드 기반 실무 패턴
범위: 기본부터 고급까지 TypeScript 변환 가이드
📚 목차
- 기본 패턴 (Essential Patterns)
- 중급 패턴 (Intermediate Patterns)
- 고급 패턴 (Advanced Patterns)
- 성능 최적화 패턴
- 실무 베스트 프랙티스
1. 기본 패턴 (Essential Patterns)
1.1 컴포넌트
1.1.1 필수 Props 정의 – defineProps만 사용하는 경우
<!-- VPivottableHeaderColumns.vue 기반 -->
<script setup lang="ts">
import type { PivotKey } from '@/types'
<!-- ✅ 기본 Props 패턴 -->
interface Props {
colKeys: PivotKey[]
colIndex: number
colAttrsLength: number
rowAttrsLength: number
}
const props = defineProps<Props>()
</script>
- 모든 props는 필수입니다.
- ? 없이 타입이 선언되면 TypeScript 및 Vue 모두 해당 prop이 반드시 상위에서 전달돼야 함을 요구합니다.
- ❌ withDefaults는 사용할 필요도, 의미도 없습니다.
1.1.2 선택적 Props 정의 – withDefaults로 기본값 제공
<!-- VPivottableBody.vue 기반 -->
<script setup lang="ts">
interface Props {
rowTotal?: boolean
colTotal?: boolean
localeStrings?: {
totals: string
}
tableOptions?: {
clickCallback: (() => void) | null
}
}
const props = withDefaults(defineProps<Props>(), {
rowTotal: true,
colTotal: true,
localeStrings: () => ({ totals: 'Totals' }),
tableOptions: () => ({ clickCallback: null })
})
</script>
- prop이 생략될 수도 있고, 그 경우 default 값이 사용됩니다.
- ?로 타입이 optional로 설정되어 있어야 TypeScript가 이 구조를 허용합니다.
1.2 Emits 정의
<!-- VDraggableAttribute.vue 기반 -->
<script setup lang="ts">
// ✅ 타입 안전한 Emits 정의
interface Emits {
'update:zIndexOfFilterBox': [attributeName: string]
'update:unselectedFilterValues': [data: { key: string; value: Record<string, boolean> }]
'update:openStatusOfFilterBox': [data: { key: string; value: boolean }]
}
const emit = defineEmits<Emits>()
// 사용
const toggleFilterBox = () => {
// 타입 체크됨
emit('update:openStatusOfFilterBox', {
key: props.attributeName,
value: !props.open
})
}
</script>
1.3 Reactive 상태 관리
<!-- VFilterBox.vue 기반 -->
<script setup lang="ts">
import { ref } from 'vue'
// ✅ 기본 ref 타입
const filterText = ref<string>('')
const showMenu = ref<boolean>(filterBoxValuesList.length < menuLimit.value)
</script>
// usePivotUiState.js → TypeScript 변환
import { reactive } from 'vue'
// ✅ 복잡한 객체는 reactive + interface
export interface PivotUiState {
unusedOrder: string[]
zIndices: Record<string, number>
maxZIndex: number
openStatus: Record<string, boolean>
}
export function usePivotUiState() {
const pivotUiState = reactive<PivotUiState>({
unusedOrder: [],
zIndices: {},
maxZIndex: 1000,
openStatus: {}
})
}
<!-- VPivottableBodyRowsTotalRow.vue 기반 -->
<script setup lang="ts">
import type { PivotValue } from '@/types'
// ✅ 계산된 속성의 타입 추론
const grandTotalValue = computed<PivotValue>(() => {
return getAggregator([], []).value()
})
</script>
1.4 템플릿 참조 (Template Refs)
<template>
<input ref="inputEl" type="text" />
<my-component ref="componentEl" />
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import MyComponent from './MyComponent.vue'
// ✅ DOM 요소 참조
const inputEl = ref<HTMLInputElement>()
// ✅ 컴포넌트 인스턴스 참조
const componentEl = ref<InstanceType<typeof MyComponent>>()
onMounted(() => {
inputEl.value?.focus()
componentEl.value?.somePublicMethod()
})
</script>
2. 중급 패턴 (Intermediate Patterns)
2.1 Composables 패턴
// usePivotUiState.js → TypeScript 변환
import { reactive } from 'vue'
export interface PivotUiState {
unusedOrder: string[]
zIndices: Record<string, number>
maxZIndex: number
openStatus: Record<string, boolean>
}
export function usePivotUiState() {
const pivotUiState = reactive<PivotUiState>({
unusedOrder: [],
zIndices: {},
maxZIndex: 1000,
openStatus: {}
})
const onMoveFilterBoxToTop = (attributeName: string) => {
pivotUiState.maxZIndex++
pivotUiState.zIndices[attributeName] = pivotUiState.maxZIndex
}
return {
state: pivotUiState,
onMoveFilterBoxToTop,
onUpdateOpenStatus,
onUpdateUnusedOrder
}
}
2.2 provide/inject 타입 안전하게
// useProvidePivotData.js → TypeScript 변환
// ✅ 타입 안전한 Injection Key
export interface PivotDataContext {
pivotData: ComputedRef<PivotData | null>
rowKeys: ComputedRef<PivotKey[]>
colKeys: ComputedRef<PivotKey[]>
getAggregator: (rowKey: PivotKey, colKey: PivotKey) => AggregatorInstance
spanSize: (arr: PivotKey[], i: number, j: number) => number
}
const PIVOT_DATA_KEY: InjectionKey<PivotDataContext> = Symbol('pivotData')
// Provider
export function providePivotData(props: BasePivotProps): PivotDataContext {
const pivotData = computed(() => {
try {
return new PivotData(props)
} catch (err) {
console.error(err instanceof Error ? err.stack : err)
return null
}
})
const context: PivotDataContext = {
pivotData,
rowKeys: computed(() => pivotData.value?.getRowKeys() || []),
colKeys: computed(() => pivotData.value?.getColKeys() || []),
getAggregator: (rowKey, colKey) => pivotData.value?.getAggregator(rowKey, colKey),
spanSize: (arr, i, j) => {
// spanSize 로직...
return calculateSpan(arr, i, j)
}
}
provide(PIVOT_DATA_KEY, context)
return context
}
// Consumer
export function useProvidePivotData(): PivotDataContext {
const context = inject(PIVOT_DATA_KEY)
if (!context) {
throw new Error('useProvidePivotData must be used within a provider')
}
return context
}
2.3 동적 컴포넌트와 타입
<!-- VPivottable.vue -->
<template>
<component :is="currentRenderer" v-bind="rendererProps" />
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue'
import type { BasePivotProps } from '@/types'
import TableRenderers from './renderer/index'
// ✅ 렌더러 컴포넌트 타입 정의 (setup 함수를 가진 객체)
interface RendererComponent {
name?: string
setup: (props: any) => () => any
}
// ✅ 렌더러 맵 타입 정의
type RendererMap = {
'Table': RendererComponent
'Table Heatmap': RendererComponent
'Table Row Heatmap': RendererComponent
'Table Col Heatmap': RendererComponent
'Export Table TSV': RendererComponent
}
type RendererKey = keyof RendererMap
interface Props extends BasePivotProps {
renderers?: Partial<Record<RendererKey, RendererComponent>>
rendererName?: RendererKey
}
const props = withDefaults(defineProps<Props>(), {
rendererName: 'Table'
})
// ✅ 기본 렌더러들 (실제 프로젝트의 구조)
const defaultRenderers: RendererMap = TableRenderers as RendererMap
// ✅ 현재 렌더러 선택
const currentRenderer = computed(() => {
const rendererName = props.rendererName || 'Table'
return props.renderers?.[rendererName] || defaultRenderers[rendererName]
})
// ✅ 렌더러별 Props (각 렌더러의 setup 함수에서 처리)
const rendererProps = computed(() => {
return {
data: props.data,
aggregators: props.aggregators,
aggregatorName: props.aggregatorName,
cols: props.cols,
rows: props.rows,
vals: props.vals,
showRowTotal: props.showRowTotal,
showColTotal: props.showColTotal,
localeStrings: props.localeStrings,
tableOptions: props.tableOptions,
// heatmapMode는 각 렌더러 내부에서 설정됨
}
})
</script>
2.4 Slots 타입 정의
// ✅ Slot Props 타입 정의
interface PvtAttrSlotProps {
attrName: string
filtered: boolean
restricted: boolean
}
interface OutputSlotProps {
pivotData: PivotDataInstance
error: string | null
}
// 슬롯 타입 정의
interface VPivottableSlots {
pvtAttr(props: PvtAttrSlotProps): any
output(props: OutputSlotProps): any
rendererCell(): any
aggregatorCell(): any
}
3. 고급 패턴 (Advanced Patterns)
3.1 고급 제네릭 컴포저블
// composables/useAsyncData.ts
import { ref, type Ref } from 'vue'
export interface AsyncDataOptions<T> {
immediate?: boolean
onSuccess?: (data: T) => void
onError?: (error: Error) => void
transform?: (raw: any) => T
}
export interface AsyncDataReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
isLoading: Ref<boolean>
execute: () => Promise<void>
refresh: () => Promise<void>
}
export function useAsyncData<T = any>(
fetcher: () => Promise<T>,
options: AsyncDataOptions<T> = {}
): AsyncDataReturn<T> {
const { immediate = true, onSuccess, onError, transform } = options
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const isLoading = ref(false)
const execute = async () => {
try {
isLoading.value = true
error.value = null
const result = await fetcher()
const transformedData = transform ? transform(result) : result
data.value = transformedData
onSuccess?.(transformedData)
} catch (err) {
const errorObj = err instanceof Error ? err : new Error(String(err))
error.value = errorObj
onError?.(errorObj)
} finally {
isLoading.value = false
}
}
const refresh = () => execute()
if (immediate) {
execute()
}
return {
data,
error,
isLoading,
execute,
refresh
}
}
// 사용 예시
interface User {
id: number
name: string
email: string
}
interface ApiResponse<T> {
data: T
status: string
}
// ✅ 제네릭 타입 추론
const { data: user, isLoading, error } = useAsyncData<User>(
() => fetch('/api/user').then(res => res.json()),
{
transform: (raw: ApiResponse<User>) => raw.data,
onSuccess: (user) => {
console.log('User loaded:', user.name) // 타입 안전
}
}
)
3.2 조건부 타입과 유틸리티 타입
// types/utils.ts
// ✅ 조건부 Props 타입
export type ConditionalProps<
T extends 'button' | 'link',
Base = {}
> = Base & (
T extends 'button'
? { onClick: () => void; disabled?: boolean }
: T extends 'link'
? { href: string; target?: string }
: never
)
// ✅ 깊은 부분 선택 타입
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
// ✅ 함수 오버로드 타입
export interface QueryBuilder {
<T extends string>(field: T): QueryBuilder
<T extends string, V>(field: T, value: V): QueryBuilder
<T extends Record<string, any>>(conditions: T): QueryBuilder
}
// ✅ 이벤트 핸들러 타입 추출
export type EventHandlers<T> = {
[K in keyof T as K extends `on${string}` ? K : never]: T[K]
}
// 사용 예시
interface ComponentProps {
title: string
onClick: () => void
onSubmit: (data: any) => void
onError: (error: Error) => void
disabled: boolean
}
type Handlers = EventHandlers<ComponentProps>
// 결과: { onClick: () => void; onSubmit: (data: any) => void; onError: (error: Error) => void }
3.3 고급 컴포넌트 패턴
<!-- AdvancedComponent.vue -->
<template>
<div>
<!-- Render Props 패턴 -->
<slot
v-if="$slots.default"
:state="state"
:actions="actions"
:computed="computedValues"
/>
<!-- Fallback 렌더링 -->
<default-render
v-else
:state="state"
:actions="actions"
/>
</div>
</template>
<script setup lang="ts">
import { reactive, computed, provide } from 'vue'
// ✅ 복잡한 상태 관리
interface ComponentState {
items: any[]
selectedIds: Set<string>
filters: Record<string, any>
sorting: {
field: string
direction: 'asc' | 'desc'
}
}
interface ComponentActions {
addItem: (item: any) => void
removeItem: (id: string) => void
toggleSelection: (id: string) => void
updateFilter: (key: string, value: any) => void
sort: (field: string) => void
reset: () => void
}
const state = reactive<ComponentState>({
items: [],
selectedIds: new Set(),
filters: {},
sorting: { field: 'id', direction: 'asc' }
})
const actions: ComponentActions = {
addItem: (item) => {
state.items.push({ ...item, id: crypto.randomUUID() })
},
removeItem: (id) => {
state.items = state.items.filter(item => item.id !== id)
state.selectedIds.delete(id)
},
toggleSelection: (id) => {
if (state.selectedIds.has(id)) {
state.selectedIds.delete(id)
} else {
state.selectedIds.add(id)
}
},
updateFilter: (key, value) => {
if (value === null || value === undefined || value === '') {
delete state.filters[key]
} else {
state.filters[key] = value
}
},
sort: (field) => {
if (state.sorting.field === field) {
state.sorting.direction = state.sorting.direction === 'asc' ? 'desc' : 'asc'
} else {
state.sorting.field = field
state.sorting.direction = 'asc'
}
},
reset: () => {
state.items = []
state.selectedIds.clear()
state.filters = {}
state.sorting = { field: 'id', direction: 'asc' }
}
}
// ✅ 복잡한 계산된 값들
const computedValues = {
filteredItems: computed(() => {
return state.items.filter(item => {
return Object.entries(state.filters).every(([key, value]) => {
return String(item[key]).toLowerCase().includes(String(value).toLowerCase())
})
})
}),
sortedItems: computed(() => {
const items = [...computedValues.filteredItems.value]
const { field, direction } = state.sorting
return items.sort((a, b) => {
const aVal = a[field]
const bVal = b[field]
const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0
return direction === 'asc' ? comparison : -comparison
})
}),
selectedItems: computed(() => {
return computedValues.sortedItems.value.filter(item =>
state.selectedIds.has(item.id)
)
}),
stats: computed(() => ({
total: state.items.length,
filtered: computedValues.filteredItems.value.length,
selected: state.selectedIds.size
}))
}
// ✅ Context 제공 (고급 패턴)
export interface ComponentContext {
state: ComponentState
actions: ComponentActions
computed: typeof computedValues
}
const componentKey = Symbol('advanced-component')
provide<ComponentContext>(componentKey, {
state,
actions,
computed: computedValues
})
// 외부에서 사용할 수 있도록 expose
defineExpose({
state,
actions,
computed: computedValues
})
</script>
4. 성능 최적화 패턴
4.1 계산 비용이 큰 computed 최적화
<script setup lang="ts">
import { ref, computed, shallowRef, triggerRef } from 'vue'
// ✅ 얕은 반응성으로 성능 최적화
const expensiveData = shallowRef<{
items: LargeObject[]
metadata: any
}>({
items: [],
metadata: {}
})
// ✅ 메모이제이션된 계산
const processedData = computed(() => {
// 큰 계산 작업
return expensiveData.value.items
.filter(item => item.isActive)
.map(item => ({
...item,
computed: heavyComputation(item)
}))
})
// 데이터 업데이트시 수동으로 트리거
const updateExpensiveData = (newData: any) => {
expensiveData.value = newData
triggerRef(expensiveData) // 수동 업데이트 트리거
}
</script>
4.2 동적 Import와 코드 분할
<script setup lang="ts">
import { ref, defineAsyncComponent, type Component } from 'vue'
// ✅ 비동기 컴포넌트 로딩
const HeavyComponent = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: {
template: '<div>Loading...</div>'
},
errorComponent: {
template: '<div>Error loading component</div>'
},
delay: 200,
timeout: 3000
})
// ✅ 조건부 동적 로딩
const dynamicComponents = ref<Record<string, Component>>({})
const loadComponent = async (name: string) => {
if (!dynamicComponents.value[name]) {
try {
const module = await import(`./components/${name}.vue`)
dynamicComponents.value[name] = module.default
} catch (error) {
console.error(`Failed to load component: ${name}`, error)
}
}
return dynamicComponents.value[name]
}
</script>
4.3 메모리 누수 방지 패턴
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
// ✅ 이벤트 리스너 정리
let resizeHandler: () => void
let intervalId: NodeJS.Timeout
onMounted(() => {
// ResizeObserver 사용
const resizeObserver = new ResizeObserver((entries) => {
// 처리 로직
})
const targetElement = document.getElementById('target')
if (targetElement) {
resizeObserver.observe(targetElement)
}
// 정리 함수 등록
onUnmounted(() => {
resizeObserver.disconnect()
if (intervalId) clearInterval(intervalId)
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
}
})
})
// ✅ WeakMap을 사용한 메모리 효율적 캐싱
const cache = new WeakMap<object, any>()
const getCachedValue = (obj: object, computer: () => any) => {
if (cache.has(obj)) {
return cache.get(obj)
}
const value = computer()
cache.set(obj, value)
return value
}
</script>
5. 실무 베스트 프랙티스
5.1 에러 핸들링 패턴
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
// ✅ 컴포넌트 레벨 에러 처리
interface ErrorInfo {
message: string
stack?: string
componentStack?: string
timestamp: Date
}
const errors = ref<ErrorInfo[]>([])
onErrorCaptured((error: Error, instance, info: string) => {
const errorInfo: ErrorInfo = {
message: error.message,
stack: error.stack,
componentStack: info,
timestamp: new Date()
}
errors.value.push(errorInfo)
// 에러 리포팅 서비스로 전송
reportError(errorInfo)
// true를 반환하면 에러 전파 중단
return false
})
// ✅ 비동기 에러 처리
const safeAsyncOperation = async <T>(
operation: () => Promise<T>,
fallback?: T
): Promise<T | null> => {
try {
return await operation()
} catch (error) {
console.error('Async operation failed:', error)
return fallback || null
}
}
</script>
5.2 타입 가드와 검증
// utils/typeGuards.ts
// ✅ 기본 타입 가드
export const isString = (value: unknown): value is string =>
typeof value === 'string'
export const isNumber = (value: unknown): value is number =>
typeof value === 'number' && !isNaN(value)
export const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
// ✅ 복합 타입 가드
export interface User {
id: number
name: string
email: string
isActive?: boolean
}
export const isUser = (value: unknown): value is User => {
return isObject(value) &&
isNumber(value.id) &&
isString(value.name) &&
isString(value.email) &&
(value.isActive === undefined || typeof value.isActive === 'boolean')
}
// ✅ 배열 타입 가드
export const isUserArray = (value: unknown): value is User[] =>
Array.isArray(value) && value.every(isUser)
// ✅ API 응답 검증
export interface ApiResponse<T> {
data: T
status: 'success' | 'error'
message?: string
}
export const isApiResponse = <T>(
value: unknown,
dataGuard: (data: unknown) => data is T
): value is ApiResponse<T> => {
return isObject(value) &&
dataGuard(value.data) &&
(value.status === 'success' || value.status === 'error') &&
(value.message === undefined || isString(value.message))
}
// 사용 예시
const fetchUser = async (id: number): Promise<User | null> => {
try {
const response = await fetch(`/api/users/${id}`)
const data = await response.json()
if (isApiResponse(data, isUser) && data.status === 'success') {
return data.data
}
return null
} catch {
return null
}
}
5.3 테스트 가능한 컴포넌트 설계
<!-- TestableComponent.vue -->
<template>
<div>
<button
@click="handleClick"
:disabled="isLoading"
data-testid="submit-button"
>
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
// ✅ 테스트하기 쉬운 Props 정의
interface Props {
isLoading?: boolean
onSubmit?: (data: any) => Promise<void> | void
buttonText?: string
}
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
buttonText: 'Submit'
})
// ✅ 외부 의존성 주입 가능하도록 설계
interface Dependencies {
apiClient?: {
post: (url: string, data: any) => Promise<any>
}
logger?: {
log: (message: string) => void
}
}
const deps = inject<Dependencies>('dependencies', {
apiClient: {
post: (url: string, data: any) => fetch(url, {
method: 'POST',
body: JSON.stringify(data)
}).then(r => r.json())
},
logger: {
log: console.log
}
})
// ✅ 테스트 가능한 로직 분리
const handleClick = async () => {
try {
const result = await props.onSubmit?.({
timestamp: Date.now(),
userAgent: navigator.userAgent
})
deps.logger?.log('Submit successful')
return result
} catch (error) {
deps.logger?.log(`Submit failed: ${error}`)
throw error
}
}
// ✅ 테스트를 위한 public 메서드 expose
defineExpose({
handleClick,
// 테스트에서 내부 상태 검증 가능
getInternalState: () => ({
isLoading: props.isLoading,
buttonText: props.buttonText
})
})
</script>
5.4 성능 모니터링
// composables/usePerformanceMonitor.ts
import { ref, onMounted, onUnmounted } from 'vue'
export interface PerformanceMetrics {
renderTime: number
componentCount: number
memoryUsage: number
updateCount: number
}
export function usePerformanceMonitor(componentName: string) {
const metrics = ref<PerformanceMetrics>({
renderTime: 0,
componentCount: 0,
memoryUsage: 0,
updateCount: 0
})
const startTime = performance.now()
let updateCount = 0
const measureRenderTime = () => {
metrics.value.renderTime = performance.now() - startTime
}
const measureMemoryUsage = () => {
if ('memory' in performance) {
metrics.value.memoryUsage = (performance as any).memory.usedJSHeapSize
}
}
const incrementUpdateCount = () => {
updateCount++
metrics.value.updateCount = updateCount
}
// 개발 모드에서만 성능 측정
if (process.env.NODE_ENV === 'development') {
onMounted(() => {
measureRenderTime()
measureMemoryUsage()
console.group(`Performance Metrics - ${componentName}`)
console.log('Render Time:', metrics.value.renderTime.toFixed(2), 'ms')
console.log('Memory Usage:', (metrics.value.memoryUsage / 1024 / 1024).toFixed(2), 'MB')
console.groupEnd()
})
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.includes(componentName)) {
console.log(`${componentName} Performance:`, entry)
}
}
})
observer.observe({ entryTypes: ['measure', 'navigation'] })
onUnmounted(() => {
observer.disconnect()
})
}
return {
metrics,
measureRenderTime,
measureMemoryUsage,
incrementUpdateCount
}
}
🚀 실무 적용 체크리스트
✅ 기본 준비사항
- TypeScript 5.0+ 설정 완료
- Vue 3.3+ 사용 (최신 TypeScript 지원)
- Vite/Webpack TypeScript 설정 최적화
- ESLint + TypeScript 규칙 설정
✅ 컴포넌트 설계
- Props interface 명확히 정의
- Emits 타입 안전하게 정의
- Slots 타입 정의 (필요시)
- 복잡한 상태는 composable로 분리
✅ 타입 시스템
- 전역 타입 정의 (types/ 폴더)
- API 응답 타입 정의
- 이벤트 핸들러 타입 정의
- 유틸리티 타입 활용
✅ 성능 최적화
- 큰 객체는 shallowRef 사용
- 무거운 컴포넌트는 defineAsyncComponent
- 메모리 누수 방지 패턴 적용
- 불필요한 re-render 방지
✅ 에러 핸들링 & 테스트
- 컴포넌트 레벨 에러 처리
- 타입 가드로 런타임 검증
- 테스트 가능한 구조 설계
- 성능 모니터링 (개발 환경)
이 가이드는 실무에서 바로 사용할 수 있는 패턴들로 구성했습니다.