Express 라우트에서 Try-Catch 안티패턴 피하기
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
Node.js와 웹 개발의 세계에서 Express.js는 강력한 API와 웹 애플리케이션을 구축하는 데 널리 사용되는 프레임워크입니다. 개발자로서 우리는 항상 깔끔하고, 유지보수 가능하며, 탄력적인 코드를 추구합니다. 특히 웹 서버에 내재된 비동기 작업에서 오류를 처리하는 데 있어서 try-catch 블록은 자주 떠오르는 해결책입니다. 그러나 Express 애플리케이션에서 흔히 발생하는 함정은 각 라우트 핸들러 내에 try-catch를 광범위하게 사용하는 것입니다. 겉보기에는 간단해 보일 수 있지만, 이 접근 방식은 빠르게 오류 처리 안티패턴으로 이어져, 반복적인 코드를 생성하고, 가독성을 떨어뜨리며, 향후 리팩터링을 어렵게 만듭니다. 이 글에서는 이 관행이 왜 문제가 되는지 살펴보고 Express.js에서 비동기 오류를 관리하기 위한 보다 우아하고 확장 가능한 솔루션을 탐구합니다.
광범위한 Try-Catch의 문제점
안티패턴을 분석하기 전에 Express.js 오류 처리에 관련된 몇 가지 핵심 용어를 간략하게 정의해 보겠습니다.
- 라우트 핸들러 (Route Handler): 특정 라우트가 일치할 때 Express가 실행하는 함수입니다. 일반적으로 
req,res,next를 인자로 받습니다. - 미들웨어 (Middleware): 요청 및 응답 객체, 그리고 애플리케이션의 요청-응답 주기의 다음 미들웨어 함수에 액세스할 수 있는 함수입니다. 코드를 실행하거나, 요청 및 응답 객체를 수정하거나, 요청-응답 주기를 종료하거나, 다음 미들웨어를 호출할 수 있습니다.
 - 오류 미들웨어 (Error Middleware): Express에서 특별한 유형의 미들웨어로 (네 개의 인자: 
err,req,res,next로 정의됨) 요청-응답 주기의 오류를 포착하고 처리하도록 특별히 설계되었습니다. - 비동기 작업 (Asynchronous Operations): 데이터베이스 쿼리, 네트워크 요청 또는 파일 I/O와 같이 실행의 메인 스레드를 차단하지 않는 작업입니다. JavaScript에서는 일반적으로 Promises와 
async/await로 처리됩니다. 
안티패턴 설명
데이터베이스에서 데이터를 가져오는 것과 같이 비동기 작업을 수행하는 일반적인 Express 라우트 핸들러를 생각해 보겠습니다.
// 일반적이지만 문제가 있는 접근 방식 app.get('/users/:id', async (req, res) => { try { const user = await UserModel.findById(req.params.id); if (!user) { return res.status(404).send('User not found'); } res.json(user); } catch (error) { console.error('Error fetching user:', error); res.status(500).send('Something went wrong'); } });
처음에는 이 코드가 완벽해 보입니다. 데이터베이스 쿼리 중 발생할 수 있는 오류를 처리하고 적절하게 응답합니다. 하지만 수십, 심지어 수백 개의 이러한 라우트가 있는 애플리케이션을 상상해 보세요. 이러한 각 라우트 핸들러는 오류 로깅 및 500 응답 전송과 같은 동일한 오류 처리 로직을 반복하는 자체 try-catch 블록을 가질 가능성이 높습니다. 이는 다음을 초래합니다.
- 반복적인 코드 (Boilerplate Repetition): 모든 곳에 
try-catch블록을 복제하면 코드가 장황해지고 의미 있는 비즈니스 로직이 가려집니다. - 가독성 저하 (Reduced Readability): 라우트 핸들러의 핵심 목적(사용자 가져오기 및 반환)이 오류 처리 문제 아래 묻힙니다.
 - 유지보수 부담 (Maintenance Overhead): 오류를 로깅하는 방법이나 500 응답의 구조(예: 특정 오류 코드 추가)를 변경해야 하는 경우, 모든 
try-catch블록을 수정해야 합니다. - 일관성 없는 오류 응답 (Inconsistent Error Responses): 개발자가 서로 다른 
try-catch블록에서 약간 다른 오류 응답을 구현할 수 있어 API 일관성이 떨어집니다. - 디버깅의 어려움 (Harder Debugging): 광범위한 
try-catch는 주의 깊게 처리하지 않으면 오류의 진정한 출처를 숨겨 디버깅을 더 어렵게 만들 수 있습니다. 
근본적인 문제는 라우트 핸들러 내에서 동기적으로 포착된 async/await 오류가 Express의 전역 오류 미들웨어로 자동으로 전파되지 않는다는 것입니다. 이를 위해서는 오류를 next 함수로 명시적으로 전달해야 합니다.
더 나은 솔루션
좋은 소식은 Express가 비동기 오류를 훨씬 더 우아하게 처리할 수 있는 메커니즘을 제공한다는 것입니다.
1. next(error) 사용
try-catch를 계속 사용하면서도 try-catch 안티패턴을 수정하는 가장 직접적인 방법은 포착된 오류를 next 함수로 명시적으로 전달하는 것입니다. 이렇게 하면 Express에 후속 미들웨어 및 라우트 핸들러를 건너뛰고 대신 오류 처리 미들웨어를 호출하도록 지시합니다.
app.get('/users/:id', async (req, res, next) => { // 'next'를 잊지 마세요 try { const user = await UserModel.findById(req.params.id); if (!user) { // 애플리케이션별 오류의 경우 사용자 정의 오류 클래스를 만드는 것이 좋습니다. return res.status(404).send('User not found'); } res.json(user); } catch (error) { next(error); // 오류 처리 미들웨어로 오류 전달 } }); // 전역 오류 처리 미들웨어 (마지막에 정의해야 함) app.use((err, req, res, next) => { console.error('Caught by error middleware:', err.stack); // 디버깅을 위해 스택 추적 로깅 res.status(err.statusCode || 500).json({ message: err.message || 'Something went wrong', error: process.env.NODE_ENV === 'development' ? err : {} // 개발 중에만 상세 오류 전송 }); });
이 접근 방식은 오류 미들웨어에서 오류에 대한 응답 로직을 중앙 집중화하는 동시에, 여전히 라우트 내에서 오류를 포착하기 위해 try-catch를 허용합니다.
2. 비동기 래퍼 (고차 함수) 사용
더 나아가, 고차 함수를 사용하여 라우트 핸들러에서 try-catch 블록을 완전히 추상화할 수 있습니다.
// utils/asyncHandler.js const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; // app.js // ... 기타 가져오기 및 미들웨어 app.get('/users/:id', asyncHandler(async (req, res) => { const user = await UserModel.findById(req.params.id); if (!user) { // 상태 코드를 포함하는 사용자 정의 오류를 여기서 throw할 수 있습니다. throw new Error('User not found'); } res.json(user); })); app.post('/products', asyncHandler(async (req, res) => { const newProduct = await ProductModel.create(req.body); res.status(201).json(newProduct); })); // 전역 오류 처리 미들웨어 (위와 동일) app.use((err, req, res, next) => { console.error('Caught by error middleware:', err.stack); res.status(err.statusCode || 500).json({ message: err.message || 'Something went wrong', error: process.env.NODE_ENV === 'development' ? err : {} }); });
여기서 asyncHandler는 다음과 같이 작동합니다.
- 비동기 함수(
fn)를 인자로 받습니다. - 새로운 미들웨어 함수 
(req, res, next)를 반환합니다. - 이 새 함수 내부에서 원래 
fn을 실행합니다. Promise.resolve(fn(...))는fn이 명시적으로async가 아니더라도 그 반환 값을 Promise로 처리하도록 보장합니다..catch(next)는fn(또는fn이 await하는 Promise) 내에서 발생하거나 거부된 모든 오류를 포착하여 Express의next함수로 직접 전달하며, 이는 전역 오류 미들웨어를 트리거합니다.
이 패턴은 개별 라우트 핸들러에서 try-catch 반복 코드를 완전히 제거하여 비즈니스 로직에 훨씬 더 깔끔하고 집중할 수 있게 합니다.
3. 비동기 오류 처리를 위한 라이브러리 사용
더 편리하고 강력한 오류 처리를 위해 특수 라이브러리를 사용할 수 있습니다. 인기 있는 선택지는 express-async-errors입니다.
// index.js 또는 app.js require('express-async-errors'); // 애플리케이션의 최상단에 임포트 const express = require('express'); const app = express(); // ... 기타 미들웨어 app.get('/users/:id', async (req, res) => { const user = await UserModel.findById(req.params.id); if (!user) { throw new Error('User not found'); } res.json(user); }); // 오류를 throw하는 모든 라우트 핸들러는 자동으로 다음을 통해 포착됩니다: app.use((err, req, res, next) => { console.error('Caught by error middleware:', err.stack); res.status(err.statusCode || 500).json({ message: err.message || 'Something went wrong', error: process.env.NODE_ENV === 'development' ? err : {} }); }); // ... 서버 시작
express-async-errors를 한 번만 임포트하면 Express를 패치하여 비동기 라우트 핸들러에서 처리되지 않은 Promise 거부를 자동으로 포착하고 오류 미들웨어로 전달하므로, 각 라우트 핸들러에 asyncHandler 래퍼나 명시적인 try-catch 블록이 필요하지 않습니다.
결론
try-catch는 오류 처리를 위한 기본적인 도구이지만, 모든 비동기 Express 라우트 핸들러 내에 광범위하게 사용하는 것은 지저분하고, 반복적이며, 유지보수하기 어려운 코드를 초래하는 안티패턴입니다. Express의 next(error) 메커니즘을 활용하거나, 재사용 가능한 asyncHandler 래퍼를 만들거나, express-async-errors와 같은 특수 라이브러리를 통합함으로써 개발자는 비동기 오류 관리를 중앙 집중화하고 간소화할 수 있습니다. 이는 더 깔끔하고 가독성 높은 라우트 핸들러가 비즈니스 로직에 집중하고, Express 애플리케이션 전반에 걸쳐 더 강력하고 일관된 오류 처리 전략을 제공합니다. 이러한 패턴을 채택하여 더 탄력적이고 유지보수 가능한 웹 서비스를 구축하십시오.

