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

#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 런타임 검증까지 묶어 정리하겠습니다.

X