Ch 04. API 요청 응답 타입 공유
프론트엔드와 백엔드를 모두 TypeScript로 만들면 한 가지 유혹이 생깁니다. "같은 타입을 두 곳에 따로 정의하지 말고 공유하면 어떨까?" API 응답 타입이 바뀌면 클라이언트 코드도 컴파일 시점에 바로 잡힌다면 얼마나 좋을까요. 이 챕터에서는 그 방법을 배웁니다.
타입 중복의 문제
PART 07의 React 앱에는 다음과 같은 타입이 있었습니다.
// todo-app/src/types/todo.ts (프론트엔드)interface Todo { id: number; title: string; completed: boolean;}
PART 08의 API 서버에도 같은 타입이 있습니다.
// todo-api/src/types/todo.ts (백엔드)export interface Todo { id: number; title: string; completed: boolean; createdAt: Date; updatedAt: Date;}
서버가 createdAt을 추가했지만 프론트엔드는 모릅니다. API 응답에 새 필드가 생겨도 프론트엔드는 컴파일 시점에 알 수 없습니다. 타입이 분리되어 있기 때문입니다.
공유 타입 패키지 전략
해결책은 타입을 별도 패키지로 추출해서 프론트엔드와 백엔드 모두에서 설치하는 것입니다.
workspace/
├── todo-api/ # 백엔드
├── todo-app/ # 프론트엔드 (PART 07)
└── todo-types/ # 공유 타입 패키지
npm 워크스페이스나 monorepo 도구(Turborepo, Nx)를 쓰면 로컬 패키지를 바로 참조할 수 있습니다.
공유 타입 패키지 생성
mkdir todo-typescd todo-typesnpm init -ynpm install -D typescript
// 새 파일: todo-types/package.json{ "name": "@todo/types", "version": "1.0.0", "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { "build": "tsc", "dev": "tsc --watch" }, "devDependencies": { "typescript": "^5.0.0" }}
// 새 파일: todo-types/tsconfig.json{ "compilerOptions": { "target": "ES2022", "module": "CommonJS", "outDir": "./dist", "rootDir": "./src", "strict": true, "declaration": true, "declarationMap": true }, "include": ["src/**/*"]}
// 새 파일: todo-types/src/index.tsexport interface Todo { id: number; title: string; completed: boolean; createdAt: string; // JSON 직렬화 후 string updatedAt: string;}export interface CreateTodoDto { title: string;}export interface UpdateTodoDto { title?: string; completed?: boolean;}export interface ApiResponse<T> { data: T; message?: string;}export interface ApiError { error: string; message: string; statusCode: number;}
createdAt이 string인 점에 주목하세요. 백엔드에서는 Date 객체이지만, JSON으로 직렬화되면 ISO 8601 문자열이 됩니다. 프론트엔드는 string을 받아서 new Date(todo.createdAt)으로 변환합니다. 공유 타입은 네트워크를 통과한 후의 형태를 기준으로 정의합니다.
npm link로 로컬 패키지 연결
# todo-types 폴더에서npm run buildnpm link# todo-api 폴더에서npm link @todo/types# todo-app 폴더에서npm link @todo/types
npm link는 전역 node_modules에 심볼릭 링크를 만들어서, 실제로 npm에 배포하지 않아도 로컬 패키지를 node_modules/@todo/types처럼 가져올 수 있게 합니다.
백엔드에서 공유 타입 사용
// 수정: src/routes/todos.tsimport { Router, Request, Response } from 'express';import type { Todo as SharedTodo, CreateTodoDto, UpdateTodoDto, ApiResponse } from '@todo/types';import { prisma } from '../db/prisma.js';import type { Todo } from '@prisma/client';import { validateBody, isCreateTodoDto } from '../middleware/validate.js';// Prisma Todo → SharedTodo 변환 함수function toSharedTodo(todo: Todo): SharedTodo { return { ...todo, createdAt: todo.createdAt.toISOString(), updatedAt: todo.updatedAt.toISOString(), };}const router = Router();router.get('/', async (_req: Request, res: Response<ApiResponse<SharedTodo[]>>) => { const todos = await prisma.todo.findMany({ orderBy: { createdAt: 'desc' } }); res.json({ data: todos.map(toSharedTodo) });});router.get( '/:id', async ( req: Request<{ id: string }>, res: Response<ApiResponse<SharedTodo> | { error: string }> ) => { const id = parseInt(req.params.id, 10); const todo = await prisma.todo.findUnique({ where: { id } }); if (!todo) { res.status(404).json({ error: `id ${id}인 할 일을 찾을 수 없습니다.` }); return; } res.json({ data: toSharedTodo(todo) }); });router.post( '/', validateBody(isCreateTodoDto), async ( req: Request<{}, ApiResponse<SharedTodo>, CreateTodoDto>, res: Response<ApiResponse<SharedTodo>> ) => { const { title } = req.body; const todo = await prisma.todo.create({ data: { title } }); res.status(201).json({ data: toSharedTodo(todo), message: '할 일이 생성되었습니다.' }); });router.patch( '/:id', async ( req: Request<{ id: string }, ApiResponse<SharedTodo>, UpdateTodoDto>, res: Response<ApiResponse<SharedTodo> | { error: string }> ) => { const id = parseInt(req.params.id, 10); try { const todo = await prisma.todo.update({ where: { id }, data: req.body }); res.json({ data: toSharedTodo(todo) }); } catch { res.status(404).json({ error: `id ${id}인 할 일을 찾을 수 없습니다.` }); } });router.delete('/:id', async (req: Request<{ id: string }>, res: Response) => { const id = parseInt(req.params.id, 10); try { await prisma.todo.delete({ where: { id } }); res.status(204).send(); } catch { res.status(404).json({ error: `id ${id}인 할 일을 찾을 수 없습니다.` }); }});export default router;
toSharedTodo 함수가 Prisma의 Todo(Date 사용)를 네트워크 공유 타입 SharedTodo(string 사용)로 변환합니다. 이 변환 함수가 없으면 프론트엔드에서 날짜를 처리할 때 예측 불가능한 동작이 생깁니다.
OpenAPI로 타입 자동 생성
프로젝트가 커지면 공유 타입 패키지를 수동으로 관리하기 어렵습니다. OpenAPI(구 Swagger) 스펙을 작성하고 코드를 자동 생성하는 방법도 있습니다.
OpenAPI 스펙 정의
# 새 파일: openapi.yamlopenapi: "3.1.0"info: title: Todo API version: "1.0.0"paths: /todos: get: summary: 전체 할 일 목록 조회 responses: "200": content: application/json: schema: $ref: "#/components/schemas/TodoListResponse" post: summary: 새 할 일 생성 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateTodoDto" responses: "201": content: application/json: schema: $ref: "#/components/schemas/TodoResponse"components: schemas: Todo: type: object required: [id, title, completed, createdAt, updatedAt] properties: id: type: integer title: type: string completed: type: boolean createdAt: type: string format: date-time updatedAt: type: string format: date-time CreateTodoDto: type: object required: [title] properties: title: type: string TodoResponse: type: object properties: data: $ref: "#/components/schemas/Todo" TodoListResponse: type: object properties: data: type: array items: $ref: "#/components/schemas/Todo"
openapi-typescript로 타입 생성
npm install -D openapi-typescriptnpx openapi-typescript openapi.yaml -o src/types/api.generated.ts
생성된 파일에는 OpenAPI 스펙에서 자동 추출한 TypeScript 타입이 담깁니다. 스펙이 바뀌면 명령 한 번으로 타입이 업데이트됩니다.
공유 타입 패키지 방식과 OpenAPI 코드젠 방식 중 어느 것이 더 좋은지는 팀마다 다릅니다. 팀 전체가 TypeScript를 쓴다면 공유 패키지가 편하고, 다양한 언어가 섞여 있다면 OpenAPI가 유리합니다.