asyncHandler 래퍼 패턴
Express에서 비동기 라우트 핸들러를 작성할 때 가장 먼저 부딪히는 문제가 있다. async/await에서 발생하는 에러가 Express의 에러 처리 미들웨어로 전달되지 않는다는 것이다.
// 이 코드는 에러가 발생하면 서버가 멈춘다
app.get("/items", async (req, res) => {
const items = await fetchItems(); // 여기서 에러 발생 시?
res.json(items);
});
fetchItems()가 에러를 던지면 어떻게 될까? Express는 이 에러를 잡지 못한다. Promise가 reject되면서 UnhandledPromiseRejection 경고가 뜨고, 클라이언트는 응답을 받지 못한 채 타임아웃을 맞는다. Express 4.x는 내부적으로 동기 에러만 자동으로 잡도록 설계되어 있기 때문이다.
왜 Express는 async 에러를 못 잡을까
Express의 라우터는 핸들러를 호출할 때 내부적으로 try-catch로 감싸고 있다. 하지만 이 try-catch는 동기 코드에서 발생하는 에러만 잡을 수 있다.
// Express 내부의 라우트 실행 로직 (단순화)
try {
handler(req, res, next);
} catch (err) {
next(err); // 동기 에러는 여기서 잡힘
}
async 함수는 호출 즉시 Promise를 반환한다. try-catch는 이 Promise의 반환 자체는 성공으로 본다. Promise 내부에서 나중에 발생하는 rejection은 이미 try-catch 블록을 벗어난 뒤이기 때문에 잡히지 않는다.
// async 함수가 반환하는 건 Promise
const result = handler(req, res, next); // → Promise { <pending> }
// try-catch는 여기까지만 본다. Promise 안에서 뭐가 터지든 모른다.
이 구조적 한계 때문에 모든 async 핸들러에 try-catch를 직접 작성해야 한다.
try-catch의 반복 문제
해결 방법은 간단하다. 모든 async 핸들러에 try-catch를 넣으면 된다.
app.get("/items", async (req, res, next) => {
try {
const items = await fetchItems();
res.json(items);
} catch (err) {
next(err); // Express 에러 미들웨어로 전달
}
});
app.post("/items", async (req, res, next) => {
try {
const item = await createItem(req.body);
res.status(201).json(item);
} catch (err) {
next(err);
}
});
app.delete("/items/:id", async (req, res, next) => {
try {
await deleteItem(req.params.id);
res.status(204).end();
} catch (err) {
next(err);
}
});
동작은 한다. 하지만 라우트가 10개, 20개, 50개로 늘어나면 매번 같은 try-catch 보일러플레이트를 반복해야 한다. 실제 비즈니스 로직은 try 안에 2~3줄인데, 감싸는 코드가 그보다 더 많다. 그리고 가장 위험한 건, 개발자가 실수로 try-catch를 빼먹는 순간 프로덕션에서 에러가 조용히 삼켜진다는 점이다.
asyncHandler 래퍼의 원리
이 반복을 제거하는 방법은 고차 함수(Higher-Order Function)를 사용하는 것이다. 핸들러 함수를 인자로 받아서, try-catch가 포함된 새로운 함수를 반환하면 된다.
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
이 한 줄이 전부다. 분해해서 보자.
fn은 우리가 작성한 async 핸들러 함수다.- Express가 실행할 새로운 함수
(req, res, next) => ...를 반환한다. Promise.resolve()로fn의 반환값을 감싼다.fn이 async 함수면 이미 Promise를 반환하지만, 일반 함수일 수도 있으니Promise.resolve()로 통일한다..catch(next)로 Promise rejection을 잡아서 Express의next()로 전달한다.
try-catch 버전과 Promise.catch 버전은 동작이 동일하다. 비동기 에러를 잡아서 next(err)를 호출하는 것이 핵심이고, 표현 방식만 다를 뿐이다.
// try-catch 버전 (동일한 동작)
const asyncHandler = (fn) => async (req, res, next) => {
try {
await fn(req, res, next);
} catch (err) {
next(err);
}
};
두 버전 모두 같은 역할을 하지만, Promise.resolve().catch() 방식이 더 간결하고 한 줄로 표현할 수 있어서 널리 쓰인다.
적용 방법
asyncHandler로 핸들러를 감싸기만 하면 된다.
// Before — 매번 try-catch
app.get("/items", async (req, res, next) => {
try {
const items = await fetchItems();
res.json(items);
} catch (err) {
next(err);
}
});
// After — asyncHandler로 감싸기
app.get("/items", asyncHandler(async (req, res) => {
const items = await fetchItems();
res.json(items);
}));
핸들러 안에서 에러가 발생하면 asyncHandler가 자동으로 next(err)를 호출하기 때문에, Express의 에러 처리 미들웨어로 정상적으로 전달된다. next 파라미터를 직접 사용할 일이 없으니 핸들러 시그니처에서 빼도 된다.
컨트롤러를 객체로 분리해서 사용하는 경우에도 동일하게 적용할 수 있다.
const controller = {
getAll: asyncHandler(async (req, res) => {
const items = await service.getAll();
res.status(200).json({ success: true, data: items });
}),
create: asyncHandler(async (req, res) => {
const { title, description } = req.body;
const id = await service.create(title, description);
res.status(201).json({ success: true, data: { id } });
}),
update: asyncHandler(async (req, res) => {
const { id } = req.params;
const { title, description } = req.body;
await service.update(id, title, description);
res.status(200).json({ success: true, message: "수정 완료" });
}),
remove: asyncHandler(async (req, res) => {
const { id } = req.params;
await service.remove(id);
res.status(204).end();
}),
};
이렇게 하면 모든 라우트에서 try-catch 보일러플레이트가 사라지고, 비즈니스 로직만 깔끔하게 남는다.
express-async-errors 라이브러리
asyncHandler를 직접 만들기 싫다면 express-async-errors라는 패키지를 사용하는 방법도 있다. 이 패키지는 Express의 내부 Layer.handle 메서드를 monkey-patch해서, async 함수의 반환값이 Promise이면 자동으로 .catch(next)를 붙여준다.
require("express-async-errors"); // 최상단에 한 줄 추가
app.get("/items", async (req, res) => {
const items = await fetchItems(); // 에러 나면 자동으로 next(err)
res.json(items);
});
import 한 줄만 추가하면 모든 async 핸들러에서 에러가 자동으로 처리된다. asyncHandler로 감쌀 필요도 없다.
다만 이 방식에는 트레이드오프가 있다.
- 장점: 기존 코드 수정 없이 적용 가능. 가장 적은 코드 변경.
- 단점: Express 내부 동작을 수정하기 때문에 Express 버전 업데이트 시 호환성 문제가 생길 수 있다. 암시적 동작이라 코드만 보고는 에러 처리가 어디서 되는지 파악하기 어렵다.
직접 만든 asyncHandler는 명시적이다. 코드를 읽는 사람이 "이 핸들러는 에러 처리가 되어 있구나"를 바로 알 수 있다. 프로젝트 규모가 커질수록 이런 명시성이 유지보수에 도움이 된다.
Express 5에서는?
Express 5부터는 async 핸들러에서 reject된 Promise를 자동으로 next(err)로 전달한다. 즉, asyncHandler 래퍼가 더 이상 필요하지 않다.
// Express 5에서는 이것만으로 충분
app.get("/items", async (req, res) => {
const items = await fetchItems(); // reject 시 자동으로 에러 미들웨어로 전달
res.json(items);
});
Express 5는 2024년에 정식 출시되었지만, 기존 프로젝트 대부분은 아직 Express 4를 사용하고 있다. Express 4 환경이라면 asyncHandler는 여전히 필수적인 패턴이다.
정리
| 방식 | 장점 | 단점 |
|---|---|---|
| 매번 try-catch | 명시적, 의존성 없음 | 보일러플레이트 반복, 누락 위험 |
| asyncHandler 래퍼 | 간결, 명시적 | 매번 감싸야 함 |
| express-async-errors | 코드 변경 최소 | 암시적, 호환성 리스크 |
| Express 5 | 네이티브 지원 | Express 5로 마이그레이션 필요 |
asyncHandler는 결국 "async 함수의 에러를 Express에 연결해주는 다리" 역할을 하는 3줄짜리 유틸리티다. 원리를 이해하고 나면 단순하지만, 이것 하나로 프로젝트 전체의 에러 처리 안정성이 크게 달라진다.
관련 문서
- Express 에러 처리 미들웨어 - 환경별 에러 응답 분기
- 커스텀 에러 클래스 - AppError 설계와 isOperational 패턴
- express-session 설정 - 세션 기반 인증 미들웨어 설정