Spring BootREST APIException Handling

Spring Rest API 구현시 예외 처리 설계

컨트롤러마다 흩어진 try-catch를 걷어내고, 클라이언트 응답과 서버 로그를 분리해 정리한 예외 처리 구조를 기록했습니다.

Srue2026년 3월 21일
Spring Rest API 구현시 예외 처리 설계

처음 REST API를 만들 때는 예외 처리를 대충 넘기기 쉽습니다.
요청이 오면 서비스 호출하고, 문제가 생기면 try-catch로 잡아서 메시지 하나 내려주면 된다고 생각하기 때문입니다.

저도 처음에는 그랬습니다. 작은 기능 몇 개 만들 때는 크게 불편하지 않았습니다. 그런데 API 수가 늘어나고, 프론트와 함께 붙기 시작하니 문제가 바로 드러났습니다.

  • 어떤 API는 400을 내려주고
  • 어떤 API는 같은 상황인데 500을 내려주고
  • 어떤 API는 message 필드 이름이 다르고
  • 어떤 API는 스택트레이스 비슷한 문장까지 그대로 응답에 들어갔습니다

사용자 입장에서도 불편했고, 프론트엔드 입장에서도 처리 기준이 흔들렸습니다. 서버 쪽에서도 로그를 읽기 어려웠습니다. 결국 예외는 "잡았다"가 끝이 아니라, 어떤 기준으로 분류하고 어떻게 응답할지를 먼저 정해야 한다는 걸 그때 배웠습니다.

처음에 가장 먼저 정한 기준

예외 처리를 다시 정리하면서 아래 세 가지를 먼저 맞췄습니다.

  1. 클라이언트에 보여줄 메시지와 서버 로그 메시지를 분리한다
  2. 비즈니스 예외와 예상하지 못한 시스템 예외를 구분한다
  3. 모든 API가 같은 응답 형식을 사용한다

이 세 가지만 맞춰도 API를 사용하는 쪽이 훨씬 편해집니다.

예를 들어 재고가 부족한 상황은 "예상 가능한 비즈니스 예외"입니다. 이 경우에는 프론트가 사용자에게 안내할 수 있는 메시지와 코드가 있어야 합니다.
반대로 DB 연결 문제나 외부 API 장애는 "예상하지 못한 시스템 예외"에 가깝습니다. 이건 사용자에게 구체적인 내부 사정을 노출하기보다, 서버 로그에 충분히 남기고 안전한 메시지만 내려주는 편이 맞았습니다.

컨트롤러에서 예외를 직접 잡지 않기로 했다

가장 먼저 걷어낸 건 컨트롤러 안의 try-catch였습니다.

@PostMapping("/orders")
public ResponseEntity<?> createOrder(@RequestBody CreateOrderRequest request) {
    try {
        orderService.create(request);
        return ResponseEntity.ok().build();
    } catch (IllegalArgumentException e) {
        return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
    } catch (Exception e) {
        return ResponseEntity.status(500).body(Map.of("message", "알 수 없는 오류"));
    }
}

처음엔 나쁘지 않아 보였지만, 컨트롤러가 늘어나면 이 방식은 금방 지저분해집니다.
비슷한 catch가 반복되고, 어느 순간부터는 누구는 IllegalStateException, 누구는 RuntimeException, 누구는 커스텀 예외를 그대로 내려주기 시작합니다.

그래서 컨트롤러는 요청과 응답만 담당하고, 예외는 전역에서 처리하는 구조로 바꿨습니다.

에러 코드를 먼저 만들었다

예외 처리를 정리할 때 가장 도움이 된 건 에러 코드를 먼저 만든 것이었습니다.

public enum ErrorCode {
    INVALID_INPUT(HttpStatus.BAD_REQUEST, "COMMON-400", "잘못된 요청입니다."),
    ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER-404", "주문을 찾을 수 없습니다."),
    STOCK_NOT_ENOUGH(HttpStatus.CONFLICT, "ORDER-409", "재고가 부족합니다."),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON-500", "일시적인 오류가 발생했습니다.");
 
    private final HttpStatus status;
    private final String code;
    private final String message;
}

이렇게 해두면 좋은 점이 분명했습니다.

  • 상태 코드가 매번 흔들리지 않는다
  • 프론트가 message가 아니라 code 기준으로 처리할 수 있다
  • 운영 중에 로그를 볼 때도 어떤 문제인지 빨리 분류된다

메시지는 나중에 얼마든지 다듬을 수 있지만, 에러 코드 체계는 처음에 조금 더 신경 쓰는 편이 낫다고 느꼈습니다.

비즈니스 예외는 공통 예외로 감쌌다

서비스 코드에서 비즈니스 규칙이 깨지는 경우는 BusinessException 하나로 모았습니다.

public class BusinessException extends RuntimeException {
 
    private final ErrorCode errorCode;
 
    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
 
    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

서비스 레이어에서는 상황에 따라 에러 코드만 던지면 됐습니다.

if (order == null) {
    throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
}
 
if (availableStock < request.quantity()) {
    throw new BusinessException(ErrorCode.STOCK_NOT_ENOUGH);
}

이렇게 해두니 서비스 코드가 훨씬 읽기 편해졌습니다.
무슨 상태를 어떤 응답으로 바꿀지는 서비스가 아니라 핸들러가 맡고, 서비스는 "지금 어떤 규칙이 깨졌는가"만 표현하면 됐습니다.

실제 응답은 전역 핸들러에서 만들었다

예외 응답은 @RestControllerAdvice에서 한 번에 처리했습니다.

@RestControllerAdvice
public class GlobalExceptionHandler {
 
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        ErrorCode errorCode = e.getErrorCode();
 
        return ResponseEntity
            .status(errorCode.getStatus())
            .body(ErrorResponse.of(errorCode));
    }
 
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR));
    }
}

응답 포맷도 단순하게 고정했습니다.

public record ErrorResponse(
    String code,
    String message
) {
    public static ErrorResponse of(ErrorCode errorCode) {
        return new ErrorResponse(errorCode.getCode(), errorCode.getMessage());
    }
}

이 구조가 좋았던 이유는 API를 보는 사람 입장에서 예측 가능해졌기 때문입니다.
어떤 예외가 나더라도 최소한 응답 형식은 같고, 프론트는 codestatus 조합만 보면 됩니다.

도메인 예외별 핸들러를 따로 두는 것도 괜찮았다

처음에는 BusinessException 하나로 대부분 묶어서 처리했습니다.
그런데 기능이 늘어나면서 어떤 도메인은 예외 응답을 조금 더 선명하게 분리하고 싶을 때가 있었습니다.

예를 들면 공간 예약, 쿠폰, 결제처럼 도메인 자체가 분명한 경우입니다.

@ExceptionHandler({SpaceException.class})
public ResponseEntity<ErrorResponse> exceptionHandler(SpaceException e) {
    ErrorCode errorCode = e.getErrorCode();
 
    return ResponseEntity
        .status(errorCode.getStatus())
        .body(ErrorResponse.of(errorCode));
}

이렇게 두면 좋은 점은 두 가지였습니다.

  1. 어떤 도메인 예외를 어떤 에러 코드 체계로 다루는지 더 분명해진다
  2. 같은 BusinessException이라도 도메인별 로그나 후처리를 따로 넣기 쉬워진다

예를 들어 SpaceException은 예약 가능 시간, 중복 예약, 공간 비활성화 같은 규칙이 함께 묶여 있을 수 있습니다.
이 도메인에서만 공통으로 남기고 싶은 로그가 있다면, 별도 @ExceptionHandler가 오히려 읽기 쉬웠습니다.

물론 모든 예외 클래스를 전부 따로 빼는 건 과할 수 있습니다.
그래서 저는 기본은 공통 BusinessException으로 두고, 도메인 규칙이 선명하고 응답 구분이 자주 필요한 경우에만 예외 핸들러를 분리하는 편이 더 낫다고 느꼈습니다.

Validation 예외는 따로 분리했다

비즈니스 예외와 입력값 검증 실패는 성격이 달랐습니다.
예를 들어 @Valid에서 걸리는 문제는 "규칙 위반"이 아니라 "요청 자체가 잘못된 상태"에 가까웠습니다.

그래서 MethodArgumentNotValidException은 별도 처리했습니다.

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
    MethodArgumentNotValidException e
) {
    String message = e.getBindingResult()
        .getFieldErrors()
        .stream()
        .findFirst()
        .map(FieldError::getDefaultMessage)
        .orElse("요청 값을 다시 확인해주세요.");
 
    return ResponseEntity
        .badRequest()
        .body(new ErrorResponse("COMMON-400", message));
}

이렇게 분리하고 나니 프론트에서 "폼 검증 오류"와 "실제 비즈니스 오류"를 나누기 쉬워졌습니다.

로그는 응답보다 조금 더 자세히 남겼다

클라이언트 응답은 짧고 안전하게, 서버 로그는 원인 파악이 가능하게 남기는 쪽으로 정리했습니다.

비즈니스 예외는 warn, 시스템 예외는 error로 나눴습니다.

@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
    log.warn("business-exception code={}, message={}", e.getErrorCode().getCode(), e.getMessage());
    ...
}
 
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
    log.error("unexpected-exception", e);
    ...
}

예전에 가장 아쉬웠던 건, 사용자에게 내려준 메시지와 서버 로그 메시지가 섞여 있었던 점이었습니다.
지금은 클라이언트에는 안전한 문장만 내려가고, 실제 디버깅에 필요한 스택트레이스와 상세 원인은 서버 로그에서만 보이게 해두었습니다.

예외 메시지를 그대로 노출하지 않기로 했다

이 부분은 나중에 특히 중요하다고 느꼈습니다.

예외를 그대로 노출하면 편할 때도 있습니다. 개발 중에는 빨리 원인을 볼 수 있기 때문입니다. 하지만 운영 환경에서는 아래 문제가 생깁니다.

  • 내부 테이블 이름이나 구현 정보가 노출될 수 있다
  • 외부 시스템 명칭이나 주소가 메시지에 남을 수 있다
  • 프론트가 메시지 문구에 의존하기 시작한다

한 번 그렇게 흘러가면 나중에 문구 하나 바꾸는 것도 부담이 됩니다.
그래서 사용자 응답은 코드와 짧은 안내 문장까지만, 상세 원인은 로그에서만 보도록 기준을 잡았습니다.

실제로 달라진 점

구조를 정리하고 나서 가장 먼저 좋아진 건 프론트와의 대화였습니다.

예전에는 "어떤 경우에 400이 오고 어떤 경우에 409가 오나요?" 같은 질문이 자주 나왔습니다.
지금은 문서와 응답 코드가 맞아떨어져서, API 연동 쪽 대화가 훨씬 줄었습니다.

운영에서도 비슷했습니다.

  • 비즈니스 오류인지
  • 입력값 오류인지
  • 시스템 장애인지

가 로그만 봐도 빨리 구분됐습니다. 장애 대응이 빨라졌고, 무엇보다 "이건 서버가 고쳐야 할 문제인지, 사용자가 다시 요청하면 되는 문제인지"를 빨리 판단할 수 있었습니다.

지금도 유지하는 기준

REST API 예외 처리를 설계할 때 지금은 아래 기준을 먼저 봅니다.

  1. 이 예외는 사용자에게 알려줄 수 있는가
  2. 프론트가 코드 기준으로 분기할 수 있는가
  3. 로그만 봐도 원인을 다시 찾을 수 있는가
  4. 컨트롤러가 예외 처리 책임까지 떠안고 있지는 않은가

예외 처리는 기능이 늘어날수록 뒤늦게 손보게 되는 경우가 많습니다. 그런데 한 번 API를 외부에 열고 나면, 그때부터는 응답 형식도 계약이 됩니다.
그래서 오히려 초반에 기준을 단단하게 잡아두는 편이 나중에 훨씬 편했습니다.

마무리

REST API에서 예외 처리는 "에러가 나면 어떻게 막을까"보다 "문제가 생겼을 때 누구에게 어떤 정보를 줄 것인가"를 정하는 일에 더 가까웠습니다.

저는 지금도 새로운 API를 만들 때 기능 구현보다 먼저, 이 API에서 실패는 어떤 모양으로 돌아갈지를 같이 생각합니다.
그 기준이 정리돼 있을수록 API는 더 믿고 쓰기 쉬워졌습니다.