- Published on
TypeScript 5.x 제너릭으로 API 응답 타입 안전 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버와 클라이언트가 분리된 환경에서 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가지를 얻고 싶습니다.
- 성공/실패를 한 필드로 확실히 구분한다
- 성공 시
data의 타입을 엔드포인트별로 정확히 추론한다 - 실패 시 에러 구조를 표준화해 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)로 동작한다는 점입니다. status나 success 같은 이름을 써도 되지만, 프로젝트 전체에서 한 가지로 통일하는 게 중요합니다.
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(", ");
}
이렇게 하면 리스트 응답은 항상 items와 meta를 갖는다고 강제할 수 있어, 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) 실전 팁: 에러 모델을 “확장 가능”하게 설계하기
처음에는 code와 message면 충분하지만, 시간이 지나면 요구가 늘어납니다.
- 입력 검증 에러: 필드별 에러가 필요
- 인증 에러: 로그인 유도에 필요한 정보
- 레이트 리밋: 재시도 가능한 시간
이때 제너릭 에러 타입을 확장 포인트로 두면 좋습니다.
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: 성공인데 data가 null인 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로 귀결시킨다
이 구조를 한 번 잡아두면, 이후 기능 추가나 리팩터링에서 "타입이 곧 문서" 역할을 하며 버그를 선제적으로 차단합니다. 특히 프런트와 백엔드가 병렬로 개발되는 환경일수록 효과가 큽니다.