ApplicationExceptionHandler.java
package com.project.auth.presentation.support.exception;
import com.project.auth.application.support.exception.BusinessException;
import com.project.auth.application.support.exception.CommonErrorCode;
import com.project.auth.application.support.logging.LogSanitizer;
import com.project.auth.presentation.support.response.ApiResult;
import com.project.auth.presentation.support.response.ApiResultFactory;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class ApplicationExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ApplicationExceptionHandler.class);
private final ApiResultFactory apiResultFactory;
public ApplicationExceptionHandler(ApiResultFactory apiResultFactory) {
this.apiResultFactory = apiResultFactory;
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResult<Void>> handleBusinessException(
BusinessException exception,
HttpServletRequest request
) {
log.warn(
"Business exception. exceptionType={} errorCode={} method={} requestPath={}",
exception.getClass().getSimpleName(),
exception.getErrorCode().code(),
request.getMethod(),
LogSanitizer.requestPath(request.getRequestURI())
);
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(exception.getErrorCode()))
.body(apiResultFactory.failure(exception.getErrorCode().code(), exception.getErrorCode().message()));
}
/**
* 응답 직렬화 실패에 대한 정직한 처리.
*
* 응답이 이미 커밋된 상태(이미 바이트가 wire에 나간 상태)라면 회복 경로가 없다.
* 같은 직렬화 경로로 본문을 또 쓰려고 하면 동일한 실패가 재발하거나 무시된다.
* 이때는 본문 없는 빈 ResponseEntity(500)를 반환한다 — @ExceptionHandler가 null을
* 반환하면 Spring은 "응답이 작성되지 않음"으로 해석해 리졸버 체인으로 재진입할 수 있고,
* 우리가 막으려는 재귀 실패가 바로 그것이다.
*
* 응답이 아직 커밋되지 않았다면 표준 ApiResult 본문을 시도한다. 직렬화 실패가
* 구조적인 원인이면 2차 시도도 같은 이유로 실패할 수 있는데, 그때는
* @ExceptionHandler(Exception.class) 안전망이 다시 잡아낸다.
*/
@ExceptionHandler(HttpMessageNotWritableException.class)
public ResponseEntity<ApiResult<Void>> handleMessageNotWritableException(
HttpMessageNotWritableException exception,
HttpServletRequest request,
HttpServletResponse response
) {
log.error(
"Response body not writable. committed={} method={} requestPath={}",
response.isCommitted(),
request.getMethod(),
LogSanitizer.requestPath(request.getRequestURI()),
exception
);
if (response.isCommitted()) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(PresentationErrorCode.MESSAGE_NOT_WRITABLE))
.body(apiResultFactory.failure(
PresentationErrorCode.MESSAGE_NOT_WRITABLE.code(),
PresentationErrorCode.MESSAGE_NOT_WRITABLE.message()
));
}
/**
* 더 구체적인 @ExceptionHandler에 매칭되지 않은 모든 예외에 대한 최후의 안전망.
*
* 이 핸들러가 없으면 매칭되지 않은 예외는 advice를 우회해 {@code /error}로 흘러가고,
* Spring Boot 기본 응답 형태가 반환되어 {@link ApiResult} 계약이 깨진다.
*
* 항상 500 + COMMON-999를 반환하고, 알 수 없는 장애 모드를 운영이 식별할 수 있도록
* ERROR 레벨로 전체 스택트레이스를 남긴다.
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResult<Void>> handleUncaughtException(
Exception exception,
HttpServletRequest request
) {
log.error(
"Uncaught exception reached @ExceptionHandler safety net. exceptionType={} method={} requestPath={}",
exception.getClass().getName(),
request.getMethod(),
LogSanitizer.requestPath(request.getRequestURI()),
exception
);
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(CommonErrorCode.INTERNAL_SERVER_ERROR))
.body(apiResultFactory.failure(
CommonErrorCode.INTERNAL_SERVER_ERROR.code(),
CommonErrorCode.INTERNAL_SERVER_ERROR.message()
));
}
}