본문으로 건너뛰기

VAC + Compound Component Pattern

Overview

이 문서는 VAC(View–Asset–Container) 패턴과 Compound Component 패턴을 함께 사용하는 이유를 설명하고, 실제로 이 패턴들이 어떻게 적용되는지에 대한 예시를 제공합니다.

Why Combine VAC Pattern with the Compound Component Pattern?​

VAC 패턴과 Compound Component 패턴을 함께 사용하는 이유를 이해하려면, 각 패턴이 어떤 역할을 하는지부터 알아야 합니다.

What is the Compound Component Pattern?​

Compound Component Pattern은 상위 컴포넌트가 상태와 로직을 관리하고, 하위 컴포넌트들은 해당 상태를 기반으로 UI 역할을 수행하는 구조입니다. 이 방식은 UI 구성 요소를 모듈화하고 재사용성을 높이며, 컴포넌트 내부에서 Context를 통해 하위 슬롯들을 선언적으로 사용할 수 있게 해줍니다.

What is VAC Pattern?​

View Asset Component (VAC) 패턴은 View 로직과 UI 표현을 명확히 분리하기 위한 설계 방식입니다. View 컴포넌트는 로직과 상태를 관리하고, VAC 컴포넌트는 오직 props 기반으로 JSX를 렌더링합니다. 핵심 규칙은 다음과 같습니다:

  • 조건부 렌더링, 반복 렌더링 등 UI 렌더 제어만 담당합니다.
  • 자체 상태를 가지지 않고, props만으로 동작하는 순수 컴포넌트입니다.
  • 이벤트에 직접 핸들러를 바인딩하며, 부가 로직은 포함하지 않습니다.
  • 상태를 가진 자식 컴포넌트를 포함할 수는 있지만, VAC 자체는 그 상태에 관여하지 않습니다.
  • props는 데이터 중심보다는 UI 렌더링 중심으로 명명되어야 합니다.
    • 예시:
      • isMax, isMin 대신 disabledDecrease, disabledIncrease
      • isLogin, isOwner를 따로 넘기는 대신, showEditButton: isLogin && isOwner

VAC Example

What are we trying to solve?​

  • UI 컴포넌트와 데이터 로직이 강하게 결합되어 있어 변경에 유연하지 않음
  • 동일한 목적의 UI에 대해 컴포넌트 수정이나 새로운 변형(variant) 컴포넌트를 자주 생성하게 됨
  • 로직 중심의 props 설계로 인해 인터페이스 변경이 빈번하게 발생함

이러한 문제를 해결하기 위해 VAC Pattern과 Compound Component Pattern을 함께 적용합니다.

So What are the Benefits?​

Two Patterns Combined Visual

VAC (View–Asset–Component) 패턴과 Compound Component Pattern을 결합하면 다음과 같은 효과를 얻을 수 있습니다:

  • 확장 가능(Scalable)
  • 조합 가능(Composable)
  • 디자인 일관성 유지(Design-consistent)
  • 손쉬운 디자인 업데이트(Easy Design Update)

1. Separation of Logic and UI (via VAC)​

VAC 패턴은 로직과 UI를 명확히 분리하는 구조를 지향합니다.

Benefits:​

  • 비즈니스 로직과 UI가 분리되어 코드 이해가 쉬워집니다.
  • UI 디자인만 변경하고 싶을 때는 VAC 컴포넌트만 수정하면 됩니다.
  • 데이터 중심이 아닌, 렌더링에 직관적인 props interface를 정의할 수 있습니다.
  • props 기반으로만 구성되어, 전달 방식이나 내부 상태에 대해 고민할 필요가 없습니다.

2. Declarative and Flexible UI Composition (via Compound Component Pattern)​

Compound Component는 Card.Header, Card.Body처럼 slot 기반 구조를 선언적으로 정의하고 재사용할 수 있습니다.

Benefits:​

  • 각 컴포넌트가 자체적으로 상태를 관리하고, 내부적으로 공유할 수 있어 별도의 상태 관리가 필요 없습니다.
  • 상위 컴포넌트 하나만 import 하면 하위 slot 컴포넌트들도 함께 사용할 수 있어 import가 간결합니다.
  • 명확한 구조로 복잡한 UI를 쉽게 작성할 수 있습니다:
import { FlyOut } from "./FlyOut";

export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
);
}
  • 디자인 시스템, 다중 슬롯 컴포넌트(Modal, Tabs, Cards 등)에 적합합니다.

3. UI-Centric Props Integration with Compound Components (VAC Discipline)​

VAC은 UI 표현 중심의 props 인터페이스 정의를 권장하며, 이를 Compound Component에 그대로 적용할 수 있습니다.

Benefits:​

  • props는 데이터 구조가 아닌 UI 표현 의도에 맞춰 정의되어 직관적입니다.
  • 데이터 구조가 바뀌어도 UI 디자인이 그대로라면 컴포넌트 API는 변하지 않아 인터페이스 변경이 줄어듭니다.
  • 단일 props 객체로 여러 슬롯 컴포넌트들을 제어할 수 있어 복잡한 UI 구성에서도 일관성을 유지할 수 있습니다.
  • 비즈니스 로직과 UI 구조가 분리되어 예측 가능하고 유지보수성이 높습니다.

VAC and Compound Component Applied Example​

Goal:​

다음 조건을 만족하는 VAC 스타일의 컴포넌트를 구현합니다:

  • Compound Component 패턴을 사용함 (Card.Header, Card.Body 등)
  • Controller/View로부터 하나의 props 객체만 전달받음
  • 컴포넌트 내부에 로직이나 상태를 포함하지 않음
  • 오직 전달된 props 기반으로 JSX를 렌더링 (VAC 원칙 준수)

1. CounterCardCompound.tsx (Compound Components with Context)​

  • Context는 여러 컴포넌트에서 공통으로 필요한 데이터를 공유할 때만 사용합니다.
    • 이 예시에서는 theme (UI 설정)를 CounterCard.Container와 CounterCard.Title에서 공유하기 위해 사용합니다.
    • count, title, event handlers 같은 동적 값들은 VAC 원칙에 따라 명시적으로 props로 전달합니다.
import React, { createContext, useContext } from 'react'
import { View, Text, Button, StyleSheet } from 'react-native'

// --- Context Definition ---
export type CounterCardContextValue = {
theme: "DARK" | "LIGHT"
}

const CounterCardContext = createContext<CounterCardContextValue | null>(null)

function useCounterCardContext() {
const ctx = useContext(CounterCardContext)
if (!ctx) throw new Error('CounterCard components must be used inside <CounterCard.Provider>')
return ctx
}

// --- Compound Component Structure ---
const CounterCard = {
Provider: CounterCardContext.Provider,

Container: ({ children }: { children: React.ReactNode }) => {
const { theme } = useCounterCardContext()
const cardStyle = [styles.card, { backgroundColor: theme === 'DARK' ? 'black' : 'white' }]
return <View style={cardStyle}>{children}</View>
},

Header: ({ title }: { title: string }) => {
const { theme } = useCounterCardContext()
const textStyle = [styles.title, { color: theme === 'DARK' ? 'white' : 'black' }]
return <Text style={textStyle}>{title}</Text>
},

Body: ({ count }: { count: number }) => {
return <Text style={styles.count}>Count: {count}</Text>
},

Controls: ({ onIncrease, onDecrease, disabledIncrease, disabledDecrease }: {
onIncrease: () => void,
onDecrease: () => void,
disabledIncrease: boolean,
disabledDecrease: boolean
}) => {
return (
<View style={styles.controls}>
<Button title="–" onPress={onDecrease} disabled={disabledDecrease} />
<Button title="+" onPress={onIncrease} disabled={disabledIncrease} />
</View>
)
},
}

export { CounterCard }
export type { CounterCardContextValue }

const styles = StyleSheet.create({
card: {
padding: 16,
borderRadius: 8,
backgroundColor: '#fff',
shadowColor: '#000',
shadowOpacity: 0.1,
shadowOffset: { width: 0, height: 2 },
elevation: 4,
},
title: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
count: {
fontSize: 16,
marginBottom: 12,
},
controls: {
flexDirection: 'row',
justifyContent: 'space-between',
},
})

2. CounterCardVAC.tsx (VAC Layer: Pure Props → JSX)​

import React from 'react'
import { CounterCard, type CounterCardContextValue } from './CounterCardCompound'

export type CounterCardVACProps = CounterCardContextValue & {
title: string
count: number
onIncrease: () => void
onDecrease: () => void
disabledIncrease: boolean
disabledDecrease: boolean
}

export function CounterCardVAC(props: CounterCardVACProps) {
const { theme, ...rest } = props

return (
<CounterCard.Provider value={{ theme }}>
<CounterCard.Container>
<CounterCard.Header title={rest.title} />
<CounterCard.Body count={rest.count} />
<CounterCard.Controls
onIncrease={rest.onIncrease}
onDecrease={rest.onDecrease}
disabledIncrease={rest.disabledIncrease}
disabledDecrease={rest.disabledDecrease}
/>
</CounterCard.Container>
</CounterCard.Provider>
)
}

3. useCounterCardController.ts​

이 커스텀 훅은 비즈니스 로직을 캡슐화하고, VAC 컴포넌트에서 사용할 UI 중심의 props 객체를 반환합니다.

import { useState } from 'react'
import type { CounterCardVACProps } from './CounterCardVAC'

export function useCounterCardController(): CounterCardVACProps {
const [count, setCount] = useState(0)
const MIN = 0
const MAX = 5

return {
theme: 'LIGHT',
title: 'Item Count',
count,
disabledDecrease: count <= MIN,
disabledIncrease: count >= MAX,
onIncrease: () => setCount(c => Math.min(c + 1, MAX)),
onDecrease: () => setCount(c => Math.max(c - 1, MIN)),
}
}

4. CounterCardView.tsx (Controller)​

  • CounterCardView는 useCounterCardController를 사용하여 props를 생성합니다.
  • 실제로는 controller 역할을 하는 컴포넌트이기 때문에, 반드시 하나의 훅으로 props를 캡슐화할 필요는 없습니다.
import React from 'react'
import { CounterCardVAC } from './CounterCardVAC'
import { useCounterCardController } from './useCounterCardController'

export default function CounterCardView() {
const props = useCounterCardController()

return <CounterCardVAC {...props} />
}

References​