9편. 에러 처리 기법 이해하기
📚 목차
1. Error 객체 구조와 스택 트레이스 (Stack Trace)
2. Error.cause (ES2022)
3. throw & try/catch 실무 규칙
4. 비동기 에러 처리 (Promise/async/await, 이벤트 error)
5. Custom Error 클래스 설계

📂 [GitHub 실습 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /JavaScript
1. Error 객체 구조와 스택 트레이스 (Stack Trace)
JavaScript에서 오류가 발생하면 엔진은 단순히 프로그램 실행을 중단하는 것이 아니라, 해당 오류 상황을 상세하게 기술한 Error 객체를 생성합니다.
이 객체는 디버깅 과정에서 중요한 역할을 하며, 오류의 종류와 발생 지점을 체계적으로 파악할 수 있도록 도와줍니다.
🔷 Error 객체의 핵심 속성
1. name
발생한 오류의 유형을 나타내는 문자열입니다
예:
▸ ReferenceError
▸ TypeError
▸ SyntaxError
▸ RangeError 등
2. message
오류가 왜 발생했는지를 설명하는 사람이 읽을 수 있는 문자열입니다.
디버깅과 로깅 시 가장 먼저 참고하는 정보입니다.
3. stack(스택 트레이스)
stack은 Error 객체에서 가장 중요한 속성으로, 오류가 발생하기까지 어떤 함수들이 어떤 순서로 호출되었는지를 기록한 "호출 스택(Call Stack)" 정보입니다.
스택 트레이스는 일종의 "사고 현장 기록"과 동일하며, 문제가 발생한 정확한 위치와 호출 흐름을 추적하는 데 매우 중요한 단서를 제공합니다.
✔️ 예시 코드
아래 코드는 의도적으로 ReferenceError를 발생시킨 후, Error 객체의 주요 속성을 출력하여 호출 경로를 확인하는 예제입니다.
function thirdFunc() {
// 정의되지 않은 변수를 참조하여 ReferenceError를 발생시킵니다.
console.log(nonExistentVariable);
}
function secondFunc() {
// 세 번째 함수를 호출합니다.
thirdFunc();
}
function firstFunc() {
try {
// 비정상 동작이 발생할 가능성이 있는 함수 호출을 감싸고 있습니다.
secondFunc();
} catch (error) {
console.error("--- 에러 정보 ---");
// Error 객체의 핵심 속성을 출력합니다.
// ReferenceError
console.error("에러 이름 (name):", error.name);
// nonExistentVariable is not defined
console.error("에러 메시지 (message):", error.message);
console.error("\n--- 스택 트레이스 (stack) ---");
// 에러가 발생한 지점까지의 호출 경로를 보여줍니다.
console.error(error.stack);
/*
출력된 스택 트레이스를 통해
firstFunc → secondFunc → thirdFunc 순으로 함수가 호출되었고,
최종적으로 thirdFunc에서 오류가 발생했음을 확인할 수 있습니다.
*/
}
}
// 전체 호출 흐름을 시작합니다.
firstFunc();
2. Error.cause (ES2022)
실무 환경에서는 여러 함수 호출과 비동기 작업이 연속적으로 수행되는 과정에서 오류가 발생하는 경우가 흔합니다.
예를 들어, 데이터베이스 조회 과정에서 발생한 오류를 상위 계층의 API 핸들러가 자체적인 오류 형식으로 다시 포장하여 던져야 하는 상황이 있을 수 있습니다.
ES2022에서 도입된 Error.cause 속성은 이러한 시나리오에서 새로운 오류를 생성할 때, 그 원인이 된 최초의 오류를 체계적으로 연결(Chaining) 할 수 있도록 지원합니다.
이를 통해 오류를 필요한 형태로 캡슐화하면서도, 디버깅 과정에서 중요한 원본 오류 정보를 잃지 않도록 보존할 수 있어 매우 유용합니다.
✔️ Error.cause 특징
▸ Error.cause는 ES2022에서 도입된 기능으로, 새로운 오류를 생성할 때 “원인 오류”를 안전하게 전달할 수 있게 해주는 메커니즘입니다.
▸ 복잡한 호출 구조를 가진 애플리케이션에서 오류를 재래핑하더라도 근본 원인을 잃지 않도록 유지할 수 있다는 점에서 극도로 유용합니다.
▸ 디버깅, 로깅, 모니터링 시스템과 결합될 때 특히 강력한 효과를 발휘합니다.
✔️ Error.cause를 이용한 에러 연결
function callExternalAPI() {
// 외부 API 호출 시 발생한 에러를 시뮬레이션합니다.
throw new Error("외부 서버 응답 오류 (HTTP 500)", { cause: 'Connection Timeout' });
}
function handleUserData() {
try {
callExternalAPI();
} catch (externalError) {
// (1) 외부 에러를 잡습니다.
console.error("1차 에러: 외부 API 호출 실패");
// (2) 더 상위 계층으로 던지기 위해 Custom Error로 포장합니다.
// 이때, 'cause' 속성에 최초의 에러 (externalError)를 담아 정보를 보존합니다.
throw new Error("사용자 데이터 처리 중 심각한 오류 발생", { cause: externalError });
}
}
try {
handleUserData();
} catch (finalError) {
console.error("\n최종 에러 (사용자에게 표시):", finalError.message);
// (3) 'cause' 속성을 확인하여 에러의 실제 원인을 추적합니다.
if (finalError.cause) {
console.error("--- 에러의 실제 원인 추적 ---");
console.error("최초 원인 객체:", finalError.cause);
console.error("최초 원인 메시지:", finalError.cause.message || finalError.cause);
}
}
3. throw & try/catch 실무 규칙
에러 처리는 단순히 에러를 잡는 것을 넘어, 코드의 안전성과 복구 전략을 결정하는 핵심 요소입니다.
실무에서는 다음과 같은 "Fail Fast" 원칙과 복구 전략을 따르는 것이 좋습니다.
🔷 1. throw의 역할 - 오류 전파(Propagation)
함수가 정상적인 작업을 더 이상 수행할 수 없을 때 에러를 발생시켜 호출자에게 책임을 넘깁니다.
특히, 잘못된 인수가 전달되거나 데이터베이스 연결 실패 등 '복구 불가능한' 상황에서 사용합니다.
🔷 2. try/catch의 역할 - 오류 복구 및 대응
try/catch는 오류 발생 시 복구가 가능한지 여부에 따라 두 가지 방향으로 사용됩니다.
▸ 복구 가능한 오류 처리
오류가 발생했지만 대체 로직을 수행하거나 사용자에게 더 친절한 메시지를 전달할 수 있는 경우입니다.
▸ 로깅 후 재전파(rethrow)
함수 내부에서 복구할 수 없는 오류라면 해당 함수는 단지 에러를 기록한 후, 오류를 다시 던져 상위 계층에서 적절히 처리하도록 해야 합니다.
🔷 3. finally의 역할 - 정리(Cleanup) 작업
finally 블록은 오류 발생 여부와 관계없이 항상 실행되는 영역으로, 아래와 같은 정리 작업을 수행할 때 필수적입니다.
▸ 파일 핸들 닫기
▸ 데이터베이스 커넥션 반환
▸ 임시 리소스 해제
▸ 트랜잭션 종료
리소스 누수를 방지하기 위해 실무에서 매우 중요하게 다뤄지는 블록입니다.
✔️ try/catch/finally 실무 패턴
아래 예제는 파일 처리 과정에서 발생할 수 있는 복구 가능한 오류와 복구 불가능한 오류를 구분하여 처리하는 실무형 패턴을 보여줍니다.
function processFile(filePath) {
let fileHandle; // 파일 리소스를 추적하기 위한 핸들
try {
// (1) 파일 경로 유효성 검사
if (!filePath) {
// 복구가 불가능한 상황이므로 호출자에게 오류를 전달합니다.
throw new TypeError("파일 경로를 반드시 지정해야 합니다.");
}
// (2) 파일 열기 (예제에서는 가상의 핸들로 대체)
fileHandle = "File_ID_123";
console.log("파일을 성공적으로 열었습니다.");
// (3) 파일 처리 로직이 들어가는 영역
// ...
} catch (error) {
// (4) 에러가 발생했을 때의 처리 로직
if (error instanceof TypeError) {
// 예상 가능한 입력 오류 → 로깅 후 복구 처리 가능
console.error(`[로깅] 입력값 오류: ${error.message}`);
console.log("기본값 또는 대체 경로로 처리할 수 있습니다.");
return null; // 호출자에게 작업 실패를 명확히 전달
} else {
// 예상치 못한 예외 → 로깅 후 재전파(Fail Fast 전략)
console.error(`[심각한 오류] 알 수 없는 오류 발생: ${error.message}`);
throw error; // 호출자에게 다시 전달
}
} finally {
// (5) 에러 발생 여부와 관계없이 반드시 실행되는 정리 작업
if (fileHandle) {
console.log("파일 리소스를 정상적으로 닫았습니다. (정리 작업 완료)");
// fileHandle.close(); // 실제 파일 핸들 해제 코드
}
}
}
// 예시 1: 성공 케이스
console.log("--- 성공 케이스 ---");
processFile("/data/file.txt");
// 예시 2: 복구 가능한 오류(TypeError) 발생
console.log("\n--- 복구 가능한 오류 케이스 ---");
processFile(null);
▸ throw는 복구가 불가능한 오류를 호출자에게 즉시 전달하는 데 사용됩니다.
▸ try/catch는 오류 상황을 복구하거나 적절히 대응하는 영역입니다.
▸ finally는 리소스 정리에 반드시 필요한 안전 장치입니다.
▸ 이 세 요소를 적절히 조합하면, 안정적이고 예측 가능한 오류 흐름을 갖춘 Node.js 애플리케이션을 구축할 수 있습니다.
4. 비동기 에러 처리 (Promise/async/await, 이벤트 error)
JavaScript의 비동기 환경(Promise, async/await, EventEmitter 등)에서는 동기 코드의 try/catch만으로는 모든 오류를 처리할 수 없습니다.
특히 Node.js 환경에서는 네트워크, 파일 I/O, 이벤트 스트림 등 다양한 비동기 흐름이 존재하기 때문에 비동기 오류 전파 방식에 대한 깊은 이해가 필수적입니다.
아래에서는 비동기 코드에서 오류가 전파되는 방식과, 이를 안전하게 관리하는 실무적 기법을 설명합니다.
🔷 Promise: .catch()에 의한 오류 처리
Promise 체인에서는 오류가 발생하면 가장 가까운 .catch() 블록으로 전달됩니다.
▸ then 내부에서 오류가 발생해도 .catch()가 처리합니다.
▸ .catch()가 존재하지 않으면 해당 Promise는 “처리되지 않은 거부(unhandled rejection)” 상태가 됩니다.
▸ 브라우저와 Node.js 모두 “전역 unhandledrejection 이벤트”를 발생시키며, 이는 실무 로깅에서 반드시 처리해야 하는 중요한 이벤트입니다.
// Promise 체인에서 발생한 오류는 가장 가까운 .catch()로 전달됩니다.
function getData() {
return new Promise((resolve, reject) => {
// 의도적으로 오류를 발생시킵니다.
reject(new Error("데이터 가져오기 실패"));
});
}
getData()
.then((data) => {
console.log("데이터:", data);
})
.catch((error) => {
// reject에서 전달된 오류를 여기서 처리합니다.
console.error("[Promise 오류 처리]", error.message);
});
🔷 async/await: 비동기 코드의 동기적 오류 처리
async/await 구문은 Promise를 기반으로 동작하며, await 표현식에서 발생한 오류는 동기 코드와 동일하게 try/catch로 처리할 수 있습니다.
실무에서는 다음 패턴을 가장 권장합니다.
▸ 네트워크 요청, 파일 처리, DB 쿼리 등 오류 가능성이 있는 모든 await는 try/catch로 감싸기
▸ await 이후의 반환값은 반드시 검증(예: response.ok 체크)
이러한 패턴을 통해 오류가 프로그램 전체로 전파되는 것을 방지하고, 사용자 경험 측면에서도 안정적인 UI/UX를 제공할 수 있습니다.
// async/await 환경에서는 발생한 오류를 try/catch로 처리할 수 있습니다.
async function fetchUser() {
try {
// await에서 오류가 발생하면 catch 블록으로 이동합니다.
const response = await fetch("https://invalid-url.example.com");
return await response.json();
} catch (error) {
console.error("[async/await 오류 처리]", error.message);
return null; // 프로그램 중단을 방지하기 위해 기본값 반환
}
}
fetchUser();
🔷 전역 비동기 오류 처리(unhandledrejection, error)
어떠한 이유로든 Promise 오류가 .catch()로 처리되지 않거나 코드 전반에서 예외가 누락될 경우, 시스템은 다음과 같은 전역 이벤트를 발생시킵니다.
브라우저:
▸ window.addEventListener('unhandledrejection')
▸ window.addEventListener('error')
Node.js:
▸ process.on('unhandledRejection')
▸ process.on('uncaughtException')
이 전역 핸들러는 다음 목적을 위해 실무적으로 매우 중요합니다.
▸ 프로덕션 환경에서 누락된 오류를 최종적으로 기록
▸ 모니터링 시스템(예: Sentry, Datadog)에 오류 전송
▸ 예기치 않은 프로그램 중단을 방지하거나 Graceful Shutdown 수행
✔️ 처리되지 않은 Promise 거부(unhandledRejection)
// (1) 전역에서 처리되지 않은 Promise.reject() 감지
process.on("unhandledRejection", (reason, promise) => {
console.error("[Node.js 전역 unhandledRejection 감지]");
console.error("거부된 Promise:", promise);
console.error("이유(reason):", reason);
// 실무에서는 여기서 로깅 및 모니터링 시스템(Sentry, Datadog 등)으로 전송합니다.
// 예: sendErrorToMonitoringService(reason);
// 심각한 문제이므로 프로세스를 종료할지 여부를 판단합니다.
// process.exit(1); // 필요 시 활성화
});
// 의도적으로 .catch()를 생략하여 unhandledRejection 발생
Promise.reject(new Error("전역에서 잡힌 Promise 거부"));
✔️ 처리되지 않은 일반 예외(uncaughtException)
// (2) try/catch로 잡히지 않은 오류 전역 감지
process.on("uncaughtException", (error) => {
console.error("[Node.js 전역 uncaughtException 감지]");
console.error("오류 메시지:", error.message);
console.error("스택:", error.stack);
// 프로덕션에서는 반드시 치명적 오류로 간주합니다.
// 로깅 후 안전하게 서버를 재시작하는 전략을 취합니다.
// 예: cleanupResources(); gracefulShutdown();
// process.exit(1); // 안전하게 종료
});
// 존재하지 않는 함수를 호출하여 uncaughtException 발생
nonExistentFunction();

5. Custom Error 클래스 설계
애플리케이션의 규모가 커지면 에러를 단순히 Error나 TypeError로 처리하는 것은 비효율적입니다.
Custom Error 클래스를 설계하여 에러에 의미 있는 이름을 부여하고 추가 정보를 담을 수 있습니다.
Custom Error는 반드시 JavaScript의 내장 Error 클래스를 상속(extends) 받아야 합니다.
상속을 통해 스택 트레이스가 자동으로 기록되며, try/catch 블록에서 instanceof 연산자를 사용하여 특정 에러만 정확히 식별하고 대응할 수 있습니다.
✔️ 예시 : Custom Error 클래스 설계
/**
* 💡 Custom Error 클래스는 반드시 내장 Error 클래스를 상속받아야 합니다.
*/
class AuthenticationError extends Error {
constructor(message, errorCode = 401) {
// (1) 부모(Error)의 생성자를 호출하여 name과 message를 설정하고, 스택 트레이스를 기록합니다.
super(message);
// (2) 에러 이름을 고정하여 디버깅 시 식별을 용이하게 합니다.
this.name = "AuthenticationError";
// (3) Custom 속성(예: HTTP 상태 코드)을 추가하여 정보를 풍부하게 만듭니다.
this.errorCode = errorCode;
}
}
class DatabaseConnectionError extends Error {
constructor(message) {
super(message);
this.name = "DatabaseConnectionError";
}
}
function checkUser(role) {
if (role !== 'admin') {
// 특정 Custom Error를 발생시킵니다.
throw new AuthenticationError(`접근 권한이 없습니다. (현재 역할: ${role})`);
}
// ... 정상 로직
}
try {
checkUser('guest');
} catch (error) {
// (4) instanceof를 사용하여 에러의 타입을 정확히 식별하고 분기 처리합니다.
if (error instanceof AuthenticationError) {
console.warn(`[인증 에러 감지] 사용자에게 로그인 페이지로 리다이렉트 안내 (${error.errorCode})`);
} else if (error instanceof DatabaseConnectionError) {
console.error(`[시스템 에러 감지] 데이터베이스 문제 발생`);
} else {
// 예상치 못한 다른 모든 에러
console.error(`[일반 에러] ${error.name}: ${error.message}`);
}
}
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'4.Node.js > JavaScript&TypeScript' 카테고리의 다른 글
| [TypeScript] 1편. TypeScript 핵심 개념과 기본 타입 이해하기 (0) | 2025.12.10 |
|---|---|
| [JavaScript] 10편. 퍼포먼스 & 메모리 최적화 이해하기 (0) | 2025.12.09 |
| [JavaScript] 8편. 반복문 이해하기: for, while, forEach, Iterable (0) | 2025.12.08 |
| [JavaScript] 7편. 컬렉션 이해하기: 배열,Map,Set,groupBy,WeakMap (0) | 2025.12.07 |
| [JavaScript] 6편. 모듈 시스템 이해하기: ESM, import/export (0) | 2025.12.07 |