Node.js 인터뷰 질문 23
질문: Node.js에서 효과적인 에러 처리는 어떻게 구현하나요?
답변:
Node.js에서 효과적인 에러 처리는 애플리케이션의 안정성, 가용성 및 디버깅 용이성을 위해 매우 중요합니다. 적절한 에러 처리를 구현하면 예상치 못한 상황에서도 애플리케이션이 예측 가능하게 동작하며, 개발자와 사용자 모두에게 더 나은 경험을 제공합니다.
에러 유형 이해하기
Node.js에서는 다양한 유형의 에러가 발생할 수 있습니다:
표준 JavaScript 에러:
Error
: 일반적인 에러SyntaxError
: 문법 오류ReferenceError
: 존재하지 않는 변수 참조TypeError
: 잘못된 타입 연산RangeError
: 범위를 벗어난 값 사용
Node.js 특화 에러:
SystemError
: 운영 체제 수준 에러 (파일 액세스, 네트워크 연결 등)- 각종 모듈별 특화 에러 (예:
fs
모듈의 파일 접근 에러)
동기식 코드에서의 에러 처리
동기식 코드에서는 전통적인 try-catch 블록을 사용합니다:
try {
// 에러가 발생할 수 있는 동기식 코드
const data = fs.readFileSync("non-existent-file.txt");
console.log(data);
} catch (err) {
// 에러 처리
console.error("파일 읽기 오류:", err.message);
// 에러 유형에 따른 처리
if (err.code === "ENOENT") {
console.error("파일이 존재하지 않습니다.");
}
}
비동기식 코드에서의 에러 처리
1. 콜백 패턴
Node.js의 전통적인 콜백 패턴에서는 첫 번째 인자로 에러 객체를 전달합니다 (Error-first 콜백):
fs.readFile("file.txt", (err, data) => {
if (err) {
// 에러 처리
console.error("파일 읽기 오류:", err.message);
return;
}
// 정상 처리
console.log(data.toString());
});
2. Promise 기반 에러 처리
Promise를 사용한 비동기 코드에서는 .catch()
를 사용하여 에러를 처리합니다:
fs.promises
.readFile("file.txt")
.then((data) => {
// 정상 처리
console.log(data.toString());
})
.catch((err) => {
// 에러 처리
console.error("파일 읽기 오류:", err.message);
});
Promise 체인에서는 각 단계별로 에러를 처리할 수 있습니다:
fetchData()
.then((data) => processData(data))
.then((processedData) => saveData(processedData))
.catch((err) => {
if (err.name === "FetchError") {
console.error("데이터 가져오기 오류:", err.message);
} else if (err.name === "ProcessingError") {
console.error("데이터 처리 오류:", err.message);
} else {
console.error("알 수 없는 오류:", err.message);
}
})
.finally(() => {
// 항상 실행되는 정리 코드
console.log("작업 완료");
});
3. Async/Await를 사용한 에러 처리
Async/Await 패턴을 사용하면 비동기 코드에서도 try-catch 블록을 사용할 수 있습니다:
async function readAndProcessFile() {
try {
const data = await fs.promises.readFile("file.txt");
// 정상 처리
const processedData = await processData(data);
return processedData;
} catch (err) {
// 에러 처리
console.error("파일 처리 오류:", err.message);
throw new Error("파일 처리 중 오류가 발생했습니다: " + err.message);
}
}
// 함수 사용
readAndProcessFile()
.then((result) => console.log("처리 결과:", result))
.catch((err) => console.error("최종 오류:", err.message));
커스텀 에러 클래스 만들기
애플리케이션에 특화된 에러 유형을 정의하면 에러 처리와 디버깅이 용이해집니다:
class DatabaseError extends Error {
constructor(message, query) {
super(message);
this.name = 'DatabaseError';
this.query = query;
this.date = new Date();
// 스택 트레이스 유지
Error.captureStackTrace(this, DatabaseError);
}
}
// 사용 예시
function queryDatabase(query) {
try {
// 데이터베이스 쿼리 실행
if (/* 쿼리 실패 조건 */) {
throw new DatabaseError('데이터베이스 쿼리 실패', query);
}
} catch (err) {
console.error(`${err.name}: ${err.message}`);
console.error(`문제가 발생한 쿼리: ${err.query}`);
console.error(`발생 시간: ${err.date}`);
throw err; // 필요에 따라 상위로 에러 전파
}
}
에러 전파 및 중앙화된 에러 처리
일관된 에러 처리 전략을 구현하기 위해 에러를 중앙화하여 처리할 수 있습니다:
Express.js에서의 에러 처리 미들웨어
const express = require("express");
const app = express();
// 일반 라우트 및 미들웨어
app.get("/data", async (req, res, next) => {
try {
// 비즈니스 로직
const data = await fetchData();
res.json(data);
} catch (err) {
// 에러를 다음 미들웨어로 전달
next(err);
}
});
// 에러 처리 미들웨어 (모든 라우트 후에 정의)
app.use((err, req, res, next) => {
console.error("애플리케이션 에러:", err);
// 에러 유형에 따른 응답
if (err.name === "ValidationError") {
return res.status(400).json({
error: "유효성 검증 실패",
details: err.details,
});
}
if (err.name === "DatabaseError") {
return res.status(503).json({
error: "데이터베이스 오류",
message: "서비스를 일시적으로 사용할 수 없습니다.",
});
}
// 일반적인 서버 에러
res.status(500).json({
error: "서버 오류",
message:
process.env.NODE_ENV === "production"
? "서비스 처리 중 문제가 발생했습니다."
: err.message,
});
});
스트림과 이벤트 에미터에서의 에러 처리
스트림이나 이벤트 에미터를 사용할 때는 'error' 이벤트 리스너를 추가해야 합니다:
const readable = getReadableStream();
// 스트림 에러 처리
readable.on("error", (err) => {
console.error("스트림 에러:", err.message);
});
// 이벤트 에미터 에러 처리
const myEmitter = new EventEmitter();
myEmitter.on("error", (err) => {
console.error("이벤트 에미터 에러:", err.message);
});
처리되지 않은 예외 처리
프로세스 전체에 영향을 미칠 수 있는 처리되지 않은 예외를 캐치하기 위한 메커니즘:
// 처리되지 않은 예외 캐치
process.on("uncaughtException", (err) => {
console.error("처리되지 않은 예외:", err);
// 로그 기록, 알림 발송 등의 작업 수행
// 프로세스 종료 (권장)
process.exit(1);
});
// 처리되지 않은 Promise 거부 캐치
process.on("unhandledRejection", (reason, promise) => {
console.error("처리되지 않은 Promise 거부:", reason);
});
주의: uncaughtException
이벤트는 마지막 수단으로 사용해야 합니다. 이 이벤트가 발생한 후에는 애플리케이션의 상태가 불안정할 수 있으므로, 가능한 한 정상적으로 종료하는 것이 좋습니다.
에러 로깅과 모니터링
효과적인 에러 처리를 위해 로깅 및 모니터링 시스템을 구축하는 것이 중요합니다:
const winston = require("winston");
// 로거 설정
const logger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
// 콘솔 출력
new winston.transports.Console(),
// 파일 저장
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "combined.log" }),
],
});
// 에러 발생 시 로깅
try {
someFunction();
} catch (err) {
logger.error("에러 발생", {
error: {
message: err.message,
stack: err.stack,
name: err.name,
},
additionalInfo: "관련 컨텍스트 정보",
});
}
실제 프로덕션 환경에서는 Sentry, Datadog, New Relic과 같은 외부 모니터링 서비스를 활용하여 에러를 실시간으로 모니터링하고 알림을 받는 것이 좋습니다.
에러 처리 모범 사례
항상 에러를 처리하라: 모든 비동기 작업과 외부 서비스 호출에는 에러 처리 코드를 포함해야 합니다.
에러 메시지는 명확하게: 에러 메시지는 문제의 원인과 가능한 해결책을 제시할 수 있도록 상세하게 작성합니다.
사용자에게 적절한 정보만 제공: 프로덕션 환경에서는 사용자에게 상세한 에러 정보를 노출하지 않습니다.
발생 가능한 에러 유형을 문서화: API와 함수에서 발생할 수 있는 에러 유형을 문서화하여 개발자가 적절히 대응할 수 있도록 합니다.
단계적 에러 처리: 각 계층(데이터 액세스, 비즈니스 로직, API)에서 적절한 수준의 에러 처리를 구현합니다.
에러 회복 메커니즘: 가능한 경우 에러로부터 회복할 수 있는 메커니즘을 구현합니다(예: 재시도 로직).
테스트 중 에러 시나리오 포함: 단위 테스트와 통합 테스트에 다양한 에러 시나리오를 포함시킵니다.
효과적인 에러 처리는 단순히 예외를 catch하는 것을 넘어, 애플리케이션의 안정성을 보장하고 문제를 빠르게 진단하고 해결할 수 있는 종합적인 전략이 필요합니다. 잘 설계된 에러 처리 시스템은 개발자 경험과 사용자 경험 모두를 향상시키는 핵심 요소입니다.