Spring 예외처리 전략

KWAK-JINHO·2026년 2월 8일

Spring

목록 보기
5/5
post-thumbnail

사이드 프로젝트를 진행하며 백엔드부터 프론트까지 개발하려고 하니 적절한 예외처리가 전체적인 개발 속도에 중요한 영향을 미치는 것을 깨달았다. 백엔드와 프론트의 코드가 분리되어 있어 Claude Code를 사용하더라도 적절한 예외처리를 하지 않으면 명확한 지시를 하기 어려웠고, 그렇다고 스택 트레이스를 그대로 전달하자니 학습하려면 에러를 씹고 뜯고 맛봐야 하는데 AI가 혼자 다 해버리니.. 이에 예외처리를 학습하고 적절한 예외처리 전략을 세우기로 했다.

예외처리 흐름

예외가 발생하면 API request의 호출 스택을 거슬러 올라가며 전파된다. 중간에 catch되지 않는다면 최종적으로 DispatcherServlet에 도달한다.

processHandlerException

DispatcherServlet의 코드를 보면 processHandlerException 메서드에서 등록된 HandlerExceptionResolver 목록을 탐색하는 것을 알 수 있다. 해당 예외를 처리할 수 있는 resolver가 하나도 없다면, 예외는 서블릿 컨테이너(Tomcat)까지 그대로 던져진다. 그 결과로 500 에러를 반환하게 된다.

커스텀 예외처리

자바의 기본 예외인 RuntimeException, IllegalArgumentException 등은 너무 범용적이어서 명확한 의미 전달이 어렵다. 이를 UserNotFoundException 등으로 알기 쉬운 이름을 붙인다면 어떤 비즈니스 규칙을 어겼는지 바로 알 수 있을 것이다.

위에서 말한 HandlerExceptionResolver의 구현체 중 ExceptionHandlerExceptionResolver는
@ExceptionHandler를 로컬 컨트롤러, 전역 어드바이스 순으로 존재하는지 체크한 후 실행한다. 이를 이용해 @RestControllerAdvice에 @ExceptionHandler를 정의해서 원하는 예외 클래스를 커스텀해 처리할 수 있다.

  1. 예외발생
    서비스 로직 내에서 에러가 발생하면 CustomException을 생성하고 throw한다.

    User user = userRepository.findById(userId)
                    .orElseThrow(() -> new CustomException(ErrorType.USER_NOT_FOUND));
  2. 예외 전파
    CustomException은 RuntimeException을 상속받는다.

    public class CustomException extends RuntimeException {
    
        private final ErrorType errorType;
    
        public CustomException(ErrorType errorType) {
            super(errorType.getMessage());
            this.errorType = errorType;
        }
    }
  3. 전역 예외 처리
    @RestControllerAdvice 에 @ExceptionHandler(CustomException.class) 애너테이션이 지정된 customExceptionHandler 메서드 작성

    @ExceptionHandler(CustomException.class)
    protected ResponseEntity<ExceptionResponse> customExceptionHandler(CustomException e, HttpServletRequest request) {
    	log.error("CustomException: {}", e.getMessage());
    
    	ErrorType errorType = e.getErrorType();
    	ExceptionResponse response = ExceptionResponse.from(errorType, request.getRequestURI());
    
    	return ResponseEntity
    		.status(errorType.getHttpStatus())
    		.body(response);
    }
  4. 응답 생성
    customExceptionHandler 메서드는 CustomException에서 ErrorType을 꺼낸다. ErrorType에 정의된 HttpStatus와 message를 사용하여 클라이언트에게 보낼 ResponseEntity< ExceptionResponse >를 생성하여 반환한다.

    public enum ErrorType {
    
    ...
    
    /**
     * User
     */
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자 정보를 찾을 수 없습니다."),
    ...
    
    private final HttpStatus httpStatus;
    private final String message;
    }
    	
    ---
    
    public record ExceptionResponse(
            int status,
            String message,
            LocalDateTime timestamp,
            String path,
            List<ValidationError> errors
    )  {
        ...
    }

원인 예외 추가

Throwable cause를 받을 수 있도록 생성자를 추가해줬다.

public CustomException(ErrorType errorType, Throwable cause) {
	super(errorType.getMessage(), cause);
	this.errorType = errorType;
}

모든 DomainException에 대해서 e.getCause()를 포함한 스택 트레이스를 로그로 남기면 너무 길고 복잡한 로그가 남게 되어 로그를 파악하기 어려워질 수 있다. 따라서 에러의 종류를 나눠 로그 레벨과 방식을 다르게 처리해주었다.

  • 4xx : INFO, WARN 레벨로 간단한 메시지만 기록

  • 5xx : 서버 내부 문제(DB, I/O 등)이 원인으로, 디버깅을 위해 원인 예외를 포함한 전체 stack trace를 ERROR 레벨로 기록.

    @ExceptionHandler(CustomException.class)
    protected ResponseEntity<ExceptionResponse> customExceptionHandler(CustomException e, HttpServletRequest request) {
        ErrorType errorType = e.getErrorType();
    
        if (errorType.getHttpStatus().is5xxServerError()) {
            log.error(
               "[{}] {} - (URI: {})",
                errorType.name(),
                errorType.getMessage(),
                request.getRequestURI(),
                e
            );
        } else {
            log.info(
               "[{}] {} - (URI: {})",
                errorType.name(),
                errorType.getMessage(),
                request.getRequestURI()
             );
        }
    
        ExceptionResponse response = ExceptionResponse.from(errorType, request.getRequestURI());
    
        return ResponseEntity
            .status(errorType.getHttpStatus())
            .body(response);
    }

추후 개선 방향

예외 객체 생성 시 비용이 큰 작업이 스택 트레이스를 생성하는 것이다.

@Override
public synchronized Throwable fillInStackTrace() {
	return this;
}

이처럼 RuntimeException의 fillInStackTrace를 오버라이드해 스택 트레이스를 아예 생성하지 않는 방법 또는,

public CustomException(ErrorType errorType) {
	this(errorType, null);
}

public CustomException(ErrorType errorType, Throwable cause) {

	super(errorType.getMessage(), cause, true, isStackTraceWritable(errorType));
	this.errorType = errorType;
}

private static boolean isStackTraceWritable(ErrorType errorType) {
	HttpStatus status = errorType.getHttpStatus();
	return status.is5xxServerError();
}

이렇게 writableStackTrace로 스택 트레이스 작성 여부를 제어 하는 방법을 고민해볼 수 있다.

profile
매일 더 나은 내가 되자

0개의 댓글