Error Throw 실무 사용 방법

throw는 프로그램 실행 중 발생한 예외 상황을 호출자에게 전달하기 위해 사용하는 문법이다.

실무에서는 단순히 에러 메시지를 출력하는 용도로 사용하지 않는다.
주로 현재 함수가 정상적인 결과를 반환할 수 없는 상황을 명확하게 알리고, 상위 계층에서 예외를 처리하도록 만드는 역할로 사용한다.

기본 사용법

function GetUser(id: number) {
    if (id <= 0) {
        throw new Error("Invalid user id");
    }

    return FindUser(id);
}

throw가 실행되는 순간 현재 함수의 정상적인 실행은 중단된다.

GetUser() -> validation 실패 -> throw -> 호출자에게 예외 전달

따라서 throw 이후의 코드는 실행되지 않는다.

function Test() {
    throw new Error("Something went wrong");

    console.log("Never executed");
}

throw와 return의 차이

return은 정상적인 결과를 반환한다.

function Divide(a: number, b: number) {
    if (b === 0) {
        return null;
    }

    return a / b;
}

반면 throw는 정상적인 결과를 반환할 수 없는 상황을 예외로 전달한다.

function Divide(a: number, b: number) {
    if (b === 0) {
        throw new Error("Cannot divide by zero");
    }

    return a / b;
}

실무에서는 다음과 같이 구분하는 것이 좋다.

정상적인 결과 -> return
정상적인 결과가 존재하지 않는 예외 상황 -> throw

try / catch와 함께 사용

throw로 발생시킨 예외는 상위 호출자의 catch에서 처리할 수 있다.

function GetUser(id: number) {
    if (id <= 0) {
        throw new Error("Invalid user id");
    }

    return FindUser(id);
}

try {
    const user = GetUser(-1);
} catch (error) {
    console.error(error);
}

중요한 점은 에러를 발생시키는 코드와 에러를 처리하는 코드를 분리할 수 있다는 것이다.

GetUser() -> throw
             |
             v
         Service Layer
             |
             v
         Controller
             |
             v
           catch

실무에서는 어디서 throw하는가

가장 일반적인 위치는 검증 단계와 비즈니스 로직이다.

입력값 검증

function CreateUser(name: string) {
    if (!name || name.trim().length === 0) {
        throw new Error("User name is required");
    }

    // User 생성
}

잘못된 입력을 발견한 함수에서 즉시 중단한다.


존재하지 않는 데이터

function GetUser(userId: number) {
    const user = userRepository.findById(userId);

    if (!user) {
        throw new Error("User not found");
    }

    return user;
}

이렇게 하면 상위 계층에서는 user가 없을 가능성을 계속 검사할 필요가 없다.


권한 검증

function DeletePost(user: User, post: Post) {
    if (post.authorId !== user.id) {
        throw new Error("Permission denied");
    }

    postRepository.delete(post.id);
}

비즈니스 규칙을 위반한 경우 예외를 발생시킨다.


Custom Error 사용

실무에서는 Error 하나만 사용하는 것보다 에러 종류를 구분하는 것이 유리하다.

class NotFoundError extends Error {
    constructor(message: string) {
        super(message);
        this.name = "NotFoundError";
    }
}

class ValidationError extends Error {
    constructor(message: string) {
        super(message);
        this.name = "ValidationError";
    }
}

사용한다.

function GetUser(id: number) {
    if (id <= 0) {
        throw new ValidationError("Invalid user id");
    }

    const user = FindUser(id);

    if (!user) {
        throw new NotFoundError("User not found");
    }

    return user;
}

그리고 상위 계층에서 종류에 따라 처리한다.

try {
    const user = GetUser(id);
} catch (error) {
    if (error instanceof ValidationError) {
        // 잘못된 요청
    }
    else if (error instanceof NotFoundError) {
        // 데이터 없음
    }
    else {
        // 예상하지 못한 서버 오류
    }
}

이 방식은 서버 개발에서 특히 유용하다.


HTTP API에서의 활용

Node.js 서버에서는 throw를 이용해 Service 계층에서 오류를 발생시키고, Controller 또는 Middleware에서 HTTP 응답으로 변환할 수 있다.

async function GetUser(userId: number) {
    const user = await userRepository.findById(userId);

    if (!user) {
        throw new NotFoundError("User not found");
    }

    return user;
}

Controller에서는 직접 비즈니스 로직을 처리하지 않고 예외를 전달한다.

try {
    const user = await userService.GetUser(userId);

    res.json(user);
} catch (error) {
    next(error);
}

그리고 전역 Error Middleware에서 처리한다.

app.use((error, req, res, next) => {
    if (error instanceof NotFoundError) {
        return res.status(404).json({
            message: error.message
        });
    }

    if (error instanceof ValidationError) {
        return res.status(400).json({
            message: error.message
        });
    }

    res.status(500).json({
        message: "Internal Server Error"
    });
});

구조는 다음과 같다.

Request -> Controller -> Service -> Repository
                         |
                         v
                       throw
                         |
                         v
                 Error Middleware
                         |
                         v
                    HTTP Response

이렇게 구성하면 각 계층의 책임이 명확해진다.


throw를 남발하면 안 되는 이유

모든 상황을 throw로 처리하는 것도 좋은 설계는 아니다.

예를 들어 단순히 검색 결과가 없는 것이 정상적인 상황이라면:

function FindUsers(): User[] {
    return [];
}

처럼 빈 배열을 반환하는 것이 적절하다.

반면 반드시 존재해야 하는 데이터가 없는 상황이라면:

function GetRequiredUser(id: number): User {
    const user = FindUser(id);

    if (!user) {
        throw new NotFoundError("User not found");
    }

    return user;
}

처럼 예외를 사용하는 것이 적절하다.

핵심은 이것이다.

정상적인 상태의 범위 -> return
정상적인 상태를 벗어난 상황 -> throw

throw와 로그를 같이 사용하지 말 것

다음과 같은 코드는 흔히 발생하는 안 좋은 패턴이다.

try {
    DoSomething();
} catch (error) {
    console.error(error);
    throw error;
}

모든 계층에서 로그를 찍으면 동일한 에러가 중복 기록될 수 있다.

Repository -> log
Service    -> log
Controller -> log
Middleware -> log

결과적으로 하나의 오류가 여러 번 기록된다.

일반적으로는 처리할 수 있는 계층에서 처리하고, 최종적으로 처리되지 않은 예외를 중앙에서 기록하는 방식이 깔끔하다.

try {
    await service.Execute();
} catch (error) {
    next(error);
}

그리고 전역 Error Handler에서 로그를 남긴다.


throw할 때 에러 정보를 잃지 않기

다음처럼 원래 에러를 버리는 것도 좋지 않다.

try {
    await database.query();
} catch (error) {
    throw new Error("Database error");
}

원래 발생한 에러의 원인이 사라진다.

가능하면 원인을 보존한다.

try {
    await database.query();
} catch (error) {
    throw new Error("Database error", {
        cause: error
    });
}

이렇게 하면 상위 계층에서 새로운 의미의 에러를 만들면서 원래 원인도 유지할 수 있다.


실무에서의 핵심 원칙

throw는 단순한 에러 출력 문법이 아니다.

현재 계층에서 정상적인 처리를 계속할 수 없다는 사실을 상위 계층에 전달하는 제어 흐름이다.

실무에서는 다음 정도를 기준으로 사용하면 된다.

입력 검증 실패 -> throw ValidationError
권한 부족 -> throw AuthorizationError
데이터 없음 -> throw NotFoundError
비즈니스 규칙 위반 -> throw BusinessError
외부 시스템 실패 -> 원인 보존 후 throw
예상하지 못한 오류 -> 상위 계층으로 전달
최종 예외 처리 -> Global Error Handler

특히 서버 구조에서는 throw 자체보다 어디에서 발생시키고 어디에서 최종 처리할 것인가가 더 중요하다.

Service -> throw -> Controller/Middleware -> Global Error Handler 구조를 잡아두면 예외 처리가 일관되고, 비즈니스 로직과 HTTP 응답 처리도 분리할 수 있다.