정의
제네릭 컨텍스트 팩토리는 제네릭 함수 안에서 React context를 만들고, 같은 함수 안에서 만든 컴포넌트들이 그 context를 클로저로 붙잡게 하는 패턴이에요.
호출 한 번이 곧 "타입도 런타임 객체도 완전히 별개인 컴포넌트 한 세트"가 돼요. 이 패턴이 필요한 이유는 단순해요. const 선언에는 타입 파라미터를 붙일 수 없기 때문이에요.
왜 필요한가
Table.Root / Table.Head / Table.Body 처럼 조각을 조립해 쓰는 합성 컴포넌트(compound component) 를 여러 도메인이 재사용한다고 해볼게요. 주문 표는 주문 타입, 사용자 표는 사용자 타입 — 도메인마다 한 줄(Row)의 생김새가 달라요.
이 타입을 context에 실어 나르고 싶은데, 문법이 막아요.
function f<T>(x: T) {} // ✅ 함수는 타입 파라미터 가능
type Box<T> = { v: T }; // ✅ 타입 별칭도 가능
const Ctx<T> = createContext<T>(null); // ❌ const 선언은 타입 파라미터 불가
createContext(...)는 타입이 아니라 값이에요. 부르는 순간 타입이 하나로 확정돼 버려요.
우회 시도 두 개가 먼저 떠오르는데, 둘 다 탈락해요.
unknown으로 만들고 소비 지점마다 as로 변환하기 — 조각 안에 도메인 타입을 하드코딩하게 돼요. 재사용하려고 만든 컴포넌트인데 도메인에 묶여 버리니 목적이 사라져요.
도메인마다 파일 복사하기 — 표 로직 전체가 중복돼요. 버그를 고칠 때 복사본 개수만큼 고쳐야 해요.
동작 원리
해법은 제네릭 함수 안에서 context를 만들고, 같이 만든 컴포넌트들이 클로저로 그걸 붙잡는 거예요.
export function createDataTable<Row>() {
type ContextValue = {
columns: readonly ColumnDef<Row>[];
rows: readonly Row[];
getRowId: (row: Row) => string;
};
const Ctx = createContext<ContextValue | null>(null);
const useTable = () => {
const c = useContext(Ctx);
if (!c) throw new Error("Table part must be used inside Table.Root");
return c;
};
function Root<const Cols extends readonly ColumnDef<Row>[]>(props: RootProps<Row, Cols>) {
return <Ctx.Provider value={/* ... */}>{props.children}</Ctx.Provider>;
}
function Body() {
const { columns, rows, getRowId } = useTable(); // ← as 없이 Row가 살아 있어요
return <tbody>{rows.map((r) => <Row key={getRowId(r)} data={r} />)}</tbody>;
}
return { Root, Head, Body }; // Ctx 자체는 내보내지 않아요
}
여기서 벌어지는 일이 세 가지예요.
하나. Ctx는 createDataTable을 부를 때마다 새로 만들어져요. 그래서 호출 1회 = 완전히 독립된 세트 1개예요. 다른 팩토리의 조각을 섞어 쓰면 context를 못 찾아서 런타임에 throw해요.
둘. Body는 Row가 무엇인지 몰라도 돼요. 클로저로 Ctx를 붙잡고 있고, Ctx의 타입은 이미 Row로 채워져 있으니까요. as 한 줄 없이 타입이 살아 있어요.
셋. if (!c) throw 로 좁히면 ! non-null 단언 없이 타입이 ContextValue로 확정돼요. TypeScript가 throw 뒤로는 c가 null일 수 없다는 걸 알아요.
제네릭이 두 계층인 이유
Row는 팩토리를 부를 때 사람이 지정하고, 컬럼 타입 Cols는 Root가 columns prop에서 자동으로 추론해요. 계층이 나뉘는 이유는 TypeScript가 타입 인자를 일부만 지정하는 문법을 지원하지 않기 때문이에요. 한 시그니처에 둘 다 넣으면 Row를 지정하는 순간 Cols도 지정하라고 요구해요.
실무 적용
// orders/table.ts — 모듈 최상단에서 딱 한 번
export const OrderTable = createDataTable<OrderRow>();
// orders/page.tsx
<OrderTable.Root columns={cols} rows={orders} getRowId={(r) => r.orderNo}>
<OrderTable.Head />
<OrderTable.Body />
</OrderTable.Root>
다른 도메인 컬럼을 넘기면 컴파일이 막히고, 다른 팩토리의 조각을 섞으면 런타임에 throw해요.
반드시 지킬 두 가지
팩토리는 모듈 최상단에서 정확히 한 번만 불러요.
// ❌ 컴포넌트 본문 안에서 호출
function OrderPage() {
const Table = createDataTable<OrderRow>(); // 매 렌더마다 새 세트
return <Table.Root ...>...</Table.Root>;
}
이건 에러가 안 나요. context 연결도 정상이에요. Provider와 consumer가 같은 클로저에서 나왔으니까요.
진짜 문제는 다른 곳에 있어요. 매 렌더마다 Table.Root가 새로운 함수 identity를 갖게 되고, React는 그걸 다른 컴포넌트로 판단해서 언마운트하고 다시 마운트해요. 증상이 에러가 아니라 "스크롤 위치와 입력값과 포커스가 자꾸 날아감"이라 원인 찾기가 훨씬 어려워요.
반환 객체에 Context 자체를 넣지 않아요.
팩토리 안에 격리된 as 한 줄이 안전한 유일한 근거는 "Root가 그 콜백을 자기 좁은 값으로만 호출한다"예요. Ctx를 밖으로 내보내면 누구나 아무 값이나 Provider에 넣을 수 있게 되고, 그 순간 그 단언이 진짜 버그가 돼요.
트레이드오프
얻는 것은 타입 안전한 재사용이에요. 한 벌의 로직으로 도메인 수만큼의 표를 만들 수 있고, 각각은 서로 섞이지 않아요.
치르는 비용은 세 가지예요.
첫째, 함수 컴포넌트가 팩토리 안에 있어서 React DevTools에서 이름이 흐려질 수 있어요. displayName을 명시적으로 붙여 두는 게 좋아요.
둘째, 번들 분할이 어려워져요. 조각들이 한 함수 안에 묶여 있으니 Body만 lazy load 하는 식이 안 돼요.
셋째, 호출 위치 규칙이 코드로 강제되지 않아요. 모듈 최상단 호출은 관례일 뿐이라, 새 팀원이 컴포넌트 안에서 부르는 걸 타입이 막지 못해요. lint 규칙이나 리뷰로 방어해야 해요.
도메인이 하나뿐이면 이 비용을 낼 이유가 없어요. createContext<OrderTableValue>(null) 로 평범하게 쓰는 게 훨씬 읽기 쉬워요.
사용하면 안 되는 경우
- 도메인이 하나거나 둘인 경우. 팩토리 비용이 얻는 것보다 커요. 그냥 구체 타입으로 context를 만들어요.
- 조각들을 개별적으로 lazy load 해야 하는 경우. 한 함수 안에 묶여 있어서 코드 분할이 안 돼요.
- 서버 컴포넌트 경계를 넘나드는 구조. context는 클라이언트 전용이라, 팩토리를 서버 컴포넌트에서 부르면 동작하지 않아요.
- 타입 파라미터가 실제로는 안 바뀌는 경우. 제네릭으로 열어 뒀지만 모든 호출부가 같은 타입을 넣고 있다면, 그건 복잡도만 늘린 거예요.
흔한 실수
- 컴포넌트 본문 안에서 팩토리를 호출해요. 에러 없이 리마운트만 반복해요. 상태 소실 버그를 며칠 쫓게 만드는 대표적인 함정이에요.
- 반환 객체에
Ctx를 넣어요. 격리의 근거가 사라져서 내부 단언이 진짜 버그가 돼요. - 제네릭 컴포넌트에
memo를 씌워요.memo는 제네릭 컴포넌트를 비제네릭으로 만들어서 컬럼 타입 추론이 통째로 죽어요. 메모는 제네릭이 없는 말단 컴포넌트에만 써요. - 조각을
Root밖에서 쓰는 걸 타입으로 막으려 해요. 막으려면 결국 prop drilling이 돼서 합성 컴포넌트의 장점이 사라져요. 런타임 throw + 테스트로 방어하고, 남은 구멍은 문서에 적는 게 맞는 거래예요. displayName을 안 붙여요. DevTools에서 전부 익명 함수로 보여서 디버깅이 괴로워져요.
관련 개념
- derive-types-from-values —
Root가 컬럼 배열에서 정렬 키 타입을 파생하는 부분의 원리 - react-context-render-granularity — 이 context를 구독하는 조각들의 리렌더 범위 문제
- typescript-variance — context에 실린 콜백의 파라미터가 어떤 강도로 검사되는지