Spring Boot 전역 예외 처리와 에러 코드 설계

도입 배경: 왜 에러 코드를 분리해야 할까?
서비스가 커지고 도메인이 복잡해질수록 에러 처리에 대한 고민도 깊어진다. 초기에는 전역 에러 코드만으로 충분했지만, 기능이 늘어나면서 다음과 같은 문제가 생겼다.
- 에러 코드의 의미가 모호하다 —
INVALID_INPUT같은 범용 코드가 여러 도메인에서 섞여 쓰이다 보니, 정확히 어디서 문제가 생겼는지 파악하기 어려웠다. - 유지보수가 어렵다 — 하나의 Enum에 모든 에러 코드를 담다 보니 클래스가 비대해지고, 서로 다른 도메인의 에러 코드가 뒤섞여 충돌 가능성이 높아졌다.
- 책임이 불분명하다 — 로그만 봐서는 회원 도메인의 문제인지, 결제 도메인의 문제인지 바로 알기 힘들었다.
이 문제를 해결하기 위해 전역(Global) 에러와 도메인(Domain) 에러를 분리해 관리하는 전략을 도입했다.
핵심 전략: 책임의 분리
에러 코드를 두 가지 레벨로 나누어 관리하기로 했다.
| 구분 | 설명 | 예시 |
|---|---|---|
| 전역 에러 (Global) | 시스템 전반에서 공통으로 발생하는 기술적·범용적 오류 | INTERNAL_SERVER_ERROR, INVALID_INPUT, ACCESS_DENIED |
| 도메인 에러 (Domain) | 각 비즈니스 로직에 특화된 구체적인 오류 | FAQ_NOT_FOUND, NOTICE_ALREADY_DELETED, USER_EMAIL_DUPLICATED |
이렇게 나누면 전역 예외는 시스템의 안정성을, 도메인 예외는 비즈니스의 명확성을 담당하게 된다.
프로젝트 구조
디렉터리 구조만 봐도 에러 코드의 성격을 알 수 있도록 설계했다.
global/
└── error/
├── ErrorCode.java # 공통 인터페이스
├── GlobalErrorCode.java # 전역 에러 정의
├── CustomException.java # 통합 예외 클래스
└── GlobalExceptionHandler.java # 전역 핸들러
domain/
├── faq/error/FaqErrorCode.java
├── notice/error/NoticeErrorCode.java
└── user/error/UserErrorCode.java구현 상세
1. ErrorCode 인터페이스
모든 에러 코드 Enum이 구현하는 공통 인터페이스다. 덕분에 CustomException은 구체적인 Enum 타입에 의존하지 않고 이 인터페이스만 바라보면 된다.
public interface ErrorCode {
HttpStatus getStatus();
String getMessage();
}2. 전역·도메인 에러 코드
전역 에러는 GlobalErrorCode에서, 도메인 에러는 각 도메인의 Enum에서 관리한다.
// 전역 에러
@RequiredArgsConstructor
public enum GlobalErrorCode implements ErrorCode {
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류"),
INVALID_INPUT(HttpStatus.BAD_REQUEST, "입력값 오류");
private final HttpStatus status;
private final String message;
@Override public HttpStatus getStatus() { return status; }
@Override public String getMessage() { return message; }
}
// 도메인 에러 (예: FAQ)
@RequiredArgsConstructor
public enum FaqErrorCode implements ErrorCode {
NOT_FOUND(HttpStatus.NOT_FOUND, "FAQ 항목을 찾을 수 없습니다."),
DUPLICATE(HttpStatus.CONFLICT, "FAQ 항목이 중복되었습니다.");
private final HttpStatus status;
private final String message;
@Override public HttpStatus getStatus() { return status; }
@Override public String getMessage() { return message; }
}3. 통합 예외 클래스 (CustomException)
비즈니스 로직에서 발생하는 예외는 모두 이 클래스로 통일한다. 생성자에서 ErrorCode 인터페이스를 받기 때문에 전역·도메인 어떤 구현체든 받을 수 있다.
@Getter
public class CustomException extends RuntimeException {
private final ErrorCode errorCode;
public CustomException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}4. 전역 예외 핸들러 (GlobalExceptionHandler)
@RestControllerAdvice로 발생하는 모든 CustomException을 잡아 일관된 응답 형식으로 바꾼다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(CustomException ex, HttpServletRequest request) {
ErrorCode code = ex.getErrorCode();
return ResponseEntity
.status(code.getStatus())
.body(new ErrorResponse(
code.getStatus().value(),
code.getMessage(),
request.getRequestURI()
));
}
// 그 외 처리되지 않은 예외에 대한 안전장치
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnhandledException(Exception ex, HttpServletRequest request) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(
500,
"서버 내부 오류가 발생했습니다.",
request.getRequestURI()
));
}
}실제 사용 예시와 흐름
서비스 로직에서는 상황에 맞는 에러 코드를 골라 예외를 던지기만 하면 된다.
public NoticeDto getNoticeById(Long id) {
return noticeRepository.findById(id)
.filter(n -> "N".equals(n.getDelYn()))
.map(NoticeDto::new)
.orElseThrow(() -> new CustomException(NoticeErrorCode.NOT_FOUND));
}예외가 발생했을 때의 처리 흐름은 다음과 같다.
Client → Controller GET /api/support/faqs/1
Controller → Service getFaq(1)
[데이터가 없을 때]
Service → Controller throw new CustomException(FaqErrorCode.NOT_FOUND)
Controller → GlobalExceptionHandler 예외 전파
GlobalExceptionHandler → Client 404 Not Found (JSON ErrorResponse)
[데이터가 있을 때]
Service → Controller FaqDto
Controller → Client 200 OK도입 효과와 결론
이 전략을 도입해서 얻은 이점은 명확하다.
- 가독성 —
throw new CustomException(FaqErrorCode.NOT_FOUND)코드만 봐도 "FAQ 도메인에서 데이터가 없어 발생한 에러"라는 걸 바로 알 수 있다. - 확장성 — 새 도메인이 추가되어도
ErrorCode를 구현한 Enum만 만들면 되므로 기존 코드를 고칠 필요가 없다. - 일관성 — 클라이언트는 항상 같은 구조의 에러 응답을 받기 때문에 프론트엔드 에러 처리가 단순해진다.
결국 좋은 에러 처리는 개발자에게는 명확한 디버깅 정보를, 사용자에게는 친절한 안내를 주는 것이다. 이번 구조 개선으로 우리 팀은 이 두 가지 목표를 모두 달성할 수 있었다.