웹 애플리케이션을 개발하다 보면 데이터베이스 조회 실패, 잘못된 파라미터 입력, 권한 부족 등 수많은 예외(Exception) 상황을 마주하게 됩니다. 백엔드 서버에서 이러한 예외를 제때 잡아내지 못하면, 클라이언트에게 내부 소스 코드나 톰캣 에러 페이지(500 Internal Server Error)가 그대로 노출되는 심각한 보안 및 사용자 경험 문제가 발생합니다.
전통적인 자바 웹 개발에서는 모든 컨트롤러 메서드마다 try-catch 블록을 떡칠하여 예외를 처리하곤 했습니다. 하지만 이 방식은 코드 중복을 낳고 비즈니스 로직을 파악하기 어렵게 만듭니다. 스프링 프레임워크(Spring Framework)는 이를 우아하게 해결하기 위해 AOP(측면 지향 프로그래밍) 기술을 기반으로 한 전역 예외 처리 메커니즘을 제공합니다.
이번 글에서는 실무에서 표준으로 사용하는 @RestControllerAdvice와 @ExceptionHandler를 활용한 전역 예외 처리(Global Exception Handler) 아키텍처를 완벽하게 구축해 보겠습니다.
1. 기존 예외 처리 방식의 문제점과 컨트롤러 어드바이스의 등장
스프링 부트에서 예외가 발생했을 때 별도의 처리를 하지 않으면, 스프링은 기본적으로 /error 경로로 요청을 포워딩하여 BasicErrorController가 처리하도록 만듭니다. 이 경우 클라이언트는 정형화되지 않은 화이트라벨 에러 페이지(Whitelabel Error Page)나 복잡한 스택 트레이스(Stack Trace)를 받게 됩니다.
이를 막기 위해 각 컨트롤러 내부에 @ExceptionHandler를 선언할 수도 있지만, 이 역시 해당 컨트롤러 안에서 발생하는 예외만 잡을 수 있다는 한계가 있습니다. 시스템 전반에 걸쳐 수십 개의 컨트롤러가 존재한다면 똑같은 예외 처리 코드를 모든 클래스에 복사·붙여넣기 해야 합니다.
이러한 공통 관심사(Cross-cutting Concerns)를 한곳으로 모아 격리한 곳이 바로 @RestControllerAdvice입니다. 이 어노테이션은 모든 컨트롤러에서 발생하는 예외를 한군데에서 가로채어(Intercept) 중앙 집중식으로 처리할 수 있게 해주는 컨트롤러 전용 구원투수입니다.
2. 실무형 예외 처리 아키텍처 설계 3단계
실무 표준에 맞는 전역 예외 처리기를 만들기 위해서는 크게 (1) 공통 에러 응답 객체 정의, (2) 비즈니스 커스텀 예외 정의, (3) 전역 어드바이스 클래스 구현의 3단계 프로세스를 거칩니다.
1단계: 일관된 에러 응답 스펙 (Error Response) 정의
클라이언트(프론트엔드, 모바일 앱 등)가 에러가 발생했을 때 일관된 구조로 데이터를 파싱할 수 있도록 공통 JSON 포맷을 설계해야 합니다.
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
@Getter
public class ErrorResponse {
private final LocalDateTime timestamp = LocalDateTime.now();
private final int status;
private final String error;
private final String code;
private final String message;
@Builder
public ErrorResponse(int status, String error, String code, String message) {
this.status = status;
this.error = error;
this.code = code;
this.message = message;
}
}
2단계: 비즈니스 에러 코드를 위한 Enum 및 Custom Exception 구현
예외 종류별로 HTTP 상태 코드와 비즈니스 커스텀 코드를 매핑하기 위해 에러 코드 Enum을 작성하고, 이를 사용하는 전역 비즈니스 예외 클래스를 생성합니다.
// [ErrorCode.java]
@Getter
public enum ErrorCode {
INVALID_INPUT_VALUE(400, "COMMON_001", "잘못된 입력 값입니다."),
METHOD_NOT_ALLOWED(405, "COMMON_002", "허용되지 않은 메서드입니다."),
USER_NOT_FOUND(404, "USER_001", "존재하지 않는 유저 회원 정보입니다."),
DUPLICATE_EMAIL(409, "USER_002", "이미 가입된 이메일 주소입니다.");
private final int status;
private final String code;
private final String message;
ErrorCode(int status, String code, String message) {
this.status = status;
this.code = code;
this.message = message;
}
}
// [BusinessException.java]
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
3단계: @RestControllerAdvice를 이용한 전역 예외 처리기 구축
이제 마지막으로 컨트롤러 바깥에 성벽을 세우듯 애플리케이션 전역의 예외를 잡아낼 GlobalExceptionHandler 클래스를 선언합니다.
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// 1. 우리가 직접 정의한 비즈니스 커스텀 예외 처리
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
log.error("handleBusinessException: {}", e.getErrorCode().getMessage());
ErrorCode errorCode = e.getErrorCode();
ErrorResponse response = ErrorResponse.builder()
.status(errorCode.getStatus())
.error(errorCode.name())
.code(errorCode.getCode())
.message(errorCode.getMessage())
.build();
return ResponseEntity.status(errorCode.getStatus()).body(response);
}
// 2. 그 외 인프라 레벨이나 예측하지 못한 시스템 전체 최상위 예외(500) 처리
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("handleException", e);
ErrorResponse response = ErrorResponse.builder()
.status(500)
.error("INTERNAL_SERVER_ERROR")
.code("SERVER_001")
.message("서버 내부 시스템 오류가 발생했습니다. 관리자에게 문의하세요.")
.build();
return ResponseEntity.status(500).body(response);
}
}
3. 전역 예외 처리 아키텍처가 주는 3가지 이점
이와 같이 중앙 집중식 예외 처리 프레임워크를 구성하면 프로젝트 관리에 엄청난 혜택이 돌아옵니다.
- 순수한 비즈니스 로직 집중: 컨트롤러나 서비스단 코드가 매우 깔끔해집니다. 예외가 발생할 만한 지점에서
throw new BusinessException(ErrorCode.USER_NOT_FOUND);한 줄만 던지면 되므로 코드가 간결해집니다. - 보안성 향상: 개발자가 실수로 놓친 모든 예외가 최상위
Exception.class처리기에 걸러지므로, SQL 문법 에러나 서버 내부 경로 등 민감한 스택 트레이스 정보가 외부 해커에게 노출되는 것을 완벽하게 방지합니다. - 유연한 프론트엔드 협업: 에러 응답 객체의 데이터 포맷(
status,code,message)이 늘 동일하므로 프론트엔드 개발자가 에러 상황에 맞춰 공통 alert 창을 띄우거나 예외 처리를 분기하기 매우 편해집니다.
4. 결론 및 요약
스프링의 @RestControllerAdvice는 단순한 에러 처리를 넘어 자바 백엔드 시스템의 완성도와 안정성을 결정짓는 핵심 아키텍처입니다. 실무 프로젝트를 설계할 때는 무작정 모든 예외 상황에 자바 표준 예외를 던지기보다, 팀원 간 합의된 ErrorCode 명세서를 만들고 이를 @RestControllerAdvice로 일괄 제어하는 시스템을 초기 단계부터 구축해 놓아야 거대한 대규모 프로젝트에서도 흔들림 없는 안전한 코드를 유지할 수 있습니다.