Ch 05. 에러 처리 타입 설계
예외 처리는 모든 언어에서 어렵습니다. TypeScript도 예외는 아닙니다. throw된 값의 타입은 항상 unknown입니다. catch (e) 블록에서 e가 무엇인지 TypeScript는 모릅니다. 직접 확인해야 합니다.
이 챕터에서는 세 가지 접근법을 배웁니다. 커스텀 에러 클래스로 에러를 분류하고, Result<T, E> 패턴으로 예외를 값으로 다루며, 전역 에러 핸들러 미들웨어로 일관된 응답을 보냅니다.
커스텀 에러 클래스
기본 Error 클래스를 상속해서 HTTP 상태 코드와 에러 코드를 담는 커스텀 에러를 만듭니다.
// 새 파일: src/errors/AppError.tsexport class AppError extends Error { constructor( public readonly statusCode: number, public readonly errorCode: string, message: string ) { super(message); this.name = this.constructor.name; // TypeScript에서 Error 상속 시 프로토타입 체인 복원 Object.setPrototypeOf(this, new.target.prototype); }}export class NotFoundError extends AppError { constructor(resource: string, id: number | string) { super(404, 'NOT_FOUND', `${resource} (id: ${id})를 찾을 수 없습니다.`); }}export class ValidationError extends AppError { constructor(message: string) { super(400, 'VALIDATION_ERROR', message); }}export class ConflictError extends AppError { constructor(message: string) { super(409, 'CONFLICT', message); }}export class InternalError extends AppError { constructor(message = '서버 내부 오류가 발생했습니다.') { super(500, 'INTERNAL_ERROR', message); }}
Object.setPrototypeOf(this, new.target.prototype)는 TypeScript에서 Error를 상속할 때 반드시 추가해야 하는 코드입니다. instanceof 체크가 올바르게 동작하려면 프로토타입 체인을 수동으로 복원해야 합니다. 이 줄이 없으면 error instanceof NotFoundError가 false를 반환합니다.
전역 에러 핸들러 미들웨어
Express의 에러 핸들러는 파라미터가 네 개입니다. (err, req, res, next) 형태여야 Express가 에러 핸들러로 인식합니다.
// 새 파일: src/middleware/errorHandler.tsimport { Request, Response, NextFunction } from 'express';import { AppError } from '../errors/AppError.js';export function errorHandler( err: unknown, _req: Request, res: Response, _next: NextFunction): void { console.error('[Error]', err); if (err instanceof AppError) { res.status(err.statusCode).json({ error: err.errorCode, message: err.message, statusCode: err.statusCode, }); return; } // 예상치 못한 에러 if (err instanceof Error) { res.status(500).json({ error: 'INTERNAL_ERROR', message: process.env.NODE_ENV === 'production' ? '서버 오류가 발생했습니다.' : err.message, statusCode: 500, }); return; } res.status(500).json({ error: 'UNKNOWN_ERROR', message: '알 수 없는 오류가 발생했습니다.', statusCode: 500, });}
err: unknown이 올바른 타입입니다. err: Error로 쓰면 TypeScript 컴파일러가 경고하지 않지만, 실제로는 string이나 null이 throw될 수 있습니다. unknown으로 받고 instanceof로 좁히는 것이 안전합니다.
에러 핸들러를 앱에 등록합니다.
// 수정: src/index.tsimport express from 'express';import todosRouter from './routes/todos.js';import { errorHandler } from './middleware/errorHandler.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.use(errorHandler);app.listen(PORT, () => { console.log(`서버가 http://localhost:${PORT} 에서 실행 중입니다.`);});export default app;
에러 핸들러는 반드시 모든 라우트와 미들웨어 뒤에 등록해야 합니다. Express는 파라미터가 네 개인 미들웨어를 에러 핸들러로 특별히 처리합니다.
라우트에서 커스텀 에러 사용
// 수정: src/routes/todos.tsimport { Router, Request, Response, NextFunction } 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';import { NotFoundError } from '../errors/AppError.js';const router = Router();function toSharedTodo(todo: Todo): SharedTodo { return { ...todo, createdAt: todo.createdAt.toISOString(), updatedAt: todo.updatedAt.toISOString(), };}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>>, next: NextFunction ) => { const id = parseInt(req.params.id, 10); const todo = await prisma.todo.findUnique({ where: { id } }); if (!todo) { next(new NotFoundError('Todo', 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>>, next: NextFunction ) => { 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 { next(new NotFoundError('Todo', id)); } });router.delete( '/:id', async (req: Request<{ id: string }>, res: Response, next: NextFunction) => { const id = parseInt(req.params.id, 10); try { await prisma.todo.delete({ where: { id } }); res.status(204).send(); } catch { next(new NotFoundError('Todo', id)); } });export default router;
next(error)를 호출하면 Express가 에러 핸들러로 제어를 넘깁니다. 각 라우트에서 res.status(404).json(...)을 반복하지 않아도 됩니다. 에러 응답 형식이 errorHandler 한 곳에서 관리됩니다.
Result 패턴
try-catch 대신 에러를 값으로 다루는 패턴입니다. 함수가 성공 또는 실패를 반환값으로 표현합니다.
// 새 파일: src/utils/result.tsexport type Result<T, E = Error> = | { ok: true; value: T } | { ok: false; error: E };export function ok<T>(value: T): Result<T, never> { return { ok: true, value };}export function err<E>(error: E): Result<never, E> { return { ok: false, error };}
이 패턴을 서비스 레이어에 적용합니다.
// 새 파일: src/services/todoService.tsimport { prisma } from '../db/prisma.js';import type { Todo as SharedTodo, CreateTodoDto, UpdateTodoDto } from '@todo/types';import type { Todo } from '@prisma/client';import { NotFoundError } from '../errors/AppError.js';import { ok, err, Result } from '../utils/result.js';function toSharedTodo(todo: Todo): SharedTodo { return { ...todo, createdAt: todo.createdAt.toISOString(), updatedAt: todo.updatedAt.toISOString(), };}export async function findAllTodos(): Promise<Result<SharedTodo[]>> { try { const todos = await prisma.todo.findMany({ orderBy: { createdAt: 'desc' } }); return ok(todos.map(toSharedTodo)); } catch (e) { return err(e instanceof Error ? e : new Error(String(e))); }}export async function findTodoById(id: number): Promise<Result<SharedTodo, NotFoundError>> { const todo = await prisma.todo.findUnique({ where: { id } }); if (!todo) return err(new NotFoundError('Todo', id)); return ok(toSharedTodo(todo));}export async function createTodo(dto: CreateTodoDto): Promise<Result<SharedTodo>> { try { const todo = await prisma.todo.create({ data: dto }); return ok(toSharedTodo(todo)); } catch (e) { return err(e instanceof Error ? e : new Error(String(e))); }}export async function updateTodo( id: number, dto: UpdateTodoDto): Promise<Result<SharedTodo, NotFoundError>> { try { const todo = await prisma.todo.update({ where: { id }, data: dto }); return ok(toSharedTodo(todo)); } catch { return err(new NotFoundError('Todo', id)); }}export async function deleteTodo(id: number): Promise<Result<void, NotFoundError>> { try { await prisma.todo.delete({ where: { id } }); return ok(undefined); } catch { return err(new NotFoundError('Todo', id)); }}
Result<SharedTodo, NotFoundError>를 반환하는 함수를 호출하면, 호출자는 반드시 ok 여부를 확인해야 합니다. 에러를 무시할 수 없습니다. TypeScript가 result.ok를 체크하지 않으면 result.value에 접근하지 못하게 막습니다.
const result = await findTodoById(1);if (!result.ok) { // result.error는 NotFoundError 타입으로 좁혀짐 next(result.error); return;}// result.value는 SharedTodo 타입으로 좁혀짐res.json({ data: result.value });
Result 패턴은 에러를 타입 시스템 안으로 끌어들입니다. "이 함수는 NotFoundError가 날 수 있다"는 사실이 반환 타입에 명시됩니다. 함수 시그니처만 봐도 어떤 에러가 날 수 있는지 알 수 있습니다.