본문 바로가기
Framework/Spring Boot

[Spring Boot] 전역 예외 처리(Global Exception Handling): 에러의 혼돈을 우아하게 제압하라

반응형

우리가 정성스럽게 만든 REST API 서버가 실제 세상에 배포되는 순간, 상상을 초월하는 사용자(Client)들을 만나게 됩니다. 숫자가 들어가야 할 나이 필드에 '스물다섯'이라는 문자를 던지거나, 존재하지 않는 user_id=999를 집요하게 요청하는 등 무수한 공격과 실수가 쏟아집니다. 이때 서버 내부에서 NullPointerException이나 IllegalArgumentException 같은 자바 에러가 터지면 어떻게 될까요? 스프링 부트(Spring Boot)는 아무 설정을 안 하면 시뻘건 스택 트레이스(Stack Trace) 로그와 함께 아주 끔찍하고 못생긴 "Whitelabel Error Page"나 알 수 없는 HTML 덩어리를 응답으로 뱉어냅니다. 만약 프론트엔드(React, Vue) 개발자가 JSON 응답을 기다리다가 저런 HTML 에러 페이지를 받게 되면 즉시 슬랙(Slack)으로 욕설(?)이 날아올 것입니다.

이번 포스팅에서는 "애플리케이션 어디서 에러가 터지든 하나의 블랙홀로 쏙 빨아들여, 프론트엔드가 사랑하는 예쁘고 규격화된 JSON 에러 메시지로 가공해 주는" @RestControllerAdvice 전역 예외 처리 아키텍처에 대하여 다뤄보겠습니다.

💣 1. try-catch 지옥의 도래

에러를 처리하라고 하면 초보 개발자들은 컨트롤러의 모든 메서드에 try-catch 문을 도배하기 시작합니다.

@GetMapping("/users/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
    try {
        User user = userService.findById(id);
        return ResponseEntity.ok(user);
    } catch (UserNotFoundException e) {
        return ResponseEntity.status(404).body("유저를 찾을 수 없어요.");
    } catch (IllegalArgumentException e) {
        return ResponseEntity.status(400).body("잘못된 값입니다.");
    } catch (Exception e) {
        return ResponseEntity.status(500).body("서버 펑!");
    }
}

컨트롤러 API가 100개라면 100곳에 똑같은 try-catch를 써야 합니다. 비즈니스 로직(1줄)보다 에러 잡는 코드(10줄)가 더 길어지는 심각한 배보다 배꼽이 더 큰 상황, 즉 중복 코드의 지옥이 펼쳐집니다.


🛡️ 2. 구원자: @RestControllerAdvice의 등장

스프링 프레임워크는 AOP(관점 지향 프로그래밍) 기술을 활용해 이 지옥을 끝내버렸습니다. "컨트롤러에서는 try-catch를 싹 다 지우고 마음 편히 에러를 던져라(throw)! 내가 밖에서 그물망을 치고 있다가 에러가 떨어지면 다 낚아채서 일괄 처리해 줄게!" 이것이 바로 @RestControllerAdvice입니다.

1단계: 프론트엔드와 약속할 공통 에러 규격(DTO) 만들기

@Getter
@AllArgsConstructor
public class ErrorResponse {
    private final String errorCode;    // 예: "USER_NOT_FOUND"
    private final String errorMessage; // 예: "해당 ID의 유저가 없습니다."
    private final int status;          // 예: 404
}

2단계: 전역 예외 처리기(GlobalExceptionHandler) 작성하기

@Slf4j
@RestControllerAdvice // 🌟 전 프로젝트의 모든 컨트롤러에서 발생하는 예외를 감지합니다.
public class GlobalExceptionHandler {

    // 1. 우리가 직접 만든 비즈니스 예외 처리 (예: 유저 없음)
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        log.warn("UserNotFoundException 발생 : {}", e.getMessage());
        ErrorResponse response = new ErrorResponse("USER_NOT_FOUND", e.getMessage(), 404);
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }

    // 2. 클라이언트가 파라미터를 잘못 보냈을 때 (예: 타입 불일치)
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
        log.warn("잘못된 요청 파라미터 : {}", e.getMessage());
        ErrorResponse response = new ErrorResponse("INVALID_INPUT", "요청 파라미터가 잘못되었습니다.", 400);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
    }

    // 3. 누군가 뚫고 들어온 예상치 못한 모든 최상위 에러 처리 (500 에러)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAllUncaughtException(Exception e) {
        log.error("🔴 예상치 못한 치명적 서버 에러 발생!", e); // 서버 에러는 로그를 빡세게(ERROR) 남겨야 함
        ErrorResponse response = new ErrorResponse("INTERNAL_SERVER_ERROR", "서버 내부에 알 수 없는 문제가 발생했습니다.", 500);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
}

✨ 3. 컨트롤러의 놀라운 변화 (클린 코드)

이제 위의 글로벌 핸들러를 켜두고 다시 컨트롤러 코드를 보겠습니다.

@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
    // try-catch가 싹 다 사라졌습니다! 오직 행복한 성공 경로(Happy Path) 코드만 남습니다.
    // 만약 서비스 로직 안에서 throw new UserNotFoundException() 이 터지면?
    // 여기서 안 잡고 바로 튕겨나가서 @RestControllerAdvice가 낚아챕니다.
    User user = userService.findById(id);
    return ResponseEntity.ok(user);
}

이 얼마나 아름답고 가독성이 뛰어난 코드입니까! 비즈니스 로직과 에러 처리 로직이 완벽하게 분리(관심사의 분리)된 실무 최적화 아키텍처입니다.


🎯 4. 마무리 및 다음 단계

지금까지 지저분한 try-catch 도배를 걷어내고, 서버 내의 모든 에러를 한 곳에서 통제하여 프론트엔드 개발자에게 규격화되고 친절한 JSON 응답을 내려주는 마법, @RestControllerAdvice를 활용한 예외 처리 고도화 전략에 대해 다루어 보았습니다. 이제 여러분의 서버는 어떤 외부 공격이나 에러에도 당황하지 않고 예쁜 JSON을 뱉어낼 것입니다.

웹 요청을 받고 응답을 내리는 껍데기 세팅은 이제 실무 수준으로 완벽해졌습니다. 이제 진짜 데이터베이스(DB)와 통신하며 알맹이를 채울 차례입니다. 과거 MyBatis 시절에는 자바 개발자인지 SQL 쿼리 작성 기계인지 헷갈릴 정도로 긴 쿼리를 짰습니다. 하지만 이제 시대가 바뀌었습니다. 이어지는 08단계 포스팅에서는 "자바 객체(Object)를 만들면 스프링이 알아서 데이터베이스 테이블과 매핑하고 SQL을 쏴준다!" 현대 백엔드 개발의 꽃, JPA(Hibernate)의 기본 개념과 영속성 컨텍스트(Persistence Context)의 원리에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!

반응형