Published on

TypeScript 5.x 제너릭으로 API 응답 타입 안전 설계

Authors
Binance registration banner

서버와 클라이언트가 분리된 환경에서 API 응답 타입이 흔들리기 시작하면, 버그는 대개 런타임에서야 드러납니다. 대표적으로는 다음 같은 상황입니다.

  • 성공 응답은 data가 오는데, 실패 응답은 message만 오거나 errors 배열이 오기도 함
  • 어떤 API는 data가 객체, 어떤 API는 배열, 어떤 API는 null
  • 페이지네이션이 붙은 엔드포인트는 meta가 다르고, 리스트/상세가 섞이면 더 복잡해짐
  • 프런트에서 if (res.ok) { ... } 분기했는데도 타입이 좁혀지지 않아 any로 캐스팅하게 됨

TypeScript 5.x에서는 제너릭, 조건부 타입, satisfies, const 제네릭 패턴 등을 조합해 이런 문제를 상당히 깔끔하게 정리할 수 있습니다. 이 글에서는 "응답 스키마 표준화"와 "타입 좁히기(Discriminated Union)"를 중심으로, 실제 프로젝트에서 바로 쓸 수 있는 설계를 단계별로 소개합니다.

또한 Next.js 기반 프로젝트에서는 빌드/런타임 문제가 한 번에 터지는 경우가 많습니다. 타입이 흔들릴 때 디버깅 비용이 급증하는데, 이런 상황에서의 운영 관점 점검은 Next.js 14 빌드 OOM·느려짐 해결 - SWC 캐시·메모리 튜닝 같은 글과 함께 보면 전체 개발 경험을 안정화하는 데 도움이 됩니다.

1) 목표: “한 가지 응답 형태”로 통일하고 타입으로 강제하기

먼저 서버가 어떤 언어이든, 클라이언트에서는 다음 3가지를 얻고 싶습니다.

  1. 성공/실패를 한 필드로 확실히 구분한다
  2. 성공 시 data의 타입을 엔드포인트별로 정확히 추론한다
  3. 실패 시 에러 구조를 표준화해 UI/로깅이 일관되게 동작한다

이를 위해 가장 흔히 쓰는 방법이 Discriminated Union입니다.

  • 성공: ok: true와 함께 data 제공
  • 실패: ok: false와 함께 error 제공

이 구조는 타입 좁히기가 매우 강력하게 동작합니다.

2) 기본 응답 타입: ApiResult 제너릭

가장 작은 단위부터 시작해봅니다.

export type ApiError = {
  code: string;
  message: string;
  details?: unknown;
};

export type ApiSuccess<TData> = {
  ok: true;
  data: TData;
};

export type ApiFailure<TError = ApiError> = {
  ok: false;
  error: TError;
};

export type ApiResult<TData, TError = ApiError> =
  | ApiSuccess<TData>
  | ApiFailure<TError>;

이제 어떤 API든 ApiResult로 감싸면 성공/실패가 확실히 분리됩니다.

타입 좁히기 예시

function renderUser(res: ApiResult<{ id: string; name: string }>) {
  if (res.ok) {
    // res는 ApiSuccess로 좁혀짐
    return res.data.name;
  }

  // res는 ApiFailure로 좁혀짐
  return `Error: ${res.error.code} - ${res.error.message}`;
}

여기서 핵심은 ok가 판별자(discriminant)로 동작한다는 점입니다. statussuccess 같은 이름을 써도 되지만, 프로젝트 전체에서 한 가지로 통일하는 게 중요합니다.

3) 페이지네이션/리스트 응답을 제너릭으로 확장하기

대부분의 서비스는 리스트 응답에 페이지 정보가 붙습니다. 이때부터 응답 스키마가 흔들리기 시작합니다.

  • 어떤 API는 items를 쓰고
  • 어떤 API는 data가 배열이고
  • 어떤 API는 meta가 다르고
  • 어떤 API는 커서 기반

이를 통일하려면, "데이터"와 "메타"를 분리한 제너릭 래퍼가 유용합니다.

export type PageMeta = {
  page: number;
  pageSize: number;
  total: number;
};

export type Page<TItem, TMeta = PageMeta> = {
  items: TItem[];
  meta: TMeta;
};

export type ApiPageResult<TItem, TError = ApiError, TMeta = PageMeta> =
  ApiResult<Page<TItem, TMeta>, TError>;

사용 예시

type User = { id: string; name: string };

type UsersResponse = ApiPageResult<User>;

function renderUsers(res: UsersResponse) {
  if (!res.ok) return res.error.message;

  // res.data.items는 User[]
  // res.data.meta.total은 number
  return res.data.items.map((u) => u.name).join(", ");
}

이렇게 하면 리스트 응답은 항상 itemsmeta를 갖는다고 강제할 수 있어, UI 컴포넌트가 매우 단순해집니다.

4) 엔드포인트별 응답 스키마를 “맵”으로 관리하기

실무에서는 API가 수십~수백 개가 됩니다. 이때 fetchUser(): ApiResult<User> 같은 함수들을 개별로 늘리면 관리가 힘들고, 타입도 중복됩니다.

엔드포인트별로 요청/응답 타입을 한 곳에 모아두면 유지보수가 쉬워집니다.

type ApiSpec = {
  "/users/:id": {
    method: "GET";
    params: { id: string };
    response: ApiResult<{ id: string; name: string }>;
  };
  "/users": {
    method: "GET";
    query: { page?: number; pageSize?: number };
    response: ApiPageResult<{ id: string; name: string }>;
  };
};

이제 제너릭으로 path를 받으면 응답 타입이 자동으로 따라오게 만들 수 있습니다.

type Path = keyof ApiSpec;

type ResponseOf<TPath extends Path> = ApiSpec[TPath]["response"];

type MethodOf<TPath extends Path> = ApiSpec[TPath]["method"];

타입 안전한 API 호출 함수

아래 예시는 런타임 구현은 단순화했지만, 타입 구조는 실무에서도 그대로 통합니다.

async function apiFetch<TPath extends Path>(
  path: TPath,
  options: {
    method: MethodOf<TPath>;
    // 필요하면 params/query/body를 ApiSpec에서 끌어오도록 확장
  }
): Promise<ResponseOf<TPath>> {
  const res = await fetch(path, { method: options.method });

  // 실제로는 status code, json parse 에러 등을 처리해야 함
  return (await res.json()) as ResponseOf<TPath>;
}

async function example() {
  const res = await apiFetch("/users", { method: "GET" });

  if (res.ok) {
    res.data.items[0]?.id;
  }
}

이 패턴의 장점은 다음과 같습니다.

  • 엔드포인트 추가 시 ApiSpec에만 정의하면 됨
  • 오타로 잘못된 path를 넣으면 컴파일 타임에 막힘
  • 응답 타입이 자동 추론되므로 캐스팅이 줄어듦

5) 조건부 타입으로 “성공 데이터만” 뽑아내기

UI 코드에서는 실패 케이스를 처리한 뒤, 성공 데이터만 넘기고 싶을 때가 많습니다. 이때 조건부 타입이 유용합니다.

export type SuccessData<T> = T extends { ok: true; data: infer D } ? D : never;
export type FailureError<T> = T extends { ok: false; error: infer E } ? E : never;

활용 예시

type UsersRes = ApiPageResult<{ id: string; name: string }>;

type UsersData = SuccessData<UsersRes>; // Page<{id,name}>

이렇게 뽑아낸 타입은 컴포넌트 props나 상태 타입을 정의할 때 특히 유용합니다.

6) satisfies로 서버 목업/픽스처의 타입 품질 올리기

TypeScript 5.x에서 자주 쓰는 패턴 중 하나가 satisfies입니다. 목업 데이터를 만들 때 "타입을 만족하는지"만 검사하고, 값의 리터럴 타입은 유지할 수 있습니다.

const mockSuccess = {
  ok: true,
  data: {
    id: "u_1",
    name: "Ada",
  },
} satisfies ApiResult<{ id: string; name: string }>;

const mockFailure = {
  ok: false,
  error: {
    code: "NOT_FOUND",
    message: "User not found",
  },
} satisfies ApiResult<{ id: string; name: string }>;
  • as ApiResult<...> 캐스팅보다 안전합니다
  • 오타 필드가 있거나 필수 필드가 빠지면 즉시 컴파일 에러가 납니다

7) 런타임 검증: 타입만 믿지 말고 “경계”에서 검증하기

제너릭으로 타입을 잘 설계해도, 네트워크 경계에서는 결국 런타임 값이 들어옵니다. 즉, 서버 버그나 프록시/캐시 이슈로 스키마가 깨질 수 있습니다.

권장 전략은 다음과 같습니다.

  • fetch 직후 JSON을 파싱한 다음
  • 스키마 검증 도구(예: zod, valibot 등)로 런타임 검증
  • 검증 실패 시 ok: false로 변환해 표준 에러로 래핑

간단한 형태로는 아래처럼 "타입 가드"를 둘 수도 있습니다.

function isApiResult(x: unknown): x is { ok: boolean } {
  return typeof x === "object" && x !== null && "ok" in x;
}

async function safeJson<T>(res: Response): Promise<ApiResult<T>> {
  try {
    const json: unknown = await res.json();

    if (!isApiResult(json)) {
      return {
        ok: false,
        error: { code: "BAD_SHAPE", message: "Invalid response shape" },
      };
    }

    return json as ApiResult<T>;
  } catch {
    return {
      ok: false,
      error: { code: "JSON_PARSE", message: "Failed to parse JSON" },
    };
  }
}

여기서 중요한 점은 "표준 실패 형태"로 귀결시키는 것입니다. 그래야 호출부는 언제나 if (res.ok) 패턴으로 처리할 수 있습니다.

운영 환경에서는 네트워크/외부 API의 불안정성 때문에 재시도나 폴백이 필요할 때가 많습니다. 이런 설계를 응답 타입과 함께 묶어두면 장애 대응이 쉬워집니다. 관련해서는 OpenAI Responses API 503 멈춤 - 재시도·폴백 설계도 같은 관점에서 참고할 만합니다.

8) 실전 팁: 에러 모델을 “확장 가능”하게 설계하기

처음에는 codemessage면 충분하지만, 시간이 지나면 요구가 늘어납니다.

  • 입력 검증 에러: 필드별 에러가 필요
  • 인증 에러: 로그인 유도에 필요한 정보
  • 레이트 리밋: 재시도 가능한 시간

이때 제너릭 에러 타입을 확장 포인트로 두면 좋습니다.

type ValidationError = ApiError & {
  code: "VALIDATION";
  fieldErrors: Record<string, string[]>;
};

type AuthError = ApiError & {
  code: "UNAUTHORIZED" | "FORBIDDEN";
  redirectTo?: string;
};

type AppError = ValidationError | AuthError | ApiError;

type Result<T> = ApiResult<T, AppError>;

이제 Result를 프로젝트 표준으로 쓰면, 실패 처리 UI에서 code 기반 분기가 쉬워집니다.

9) 흔한 함정과 해결책

함정 1: 성공인데 datanull인 API

이런 API가 존재한다면 ApiResult<T | null>처럼 명시적으로 표현하세요.

type MaybeUserRes = ApiResult<{ id: string; name: string } | null>;

호출부는 res.ok 이후에도 null 체크를 강제받게 됩니다.

함정 2: HTTP status와 ok가 불일치

  • HTTP 200인데 ok: false를 내려주는 서버
  • HTTP 400인데 바디는 성공 형태

가능하면 서버에서 일관되게 맞추는 게 최선이지만, 현실적으로 어렵다면 클라이언트에서 "정규화(normalize)" 계층을 두세요.

  • status code 기반으로 1차 분류
  • 바디 스키마를 검증
  • 최종적으로 ApiResult로 변환

함정 3: 엔드포인트 맵이 커지며 유지보수 지옥

ApiSpec이 커지면 파일을 도메인별로 쪼개고, type ApiSpec = SpecA & SpecB & SpecC로 합치는 방식이 실무에서 잘 통합니다.

10) 정리: “응답 타입 표준화”가 곧 생산성이다

TypeScript 5.x 제너릭으로 API 응답을 타입 안전하게 설계하는 핵심은 단순합니다.

  • 성공/실패를 Discriminated Union으로 고정한다: ok: true | false
  • 성공 데이터는 ApiResult의 제너릭으로 엔드포인트마다 정확히 표현한다
  • 페이지네이션/메타는 별도 래퍼로 통일한다
  • 엔드포인트 스펙은 맵으로 관리해 자동 추론을 얻는다
  • 런타임 경계에서는 반드시 검증하고, 최종 형태는 항상 ApiResult로 귀결시킨다

이 구조를 한 번 잡아두면, 이후 기능 추가나 리팩터링에서 "타입이 곧 문서" 역할을 하며 버그를 선제적으로 차단합니다. 특히 프런트와 백엔드가 병렬로 개발되는 환경일수록 효과가 큽니다.