[아이티센 부트캠프] Spring Boot 5

이언덕·2026년 4월 27일

아이티센 부트캠프

목록 보기
74/115
post-thumbnail

오류 처리

이 단원은 Controller에서 예외가 발생했을 때 서버 오류 화면으로 끝내지 않고, 정해진 처리 흐름으로 보내는 방법을 이해하는 구간이다.
오류 처리는 예외를 없애는 기능이 아니다.
예외가 발생했을 때 사용자에게 어떤 화면이나 응답을 보여 줄지 정리하는 기능이다.


웹 애플리케이션은 사용자의 요청을 받아 처리한다.
하지만 사용자가 항상 올바른 값만 보내지는 않는다.
숫자가 들어와야 하는 자리에 문자가 들어올 수도 있고, 존재하지 않는 데이터를 요청할 수도 있다.


이런 문제가 생기면 Controller 메서드는 정상적으로 화면을 반환하지 못한다.
이때 아무 처리도 하지 않으면 사용자는 복잡한 서버 오류 화면을 보게 된다.
그래서 Spring MVC는 예외가 발생했을 때 예외를 처리할 메서드로 흐름을 넘길 수 있는 기능을 제공한다.


오류 처리의 핵심은 단순하다.
정상 흐름에서 문제가 생기면, 예외 처리 흐름으로 바꿔서 정해진 화면이나 응답을 반환하는 것이다.



오류 처리가 필요한 이유

웹 요청은 항상 정상적인 값으로 들어오지 않는다.
예를 들어 회원 번호는 숫자여야 한다.
그런데 사용자가 주소창에 /member/detail/abc처럼 문자를 넣을 수 있다.
이 경우 abc를 숫자로 바꾸는 과정에서 예외가 발생할 수 있다.


또 다른 경우도 있다.
사용자가 /member/detail/10처럼 형식에 맞는 숫자를 보냈다고 해도, 실제로 10번 회원이 없을 수 있다.
이 경우 요청값의 형식은 맞지만, 처리할 데이터가 없어서 정상 화면을 만들 수 없다.


이런 상황을 아무 처리 없이 두면 예외가 그대로 밖으로 퍼진다.
그러면 사용자는 개발자용 오류 메시지나 서버 오류 화면을 볼 수 있다.
이 화면은 사용자가 이해하기 어렵고, 서비스 입장에서도 좋지 않다.


사용자에게는 복잡한 오류 내용보다 “잘못된 요청입니다”, “해당 회원이 없습니다”처럼 이해할 수 있는 화면을 보여 주는 편이 더 자연스럽다.
그래서 오류 처리는 사용자에게 보여 줄 실패 화면을 정리하는 역할을 한다.


오류 처리는 프로그램이 실패하지 않게 만드는 기능이 아니라, 실패했을 때 어떻게 응답할지 정하는 기능이다.
예외가 생기지 않도록 미리 검사하는 것과, 이미 발생한 예외를 처리하는 것은 다르다.
입력값 검사는 예외를 줄이는 작업이고, 오류 처리는 발생한 예외를 정리하는 작업이다.


이 차이를 이해하면 @ExceptionHandler와 @ControllerAdvice도 훨씬 쉽게 읽힌다.
이 둘은 예외가 발생한 뒤, 그 예외를 어떤 메서드가 처리할지 정하는 도구이다.



예외가 발생하면 요청 흐름이 어떻게 바뀌는가

일반적인 Spring MVC 요청 흐름은 사용자의 요청을 받아 알맞은 컨트롤러 메서드를 실행하고, 그 메서드가 반환한 값으로 화면을 결정하는 방식이다.
사용자가 특정 URL을 요청하면 DispatcherServlet이 먼저 요청을 받는다.
DispatcherServlet은 Spring MVC에서 들어온 요청을 가장 앞에서 받아서 알맞은 Controller로 보내는 중심 역할을 한다.


이 구간에서 중요한 점은 정상 흐름과 예외 흐름을 구분해서 보는 것이다.
정상 흐름에서는 컨트롤러 메서드가 끝까지 실행되고 return으로 화면이 결정된다.
하지만 예외가 발생하면 원래 실행되던 흐름이 멈추고, 예외를 처리하는 흐름으로 바뀐다.


정상 요청 흐름

정상 상황에서는 흐름이 비교적 단순하다.
사용자가 /member/detail/10 같은 주소를 요청하면 DispatcherServlet이 이 요청을 처리할 수 있는 Controller 메서드를 찾는다.
그리고 메서드를 실행하기 위해 필요한 값을 준비한다.


예를 들어 컨트롤러 메서드에 int id 같은 매개변수가 있다면, Spring MVC는 요청 주소나 요청 파라미터에서 값을 꺼내 id에 넣을 수 있는 형태로 바꾼다.
이 준비가 끝나야 컨트롤러 메서드의 본문이 실행된다.
여기서 본문은 메서드의 { } 안에 작성한 실제 처리 코드를 의미한다.


컨트롤러 메서드가 끝까지 실행되고 return "memberDetail"처럼 뷰 이름을 반환하면, Spring MVC는 그 이름에 맞는 화면 파일을 찾아 사용자에게 응답한다.
여기서 return은 단순히 문자열을 돌려주는 것이 아니라, 어떤 화면을 보여 줄지 알려 주는 역할을 한다.


정상 흐름을 단계로 보면 이렇다.

  • 사용자가 특정 URL을 요청한다.
  • DispatcherServlet이 요청을 받는다.
  • 요청 주소와 연결된 Controller 메서드를 찾는다.
  • 컨트롤러 메서드 실행에 필요한 매개변수 값을 준비한다.
  • 매개변수 준비가 끝나면 Controller 메서드 본문이 실행된다.
  • 메서드가 String, ModelAndView 같은 값을 반환한다.
  • 반환값을 기준으로 보여 줄 View를 찾는다.
  • 최종 화면이 사용자에게 응답된다.

즉, 정상 흐름의 핵심은 매개변수 준비가 성공하고, 컨트롤러 메서드가 끝까지 실행되고, 정상적으로 return을 한다는 점이다.
정상 요청은 요청 → 매개변수 준비 → 컨트롤러 실행 → 뷰 반환 → 화면 응답 순서로 진행된다.


예외가 발생했을 때의 흐름

예외가 발생하면 정상 흐름이 그대로 진행되지 않는다.
예외는 정상적인 실행 흐름을 끊는 신호이다.
컨트롤러 메서드 안에서 예외가 발생하면, 그 아래에 있던 코드는 더 이상 실행되지 않는다.
메서드가 원래 반환하려던 return도 실행되지 못한다.


아래 예제처럼 요청 처리 중간에 예외가 발생한다고 생각하면 된다.

// ErrorFlowExample.java
@GetMapping("/error-flow")
public String errorFlow() {
    // 요청 처리 중 문제가 발생한다
    throw new IllegalArgumentException("잘못된 요청입니다.");

    // 위에서 예외가 발생했으므로 이 코드는 실행되지 않는다
    // return "normalView";
}

throw는 예외를 직접 발생시키는 문법이다.
이 코드에서는 IllegalArgumentException이 발생하는 순간 메서드의 정상 흐름이 끊긴다.
그래서 return "normalView"로 정상 화면을 반환하는 흐름까지 가지 못한다.


예외가 발생하면 원래 컨트롤러 메서드의 정상 return은 실행되지 않고, Spring MVC가 예외를 처리할 수 있는 다른 흐름을 찾는다.
이때 찾는 대상이 바로 @ExceptionHandler가 붙은 예외 처리 메서드이다.


여기서 중요한 점은 예외 처리 메서드를 개발자가 직접 호출하지 않는다는 것이다.
개발자가 handleException() 같은 메서드를 코드에서 직접 실행하는 구조가 아니다.
컨트롤러 실행 전이나 실행 중에 예외가 발생하면 Spring MVC가 현재 발생한 예외의 종류를 확인하고, 그 예외를 처리할 수 있는 메서드가 있는지 자동으로 찾는다.


예외 처리 흐름을 단계로 보기

예외 처리 흐름을 단계로 보면 이렇다.

  • 사용자가 특정 URL을 요청한다.
  • DispatcherServlet이 요청을 받는다.
  • 요청 주소와 연결된 Controller 메서드를 찾는다.
  • 컨트롤러 메서드 실행에 필요한 매개변수 값을 준비한다.
  • 요청값을 매개변수 타입으로 바꾸는 과정에서 예외가 발생할 수 있다.
  • 매개변수 준비가 성공하면 Controller 메서드 본문이 실행된다.
  • 메서드 본문 실행 중에도 예외가 발생할 수 있다.
  • 예외가 발생하면 원래 컨트롤러 메서드의 정상 return 흐름이 멈춘다.
  • Spring MVC가 해당 예외를 처리할 수 있는 @ExceptionHandler 메서드를 찾는다.
  • 처리 가능한 메서드가 있으면 그 메서드가 대신 실행된다.
  • 예외 처리 메서드가 반환한 화면 이름이나 응답값을 기준으로 사용자에게 오류 화면을 보여 준다.

이 흐름에서 핵심은 예외가 발생한 뒤에 새로운 요청이 다시 들어오는 것이 아니라, 기존 요청 흐름 안에서 예외 처리 흐름으로 방향이 바뀐다는 점이다.
사용자는 한 번 요청했지만, 서버 내부에서는 정상 처리 흐름이 실패하고 예외 처리 흐름으로 넘어간다.


정상 흐름과 예외 흐름을 비교하면 차이가 더 분명하다.

  • 정상 흐름: 요청 → 매개변수 준비 성공 → 컨트롤러 실행 → 정상 return → 화면 응답
  • 예외 흐름: 요청 → 매개변수 준비 실패 또는 실행 중 예외 발생 → 예외 처리 메서드 실행 → 오류 화면 응답

따라서 오류 처리를 이해할 때는 “예외가 발생하면 기존 메서드가 계속 실행되는가?”를 먼저 봐야 한다.
정답은 아니다.
예외가 발생하면 기존 메서드의 정상 흐름은 멈추고, 예외 처리 흐름으로 넘어간다.


예외가 발생하는 위치

예외는 크게 두 위치에서 발생할 수 있다.
첫 번째는 컨트롤러 메서드 본문이 실행되기 전이다.
두 번째는 컨트롤러 메서드 본문이 실행되는 중이다.
이 차이를 알아야 뒤에서 나오는 num 예제가 이해된다.


컨트롤러 메서드 본문이 실행되기 전에도 예외가 발생할 수 있다.
예를 들어 컨트롤러 메서드가 int num을 받는다고 생각해 보자.
int는 숫자만 담을 수 있는 타입이다.
그런데 사용자가 /exceptionTest?num=abc처럼 문자를 보내면 Spring MVC는 abc를 숫자로 바꾸려고 시도한다.


이 변환은 메서드 본문이 실행되기 전에 일어난다.
왜냐하면 detail(int num, Model model) 메서드를 실행하려면 먼저 num 자리에 넣을 값을 준비해야 하기 때문이다.
그런데 abc는 int로 바꿀 수 없으므로, 메서드 안쪽 코드가 실행되기 전에 예외가 발생한다.

// TypeMismatchFlowExample.java
@GetMapping("/exceptionTest")
public String detail(int num, Model model) {
    // num 값이 int로 변환된 뒤에야 이 코드가 실행될 수 있다
    model.addAttribute("num", num);
    return "resultView";
}

/exceptionTest?num=10처럼 숫자를 보내면 num에 10이 들어가고 메서드 본문이 실행된다.
하지만 /exceptionTest?num=abc처럼 문자를 보내면 abc를 int로 바꾸는 과정에서 실패한다.
그러면 detail() 메서드 안쪽 코드는 실행되지 않고, 바로 예외 처리 흐름으로 넘어간다.


두 번째는 컨트롤러 메서드 본문이 실행되는 중에 예외가 발생하는 경우이다.
이 경우에는 요청값 변환까지는 성공했지만, 메서드 내부 로직을 처리하다가 문제가 생긴 것이다.
예를 들어 num=1은 숫자이므로 int num에 들어갈 수 있다.
하지만 그 번호에 해당하는 데이터가 없으면 컨트롤러 내부에서 직접 예외를 발생시킬 수 있다.

// NotFoundFlowExample.java
@GetMapping("/exceptionTest")
public String detail(int num, Model model) throws FriendNotFoundException {
    FriendDTO friend = friendService.get(num); // num으로 친구 정보를 조회한다

    if (friend == null) {
        // 조회 결과가 없으면 직접 만든 예외를 발생시킨다
        throw new FriendNotFoundException();
    }

    model.addAttribute("friend", friend); // 친구 정보를 화면으로 넘긴다
    return "friendView"; // 정상 화면 이름을 반환한다
}

이 흐름에서는 num을 숫자로 받는 데는 성공했다.
그래서 메서드 본문은 실행된다.
하지만 friendService.get(num) 결과가 null이면 정상 화면을 만들 수 없다.
이때 FriendNotFoundException을 발생시키면, 그 순간부터 정상 화면인 friendView로 가는 흐름은 멈춘다.
그리고 이 예외를 처리할 수 있는 @ExceptionHandler를 찾는다.


정리하면 예외 발생 위치에 따라 흐름은 이렇게 나뉜다.

  • 요청값을 컨트롤러 매개변수로 바꾸는 과정에서 실패하면, 컨트롤러 메서드 본문이 실행되기 전에 예외 처리 흐름으로 넘어간다.
  • 컨트롤러 메서드 본문이 실행된 뒤 로직 처리 중 문제가 생기면, 실행 중이던 메서드가 멈추고 예외 처리 흐름으로 넘어간다.
  • 두 경우 모두 최종적으로는 해당 예외를 처리할 수 있는 @ExceptionHandler 메서드를 찾는다.

그래서 오류 처리를 볼 때는 “예외가 어디서 발생했는가”를 먼저 봐야 한다.
요청값 변환 단계에서 발생한 예외인지, 컨트롤러 내부 로직에서 직접 발생시킨 예외인지에 따라 원인 설명이 달라진다.


예외 처리 메서드가 응답을 만드는 방식

예외 처리 메서드가 발견되면 그 메서드가 대신 응답을 만든다.
예외 처리 메서드도 일반 컨트롤러 메서드처럼 String으로 화면 이름을 반환할 수 있다.
또는 ModelAndView를 사용해서 화면 이름과 오류 메시지를 함께 반환할 수도 있다.


예를 들어 @ExceptionHandler 메서드가 "errorPage"를 반환하면 정상 화면 대신 errorPage.html이 열린다.
ModelAndView에 msg 값을 담아 반환하면 오류 화면에서 그 메시지를 출력할 수 있다.
즉, 예외가 발생해도 사용자는 서버의 복잡한 오류 화면이 아니라 개발자가 준비한 오류 화면을 보게 된다.


예외 처리 흐름은 실패를 없애는 흐름이 아니라, 실패했을 때 사용자에게 어떤 응답을 보여 줄지 바꾸는 흐름이다.
정상 요청은 정상 화면으로 가고, 문제가 생긴 요청은 예외 처리 메서드를 거쳐 오류 화면으로 간다.
이 차이를 이해하면 @ExceptionHandler와 @ControllerAdvice가 왜 필요한지 자연스럽게 이어진다.


핵심 정리

이 구간의 핵심을 다시 정리하면 이렇다.

  • 정상 흐름에서는 매개변수 준비가 성공한 뒤 컨트롤러 메서드가 끝까지 실행되고, return으로 화면을 결정한다.
  • 예외가 발생하면 원래 메서드의 정상 return 흐름은 멈춘다.
  • Spring MVC는 발생한 예외를 처리할 수 있는 @ExceptionHandler 메서드를 찾는다.
  • 예외 처리 메서드는 개발자가 직접 호출하지 않고, 예외가 발생했을 때 Spring MVC가 자동으로 실행한다.
  • 예외는 요청값 변환 단계에서 발생할 수도 있고, 컨트롤러 내부 로직에서 발생할 수도 있다.
  • 예외 처리 메서드가 반환한 값이 최종 오류 화면이나 오류 응답이 된다.

따라서 예외가 발생했을 때의 흐름은 요청 → 컨트롤러 실행 → 정상 화면 반환이 아니다.
예외가 발생한 순간부터 요청 → 매개변수 준비 실패 또는 실행 중 예외 발생 → 예외 처리 메서드 실행 → 오류 화면 반환 흐름으로 바뀐다.



@ExceptionHandler로 컨트롤러 안에서 예외 처리하기

@ExceptionHandler는 특정 Controller 안에서 발생한 예외를 처리하는 메서드에 붙이는 어노테이션이다.
쉽게 말하면, 같은 컨트롤러 안에서 문제가 생겼을 때 대신 실행될 예외 처리 메서드를 지정하는 표시이다.


앞에서 예외가 발생하면 원래 컨트롤러 메서드의 정상 return 흐름이 멈춘다고 정리했다.
그다음 Spring MVC는 발생한 예외를 처리할 수 있는 메서드를 찾는다.
이때 같은 컨트롤러 안에 @ExceptionHandler가 붙은 메서드가 있으면, 그 메서드가 예외를 대신 처리한다.


@ExceptionHandler의 역할

@ExceptionHandler는 일반 요청 주소를 처리하는 어노테이션이 아니다.
@GetMapping, @PostMapping, @RequestMapping처럼 사용자가 직접 요청한 URL과 연결되는 용도가 아니다.


@ExceptionHandler는 예외가 발생했을 때 실행될 메서드를 지정한다.
즉, 사용자가 직접 /handle-error 같은 주소로 호출하는 메서드가 아니라, 컨트롤러 처리 중 예외가 발생했을 때 Spring MVC가 자동으로 호출하는 메서드이다.


예를 들어 아래처럼 작성할 수 있다.

// ExceptionHandlerBasicExample.java
@ExceptionHandler(IllegalArgumentException.class)
public String handleIllegalArgumentException() {
    // IllegalArgumentException이 발생했을 때 실행된다
    return "errorView"; // 오류 화면 이름을 반환한다
}

@ExceptionHandler(IllegalArgumentException.class)는 IllegalArgumentException이 발생하면 이 메서드가 처리하겠다는 뜻이다.
IllegalArgumentException은 잘못된 인자값이 들어왔을 때 사용할 수 있는 예외 클래스이다.


여기서 중요한 점은 괄호 안의 예외 클래스이다.
@ExceptionHandler는 괄호 안에 적은 예외 종류와 실제 발생한 예외 종류를 비교한다.
그리고 처리할 수 있는 예외라고 판단되면 해당 메서드를 실행한다.


@ExceptionHandler의 핵심은 예외가 발생했을 때 정상 화면 대신 실행될 예외 처리 메서드를 연결하는 것이다.
그래서 예외 처리 메서드는 요청을 처음부터 처리하는 메서드가 아니라, 실패한 요청 흐름을 이어받아 오류 응답을 만드는 메서드라고 이해하면 된다.


같은 컨트롤러 안에서 처리된다는 의미

@ExceptionHandler를 Controller 클래스 안에 작성하면 기본적으로 그 컨트롤러 안에서 발생한 예외를 처리한다.
이것을 지역 예외 처리라고 생각하면 쉽다.
지역 예외 처리는 여러 컨트롤러 전체가 아니라, 해당 컨트롤러 내부에 한정해서 예외를 처리하는 방식이다.


예를 들어 MemberController 안에 @ExceptionHandler가 있다면, MemberController 안에서 발생한 예외를 처리하는 흐름으로 보면 된다.
반대로 다른 컨트롤러에서 발생한 예외까지 공통으로 처리하고 싶다면 뒤에서 보는 @ControllerAdvice를 사용한다.


흐름을 단계로 보면 이렇다.

  • 사용자가 특정 URL을 요청한다.
  • 요청과 연결된 Controller 메서드가 실행된다.
  • 메서드 실행 전이나 실행 중 예외가 발생한다.
  • Spring MVC가 같은 컨트롤러 안에서 해당 예외를 처리할 @ExceptionHandler 메서드를 찾는다.
  • 처리 가능한 메서드가 있으면 그 메서드가 실행된다.
  • 예외 처리 메서드가 반환한 값으로 오류 화면이나 오류 응답이 만들어진다.

즉, @ExceptionHandler는 같은 컨트롤러 안에서 발생한 문제를 그 컨트롤러 내부에서 정리하는 방식이다.
이 방식은 특정 컨트롤러에서만 다르게 처리해야 하는 예외가 있을 때 사용하기 좋다.


예외 클래스와 메서드가 연결되는 방식

@ExceptionHandler는 예외 클래스와 예외 처리 메서드를 연결한다.
예외 클래스는 어떤 종류의 예외인지 나타내는 클래스이다.
예를 들어 IllegalArgumentException, RuntimeException, MethodArgumentTypeMismatchException 같은 것들이 예외 클래스이다.


아래 코드는 IllegalArgumentException을 처리하는 예외 처리 메서드이다.

// IllegalArgumentExceptionHandlerExample.java
@ExceptionHandler(IllegalArgumentException.class)
public String handleIllegalArgumentException() {
    // IllegalArgumentException이 발생하면 이 메서드가 실행된다
    return "errorView"; // errorView.html 화면으로 이동한다
}

이 코드는 IllegalArgumentException이 발생했을 때만 실행되는 처리 메서드이다.
예외가 발생하지 않으면 실행되지 않는다.
또한 다른 종류의 예외가 발생했는데 이 메서드가 처리 대상이 아니라면 실행되지 않는다.


예외 처리 메서드는 발생한 예외 객체를 매개변수로 받을 수도 있다.
매개변수는 메서드가 실행될 때 외부에서 전달받는 값이다.
여기서는 발생한 예외 객체가 매개변수로 들어온다.

// ExceptionMessageHandlerExample.java
@ExceptionHandler(IllegalArgumentException.class)
public String handleIllegalArgumentException(IllegalArgumentException ex, Model model) {
    // 발생한 예외 객체를 ex로 받는다
    model.addAttribute("message", ex.getMessage()); // 예외 메시지를 화면으로 넘긴다
    return "errorView"; // 오류 화면 이름을 반환한다
}

ex에는 실제로 발생한 IllegalArgumentException 객체가 들어온다.
그래서 ex.getMessage()를 사용하면 예외를 발생시킬 때 넣은 메시지를 꺼낼 수 있다.


Model은 컨트롤러에서 화면으로 데이터를 넘길 때 사용하는 객체이다.
여기서는 예외 메시지를 message라는 이름으로 화면에 전달한다.
그러면 errorView.html에서 ${message} 같은 방식으로 값을 출력할 수 있다.


즉, @ExceptionHandler 메서드는 단순히 오류 화면만 반환하는 것이 아니다.
필요하면 발생한 예외 정보를 꺼내서 화면에 전달할 수도 있다.


기본 개념예제

아래 예제는 /error-test 요청이 들어오면 일부러 예외를 발생시키고, 같은 Controller 안의 @ExceptionHandler 메서드가 그 예외를 처리하는 흐름이다.

// ErrorBasicController.java
package com.example.springedu.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class ErrorBasicController {
    @GetMapping("/error-test")
    public String errorTest() {
        // 일반 요청 처리 메서드가 실행된다
        throw new IllegalArgumentException("잘못된 요청입니다."); // 예외를 일부러 발생시킨다
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public String handleIllegalArgumentException(IllegalArgumentException ex, Model model) {
        // IllegalArgumentException이 발생하면 이 메서드가 자동으로 실행된다
        model.addAttribute("message", ex.getMessage()); // 예외 메시지를 화면으로 넘긴다
        return "errorView"; // 오류 화면 이름을 반환한다
    }
}

이 코드에서 /error-test를 요청하면 먼저 errorTest()가 실행된다.
errorTest()는 정상 화면 이름을 반환하지 않는다.
대신 throw new IllegalArgumentException("잘못된 요청입니다.")를 실행해서 예외를 발생시킨다.


예외가 발생하는 순간 errorTest()의 정상 흐름은 끝난다.
그래서 errorTest()가 직접 화면 이름을 반환하는 것이 아니다.
그다음 Spring MVC가 같은 컨트롤러 안에서 IllegalArgumentException을 처리할 수 있는 메서드를 찾는다.


같은 클래스 안에 @ExceptionHandler(IllegalArgumentException.class)가 붙은 handleIllegalArgumentException()이 있다.
그래서 이 메서드가 자동으로 실행된다.
여기서 자동으로 실행된다는 말은 개발자가 코드에서 handleIllegalArgumentException()을 직접 호출하지 않는다는 뜻이다.


handleIllegalArgumentException()은 발생한 예외 객체를 ex로 받는다.
그리고 ex.getMessage()로 "잘못된 요청입니다."라는 메시지를 꺼낸다.
그 값을 model.addAttribute("message", ex.getMessage())로 화면에 넘긴다.
마지막으로 "errorView"를 반환해서 오류 화면으로 이동한다.


오류 화면은 아래처럼 작성할 수 있다.

// errorView.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>errorView</title>
</head>
<body>
<h1>오류가 발생했습니다.</h1>
<h2 th:text="${message}"></h2> <!-- 컨트롤러에서 넘긴 예외 메시지를 출력한다 -->
</body>
</html>

이 화면은 컨트롤러가 message라는 이름으로 넘긴 예외 메시지를 출력한다.
th:text="${message}"는 Thymeleaf에서 message 값을 화면에 출력하는 표현이다.


실행 결과는 아래처럼 볼 수 있다.

// 출력결과
// /error-test 요청
// 오류가 발생했습니다.
// 잘못된 요청입니다.

이 예제의 요청 흐름을 다시 정리하면 이렇다.

  • 사용자가 /error-test를 요청한다.
  • errorTest()가 실행된다.
  • IllegalArgumentException이 발생한다.
  • errorTest()는 정상 return을 하지 못한다.
  • Spring MVC가 @ExceptionHandler(IllegalArgumentException.class) 메서드를 찾는다.
  • handleIllegalArgumentException()이 자동으로 실행된다.
  • 예외 메시지가 Model에 담긴다.
  • "errorView"가 반환된다.
  • errorView.html에 오류 메시지가 출력된다.

이 예제의 핵심은 예외가 발생했을 때 일반 요청 처리 메서드가 멈추고, 같은 컨트롤러 안의 예외 처리 메서드로 흐름이 바뀐다는 점이다.


String 반환과 Model 사용 흐름

@ExceptionHandler 메서드도 일반 컨트롤러 메서드처럼 String을 반환할 수 있다.
여기서 String은 오류 화면의 이름을 의미한다.
예를 들어 "errorView"를 반환하면 errorView.html 화면으로 이동한다.


오류 화면에 단순 문구만 보여 줄 수도 있지만, 실제 예외 메시지를 보여 주고 싶을 때도 있다.
이때 Model을 사용한다.
Model에 값을 담으면 오류 화면에서 그 값을 꺼내 출력할 수 있다.


흐름을 값 기준으로 보면 이렇다.

// ExceptionMessageFlow.java
throw new IllegalArgumentException("잘못된 요청입니다.");

ex.getMessage();
// 결과: "잘못된 요청입니다."

model.addAttribute("message", ex.getMessage());
// message라는 이름으로 오류 메시지를 화면에 전달한다

return "errorView";
// errorView.html이 열린다

이 흐름에서 throw에 들어간 메시지가 ex.getMessage()로 꺼내진다.
그리고 그 메시지가 Model에 담겨 errorView.html로 전달된다.


즉, 예외 처리 메서드는 오류 화면으로 보내는 역할만 하는 것이 아니다.
오류 화면에 보여 줄 메시지나 필요한 정보를 함께 넘기는 역할도 한다.


적용 범위 정리

@ExceptionHandler를 사용할 때 꼭 기억해야 할 점은 적용 범위이다.
컨트롤러 안에 작성한 @ExceptionHandler는 기본적으로 그 컨트롤러 안에서 발생한 예외를 처리한다.


그래서 특정 컨트롤러에서만 특별한 오류 화면을 보여 주고 싶다면 컨트롤러 내부에 @ExceptionHandler를 작성하면 된다.
예를 들어 회원 컨트롤러에서는 회원 오류 화면을 보여 주고, 게시글 컨트롤러에서는 게시글 오류 화면을 보여 주고 싶을 수 있다.
이런 경우에는 각 컨트롤러 안에 지역 예외 처리 메서드를 따로 둘 수 있다.


반대로 여러 컨트롤러에서 같은 예외 처리를 반복해서 사용해야 한다면 @ControllerAdvice를 사용하는 편이 더 적합하다.
@ControllerAdvice는 여러 컨트롤러에 공통으로 적용되는 예외 처리 클래스를 만들 때 사용한다.


정리하면 이렇게 볼 수 있다.

  • 특정 컨트롤러 안에서만 처리할 예외라면 @ExceptionHandler를 컨트롤러 내부에 작성한다.
  • 여러 컨트롤러에서 공통으로 처리할 예외라면 @ControllerAdvice 안에 @ExceptionHandler를 작성한다.
  • @ExceptionHandler는 예외가 발생했을 때 실행될 메서드를 지정한다.
  • 예외 처리 메서드는 개발자가 직접 호출하지 않고 Spring MVC가 자동으로 호출한다.

결국 @ExceptionHandler는 예외가 발생했을 때 정상 흐름을 오류 처리 흐름으로 바꾸는 가장 기본적인 도구이다.
이 개념을 이해하면 다음에 나오는 @ControllerAdvice도 쉽게 연결된다.



@ControllerAdvice로 공통 예외 처리하기

@ControllerAdvice는 여러 Controller에서 발생하는 예외를 공통으로 처리할 때 사용하는 어노테이션이다.
앞에서 본 @ExceptionHandler는 특정 예외를 처리할 메서드를 지정하는 역할을 한다.
그런데 @ExceptionHandler를 각 컨트롤러 안에 계속 작성하면 같은 예외 처리 코드가 여러 곳에 반복될 수 있다.


예를 들어 회원 컨트롤러, 게시글 컨트롤러, 댓글 컨트롤러에서 모두 RuntimeException 계열 오류를 같은 오류 화면으로 보내고 싶다고 생각해 보자.
이때 각 컨트롤러마다 똑같은 @ExceptionHandler 메서드를 작성하면 코드가 불필요하게 반복된다.
이런 반복을 줄이기 위해 공통 예외 처리 클래스를 만들 수 있다.
그 공통 예외 처리 클래스에 붙이는 어노테이션이 @ControllerAdvice이다.


@ControllerAdvice가 필요한 이유

@ExceptionHandler를 컨트롤러 내부에 작성하면 해당 컨트롤러 안에서 발생한 예외를 처리할 수 있다.
이 방식은 특정 컨트롤러에서만 다른 오류 화면을 보여 주고 싶을 때 좋다.
하지만 여러 컨트롤러에서 같은 방식으로 예외를 처리해야 한다면 중복이 생긴다.


예를 들어 아래와 같은 상황을 생각하면 된다.

  • 회원 조회 중 예외가 발생하면 공통 오류 화면으로 보낸다.
  • 게시글 조회 중 예외가 발생해도 공통 오류 화면으로 보낸다.
  • 댓글 처리 중 예외가 발생해도 공통 오류 화면으로 보낸다.

이 세 컨트롤러가 모두 같은 오류 화면을 사용한다면, 각 컨트롤러 안에 같은 예외 처리 메서드를 반복해서 둘 필요가 없다.
공통 예외 처리 클래스를 하나 만들고, 그 안에서 예외를 처리하면 된다.


@ControllerAdvice는 여러 컨트롤러에서 반복될 수 있는 예외 처리 코드를 한 곳으로 모아 관리하기 위한 구조이다.
그래서 특정 컨트롤러 전용 예외 처리는 컨트롤러 내부의 @ExceptionHandler가 어울리고, 여러 컨트롤러에 공통으로 적용할 예외 처리는 @ControllerAdvice가 어울린다.


@ControllerAdvice와 @ExceptionHandler의 관계

@ControllerAdvice만 붙인다고 예외가 자동으로 처리되는 것은 아니다.
@ControllerAdvice는 공통 예외 처리 클래스를 만들겠다는 표시이다.
실제로 어떤 예외를 처리할지는 그 클래스 안에 작성한 @ExceptionHandler가 정한다.


즉, 둘의 역할은 다르다.

  • @ControllerAdvice는 여러 컨트롤러에 공통으로 적용되는 예외 처리 클래스를 만든다.
  • @ExceptionHandler는 그 안에서 어떤 예외를 어떤 메서드가 처리할지 정한다.

둘은 서로 대체 관계가 아니다.
@ControllerAdvice는 공통 처리 범위를 만들고, @ExceptionHandler는 실제 처리할 예외 종류를 지정한다.


아래처럼 이해하면 쉽다.

// CommonExceptionHandler.java
@ControllerAdvice
public class CommonExceptionHandler {
    @ExceptionHandler(RuntimeException.class)
    public String handleRuntimeException() {
        // RuntimeException 계열 예외를 공통으로 처리한다
        return "commonError";
    }
}

@ControllerAdvice가 붙은 CommonExceptionHandler는 여러 컨트롤러에서 발생한 예외를 처리할 수 있는 공통 클래스가 된다.
그 안의 @ExceptionHandler(RuntimeException.class)는 RuntimeException 계열 예외가 발생했을 때 실행될 메서드를 지정한다.


정리하면 @ControllerAdvice는 “어디에 공통으로 적용할 것인가”에 가깝고, @ExceptionHandler는 “어떤 예외를 처리할 것인가”에 가깝다.


RuntimeException 계열 예외 처리

RuntimeException은 실행 중에 발생할 수 있는 예외의 큰 종류이다.
여기서 “큰 종류”라는 말은 RuntimeException 아래에 여러 하위 예외가 있다는 뜻이다.


예를 들어 IllegalStateException은 RuntimeException의 하위 예외이다.
그래서 @ExceptionHandler(RuntimeException.class)로 작성한 메서드는 RuntimeException뿐만 아니라 IllegalStateException 같은 하위 예외도 함께 처리할 수 있다.


흐름을 간단히 보면 이렇다.

  • IllegalStateException이 발생한다.
  • IllegalStateException은 RuntimeException의 하위 예외이다.
  • @ExceptionHandler(RuntimeException.class)가 처리할 수 있는 예외 범위에 포함된다.
  • 그래서 공통 예외 처리 메서드가 실행될 수 있다.

이 구조를 이해하면 응용예제에서 IllegalStateException 지역 핸들러를 막았을 때 공통 핸들러가 대신 처리하는 이유를 이해할 수 있다.
IllegalStateException 자체를 직접 지정하지 않아도, 상위 타입인 RuntimeException을 처리하는 메서드가 있기 때문이다.


다만 공통으로 너무 넓은 예외를 잡으면 어떤 컨트롤러에서 어떤 문제가 발생했는지 구분하기 어려울 수 있다.
그래서 실제로는 예외 종류에 따라 필요한 만큼 나누어 처리하는 것이 좋다.
이 글에서는 공통 처리 흐름을 이해하기 위해 RuntimeException을 기준으로 본다.


기본 개념예제

아래 예제는 여러 컨트롤러에서 발생할 수 있는 RuntimeException 계열 예외를 공통 예외 처리 클래스에서 처리하는 흐름이다.
먼저 예외가 발생하는 컨트롤러를 본다.

// CommonErrorTestController.java
package com.example.springedu.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class CommonErrorTestController {
    @GetMapping("/common-error1")
    public String commonError1() {
        // 첫 번째 요청에서 예외가 발생한다
        throw new RuntimeException("첫 번째 컨트롤러 오류입니다."); // 공통 처리 대상 예외
    }

    @GetMapping("/common-error2")
    public String commonError2() {
        // 두 번째 요청에서도 예외가 발생한다
        throw new IllegalStateException("두 번째 컨트롤러 오류입니다."); // RuntimeException의 하위 예외
    }
}

이 컨트롤러에는 예외 처리 메서드가 없다.
/common-error1을 요청하면 RuntimeException이 발생한다.
/common-error2를 요청하면 IllegalStateException이 발생한다.


하지만 CommonErrorTestController 안에는 @ExceptionHandler가 없다.
따라서 이 컨트롤러 내부에서 예외를 직접 처리하지 않는다.
이때 공통 예외 처리 클래스가 있으면, 그 클래스가 예외를 대신 처리할 수 있다.


공통 예외 처리 클래스는 아래처럼 작성한다.

// CommonExceptionHandler.java
package com.example.springedu.controller;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.ModelAndView;

@ControllerAdvice
public class CommonExceptionHandler {
    @ExceptionHandler(RuntimeException.class)
    public ModelAndView handleRuntimeException(RuntimeException ex) {
        // RuntimeException 계열 예외가 발생하면 이 메서드가 실행된다
        ModelAndView mav = new ModelAndView(); // 화면 이름과 데이터를 함께 담을 객체를 만든다
        mav.setViewName("commonError"); // 공통 오류 화면 이름을 지정한다
        mav.addObject("exception", ex); // 예외 객체를 화면으로 전달한다
        return mav; // 화면 이름과 예외 정보를 함께 반환한다
    }
}

이 클래스에는 @ControllerAdvice가 붙어 있다.
그래서 특정 컨트롤러 하나에만 묶이지 않고, 여러 컨트롤러에서 발생하는 예외를 공통으로 처리할 수 있다.


handleRuntimeException()에는 @ExceptionHandler(RuntimeException.class)가 붙어 있다.
따라서 RuntimeException 또는 그 하위 예외가 발생했을 때 이 메서드가 실행된다.
/common-error1에서 발생한 RuntimeException도 처리할 수 있고, /common-error2에서 발생한 IllegalStateException도 처리할 수 있다.


ModelAndView는 모델 데이터와 뷰 이름을 한 객체에 함께 담는 방식이다.
여기서는 "commonError"라는 오류 화면 이름과 exception이라는 예외 객체를 함께 담는다.
즉, “어떤 오류 화면을 보여 줄지”와 “화면에 어떤 예외 정보를 넘길지”를 한 번에 반환하는 구조이다.


오류 화면은 아래처럼 작성할 수 있다.

// commonError.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>commonError</title>
</head>
<body>
<h1>공통 오류 화면입니다.</h1>
<h2 th:text="${exception.message}"></h2> <!-- 전달받은 예외 객체의 메시지를 출력한다 -->
</body>
</html>

이 화면은 공통 예외 처리 클래스에서 넘긴 exception 객체의 메시지를 출력한다.
exception.message는 예외 객체 안에 들어 있는 메시지를 의미한다.


실행 결과는 아래처럼 볼 수 있다.

// 출력결과
// /common-error1 요청
// 공통 오류 화면입니다.
// 첫 번째 컨트롤러 오류입니다.
// /common-error2 요청
// 공통 오류 화면입니다.
// 두 번째 컨트롤러 오류입니다.

/common-error1은 RuntimeException을 직접 발생시킨다.
그래서 @ExceptionHandler(RuntimeException.class)가 처리한다.


/common-error2는 IllegalStateException을 발생시킨다.
IllegalStateException은 RuntimeException의 하위 예외이므로, 같은 공통 예외 처리 메서드가 처리할 수 있다.


이 예제의 핵심은 예외 처리 코드가 컨트롤러 안에 없어도, @ControllerAdvice가 붙은 공통 클래스에서 여러 컨트롤러의 예외를 처리할 수 있다는 점이다.


지역 예외 처리와 공통 예외 처리의 관계

@ControllerAdvice가 있다고 해서 컨트롤러 내부의 @ExceptionHandler가 필요 없어지는 것은 아니다.
둘은 사용 목적이 다르다.


컨트롤러 내부의 @ExceptionHandler는 특정 컨트롤러에서만 다르게 처리해야 하는 예외에 적합하다.
반면 @ControllerAdvice는 여러 컨트롤러에서 같은 방식으로 처리할 예외에 적합하다.


예를 들어 회원 컨트롤러에서만 회원 없음 화면을 보여 주고 싶다면, 회원 컨트롤러 안에 @ExceptionHandler를 둘 수 있다.
하지만 여러 컨트롤러에서 공통적으로 발생하는 실행 오류를 같은 오류 화면으로 보내고 싶다면, @ControllerAdvice를 사용하는 것이 더 자연스럽다.


흐름을 정리하면 이렇다.

  • 특정 컨트롤러만의 오류 처리라면 컨트롤러 내부의 @ExceptionHandler를 사용한다.
  • 여러 컨트롤러에서 반복되는 오류 처리라면 @ControllerAdvice를 사용한다.
  • @ControllerAdvice 안에서도 실제 예외를 잡는 역할은 @ExceptionHandler가 한다.
  • 지역 예외 처리는 개별 컨트롤러의 특수한 상황을 처리하기 좋다.
  • 공통 예외 처리는 여러 컨트롤러의 반복되는 예외 처리 코드를 한 곳으로 모으기 좋다.

이 관계를 알아야 뒤의 응용예제에서 지역 핸들러와 공통 핸들러의 차이를 이해할 수 있다.
컨트롤러 안에 해당 예외를 처리하는 메서드가 있으면 지역 예외 처리로 볼 수 있고, 공통 클래스에서 처리하면 공통 예외 처리로 볼 수 있다.


핵심 정리

@ControllerAdvice는 여러 컨트롤러에 공통으로 적용되는 예외 처리 클래스를 만들 때 사용한다.
@ExceptionHandler는 그 클래스 안에서 어떤 예외를 처리할지 지정한다.


정리하면 이렇게 볼 수 있다.

  • @ControllerAdvice는 공통 예외 처리 클래스를 만든다.
  • @ControllerAdvice만으로 예외가 처리되는 것은 아니고, 내부에 @ExceptionHandler가 필요하다.
  • @ExceptionHandler(RuntimeException.class)는 RuntimeException과 그 하위 예외를 처리할 수 있다.
  • ModelAndView를 사용하면 오류 화면 이름과 예외 정보를 함께 담아 반환할 수 있다.
  • 지역 예외 처리는 특정 컨트롤러 전용 처리에 적합하다.
  • 공통 예외 처리는 여러 컨트롤러에서 반복되는 예외 처리에 적합하다.

결국 @ControllerAdvice는 예외 처리 코드를 한 곳으로 모으기 위한 구조이다.
예외가 발생하는 위치는 여러 컨트롤러로 흩어져 있어도, 처리 방식이 같다면 공통 예외 처리 클래스로 모아서 관리할 수 있다.



ExceptionHandler와 ControllerAdvice 비교

@ExceptionHandler와 @ControllerAdvice는 둘 다 예외 처리를 위해 사용한다.
하지만 두 어노테이션은 같은 일을 하는 것이 아니다.
@ExceptionHandler는 어떤 예외를 어떤 메서드가 처리할지 정하는 역할을 한다.
@ControllerAdvice는 그런 예외 처리 메서드를 여러 Controller에 공통으로 적용할 수 있게 만드는 역할을 한다.


즉, 둘은 대체 관계가 아니다.
@ControllerAdvice를 쓰면 @ExceptionHandler가 필요 없어지는 것이 아니다.
공통 예외 처리 클래스를 만들 때도 실제 예외를 잡는 메서드에는 @ExceptionHandler가 필요하다.


역할의 차이

@ExceptionHandler는 예외 처리 메서드에 붙인다.
이 어노테이션은 “이 예외가 발생하면 이 메서드가 처리한다”는 연결 규칙을 만든다.
예를 들어 @ExceptionHandler(IllegalArgumentException.class)라고 작성하면 IllegalArgumentException이 발생했을 때 해당 메서드가 실행된다.


반면 @ControllerAdvice는 클래스에 붙인다.
이 어노테이션은 “이 클래스 안의 예외 처리 메서드들을 여러 컨트롤러에 공통으로 적용한다”는 범위를 만든다.
따라서 @ControllerAdvice가 붙은 클래스 안에도 실제 예외를 처리할 @ExceptionHandler 메서드가 있어야 한다.


둘의 역할을 짧게 나누면 이렇다.

  • @ExceptionHandler: 어떤 예외를 어떤 메서드가 처리할지 정한다.
  • @ControllerAdvice: 예외 처리 메서드를 여러 컨트롤러에 공통으로 적용할 범위를 만든다.

따라서 @ExceptionHandler는 예외와 메서드를 연결하는 어노테이션이고, @ControllerAdvice는 공통 예외 처리 클래스를 만드는 어노테이션이라고 이해하면 된다.


적용 범위의 차이

가장 쉬운 구분은 적용 범위이다.
@ExceptionHandler를 특정 Controller 안에 작성하면 그 컨트롤러에서 발생한 예외를 처리한다.
이런 방식은 지역 예외 처리라고 볼 수 있다.


지역 예외 처리는 특정 컨트롤러에서만 다른 오류 화면이나 다른 메시지를 보여 주고 싶을 때 사용하기 좋다.
예를 들어 회원 컨트롤러에서 발생한 회원 없음 오류는 회원 전용 오류 화면으로 보내고 싶을 수 있다.
이런 경우에는 회원 컨트롤러 안에 @ExceptionHandler를 두면 된다.


반면 @ControllerAdvice가 붙은 클래스 안에 @ExceptionHandler를 작성하면 여러 컨트롤러에서 발생한 예외를 공통으로 처리할 수 있다.
이 방식은 공통 예외 처리라고 볼 수 있다.
여러 컨트롤러에서 같은 오류 화면이나 같은 오류 메시지를 사용해야 할 때 적합하다.


적용 범위를 비교하면 이렇다.

  • 컨트롤러 내부의 @ExceptionHandler: 해당 컨트롤러 중심으로 동작한다.
  • @ControllerAdvice 안의 @ExceptionHandler: 여러 컨트롤러에 공통으로 동작한다.

초보자는 먼저 이렇게 기억하면 된다.
특정 컨트롤러 안에서만 처리하면 @ExceptionHandler, 여러 컨트롤러에 공통으로 적용하면 @ControllerAdvice이다.


둘을 함께 쓰는 구조

@ControllerAdvice만 붙인다고 예외가 자동으로 처리되는 것은 아니다.
공통 처리 클래스 안에도 실제 예외를 잡을 @ExceptionHandler 메서드가 필요하다.


아래처럼 구조를 보면 더 쉽다.

// CommonExceptionHandler.java
@ControllerAdvice
public class CommonExceptionHandler {
    @ExceptionHandler(RuntimeException.class)
    public String handleRuntimeException() {
        // RuntimeException 계열 예외가 발생하면 이 메서드가 실행된다
        return "commonError";
    }
}

이 코드에서 @ControllerAdvice는 CommonExceptionHandler 클래스를 공통 예외 처리 클래스로 만든다.
그리고 @ExceptionHandler(RuntimeException.class)는 RuntimeException 계열 예외가 발생했을 때 handleRuntimeException() 메서드가 실행되도록 연결한다.


즉, @ControllerAdvice는 공통 처리 범위를 만들고, @ExceptionHandler는 실제 처리할 예외 종류를 지정한다.
둘 중 하나만으로 전체 공통 예외 처리 흐름이 완성되는 것이 아니라, 두 어노테이션이 함께 사용되면서 공통 예외 처리 구조가 만들어진다.


지역 처리와 공통 처리 중 무엇이 먼저인가

예외 처리를 볼 때는 지역 처리와 공통 처리를 함께 생각해야 한다.
특정 컨트롤러 안에 해당 예외를 처리하는 @ExceptionHandler가 있으면, 그 컨트롤러 내부에서 예외가 처리되는 흐름으로 볼 수 있다.
이것이 지역 예외 처리이다.


반대로 컨트롤러 내부에 해당 예외를 처리할 메서드가 없거나, 해당 예외를 잡는 지역 핸들러가 없다면 @ControllerAdvice가 붙은 공통 예외 처리 클래스가 처리할 수 있다.
뒤의 응용예제에서도 IllegalStateException을 처리하는 지역 핸들러를 막으면, RuntimeException을 처리하는 공통 핸들러가 대신 실행된다.


흐름을 정리하면 이렇다.

  • 예외가 발생한 컨트롤러 안에 해당 예외를 처리할 @ExceptionHandler가 있으면 지역 처리로 해결된다.
  • 해당 컨트롤러 안에 처리할 메서드가 없으면 공통 예외 처리 클래스를 확인한다.
  • 공통 클래스의 @ExceptionHandler가 해당 예외를 처리할 수 있으면 공통 처리로 해결된다.

이 흐름을 이해하면 응용예제에서 지역 핸들러를 주석으로 막았을 때 왜 CommonExceptionHandler가 대신 실행되는지 자연스럽게 이해할 수 있다.
지역 예외 처리는 개별 컨트롤러의 특수한 오류를 처리하고, 공통 예외 처리는 여러 컨트롤러에 반복되는 오류를 한 곳에서 처리한다.


반환 방식의 차이

예외 처리 메서드는 일반 컨트롤러 메서드처럼 값을 반환한다.
반환값은 사용자에게 어떤 오류 화면이나 오류 응답을 보여 줄지 결정한다.


가장 단순한 방식은 String을 반환하는 것이다.
String은 오류 화면 이름만 반환할 때 사용한다.
예를 들어 "noFriend"를 반환하면 noFriend.html 화면으로 이동한다.

// StringReturnExceptionHandlerExample.java
@ExceptionHandler(FriendNotFoundException.class)
public String handleNotFoundException() {
    // 화면에 추가로 넘길 데이터가 없으면 화면 이름만 반환한다
    return "noFriend";
}

이 방식은 오류 화면에 따로 전달할 데이터가 없을 때 단순하고 편하다.
즉, 화면 이름만 정하면 되는 경우에 사용한다.


반대로 오류 화면 이름과 함께 메시지나 예외 객체 같은 데이터를 넘겨야 할 때는 ModelAndView를 사용할 수 있다.
ModelAndView는 모델 데이터와 뷰 이름을 한 객체에 함께 담는 방식이다.

// ModelAndViewExceptionHandlerExample.java
@ExceptionHandler(RuntimeException.class)
public ModelAndView handleRuntimeException(RuntimeException ex) {
    // 화면 이름과 예외 정보를 함께 담는다
    ModelAndView mav = new ModelAndView();
    mav.setViewName("commonErrorPage"); // 오류 화면 이름을 지정한다
    mav.addObject("exceptionInfo", ex); // 예외 객체를 화면으로 넘긴다
    return mav;
}

이 방식은 오류 화면에서 실제 예외 정보나 오류 메시지를 출력해야 할 때 사용한다.
예를 들어 exceptionInfo라는 이름으로 예외 객체를 넘기면 화면에서 그 값을 출력할 수 있다.


반환 방식을 비교하면 이렇다.

  • String: 오류 화면 이름만 반환할 때 사용한다.
  • ModelAndView: 오류 화면 이름과 화면에 보여 줄 데이터를 함께 반환할 때 사용한다.

즉, 화면 이름만 필요하면 String, 화면 이름과 데이터가 함께 필요하면 ModelAndView라고 구분하면 된다.


핵심 정리

@ExceptionHandler와 @ControllerAdvice는 둘 다 예외 처리와 관련되어 있지만, 역할과 적용 범위가 다르다.
@ExceptionHandler는 실제로 예외를 처리할 메서드를 지정하고, @ControllerAdvice는 그 예외 처리 메서드를 여러 컨트롤러에 공통으로 적용할 수 있는 클래스를 만든다.


정리하면 이렇다.

  • @ExceptionHandler는 특정 예외를 처리할 메서드를 지정한다.
  • 컨트롤러 내부의 @ExceptionHandler는 해당 컨트롤러 중심의 지역 예외 처리로 동작한다.
  • @ControllerAdvice는 공통 예외 처리 클래스를 만든다.
  • @ControllerAdvice 안의 @ExceptionHandler는 여러 컨트롤러의 예외를 공통으로 처리한다.
  • 지역 핸들러가 있으면 해당 컨트롤러 안에서 먼저 처리되는 흐름으로 볼 수 있다.
  • 지역 핸들러가 없거나 해당 예외를 처리하지 않으면 공통 핸들러가 처리할 수 있다.
  • 화면 이름만 반환하면 String, 화면 이름과 데이터를 함께 반환하면 ModelAndView를 사용한다.

오류 처리의 핵심은 예외가 발생한 뒤에도 사용자에게 보여 줄 응답 흐름을 개발자가 직접 정리할 수 있다는 점이다.
@ExceptionHandler는 개별 컨트롤러 예외 처리에 적합하고, @ControllerAdvice는 공통 예외 처리에 적합하다.
이 차이를 알면 이후 응용예제에서 MethodArgumentTypeMismatchException, 직접 만든 예외, 공통 오류 화면이 어떻게 연결되는지도 자연스럽게 이해할 수 있다.





응용예제

1. 지역 예외 처리와 공통 예외 처리 (ExceptionLocalController.java, CommonExceptionHandler.java, FriendService.java, FriendDTO.java, FriendNotFoundException.java, errorPage.html, friendView.html, noFriend.html, commonErrorPage.html)

앞에서는 @ExceptionHandler가 특정 Controller 안에서 발생한 예외를 처리하고, @ControllerAdvice가 여러 Controller에서 발생하는 예외를 공통으로 처리한다고 정리했다.
그런데 이 예제는 단순히 두 어노테이션의 차이만 보여 주는 예제가 아니다.


실제로는 요청 파라미터 타입 변환 실패, 직접 만든 예외 발생, Service 조회 결과에 따른 정상 화면 반환, ModelAndView를 이용한 오류 메시지 전달, 지역 예외 처리와 공통 예외 처리의 우선 흐름까지 함께 보여 준다.
즉, 이 묶음은 하나의 /exceptionTest 요청에서 num 값에 따라 정상 처리, 지역 예외 처리, 공통 예외 처리가 어떻게 갈라지는지 확인하는 응용예제이다.


먼저 요청 흐름만 간단히 보면 이렇다.

  • /exceptionTest처럼 num을 전달하지 않으면 이 예제 실행 결과에서는 IllegalStateException 처리 핸들러가 실행된다.
  • /exceptionTest?num=이언덕처럼 숫자 자리에 문자를 전달하면 MethodArgumentTypeMismatchException이 발생한다.
  • /exceptionTest?num=10처럼 정상 숫자를 전달하고 해당 친구 정보가 있으면 friendView.html이 출력된다.
  • /exceptionTest?num=1처럼 숫자 형식은 맞지만 친구 정보가 없으면 직접 만든 FriendNotFoundException이 발생한다.
  • IllegalStateException을 처리하는 지역 핸들러를 막으면 CommonExceptionHandler가 공통으로 예외를 처리한다.



이 예제가 같이 보여주는 개념

이 예제의 중심은 ExceptionLocalController.java이다.
이 컨트롤러는 /exceptionTest 요청을 받고, 요청 파라미터 num을 int 타입으로 받는다.
int는 숫자만 받을 수 있는 기본형 타입이다.
그래서 num이 없거나 숫자로 바꿀 수 없는 값이 들어오면 정상적으로 메서드를 실행하기 어렵다.

// ExceptionLocalController.java
package com.example.springedu.controller;

import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.ModelAndView;
import com.example.springedu.service.FriendNotFoundException;
import com.example.springedu.service.FriendService;
import com.example.springedu.domain.FriendDTO;

@Controller
public class ExceptionLocalController {
    @Autowired
    FriendService ms; // 친구 정보를 조회하는 서비스 객체를 주입받는다

    @RequestMapping("/exceptionTest")
    public String detail(int num, Model model) throws FriendNotFoundException {
        FriendDTO vo = ms.get(num); // num 값으로 친구 정보를 조회한다
        if (vo == null) {
            throw new FriendNotFoundException(); // 친구 정보가 없으면 직접 만든 예외를 발생시킨다
        }
        model.addAttribute("friend", vo); // 조회된 친구 정보를 화면으로 넘긴다
        return "friendView"; // 정상 조회 화면으로 이동한다
    }

    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ModelAndView handleTypeMismatchException(MethodArgumentTypeMismatchException ex) {
        System.out.println("TypeMismatchException 발생시 처리하는 핸들러가 오류 처리합니다.");
        ModelAndView mav = new ModelAndView(); // 화면 이름과 데이터를 함께 담을 객체를 만든다
        mav.addObject("msg", "타입을 맞춰주세용!!"); // 오류 화면에 보여 줄 메시지를 담는다
        mav.setViewName("errorPage"); // errorPage.html로 이동한다
        return mav;
    }

    @ExceptionHandler(FriendNotFoundException.class)
    public String handleNotFoundException() throws IOException {
        System.out.println("FriendNotFoundException 발생시 처리하는 핸들러가 오류 처리합니다.");
        return "noFriend"; // noFriend.html로 이동한다
    }

    @ExceptionHandler(IllegalStateException.class) // 주석으로 막을시 CommonExceptionHandler가 처리함
    public ModelAndView handleIllegalStateException() throws IOException {
        System.out.println("IllegalStateException 발생시 처리하는 핸들러가 오류 처리합니다.");
        ModelAndView mav = new ModelAndView(); // 화면 이름과 데이터를 함께 담을 객체를 만든다
        mav.addObject("msg", "num=숫자 형식의 쿼리를 전달하세요!!"); // 오류 메시지를 담는다
        mav.setViewName("errorPage"); // errorPage.html로 이동한다
        return mav;
    }
}

이 코드에서 가장 먼저 봐야 할 부분은 detail(int num, Model model)이다.
/exceptionTest?num=10처럼 요청하면 num에는 10이 들어간다.
그리고 FriendService의 get(num)을 호출해서 친구 정보를 조회한다.


여기서 Model은 컨트롤러에서 화면으로 데이터를 넘길 때 사용하는 객체이다.
정상적으로 친구 정보를 찾으면 model.addAttribute("friend", vo)로 friend라는 이름에 친구 정보를 담는다.
이렇게 담긴 값은 friendView.html에서 [[${ friend.name }]]처럼 꺼내 쓸 수 있다.


반대로 FriendService가 친구 정보를 찾지 못하면 vo는 null이 된다.
이때 컨트롤러는 직접 만든 FriendNotFoundException을 발생시킨다.
즉, 이 예제는 값이 없을 때 단순히 화면을 반환하는 것이 아니라, 직접 만든 예외를 발생시키고 그 예외를 지역 핸들러에서 처리하는 흐름을 보여 준다.


FriendService.java는 실제 친구 정보를 조회하는 역할을 한다.
여기서는 num이 10일 때만 친구 정보를 만들어 반환한다.
그 외 숫자는 전부 null을 반환한다.

// FriendService.java
package com.example.springedu.service;

import org.springframework.stereotype.Service;
import com.example.springedu.domain.FriendDTO;

@Service
public class FriendService {
    public FriendDTO get(int num) {
        FriendDTO vo = null; // 처음에는 친구 정보가 없다고 둔다
        if (num == 10) {
            vo = new FriendDTO(); // num이 10이면 친구 정보 객체를 만든다
            vo.setPhoneNum("010-1111-2222"); // 전화번호를 저장한다
            vo.setName("Dooly"); // 이름을 저장한다
        }
        return vo; // 친구 정보가 있으면 객체를, 없으면 null을 반환한다
    }
}

@Service는 이 클래스가 비즈니스 로직을 처리하는 Spring Bean이라는 표시이다.
비즈니스 로직은 실제 서비스에서 필요한 처리 규칙을 의미한다.
이 예제에서는 “num이 10이면 친구 정보를 반환하고, 아니면 없다고 처리한다”가 비즈니스 로직이다.


FriendDTO.java는 친구 정보를 담는 객체이다.
DTO는 데이터를 옮기기 위해 사용하는 객체라고 이해하면 된다.
이 예제에서는 전화번호와 이름을 화면으로 넘기기 위해 사용한다.

// FriendDTO.java
package com.example.springedu.domain;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class FriendDTO {
    private String phoneNum; // 친구 전화번호
    private String name; // 친구 이름
}

@Getter와 @Setter는 Lombok이 제공하는 어노테이션이다.
직접 getPhoneNum(), setPhoneNum(), getName(), setName() 메서드를 쓰지 않아도 자동으로 만들어 준다.
그래서 friendView.html에서는 friend.phoneNum, friend.name처럼 값을 꺼낼 수 있다.


친구 정보가 없을 때 사용할 예외는 직접 만든다.
FriendNotFoundException은 Exception을 상속받는다.
이렇게 직접 만든 예외를 사용하면 “친구가 없는 상황”을 코드에서 더 명확하게 표현할 수 있다.

// FriendNotFoundException.java
package com.example.springedu.service;

public class FriendNotFoundException extends Exception {
    private static final long serialVersionUID = 1L; // 예외 클래스 직렬화 버전 값

    public FriendNotFoundException() {
    }

    public FriendNotFoundException(String message) {
        super(message); // 부모 예외 클래스에 메시지를 전달한다
    }
}

이 예외는 Exception을 상속받았기 때문에 일반적인 실행 예외인 RuntimeException과는 다르다.
그래서 detail() 메서드 선언부에 throws FriendNotFoundException을 적어, 이 메서드에서 해당 예외가 발생할 수 있음을 표시한다.


오류 메시지를 보여 주는 화면은 errorPage.html이다.
ModelAndView에 msg라는 이름으로 담은 메시지를 화면에서 출력한다.

// errorPage.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h1>[[${ msg }]]</h1>
</body>
</html>

[[${ msg }]]는 Thymeleaf 표현식이다.
컨트롤러에서 mav.addObject("msg", "...")로 담은 값을 화면에 출력한다.
즉, 어떤 예외가 발생했는지에 따라 같은 errorPage.html을 사용하더라도 다른 메시지를 보여 줄 수 있다.


정상적으로 친구 정보가 조회되면 friendView.html이 사용된다.
이 화면은 컨트롤러가 friend라는 이름으로 넘긴 FriendDTO 객체를 출력한다.

// friendView.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>친구 정보</h2>
<hr>
<h3>전화 번호 : [[${ friend.phoneNum }]]</h3>
<h3>이름 : [[${ friend.name }]]</h3>
</body>
</html>

friend.phoneNum은 FriendDTO의 전화번호 값이고, friend.name은 이름 값이다.
num=10일 때만 FriendService가 이 값을 만들어 주기 때문에 정상 화면은 num=10 요청에서만 볼 수 있다.


친구 정보가 없으면 noFriend.html이 사용된다.
이 화면은 별도의 데이터를 받지 않고, 단순히 친구가 없다는 메시지만 출력한다.

// noFriend.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h1>친구가 없당!!</h1>
</body>
</html>

여기서 ExceptionLocalController의 handleNotFoundException()은 String으로 "noFriend"를 반환한다.
String으로 뷰 이름만 반환해도 되는 이유는 화면에 따로 넘길 데이터가 없기 때문이다.


마지막으로 공통 예외 처리 클래스인 CommonExceptionHandler.java를 봐야 한다.
이 클래스에는 @ControllerAdvice가 붙어 있다.
따라서 특정 컨트롤러 하나에만 묶이지 않고, 여러 컨트롤러에서 발생하는 예외를 공통으로 처리할 수 있다.

// CommonExceptionHandler.java
package com.example.springedu.service;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.ModelAndView;

@ControllerAdvice
public class CommonExceptionHandler {
    @ExceptionHandler(RuntimeException.class)
    private ModelAndView errorModelAndView(Exception ex) {
        ModelAndView modelAndView = new ModelAndView(); // 화면 이름과 데이터를 함께 담을 객체를 만든다
        modelAndView.setViewName("commonErrorPage"); // 공통 오류 화면 이름을 지정한다
        modelAndView.addObject("msg", "RuntimeException은 내가 다 잡는다!!!"); // 공통 오류 메시지를 담는다
        modelAndView.addObject("exceptionInfo", ex); // 발생한 예외 객체를 화면으로 넘긴다
        return modelAndView;
    }
}

이 메서드는 Exception ex로 예외 객체를 받는다.
하지만 @ExceptionHandler(RuntimeException.class)로 지정되어 있기 때문에, 실제로는 RuntimeException 계열 예외가 들어온다.
즉, 매개변수 타입은 넓게 받을 수 있지만, 어떤 예외를 처리할지는 @ExceptionHandler의 괄호 안에 적은 예외 클래스가 기준이 된다.


예제에서는 예외 처리 메서드가 private으로 작성되어 있다.
하지만 여기서 중요한 것은 접근 제한자가 아니라, @ControllerAdvice가 붙은 클래스 안에 @ExceptionHandler(RuntimeException.class)가 선언되어 있다는 점이다.
즉, 이 메서드는 RuntimeException 계열 예외가 발생했을 때 공통 예외 처리 메서드로 사용된다.


공통 오류 화면은 commonErrorPage.html이다.
CommonExceptionHandler에서 msg와 exceptionInfo를 화면으로 넘기기 때문에, 이 화면에서는 공통 오류 메시지와 실제 예외 정보를 함께 출력한다.

// commonErrorPage.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h1 style="color:red" th:text="${ msg }"></h1>
<hr>
<h3 th:text="${ exceptionInfo }"></h3>
</body>
</html>

th:text="${ msg }"는 공통 예외 처리 클래스에서 넘긴 오류 메시지를 출력한다.
th:text="${ exceptionInfo }"는 실제 발생한 예외 객체 정보를 출력한다.
즉, 이 화면은 단순히 “오류가 났다”만 보여 주는 것이 아니라, 공통 핸들러가 어떤 메시지와 어떤 예외 정보를 화면에 넘겼는지 확인하는 화면이다.


이 코드는 RuntimeException 계열 예외를 공통으로 처리한다.
IllegalStateException은 RuntimeException의 하위 예외이다.
그래서 ExceptionLocalController 안에서 IllegalStateException을 처리하는 지역 핸들러를 주석으로 막으면, 이 공통 핸들러가 대신 처리할 수 있다.


이 흐름에서 중요한 점은 지역 예외 처리와 공통 예외 처리의 관계이다.
특정 컨트롤러 안에 해당 예외를 처리하는 @ExceptionHandler가 있으면 그 컨트롤러 내부에서 처리된다.
하지만 해당 예외를 처리할 지역 핸들러가 없거나 해당 예외를 처리하지 않으면 @ControllerAdvice가 붙은 공통 예외 처리 클래스가 처리할 수 있다.


그래서 결과가 이렇게 나온다

첫 번째는 /exceptionTest처럼 num을 아예 전달하지 않은 경우이다.
컨트롤러 메서드는 int num을 필요로 한다.
그런데 요청에 num이 없으면 int에 넣을 값이 없다.
int는 기본형 타입이라 null을 담을 수 없기 때문에 이 예제 실행 결과에서는 IllegalStateException 처리 핸들러가 실행된다.


이 예제에서는 ExceptionLocalController 안에 @ExceptionHandler(IllegalStateException.class)가 있다.
그래서 지역 예외 처리 메서드인 handleIllegalStateException()이 실행된다.
그 결과 errorPage.html에 num=숫자 형식의 쿼리를 전달하세요!! 메시지가 출력된다.


IllegalStateException 로그

콘솔에는 IllegalStateException을 처리하는 핸들러가 오류를 처리했다는 로그가 출력된다.


num 없이 요청한 결과

화면에는 errorPage.html이 열리고, ModelAndView에 담긴 msg 값이 출력된다.
즉, num이 없어서 예외가 발생했지만 서버 오류 화면이 아니라 준비한 오류 화면으로 응답한다.



두 번째는 /exceptionTest?num=이언덕처럼 num에 문자를 전달한 경우이다.
컨트롤러 메서드는 int num을 기대한다.
그런데 이언덕은 숫자로 바꿀 수 없는 문자열이다.
그래서 Spring MVC가 요청값을 int로 변환하는 과정에서 MethodArgumentTypeMismatchException이 발생한다.


이 예외는 @ExceptionHandler(MethodArgumentTypeMismatchException.class)가 붙은 handleTypeMismatchException()에서 처리한다.
그 결과 errorPage.html에 타입을 맞춰주세용!! 메시지가 출력된다.


문자 타입 요청 결과

화면에는 타입이 맞지 않는다는 메시지가 출력된다.
요청 주소의 형태는 맞지만, num에 들어온 값의 타입이 맞지 않기 때문이다.


TypeMismatchException 로그

콘솔에는 TypeMismatchException을 처리하는 핸들러가 오류를 처리했다는 로그가 출력된다.
이 흐름을 통해 요청 파라미터 타입 변환 실패도 지역 예외 처리 메서드로 잡을 수 있다는 점을 확인할 수 있다.



세 번째는 /exceptionTest?num=10처럼 정상 숫자를 전달하고, 해당 친구 정보가 존재하는 경우이다.
num에는 숫자 10이 들어간다.
FriendService의 get(10)은 FriendDTO 객체를 만들어 반환한다.


컨트롤러는 반환받은 FriendDTO를 model.addAttribute("friend", vo)로 화면에 넘긴다.
그리고 "friendView"를 반환하므로 friendView.html이 열린다.


num=10 정상 조회 결과
화면에는 친구 정보가 출력된다.
전화번호는 010-1111-2222이고, 이름은 Dooly이다.
이 경우는 예외가 발생하지 않았기 때문에 @ExceptionHandler가 실행되지 않는다.
즉, 정상 요청 흐름 그대로 컨트롤러가 뷰를 반환한 결과이다.


네 번째는 /exceptionTest?num=1처럼 숫자 형식은 맞지만 친구 정보가 없는 경우이다.
num은 숫자이므로 타입 변환 문제는 없다.
하지만 FriendService의 get(1)은 친구 정보를 만들지 않고 null을 반환한다.


컨트롤러는 vo == null을 확인하고 직접 FriendNotFoundException을 발생시킨다.
이 예외는 @ExceptionHandler(FriendNotFoundException.class)가 붙은 handleNotFoundException()에서 처리한다.
그 결과 "noFriend"가 반환되고 noFriend.html이 열린다.


num=1 친구 없음 결과

화면에는 친구가 없당!!이 출력된다.
이 결과는 요청값 자체가 잘못된 것이 아니라, 요청한 번호에 해당하는 데이터가 없어서 나온 결과이다.


FriendNotFoundException 로그

콘솔에는 FriendNotFoundException을 처리하는 핸들러가 오류를 처리했다는 로그가 출력된다.
이 흐름을 통해 직접 만든 예외도 @ExceptionHandler로 처리할 수 있다는 것을 확인할 수 있다.



다섯 번째는 ExceptionLocalController 안의 IllegalStateException 지역 핸들러를 주석으로 막은 뒤 /exceptionTest를 요청한 경우이다.
원래는 num이 없어서 발생한 예외를 컨트롤러 내부 핸들러가 처리했다.
하지만 그 핸들러를 막으면 컨트롤러 내부에는 해당 예외를 처리할 메서드가 없어진다.


이때 IllegalStateException은 RuntimeException의 하위 예외이므로, @ControllerAdvice가 붙은 CommonExceptionHandler의 @ExceptionHandler(RuntimeException.class)가 대신 처리한다.
그 결과 공통 오류 화면인 commonErrorPage가 열린다.


공통 예외 처리 결과

화면에는 RuntimeException은 내가 다 잡는다!!!라는 메시지가 출력된다.
그리고 아래에는 실제 발생한 예외 정보가 함께 출력된다.


이 결과가 중요한 이유는 지역 예외 처리와 공통 예외 처리의 차이가 눈에 보이기 때문이다.
컨트롤러 안에 처리할 지역 핸들러가 있으면 지역 핸들러가 처리하고, 지역 핸들러가 없거나 해당 예외를 처리하지 않으면 공통 핸들러가 처리할 수 있다.


이 예제의 핵심을 다시 정리하면 이렇다.

  • num이 없으면 이 예제 실행 결과에서는 IllegalStateException 처리 핸들러가 실행된다.
  • num에 문자가 들어오면 MethodArgumentTypeMismatchException이 발생한다.
  • num=10이면 정상적으로 친구 정보가 출력된다.
  • num=1이면 직접 만든 FriendNotFoundException이 발생한다.
  • 컨트롤러 내부 @ExceptionHandler는 해당 컨트롤러의 예외를 지역적으로 처리한다.
  • @ControllerAdvice는 지역 핸들러가 없거나 해당 예외를 처리하지 않을 때 공통으로 처리할 수 있다.
  • ModelAndView는 오류 화면 이름과 화면에 보여 줄 데이터를 함께 담을 때 사용한다.
  • String 반환은 화면 이름만 반환해도 충분할 때 사용한다.

결국 이 응용예제는 오류 처리 개념을 실제 요청 결과로 확인하는 예제이다.
단순히 예외가 발생했다는 사실만 보는 것이 아니라, 어떤 예외가 발생했고, 그 예외를 지역 핸들러가 처리했는지 공통 핸들러가 처리했는지까지 구분해서 보는 것이 핵심이다.

0개의 댓글