타입스크립트 + React 실전 #5 Context와 제네릭 컴포넌트

13 분 소요

#4 이벤트와 폼 타이핑 까지로 컴포넌트 안쪽의 가장 흔한 타이핑은 정리됐습니다. 이번 글은 한 단계 위 — 여러 컴포넌트가 공유하는 값(Context)여러 형태의 데이터를 받아내는 컴포넌트(제네릭) 를 다룹니다.

Context 의 타입 인자 — 시작값과 사용 시점이 다르다

createContext 는 시작값을 그대로 받아 그 타입으로 추론합니다. 문제는 시작값이 의미 있는 값이 아닐 때가 많다는 거예요. "Provider 안에서만 의미가 있고, 바깥에서는 쓰면 안 되는 값"을 어떻게 표현할까요?

세 가지 흔한 패턴이 있고, 각자의 트레이드오프가 다릅니다.

1) 의미 있는 기본값을 주는 방식

가장 단순합니다. Provider 없이도 동작하는 의미 있는 기본값을 시작값으로 주는 거예요. 테마처럼 "기본은 light, 필요하면 Provider로 override" 같은 자리에 어울립니다.

기본값이 있는 Context
import { createContext, useContext } from 'react';
 
type Theme = 'light' | 'dark';
 
const ThemeContext = createContext<Theme>('light');
 
function useTheme() {
  return useContext(ThemeContext);
}
 
// 사용처
function Toolbar() {
  const theme = useTheme();    // Theme — 항상 의미 있는 값
  return <div className={theme}>...</div>;
}

장점은 단순함. 단점은 "Provider를 까먹어도 동작은 함" 이라 실수가 늦게 잡힙니다.

2) null 시작 + 안전한 useContext 헬퍼

Provider 안에서만 의미 있는 값(예: 사용자 정보, 카트, dispatch)일 때는 시작값을 null 로 두고, 사용처에서 한 번 검사하는 헬퍼를 만들어 둡니다. 이 패턴이 실무에서 가장 많이 쓰입니다.

null 시작 + 헬퍼
type User = { id: string; name: string };
 
const UserContext = createContext<User | null>(null);
 
export function useUser() {
  const user = useContext(UserContext);
  if (user === null) {
    throw new Error('useUser는 UserProvider 안에서만 호출하세요');
  }
  return user;     // 여기서부터 User로 좁혀짐
}
 
// 사용처
function Profile() {
  const user = useUser();      // User (null 분기 불필요)
  return <p>{user.name}</p>;
}

호출하는 컴포넌트에서 매번 if (user === null) 을 적지 않아도 되도록 헬퍼가 한 번 막아 줍니다. 헬퍼 안의 throw 가 핵심이에요 — 타입스크립트가 그 뒤로는 null 가능성을 제외해 줍니다.

3) 캐스팅으로 시작하기 — 권장하지 않음

createContext<User>({} as User) 처럼 거짓 시작값을 캐스팅하는 패턴을 종종 봅니다. 코드는 짧지만, 사용 시점에 Provider가 없으면 빈 객체가 그대로 새어 나가서 런타임 버그로 이어져요. 거의 항상 패턴 2가 더 안전합니다.

State + Dispatch 를 함께 흘릴 때 — 두 Context로 나누기

Context로 상태와 변경 함수를 함께 내려보낼 때, 상태와 dispatch를 두 Context로 나누는 게 리렌더 비용을 줄여 줍니다. dispatch만 쓰는 컴포넌트가 상태가 바뀐다고 같이 리렌더되는 걸 막을 수 있어요.

state Context + dispatch Context
type State = { count: number };
type Action = { type: 'inc' } | { type: 'dec' };
 
const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<React.Dispatch<Action> | null>(null);
 
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'inc': return { count: state.count + 1 };
    case 'dec': return { count: state.count - 1 };
  }
}
 
export function CounterProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
}
 
export function useCounterState() {
  const v = useContext(StateContext);
  if (v === null) throw new Error('CounterProvider 안에서만 사용');
  return v;
}
 
export function useCounterDispatch() {
  const v = useContext(DispatchContext);
  if (v === null) throw new Error('CounterProvider 안에서만 사용');
  return v;
}

useCounterDispatch 만 쓰는 컴포넌트는 count가 바뀌어도 리렌더되지 않습니다. 작은 앱에선 과한 최적화일 수 있지만, Context로 빈번한 상태를 흘리고 있다면 한 번쯤 고려해볼 패턴이에요.

제네릭 컴포넌트 — 어떤 데이터든 받아내는 컴포넌트

리스트, 셀렉트, 테이블 같은 컴포넌트는 "어떤 데이터든 받아서 렌더해 줘" 가 자연스러운 요구입니다. 함수에 제네릭을 쓰는 것처럼 컴포넌트에도 제네릭을 쓸 수 있어요.

제네릭 List 컴포넌트
type ListProps<T> = {
  items: readonly T[];
  renderItem: (item: T) => React.ReactNode;
  keyOf: (item: T) => string | number;
};
 
function List<T>({ items, renderItem, keyOf }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyOf(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}
 
// 사용처
type Todo = { id: string; text: string };
 
function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <List
      items={todos}
      keyOf={(t) => t.id}
      renderItem={(t) => <span>{t.text}</span>}
    />
  );
}

핵심은 function List<T>(props: ListProps<T>) 처럼 함수 키워드 뒤에 제네릭 파라미터를 적는 것입니다. 호출하는 쪽에서는 items 의 타입으로부터 T 가 자동 추론돼요.

참고: .tsx 파일에서 화살표 함수 + 제네릭은 <T> 가 JSX 태그로 잡혀 모호해질 수 있습니다. 그래서 제네릭 컴포넌트는 거의 항상 function 선언 형태를 씁니다.

제네릭 컴포넌트 + 제약(extends)

T가 어떤 모양이어야 한다는 제약을 두면 컴포넌트 본문에서 그 필드를 직접 쓸 수 있습니다.

제약 — id 필드를 강제
type WithId = { id: string };
 
type ListProps<T extends WithId> = {
  items: readonly T[];
  renderItem: (item: T) => React.ReactNode;
};
 
function List<T extends WithId>({ items, renderItem }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{renderItem(item)}</li>   // item.id 사용 가능
      ))}
    </ul>
  );
}
 
// 사용처는 keyOf 안 써도 됨
<List items={todos} renderItem={(t) => <span>{t.text}</span>} />

이 방식은 호출이 짧아져서 좋지만, 이제 List가 받는 데이터가 반드시 id: string 을 가져야 하는 제약이 생깁니다. 두 패턴 모두 자리가 있고, 유연성이 더 중요하면 keyOf 함수를, 안전성과 짧은 호출이 중요하면 제약이 어울립니다.

다형 컴포넌트 — as prop으로 태그 바꾸기

같은 디자인이지만 어떤 곳에서는 <button>, 어떤 곳에서는 <a>, 어떤 곳에서는 <Link> 로 렌더되는 컴포넌트를 종종 만듭니다. 이를 다형(polymorphic) 컴포넌트 라고 부르고, 타입스크립트로 제대로 잡으려면 손이 좀 갑니다.

가장 단순한 형태부터 보겠습니다.

다형 컴포넌트 — 기초 형태
import type { ElementType, ComponentPropsWithoutRef } from 'react';
 
type BoxProps<E extends ElementType> = {
  as?: E;
  children?: React.ReactNode;
} & Omit<ComponentPropsWithoutRef<E>, 'as' | 'children'>;
 
function Box<E extends ElementType = 'div'>({
  as,
  children,
  ...rest
}: BoxProps<E>) {
  const Tag = as ?? 'div';
  return <Tag {...rest}>{children}</Tag>;
}
 
// 사용처
<Box>기본 div</Box>
<Box as="a" href="/about">링크처럼</Box>
<Box as="button" onClick={() => {}}>버튼처럼</Box>

작동 원리를 한 줄씩 풀면:

  1. E extends ElementType — E는 HTML 태그 이름('div', 'a', ...)이거나 컴포넌트 타입.
  2. ComponentPropsWithoutRef<E> — 그 태그/컴포넌트의 모든 props를 가져옴.
  3. Omit<..., 'as' | 'children'> — 우리가 따로 정의한 prop과 겹치지 않게 빼냄.
  4. 디폴트 제네릭 E = 'div'<Box> 만 적었을 때 타입이 div로 잡히게.

이 패턴 하나로 as="a" 일 때는 href 자동완성, as="button" 일 때는 onClick 자동완성이 모두 정확하게 동작합니다.

다형 컴포넌트는 정말 필요할 때만

이 패턴은 강력하지만 타입이 빠르게 복잡해지고, 에디터의 자동완성이 무거워질 수 있습니다. 디자인 시스템 라이브러리 입장에서는 가치가 크지만, 일반 앱 코드라면 as 대신 Button/LinkButton 두 컴포넌트를 따로 만드는 쪽이 읽기 좋을 때가 많아요. 트레이드오프를 의식하고 쓰세요.

제네릭 hook — 살짝만

같은 제네릭 패턴이 hook에도 적용됩니다. 자주 쓰는 예가 "API 응답을 들고 있는 hook" 인데, 이건 다음 글(#6 fetch와 API 응답 타이핑)에서 본격적으로 다룰게요. 미리 모양만 보면:

제네릭 hook 미리보기
function useResource<T>(url: string): { data: T | null; loading: boolean } {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    fetch(url)
      .then((r) => r.json() as Promise<T>)
      .then((d) => {
        setData(d);
        setLoading(false);
      });
  }, [url]);
 
  return { data, loading };
}
 
// 사용처
const { data } = useResource<User>('/api/me');
// data: User | null

이 코드의 r.json() as Promise<T> 가 사실 가장 위험한 부분입니다. 서버가 진짜 T 모양을 줬는지 검증한 적이 없거든요. 다음 글에서 zod로 이 자리를 안전하게 메우는 방법을 다룹니다.

마무리

이번 글에서는 다음을 정리했습니다.

  • Context 시작값은 null + 헬퍼 패턴이 실무 기본값
  • State와 dispatch를 두 Context로 나누면 리렌더가 줄어듦
  • 제네릭 컴포넌트는 function List<T>(...) 형태. 화살표 함수는 JSX와 충돌
  • 호출 단축이 필요하면 T extends WithId 같은 제약을 둠
  • 다형 컴포넌트는 as prop + ComponentPropsWithoutRef<E> 조합. 강력하지만 비용 있음

다음 글(#6 fetch와 API 응답 타이핑)에서는 시리즈 마지막으로, 외부 API에서 온 데이터를 어떻게 타입스크립트 안에서 안전하게 다룰지 — 제네릭 fetcher와 zod 런타임 검증까지 묶어 정리합니다.