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 응답 처리도 분리할 수 있다.