Skip to content

예외 처리 가이드

서비스 계층은 발생할 수 있는 모든 예외를 BizException 하나로 감싸 던지고, 전역 예외 처리기가 이를 표준 ResponseDto 포맷(successYn/messageCd)으로 변환해 응답한다.

HTTP 상태 코드와 1:1로 매핑되는 enum이다. messageCd(클라이언트가 분기 처리하는 안정적인 코드값)로 응답에 실린다.

common/code/CodeApiResult.java
@Getter
@AllArgsConstructor
public enum CodeApiResult {
SUCCESS(HttpStatus.OK),
FAILED(HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_PARAMETER(HttpStatus.BAD_REQUEST),
SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR),
NOT_FOUND(HttpStatus.NOT_FOUND),
PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED),
NOT_LOGIN(HttpStatus.UNAUTHORIZED),
ACCOUNT_LOCKED(HttpStatus.UNAUTHORIZED),
FORBIDDEN(HttpStatus.FORBIDDEN),
;
private final HttpStatus httpStatus;
}

새 오류 상황이 필요하면 여기에 상수 하나를 추가하고, 어떤 HTTP 상태와 매핑할지만 정하면 된다.

  1. 서비스 계층에서 발생하는 모든 비즈니스 예외를 감싸는 공통 예외 — 항상 CodeApiResult를 함께 들고 있다.
    common/exception/BizException.java
    @Getter
    public class BizException extends RuntimeException {
    @JsonIgnore private final Exception origin;
    private final CodeApiResult codeApiResult;
    private String message = "";
    /** 원인 예외 없이 SERVER_ERROR로 초기화한다 — 방어적 기본값. */
    public BizException() {
    origin = this;
    this.codeApiResult = CodeApiResult.SERVER_ERROR;
    }
    /** 다른 예외를 감싼다. 이미 BizException이면 그대로 이어받고, 아니면 SERVER_ERROR로 감싼다. */
    public BizException(final Exception e) {
    if (e instanceof BizException) {
    origin = ((BizException) e).getOrigin();
    codeApiResult = ((BizException) e).getCodeApiResult();
    message = e.getMessage();
    } else {
    origin = e;
    codeApiResult = CodeApiResult.SERVER_ERROR;
    if (StringUtils.isNotBlank(e.getMessage())) {
    this.message = e.getMessage();
    }
    }
    if (origin != this) {
    initCause(origin);
    }
    }
    /** 원인 예외 없이 지정한 결과 코드로 초기화한다 — 서비스 코드가 검증 실패 등을 직접 알릴 때 사용한다. */
    public BizException(final CodeApiResult error) {
    origin = this;
    this.codeApiResult = error;
    }
    }
  2. 서비스 메소드에서 쓰는 두 가지 패턴
    // 1) 조건 실패를 직접 알림
    boardRepository
    .findById(id)
    .orElseThrow(() -> new BizException(CodeApiResult.NOT_FOUND));
    // 2) 그 외 모든 예외를 감쌈
    try {
    return boardRepository.save(entity);
    } catch (final Exception e) {
    throw new BizException(e);
    }

전역 예외 처리기 (RestApiExceptionAdvice)

Section titled “전역 예외 처리기 (RestApiExceptionAdvice)”
  1. @RestControllerAdvice(annotations = RestController.class) — @RestController 전체에 적용되는 전역 예외 처리기다. 마지막 exceptionHandler가 나머지 모든 예외를 잡는 catch-all이라, 이 어드바이스 범위 밖(필터 등)에서 발생하지 않는 한 Spring Boot 기본 에러 응답이 클라이언트로 새어나가지 않는다.
    common/exception/advice/RestApiExceptionAdvice.java
    @Slf4j
    @RestControllerAdvice(annotations = RestController.class)
    public class RestApiExceptionAdvice {
    @ExceptionHandler(BizException.class)
    public ResponseEntity<ResponseDto<Void>> bizExceptionHandler(final BizException e) {
    final CodeApiResult codeApiResult =
    Objects.requireNonNullElse(e.getCodeApiResult(), CodeApiResult.SERVER_ERROR);
    if (codeApiResult.getHttpStatus().is5xxServerError()) {
    log.error("### BizException Occurred. codeApiResult={}", codeApiResult, e.getOrigin());
    } else {
    log.warn("### BizException Occurred. codeApiResult={}", codeApiResult);
    }
    return ResponseEntity.status(codeApiResult.getHttpStatus())
    .body(new ResponseDto<>(codeApiResult));
    }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ResponseDto<Void>> methodArgumentNotValidExceptionHandler(
    final MethodArgumentNotValidException e) {
    return ResponseEntity.badRequest().body(new ResponseDto<>(CodeApiResult.INVALID_PARAMETER));
    }
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ResponseDto<Void>> exceptionHandler(final Exception e) {
    log.error("### Unhandled Exception Occurred.", e);
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
    .body(new ResponseDto<>(CodeApiResult.SERVER_ERROR));
    }
    }
  2. 4xx(NOT_FOUND/PASSWORD_NOT_MATCH 등)는 정상적인 클라이언트 흐름이라 WARN으로, 실제 서버 오류인 5xx만 ERROR + 원인 스택트레이스로 남겨 로그 노이즈를 줄인다.
  3. 이 외에도 HttpMessageNotReadableException(JSON 파싱 실패), ConstraintViolationException(파라미터 제약 위반), MethodArgumentTypeMismatchException(@PathVariable/@RequestParam 타입 불일치)을 각각 400 + INVALID_PARAMETER로 처리하는 핸들러가 있다.

예외가 응답으로 바뀔 때는 successYn: "N" + messageCd(원인 예외가 BizException이면 그 codeApiResult 이름, 아니면 FAILED)로 실린다.

{
"successYn": "N",
"messageCd": "NOT_FOUND"
}
common/dto/ResponseDto.java
public ResponseDto(final Exception e) {
successYn = "N";
if (e instanceof BizException biz) {
messageCd = biz.getCodeApiResult().name();
} else {
messageCd = CodeApiResult.FAILED.name();
}
}

필터 단계 예외 (JsonErrorController)

Section titled “필터 단계 예외 (JsonErrorController)”
  1. ErrorController를 직접 구현한 빈을 등록하면 ErrorMvcAutoConfiguration이 @ConditionalOnMissingBean으로 물러나 기본 BasicErrorController가 아예 등록되지 않는다 — 이를 이용해 필터 단계 예외도 표준 ResponseDto JSON으로 응답하게 만든다.
  2. 로그인 여부와 무관하게 항상 원래 상태 코드로 응답해야 하므로 @PublicApi를 붙인다.