JavaScript 에러 처리 | try-catch, Error 객체, 커스텀 에러

이 글의 핵심

문자열을 throw하면 스택 트레이스가 남지 않아 운영에서 원인을 추적하기 어려워집니다. 에러를 종류별로 구분해 던지고 받는 커스텀 에러 설계, 비동기 코드에서 try-catch가 잡지 못하는 경우, 재시도할 에러와 즉시 실패시킬 에러를 나누는 기준을 짚어 복구 가능한 에러 처리 구조를 만들게 합니다.

들어가며

에러 처리는 실행 중 실패할 수 있는 코드에서 사용자·로그·복구 경로를 정하는 일입니다. try/catch로 잡을지, Promise의 catch로 이어질지, 에러 타입을 나눌지까지 이 글에서 정리합니다.


try-catch-finally 기본

기본 사용법

try {
    const result = riskyOperation();
    console.log(result);
} catch (error) {
    console.error("에러 발생:", error.message);
} finally {
    console.log("정리 작업");
}

try 블록에서 에러가 던져지면 그 뒤의 문장은 건너뛰고 곧바로 catch로 이동합니다. finally는 에러가 났든 안 났든, 심지어 try나 catch 안에서 return을 해도 반드시 실행되므로 파일 핸들 닫기, 로딩 스피너 끄기, 락 해제처럼 “어떤 경우에도 해야 하는 정리”를 두는 자리입니다. 주의할 점은 finally 안에서 return하면 try의 반환값이나 던져진 에러를 덮어쓴다는 것입니다. finally { return "ok"; }가 있으면 try에서 난 에러가 조용히 사라지므로, finally에는 정리 코드만 두고 값을 반환하지 않는 것이 원칙입니다.

catch는 에러 종류를 가리지 않고 모두 잡습니다. Java처럼 catch (TypeError e) 식으로 타입별 블록을 만들 수 없기 때문에, 특정 에러만 처리하려면 뒤에서 볼 instanceof 분기를 쓰고 나머지는 다시 던져야 합니다. 에러 객체가 필요 없다면 ES2019부터 catch { ... }처럼 괄호를 생략할 수도 있습니다.

0으로 나누기를 에러로 알리기

function divide(a, b) {
    if (b === 0) {
        throw new Error("0으로 나눌 수 없습니다");
    }
    return a / b;
}
try {
    console.log(divide(10, 2));  // 5
    console.log(divide(10, 0));  // Error!
    console.log("이 줄은 실행 안 됨");
} catch (error) {
    console.error("에러:", error.message);
} finally {
    console.log("계산 완료");
}

참고로 JavaScript에서 10 / 0은 에러가 아니라 Infinity를 반환합니다. 그래서 위 divide처럼 나누는 값이 0인 경우를 직접 검사해 에러를 던지는 것은 “언어가 에러로 취급하지 않는 상황을 도메인 규칙상 에러로 만드는” 선택입니다. Infinity나 NaN이 계산을 따라 흘러가 한참 뒤에 화면에 NaN원으로 나타나는 것보다, 원인이 생긴 지점에서 즉시 실패하는 편이 디버깅이 훨씬 쉽습니다.

중첩 try-catch

try {
    try {
        throw new Error("내부 에러");
    } catch (innerError) {
        console.log("내부 처리:", innerError.message);
        throw new Error("외부 에러");
    }
} catch (outerError) {
    console.log("외부 처리:", outerError.message);
}

중첩 구조는 “내부에서 일부를 처리하고, 더 의미 있는 에러로 바꿔서 위로 올리는” 경우에 씁니다. 다만 위 예제처럼 새 에러를 만들어 던지면 원래 에러의 스택 트레이스가 사라집니다. 실제 코드에서는 ES2022의 cause 옵션으로 원인을 연결해 두는 것이 좋습니다.

try {
    JSON.parse(rawConfig);
} catch (err) {
    throw new Error("설정 파일을 읽을 수 없습니다", { cause: err });
}

Node.js 16.9 이상과 최신 브라우저는 cause를 지원하며, Node.js는 처리되지 않은 에러를 출력할 때 [cause]: SyntaxError: ...처럼 원인 에러까지 함께 보여 줍니다. 에러를 감쌀 때 원인을 버리는 것은 운영 환경에서 원인 추적을 가장 어렵게 만드는 습관 중 하나입니다.


내장 Error 타입과 속성

내장 Error 타입

// Error: 기본 에러
throw new Error("일반 에러");
// SyntaxError: 문법 에러
try {
    eval("{ invalid json");
} catch (e) {
    console.log(e.name);  // SyntaxError
}
// ReferenceError: 존재하지 않는 변수
try {
    console.log(nonExistent);
} catch (e) {
    console.log(e.name);  // ReferenceError
}
// TypeError: 타입 에러
try {
    null.toString();
} catch (e) {
    console.log(e.name);  // TypeError
}
// RangeError: 범위 에러
try {
    new Array(-1);
} catch (e) {
    console.log(e.name);  // RangeError
}

실무에서 가장 많이 보는 것은 단연 TypeError입니다. Cannot read properties of undefined (reading 'name')(Chrome/Node.js의 메시지)처럼 API 응답에 기대한 필드가 없을 때 나는 에러가 대부분이며, 옵셔널 체이닝(user?.profile?.name)으로 미리 막을 수 있는 경우가 많습니다. 반면 SyntaxError는 코드 자체의 문법 오류라면 스크립트가 파싱 단계에서 실패하므로 try-catch로 잡을 수 없습니다. 위 예제처럼 eval이나 JSON.parse로 실행 중에 문자열을 해석할 때만 잡을 수 있습니다. ReferenceError 역시 대부분 오타에서 나오므로 잡아서 처리하기보다는 ESLint의 no-undef 규칙이나 TypeScript로 미리 막는 대상입니다.

Error 객체 속성

try {
    throw new Error("테스트 에러");
} catch (error) {
    console.log(error.name);     // Error
    console.log(error.message);  // 테스트 에러
    console.log(error.stack);    // 스택 트레이스
}

stack은 표준 명세에 들어 있지 않지만 모든 주요 엔진이 지원하는 속성으로, 형식은 엔진마다 조금씩 다릅니다. 중요한 점은 스택이 Error 객체가 생성되는 시점에 기록된다는 것입니다. 그래서 throw "실패"처럼 문자열이나 일반 객체를 던지면 스택 정보가 아예 없고, 에러 객체를 미리 만들어 두었다가 나중에 던지면 던진 위치가 아니라 만든 위치가 기록됩니다. 운영 환경에서는 코드가 번들·압축되어 스택의 줄 번호가 의미 없어지므로, Sentry 같은 모니터링 도구에 소스맵을 업로드해야 원래 파일과 줄 번호로 되돌릴 수 있습니다.


커스텀 에러 클래스

커스텀 에러 클래스

class ValidationError extends Error {
    constructor(message) {
        super(message);
        this.name = "ValidationError";
    }
}
class NetworkError extends Error {
    constructor(message, statusCode) {
        super(message);
        this.name = "NetworkError";
        this.statusCode = statusCode;
    }
}
function validateAge(age) {
    if (typeof age !== 'number') {
        throw new ValidationError("나이는 숫자여야 합니다");
    }
    if (age < 0 || age > 150) {
        throw new ValidationError("나이는 0-150 사이여야 합니다");
    }
    return true;
}
try {
    validateAge("25");
} catch (error) {
    if (error instanceof ValidationError) {
        console.error("유효성 에러:", error.message);
    } else {
        console.error("알 수 없는 에러:", error);
    }
}

커스텀 에러를 만드는 이유는 호출한 쪽이 에러 종류에 따라 다르게 반응할 수 있게 하기 위해서입니다. 유효성 에러라면 사용자에게 입력을 고치라고 안내하고, 네트워크 에러라면 재시도하고, 그 밖의 에러는 버그로 보고해야 합니다. 에러 메시지 문자열을 error.message.includes("나이")처럼 비교해 분기하면 메시지 문구를 바꾸는 순간 로직이 깨지므로, 타입이나 code 같은 별도 필드로 구분하는 편이 안전합니다.

this.name을 직접 지정하지 않으면 스택 트레이스와 로그에 그냥 Error로 찍혀서 커스텀 클래스를 만든 의미가 반쯤 사라집니다. 클래스가 많아지면 this.name = new.target.name;으로 클래스 이름을 자동으로 넣는 방법도 있지만, 코드가 압축되면서 클래스 이름이 e처럼 바뀌는 번들 설정에서는 문자열로 적는 것이 확실합니다. 또 TypeScript를 ES5로 컴파일하던 시절에는 내장 Error를 상속한 클래스에서 instanceof가 false를 반환하는 문제가 유명했는데, 지금처럼 ES2015 이상을 대상으로 하면 발생하지 않습니다.

여러 에러 타입 처리

class DatabaseError extends Error {
    constructor(message, query) {
        super(message);
        this.name = "DatabaseError";
        this.query = query;
    }
}
function processData(data) {
    try {
        if (!data) {
            throw new ValidationError("데이터가 없습니다");
        }
        
        if (data.age < 0) {
            throw new ValidationError("나이는 양수여야 합니다");
        }
        
        return data;
    } catch (error) {
        if (error instanceof ValidationError) {
            console.error("유효성 에러:", error.message);
        } else if (error instanceof DatabaseError) {
            console.error("DB 에러:", error.message, error.query);
        } else {
            console.error("알 수 없는 에러:", error);
        }
        throw error;
    }
}

마지막 throw error;가 이 패턴의 핵심입니다. 이 함수는 에러를 기록만 하고 처리 책임은 호출한 쪽에 다시 넘깁니다. 로그를 남긴 뒤 에러를 삼켜 버리면 호출한 쪽은 실패를 모른 채 undefined를 정상 결과처럼 사용하게 됩니다. 반대로 모든 계층이 로그를 남기고 다시 던지면 같은 에러가 로그에 서너 번 찍히는 문제가 생기므로, 실제 프로젝트에서는 “로그는 최상위 한 곳에서만 남기고, 중간 계층은 필요할 때 문맥을 덧붙여(cause) 다시 던진다”는 식으로 역할을 나누는 경우가 많습니다.


Promise와 async/await의 에러

Promise 에러

// .catch()
fetch("https://api.example.com/data")
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error("에러:", error));
// 체인 중간 에러
Promise.resolve(1)
    .then(x => {
        throw new Error("에러!");
    })
    .then(x => console.log(x))
    .catch(error => console.error(error.message))
    .then(() => console.log("복구됨"));
// 여러 Promise
Promise.all([
    fetch("/api/users"),
    fetch("/api/posts")
])
.then(responses => Promise.all(responses.map(r => r.json())))
.then(data => console.log(data))
.catch(error => console.error("하나라도 실패:", error));

Promise 체인에서 .catch()는 그보다 앞의 모든 단계에서 난 에러를 잡고, 그 뒤의 .then()은 catch가 값을 반환하면 정상 흐름으로 이어집니다. 두 번째 예제에서 “복구됨”이 출력되는 이유입니다. 이 동작은 편리하지만, catch를 체인 중간에 두고 아무것도 반환하지 않으면 다음 단계가 undefined를 받아 엉뚱한 곳에서 다시 에러가 나기 쉽습니다.

첫 번째 예제에서 가장 많이 오해하는 부분은 fetch가 HTTP 에러에서 reject되지 않는다는 점입니다. fetch는 DNS 실패, 연결 끊김, CORS 차단처럼 요청 자체가 실패할 때만 reject하고, 404나 500 응답은 정상적으로 resolve됩니다. 그래서 위 코드에서 서버가 500과 함께 HTML 에러 페이지를 돌려주면 .catch에 도달하는 것은 네트워크 에러가 아니라 response.json()의 SyntaxError: Unexpected token '<'입니다. 이 메시지를 보고 JSON 파싱 문제로 착각해 한참 헤매는 경우가 흔한데, 뒤의 async/await 예제처럼 response.ok를 먼저 확인해야 합니다.

Promise.all은 하나라도 reject되면 즉시 실패하고 나머지 결과는 버립니다. 일부 실패를 허용하고 성공한 결과만 쓰고 싶다면 Promise.allSettled를 써서 각 결과의 status가 "fulfilled"인지 "rejected"인지 확인합니다. 그리고 어디서도 .catch하지 않은 Promise가 reject되면 브라우저는 콘솔에 Uncaught (in promise)를 출력하고, Node.js 15 이상은 기본적으로 프로세스를 종료합니다. 서버 코드에서 await 없이 호출한 async 함수 하나 때문에 프로세스가 재시작되는 일이 생기는 이유입니다.

async/await 에러

async function fetchData() {
    try {
        const response = await fetch("https://api.example.com/data");
        
        if (!response.ok) {
            throw new NetworkError(`HTTP ${response.status}`, response.status);
        }
        
        const data = await response.json();
        return data;
    } catch (error) {
        console.error("에러:", error.message);
        return null;
    }
}
async function complexOperation() {
    try {
        const data = await fetchData();
        const result = processData(data);
        return result;
    } catch (error) {
        if (error instanceof NetworkError) {
            console.error("네트워크 에러:", error.statusCode);
        } else if (error instanceof ValidationError) {
            console.error("유효성 에러:", error.message);
        } else {
            console.error("알 수 없는 에러:", error);
        }
        throw error;
    }
}

await는 reject된 Promise를 동기 코드의 throw처럼 바꿔 주기 때문에 try-catch로 잡을 수 있습니다. 단, await한 Promise만 잡힙니다. try { fetchData(); } catch {}처럼 await를 빠뜨리면 함수는 Promise를 반환하고 곧바로 try 블록을 빠져나가므로, 나중에 발생하는 에러는 이 catch와 무관해집니다. setTimeout 콜백 안에서 던진 에러도 같은 이유로 바깥의 try-catch에 잡히지 않습니다. 콜백이 실행되는 시점에는 이미 try 블록이 끝난 뒤이기 때문입니다.

위 코드에는 의도적으로 짚어 볼 설계 문제가 하나 있습니다. fetchData가 에러를 잡아서 null을 반환하기 때문에 complexOperation의 NetworkError 분기는 절대 실행되지 않습니다. 대신 processData(null)이 ValidationError("데이터가 없습니다")를 던져서, 실제 원인은 네트워크인데 로그에는 유효성 에러가 찍힙니다. 에러를 잡는 위치를 정할 때는 “이 계층에서 의미 있게 복구할 수 있는가”를 기준으로 삼고, 복구할 수 없다면 잡지 말고 위로 흘려보내는 편이 원인 추적에 유리합니다. 여기서는 fetchData의 try-catch를 없애거나, 로그만 남기고 다시 던지는 쪽이 맞습니다.

에러 래퍼, 재시도, 로깅 패턴

패턴 1: 에러 래퍼

class Result {
    constructor(success, data, error) {
        this.success = success;
        this.data = data;
        this.error = error;
    }
    
    static ok(data) {
        return new Result(true, data, null);
    }
    
    static fail(error) {
        return new Result(false, null, error);
    }
}
async function fetchUserSafe(id) {
    try {
        const response = await fetch(`/api/users/${id}`);
        const user = await response.json();
        return Result.ok(user);
    } catch (error) {
        return Result.fail(error.message);
    }
}
const result = await fetchUserSafe(1);
if (result.success) {
    console.log("데이터:", result.data);
} else {
    console.error("에러:", result.error);
}

Result 패턴은 “실패도 정상적인 반환값의 하나”로 다루는 방식으로, Rust의 Result나 Go의 (value, err) 반환과 같은 발상입니다. 호출한 쪽이 if (result.success)를 거치지 않고는 데이터에 접근할 수 없으므로 에러 처리를 잊기 어렵고, try-catch가 여러 겹으로 중첩되는 것도 피할 수 있습니다. 대신 에러 정보가 error.message 문자열로 축소되어 스택과 타입이 사라진다는 단점이 있습니다. 위 코드처럼 error.message만 담지 말고 에러 객체 자체를 담아 두면 나중에 instanceof로 구분하거나 로그에 스택을 남길 수 있습니다.

또 fetchUserSafe는 response.ok를 검사하지 않으므로, 서버가 404와 함께 {"message": "not found"}를 보내면 그것이 그대로 Result.ok의 사용자 데이터가 됩니다. 마지막 줄의 최상위 await는 ES 모듈(<script type="module">, .mjs, "type": "module")에서만 쓸 수 있고, 일반 스크립트에서는 await is only valid in async functions and the top level bodies of modules 에러가 납니다.

패턴 2: 재시도

async function retry(fn, maxRetries = 3, delay = 1000) {
    for (let i = 0; i < maxRetries; i++) {
        try {
            return await fn();
        } catch (error) {
            if (i === maxRetries - 1) {
                throw error;
            }
            console.log(`재시도 ${i + 1}/${maxRetries}`);
            await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
        }
    }
}
retry(() => fetch("https://api.example.com/data"))
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error("최종 실패:", error));

재시도는 일시적인 실패(타임아웃, 연결 리셋, 503, 429)에만 의미가 있습니다. 잘못된 요청(400), 인증 실패(401/403), 존재하지 않는 리소스(404)는 몇 번을 다시 보내도 같은 결과가 나오므로 곧바로 실패시키는 것이 맞습니다. 그런데 위 retry에 fetch를 그대로 넘기면 앞에서 설명했듯이 fetch가 500에도 reject하지 않기 때문에, 서버 에러는 재시도되지 않고 네트워크 에러만 재시도됩니다. 실제로는 fn 안에서 response.status를 보고 재시도 가능한 경우에만 에러를 던지고, retry는 에러 객체에 retryable 같은 표시가 있을 때만 다시 시도하도록 만드는 것이 일반적입니다.

대기 시간을 delay * (i + 1)로 늘리는 것은 선형 백오프입니다. 여러 클라이언트가 동시에 실패한 상황에서 모두 같은 간격으로 재시도하면 서버가 복구되자마자 요청이 한꺼번에 몰리므로, 실무에서는 간격을 두 배씩 늘리는 지수 백오프에 무작위 지터(Math.random()을 곱한 값)를 더하는 방식을 많이 씁니다. 결제 요청처럼 두 번 실행되면 안 되는 POST 요청은 재시도하기 전에 멱등성 키 같은 장치가 서버에 있는지 반드시 확인해야 합니다. 응답은 왔는데 연결이 끊겨서 클라이언트만 실패로 판단하는 경우가 있기 때문입니다.

패턴 3: 에러 로깅

class ErrorLogger {
    static log(error, context = {}) {
        const errorInfo = {
            name: error.name,
            message: error.message,
            stack: error.stack,
            timestamp: new Date().toISOString(),
            ...context
        };
        
        console.error("에러 로그:", JSON.stringify(errorInfo, null, 2));
        
        // 서버로 전송
        // fetch('/api/errors', { method: 'POST', body: JSON.stringify(errorInfo) });
    }
}
try {
    throw new Error("테스트 에러");
} catch (error) {
    ErrorLogger.log(error, { userId: 123, action: "데이터 로드" });
}

JSON.stringify(error)를 바로 호출하면 {}만 나온다는 점 때문에 이렇게 속성을 직접 꺼내 담습니다. name, message, stack은 열거 불가능한(non-enumerable) 속성이라 JSON.stringify와 스프레드 연산자가 무시하기 때문입니다. 로그에 context를 함께 남기는 것은 “무슨 작업을 하다가 실패했는지”가 스택 트레이스만큼 중요하기 때문인데, 이때 비밀번호나 토큰 같은 민감 정보가 섞이지 않도록 주의해야 합니다. catch로 받은 값이 항상 Error라는 보장도 없습니다. 서드파티 코드가 문자열이나 undefined를 던질 수도 있으므로, 로거에서는 error instanceof Error가 아닐 때 String(error)로 기록하는 방어 코드를 두는 편이 안전합니다.

어디서도 잡히지 않은 에러를 놓치지 않으려면 브라우저에서는 window.addEventListener("error", ...)와 "unhandledrejection" 이벤트를, Node.js에서는 process.on("unhandledRejection", ...)을 등록해 마지막 안전망으로 사용합니다. Node.js의 uncaughtException 핸들러에서 에러를 기록한 뒤에도 프로세스를 계속 실행하는 것은 상태가 망가진 채로 요청을 처리할 위험이 있어서, 공식 문서도 기록 후 종료하고 프로세스 매니저가 재시작하게 하는 방식을 권합니다.


에러 처리를 갖춘 API 클라이언트

class ApiClient {
    constructor(baseUrl) {
        this.baseUrl = baseUrl;
    }
    
    async request(endpoint, options = {}) {
        const url = `${this.baseUrl}${endpoint}`;
        
        try {
            const response = await fetch(url, options);
            
            if (!response.ok) {
                throw new NetworkError(
                    `HTTP ${response.status}: ${response.statusText}`,
                    response.status
                );
            }
            
            const data = await response.json();
            return Result.ok(data);
        } catch (error) {
            if (error instanceof NetworkError) {
                console.error("네트워크 에러:", error.message);
            } else if (error instanceof SyntaxError) {
                console.error("JSON 파싱 에러:", error.message);
            } else {
                console.error("알 수 없는 에러:", error);
            }
            return Result.fail(error.message);
        }
    }
    
    async get(endpoint) {
        return this.request(endpoint);
    }
    
    async post(endpoint, body) {
        return this.request(endpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body)
        });
    }
}
const api = new ApiClient("https://api.example.com");
const result = await api.get("/users/1");
if (result.success) {
    console.log("사용자:", result.data);
} else {
    console.error("에러:", result.error);
}

이 ApiClient는 앞의 패턴들을 합친 형태입니다. request가 HTTP 상태를 확인해 NetworkError로 바꾸고, JSON 파싱 실패는 SyntaxError로 구분하며, 모든 결과를 Result로 감싸 호출한 쪽이 try-catch 없이 성공 여부를 확인하게 합니다. 이름은 NetworkError지만 실제로는 “서버가 에러 상태 코드를 응답한 경우”를 뜻하고, 진짜 네트워크 실패(fetch의 TypeError: Failed to fetch)는 “알 수 없는 에러” 분기로 간다는 점은 이름과 동작이 어긋나는 부분이라 실무 코드라면 HttpError처럼 이름을 나누는 편이 명확합니다.

그대로 쓰기 전에 보완할 점도 있습니다. 204 No Content처럼 본문이 없는 성공 응답에서 response.json()을 호출하면 SyntaxError: Unexpected end of JSON input이 나서 성공한 요청이 실패로 보고됩니다. fetch에는 기본 타임아웃이 없으므로 서버가 응답하지 않으면 Promise가 한없이 대기하는데, AbortSignal.timeout(5000)을 signal 옵션으로 넘기면 5초 후 TimeoutError로 끝낼 수 있습니다. 그리고 서버가 에러 응답 본문에 상세 메시지를 담아 보내는 경우가 많으니, !response.ok일 때도 본문을 읽어 에러 객체에 담아 두면 디버깅이 훨씬 수월해집니다.


에러 처리 요약

  1. try-catch-finally: 에러 처리 구조
  2. throw: 에러 발생
  3. Error 객체: name, message, stack
  4. 커스텀 에러: extends Error
  5. 비동기: .catch() 또는 try-catch (async/await)

에러를 잡을 위치 정하기

에러 처리에서 가장 어려운 결정은 문법이 아니라 어디서 잡을지입니다. 이 글의 예제들을 관통하는 기준은 “그 자리에서 의미 있게 복구할 수 있으면 잡고, 그렇지 않으면 문맥을 덧붙여 위로 넘긴다”입니다. 입력 폼 검증 에러는 폼 컴포넌트에서 잡아 메시지를 보여 주고, 일시적인 네트워크 에러는 API 클라이언트에서 재시도하며, 예상하지 못한 에러는 최상위 핸들러까지 올려 보내 로그를 남기고 사용자에게 일반적인 안내를 보여 줍니다. 모든 함수를 try-catch로 감싸고 console.error만 찍는 코드는 에러를 처리하는 것이 아니라 숨기는 것에 가깝습니다.

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. throw할 때 문자열 대신 Error 객체를 던져야 하는 이유는 무엇인가요?

A. throw "실패"처럼 문자열을 던지면 stack 속성이 없어서 어디서 에러가 났는지 추적하기 어렵습니다. Error 객체는 message, name, stack을 가지므로 로그나 에러 모니터링 도구에서 발생 위치를 바로 확인할 수 있습니다. Error를 상속한 커스텀 에러를 쓰면 instanceof로 종류를 구분해 처리할 수 있고, cause 옵션으로 원래 에러를 연결해 둘 수도 있습니다.