[Practice-3] Spring Core: 깔끔하고 확장성 있는 예외 처리

2025. 12. 16. 17:24·Spring/Core

1. 들어가며

 웹 애플리케이션을 개발할 때 정상적인 성공 응답만큼이나 중요한 것이 에러 상황을 클라이언트에게 어떻게 전달할 것인가이다.  Spring Boot를 사용하면 기본적으로 White Label Error Page 또는 단순한 에러 JSON이 내려가지만, 이는 프론트엔드나 모바일 클라이언트가 사용하기에는 정보가 부족하고 형식 또한 일관되지 않다.

 

 특히 REST API 환경에서는 에러 응답 역시 하나의 계약(Contract) 이다. 어떤 에러가 발생하더라도 항상 동일한 구조의 JSON을 내려주어야 클라이언트는 예외 케이스를 예측 가능하게 처리할 수 있다. 본 글에서는 @RestControllerAdvice를 활용해 예외 처리를 중앙화하고, ErrorCode, BusinessException, ErrorResponse, BindingResult를 조합하여 확장 가능하고 일관된 에러 처리 구조를 설계하는 방법을 정리한다.


2. 예외 처리 전략 및 설계 철학

2.1. 일관성 있는 응답 포맷 (Uniformity)

API 설계에서 가장 중요한 요소 중 하나는 예측 가능성이다. 이를 위해 다음과 같은 원칙을 세운다.

  • HTTP Status Code를 정확히 사용한다
    모든 에러를 200 OK로 내려보내지 않고, 400 / 404 / 500 등 HTTP 표준 상태 코드를 의미에 맞게 사용한다.
  • Custom Error Code를 함께 제공한다
    HTTP Status만으로는 비즈니스 에러를 구분하기 어렵다. 예를 들어 400 Bad Request 안에서도 "유효성 검증 실패"와 "비즈니스 규칙 위반"은 의미가 다르다. 이를 구분하기 위해 자체 정의한 에러 코드를 함께 내려준다.
  • Validation 오류는 상세 정보를 제공한다
    @Valid, @Validated에서 발생하는 오류는 단순 메시지가 아니라, 어떤 필드가 왜 잘못되었는지를 리스트 형태로 전달한다.

2.2. 예외 처리의 분리와 중앙화 (Separation of Concerns)

컨트롤러나 서비스 계층에서 try-catch가 난무하면 비즈니스 로직의 가독성이 급격히 떨어진다. Spring은 AOP 기반의 @RestControllerAdvice를 제공하여 예외 처리 로직을 한 곳으로 모을 수 있게 한다.

  • Business Layer: 문제가 발생하면 예외를 던지는 역할만 수행한다.
  • Exception Handler: 던져진 예외를 가로채 공통 응답 포맷으로 변환한다.

이 구조를 통해 비즈니스 로직과 에러 처리 로직을 명확히 분리할 수 있다.


3. 코드 구현 및 상세 분석

3.1. 에러 코드의 추상화 (ErrorCode 인터페이스)

에러 코드를 단순 Enum으로 바로 정의하지 않고, 먼저 인터페이스를 정의한다. .

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

이 인터페이스는 모든 에러 코드가 반드시 가져야 할 공통 속성을 정의한다.

  • getStatus() : HTTP 상태 코드
  • getCode() : 서비스 내부에서 사용하는 에러 코드
  • getMessage() : 클라이언트에 노출할 메시지

이후 Global, Domain, Auth 등 다양한 에러 그룹이 생기더라도 동일한 계약을 유지할 수 있다.

3.2. GlobalErrorCode Enum 구현

ErrorCode 인터페이스를 구현하는 Enum 구현이다.

public enum GlobalErrorCode implements ErrorCode{

    // 400 Bad Request
    INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "G001", "유효하지 않은 입력값입니다."),
    MISSING_INPUT_VALUE(HttpStatus.BAD_REQUEST, "G002", "필수 파라미터가 누락되었습니다."),
    TYPE_MISMATCH(HttpStatus.BAD_REQUEST, "G003", "파라미터의 타입이 일치하지 않습니다."),

    // 404 Not Found
    RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "G004", "요청한 리소스를 찾을 수 없습니다."),

    // 405 Method Not Allowed
    METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "G005", "지원하지 않는 HTTP 메서드입니다."),

    // 500 Internal Server Error
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "G006", "서버 내부 오류가 발생했습니다.");

    private final HttpStatus status;
    private final String code;
    private final String message;

    GlobalErrorCode(HttpStatus status, String code, String message) {
        this.status = status;
        this.code = code;
        this.message = message;
    }

    @Override
    public HttpStatus getStatus() {
        return this.status;
    }
    @Override
    public String getCode() {
        return this.code;
    }
    @Override
    public String getMessage() {
        return this.message;
    }
}

각 Enum 상수는 (HttpStatus, 내부 코드, 메시지) 를 파라미터로 받는다. 그리고 ErrorCode 인터페이스의 추상 메서드들을 getXXX() 형태로 구현한다. 이 방식의 장점은 다음과 같다.

  • 컴파일 시점에 타입 안정성이 보장된다.
  • 에러 코드가 한 곳에 모여 관리된다.
  • 중복되거나 의미가 겹치는 에러 정의를 방지할 수 있다.

3.3. 비즈니스 예외 정의 (BusinessException)

비즈니스 로직에서 의도적으로 발생시키는 예외를 표현하기 위한 클래스이다.

// BusinessException.java
@Getter
public class BusinessException extends RuntimeException {
    private final ErrorCode errorCode;

    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}

이 예외는 반드시 ErrorCode를 품고 있다. 즉, 예외가 발생하는 순간 이미 어떤 에러 코드로 응답해야 할지 결정된 상태이다.

RuntimeException을 상속한 이유는 다음과 같다.

  • 대부분의 웹 예외는 복구 대상이 아니다.
  • 트랜잭션 롤백이 필요하다.
  • 불필요한 throws 전파를 제거할 수 있다.

3.4. Validation 오류와 CustomFieldError

@Valid, @Validated에서 발생하는 유효성 검증 오류는 Spring 내부에서 BindingResult라는 객체에 저장된다. 즉, Validation 오류는 즉시 예외로 변환되는 것이 아니라, 먼저 BindingResult라는 별도의 에러 저장소에 누적된다. 이 데이터를 그대로 반환하지 않고, API 응답에 맞게 변환하기 위해 CustomFieldError라는 DTO를 만든다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CustomFieldError {


    private String field;
    private String value;
    private String reason;


    private CustomFieldError(String field, String value, String reason) {
        this.field = field;
        this.value = value;
        this.reason = reason;
    }


    public static List<CustomFieldError> of(BindingResult bindingResult) {
        return bindingResult.getFieldErrors().stream()
                .map(error -> new CustomFieldError(
                        error.getField(),
                        error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(),
                        error.getDefaultMessage()
                ))
                .collect(Collectors.toList());
    }
}

이 구조를 통해 다음을 만족한다. 즉, 항상 동일한 응답 구조를 유지할 수 있다.

  • Validation 오류가 있을 때: 필드별 상세 에러 목록 제공
  • Validation 오류가 없을 때: 빈 리스트 반환

3.5. 표준 응답 객체와 필드 에러 처리 (ErrorResponse)

클라이언트에게 내려줄 JSON 객체다. 특히 BindingResult에 담긴 필드 에러 정보를 변환하는 로직이 중요하다.

// ErrorResponse.java

@Getter
public class ErrorResponse {

    private final LocalDateTime timestamp = LocalDateTime.now();
    private final int status;
    private final String code;
    private final String message;
    private final List<FieldError> errors; // 상세 에러 목록

    private ErrorResponse(ErrorCode errorCode, List<FieldError> errors) {
        this.status = errorCode.getStatus().value();
        this.code = errorCode.getCode();
        this.message = errorCode.getMessage();
        this.errors = errors;
    }

    public static ErrorResponse of(ErrorCode errorCode) {
        return new ErrorResponse(errorCode, new ArrayList<>());
    }

    public static ErrorResponse of(ErrorCode errorCode, BindingResult bindingResult) {
        return new ErrorResponse(errorCode, FieldError.of(bindingResult));
    }

    @Getter
    public static class FieldError {
        private final String field;
        private final String value;
        private final String reason;

        private FieldError(String field, String value, String reason) {
            this.field = field;
            this.value = value;
            this.reason = reason;
        }

        public static List<FieldError> of(String field, String value, String reason) {
            List<FieldError> fieldErrors = new ArrayList<>();
            fieldErrors.add(new FieldError(field, value, reason));
            return fieldErrors;
        }

        private static List<FieldError> of(BindingResult bindingResult) {
            final List<org.springframework.validation.FieldError> fieldErrors = bindingResult.getFieldErrors();
            return fieldErrors.stream()
                    .map(error -> new FieldError(
                            error.getField(),
                            error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(),
                            error.getDefaultMessage()))
                    .collect(Collectors.toList());
        }
    }
}
  • 정적 팩토리 메서드 패턴: 생성자를 private으로 숨기고 of() 메서드를 통해서만 객체를 생성하게 강제함으로써, 객체 생성의 의도를 명확히 하고 내부 구현을 캡슐화했다.
  • BindingResult 변환: Spring의 BindingResult는 뷰(View)에 종속적인 객체이므로, 이를 직접 반환하지 않고 필요한 정보만 추출하여 List<FieldError> DTO로 변환해 반환한다.

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

마지막으로 모든 예외를 잡아내는 핸들러다. @RestControllerAdvice는 @ControllerAdvice와 @ResponseBody가 합쳐진 어노테이션으로, 응답을 자동으로 JSON 직렬화해준다.

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 비즈니스 로직 실행 중 발생하는 예외 처리
    @ExceptionHandler(BusinessException.class)
    protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        ErrorCode errorCode = e.getErrorCode();
        ErrorResponse response = ErrorResponse.of(errorCode);
        return new ResponseEntity<>(response, errorCode.getStatus());
    }

    // 바인딩 에러 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        log.warn("Validation Failed: {}", e.getMessage());
        ErrorResponse response = ErrorResponse.of(GlobalErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
        return new ResponseEntity<>(response, GlobalErrorCode.INVALID_INPUT_VALUE.getStatus());
    }

    // 나머지 모든 예외 처리 (500 Internal Server Error)
    @ExceptionHandler(Exception.class)
    protected ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("Internal Server Error: {}", e.getMessage(), e);
        ErrorResponse response = ErrorResponse.of(GlobalErrorCode.INTERNAL_SERVER_ERROR);
        return new ResponseEntity<>(response, GlobalErrorCode.INTERNAL_SERVER_ERROR.getStatus());
    }

}
  • 계층적 처리:
    1. BusinessException: 구체적인 비즈니스 로직 에러.
    2. MethodArgumentNotValidException: 프레임워크 레벨의 입력값 검증 에러.
    3. Exception: 최후의 방어선. 예상치 못한 에러가 발생했을 때 Stack Trace를 노출하지 않고 "서버 내부 오류"라는 메시지로 안전하게 처리한다.
  • 로깅 전략: 예상된 예외(BusinessException)는 WARN 레벨로, 예상치 못한 시스템 예외(Exception)는 ERROR 레벨로 로깅하여 모니터링의 중요도를 구분한다.

4. 전체 흐름 정리

  1. 비즈니스 로직에서 문제가 발생한다.
  2. BusinessException이 ErrorCode를 품고 던져진다.
  3. GlobalExceptionHandler가 예외를 가로챈다.
  4. ErrorCode 정보를 기반으로 ErrorResponse를 생성한다.
  5. 표준화된 JSON 응답이 클라이언트로 전송된다.

이 구조를 통해 확장 가능하고 일관된 전역 예외 처리 시스템을 구축할 수 있다.

'Spring > Core' 카테고리의 다른 글

[Basic-3] 관심사의 분리와 의존관계 주입(DI)  (0) 2025.12.30
[Basic-2] 순수 자바 예제로 이해하는 DIP와 OCP 위반  (0) 2025.12.30
[Basic-1] 객체 지향 설계와 스프링의 탄생 배경  (0) 2025.12.30
[Practice-2] 객체 생성: 빌더 패턴과 정적 팩터리 메서드  (0) 2025.09.02
[Practice-1] ResponseEntity<>  (0) 2025.08.27
'Spring/Core' 카테고리의 다른 글
  • [Basic-2] 순수 자바 예제로 이해하는 DIP와 OCP 위반
  • [Basic-1] 객체 지향 설계와 스프링의 탄생 배경
  • [Practice-2] 객체 생성: 빌더 패턴과 정적 팩터리 메서드
  • [Practice-1] ResponseEntity<>
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (241) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (16)
        • MSA 기본 (11)
        • MSA 아키텍처 (5)
      • Kafka (30) N
        • Core (18) N
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Practice-3] Spring Core: 깔끔하고 확장성 있는 예외 처리
상단으로

티스토리툴바