앞선 포스팅에서 트랜잭션 롤백과 예외의 성격(Checked vs Unchecked)에 대해 깊이 있게 다루었습니다. 이제 우리는 예외를 언제, 어떻게 던져야 하는지 알게 되었습니다.
그렇다면, 던져진 예외를 클라이언트(프론트엔드)에게 어떻게 전달해야 할까요? 그냥 놔두면 스프링 부트는 500 에러와 함께 못생긴 화이트 라벨 에러 페이지나 알 수 없는 스택 트레이스를 뱉어냅니다. 이는 사용자 경험을 망치고, 프론트엔드 개발자와의 소통 비용을 증가시킵니다.
Spring Boot 전역 예외 처리의 정석: @RestControllerAdvice와 실무 패턴
1. 서론
백엔드 API 서버를 개발하다 보면 가장 골치 아픈 문제 중 하나가 바로 ‘일관성 없는 에러 응답‘입니다. 어떤 컨트롤러는 에러가 났을 때 null을 반환하고, 어떤 곳은 HTTP 500을, 또 어떤 곳은 제각각의 JSON 포맷을 내려줍니다. 이렇게 되면 프론트엔드 개발자는 API마다 에러 처리 로직을 따로 짜야 하는 지옥을 맛보게 됩니다.
“비밀번호가 틀렸습니다”, “중복된 이메일입니다”와 같은 비즈니스 로직상의 예외부터, 시스템 내부의 알 수 없는 런타임 에러까지, 이 모든 상황을 우아하고 일관성 있게 처리할 수는 없을까요? 다행히 스프링 부트는 AOP(Aspect Oriented Programming) 기술을 기반으로 한 강력한 예외 처리 도구인 @ControllerAdvice와 @ExceptionHandler를 제공합니다. 오늘은 이 두 가지 어노테이션을 활용하여 전역(Global)에서 예외를 핸들링하는 표준 아키텍처를 구축해 보겠습니다.
2. 본론
1: 전역 예외 처리의 핵심, @RestControllerAdvice
스프링에서 예외를 처리하는 방법은 크게 세 가지가 있습니다.
- 메서드 내의
try-catch(지엽적, 코드 중복 발생) - 컨트롤러 내의
@ExceptionHandler(해당 컨트롤러에서만 유효) @ControllerAdvice를 이용한 전역 처리 (권장)
우리가 주목할 것은 3번입니다. @ControllerAdvice는 모든 컨트롤러를 감시하다가 예외가 발생하면 이를 가로채서(Intercept) 처리하는 역할을 합니다. 특히 Rest API 서버를 만든다면 @ResponseBody가 결합된 @RestControllerAdvice를 사용하는 것이 일반적입니다. 이를 통해 예외 발생 시 객체를 JSON 형태로 바로 반환할 수 있습니다.
2: 실무형 전역 예외 처리 설계 단계 (Step-by-Step)
단순히 어노테이션만 붙인다고 끝이 아닙니다. 실무에서는 체계적인 클래스 설계가 필요합니다. 가장 널리 사용되는 4단계 패턴을 소개합니다.
Step 1: 공통 에러 응답 객체 (ErrorResponse) 설계
먼저 클라이언트에게 내려줄 JSON 포맷을 통일해야 합니다. 성공이든 실패든 약속된 규격이 있어야 프론트엔드에서 자동화된 처리가 가능합니다.
Java
@Getter
@Builder
public class ErrorResponse {
private final LocalDateTime timestamp = LocalDateTime.now();
private final int status; // HTTP 상태 코드 (예: 400, 404)
private final String error; // 에러 이름 (예: BAD_REQUEST)
private final String code; // 서버 내부 관리 코드 (예: C001)
private final String message; // 클라이언트에게 보여줄 메시지
public static ResponseEntity<ErrorResponse> toResponseEntity(ErrorCode errorCode) {
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ErrorResponse.builder()
.status(errorCode.getHttpStatus().value())
.error(errorCode.getHttpStatus().name())
.code(errorCode.name())
.message(errorCode.getMessage())
.build()
);
}
}
Step 2: 에러 코드의 관리 (ErrorCode Enum)
에러 메시지나 코드를 문자열(“User Not Found”)로 하드 코딩하면 유지보수가 불가능합니다. Enum을 활용하여 한곳에서 관리합니다.
Java
@Getter
@AllArgsConstructor
public enum ErrorCode {
// 400 BAD_REQUEST
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다."),
// 404 NOT_FOUND
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 회원을 찾을 수 없습니다."),
// 500 INTERNAL_SERVER_ERROR
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다.");
private final HttpStatus httpStatus;
private final String message;
}
Step 3: 비즈니스 예외 기본 클래스 (Custom Exception)
모든 커스텀 예외가 상속받을 부모 클래스를 만듭니다. RuntimeException을 상속받아 트랜잭션 롤백이 가능하도록 합니다.
Java
@Getter
@AllArgsConstructor
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
}
Step 4: 전역 예외 핸들러 구현 (GlobalExceptionHandler)
이제 드래곤볼을 모으듯이 모든 예외를 이곳에서 처리합니다.
Java
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* [1] BusinessException 처리
* 개발자가 의도적으로 던진 비즈니스 예외를 처리합니다.
*/
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
log.error("handleBusinessException", e);
return ErrorResponse.toResponseEntity(e.getErrorCode());
}
/**
* [2] @Valid 유효성 검사 실패 처리
* @RequestBody의 필드 유효성 검증 실패 시 발생하는 예외입니다.
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException e) {
log.error("handleValidationException", e);
// 첫 번째 에러 메시지만 반환하거나, 커스텀하여 리스트로 반환 가능
return ErrorResponse.toResponseEntity(ErrorCode.INVALID_INPUT_VALUE);
}
/**
* [3] 그 외 모든 예외 처리
* 예측하지 못한 런타임 에러는 500으로 처리하여 보안을 강화합니다.
*/
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("handleException", e);
return ErrorResponse.toResponseEntity(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
3: 이 방식이 주는 강력한 이점
위와 같은 구조를 도입했을 때 얻을 수 있는 이점은 명확합니다.
- 관심사의 분리 (Separation of Concerns): 컨트롤러는 오직 ‘요청을 받고 응답을 주는’ 역할에만 집중할 수 있습니다. 지저분한 예외 처리 로직이 컨트롤러에서 완전히 사라집니다.
- API 일관성 확보: 어떤 에러가 발생하더라도 클라이언트는 항상 동일한 JSON 구조(
status,code,message)를 받게 됩니다. 프론트엔드 개발자는code값만 확인하여 알림창을 띄우거나 페이지를 이동시키는 등 통일된 처리를 할 수 있습니다. - 보안 강화: 자바의 스택 트레이스(Stack Trace)가 외부에 노출되는 것은 보안상 매우 위험합니다. 전역 처리기에서 이를 감추고, 정제된 메시지만 전달함으로써 내부 시스템 정보를 보호할 수 있습니다.
3. 결론
지금까지 스프링 부트의 @RestControllerAdvice를 활용하여 우아하게 예외를 처리하는 방법에 대해 알아보았습니다.
핵심은 “예외는 발생 즉시 던지고(Throw), 처리는 한곳에서(Advice) 담당한다“는 원칙입니다. 개발자는 비즈니스 로직 수행 중 문제가 생기면 throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); 한 줄만 작성하면 됩니다. 나머지는 전역 핸들러가 알아서 HTTP 상태 코드와 메시지를 규격에 맞춰 클라이언트에게 전달해 줄 것입니다.
오늘 소개한 코드는 실무에서 즉시 사용 가능한 표준 패턴입니다. 여러분의 프로젝트에 적용하여 코드의 가독성을 높이고, 프론트엔드 팀과의 협업 효율을 극대화해 보시기 바랍니다. 에러 처리가 깔끔해지는 순간, 서비스의 퀄리티는 한 단계 더 높아집니다.
예외 처리 시 로그(Log)를 남기는 것은 디버깅의 생명입니다. 에러가 났을 때 개발자에게 즉시 알람이 오게 하는 방법