API 애플리케이션의 예외 설계 — Server/Client, ErrorCode, Resolver 기반 구조
API 애플리케이션을 개발하다 보면, 예외 설계는 항상 뒤로 미루기 쉬운 주제다.
하지만 한 번 잘못 설계되기 시작하면, 이후 기능이 늘어날수록 예외 구조는 걷잡을 수 없게 복잡해지고,
결국 API의 응답 구조 · 로깅 · 모니터링까지 모두 영향을 받게 된다.
이 글에서는 먼저 _자주 범하는 예외 설계 패턴_을 살펴보고,
그 다음에 Server / Client 축과 ErrorCode enum, Resolver, Factory Method를 활용한
좀 더 견고한 예외 설계 방식을 정리해본다.
자주 범하는 예외 구조
실제 코드베이스에서 자주 볼 수 있는 구조는 대략 이런 모습이다.
// 모든 API 예외의 상위라고 주장하는 추상 클래스
public class ApiException extends RuntimeException {
public ApiException(String message) {
super(message);
}
}
// Auth 관련 예외
public class AuthException extends RuntimeException {
public AuthException(String message) {
super(message);
}
}
// User 관련 예외
public class UserException extends RuntimeException {
public UserException(String message) {
super(message);
}
}
처음에는 나쁘지 않아 보인다.
하지만 실제 구현이 진행되면 이런 식으로 세분화되기 쉽다.
public class InvalidLoginAccountException extends AuthException {
public InvalidLoginAccountException() {
super("invalid login account");
}
}
public class DeactivatedUserException extends AuthException {
public DeactivatedUserException() {
super("deactivated user");
}
}
public class NoUserException extends UserException {
public NoUserException(Long userId) {
super("no user. id=" + userId);
}
}
1) 예외가 도메인별로만 분기되어 있다
AuthException, UserException, OrderException처럼
“어느 도메인에서 발생했는지”에 따라 예외를 나누는 구조는 한눈에 보기에는 이해하기 쉽다.
하지만 HTTP 응답 관점에서는 곧 문제가 드러난다.
- 어떤
AuthException은 401(Unauthorized)로 내려가야 하고 - 어떤
AuthException은 403(Forbidden)일 수도 있고 UserException중 어떤 것은 400(Bad Request)이고- 어떤 것은 404(Not Found)일 수 있다.
즉, 도메인별 예외 구조만으로는 HTTP Status를 일관성 있게 매핑하기 어렵다.
글로벌 예외 핸들러에는 instanceof와 if/else가 쌓이기 시작한다.
2) 예외가 너무 세분화되어 관리가 어렵다
상황별로 클래스를 나누다 보면:
InvalidLoginAccountExceptionDeactivatedUserExceptionInvalidPhoneNumberExceptionUserNotActiveExceptionUserAlreadyRegisteredException- ...
예외 클래스 개수가 금방 수십~수백 개가 되어버린다.
문제는:
- 전반적인 일관성 상실 (메시지/필드/로깅 정책이 제각각)
- 실제 어떤 예외가 있는지 한눈에 파악하기 어려움
- 신규 팀원이 진입했을 때 학습 비용 증가
- 중복/의미가 비슷한 예외가 생기기 시작함
3) 반대로 예외가 지나치게 추상적이다
다른 극단으로, 모든 예외를 다음과 같이 처리하는 경우도 있다.
public class ApiException extends RuntimeException {
private final int httpStatus;
public ApiException(String message, int httpStatus) {
super(message);
this.httpStatus = httpStatus;
}
public int getHttpStatus() {
return httpStatus;
}
}
여기에 모든 예외를 몰아 넣으면:
- 이게 클라이언트의 잘못인지, 서버의 잘못인지 구분하기 어렵고
- ErrorCode의 체계도 잡기 어려우며
- 도메인 레이어에서
httpStatus를 직접 알고 사용하는 구조가 된다.
결국 **프레젠테이션 계층(HTTP)**에 대한 지식이 도메인 레이어로 침범하는 문제가 발생한다.
4) 도메인 레이어가 HTTP를 직접 알게 되는 구조
위의 ApiException처럼 도메인에서 HTTP 상태 코드를 직접 들고 있게 되면,
- 나중에 gRPC, 메시지 큐, CLI 등 다른 인터페이스를 붙이기 어렵고
- Hexagonal Architecture / Clean Architecture에서 말하는 레이어 분리가 깨진다.
정리하자면, 자주 보이는 예외 구조들은
- 도메인별로만 쪼개져 있거나
- 지나치게 세분화되어 있거나
- 너무 추상적이거나
- 도메인과 HTTP가 뒤섞여 있거나
하는 문제를 가진 경우가 많다.
