Published on

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

Authors

서버와 클라이언트가 분리된 환경에서 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로 귀결시킨다

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