TypeScript와 Zod로 견고한 API 클라이언트 구축하기
Emily Parker
Product Engineer · Leapcell

소개
웹 개발의 복잡한 세계에서 API와의 상호작용은 매일의 기본입니다. 우리는 요청을 보내고 데이터를 받습니다. 간단하죠? 항상 그런 것은 아닙니다. 타이핑되지 않은 API 응답의 보이지 않는 위험은 런타임 오류, 예기치 않은 동작 및 좌절스러운 디버깅 세션의 연쇄를 초래할 수 있습니다. 'id'가 숫자가 아닌 문자열이거나 'data' 필드가 때로는 배열이고 때로는 null인 사용자 객체를 받는다고 상상해 보세요. 이러한 불일치는 종종 프로덕션에서 나타나기 전까지 숨겨져 있어 우리 애플리케이션에 대한 신뢰를 훼손하고 개발 속도를 늦춥니다. 바로 여기서 타입 안전성의 힘이 매우 중요해집니다. API 응답 구조를 미리 정의하고 검증함으로써 이러한 문제를 조기에 발견하여 버그를 방지하고 개발자 경험을 향상시킬 수 있습니다. 이 글에서는 TypeScript와 Zod의 강력한 조합을 사용하여 복잡한 API 상호 작용을 잘 방어된 요새로 변환하는, 탄력 있고 타입 안전한 API 요청 클라이언트를 구축하는 과정을 안내합니다.
핵심 개념 설명
구현에 뛰어들기 전에, 우리가 활용할 주요 도구와 개념에 대한 명확한 이해를 확립해 봅시다.
- TypeScript: 정적 타입 정의를 추가하여 JavaScript 위에 구축된 오픈소스 언어입니다. 개발자는 객체, 함수 및 변수의 모양을 정의하여 훌륭한 도구, 조기 오류 감지 및 향상된 코드 가독성과 유지보수성을 가능하게 합니다. TypeScript를 사용하면 데이터가 어떻게 보여야 하는지 설명합니다.
- Zod: TypeScript 우선 스키마 선언 및 검증 라이브러리입니다. Zod를 사용하면 런타임에 들어오는 데이터를 검증하는 데 사용할 수 있는 데이터 스키마를 정의할 수 있습니다. 강력한 추론 기능으로 유명하며, Zod 스키마를 정의하면 TypeScript가 해당 정적 타입을 자동으로 추론할 수 있습니다. 이는 Zod를 TypeScript의 이상적인 파트너로 만들어, 신뢰할 수 없는 들어오는 데이터가 예상 유형을 준수하도록 보장하는 강력한 메커니즘을 제공합니다. Zod를 데이터의 문지기라고 생각하면 됩니다. "잘 행동하는" 데이터만 애플리케이션에 들어오도록 보장합니다.
- API 클라이언트: 백엔드 API로 HTTP 요청을 만들고 응답을 처리하는 모듈 또는 함수 세트입니다. HTTP 프로토콜의 세부 사항을 추상화하여 애플리케이션 로직이 외부 서비스와 상호 작용할 수 있는 더 깔끔한 인터페이스를 제공합니다.
타입 안전한 API 클라이언트 제작
여기서의 기본 원칙은 각 API 응답 구조에 대해 Zod 스키마를 정의한 다음, 해당 스키마를 사용하여 네트워크에서 수신한 직후 데이터를 검증하는 것입니다. TypeScript는 Zod의 추론을 활용하여 이 검증된 데이터에 대한 컴파일 타임 타입 안전성을 제공합니다.
프로젝트 설정
먼저 필요한 패키지가 설치되었는지 확인해 봅시다.
npm install axios zod typescript npm install --save-dev @types/node # 환경에 따라 필요
Zod로 API 스키마 정의
사용자와 게시물을 관리하는 API와 상호 작용한다고 가정해 봅시다. 이러한 엔터티에 대한 Zod 스키마를 정의합니다.
// src/schemas.ts import { z } from 'zod'; // 단일 사용자 스키마 export const UserSchema = z.object({ id: z.number().int().positive(), name: z.string().min(1, '이름은 비워둘 수 없습니다'), email: z.string().email('잘못된 이메일 주소'), age: z.number().int().positive().optional(), // 선택적 필드 }); // Zod 스키마에서 TypeScript 타입 추론 export type User = z.infer<typeof UserSchema>; // 단일 게시물 스키마 export const PostSchema = z.object({ id: z.number().int().positive(), userId: z.number().int().positive(), title: z.string().min(1, '제목은 비워둘 수 없습니다'), body: z.string().min(1, '내용은 비워둘 수 없습니다'), }); // Zod 스키마에서 TypeScript 타입 추론 export type Post = z.infer<typeof PostSchema>; // 사용자 목록을 포함할 수 있는 API 응답 스키마 export const UsersResponseSchema = z.array(UserSchema); export type UsersResponse = z.infer<typeof UsersResponseSchema>; // 게시물 목록을 포함할 수 있는 API 응답 스키마 export const PostsResponseSchema = z.array(PostSchema); export type PostsResponse = z.infer<typeof PostsResponseSchema>; // 공통 오류 응답 스키마 (선택 사항이지만 좋은 연습) export const ErrorResponseSchema = z.object({ message: z.string(), code: z.number().optional(), }); export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
여기서 우리는 User
와 Post
객체에 대한 정확한 Zod 스키마를 정의했으며, 문자열에 대한 min(1)
, 숫자에 대한 positive()
, 이메일 형식에 대한 email()
과 같은 검증 규칙을 포함했습니다. 중요한 것은 z.infer<typeof Schema>
를 통해 TypeScript가 Zod 스키마와 완벽하게 일치하는 인터페이스 또는 타입 별칭을 자동으로 생성할 수 있다는 것입니다.
API 클라이언트 구축
이제 이러한 스키마를 활용하는 제네릭 API 클라이언트를 만들어 보겠습니다. HTTP 요청에는 axios
를 사용합니다.
// src/apiClient.ts import axios, { AxiosInstance, AxiosResponse, AxiosError } from 'axios'; import { ZodSchema, z } from 'zod'; import { User, UsersResponse, Post, PostsResponse, ErrorResponseSchema } from './schemas'; // 구성된 Axios 인스턴스 생성 const api: AxiosInstance = axios.create({ baseURL: 'https://jsonplaceholder.typicode.com', // 예제 API timeout: 10000, headers: { 'Content-Type': 'application/json', }, }); // 요청을 만들고 응답을 검증하는 제네릭 함수 async function fetchData<T>( url: string, schema: ZodSchema<T> ): Promise<T> { try { const response: AxiosResponse<unknown> = await api.get(url); // 제공된 Zod 스키마를 사용하여 응답 데이터 검증 const validatedData = schema.parse(response.data); return validatedData; } catch (error) { if (error instanceof z.ZodError) { // 데이터 검증 오류 console.error('API 응답 검증 실패:', error.errors); throw new Error(`데이터 검증 오류: ${error.errors.map(e => e.message).join(', ')}`); } else if (axios.isAxiosError(error)) { // Axios HTTP 오류 const axiosError = error as AxiosError; console.error('API 요청 실패:', axiosError.message); if (axiosError.response?.data) { try { // 오류 응답 데이터가 있는 경우 파싱 시도 const errorResponse = ErrorResponseSchema.parse(axiosError.response.data); console.error('API 오류 세부 정보:', errorResponse.message); throw new Error(`API 오류: ${errorResponse.message}`); } catch (parseError) { console.error('API 오류 응답 파싱 불가:', parseError); throw new Error(`API 오류: ${axiosError.response.status} - ${axiosError.message}`); } } throw new Error(`네트워크 오류: ${axiosError.message}`); } else { // 알 수 없는 오류 console.error('예기치 않은 오류 발생:', error); throw new Error('알 수 없는 오류 발생'); } } } // 특정 API 클라이언트 함수 export const usersApiClient = { getUsers: (): Promise<UsersResponse> => fetchData<UsersResponse>('/users', UsersResponseSchema), getUserById: (id: number): Promise<User> => fetchData<User>(`/users/${id}`, UserSchema), }; export const postsApiClient = { getPosts: (): Promise<PostsResponse> => fetchData<PostsResponse>('/posts', PostsResponseSchema), getPostById: (id: number): Promise<Post> => fetchData<Post>(`/posts/${id}`, PostSchema), };
fetchData
에서 우리는:
axios
를 사용하여 HTTP 요청을 합니다.AxiosResponse<unknown>
는 아직 들어오는 데이터를 신뢰하지 않기 때문에 처음에 사용됩니다.- 중요하게도
schema.parse(response.data)
를 호출합니다. 바로 여기서 Zod가 미리 정의된 스키마에 대해 수신된 데이터를 꼼꼼하게 검증합니다. 데이터가 일치하지 않으면 Zod는ZodError
를 발생시킵니다. catch
블록은 Zod 검증 오류, Axios HTTP 오류 및 기타 예상치 못한 문제를 구별하여 특정 오류 메시지를 제공합니다.- 검증이 성공하면
validatedData
는z.infer
덕분에T
타입(스키마에서 추론됨)으로 보장됩니다.
타입 안전한 클라이언트 소비
이제 이 클라이언트를 소비하는 것은 매우 안전하고 직관적으로 느껴집니다.
// src/app.ts import { usersApiClient, postsApiClient } from './apiClient'; import { User, Post } from './schemas'; // 여기서는 타입만 필요 async function main() { console.log('사용자 가져오는 중...'); try { const users: User[] = await usersApiClient.getUsers(); console.log(`가져온 사용자 ${users.length}명.`); // TypeScript는 `users`가 `User` 객체의 배열임을 알고 있습니다. // 자동 완성 기능이 작동하며, 속성을 잘못 사용하면 컴파일 타임 오류가 발생합니다. const firstUser = users[0]; if (firstUser) { console.log(`첫 번째 사용자: ID=${firstUser.id}, 이름=${firstUser.name}, 이메일=${firstUser.email}`); // 존재하지 않는 속성에 접근 시도 - TypeScript가 알려줍니다! // console.log(firstUser.address.city); // 오류: 'User' 타입에 'address' 속성이 존재하지 않습니다. } console.log('\n특정 게시물 가져오는 중 (ID 1)...'); const post: Post = await postsApiClient.getPostById(1); console.log(`가져온 게시물: 제목="${post.title}" 작성자 ID ${post.userId}`); // 잘못된 데이터 예제 (시뮬레이션된 시나리오) // API가 예상치 못하게 잘못된 데이터를 보내면 Zod가 잡아냅니다! // 예를 들어, '/users'가 [{ id: '1', name: 'John Doe' }]를 반환하는 경우 // fetchData는 'id'가 숫자로 예상되기 때문에 ZodError를 발생시킵니다. } catch (error: any) { console.error('데이터 가져오는 중 오류 발생:', error.message); } } main();
이 소비 예시에서 users: User[]
및 post: Post
가 명시적으로 입력되었음을 알 수 있습니다. 이것은 단지 문서화 목적만이 아닙니다. fetchData
함수가 Zod 검증 후 T
를 반환한다고 보장되기 때문에 TypeScript에서 컴파일 시점에 강제됩니다. API 응답이 스키마에서 벗어나면 Zod는 잘못된 데이터가 애플리케이션 로직에 도달하기 전에 오류를 발생시킵니다.
애플리케이션 시나리오
이 패턴은 여러 시나리오에서 매우 유용합니다
- 공개 API: 데이터 형식에 대한 제어력이 적은 타사 API를 소비할 때 Zod는 예상치 못한 변경이나 잘못된 응답에 대한 중요한 방어 계층을 제공합니다.
- 마이크로서비스: 마이크로서비스 아키텍처에서 다른 팀이 다른 서비스를 소유할 수 있습니다. Zod 스키마는 서비스 간 통신이 합의된 데이터 구조를 준수하도록 보장하는 계약 역할을 합니다.
- 프론트엔드-백엔드 분리: 프론트엔드 및 백엔드 팀이 독립적으로 작업할 때 Zod 스키마를 공유하여 타입 일관성을 보장하고 오해 및 통합 문제를 줄일 수 있습니다.
- 데이터 변환: Zod는 스키마에
.transform()
을 추가하여 데이터 변환에도 사용할 수 있으며, 데이터가 검증될 때 모양을 바꿀 수 있습니다.
결론
TypeScript를 정적 타입 검사에 사용하고 Zod를 런타임 데이터 검증에 통합함으로써 API 클라이언트에 비할 데 없는 수준의 신뢰성을 부여합니다. 이 시너지 접근 방식은 애플리케이션에 들어오는 데이터가 컴파일 타임에 구조적으로 건전할 뿐만 아니라 런타임에도 기대치와 진정으로 일치하도록 보장하여 일반적인 데이터 관련 버그의 광범위한 문제를 방지합니다. TypeScript와 Zod를 채택하여 견고하고 유지보수 가능하며 작업하기 즐거운 API 클라이언트를 구축하세요.