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가 뒤섞여 있거나
하는 문제를 가진 경우가 많다.
제안 구조: Server / Client 중심 + ErrorCode 기반 설계
위의 문제를 해결하기 위한 핵심 방향은 다음과 같다.
- 예외의 큰 축은 Client / Server 두 가지로만 나눈다.
- 발생 가능한 예외는 ErrorCode enum으로 모두 나열한다.
- 도메인 레이어는 HTTP를 전혀 모르고, 프레젠테이션 레이어에서만 HTTP를 매핑한다.
- 예외 생성은 Factory Method로 일관성 있게 만든다.
아래는 이를 Java 코드로 정리한 예시들이다.
1) 도메인 레이어: ErrorKind, ErrorCode, DomainException
먼저 예외의 종류를 크게 나누는 ErrorKind와
실제 에러 코드를 정의하는 ErrorCode enum을 만든다.
package com.example.domain.error;
public enum ErrorKind {
CLIENT, // 요청자(클라이언트) 잘못 - validation, 권한, not found 등
SERVER // 서버 내부 또는 외부 시스템 문제
}
package com.example.domain.error;
public enum ErrorCode {
// 공통/기본
INVALID_ARGUMENT,
VALIDATION_FAILED,
// 인증/인가
UNAUTHORIZED,
FORBIDDEN,
// 유저 도메인
USER_NOT_FOUND,
// 시스템/외부 의존성
EXTERNAL_API_FAILURE,
DB_FAILURE;
// 필요하다면 별도의 code 문자열 필드를 둘 수도 있다.
}
DomainException은 도메인 레이어에서 사용할 공통 예외 베이스 클래스다.
package com.example.domain.error;
import java.util.Collections;
import java.util.Map;
public class DomainException extends RuntimeException {
private final ErrorCode code;
private final ErrorKind kind;
private final Map<String, Object> metadata;
public DomainException(
ErrorCode code,
ErrorKind kind,
String message
) {
this(code, kind, message, Collections.emptyMap(), null);
}
public DomainException(
ErrorCode code,
ErrorKind kind,
String message,
Map<String, Object> metadata
) {
this(code, kind, message, metadata, null);
}
public DomainException(
ErrorCode code,
ErrorKind kind,
String message,
Map<String, Object> metadata,
Throwable cause
) {
super(message, cause);
this.code = code;
this.kind = kind;
this.metadata = metadata != null ? metadata : Collections.emptyMap();
}
public ErrorCode getCode() {
return code;
}
public ErrorKind getKind() {
return kind;
}
public Map<String, Object> getMetadata() {
return metadata;
}
}
여기서 주목해야 할 점은:
- HTTP 상태 코드가 전혀 등장하지 않는다는 것
- 도메인은 오직 error의 _종류(kind)_와 _코드(code)_만 표현한다는 것
