프로젝트/기술 면접 복습 플랫폼

Spring Boot 예외 처리 정리 (1편)

yoon4360 2025. 4. 14. 16:09

 

Spring으로 프로젝트를 하다 보면, 예외를 마주하는 것은 숙명과도 같다..
처음에는 try-catch로 대충 넘기고, IllegalArgumentException이나 RuntimeException으로 처리하곤 한다.


그런데 프로젝트가 커지고, 협업이 들어오고, 프론트엔드와 통신이 많아질수록
예외를 한 곳에서 한번에 처리하는 필요성를 절감하게 될것이다.

이번 DevInterview를 개발하면서,
예외를 막는 것이 아니라 설계하는 방향으로 구조를 잡았다. 그래서 예외 처리 플로우를 다이어그램으로 그려보았고

그에 따라 설계해 보았다.

 


 

예외를 구조화해야 하는 이유

Spring에서는 다양한 예외가 발생한다.

  • 존재하지 않는 리소스 조회
  • 삭제된 질문에 대한 접근
  • 인증되지 않은 요청
  • 다른 사용자의 데이터를 가져오려는 시도
  • 잘못된 입력 값 등

이런 상황을 전부 if-else + try-catch로 처리한다면 유지보수 지옥이 시작될 것이다..

그래서 나는 예외 처리를 다음 기준으로 설계했다.

  1. 예외 발생 시 일관된 JSON 응답을 보낸다.
  2. HTTP 상태 코드를 통일한다.
  3. Controller/Service는 예외를 단순히 throw만 한다.
  4. 각 예외별 책임을 분리한다.

예외 플로우 이해하기

[Service] → 예외 발생
                 ↓
[Controller] → 예외 전파
                 ↓
[GlobalExceptionHandler] → 자동 감지 & 실행

 

Spring의 예외 흐름은 위와 같다.

컨트롤러에서 따로 @ExceptionHandler를 붙이지 않아도,
전역 핸들러(@RestControllerAdvice)에서 자동으로 예외를 잡아준다.
이걸 이용하면 모든 예외 상황을 하나의 구조로 통합할 수 있다.

 


 

예외 처리 코드

전역 예외 처리기 설계

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(DevInterviewException.class)
    public ResponseEntity<ErrorResponse> handleCustom(DevInterviewException ex) {
        ErrorCode code = ex.getErrorCode();
        return ResponseEntity.status(code.getStatus())
                .body(ErrorResponse.of(code, ex.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        String detail = ex.getBindingResult().getFieldError().getDefaultMessage();
        return ResponseEntity.badRequest()
                .body(ErrorResponse.of(ErrorCode.VALIDATION_ERROR, detail));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnknown(Exception ex) {
        return ResponseEntity.internalServerError()
                .body(ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR, ex.getMessage()));
    }
}

 

  • 커스텀 예외는 DevInterviewException으로 감지한다.
  • 유효성 검증 실패는 MethodArgumentNotValidException 처리한다.
  • 그 외 알 수 없는 오류는 Exception으로 잡는다.

 

커스텀 예외 클래스

 

에러 코드 Enum

public enum ErrorCode {
    NOT_FOUND(404, "리소스를 찾을 수 없습니다."),
    FORBIDDEN(403, "접근 권한이 없습니다."),
    BAD_REQUEST(400, "잘못된 요청입니다."),
    VALIDATION_ERROR(400, "요청값이 유효하지 않습니다."),
    INTERNAL_SERVER_ERROR(500, "서버 오류");
}

 

 

→ 코드 + 메시지를 분리해서 재사용성과 가독성을 높였다.

 

추상 예외 클래스

public abstract class DevInterviewException extends RuntimeException {
    private final ErrorCode errorCode;

    protected DevInterviewException(ErrorCode errorCode, String detail) {
        super(detail);
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

 

  • abstract → 직접 생성하는 것을 방지했다. (구체 예외만 사용)
  • protected 생성자 → 상속 외에는 생성 못하도록 제한했다.

 

실제 예외 클래스

public class NotFoundException extends DevInterviewException {
    public NotFoundException(String detail) {
        super(ErrorCode.NOT_FOUND, detail);
    }
}

public class ForbiddenException extends DevInterviewException {
    public ForbiddenException(String detail) {
        super(ErrorCode.FORBIDDEN, detail);
    }
}

실제 서비스 코드에 적용

public Writing getWritingById(UUID id) {
    return writingRepository.findById(id)
        .orElseThrow(() -> new NotFoundException("존재하지 않는 글입니다."));
}

 

예전에는 IllegalArgumentException을 던졌다면,
이제는 NotFoundException을 던짐으로써 HTTP 404 + JSON 응답이 자동으로 반환된다.

 

 


 

마무리

이번 예외 처리 구조 설계를 해보며 Spring의 예외 흐름이

자동 전파 + 자동 실행 구조로 매우 구현하기 쉽게되어 있다는것을 알게되었다.

 

그리고 커스텀 예외 클래스 구조는 서비스 별, 도메인 별 책임 분리에 도움이 많이 된것 같다.

예외도 결국 설계 대상이고 이를 잘 해두면 비즈니스 코드를 구현할 때 로직 설계에만 집중할 수 있을 것이다.