iBetter Books
수정

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 NotFoundErrorfalse를 반환합니다.

전역 에러 핸들러 미들웨어

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이나 nullthrow될 수 있습니다. 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가 날 수 있다"는 사실이 반환 타입에 명시됩니다. 함수 시그니처만 봐도 어떤 에러가 날 수 있는지 알 수 있습니다.