iBetter Books
수정

Ch 02. 라우트와 미들웨어 타이핑

Express에서 타입 안전성이 가장 취약한 곳이 어디일까요? 바로 라우트 핸들러입니다. req.params.id가 문자열인지, req.body에 어떤 필드가 있는지, TypeScript는 아무것도 모릅니다. 이 챕터에서는 Request 제네릭을 활용해 라우트 핸들러를 완전히 타이핑합니다.

Request 제네릭 구조 이해

@types/expressRequest 타입은 네 개의 제네릭 파라미터를 받습니다.

interface Request<  P = ParamsDictionary,   // req.params  ResBody = any,          // res.json()에 넘기는 타입  ReqBody = any,          // req.body  Query = ParsedQs        // req.query> {}

기본값이 모두 any이기 때문에 그냥 쓰면 타입 검사가 의미가 없습니다. 제네릭을 직접 지정해야 합니다.

타입 정의 파일

먼저 API 전반에서 쓸 타입을 정의합니다.

// 새 파일: src/types/todo.tsexport interface Todo {  id: number;  title: string;  completed: boolean;  createdAt: Date;  updatedAt: Date;}export interface CreateTodoDto {  title: string;}export interface UpdateTodoDto {  title?: string;  completed?: boolean;}export interface TodoParams {  id: string;}// API 응답 래퍼 타입export interface ApiResponse<T> {  data: T;  message?: string;}export interface ApiError {  error: string;  message: string;  statusCode: number;}

DTO(Data Transfer Object)는 네트워크를 통해 주고받는 데이터의 모양을 정의합니다. CreateTodoDto는 클라이언트가 POST 요청으로 보내는 본문 형태이고, UpdateTodoDto는 PATCH 요청의 본문 형태입니다. UpdateTodoDto의 필드가 모두 선택적인 것에 주목하세요. PATCH는 부분 수정이기 때문입니다.

라우트 핸들러 타이핑

// 새 파일: src/routes/todos.tsimport { Router, Request, Response } from 'express';import type { Todo, CreateTodoDto, UpdateTodoDto, TodoParams, ApiResponse } from '../types/todo.js';const router = Router();// 메모리 스토리지 (Ch 03에서 Prisma로 교체)let todos: Todo[] = [];let nextId = 1;// GET /todos — 전체 목록router.get('/', (_req: Request, res: Response<ApiResponse<Todo[]>>) => {  res.json({ data: todos });});// GET /todos/:id — 단건 조회router.get(  '/:id',  (req: Request<TodoParams>, res: Response<ApiResponse<Todo> | { error: string }>) => {    const id = parseInt(req.params.id, 10);    const todo = todos.find((t) => t.id === id);    if (!todo) {      res.status(404).json({ error: `id ${id}인 할 일을 찾을 수 없습니다.` });      return;    }    res.json({ data: todo });  });// POST /todos — 새 할 일 생성router.post(  '/',  (req: Request<{}, ApiResponse<Todo>, CreateTodoDto>, res: Response<ApiResponse<Todo>>) => {    const { title } = req.body;    const todo: Todo = {      id: nextId++,      title,      completed: false,      createdAt: new Date(),      updatedAt: new Date(),    };    todos.push(todo);    res.status(201).json({ data: todo, message: '할 일이 생성되었습니다.' });  });// PATCH /todos/:id — 수정router.patch(  '/:id',  (    req: Request<TodoParams, ApiResponse<Todo>, UpdateTodoDto>,    res: Response<ApiResponse<Todo> | { error: string }>  ) => {    const id = parseInt(req.params.id, 10);    const index = todos.findIndex((t) => t.id === id);    if (index === -1) {      res.status(404).json({ error: `id ${id}인 할 일을 찾을 수 없습니다.` });      return;    }    todos[index] = {      ...todos[index],      ...req.body,      updatedAt: new Date(),    };    res.json({ data: todos[index] });  });// DELETE /todos/:id — 삭제router.delete('/:id', (req: Request<TodoParams>, res: Response) => {  const id = parseInt(req.params.id, 10);  const index = todos.findIndex((t) => t.id === id);  if (index === -1) {    res.status(404).json({ error: `id ${id}인 할 일을 찾을 수 없습니다.` });    return;  }  todos.splice(index, 1);  res.status(204).send();});export default router;

Request<TodoParams> 덕분에 req.params.idstring 타입임을 TypeScript가 압니다. req.params.idd처럼 오타를 내면 컴파일 시점에 바로 잡힙니다.

Response<ApiResponse<Todo>>로 응답 타입을 지정하면, res.json()에 넘기는 값이 ApiResponse<Todo> 구조와 맞지 않을 때 오류가 납니다. "타입이 이렇게 나올 것"이라는 문서가 코드 자체에 포함되는 셈입니다.

입력 검증 미들웨어

타입은 컴파일 타임에만 존재합니다. 런타임에 클라이언트가 어떤 값을 보낼지는 TypeScript가 보장해주지 않습니다. 입력 검증은 별도로 해야 합니다.

// 새 파일: src/middleware/validate.tsimport { Request, Response, NextFunction } from 'express';type Validator<T> = (body: unknown) => body is T;export function validateBody<T>(validator: Validator<T>) {  return (req: Request, res: Response, next: NextFunction): void => {    if (!validator(req.body)) {      res.status(400).json({        error: 'VALIDATION_ERROR',        message: '요청 본문이 올바르지 않습니다.',      });      return;    }    next();  };}// CreateTodoDto 검증 함수export function isCreateTodoDto(body: unknown): body is import('../types/todo.js').CreateTodoDto {  return (    typeof body === 'object' &&    body !== null &&    'title' in body &&    typeof (body as Record<string, unknown>).title === 'string' &&    (body as Record<string, unknown>).title !== ''  );}

body is T 형태의 반환 타입이 타입 가드 함수임을 선언합니다. validateBody 미들웨어는 이 타입 가드 함수를 받아서, 검증에 통과하면 next()를 호출하고 실패하면 400을 반환합니다.

미들웨어 체인 타이핑

미들웨어 함수는 (req, res, next) => void 시그니처를 가집니다. NextFunction을 파라미터에 포함하면 미들웨어, 없으면 최종 핸들러입니다.

// 수정: src/routes/todos.tsimport { Router, Request, Response, NextFunction } from 'express';import type { Todo, CreateTodoDto, UpdateTodoDto, TodoParams, ApiResponse } from '../types/todo.js';import { validateBody, isCreateTodoDto } from '../middleware/validate.js';const router = Router();let todos: Todo[] = [];let nextId = 1;router.get('/', (_req: Request, res: Response<ApiResponse<Todo[]>>) => {  res.json({ data: todos });});router.get(  '/:id',  (req: Request<TodoParams>, res: Response<ApiResponse<Todo> | { error: string }>) => {    const id = parseInt(req.params.id, 10);    const todo = todos.find((t) => t.id === id);    if (!todo) {      res.status(404).json({ error: `id ${id}인 할 일을 찾을 수 없습니다.` });      return;    }    res.json({ data: todo });  });router.post(  '/',  validateBody(isCreateTodoDto),  (req: Request<{}, ApiResponse<Todo>, CreateTodoDto>, res: Response<ApiResponse<Todo>>) => {    const { title } = req.body;    const todo: Todo = {      id: nextId++,      title,      completed: false,      createdAt: new Date(),      updatedAt: new Date(),    };    todos.push(todo);    res.status(201).json({ data: todo, message: '할 일이 생성되었습니다.' });  });router.patch(  '/:id',  (    req: Request<TodoParams, ApiResponse<Todo>, UpdateTodoDto>,    res: Response<ApiResponse<Todo> | { error: string }>  ) => {    const id = parseInt(req.params.id, 10);    const index = todos.findIndex((t) => t.id === id);    if (index === -1) {      res.status(404).json({ error: `id ${id}인 할 일을 찾을 수 없습니다.` });      return;    }    todos[index] = {      ...todos[index],      ...req.body,      updatedAt: new Date(),    };    res.json({ data: todos[index] });  });router.delete('/:id', (req: Request<TodoParams>, res: Response) => {  const id = parseInt(req.params.id, 10);  const index = todos.findIndex((t) => t.id === id);  if (index === -1) {    res.status(404).json({ error: `id ${id}인 할 일을 찾을 수 없습니다.` });    return;  }  todos.splice(index, 1);  res.status(204).send();});export default router;

router.post('/', validateBody(isCreateTodoDto), handler) 형태가 미들웨어 체인입니다. Express는 배열의 순서대로 미들웨어를 실행합니다. validateBody가 먼저 검증하고, 통과하면 핸들러가 실행됩니다.

라우터를 앱에 연결

// 수정: src/index.tsimport express from 'express';import todosRouter from './routes/todos.js';const app = express();const PORT = process.env.PORT ?? 3000;app.use(express.json());app.get('/health', (_req, res) => {  res.json({ status: 'ok', timestamp: new Date().toISOString() });});app.use('/todos', todosRouter);app.listen(PORT, () => {  console.log(`서버가 http://localhost:${PORT} 에서 실행 중입니다.`);});export default app;

app.use('/todos', todosRouter)로 라우터를 마운트하면, 라우터 내부의 '/'는 실제로 /todos가 되고 '/:id'/todos/:id가 됩니다. 라우트 파일이 자신의 경로 접두어를 몰라도 됩니다.