대규모 앱을 위한 고급 합성 패턴과 제네릭 기반 타입 설계.
실전 설계 패턴과 타입 안전성
입문 과정에서 컴포넌트 합성과 커스텀 훅의 기초를 익혔다면, 이제 그 개념을 라이브러리 수준으로 끌어올릴 차례입니다. 실제 디자인 시스템이나 대규모 애플리케이션에서는 "어떻게 동작하는가"를 넘어서 "어떻게 안전하게 확장되는가"가 더 중요한 물음이 됩니다.
이 레슨에서는 복합 컴포넌트(Compound Components) 패턴부터 제네릭 타입을 활용한 폴리모픽 컴포넌트까지, TypeScript 타입 시스템을 적극 활용해 오용을 컴파일 타임에 차단하는 설계 기법을 다룹니다. 동시에 과도한 추상화가 오히려 유지보수를 어렵게 만드는 함정을 피하는 기준도 함께 살펴봅니다.
학습 목표
- Compound Components 패턴을 Context와 TypeScript로 타입 안전하게 구현할 수 있다.
- 제어(Controlled)와 비제어(Uncontrolled)를 모두 지원하는 유연한 컴포넌트 API를 설계할 수 있다.
- 제네릭 컴포넌트와
asprop을 이용한 폴리모픽 타이핑을 이해하고 적용할 수 있다. - Headless UI 패턴으로 로직과 렌더링을 완전히 분리하는 훅 추상화를 구현할 수 있다.
- **
forwardRef**와 폴리모픽 ref를 타입 안전하게 다룰 수 있다.
복합 컴포넌트(Compound Components)와 슬롯 패턴
입문편에서 children을 통한 합성을 배웠습니다. 복합 컴포넌트는 이를 한 단계 발전시켜, 관련된 여러 서브컴포넌트가 부모의 상태를 암묵적으로 공유하도록 설계합니다. <select>와 <option>, Radix UI의 <Tabs.Root>와 <Tabs.Trigger>가 대표적인 예입니다.
핵심은 Context로 내부 상태를 공유하되, 공개 API는 서브컴포넌트의 조합으로만 표현한다는 점입니다.
// tabs.tsx — Context 기반 Compound Component
import React, { createContext, useContext, useState, ReactNode } from "react";
interface TabsContextValue {
activeTab: string;
setActiveTab: (id: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Tabs 서브컴포넌트는 <Tabs> 내부에서만 사용해야 합니다.");
return ctx;
}
// --- 루트 ---
interface TabsProps {
defaultTab?: string;
children: ReactNode;
}
function Tabs({ defaultTab = "", children }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// --- 서브컴포넌트 ---
function TabList({ children }: { children: ReactNode }) {
return <div role="tablist">{children}</div>;
}
interface TabProps {
id: string;
children: ReactNode;
}
function Tab({ id, children }: TabProps) {
const { activeTab, setActiveTab } = useTabs();
return (
<button
role="tab"
aria-selected={activeTab === id}
onClick={() => setActiveTab(id)}
>
{children}
</button>
);
}
interface TabPanelProps {
id: string;
children: ReactNode;
}
function TabPanel({ id, children }: TabPanelProps) {
const { activeTab } = useTabs();
if (activeTab !== id) return null;
return <div role="tabpanel">{children}</div>;
}
// --- 네임스페이스로 묶기 (tree-shaking에는 불리할 수 있으나 발견성과 선언적 API에 유리) ---
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// 세밀한 tree-shaking이 중요하면 개별 named export를 병행하세요.
export { Tabs, TabList, Tab, TabPanel };
사용 시에는 부모-자식 간 props 드릴링 없이 자연스러운 선언적 구조가 만들어집니다.
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Tab id="overview">개요</Tabs.Tab>
<Tabs.Tab id="details">상세</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="overview">개요 내용</Tabs.Panel>
<Tabs.Panel id="details">상세 내용</Tabs.Panel>
</Tabs>
💡 TIP 서브컴포넌트를
useTabs()밖에서 렌더링하면 런타임 에러가 발생하도록null체크를 필수로 두세요. 타입만으로는 잘못된 사용을 막을 수 없습니다.
제어/비제어를 모두 지원하는 유연한 컴포넌트 API
컴포넌트를 라이브러리 수준으로 배포할 때는 소비자가 상태를 직접 관리하고 싶을 수도(제어), 그냥 갖다 쓰고 싶을 수도(비제어) 있습니다. 두 모드를 동시에 지원하려면 값이 외부에서 주어졌는지 아닌지를 런타임에 감지하는 패턴이 필요합니다.
useControllable 훅이 이 패턴의 핵심입니다.
// use-controllable.ts
import { useState, useCallback } from "react";
type UseControllableProps<T> = {
value?: T; // 외부에서 전달되면 제어 모드
defaultValue?: T; // 내부 초기값 (비제어 모드)
onChange?: (val: T) => void;
};
export function useControllable<T>({
value: controlledValue,
defaultValue,
onChange,
}: UseControllableProps<T>) {
const isControlled = controlledValue !== undefined;
const [internalValue, setInternalValue] = useState<T | undefined>(defaultValue);
const value = isControlled ? controlledValue : internalValue;
const setValue = useCallback(
(next: T) => {
if (!isControlled) setInternalValue(next);
onChange?.(next);
},
[isControlled, onChange]
);
return [value, setValue] as const;
}
이 훅을 사용하면 컴포넌트 자체는 value가 제어인지 비제어인지 신경 쓰지 않아도 됩니다.
// select.tsx
interface SelectProps {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
options: { label: string; value: string }[];
}
function Select({ value, defaultValue, onChange, options }: SelectProps) {
const [selected, setSelected] = useControllable({
value,
defaultValue: defaultValue ?? options[0]?.value,
onChange,
});
return (
<select value={selected} onChange={(e) => setSelected(e.target.value)}>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
// ✅ 비제어 — 내부에서 상태 관리
<Select defaultValue="kr" options={countryOptions} />
// ✅ 제어 — 외부에서 상태 관리
<Select value={country} onChange={setCountry} options={countryOptions} />
⚠️ 주의 제어 모드와 비제어 모드를 렌더 간에 전환하는 것은 React가 경고를 발생시킵니다.
useControllable내부에서isControlled값의 변경을 감지해 개발 환경에서 경고를 추가하는 것이 좋습니다.
제네릭 컴포넌트와 폴리모픽 as prop 타이핑
버튼을 <a> 태그나 <Link>로도 렌더링하고 싶을 때, 단순히 as?: string을 받으면 props 타입 안전성이 사라집니다. 제네릭과 React.ElementType을 결합하면 렌더링 요소에 따라 props 타입이 자동으로 좁아지는 폴리모픽 컴포넌트를 만들 수 있습니다.
// polymorphic.tsx
import React, {
ComponentPropsWithoutRef,
ElementType,
ReactNode,
} from "react";
// ---- 헬퍼 타입 ----
type AsProp<C extends ElementType> = {
as?: C;
};
// 자체 props와 요소 native props를 합치되 충돌을 제거
type PolymorphicProps<C extends ElementType, OwnProps = object> = AsProp<C> &
OwnProps &
Omit<ComponentPropsWithoutRef<C>, keyof AsProp<C> | keyof OwnProps>;
// ---- 실제 컴포넌트 ----
type ButtonOwnProps = {
variant?: "primary" | "secondary";
children: ReactNode;
};
function Button<C extends ElementType = "button">({
as,
variant = "primary",
children,
...rest
}: PolymorphicProps<C, ButtonOwnProps>) {
const Component = as ?? "button";
return (
<Component
className={`btn btn-${variant}`}
{...rest}
>
{children}
</Component>
);
}
이제 렌더링 요소에 맞는 props만 허용됩니다.
// ✅ button의 native props가 자동으로 허용됨
<Button type="submit" variant="primary">저장</Button>
// ✅ as="a"이면 href 등 anchor의 native props가 타입 검사를 통과한다(허용된다)
// 단, AnchorHTMLAttributes는 href를 optional로 정의하므로 href 없이 써도 에러는 아니다.
// href를 정말 필수로 만들려면 OwnProps에 별도 조건부 타입을 추가해야 한다.
<Button as="a" href="/home" variant="secondary">홈으로</Button>
// ✅ Next.js Link와도 동작
<Button as={Link} href="/dashboard">대시보드</Button>
// ❌ href는 button에 없으므로 컴파일 에러
<Button href="/home">잘못된 사용</Button>
💡 TIP
ComponentPropsWithRef가 아닌ComponentPropsWithoutRef를 사용하는 이유는 ref를 별도로 처리하기 위해서입니다. ref 지원이 필요하다면 다음 섹션의forwardRef패턴을 함께 적용하세요.
훅 추상화로 로직과 표현 분리하기 (Headless UI)
Headless UI는 동작과 접근성 로직을 훅으로 분리하고, 렌더링은 전적으로 소비자에게 위임하는 패턴입니다. Radix UI, React Aria, Headless UI 라이브러리가 이 방식으로 구현되어 있습니다.
토글(Toggle) 컴포넌트를 예로 들면, useToggle 훅이 모든 로직을 담고 렌더링은 호출자가 결정합니다.
// use-toggle.ts — Headless 로직 훅
import { useState, useCallback, KeyboardEvent } from "react";
interface UseToggleProps {
defaultPressed?: boolean;
pressed?: boolean;
onPressedChange?: (pressed: boolean) => void;
disabled?: boolean;
}
export function useToggle({
defaultPressed = false,
pressed: controlledPressed,
onPressedChange,
disabled = false,
}: UseToggleProps = {}) {
const [internalPressed, setInternalPressed] = useState(defaultPressed);
const isControlled = controlledPressed !== undefined;
const pressed = isControlled ? controlledPressed : internalPressed;
const toggle = useCallback(() => {
if (disabled) return;
const next = !pressed;
if (!isControlled) setInternalPressed(next);
onPressedChange?.(next);
}, [disabled, isControlled, pressed, onPressedChange]);
// WAI-ARIA 키보드 접근성
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
toggle();
}
},
[toggle]
);
return {
pressed,
disabled,
// 소비자가 임의 요소에 스프레드할 수 있는 props 묶음
getToggleProps: () => ({
role: "button" as const,
"aria-pressed": pressed,
"aria-disabled": disabled || undefined,
tabIndex: disabled ? -1 : 0,
onClick: toggle,
onKeyDown: handleKeyDown,
}),
};
}
소비자는 훅을 받아 원하는 UI를 자유롭게 렌더링합니다.
// 사용 예 — 완전히 다른 두 가지 UI가 같은 로직을 공유
function HeartButton() {
const { pressed, getToggleProps } = useToggle({ defaultPressed: false });
return (
<span {...getToggleProps()} style={{ cursor: "pointer", fontSize: 24 }}>
{pressed ? "❤️" : "🤍"}
</span>
);
}
function DarkModeSwitch() {
const { pressed, getToggleProps } = useToggle({ defaultPressed: false });
return (
<div
{...getToggleProps()}
className={`switch ${pressed ? "switch--on" : ""}`}
>
<span className="switch__thumb" />
</div>
);
}
Headless 패턴의 진짜 가치는 동일한 접근성 로직을 여러 UI 변형에서 재사용하면서도, CSS나 마크업 구조를 완전히 소비자가 통제한다는 점입니다.
폴리모픽 ref 전달과 forwardRef 타입 안전성
forwardRef와 제네릭을 함께 사용할 때 TypeScript의 제약이 드러납니다. forwardRef는 함수에서 제네릭 타입 매개변수를 추론하지 못하기 때문에 타입 단언(cast)이 필요합니다.
실무에서 검증된 패턴은 forwardRef로 만든 컴포넌트를 제네릭 함수로 캐스팅하는 방식입니다.
// polymorphic-with-ref.tsx
import React, {
ElementType,
ComponentPropsWithRef,
ReactNode,
forwardRef,
} from "react";
type PolymorphicRef<C extends ElementType> =
ComponentPropsWithRef<C>["ref"];
type PolymorphicPropsWithRef<C extends ElementType, OwnProps = object> = {
as?: C;
ref?: PolymorphicRef<C>;
} & OwnProps &
Omit<
ComponentPropsWithRef<C>,
"as" | "ref" | keyof OwnProps
>;
type BoxComponent = <C extends ElementType = "div">(
props: PolymorphicPropsWithRef<C, { children?: ReactNode }>
) => React.ReactElement | null;
// forwardRef의 반환값을 제네릭 함수 타입으로 캐스팅
const Box = forwardRef(function BoxImpl<C extends ElementType = "div">(
{ as, children, ...rest }: PolymorphicPropsWithRef<C, { children?: ReactNode }>,
ref: React.Ref<unknown>
) {
const Component = as ?? "div";
return (
<Component ref={ref} {...rest}>
{children}
</Component>
);
}) as unknown as BoxComponent;
export { Box };
// ✅ div ref — HTMLDivElement 타입으로 추론
const divRef = useRef<HTMLDivElement>(null);
<Box ref={divRef}>컨텐츠</Box>
// ✅ button ref — HTMLButtonElement 타입으로 추론
const btnRef = useRef<HTMLButtonElement>(null);
<Box as="button" ref={btnRef} onClick={() => {}}>클릭</Box>
// ✅ input ref — value prop도 타입 검사
const inputRef = useRef<HTMLInputElement>(null);
<Box as="input" ref={inputRef} type="text" />
⚠️ 주의
as unknown as BoxComponent형태의 캐스팅은 타입 시스템의 허점처럼 보이지만,forwardRef의 구조적 한계 때문에 불가피합니다. 내부 구현이 올바른지를 철저한 통합 테스트로 보완하세요.
재사용 가능한 컴포넌트 라이브러리의 공개 API 설계
내부 구현 세부사항이 공개 API로 새어나오지 않도록 하는 것이 라이브러리 설계의 핵심입니다. 아래 표는 주요 설계 결정 기준을 정리한 것입니다.
| 상황 | 권장 패턴 | 이유 |
|---|---|---|
| 관련 서브컴포넌트가 3개 이상 | Compound Components | props 드릴링 없이 유연한 구조 |
| 상태를 외부에서도 제어 필요 | useControllable 패턴 | 제어/비제어 양쪽 소비자 지원 |
| UI 스타일이 완전히 다양해야 함 | Headless UI (훅 분리) | 로직 재사용 + 렌더링 자유도 |
| 다양한 HTML 요소로 렌더링 | Polymorphic + forwardRef | 네이티브 props + ref 타입 안전성 |
| 단순 래퍼 컴포넌트 | Props 인터페이스 + JSDoc | 불필요한 추상화 방지 |
공개 API를 설계할 때는 다음 원칙을 따르세요.
// ✅ 내부 타입은 export하지 않음
// 사용자가 내부 타입에 의존하면 리팩터링이 어려워짐
type InternalTabsState = { ... }; // export 없음
// ✅ 소비자가 필요한 타입만 명시적으로 export
export type { TabsProps, TabProps, TabPanelProps };
// ✅ 컴포넌트 자체만 default/named export
export { Tabs };
// ❌ 내부 Context를 직접 export하면 의존성이 생김
export { TabsContext }; // 피해야 함
또한 displayName을 설정하면 React DevTools에서 디버깅이 훨씬 편해집니다.
Tab.displayName = "Tabs.Tab";
TabPanel.displayName = "Tabs.Panel";
TabList.displayName = "Tabs.List";
과한 추상화를 피하는 패턴 선택 기준
디자인 패턴은 도구이지 목적이 아닙니다. 다음 질문에 하나라도 "아니오"라면 추상화 도입을 재고하세요.
1. 이 패턴 없이는 실제로 코드가 중복되거나 유지보수가 어려운가?
2. 팀원이 이 추상화를 이해하고 올바르게 확장할 수 있는가?
3. 소비자가 공개 API만 보고 어떻게 사용하는지 바로 알 수 있는가?
실무 판단 기준을 구체적으로 살펴보면 다음과 같습니다.
// ❌ 과도한 추상화 — 단순 버튼에 불필요한 패턴
function ButtonFactory<V extends string>({
variant,
renderSlot,
}: {
variant: V;
renderSlot: (props: { variant: V }) => ReactNode;
}) { ... }
// ✅ 충분한 수준의 추상화 — 명확한 API
interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
loading?: boolean;
}
function Button({ variant = "primary", size = "md", loading, ...rest }: ButtonProps) {
return (
<button
className={clsx("btn", `btn--${variant}`, `btn--${size}`)}
disabled={loading || rest.disabled}
{...rest}
/>
);
}
Rule of Three를 기억하세요. 같은 패턴이 세 곳에서 반복될 때 추상화를 고려하고, 두 곳이라면 당장 추상화하기보다 구체적인 코드를 유지하는 편이 더 유연할 수 있습니다.
요약
- Compound Components는 Context로 상태를 공유하고 서브컴포넌트를 네임스페이스로 묶어, props 드릴링 없이 유연한 선언적 구조를 만든다.
useControllable패턴으로 제어와 비제어 모드를 단일 컴포넌트에서 투명하게 지원할 수 있다.PolymorphicProps제네릭 헬퍼 타입을 활용하면asprop에 따라 네이티브 HTML props가 자동으로 좁혀지는 타입 안전한 폴리모픽 컴포넌트를 구현할 수 있다.- Headless UI 패턴은 접근성·상태 로직을 훅으로 분리해 다양한 UI 변형에서 재사용할 수 있게 한다.
forwardRef+ 제네릭 캐스팅으로 폴리모픽 컴포넌트에서도 ref 타입 안전성을 확보할 수 있다.- 패턴은 도구이므로, Rule of Three와 팀 이해도를 기준으로 도입 여부를 판단해야 한다.
연습문제
-
Accordion복합 컴포넌트를 구현하세요.<Accordion>,<Accordion.Item>,<Accordion.Trigger>,<Accordion.Content>로 구성되며, 한 번에 하나의 아이템만 열리는 동작을 Context로 관리해야 합니다. TypeScript로 서브컴포넌트가<Accordion>외부에서 사용될 경우 런타임 에러가 발생하도록 만드세요.힌트 루트 Context에서
activeId와setActiveId를 공유하고,Item은 자신의id를 별도 Context로 하위에 전달하세요. -
useControllable훅을 이용해Rating컴포넌트(별점 1~5)를 구현하세요.value/onChange전달 시 제어 모드,defaultValue전달 시 비제어 모드로 동작해야 합니다. TypeScript로 props 인터페이스를 작성하세요.힌트
value?: number,defaultValue?: number,onChange?: (val: number) => void를 선언하고useControllable에 그대로 위임하세요. -
Text폴리모픽 컴포넌트를 만들어asprop에 따라p,span,h1~h6,label등으로 렌더링되도록 하세요.variantprop(body,caption,heading)에 따라 className이 달라지며,forwardRef를 지원해야 합니다.힌트
PolymorphicPropsWithRef헬퍼 타입을 정의하고,forwardRef반환값을 제네릭 함수 타입으로 캐스팅하세요. -
드래그 가능한 리스트 항목을 위한
useDraggableheadless 훅을 설계하세요. 훅이 반환하는getDragProps()를 임의의 요소에 스프레드하면draggable,onDragStart,onDragEnd핸들러가 연결되어야 합니다. 로직과 UI가 완전히 분리된 예시를 작성하세요.힌트
isDragging상태와 drag 이벤트 핸들러를 훅 내부에서 관리하고,getDragProps로만 외부에 노출하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“React.js 심화” 강좌에 대한 댓글입니다.