Express 애플리케이션의 강력한 오류 처리: 실용 가이드
Lukas Schneider
DevOps Engineer · Leapcell

소개
웹 개발의 세계에서 강력하고 안정적인 애플리케이션을 구축하는 것은 무엇보다 중요합니다. 아무리 신중하게 작성된 코드라도 네트워크 장애부터 잘못된 사용자 입력까지 예상치 못한 문제에 직면할 수 있습니다. 이러한 오류를 예측하고 우아하게 처리하는 방식은 사용자 경험, 애플리케이션 안정성 및 개발자 생산성에 크게 영향을 미칠 수 있습니다. 인기 있고 미니멀한 웹 프레임워크인 Express.js를 사용하는 JavaScript 개발자에게 효과적인 오류 처리 전략을 이해하는 것은 매우 중요합니다. 이 글에서는 Express 애플리케이션에서의 오류 관리를 위한 모범 사례를 다루며, try-catch 블록, Promise.catch() 핸들러 및 전역 오류 미들웨어 간의 상호 작용에 중점을 둡니다. 이러한 기법을 마스터하면 기능적일 뿐만 아니라 복원력 있고 유지보수 가능한 Express 애플리케이션을 구축할 수 있습니다.
핵심 개념 이해
Express의 오류 처리 구체적인 사항으로 뛰어들기 전에, 우리 전략의 빌딩 블록이 되는 JavaScript의 기본적인 오류 처리 메커니즘을 간략하게 살펴보겠습니다.
-
try-catch블록: 이 동기 오류 처리 메커니즘을 사용하면 코드 블록을try할 수 있으며, 해당 블록 내에서 오류가 발생하면catch하여 처리할 수 있습니다. 직접 오류를 던질 수 있는 동기 작업에 이상적입니다.try { // 오류를 발생시킬 수 있는 코드 const result = JSON.parse("{invalid json"); console.log(result); } catch (error) { console.error("오류 발생:", error.message); } -
Promises: Promises는 비동기 작업의 최종 완료(또는 실패)와 그 결과 값을 나타내는 객체입니다. 비동기 코드를 처리하는 구조화된 방법을 제공합니다.
-
Promises의
.catch(): Promises를 사용할 때 비동기 작업 중에 발생하는 오류는 일반적으로 promise 체인을 따라 전파되며.catch()메서드를 사용하여 catch할 수 있습니다. 이것은try-catch의 비동기적인 동등물입니다.function fetchData() { return new Promise((resolve, reject) => { setTimeout(() => { const success = Math.random() > 0.5; if (success) { resolve("데이터를 성공적으로 가져왔습니다!"); } else { reject(new Error("데이터를 가져오는 데 실패했습니다.")); } }, 1000); }); } fetchData() .then(data => console.log(data)) .catch(error => console.error("Promise catch 오류:", error.message)); -
Express 미들웨어: Express 미들웨어 함수는 요청 객체(
req), 응답 객체(res), 그리고 애플리케이션의 요청-응답 주기에서 다음 미들웨어 함수에 접근할 수 있는 함수입니다. 코드를 실행하고, 요청 및 응답 객체를 수정하고, 요청-응답 주기를 종료하거나 다음 미들웨어를 호출할 수 있습니다. Express의 오류 처리 미들웨어는(err, req, res, next)의 네 가지 인수를 받는 특수 유형의 미들웨어입니다.
Express 오류 처리를 위한 모범 사례
이러한 개념을 Express 애플리케이션을 위한 실용적이고 효과적인 오류 처리 전략에 통합해 보겠습니다.
1. 동기 작업에 대한 로컬 try-catch
라우트 핸들러 또는 기타 미들웨어 내의 동기 코드의 경우 try-catch는 즉각적인 오류를 처리하는 가장 간단한 방법입니다. 이렇게 하면 동기 오류가 서버를 충돌시키는 것을 방지하고 클라이언트에게 의미 있는 오류 응답을 반환할 수 있습니다.
// 예시: 오류를 발생시킬 수 있는 동기 작업 app.get('/sync-data', (req, res, next) => { try { const userInput = req.query.data; if (!userInput) { throw new Error("Data 쿼리 매개변수가 필요합니다."); } // 동기 처리 실패 시뮬레이션 if (userInput === 'fail') { throw new Error("시뮬레이션된 동기 처리 오류."); } res.status(200).send(`처리됨: ${userInput}`); } catch (error) { // 오류를 다음 오류 처리 미들웨어로 전달 next(error); } });
이 예시에서 JSON.parse가 실패하거나 사용자 정의 유효성 검사가 오류를 발생시키면 catch 블록이 이를 캡처합니다. catch 블록에서 직접 오류 응답을 보내는 대신(일관성 없는 오류 형식으로 이어질 수 있음), next(error)를 사용하여 전역 오류 처리기로 전달합니다. 이렇게 하면 오류 응답이 중앙 집중화됩니다.
2. 비동기 작업에 대한 Promise.catch()
비동기 작업(예: 데이터베이스 호출, API 요청, 파일 I/O)을 처리할 때 Promises가 표준입니다. Promise 체인 내에서 발생하는 모든 오류는 .catch()를 사용하여 catch해야 합니다. try-catch와 마찬가지로, catch된 오류를 next 미들웨어로 전달하여 중앙에서 처리하는 것이 모범 사례입니다.
// 예시: Promise를 사용한 비동기 작업 app.get('/async-data', (req, res, next) => { someAsyncOperation(req.query.id) .then(data => { if (!data) { // 데이터가 없는 경우 사용자 정의 오류 객체를 생성할 수 있습니다. const error = new Error("데이터를 찾을 수 없습니다."); error.statusCode = 404; // 오류 처리기를 위해 상태 코드 추가 throw error; // .then() 내에서 던지면 다음 .catch()로 전달됨 } res.status(200).json(data); }) .catch(error => { // someAsyncOperation 또는 .then() 내의 throws에서 발생하는 모든 오류 catch next(error); }); }); // 비동기 작업을 시뮬레이션하는 헬퍼 함수 function someAsyncOperation(id) { return new Promise((resolve, reject) => { setTimeout(() => { if (id === '123') { resolve({ id: '123', name: '샘플 항목' }); } else if (id === 'error') { reject(new Error("데이터베이스 연결 실패.")); } else { resolve(null); // 데이터 없음 시뮬레이션 } }, 500); }); }
async/await를 사용하여 더 깔끔한 비동기 오류 처리:
async/await를 사용하면 비동기 코드를 동기 코드처럼 보이게 작성하고 작동하게 할 수 있어, 비동기 작업에 대해서도 try-catch 블록을 적용할 수 있습니다. 가독성을 위해 종종 선호되는 접근 방식입니다.
// 예시: async/await 와 try-catch app.get('/async-await-data', async (req, res, next) => { try { const id = req.query.id; if (!id) { const error = new Error("ID 매개변수가 필요합니다."); error.statusCode = 400; throw error; } const data = await someAsyncOperation(id); // promise를 await if (!data) { const error = new Error("항목을 찾을 수 없습니다."); error.statusCode = 404; throw error; } res.status(200).json(data); } catch (error) { // 모든 오류(동기 및 비동기)가 여기서 catch됨 next(error); } });
이 패턴은 async 함수 내의 단일 try-catch 블록에서 동기 및 비동기 작업 모두에 대한 오류 처리를 중앙 집중화하여 코드를 훨씬 더 깔끔하고 이해하기 쉽게 만듭니다.
3. 전역 오류 처리 미들웨어
이것은 강력한 Express 오류 처리 전략의 초석입니다. 전역 오류 미들웨어 함수는 다른 모든 라우트 및 미들웨어 뒤에 등록됩니다. Express는 네 가지 인수 (err, req, res, next)를 받기 때문에 이를 오류 처리기로 인식합니다. 라우트 또는 다른 미들웨어에서 next(error)로 전달된 모든 오류는 결국 여기에 도달합니다.
// 전역 오류 처리 미들웨어 정의 // 이것은 Express 앱 정의에서 마지막 미들웨어여야 합니다. app.use((err, req, res, next) => { console.error(`오류 발생: ${err.message}`); // 개발 모드에서는 전체 스택 추적을 로깅하지만, 프로덕션에서는 아닐 수 있습니다. if (process.env.NODE_ENV === 'development') { console.error(err.stack); } // 상태 코드 결정 // 오류 객체에 연결된 상태 코드를 우선적으로 사용하고, 기본값은 500입니다. const statusCode = err.statusCode || 500; // 일관된 오류 응답 구성 res.status(statusCode).json({ status: 'error', statusCode: statusCode, message: err.message || '예상치 못한 오류가 발생했습니다.', // 프로덕션에서는 클라이언트에 스택 추적과 같은 민감한 오류 세부 정보를 보내지 않도록 합니다. // 500 오류의 경우 일반적인 메시지를 보낼 수 있습니다. ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) }); });
전역 오류 미들웨어의 주요 이점:
- 중앙 집중식 오류 처리: 애플리케이션 전체의 모든 오류가 단일 지점으로 라우팅되어 일관된 오류 응답을 보장합니다.
- 분리: 라우트 핸들러는 애플리케이션 로직에 집중하고 오류 응답 형식 지정은 미들웨어에 위임합니다.
- 안전망: 개별
try-catch또는.catch()블록에서 벗어나는 처리되지 않은 오류를 catch하여 서버 충돌을 방지합니다. - 로깅: 컴퓨터 콘솔, 파일 또는 외부 로깅 서비스에 오류를 로깅하는 이상적인 장소입니다.
- 사용자 정의: 오류 유형, 환경(개발 vs. 프로덕션) 및 기타 요인에 따라 오류 응답을 맞춤 설정할 수 있습니다.
컴포넌트 통합: 전체 예시
const express = require('express'); const app = express(); const port = 3000; // JSON 요청 파싱을 위한 미들웨어 app.use(express.json()); // 비동기 작업을 시뮬레이션하는 헬퍼 함수 function simulateDBFetch(id) { return new Promise((resolve, reject) => { setTimeout(() => { if (id === '1') { resolve({ id: '1', name: 'Product A', price: 29.99 }); } else if (id === 'critical-fail') { reject(new Error("데이터베이스 연결 오류.")); } else { resolve(null); // 찾을 수 없음 } }, 300); }); } // 동기 및 비동기(async/await) 오류 처리가 포함된 라우트 app.get('/products/:id', async (req, res, next) => { try { const productId = req.params.id; // 동기 유효성 검사 if (!productId || typeof productId !== 'string') { const error = new Error("유효하지 않은 제품 ID가 제공되었습니다."); error.statusCode = 400; throw error; // catch 블록으로 직접 던짐 } // 잠재적인 오류가 있는 비동기 작업 const product = await simulateDBFetch(productId); if (!product) { const error = new Error(`ID ${productId}인 제품을 찾을 수 없습니다.`); error.statusCode = 404; throw error; // catch 블록으로 직접 던짐 } res.status(200).json(product); } catch (error) { // 모든 오류(동기 또는 비동기)를 catch하여 전역 오류 처리기로 전달 next(error); } }); // 처리되지 않은 Promise 거부를 시연하는 라우트 (전역 처리되지 않은 거부 핸들러 또는 Express 5+ 필요) // Express 5+는 async 라우트의 오류를 자동으로 catch하여 다음 오류 미들웨어로 전달하지만, 명시적인 .catch(next) 또는 async/await try-catch는 여전히 좋은 습관입니다. app.get('/unhandled-promise', (req, res, next) => { // 이 promise는 거부될 것이며 여기서 catch되지 않으면 Express의 기본 핸들러 또는 사용자 정의 전역 핸들러에 의해 catch됩니다. // Express 4.x의 경우 이는 처리되지 않은 promise 거부로 인해 프로세스가 충돌할 가능성이 높습니다. // Express 5.x+의 경우 이 거부는 자동으로 다음 오류 미들웨어로 전달됩니다. Promise.reject(new Error("이것은 처리되지 않은 promise 거부 오류입니다!")); }); // 동기 서버 측 렌더링 오류 라우트 (예시) app.get('/render-error', (req, res, next) => { try { // 렌더링 오류 시뮬레이션, 예: 누락된 템플릿 변수 // const templateEngine = require('some-template-engine'); // templateEngine.render('non-existent-template', { data: null }); throw new Error("누락된 템플릿 데이터로 인해 페이지 렌더링에 실패했습니다."); } catch (error) { next(error); } }); // 404 찾을 수 없음 핸들러 - 특정 유형의 오류로 작동 app.use((req, res, next) => { const error = new Error(`찾을 수 없습니다 ${req.originalUrl}`); error.statusCode = 404; next(error); // 오류 처리 미들웨어로 전달 }); // 전역 오류 처리 미들웨어 (마지막이어야 함) app.use((err, req, res, next) => { console.error(`[ERROR] ${err.message}`); if (process.env.NODE_ENV === 'development' && err.stack) { console.error(err.stack); } const statusCode = err.statusCode || 500; const responseBody = { status: 'error', statusCode: statusCode, message: err.message || '서버에서 문제가 발생했습니다.', }; // 프로덕션에서는 500에 대한 자세한 오류를 공개하지 마십시오. if (statusCode === 500 && process.env.NODE_ENV === 'production') { responseBody.message = '내부 서버 오류가 발생했습니다.'; } res.status(statusCode).json(responseBody); }); // 서버 시작 app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); console.log(`Open http://localhost:${port}/products/1`); console.log(`Open http://localhost:${port}/products/non-existent`); console.log(`Open http://localhost:${port}/products/critical-fail`); console.log(`Open http://localhost:${port}/unhandled-promise (Express 5+ or with global unhandled rejection catch)`); console.log(`Open http://localhost:${port}/render-error`); console.log(`Open http://localhost:${port}/non-existent-path`); });
중요 고려 사항:
-
Express 5.0 및
async라우트 핸들러: Express 5.0(현재 베타/RC 상태)은async라우트 핸들러 내에서 발생한 오류를 자동으로 catch하고 다음 오류 미들웨어로 전달합니다. 이렇게 하면 모든async핸들러에서await호출 주위에 명시적인try-catch블록을 사용하는 빈도가 줄어듭니다. 그러나 특정statusCode또는 메시지를 오류에 연결하여 전달 하기 전에 세분화된 오류 처리를 위해try-catch를 사용하는 것은 여전히 훌륭합니다. -
처리되지 않은 Promise 거부 (Express 라우트 외부): Express 5.x는
async라우트의 오류를 처리하지만, Express 요청/응답 주기 외부에서 거부되거나 직접await되지 않는 promise에 대한 전역unhandledRejection핸들러는 여전히 중요합니다.process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); // 애플리케이션별 로깅, 정리 또는 프로세스 종료 // 여기서도 던지지 마십시오! // process.exit(1); // 심각한 오류인 경우 잠재적으로 종료 }); -
오류 로깅: 강력한 로깅 솔루션(예: Winston, Pino)을 통합하여 오류 세부 정보, 스택 추적 및 컨텍스트를 캡처합니다. 이는 디버깅 및 모니터링에 매우 유용합니다.
-
사용자 정의 오류 클래스: 보다 구조화된 오류 처리를 위해
Error를 상속하고 상태 코드 또는 기타 관련 정보를 임베딩할 수 있는 사용자 정의 오류 클래스(예:NotFoundError,ValidationError,UnauthorizedError)를 만드는 것을 고려하십시오. 이렇게 하면 전역 미들웨어에서 오류 식별 및 처리가 훨씬 더 깔끔해집니다.class AppError extends Error { constructor(message, statusCode) { super(message); this.statusCode = statusCode; this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; this.isOperational = true; // 프로그래밍 오류와 운영 오류를 구분하기 위함 Error.captureStackTrace(this, this.constructor); } } // 사용법: throw new AppError('Product not found', 404);
결론
효과적인 오류 처리는 강력한 소프트웨어의 특징입니다. 동기 작업(async/await 특히)에 try-catch를 체계적으로 사용하고, 비동기 흐름에 Promise.catch()를 활용하며, 전역 오류 처리 미들웨어를 통해 오류 응답을 중앙 집중화함으로써 Express.js 개발자는 복원력이 뛰어나고 클라이언트에게 일관된 피드백을 제공하며 유지보수 및 디버깅이 더 쉬운 애플리케이션을 구축할 수 있습니다. 이 계층화된 접근 방식은 어떤 오류도 처리되지 않도록 보장하여 더 안정적이고 사용자 친화적인 경험으로 이어집니다.