1. 서론: 왜 예외 처리를 통일해야 하는가?
API를 개발하다 보면 다양한 예외 상황이 발생합니다. 데이터가 없는 경우, 입력값이 잘못된 경우, 서버 내부 로직 오류 등이 대표적입니다. 이때 각 컨트롤러에서 try-catch로 예외를 개별 처리하면 다음과 같은 문제가 발생합니다.
- 코드 중복: 비슷한 예외 처리 로직이 여러 컨트롤러에 반복됩니다.
- 응답 일관성 부족: 어떤 API는 JSON으로 에러를 주는데, 어떤 API는 HTML 에러 페이지를 주는 등 응답 형식이 제각각이 됩니다.
- 비즈니스 로직 집중도 저하: 예외 처리 코드가 섞여 있어 핵심 로직을 파악하기 힘듭니다.
Spring은 이를 우아하게 해결할 수 있도록 @ControllerAdvice와 @ExceptionHandler를 제공합니다.
2. 핵심 어노테이션 이해하기
2.1. @ExceptionHandler
특정 컨트롤러 내부에서 발생하는 특정 예외를 잡아 처리하는 메서드를 정의합니다. 해당 컨트롤러 안에서만 유효하다는 특징이 있습니다.
2.2. @RestControllerAdvice (@ControllerAdvice)
여러 컨트롤러에서 발생하는 예외를 한곳에서 전역적으로 처리할 수 있게 해주는 “공통 관심사” 클래스입니다. @RestControllerAdvice는 @ResponseBody가 포함되어 있어 JSON 형태로 에러를 응답하기에 적합합니다.
3. 실무적인 공통 예외 처리 구현
3.1. 에러 응답 규격 정의 (ErrorResponse)
모든 에러 응답은 동일한 구조를 가져야 클라이언트(프론트엔드)에서 처리하기 쉽습니다.
@Getter
@Builder
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;
}
3.2. 전역 예외 처리기 구현 (GlobalExceptionHandler)
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 모든 비즈니스 예외 처리
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
log.error("handleBusinessException", e);
ErrorCode errorCode = e.getErrorCode();
ErrorResponse response = ErrorResponse.builder()
.status(errorCode.getStatus())
.error(errorCode.name())
.code(errorCode.getCode())
.message(e.getMessage())
.build();
return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
}
// 그 외 예상치 못한 모든 예외 처리
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("handleException", e);
ErrorResponse response = ErrorResponse.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("INTERNAL_SERVER_ERROR")
.code("COMMON-001")
.message(e.getMessage())
.build();
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
4. 사용자 정의 예외(Custom Exception) 활용
단순히 RuntimeException을 던지기보다, 도메인의 의미를 담은 사용자 정의 예외를 만드는 것이 좋습니다.
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(String message, ErrorCode errorCode) {
super(message);
this.errorCode = errorCode;
}
}
// 예외 상황에 따른 코드 정의
@Getter
public enum ErrorCode {
USER_NOT_FOUND(404, "U001", "사용자를 찾을 수 없습니다."),
INVALID_INPUT_VALUE(400, "C001", "잘못된 입력값입니다.");
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;
}
}
5. 결론: 일관된 에러 응답의 가치
공통 예외 처리를 구축하면 개발자는 비즈니스 로직에서 throw new UserNotFoundException()처럼 예외를 던지기만 하면 됩니다. 나머지는 @RestControllerAdvice가 알아서 처리하여 일관된 JSON 응답을 클라이언트에 전달합니다.
이러한 구조는 코드의 가독성을 높일 뿐만 아니라, 프론트엔드와의 협업 효율성을 극대화하는 핵심적인 설계 패턴입니다. 지금 바로 프로젝트의 예외 처리 로직을 리팩토링해 보세요!