React Composition Patterns - Vercel이 공개한 React 합성 패턴 (전문 번역)
· react, vercel, best-practices
목차
참고: 이 문서는 Vercel Labs의 composition-patterns 스킬 전문을 번역한 것입니다. 원문은 8개 규칙 파일을 하나로 컴파일한 통합 문서(AGENTS.md) 기준으로 옮겼고, 원문 목차는 블로그 목차와 중복되어 생략했어요. 이전에 번역한 React Best Practices, Next.js Best Practices, React Native Skills가 성능 규칙 중심이었다면, 이 문서는 컴포넌트를 어떻게 설계할 것인가 — 컴파운드 컴포넌트, 상태 끌어올리기, 의존성 주입 같은 합성(composition) 패턴 중심입니다.
Version 1.0.0 · Engineering · 2026년 1월
노트: 이 문서는 주로 에이전트와 LLM이 합성을 사용해 React 코드베이스를 유지보수·생성·리팩터링할 때 따르도록 만들어졌습니다. 사람에게도 유용할 수 있지만, 여기의 가이드는 AI 기반 워크플로우의 자동화와 일관성에 최적화되어 있어요.
초록 (Abstract)
유연하고 유지보수 가능한 React 컴포넌트를 만들기 위한 합성 패턴입니다. 컴파운드 컴포넌트, 상태 끌어올리기, 내부 요소 합성으로 불리언 prop 남발을 피하세요. 이 패턴들은 코드베이스가 커져도 사람과 AI 에이전트 모두가 다루기 쉽게 만들어 줍니다.
1. 컴포넌트 아키텍처 (Component Architecture)
임팩트: HIGH
prop 남발을 피하고 유연한 합성을 가능하게 하는 컴포넌트 구조화의 근본 패턴입니다.
1.1 불리언 prop 남발을 피하세요
임팩트: CRITICAL (유지보수 불가능한 컴포넌트 변형 방지)
컴포넌트 동작을 커스터마이즈하려고 isThread, isEditing, isDMThread 같은 불리언 prop을 추가하지 마세요. 불리언 하나가 늘 때마다 가능한 상태가 두 배가 되고, 유지보수 불가능한 조건부 로직이 생깁니다. 대신 합성을 쓰세요.
잘못된 예: 불리언 prop이 지수적 복잡도를 만듦
function Composer({
onSubmit,
isThread,
channelId,
isDMThread,
dmId,
isEditing,
isForwarding,
}: Props) {
return (
<form>
<Header />
<Input />
{isDMThread ? (
<AlsoSendToDMField id={dmId} />
) : isThread ? (
<AlsoSendToChannelField id={channelId} />
) : null}
{isEditing ? (
<EditActions />
) : isForwarding ? (
<ForwardActions />
) : (
<DefaultActions />
)}
<Footer onSubmit={onSubmit} />
</form>
)
}
올바른 예: 합성이 조건문을 제거
// 채널 컴포저
function ChannelComposer() {
return (
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<Composer.Footer>
<Composer.Attachments />
<Composer.Formatting />
<Composer.Emojis />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
)
}
// 스레드 컴포저 - "채널에도 보내기" 필드 추가
function ThreadComposer({ channelId }: { channelId: string }) {
return (
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<AlsoSendToChannelField id={channelId} />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
)
}
// 편집 컴포저 - 다른 푸터 액션
function EditComposer() {
return (
<Composer.Frame>
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.CancelEdit />
<Composer.SaveEdit />
</Composer.Footer>
</Composer.Frame>
)
}
각 변형이 무엇을 렌더링하는지 명시적입니다. 하나의 거대한 부모를 공유하지 않고도 내부 요소를 공유할 수 있어요.
1.2 컴파운드 컴포넌트를 쓰세요
임팩트: HIGH (prop 드릴링 없이 유연한 합성 가능)
복잡한 컴포넌트는 공유 컨텍스트를 가진 컴파운드 컴포넌트로 구조화하세요. 각 서브컴포넌트는 props가 아니라 컨텍스트로 공유 상태에 접근합니다. 사용하는 쪽은 필요한 조각들만 조합하면 됩니다.
잘못된 예: render props를 쓰는 모놀리식 컴포넌트
function Composer({
renderHeader,
renderFooter,
renderActions,
showAttachments,
showFormatting,
showEmojis,
}: Props) {
return (
<form>
{renderHeader?.()}
<Input />
{showAttachments && <Attachments />}
{renderFooter ? (
renderFooter()
) : (
<Footer>
{showFormatting && <Formatting />}
{showEmojis && <Emojis />}
{renderActions?.()}
</Footer>
)}
</form>
)
}
올바른 예: 공유 컨텍스트를 가진 컴파운드 컴포넌트
const ComposerContext = createContext<ComposerContextValue | null>(null)
function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
return (
<ComposerContext value={{ state, actions, meta }}>
{children}
</ComposerContext>
)
}
function ComposerFrame({ children }: { children: React.ReactNode }) {
return <form>{children}</form>
}
function ComposerInput() {
const {
state,
actions: { update },
meta: { inputRef },
} = use(ComposerContext)
return (
<TextInput
ref={inputRef}
value={state.input}
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
/>
)
}
function ComposerSubmit() {
const {
actions: { submit },
} = use(ComposerContext)
return <Button onPress={submit}>Send</Button>
}
// 컴파운드 컴포넌트로 내보내기
const Composer = {
Provider: ComposerProvider,
Frame: ComposerFrame,
Input: ComposerInput,
Submit: ComposerSubmit,
Header: ComposerHeader,
Footer: ComposerFooter,
Attachments: ComposerAttachments,
Formatting: ComposerFormatting,
Emojis: ComposerEmojis,
}
사용법:
<Composer.Provider state={state} actions={actions} meta={meta}>
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
</Composer.Provider>
사용하는 쪽이 정확히 필요한 것만 명시적으로 조합합니다. 숨겨진 조건문이 없어요. 그리고 state, actions, meta는 부모 프로바이더가 의존성 주입하므로, 같은 컴포넌트 구조를 여러 용도로 쓸 수 있습니다.
2. 상태 관리 (State Management)
임팩트: MEDIUM
합성된 컴포넌트 전반에서 상태를 끌어올리고 공유 컨텍스트를 관리하는 패턴입니다.
2.1 상태 관리를 UI에서 분리하세요
임팩트: MEDIUM (UI 변경 없이 상태 구현 교체 가능)
상태가 어떻게 관리되는지는 프로바이더 컴포넌트만 알아야 합니다. UI 컴포넌트는 컨텍스트 인터페이스를 소비할 뿐 — 상태가 useState에서 오는지, Zustand에서 오는지, 서버 동기화에서 오는지 모릅니다.
잘못된 예: UI가 상태 구현에 결합됨
function ChannelComposer({ channelId }: { channelId: string }) {
// UI 컴포넌트가 전역 상태 구현을 알고 있음
const state = useGlobalChannelState(channelId)
const { submit, updateInput } = useChannelSync(channelId)
return (
<Composer.Frame>
<Composer.Input
value={state.input}
onChange={(text) => sync.updateInput(text)}
/>
<Composer.Submit onPress={() => sync.submit()} />
</Composer.Frame>
)
}
올바른 예: 상태 관리가 프로바이더에 격리됨
// 프로바이더가 모든 상태 관리 세부사항을 처리
function ChannelProvider({
channelId,
children,
}: {
channelId: string
children: React.ReactNode
}) {
const { state, update, submit } = useGlobalChannel(channelId)
const inputRef = useRef(null)
return (
<Composer.Provider
state={state}
actions={{ update, submit }}
meta={{ inputRef }}
>
{children}
</Composer.Provider>
)
}
// UI 컴포넌트는 컨텍스트 인터페이스만 알고 있음
function ChannelComposer() {
return (
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<Composer.Footer>
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
)
}
// 사용
function Channel({ channelId }: { channelId: string }) {
return (
<ChannelProvider channelId={channelId}>
<ChannelComposer />
</ChannelProvider>
)
}
다른 프로바이더, 같은 UI:
// 일시적인 폼에는 로컬 상태
function ForwardMessageProvider({ children }) {
const [state, setState] = useState(initialState)
const forwardMessage = useForwardMessage()
return (
<Composer.Provider
state={state}
actions={{ update: setState, submit: forwardMessage }}
>
{children}
</Composer.Provider>
)
}
// 채널에는 전역 동기화 상태
function ChannelProvider({ channelId, children }) {
const { state, update, submit } = useGlobalChannel(channelId)
return (
<Composer.Provider state={state} actions={{ update, submit }}>
{children}
</Composer.Provider>
)
}
같은 Composer.Input 컴포넌트가 두 프로바이더 모두에서 동작합니다 — 구현이 아니라 컨텍스트 인터페이스에만 의존하기 때문이에요.
2.2 의존성 주입을 위한 제네릭 컨텍스트 인터페이스를 정의하세요
임팩트: HIGH (유스케이스를 넘나드는 의존성 주입 가능한 상태)
컴포넌트 컨텍스트에 state, actions, meta 세 부분으로 이루어진 제네릭 인터페이스를 정의하세요. 이 인터페이스는 어떤 프로바이더든 구현할 수 있는 계약(contract)이라서, 같은 UI 컴포넌트가 완전히 다른 상태 구현과 함께 동작할 수 있게 합니다.
핵심 원칙: 상태를 끌어올리고, 내부 요소를 합성하고, 상태를 의존성 주입 가능하게 만드세요.
잘못된 예: UI가 특정 상태 구현에 결합됨
function ComposerInput() {
// 특정 훅에 강하게 결합됨
const { input, setInput } = useChannelComposerState()
return <TextInput value={input} onChangeText={setInput} />
}
올바른 예: 제네릭 인터페이스가 의존성 주입을 가능하게 함
// 어떤 프로바이더든 구현할 수 있는 '제네릭' 인터페이스 정의
interface ComposerState {
input: string
attachments: Attachment[]
isSubmitting: boolean
}
interface ComposerActions {
update: (updater: (state: ComposerState) => ComposerState) => void
submit: () => void
}
interface ComposerMeta {
inputRef: React.RefObject<TextInput>
}
interface ComposerContextValue {
state: ComposerState
actions: ComposerActions
meta: ComposerMeta
}
const ComposerContext = createContext<ComposerContextValue | null>(null)
UI 컴포넌트는 구현이 아니라 인터페이스를 소비합니다:
function ComposerInput() {
const {
state,
actions: { update },
meta,
} = use(ComposerContext)
// 이 컴포넌트는 인터페이스를 구현한 '어떤' 프로바이더와도 동작함
return (
<TextInput
ref={meta.inputRef}
value={state.input}
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
/>
)
}
서로 다른 프로바이더가 같은 인터페이스를 구현합니다:
// 프로바이더 A: 일시적인 폼을 위한 로컬 상태
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState(initialState)
const inputRef = useRef(null)
const submit = useForwardMessage()
return (
<ComposerContext
value={{
state,
actions: { update: setState, submit },
meta: { inputRef },
}}
>
{children}
</ComposerContext>
)
}
// 프로바이더 B: 채널을 위한 전역 동기화 상태
function ChannelProvider({ channelId, children }: Props) {
const { state, update, submit } = useGlobalChannel(channelId)
const inputRef = useRef(null)
return (
<ComposerContext
value={{
state,
actions: { update, submit },
meta: { inputRef },
}}
>
{children}
</ComposerContext>
)
}
같은 합성 UI가 둘 다에서 동작합니다:
// ForwardMessageProvider(로컬 상태)와 동작
<ForwardMessageProvider>
<Composer.Frame>
<Composer.Input />
<Composer.Submit />
</Composer.Frame>
</ForwardMessageProvider>
// ChannelProvider(전역 동기화 상태)와 동작
<ChannelProvider channelId="abc">
<Composer.Frame>
<Composer.Input />
<Composer.Submit />
</Composer.Frame>
</ChannelProvider>
컴포넌트 밖의 커스텀 UI도 상태와 액션에 접근할 수 있습니다:
function ForwardMessageDialog() {
return (
<ForwardMessageProvider>
<Dialog>
{/* 컴포저 UI */}
<Composer.Frame>
<Composer.Input placeholder="Add a message, if you'd like." />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
</Composer.Footer>
</Composer.Frame>
{/* 컴포저 '밖'이지만 프로바이더 '안'에 있는 커스텀 UI */}
<MessagePreview />
{/* 다이얼로그 하단의 액션 */}
<DialogActions>
<CancelButton />
<ForwardButton />
</DialogActions>
</Dialog>
</ForwardMessageProvider>
)
}
// 이 버튼은 Composer.Frame 밖에 있지만 컨텍스트 기반으로 여전히 submit할 수 있어요!
function ForwardButton() {
const {
actions: { submit },
} = use(ComposerContext)
return <Button onPress={submit}>Forward</Button>
}
// 이 프리뷰는 Composer.Frame 밖에 있지만 컴포저의 상태를 읽을 수 있어요!
function MessagePreview() {
const { state } = use(ComposerContext)
return <Preview message={state.input} attachments={state.attachments} />
}
중요한 건 시각적 중첩이 아니라 프로바이더 경계입니다. 공유 상태가 필요한 컴포넌트가 꼭 Composer.Frame 안에 있을 필요는 없어요. 프로바이더 안에만 있으면 됩니다.
ForwardButton과 MessagePreview는 시각적으로는 컴포저 박스 안에 없지만, 여전히 컴포저의 상태와 액션에 접근할 수 있습니다. 이것이 상태를 프로바이더로 끌어올리는 힘이에요.
UI는 조합해서 쓰는 재사용 가능한 조각들입니다. 상태는 프로바이더가 의존성 주입합니다. 프로바이더를 바꾸고, UI는 유지하세요.
2.3 상태를 프로바이더 컴포넌트로 끌어올리세요
임팩트: HIGH (컴포넌트 경계 밖에서 상태 공유 가능)
상태 관리를 전용 프로바이더 컴포넌트로 옮기세요. 메인 UI 밖의 형제 컴포넌트가 prop 드릴링이나 어색한 ref 없이 상태에 접근하고 수정할 수 있게 됩니다.
잘못된 예: 상태가 컴포넌트 안에 갇힘
function ForwardMessageComposer() {
const [state, setState] = useState(initialState)
const forwardMessage = useForwardMessage()
return (
<Composer.Frame>
<Composer.Input />
<Composer.Footer />
</Composer.Frame>
)
}
// 문제: 이 버튼이 컴포저 상태에 어떻게 접근하죠?
function ForwardMessageDialog() {
return (
<Dialog>
<ForwardMessageComposer />
<MessagePreview /> {/* 컴포저 상태가 필요함 */}
<DialogActions>
<CancelButton />
<ForwardButton /> {/* submit을 호출해야 함 */}
</DialogActions>
</Dialog>
)
}
잘못된 예: useEffect로 상태를 위로 동기화
function ForwardMessageDialog() {
const [input, setInput] = useState('')
return (
<Dialog>
<ForwardMessageComposer onInputChange={setInput} />
<MessagePreview input={input} />
</Dialog>
)
}
function ForwardMessageComposer({ onInputChange }) {
const [state, setState] = useState(initialState)
useEffect(() => {
onInputChange(state.input) // 변경될 때마다 동기화 😬
}, [state.input])
}
잘못된 예: submit 시점에 ref에서 상태 읽기
function ForwardMessageDialog() {
const stateRef = useRef(null)
return (
<Dialog>
<ForwardMessageComposer stateRef={stateRef} />
<ForwardButton onPress={() => submit(stateRef.current)} />
</Dialog>
)
}
올바른 예: 상태를 프로바이더로 끌어올림
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState(initialState)
const forwardMessage = useForwardMessage()
const inputRef = useRef(null)
return (
<Composer.Provider
state={state}
actions={{ update: setState, submit: forwardMessage }}
meta={{ inputRef }}
>
{children}
</Composer.Provider>
)
}
function ForwardMessageDialog() {
return (
<ForwardMessageProvider>
<Dialog>
<ForwardMessageComposer />
<MessagePreview /> {/* 커스텀 컴포넌트가 상태와 액션에 접근 가능 */}
<DialogActions>
<CancelButton />
<ForwardButton /> {/* 커스텀 컴포넌트가 상태와 액션에 접근 가능 */}
</DialogActions>
</Dialog>
</ForwardMessageProvider>
)
}
function ForwardButton() {
const { actions } = use(Composer.Context)
return <Button onPress={actions.submit}>Forward</Button>
}
ForwardButton은 Composer.Frame 밖에 있지만 프로바이더 안에 있으므로 여전히 submit 액션에 접근할 수 있습니다. 일회성 컴포넌트라도 UI 밖에서 컴포저의 상태와 액션에 접근할 수 있어요.
핵심 인사이트: 공유 상태가 필요한 컴포넌트들이 시각적으로 서로 중첩돼 있을 필요는 없습니다 — 같은 프로바이더 안에만 있으면 됩니다.
3. 구현 패턴 (Implementation Patterns)
임팩트: MEDIUM
컴파운드 컴포넌트와 컨텍스트 프로바이더를 구현하는 구체적인 기법입니다.
3.1 명시적인 컴포넌트 변형을 만드세요
임팩트: MEDIUM (자기 문서화 코드, 숨겨진 조건문 없음)
불리언 prop이 잔뜩 달린 컴포넌트 하나 대신, 명시적인 변형(variant) 컴포넌트들을 만드세요. 각 변형은 자기가 필요한 조각들을 조합합니다. 코드가 스스로를 문서화해요.
잘못된 예: 하나의 컴포넌트, 많은 모드
// 이 컴포넌트는 실제로 뭘 렌더링하는 걸까요?
<Composer
isThread
isEditing={false}
channelId='abc'
showAttachments
showFormatting={false}
/>
올바른 예: 명시적 변형
// 무엇을 렌더링하는지 즉시 명확함
<ThreadComposer channelId="abc" />
// 또는
<EditMessageComposer messageId="xyz" />
// 또는
<ForwardMessageComposer messageId="123" />
각 구현은 고유하고 명시적이며 자기 완결적입니다. 그러면서도 공유 부품을 각자 사용할 수 있어요.
구현:
function ThreadComposer({ channelId }: { channelId: string }) {
return (
<ThreadProvider channelId={channelId}>
<Composer.Frame>
<Composer.Input />
<AlsoSendToChannelField channelId={channelId} />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
</ThreadProvider>
)
}
function EditMessageComposer({ messageId }: { messageId: string }) {
return (
<EditMessageProvider messageId={messageId}>
<Composer.Frame>
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.CancelEdit />
<Composer.SaveEdit />
</Composer.Footer>
</Composer.Frame>
</EditMessageProvider>
)
}
function ForwardMessageComposer({ messageId }: { messageId: string }) {
return (
<ForwardMessageProvider messageId={messageId}>
<Composer.Frame>
<Composer.Input placeholder="Add a message, if you'd like." />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.Mentions />
</Composer.Footer>
</Composer.Frame>
</ForwardMessageProvider>
)
}
각 변형은 다음을 명시합니다:
- 어떤 프로바이더/상태를 쓰는지
- 어떤 UI 요소를 포함하는지
- 어떤 액션이 가능한지
따져봐야 할 불리언 prop 조합이 없습니다. 불가능한 상태도 없습니다.
3.2 render props보다 children 합성을 우선하세요
임팩트: MEDIUM (더 깔끔한 합성, 더 나은 가독성)
합성에는 renderX prop 대신 children을 쓰세요. children이 더 읽기 쉽고, 자연스럽게 합성되며, 콜백 시그니처를 이해할 필요도 없습니다.
잘못된 예: render props
function Composer({
renderHeader,
renderFooter,
renderActions,
}: {
renderHeader?: () => React.ReactNode
renderFooter?: () => React.ReactNode
renderActions?: () => React.ReactNode
}) {
return (
<form>
{renderHeader?.()}
<Input />
{renderFooter ? renderFooter() : <DefaultFooter />}
{renderActions?.()}
</form>
)
}
// 사용법이 어색하고 유연하지 않음
return (
<Composer
renderHeader={() => <CustomHeader />}
renderFooter={() => (
<>
<Formatting />
<Emojis />
</>
)}
renderActions={() => <SubmitButton />}
/>
)
올바른 예: children을 쓰는 컴파운드 컴포넌트
function ComposerFrame({ children }: { children: React.ReactNode }) {
return <form>{children}</form>
}
function ComposerFooter({ children }: { children: React.ReactNode }) {
return <footer className='flex'>{children}</footer>
}
// 사용법이 유연함
return (
<Composer.Frame>
<CustomHeader />
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<SubmitButton />
</Composer.Footer>
</Composer.Frame>
)
render props가 적절한 경우:
// 데이터를 돌려줘야 할 때는 render props가 잘 맞음
<List
data={items}
renderItem={({ item, index }) => <Item item={item} index={index} />}
/>
부모가 자식에게 데이터나 상태를 제공해야 할 때는 render props를 쓰세요. 정적인 구조를 합성할 때는 children을 쓰세요.
4. React 19 APIs
임팩트: MEDIUM
React 19 이상 전용입니다. forwardRef를 쓰지 말고, useContext() 대신 use()를 쓰세요.
4.1 React 19 API 변경 사항
임팩트: MEDIUM (더 깔끔한 컴포넌트 정의와 컨텍스트 사용)
⚠️ React 19 이상 전용. React 18 이하라면 이 규칙은 건너뛰세요.
React 19에서 ref는 이제 일반 prop이고(forwardRef 래퍼 불필요), use()가 useContext()를 대체합니다.
잘못된 예: React 19에서 forwardRef
const ComposerInput = forwardRef<TextInput, Props>((props, ref) => {
return <TextInput ref={ref} {...props} />
})
올바른 예: 일반 prop으로서의 ref
function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
return <TextInput ref={ref} {...props} />
}
잘못된 예: React 19에서 useContext
const value = useContext(MyContext)
올바른 예: useContext 대신 use
const value = use(MyContext)
use()는 useContext()와 달리 조건부로 호출할 수도 있습니다.
참고 자료 (References)
관련 글
- Next.js Best Practices - Vercel이 공개한 Next.js 개발 가이드 (전문 번역)Vercel이 공개한 Next.js 베스트 프랙티스 스킬 전문 번역입니다. 파일 컨벤션, RSC 경계, 데이터 패턴, 에러 처리, 이미지·폰트 최적화 등 19개 문서를 한글로 번역했어요.
- React Best Practices - Vercel 엔지니어링 팀의 성능 최적화 가이드 (전문 번역)Vercel 엔지니어링 팀이 공개한 React/Next.js 성능 최적화 가이드 전문 번역입니다. 8개 카테고리, 45개 이상의 규칙을 영향도 기준으로 정리했습니다.
- TanStack Query의 Query Key Factory 패턴TanStack Query의 Query Key를 문자열로 직접 쓰면 오타와 중복, 관리 실패가 발생합니다. 이 문제를 해결하는 Query Key Factory 패턴을 단순한 형태부터 queryOptions()와 결합한 형태까지 단계별로 알아봅니다.