김솔비 블로그
기술 블로그

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

5분 읽기

도입 배경: 왜 에러 코드를 분리해야 할까?

서비스가 커지고 도메인이 복잡해질수록 에러 처리에 대한 고민도 깊어진다. 초기에는 전역 에러 코드만으로 충분했지만, 기능이 늘어나면서 다음과 같은 문제가 생겼다.

  • 에러 코드의 의미가 모호하다 — 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 타입에 의존하지 않고 이 인터페이스만 바라보면 된다.

java
public interface ErrorCode { HttpStatus getStatus(); String getMessage(); }

2. 전역·도메인 에러 코드

전역 에러는 GlobalErrorCode에서, 도메인 에러는 각 도메인의 Enum에서 관리한다.

java
// 전역 에러 @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 인터페이스를 받기 때문에 전역·도메인 어떤 구현체든 받을 수 있다.

java
@Getter public class CustomException extends RuntimeException { private final ErrorCode errorCode; public CustomException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } }

4. 전역 예외 핸들러 (GlobalExceptionHandler)

@RestControllerAdvice로 발생하는 모든 CustomException을 잡아 일관된 응답 형식으로 바꾼다.

java
@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() )); } }

실제 사용 예시와 흐름

서비스 로직에서는 상황에 맞는 에러 코드를 골라 예외를 던지기만 하면 된다.

java
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만 만들면 되므로 기존 코드를 고칠 필요가 없다.
  • 일관성 — 클라이언트는 항상 같은 구조의 에러 응답을 받기 때문에 프론트엔드 에러 처리가 단순해진다.

결국 좋은 에러 처리는 개발자에게는 명확한 디버깅 정보를, 사용자에게는 친절한 안내를 주는 것이다. 이번 구조 개선으로 우리 팀은 이 두 가지 목표를 모두 달성할 수 있었다.

참고 자료