JSON에서 TypeScript로: API 응답에서 타입 생성하기
TypeScript에서 REST API를 호출하고 응답을 any로 타이핑한 적이 있다면, 그 뒤에 따르는 고통을 알 것입니다. 자동완성이 사라집니다. 오타가 슬며시 스며듭니다. string이라고 생각했던 필드가 null로 밝혀지고, 앱이 프로덕션에서 충돌합니다.
해결책은? JSON 응답을 적절한 TypeScript 타입으로 변환하는 것입니다. 이 가이드에서는 수동 매핑, 자동화 도구, 그리고 API 통합을 견고하게 유지하는 모범 사례를 살펴보겠습니다.
JSON에서 TypeScript 타입이 중요한 이유
TypeScript의 타입 시스템은 오류가 사용자에게 도달하기 전에 잡아내기 위해 존재합니다. API 응답을 정확한 타입으로 모델링하면 세 가지 일이 발생합니다:
- 자동완성이 모든 곳에서 작동합니다. 에디터가 데이터의 형태를 알기 때문에 필드 이름을 추측하지 않아도 됩니다.
- 리팩토링이 안전해집니다. 속성 이름을 변경하면 컴파일러가 깨지는 모든 곳을 알려줍니다.
- 런타임 오류가 줄어듭니다. 앱 깊은 곳이 아닌 경계에서
null,undefined, 예상치 못한 형태를 처리합니다.
타입이 없으면 본질적으로 추가 단계가 있는 JavaScript를 작성하는 것입니다. 타입이 있으면 코드와 그것이 소비하는 데이터 사이의 살아있는 계약을 얻게 됩니다.
수동 JSON-to-TypeScript 매핑
기본부터 시작하겠습니다. JSON 객체가 주어졌을 때, 어떻게 TypeScript 인터페이스로 변환할까요?
기본 객체
API에서 반환된 간단한 사용자 응답입니다:
{
"id": 42,
"username": "jdoe",
"email": "jdoe@example.com",
"isActive": true
}
대응하는 TypeScript 인터페이스:
interface User {
id: number;
username: string;
email: string;
isActive: boolean;
}
각 JSON 값은 TypeScript 프리미티브에 매핑됩니다: number, string, 또는 boolean. 객체 키는 속성 이름이 됩니다.
중첩 객체
실제 API는 평면 구조를 거의 반환하지 않습니다. 주소가 있는 사용자를 생각해 보세요:
{
"id": 42,
"username": "jdoe",
"address": {
"street": "123 Main St",
"city": "Springfield",
"zipCode": "62704",
"country": "US"
}
}
중첩 객체를 자체 인터페이스로 분리합니다:
interface Address {
street: string;
city: string;
zipCode: string;
country: string;
}
interface User {
id: number;
username: string;
address: Address;
}
이렇게 하면 각 타입이 집중적이고 재사용 가능해집니다. 다른 엔드포인트도 Address를 반환한다면 같은 인터페이스를 사용합니다.
배열
필드에 배열이 포함된 경우 요소 타입을 주석으로 달아줍니다:
{
"id": 42,
"username": "jdoe",
"roles": ["admin", "editor"],
"posts": [
{ "id": 1, "title": "Hello World" },
{ "id": 2, "title": "TypeScript Tips" }
]
}
interface Post {
id: number;
title: string;
}
interface User {
id: number;
username: string;
roles: string[];
posts: Post[];
}
간단한 경우에는 Type[] 구문을 사용합니다. 복잡한 제네릭의 경우 Array<Type>이 더 명확할 수 있지만, 어느 것을 선택하든 일관성이 더 중요합니다.
선택적 필드
API는 종종 있을 수도 있고 없을 수도 있는 필드를 반환합니다. ?로 표시합니다:
// 때때로 API가 bio를 반환하고, 때때로 반환하지 않습니다
{
"id": 42,
"username": "jdoe",
"bio": "Software developer"
}
interface User {
id: number;
username: string;
bio?: string; // 응답에서 빠질 수 있음
}
선택적 필드(bio?: string)는 키 자체가 완전히 없을 수 있다는 의미입니다. 이것은 키는 존재하지만 값이 null일 수 있는 nullable 필드와 다릅니다.
엣지 케이스 처리
프로덕션 API는 지저분합니다. 일반적인 문제들을 처리하는 방법은 다음과 같습니다.
Nullable 필드
필드가 명시적으로 null일 수 있는 경우:
{
"id": 42,
"username": "jdoe",
"avatarUrl": null,
"deletedAt": null
}
interface User {
id: number;
username: string;
avatarUrl: string | null;
deletedAt: string | null; // ISO 날짜 문자열 또는 null
}
필드가 부재와 null 모두 가능한 경우 둘을 결합합니다:
interface User {
id: number;
username: string;
middleName?: string | null; // 없을 수도 있고, 있지만 null일 수도 있음
}
유니온 타입
일부 필드는 여러 타입을 허용합니다. 검색 API가 혼합된 결과를 반환할 수 있습니다:
{
"results": [
{ "type": "user", "id": 1, "username": "jdoe" },
{ "type": "post", "id": 2, "title": "Hello" }
]
}
판별 유니온으로 모델링합니다:
interface UserResult {
type: 'user';
id: number;
username: string;
}
interface PostResult {
type: 'post';
id: number;
title: string;
}
type SearchResult = UserResult | PostResult;
interface SearchResponse {
results: SearchResult[];
}
type 필드가 판별자 역할을 합니다. TypeScript는 이를 확인할 때 자동으로 타입을 좁힙니다:
function renderResult(result: SearchResult) {
if (result.type === 'user') {
console.log(result.username); // TypeScript가 UserResult임을 알고 있음
} else {
console.log(result.title); // TypeScript가 PostResult임을 알고 있음
}
}
문자열 값에서의 Enum
API가 고정된 문자열 값 집합을 반환할 때, TypeScript enum 대신 문자열 리터럴 유니온을 사용합니다:
{ "status": "active" }
// 가능한 값: "active", "inactive", "suspended"
type UserStatus = 'active' | 'inactive' | 'suspended';
interface User {
id: number;
status: UserStatus;
}
문자열 리터럴 유니온은 더 단순하고, 트리 셰이킹이 가능하며, enum처럼 런타임 JavaScript를 생성하지 않습니다. 역방향 매핑(숫자에서 이름으로)이 필요한 경우에만 enum을 사용하세요.
인터페이스 vs 타입 별칭
interface와 type 모두 객체 형태를 설명할 수 있습니다. 각각을 언제 선택할지 알아보겠습니다:
확장할 수 있는 객체 형태에는 interface를 사용합니다:
interface BaseEntity {
id: number;
createdAt: string;
updatedAt: string;
}
interface User extends BaseEntity {
username: string;
email: string;
}
유니온, 인터섹션, 계산된 타입에는 type을 사용합니다:
type ApiResponse<T> = {
data: T;
meta: { total: number; page: number };
};
type UserOrAdmin = User | Admin;
실제로는 대부분의 경우 어느 것이든 작동합니다. 프로젝트에 하나의 규칙을 정하고 지키세요. 많은 팀이 데이터 모델에는 interface를, 그 외 모든 것에는 type을 사용합니다.
자동화 도구 사용
인터페이스를 수동으로 타이핑하는 것은 작은 API에는 작동하지만, 50개 이상의 필드나 깊이 중첩된 구조를 반환하는 엔드포인트를 다룰 때는 지루해집니다.
JSON을 타입으로 변환하기 전에, JSON이 유효하고 잘 포맷되어 있는지 확인하세요. JSON 포맷터는 지저분한 API 응답을 정리하고, JSON 유효성 검사기는 시간을 낭비하기 전에 구문 오류를 잡아냅니다.
온라인 변환기
quicktype, json2ts 같은 JSON-to-TypeScript 변환기를 사용하면 JSON 페이로드를 붙여넣고 즉시 인터페이스를 얻을 수 있습니다. 중첩 객체, 배열, 선택적 필드를 자동으로 처리합니다.
일반적인 워크플로우:
- API를 호출하고 응답을 복사
- 변환기에 JSON 붙여넣기
- 생성된 타입을 검토하고 이름 조정
- TypeScript를 코드베이스에 복사
OpenAPI 스펙에서 코드 생성
API에 OpenAPI(Swagger) 스펙이 있다면, JSON 변환을 완전히 건너뛰세요. openapi-typescript 같은 도구가 스펙에서 직접 타입을 생성합니다:
npx openapi-typescript https://api.example.com/openapi.json -o ./src/types/api.ts
이 접근 방식이 우수한 이유는 스펙이 선택적 필드, nullable 타입, enum을 명시적으로 정의하기 때문입니다. 샘플 데이터에서 추측하지 않고 정확한 타입을 얻습니다.
Zod를 사용한 런타임 유효성 검사
최대 안전성을 위해 Zod로 형태를 정의하고 TypeScript 타입을 추론합니다:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
bio: z.string().nullable().optional(),
});
type User = z.infer<typeof UserSchema>;
// 이제 런타임에서 유효성 검사
const response = await fetch('/api/users/42');
const data = await response.json();
const user = UserSchema.parse(data); // 형태가 일치하지 않으면 에러 발생
Zod를 사용하면 타입과 유효성 검사 로직이 한 곳에 있습니다. API가 형태를 변경하면, parse()가 잘못된 데이터가 앱 전체에 전파되도록 두지 않고 즉시 에러를 던집니다.
API 호출과 타입 통합
타입은 데이터가 애플리케이션에 들어오는 경계에서 적용할 때만 유용합니다.
타입이 적용된 fetch 래퍼
타입을 강제하는 제네릭 fetch 함수를 만듭니다:
async function fetchJson<T>(url: string, schema?: z.ZodType<T>): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (schema) {
return schema.parse(data);
}
return data as T;
}
// 사용법
const user = await fetchJson<User>('/api/users/42');
const validatedUser = await fetchJson('/api/users/42', UserSchema);
Axios와 타입
Axios를 사용한다면 응답 제네릭에 타입을 적용합니다:
import axios from 'axios';
interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
perPage: number;
lastPage: number;
};
}
async function getUsers(page: number = 1) {
const response = await axios.get<PaginatedResponse<User>>('/api/users', {
params: { page },
});
return response.data; // 완전히 타입이 적용됨
}
React Query / TanStack Query
React 앱에서 데이터 페칭 라이브러리와 타입을 결합합니다:
import { useQuery } from '@tanstack/react-query';
function useUser(id: number) {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchJson<User>(`/api/users/${id}`),
});
}
// 컴포넌트에서
const { data: user, isLoading } = useUser(42);
// user는 User | undefined로 타이핑됨
모범 사례
엄격한 null 검사 활성화
tsconfig.json에서 strict 모드를 활성화합니다(최소한 strictNullChecks):
{
"compilerOptions": {
"strict": true
}
}
엄격한 null 검사 없이는 TypeScript가 잠재적으로 null인 값에서 .username에 접근하는 것을 불평 없이 허용합니다. 이는 API 응답 타이핑의 목적을 무효화합니다.
불변 데이터에 readonly 사용
API 응답은 받은 데이터이지, 변경해야 할 데이터가 아닙니다. 속성을 readonly로 표시합니다:
interface User {
readonly id: number;
readonly username: string;
readonly email: string;
readonly createdAt: string;
}
또는 전체 인터페이스에 유틸리티 타입을 사용합니다:
type ImmutableUser = Readonly<User>;
이는 미묘한 버그를 유발하는 실수로 인한 변경을 방지합니다.
ID를 위한 브랜드 타입
브랜드 타입으로 다른 엔티티의 ID 혼동을 방지합니다:
type UserId = number & { readonly __brand: 'UserId' };
type PostId = number & { readonly __brand: 'PostId' };
function getUser(id: UserId): Promise<User> { /* ... */ }
function getPost(id: PostId): Promise<Post> { /* ... */ }
const userId = 42 as UserId;
const postId = 7 as PostId;
getUser(userId); // OK
getUser(postId); // 타입 오류! UserId가 필요한 곳에 PostId를 전달할 수 없음
타입 중앙 집중화
모든 API 관련 타입을 전용 디렉토리에 보관합니다:
src/
types/
api/
user.ts
post.ts
common.ts // PaginatedResponse 같은 공유 타입
index.ts // 재내보내기
이렇게 하면 타입을 쉽게 찾고, 가져오고, 유지 관리할 수 있습니다. API가 변경되면 하나의 파일을 업데이트하고 컴파일러가 깨지는 모든 것을 보여줍니다.
경계에서 유효성 검사
외부 데이터를 절대 신뢰하지 마세요. 완벽한 타입이 있더라도 API가 예상치 못한 것을 반환할 수 있습니다. 응답이 앱에 들어오는 곳에서 유효성을 검사합니다:
// 이렇게 하지 마세요
const user = (await response.json()) as User; // 맹목적으로 신뢰
// 이렇게 하세요
const data = await response.json();
const user = UserSchema.parse(data); // 형태를 유효성 검사
타입 단언(as User)은 컴파일러를 침묵시키지만 데이터를 확인하지 않습니다. 런타임 유효성 검사(Zod, io-ts, Valibot)는 실제로 형태가 일치하는지 확인합니다.
복잡한 JSON 구조 작업
크거나 복잡한 JSON 페이로드를 다룰 때, 타입을 작성하기 전에 JSON 에디터를 사용하여 구조를 탐색하고 이해하세요. 원시 텍스트보다 적절한 에디터에서 중첩 관계를 보는 것이 훨씬 쉽습니다.
JSON API로 작업하는 팀을 위한 관련 가이드:
- JSON 포맷팅 모범 사례 — JSON을 깔끔하고 읽기 쉽게 유지
- JSON Schema 유효성 검사 가이드 — JSON Schema로 API 계약 유효성 검사
- JSON API 디자인 패턴 — 타이핑하기 쉬운 API 설계
- JSON 유효성 검사 방법 — 잘못된 페이로드 조기 포착
모든 것을 통합하기
JSON 원시 데이터에서 React 컴포넌트의 유효성 검사된 타입 데이터까지 모든 것을 연결하는 완전한 예제입니다:
import { z } from 'zod';
// 1. 스키마 정의 (단일 진실의 원천)
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
role: z.enum(['admin', 'editor', 'viewer']),
profile: z.object({
bio: z.string().nullable(),
avatarUrl: z.string().url().nullable(),
socialLinks: z.array(z.object({
platform: z.string(),
url: z.string().url(),
})).optional(),
}),
});
// 2. 스키마에서 타입 추론
type User = z.infer<typeof UserSchema>;
// 3. 타입이 적용된 fetch 함수 생성
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`사용자 ${id} 가져오기 실패`);
const data = await response.json();
return UserSchema.parse(data);
}
// 4. 완전한 타입 안전성으로 애플리케이션에서 사용
const user = await getUser(42);
console.log(user.profile.bio); // string | null — 컴파일러가 알고 있음
console.log(user.profile.socialLinks); // 배열 또는 undefined — 컴파일러가 알고 있음
any 없음. 추측 없음. 런타임 놀라움 없음.
결론
JSON을 TypeScript 타입으로 변환하는 것은 단순히 컴파일러를 만족시키는 것이 아닙니다. 오류를 조기에 잡아내고, 에디터의 자동완성을 강화하며, 코드베이스를 유지 관리하기 쉽게 만드는 안전망을 구축하는 것입니다.
간단하게 시작하세요: JSON 응답을 복사하고, 인터페이스를 수동으로 작성하고, fetch 호출에 타입을 적용하세요. 프로젝트가 성장함에 따라 런타임 유효성 검사를 위한 Zod와 대규모 API를 위한 OpenAPI 코드 생성을 도입하세요. 초기 투자는 타입 오류가 프로덕션에 도달했을 버그를 처음으로 잡아낼 때 그 값어치를 합니다.