프레임워크/스프링

Spring 예외 처리 전략: @ControllerAdvice와 @ExceptionHandler 활용법

eodevelop 2026. 1. 4. 18:22
반응형

Spring으로 API를 개발하다 보면 예외 처리 코드가 점점 늘어납니다. 사용자가 없으면 UserNotFoundException, 권한이 없으면 AccessDeniedException, 입력값이 잘못되면 IllegalArgumentException... 이런 예외들을 컨트롤러마다 try-catch로 잡다 보면 코드가 지저분해지는 경험, 한 번쯤 있으실 겁니다. 오늘은 이 문제를 깔끔하게 해결하는 방법을 알아보겠습니다.


문제 상황: try-catch가 여기저기 널려있다

먼저 흔히 볼 수 있는 코드를 보겠습니다.

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    public ResponseEntity<?> getUser(@PathVariable Long id) {
        try {
            User user = userService.findById(id);
            return ResponseEntity.ok(user);
        } catch (UserNotFoundException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND)
                    .body(Map.of("error", "사용자를 찾을 수 없습니다"));
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(Map.of("error", "서버 오류가 발생했습니다"));
        }
    }

    @PostMapping
    public ResponseEntity<?> createUser(@RequestBody UserRequest request) {
        try {
            User user = userService.create(request);
            return ResponseEntity.ok(user);
        } catch (DuplicateEmailException e) {
            return ResponseEntity.status(HttpStatus.CONFLICT)
                    .body(Map.of("error", "이미 존재하는 이메일입니다"));
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(Map.of("error", "서버 오류가 발생했습니다"));
        }
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<?> deleteUser(@PathVariable Long id) {
        try {
            userService.delete(id);
            return ResponseEntity.ok().build();
        } catch (UserNotFoundException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND)
                    .body(Map.of("error", "사용자를 찾을 수 없습니다"));
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(Map.of("error", "서버 오류가 발생했습니다"));
        }
    }
}

 

메소드가 3개밖에 안 되는데도 벌써 try-catch가 도배되어 있습니다. 문제점을 정리해보면:

  • 똑같은 예외 처리 코드가 반복됩니다
  • 컨트롤러가 늘어날수록 복사-붙여넣기가 늘어납니다
  • 에러 응답 형식을 바꾸려면 모든 컨트롤러를 수정해야 합니다
  • 비즈니스 로직과 예외 처리 코드가 섞여서 가독성이 떨어집니다

마치 아파트 각 호수마다 경비원을 따로 두는 것과 같습니다. 비효율적이죠?


해결책: @ControllerAdvice로 예외 처리를 한 곳에 모으자

@ControllerAdvice는 모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리할 수 있게 해주는 어노테이션입니다. 아파트 입구에 경비실을 하나 두는 것과 같습니다.

[예외 발생] → [ControllerAdvice(경비실)] → [적절한 에러 응답]

기본 구조

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        ErrorResponse response = new ErrorResponse("USER_NOT_FOUND", "사용자를 찾을 수 없습니다");
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }
}

 

이게 기본 형태입니다. 하나씩 뜯어보겠습니다.


1단계: 에러 응답 형식 정의하기

먼저 API에서 일관되게 사용할 에러 응답 형식을 정의합니다.

public class ErrorResponse {
    private String code;
    private String message;
    private LocalDateTime timestamp;

    public ErrorResponse(String code, String message) {
        this.code = code;
        this.message = message;
        this.timestamp = LocalDateTime.now();
    }

    // Getter 생략
}

 

이렇게 하면 모든 에러 응답이 같은 형식을 갖게 됩니다:

{
    "code": "USER_NOT_FOUND",
    "message": "사용자를 찾을 수 없습니다",
    "timestamp": "2024-01-15T10:30:00"
}

 

클라이언트(프론트엔드) 개발자 입장에서는 에러 응답이 일관되어 있으면 처리하기 훨씬 편합니다.


2단계: 커스텀 예외 클래스 만들기

비즈니스 로직에서 사용할 예외 클래스를 만듭니다.

// 최상위 비즈니스 예외
public class BusinessException extends RuntimeException {
    private final String code;

    public BusinessException(String code, String message) {
        super(message);
        this.code = code;
    }

    public String getCode() {
        return code;
    }
}

// 사용자를 찾을 수 없을 때
public class UserNotFoundException extends BusinessException {
    public UserNotFoundException(Long userId) {
        super("USER_NOT_FOUND", "사용자를 찾을 수 없습니다. ID: " + userId);
    }
}

// 이메일이 중복될 때
public class DuplicateEmailException extends BusinessException {
    public DuplicateEmailException(String email) {
        super("DUPLICATE_EMAIL", "이미 사용 중인 이메일입니다: " + email);
    }
}

// 권한이 없을 때
public class AccessDeniedException extends BusinessException {
    public AccessDeniedException() {
        super("ACCESS_DENIED", "접근 권한이 없습니다");
    }
}

 

 왜 RuntimeException을 상속할까요? RuntimeException언체크 예외(Unchecked Exception)라서 메소드 시그니처에 throws를 붙이지 않아도 됩니다. 코드가 훨씬 깔끔해집니다.


3단계: @RestControllerAdvice로 전역 예외 처리기 만들기

이제 핵심인 전역 예외 처리기를 만들어 보겠습니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 1. 비즈니스 예외 처리
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        ErrorResponse response = new ErrorResponse(e.getCode(), e.getMessage());

        // 예외 종류에 따라 HTTP 상태 코드 결정
        HttpStatus status = determineHttpStatus(e);

        return ResponseEntity.status(status).body(response);
    }

    // 2. 입력값 검증 실패 (@Valid)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException e) {

        String message = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .collect(Collectors.joining(", "));

        ErrorResponse response = new ErrorResponse("VALIDATION_ERROR", message);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
    }

    // 3. 그 외 모든 예외 (최후의 방어선)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        // 로그는 남기되, 클라이언트에게는 자세한 정보를 노출하지 않음
        log.error("예상치 못한 오류 발생", e);

        ErrorResponse response = new ErrorResponse(
                "INTERNAL_ERROR",
                "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요."
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }

    private HttpStatus determineHttpStatus(BusinessException e) {
        if (e instanceof UserNotFoundException) {
            return HttpStatus.NOT_FOUND;
        }
        if (e instanceof DuplicateEmailException) {
            return HttpStatus.CONFLICT;
        }
        if (e instanceof AccessDeniedException) {
            return HttpStatus.FORBIDDEN;
        }
        return HttpStatus.BAD_REQUEST;
    }
}

 

@ControllerAdvice@RestControllerAdvice의 차이가 궁금하실 수 있습니다. @RestControllerAdvice@ControllerAdvice + @ResponseBody입니다. REST API를 만들 때는 @RestControllerAdvice를 쓰면 됩니다.


4단계: 컨트롤러가 깔끔해진다

이제 컨트롤러를 다시 작성해 보겠습니다.

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(user);
    }

    @PostMapping
    public ResponseEntity<User> createUser(@Valid @RequestBody UserRequest request) {
        User user = userService.create(request);
        return ResponseEntity.ok(user);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.ok().build();
    }
}

 

try-catch가 전부 사라졌습니다. 컨트롤러는 "무엇을 할 것인가"에만 집중하고, "예외가 발생하면 어떻게 할 것인가"GlobalExceptionHandler가 담당합니다.


5단계: 서비스 레이어에서 예외 던지기

서비스 레이어에서는 적절한 상황에 예외를 던지면 됩니다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    public User findById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));
    }

    public User create(UserRequest request) {
        // 이메일 중복 체크
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new DuplicateEmailException(request.getEmail());
        }

        User user = new User(request.getName(), request.getEmail());
        return userRepository.save(user);
    }

    public void delete(Long id) {
        User user = findById(id);  // 없으면 UserNotFoundException 발생
        userRepository.delete(user);
    }
}

 

서비스 레이어는 비즈니스 로직에만 집중합니다. 예외가 발생하면 그냥 던지고, 처리는 GlobalExceptionHandler에게 맡깁니다.


더 나아가기: HTTP 상태 코드를 예외 클래스에 담기

위에서 determineHttpStatus() 메소드로 예외 종류에 따라 HTTP 상태 코드를 결정했는데, 예외가 많아지면 이것도 복잡해집니다. 예외 클래스 자체에 HTTP 상태 코드를 담으면 더 깔끔해집니다.

public class BusinessException extends RuntimeException {
    private final String code;
    private final HttpStatus httpStatus;

    public BusinessException(String code, String message, HttpStatus httpStatus) {
        super(message);
        this.code = code;
        this.httpStatus = httpStatus;
    }

    public String getCode() {
        return code;
    }

    public HttpStatus getHttpStatus() {
        return httpStatus;
    }
}

public class UserNotFoundException extends BusinessException {
    public UserNotFoundException(Long userId) {
        super("USER_NOT_FOUND",
              "사용자를 찾을 수 없습니다. ID: " + userId,
              HttpStatus.NOT_FOUND);
    }
}

public class DuplicateEmailException extends BusinessException {
    public DuplicateEmailException(String email) {
        super("DUPLICATE_EMAIL",
              "이미 사용 중인 이메일입니다: " + email,
              HttpStatus.CONFLICT);
    }
}

 

이제 예외 처리기가 더 단순해집니다:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        ErrorResponse response = new ErrorResponse(e.getCode(), e.getMessage());
        return ResponseEntity.status(e.getHttpStatus()).body(response);
    }
}

 

새로운 예외를 추가할 때마다 GlobalExceptionHandler를 수정할 필요가 없어집니다. 예외 클래스만 만들면 됩니다.


@ControllerAdvice의 범위 제한하기

모든 컨트롤러가 아니라 특정 컨트롤러에만 적용하고 싶을 수도 있습니다.

// 특정 패키지의 컨트롤러에만 적용
@RestControllerAdvice(basePackages = "com.example.api")
public class ApiExceptionHandler { }

// 특정 컨트롤러에만 적용
@RestControllerAdvice(assignableTypes = {UserController.class, OrderController.class})
public class SpecificExceptionHandler { }

// 특정 어노테이션이 붙은 컨트롤러에만 적용
@RestControllerAdvice(annotations = RestController.class)
public class RestApiExceptionHandler { }

예외 처리 우선순위

여러 @ExceptionHandler가 있을 때, Spring은 가장 구체적인 예외를 처리하는 핸들러를 선택합니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 더 구체적인 예외 → 먼저 매칭
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        // ...
    }

    // 덜 구체적인 예외 → 위에서 매칭 안 되면 여기로
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        // ...
    }

    // 가장 일반적인 예외 → 최후의 방어선
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        // ...
    }
}

 

UserNotFoundException이 발생하면 handleUserNotFound()가 처리합니다. UserNotFoundExceptionBusinessException의 하위 클래스이지만, 더 구체적인 핸들러가 우선입니다.


주의할 점

1. 예외 정보를 너무 많이 노출하지 말 것

// 나쁜 예 - 스택 트레이스를 그대로 노출
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
    ErrorResponse response = new ErrorResponse(
            "ERROR",
            e.getMessage() + "\n" + Arrays.toString(e.getStackTrace())  // 위험!
    );
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}

 

클라이언트에게 스택 트레이스나 내부 구현 정보를 노출하면 보안에 취약해집니다. 로그에는 자세히 남기되, 응답에는 일반적인 메시지만 보내세요.

2. @ExceptionHandler와 try-catch를 섞어 쓰지 말 것

// 일관성 없는 코드
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
    try {
        User user = userService.findById(id);
        return ResponseEntity.ok(user);
    } catch (UserNotFoundException e) {
        // 여기서 처리하면 GlobalExceptionHandler를 안 탐
        return ResponseEntity.notFound().build();
    }
}

 

일부는 try-catch로, 일부는 @ControllerAdvice로 처리하면 코드가 혼란스러워집니다. 한 가지 방식으로 통일하세요.

3. 비즈니스 로직에서 예외를 삼키지 말 것

// 나쁜 예 - 예외를 삼킴
public User findById(Long id) {
    try {
        return userRepository.findById(id).orElseThrow();
    } catch (Exception e) {
        return null;  // 예외를 삼키고 null 반환
    }
}

 

예외를 삼키면 문제가 어디서 발생했는지 추적하기 어렵습니다. 예외는 적절히 던지고, 상위에서 처리하세요.


정리

  • @ControllerAdvice는 모든 컨트롤러의 예외를 한 곳에서 처리할 수 있게 해준다
  • @ExceptionHandler로 특정 예외 타입에 대한 처리 로직을 정의한다
  • 컨트롤러는 비즈니스 로직에만 집중하고, 예외 처리는 전역 핸들러에 위임한다
  • 에러 응답 형식을 통일하면 클라이언트가 처리하기 편하다
  • 커스텀 예외 클래스에 에러 코드와 HTTP 상태를 담으면 확장성이 좋아진다
  • 예외는 삼키지 말고 적절히 던지되, 클라이언트에게는 필요한 정보만 노출한다

try-catch를 컨트롤러마다 반복해서 쓰고 있었다면, @ControllerAdvice를 도입해보세요. 코드가 훨씬 깔끔해지고, 유지보수도 쉬워집니다.

반응형