Exception Handler

컨테이너·2025년 11월 29일

Spring Boot

목록 보기
5/7
post-thumbnail

1. Exception Handler 개념

실행 중 에러가 터졌을 때 그냥 톰캣 기본 에러 페이지가 뜨면:

  • 사용자 입장: 불편.
  • 개발자 입장: 어디서 어떤 에러 났는지 확인이 불편

그래서 스프링 MVC는 예외를 깔끔하게 모아서 처리하는 기능을 제공한다.

그중 핵심 키워드가:

  • HandlerExceptionResolver
  • @ExceptionHandler
  • @ControllerAdvice

1-1. HandlerExceptionResolver 란?

DispatcherServlet이 요청을 처리하다가 예외가 발생하면:

  1. 그냥 터뜨리는 게 아니라

  2. 등록된 HandlerExceptionResolver 들에게

    “이 예외 좀 처리해줘” 하고 넘긴다.

대표적인 구현체들:

  • SimpleMappingExceptionResolver
    • 예외 타입 → 에러 페이지 뷰 이름 매핑
  • DefaultHandlerExceptionResolver
    • 스프링이 기본 제공하는 예외 처리 (예: 405 등)
  • ResponseStatusExceptionResolver
    • @ResponseStatus 달린 예외를 보고 HTTP 상태코드 + 메시지 응답
  • ExceptionHandlerExceptionResolver
    • 컨트롤러의 @ExceptionHandler 메서드를 찾아서 호출해 줌

오늘 내용은 이중에서

@ExceptionHandler + @ControllerAdvice를 중점적으로 보는 느낌이야.


2. Exception Handler 테스트

2-1. @ExceptionHandler

2-1-1. 기본 에러 화면 (예외 처리 안 했을 때)

버튼 두 개로 각각 다른 요청을 보낸다.

<button onclick="location.href='controller-null'">NullPointerException 테스트</button>
<button onclick="location.href='controller-user'">사용자 정의 Exception 테스트</button>

컨트롤러에서 일부러 예외를 일으킴:

@GetMapping("controller-null")
public String nullPointerExceptionTest() {
    String str = null;
    System.out.println(str.charAt(0));   // 여기서 NPE 발생
    return "/";
}
@GetMapping("controller-user")
public String userExceptionTest() throws MemberRegistException {

    boolean check = true;
    if(check) {
        throw new MemberRegistException("당신 같은 사람은 회원으로 받을 수 없습니다!");
    }

    return "/";
}

지금은 예외 처리를 안 했으니까:

  • NullPointerException → 기본 에러 페이지
  • MemberRegistException → 기본 에러 페이지

즉, 그냥 “스프링 부트 기본 에러 화면”이 튀어나온다.


2-1-2. Controller 레벨에서 예외 처리

이제 같은 컨트롤러 클래스 안에 예외 처리 메서드를 추가한다.

@ExceptionHandler(NullPointerException.class)
public String nullPointerExceptionHandler(NullPointerException exception) {

    System.out.println("controller 레벨의 exception 처리");

    return "error/nullPointer";
}

에러용 뷰:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>nullPointer</title>
</head>
<body>
    <h1>널 위하여 NullPointerException 발생함!</h1>
</body>
</html>

사용자 정의 예외도 비슷하게:

@ExceptionHandler(MemberRegistException.class)
public String userExceptionHandler(Model model, MemberRegistException exception) {

    System.out.println("controller 레벨의 exception 처리");
    model.addAttribute("exception", exception);

    return "error/memberRegist";
}

뷰:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>memberRegist</title>
</head>
<body>
    <h1 th:text="${ exception.message }"></h1>
</body>
</html>

위의 동작 흐름을 파악해보자.

  • controller-null 호출 → 메소드 안에서 NPE 발생
  • 같은 컨트롤러 안에 @ExceptionHandler(NullPointerException.class) 이 있으므로
    • 그 메서드가 호출되고
    • error/nullPointer.html로 포워드
  • controller-user 호출 → MemberRegistException 발생
  • @ExceptionHandler(MemberRegistException.class) 메서드가 실행
    • 모델에 예외 객체 담고
    • error/memberRegist 뷰로 이동

정리하면:

  • @ExceptionHandler(예외타입.class)
    • 해당 컨트롤러 안에서 발생하는 그 타입 예외를 “가로채서” 처리
    • 예외에 따라 각각 다른 뷰 / 메시지 / 로깅 처리 가능

2-2. @ControllerAdvice (전역 예외 처리)

2-2-1. 다른 Controller에서 발생한 예외

이번엔 다른 컨트롤러를 새로 만든다.

버튼:

<button onclick="location.href='other-controller-null'">
    NullPointerException 테스트
</button>
<button onclick="location.href='other-controller-user'">
    사용자 정의 Exception 테스트
</button>

컨트롤러:

@GetMapping("other-controller-null")
public String otherNullPointerExceptionTest() {

    String str = null;
    System.out.println(str.charAt(0));

    return "/";
}
@GetMapping("other-controller-user")
public String otherUserExceptionTest() throws MemberRegistException {

    boolean check = true;
    if(check) {
        throw new MemberRegistException("당신 같은 사람은 회원으로 받을 수 없습니다!");
    }

    return "/";
}

이 컨트롤러에는 @ExceptionHandler가 없으니까:

  • 앞에서 만든 @ExceptionHandler는 “다른 컨트롤러”에 붙어 있어서 적용 안 됨
  • 다시 기본 에러 페이지가 뜬다

즉, 컨트롤러 안에 있는 @ExceptionHandler는 그 컨트롤러 전용임.


2-2-2. Global 레벨 예외 처리 (@ControllerAdvice)

컨트롤러가 여러 개고, 각각에 전부 @ExceptionHandler를 쓰면 코드 중복도 많고 관리가 힘들다.

→ @ExceptionHandler는 지역 에러처리 핸들러이기 때문

그래서 전역 예외 처리용 클래스를 하나 만든다.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(NullPointerException.class)
    public String nullPointerExceptionHandler(NullPointerException exception) {

        System.out.println("Global 레벨의 exception 처리");

        return "error/nullPointer";
    }

    @ExceptionHandler(MemberRegistException.class)
    public String userExceptionHandler(Model model, MemberRegistException exception) {

        System.out.println("Global 레벨의 exception 처리");
        model.addAttribute("exception", exception);

        return "error/memberRegist";
    }
}

설명 :

  • 어떤 컨트롤러에서 NullPointerException이 발생하든
    • 그 컨트롤러에 따로 @ExceptionHandler가 없다면 → GlobalExceptionHandler의 메소드가 불린다. 이 메소드 안에서 적용된 @ExceptionHandler는 전역적으로 사용 가능하다.
  • MemberRegistException도 동일

동작 우선순위:

  1. 컨트롤러 내부 @ExceptionHandler
  2. @ControllerAdvice가 붙은 전역 핸들러
  3. 그래도 없으면 스프링 기본 에러 처리

2-2-3. 상위 타입으로 “기본 예외 처리기” 만들기

이번엔 다른 종류의 예외:

버튼:

<button onclick="location.href='other-controller-array'">ArrayException 테스트</button>

컨트롤러:

@GetMapping("other-controller-array")
public String otherArrayExceptionTest() {

    double[] array = new double[0];
    System.out.println(array[0]);    // ArrayIndexOutOfBoundsException 발생

    return "/";
}

전역 핸들러에 “모든 예외의 상위 타입”인 Exception을 처리하는 메서드를 추가:

@ExceptionHandler(Exception.class)
public String defaultExceptionHandler(Exception exception) {

    return "error/default";
}

뷰:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>default</title>
</head>
<body>
    <h1>뭔가 에러가 발생함</h1>
</body>
</html>

이제 어떤 예외가 터져도:

  • 먼저 구체적인 타입용 핸들러 (NullPointerException, MemberRegistException)가 있는지 확인
  • 없으면 Exception.class용 핸들러가 “기본 처리기” 역할을 해줌

→ “모든 예외에 대한 fallback 처리기” 느낌으로 쓰면 된다.


2-3. ResponseStatusExceptionResolver (+ @ResponseStatus)

ResponseStatusExceptionResolver@ResponseStatus가 붙은 예외 클래스를 보고 응답을 만들어준다. 예외 클래스 위에 HTTP 상태코드를 직접 박아버리는 방식이다.

예시:

@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "리소스를 찾을 수 없습니다.")
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

컨트롤러:

@Controller
public class ResourceController {

    @GetMapping("/resource/{id}")
    public String getResource(@PathVariable("id") int id) {
        // 리소스 부재 시 예외 발생
        if (id <= 0) {
            throw new ResourceNotFoundException("유효하지 않은 리소스 ID");
        }
        return "/";
    }
}

요청 /resource/0 처럼 잘못된 id를 보내면:

  • ResourceNotFoundException이 던져지고
  • @ResponseStatus(HttpStatus.NOT_FOUND) 때문에
    • 응답 상태 코드: 404
    • reason: "리소스를 찾을 수 없습니다."

주로 REST API에서:

  • 404, 400, 403 같은 상태 코드를 명시적으로 주고 싶을 때 사용

2-4. SimpleMappingExceptionResolver

예외 타입 → 에러 페이지 뷰 이름을 properties처럼 일괄 매핑하는 방식.

설정 코드:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public SimpleMappingExceptionResolver simpleMappingExceptionResolver() {
        SimpleMappingExceptionResolver resolver = new SimpleMappingExceptionResolver();
        Properties exceptionMappings = new Properties();

        // 예외 타입별로 뷰 이름 매핑
        exceptionMappings.put("java.lang.Exception", "error");
        exceptionMappings.put("java.lang.RuntimeException", "runtimeError");

        resolver.setExceptionMappings(exceptionMappings);
        resolver.setDefaultErrorView("defaultError");  // 기본 에러 뷰
        resolver.setExceptionAttribute("exception");   // 뷰에 전달할 예외 객체 이름

        return resolver;
    }
}

동작 방식:

  • RuntimeException이 발생하면 → runtimeError.html로 포워드
  • 그 외 예외들(Exception) → error.html
  • 둘 다 아니고 설정 안된 예외면 → defaultError.html
  • 템플릿에서 ${exception}으로 예외 객체 접근 가능

요약:

  • 예외 종류별로 뷰만 바꾸고 싶을 때
  • @ExceptionHandler처럼 코드 안에 메서드를 만들기 싫고 설정만으로 간단하게 처리하고 싶은 경우에 적합

정리

  • @ExceptionHandler
    • 컨트롤러 내부에서 예외를 잡아서 뷰/응답 커스텀
    • 해당 컨트롤러에만 적용
  • @ControllerAdvice
    • 여러 컨트롤러 공통 예외 처리
    • 전역 예외 핸들러
    • 타입별/상위 타입별로 핸들러 메소드 구성 가능
  • @ResponseStatus
    • 특정 예외에 HTTP 상태 코드 박아두는 방식
    • REST API에서 많이 사용
  • SimpleMappingExceptionResolver
    • 예외 타입 → 뷰 이름 매핑을 설정으로 처리

profile
백엔드

0개의 댓글