[Spring] 26. @ExceptionHandler와 @ControllerAdvice

[Spring] 26. @ExceptionHandler와 @ControllerAdvice

이전 글에서는 검증 오류 메시지 처리 방법을 살펴봤습니다. @Valid로 검증을 실행하고, BindingResult로 오류 결과를 확인한 뒤 화면이나 API 응답으로 오류 메시지를 전달할 수 있었습니다.

이번 글에서는 Spring에서 예외를 처리하는 방법인 @ExceptionHandler@ControllerAdvice에 대해 정리하겠습니다. 프로젝트가 커지면 Controller마다 try-catch를 반복하는 구조가 생길 수 있습니다. 이런 반복을 줄이고, 오류 응답을 한 곳에서 관리하기 위해 공통 예외 처리가 필요합니다.

핵심은 간단합니다. @ExceptionHandler는 특정 예외가 발생했을 때 실행할 메서드를 지정하는 어노테이션이고, @ControllerAdvice는 여러 Controller에 공통으로 적용할 예외 처리 클래스를 만들 때 사용합니다.


1. 예외 처리가 필요한 이유

웹 애플리케이션에서는 여러 가지 오류 상황이 발생할 수 있습니다. 사용자가 존재하지 않는 게시글 번호로 접근할 수도 있고, 권한이 없는 사용자가 수정 요청을 보낼 수도 있고, 서버 처리 중 예상하지 못한 오류가 발생할 수도 있습니다.

존재하지 않는 게시글 조회
권한 없는 게시글 수정
잘못된 요청 값 전달
로그인하지 않은 사용자 접근
DB 처리 중 오류 발생

이런 오류를 Controller마다 직접 처리하면 코드가 금방 복잡해집니다.

@GetMapping("/board/{boardNo}")
public String detail(@PathVariable Long boardNo, Model model) {
    try {
        BoardDto board = boardService.findBoard(boardNo);
        model.addAttribute("board", board);
        return "board/detail";
    } catch (RuntimeException e) {
        model.addAttribute("message", "게시글을 찾을 수 없습니다.");
        return "error/404";
    }
}

처음에는 괜찮아 보이지만, 이런 코드가 여러 Controller에 반복되면 유지보수가 어려워집니다. 그래서 예외 처리 코드는 가능한 한 공통으로 분리하는 것이 좋습니다.


2. @ExceptionHandler란?

@ExceptionHandler는 Controller 안에서 특정 예외가 발생했을 때 실행할 메서드를 지정하는 어노테이션입니다.

@Controller
public class BoardController {

    @GetMapping("/board/{boardNo}")
    public String detail(@PathVariable Long boardNo, Model model) {
        BoardDto board = boardService.findBoard(boardNo);
        model.addAttribute("board", board);
        return "board/detail";
    }

    @ExceptionHandler(RuntimeException.class)
    public String handleRuntimeException(RuntimeException e, Model model) {
        model.addAttribute("message", e.getMessage());
        return "error/error";
    }
}

위 코드에서 BoardController 안에서 RuntimeException이 발생하면 handleRuntimeException() 메서드가 실행됩니다.

Controller 메서드 실행
 → 예외 발생
 → @ExceptionHandler 메서드 실행
 → 오류 화면 또는 오류 응답 반환

즉, @ExceptionHandler는 예외가 발생했을 때 대신 처리할 메서드를 연결해주는 역할을 합니다.


3. 직접 예외 클래스 만들기

실제 프로젝트에서는 모든 오류를 RuntimeException 하나로 처리하기보다, 의미 있는 예외 클래스를 만들어 사용하는 것이 좋습니다.

예를 들어 게시글을 찾을 수 없는 상황을 표현하는 예외를 만들 수 있습니다.

public class BoardNotFoundException extends RuntimeException {

    public BoardNotFoundException() {
        super("게시글을 찾을 수 없습니다.");
    }

    public BoardNotFoundException(String message) {
        super(message);
    }
}

Service에서는 게시글이 없을 때 이 예외를 던질 수 있습니다.

@Service
public class BoardService {

    private final BoardMapper boardMapper;

    public BoardService(BoardMapper boardMapper) {
        this.boardMapper = boardMapper;
    }

    public BoardDto findBoard(Long boardNo) {
        BoardDto board = boardMapper.findById(boardNo);

        if (board == null) {
            throw new BoardNotFoundException();
        }

        return board;
    }
}

이렇게 작성하면 게시글이 없는 상황을 코드에서 더 명확하게 표현할 수 있습니다.

board == null
 → BoardNotFoundException 발생
 → 예외 처리 메서드에서 처리

4. 특정 예외 처리하기

이제 BoardNotFoundException을 처리하는 @ExceptionHandler를 작성할 수 있습니다.

@Controller
public class BoardController {

    @GetMapping("/board/{boardNo}")
    public String detail(@PathVariable Long boardNo, Model model) {
        BoardDto board = boardService.findBoard(boardNo);
        model.addAttribute("board", board);
        return "board/detail";
    }

    @ExceptionHandler(BoardNotFoundException.class)
    public String handleBoardNotFound(
            BoardNotFoundException e,
            Model model
    ) {
        model.addAttribute("message", e.getMessage());
        return "error/404";
    }
}

이제 BoardController 안에서 BoardNotFoundException이 발생하면 error/404 화면으로 이동할 수 있습니다.

하지만 이 방식은 해당 Controller 안에서만 동작합니다. 여러 Controller에서 같은 예외를 공통으로 처리하려면 @ControllerAdvice를 사용하는 것이 좋습니다.


5. @ControllerAdvice란?

@ControllerAdvice는 여러 Controller에 공통으로 적용되는 기능을 모아두는 클래스에 사용합니다. 예외 처리, 공통 Model 데이터, 바인딩 설정 등을 공통으로 처리할 수 있습니다.

예외 처리에서는 @ControllerAdvice@ExceptionHandler를 함께 사용합니다.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BoardNotFoundException.class)
    public String handleBoardNotFound(
            BoardNotFoundException e,
            Model model
    ) {
        model.addAttribute("message", e.getMessage());
        return "error/404";
    }
}

이제 여러 Controller에서 BoardNotFoundException이 발생해도 GlobalExceptionHandler에서 공통으로 처리할 수 있습니다.

BoardController
MemberController
CommentController

→ 예외 발생
→ GlobalExceptionHandler에서 공통 처리

6. 왜 공통 예외 처리가 좋을까?

공통 예외 처리를 사용하면 Controller 코드가 훨씬 깔끔해집니다. Controller는 정상 요청 흐름에 집중하고, 오류 처리는 별도 클래스에서 담당할 수 있습니다.

Controller 요청을 받고 Service를 호출하고 정상 결과를 반환합니다.
Service 기능 흐름 중 문제가 있으면 의미 있는 예외를 던집니다.
ExceptionHandler 발생한 예외를 화면 또는 API 오류 응답으로 변환합니다.

즉, 역할을 나누는 구조입니다.

Controller
 → 정상 흐름

Service
 → 비즈니스 규칙 확인

ExceptionHandler
 → 오류 응답 처리

이렇게 나누면 Controller마다 반복되는 try-catch를 줄일 수 있습니다.


7. 화면 기반 예외 처리 예시

JSP나 Thymeleaf 화면을 반환하는 프로젝트에서는 예외가 발생했을 때 오류 화면을 반환할 수 있습니다.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BoardNotFoundException.class)
    public String handleBoardNotFound(
            BoardNotFoundException e,
            Model model
    ) {
        model.addAttribute("message", e.getMessage());
        return "error/404";
    }

    @ExceptionHandler(Exception.class)
    public String handleException(
            Exception e,
            Model model
    ) {
        model.addAttribute("message", "서버 처리 중 오류가 발생했습니다.");
        return "error/500";
    }
}

이 구조에서는 게시글이 없을 때는 404 화면을 보여주고, 예상하지 못한 오류는 500 화면으로 보낼 수 있습니다.

BoardNotFoundException
 → error/404

Exception
 → error/500

다만 실제 HTTP 상태 코드까지 정확히 지정하려면 @ResponseStatusResponseEntity를 함께 고려할 수 있습니다. API에서는 보통 ResponseEntity를 더 자주 사용합니다.


8. API 예외 처리 예시

REST API에서는 오류 화면이 아니라 JSON 오류 응답을 내려주는 것이 자연스럽습니다. 이때는 @RestControllerAdvice를 사용할 수 있습니다.

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(BoardNotFoundException.class)
    public ResponseEntity<ApiErrorResponse> handleBoardNotFound(
            BoardNotFoundException e
    ) {
        ApiErrorResponse response = new ApiErrorResponse(
                "BOARD_NOT_FOUND",
                e.getMessage()
        );

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

오류 응답 DTO는 다음처럼 만들 수 있습니다.

public class ApiErrorResponse {

    private String code;
    private String message;

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

    public String getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }
}

응답은 다음과 같은 JSON 형태가 될 수 있습니다.

{
  "code": "BOARD_NOT_FOUND",
  "message": "게시글을 찾을 수 없습니다."
}

API에서는 이렇게 오류 코드와 메시지를 함께 내려주면 프론트엔드에서 상황별 처리를 하기 쉽습니다.


9. @ControllerAdvice와 @RestControllerAdvice 차이

@ControllerAdvice@RestControllerAdvice는 비슷하지만 반환 방식에서 차이가 있습니다.

구분 @ControllerAdvice @RestControllerAdvice
주 사용 화면 기반 예외 처리 API 예외 처리
반환 방식 View 이름 반환 가능 응답 Body 데이터 반환
예시 return "error/404"; return ResponseEntity.status(...).body(...);

쉽게 기억하면 다음과 같습니다.

JSP / Thymeleaf 화면 오류 처리
 → @ControllerAdvice

REST API JSON 오류 처리
 → @RestControllerAdvice

10. 여러 예외를 나누어 처리하기

예외 종류에 따라 다른 상태 코드와 메시지를 반환할 수 있습니다.

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(BoardNotFoundException.class)
    public ResponseEntity<ApiErrorResponse> handleBoardNotFound(
            BoardNotFoundException e
    ) {
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(new ApiErrorResponse("BOARD_NOT_FOUND", e.getMessage()));
    }

    @ExceptionHandler(NoPermissionException.class)
    public ResponseEntity<ApiErrorResponse> handleNoPermission(
            NoPermissionException e
    ) {
        return ResponseEntity
                .status(HttpStatus.FORBIDDEN)
                .body(new ApiErrorResponse("NO_PERMISSION", e.getMessage()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiErrorResponse> handleException(
            Exception e
    ) {
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ApiErrorResponse("SERVER_ERROR", "서버 오류가 발생했습니다."));
    }
}

예외별로 응답 상태를 나누면 API 응답 의미가 더 명확해집니다.

예외 상태 코드 의미
BoardNotFoundException 404 Not Found 데이터 없음
NoPermissionException 403 Forbidden 권한 없음
Exception 500 Internal Server Error 서버 오류

11. @ResponseStatus 사용하기

간단한 예외는 예외 클래스에 @ResponseStatus를 붙여 상태 코드를 지정할 수도 있습니다.

@ResponseStatus(HttpStatus.NOT_FOUND)
public class BoardNotFoundException extends RuntimeException {

    public BoardNotFoundException() {
        super("게시글을 찾을 수 없습니다.");
    }
}

이렇게 하면 해당 예외가 발생했을 때 기본적으로 404 상태 코드를 응답할 수 있습니다.

하지만 실제 API에서는 오류 응답 Body를 일정하게 만들고 싶을 때가 많습니다. 그럴 때는 @RestControllerAdviceResponseEntity를 사용하는 방식이 더 유연합니다.

@ResponseStatus
 → 간단한 상태 코드 지정

@RestControllerAdvice + ResponseEntity
 → 상태 코드 + 오류 Body를 함께 제어

12. 검증 예외와 함께 보기

이전 글에서 @ValidBindingResult를 다뤘습니다. API에서는 BindingResult를 직접 쓰지 않고, 검증 실패 예외를 공통 예외 처리에서 잡는 방식도 사용합니다.

예를 들어 @Valid @RequestBody 요청에서 검증이 실패하면 Spring이 검증 관련 예외를 발생시킬 수 있습니다. 이를 @RestControllerAdvice에서 처리하면 검증 오류 응답도 공통 형식으로 만들 수 있습니다.

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<List<FieldErrorResponse>> handleValidation(
            MethodArgumentNotValidException e
    ) {
        List<FieldErrorResponse> errors = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> new FieldErrorResponse(
                        error.getField(),
                        error.getDefaultMessage()
                ))
                .toList();

        return ResponseEntity.badRequest().body(errors);
    }
}

이 방식은 API 검증 오류를 공통 JSON 형식으로 내려줄 때 유용합니다.

[
  {
    "field": "title",
    "message": "제목을 입력해주세요."
  },
  {
    "field": "content",
    "message": "내용은 10자 이상 입력해주세요."
  }
]

13. 자주 하는 실수

1) Controller마다 try-catch를 반복하는 경우

Controller마다 비슷한 try-catch를 반복하면 코드가 지저분해지고 오류 응답 형식이 달라질 수 있습니다.

// 반복되기 쉬운 구조
try {
    // 처리
} catch (Exception e) {
    // 오류 응답
}

반복되는 오류 처리는 @ControllerAdvice@RestControllerAdvice로 모으는 것이 좋습니다.

2) 모든 예외를 Exception 하나로만 처리하는 경우

모든 예외를 Exception.class 하나로만 처리하면 오류의 의미가 흐려집니다.

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleException(Exception e) {
    return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ApiErrorResponse("SERVER_ERROR", "서버 오류가 발생했습니다."));
}

이 핸들러는 마지막 안전망으로는 좋지만, 게시글 없음, 권한 없음, 입력값 오류 같은 예외는 따로 나누는 것이 좋습니다.

3) 내부 오류 메시지를 그대로 노출하는 경우

예상하지 못한 서버 오류를 처리할 때 내부 예외 메시지를 그대로 사용자에게 보여주는 것은 좋지 않습니다.

// 좋지 않은 예시
.body(new ApiErrorResponse("SERVER_ERROR", e.getMessage()))

서버 내부 정보가 노출될 수 있으므로, 사용자에게는 일반적인 메시지를 보여주는 것이 안전합니다.

.body(new ApiErrorResponse("SERVER_ERROR", "서버 오류가 발생했습니다."))

4) 화면 Controller와 API Controller의 오류 처리를 섞는 경우

화면 기반 Controller는 오류 화면을 반환하고, API Controller는 JSON 오류 응답을 반환하는 것이 자연스럽습니다. 둘을 한 클래스에서 무리하게 섞으면 응답 방식이 헷갈릴 수 있습니다.

화면 오류
 → error/404 View

API 오류
 → JSON 오류 응답

프로젝트 구조에 따라 화면용 예외 처리와 API용 예외 처리를 나누는 것이 좋습니다.

5) 예외를 너무 남발하는 경우

예외는 비정상적인 흐름을 표현할 때 사용합니다. 단순한 분기 처리까지 모두 예외로 만들면 코드 흐름이 오히려 복잡해질 수 있습니다.

예외를 사용할지, 조건문으로 처리할지는 상황에 따라 판단해야 합니다.


14. 전체 흐름 정리

공통 예외 처리 흐름을 정리하면 다음과 같습니다.

1. 사용자가 요청을 보낸다.
2. Controller가 Service를 호출한다.
3. Service에서 문제가 발생하면 의미 있는 예외를 던진다.
4. 예외가 Controller 밖으로 전달된다.
5. @ControllerAdvice 또는 @RestControllerAdvice가 예외를 잡는다.
6. @ExceptionHandler 메서드가 실행된다.
7. 화면 기반이면 오류 View를 반환한다.
8. API 기반이면 상태 코드와 JSON 오류 응답을 반환한다.

이 흐름을 이해하면 Controller에서 정상 로직과 오류 처리 로직을 분리할 수 있습니다.


정리

@ExceptionHandler는 특정 예외가 발생했을 때 실행할 메서드를 지정하는 어노테이션입니다. @ControllerAdvice는 여러 Controller에 공통으로 적용되는 예외 처리 클래스를 만들 때 사용합니다. API에서는 @RestControllerAdvice를 사용해 JSON 오류 응답을 공통으로 처리할 수 있습니다.

어노테이션 역할
@ExceptionHandler 특정 예외를 처리할 메서드를 지정합니다.
@ControllerAdvice 여러 Controller에 공통 기능을 적용합니다.
@RestControllerAdvice API Controller의 공통 오류 응답을 처리할 때 자주 사용합니다.
@ResponseStatus 예외에 기본 HTTP 상태 코드를 지정할 수 있습니다.

처음에는 다음 기준으로 기억하면 좋습니다.

Controller 내부 예외 처리
 → @ExceptionHandler

여러 Controller 공통 예외 처리
 → @ControllerAdvice

API JSON 오류 응답 공통 처리
 → @RestControllerAdvice

상태 코드와 Body를 직접 제어
 → ResponseEntity

연습해보기

아래 상황에 맞는 예외 처리 구조를 생각해보세요.

게시글이 없을 때
 → BoardNotFoundException
 → 404 Not Found

권한이 없을 때
 → NoPermissionException
 → 403 Forbidden

입력값 검증 실패
 → MethodArgumentNotValidException
 → 400 Bad Request

예상하지 못한 오류
 → Exception
 → 500 Internal Server Error

다음 글에서는 Spring MVC에서 파일 업로드를 처리하는 방법인 MultipartFile에 대해 정리하겠습니다. 게시판 첨부파일, 프로필 이미지, 리뷰 이미지 업로드 같은 기능을 만들 때 필요한 개념입니다.