본문 바로가기
language/java

Spring MVC 예외 처리 내부 구조

by 죄니안죄니 2026. 6. 24.
반응형

Spring MVC 예외 처리 내부 구조

Spring을 사용하다 보면 자연스럽게 이런 코드를 작성하게 된다.

 
@RestController
public class UserController {

    @GetMapping("/users/{id}")
    public User getUser(@PathVariable Long id) {

        throw new RuntimeException("에러 발생");
    }
}
 

실행하면

 
{
  "timestamp": "...",
  "status": 500,
  "error": "Internal Server Error"
}
 

응답이 나온다.

그런데 의문이 생긴다.

예외를 잡지도 않았는데

Spring은 어떻게 에러 응답을 만드는 걸까?

이번 글에서는

  • DispatcherServlet 내부 흐름
  • Exception 발생 과정
  • @ExceptionHandler
  • @ControllerAdvice
  • HandlerExceptionResolver
  • Spring Boot 기본 예외 처리

까지 실제 동작 원리 중심으로 알아본다.


웹 요청이 들어오는 순간

예를 들어

 
GET /users/1
 

요청이 들어왔다고 가정해보자.

Spring MVC 내부에서는

클라이언트

↓

DispatcherServlet

↓

HandlerMapping

↓

Controller

↓

Response
 

흐름이 진행된다.


실제 코드

 
@GetMapping("/users/{id}")
public User getUser(
        @PathVariable Long id
) {
    return service.findById(id);
}
 

DispatcherServlet이

 
service.findById()
 

까지 실행시킨다.


예외 발생

만약

 
public User findById(Long id) {

    throw new RuntimeException();
}
 

발생하면

호출 스택은

UserService

↓

UserController

↓

DispatcherServlet
 

순으로 전파된다.


결국

DispatcherServlet까지 예외가 올라온다.


DispatcherServlet은 try-catch를 가지고 있다

많은 사람들이 모르는 부분

Spring MVC 핵심 진입점인

 
DispatcherServlet
 

내부에는

실제로 이런 구조가 있다.

 
try {

    handler.execute();

}
catch(Exception ex) {

    processHandlerException();
}
 

(실제 구현은 훨씬 복잡)


예외가 발생하면

DispatcherServlet이 잡는다.


그 다음은?

DispatcherServlet은

직접 예외를 처리하지 않는다.


대신

 
HandlerExceptionResolver
 

들에게 처리 요청을 보낸다.


구조

Exception 발생

↓

DispatcherServlet

↓

HandlerExceptionResolver

↓

적절한 응답 생성
 

이게 Spring MVC 예외 처리의 핵심이다.


HandlerExceptionResolver

인터페이스

 
public interface HandlerExceptionResolver {

    ModelAndView resolveException(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler,
            Exception ex
    );
}
 

역할

예외 발견

↓

누가 처리 가능한지 확인

↓

응답 생성
 

Spring 기본 Resolver

Spring Boot 시작 시

기본적으로 여러 Resolver가 등록된다.

대표적으로

ExceptionHandlerExceptionResolver

ResponseStatusExceptionResolver

DefaultHandlerExceptionResolver
 

순서대로 검사한다.


ExceptionHandlerExceptionResolver

가장 많이 사용하는 Resolver


예시

 
@RestController
public class UserController {

    @ExceptionHandler(RuntimeException.class)
    public String handle(RuntimeException e) {

        return "예외 처리";
    }
}
 

예외 발생

 
throw new RuntimeException();
 

Resolver가

Reflection으로

 
@ExceptionHandler
 

메서드를 찾는다.


찾으면

 
handle()
 

실행


결과

예외 처리
 

응답


내부 동작

실제로는

예외 발생

↓

ExceptionHandlerExceptionResolver

↓

@Controller 내부 검색

↓

@ExceptionHandler 발견

↓

Reflection 호출

↓

응답 반환
 

과정이다.


여기서도 결국

Reflection이 등장한다.


@ControllerAdvice

실무에서는

Controller마다

예외 처리 안 한다.


예시

 
@RestController
public class UserController {

}
 
 
@RestController
public class OrderController {

}
 
 
@RestController
public class ProductController {

}
 

각각

 
@ExceptionHandler
 

를 넣으면

중복이 심하다.


그래서 등장한 것이

 
@ControllerAdvice
 

이다.


전역 예외 처리

 
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    public String handle(RuntimeException e) {

        return "전역 처리";
    }
}
 

이제

모든 Controller에서

 
RuntimeException
 

발생 시

여기로 온다.


구조

Controller

↓

Exception 발생

↓

DispatcherServlet

↓

ExceptionResolver

↓

@ControllerAdvice

↓

응답 생성
 

실무 방식

보통은

예외 객체를 만든다.


예시

 
public class UserNotFoundException
        extends RuntimeException {

}
 

서비스

 
throw new UserNotFoundException();
 

전역 처리

 
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(
            UserNotFoundException.class
    )
    public ErrorResponse handle(
            UserNotFoundException e
    ) {

        return new ErrorResponse(
                "USER_NOT_FOUND"
        );
    }
}
 

응답

 
{
  "code": "USER_NOT_FOUND"
}
 

실무에서는 거의 이런 형태다.


@ResponseStatus

간단한 방법

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

}
 

예외 발생

 
throw new UserNotFoundException();
 

자동으로

 
404 NOT FOUND
 

응답


내부적으로

 
ResponseStatusExceptionResolver
 

가 처리한다.


ResponseStatusException

동적으로 처리 가능

 
throw new ResponseStatusException(
        HttpStatus.NOT_FOUND,
        "회원 없음"
);
 

결과

 
404 NOT FOUND
 

실무에서는

커스텀 예외 + ControllerAdvice가 더 많이 사용된다.


DefaultHandlerExceptionResolver

Spring MVC 자체 예외 처리


예시

 
@GetMapping("/users/{id}")
 

호출

 
/users/abc
 

하지만

 
Long id
 

로 변환 실패


예외 발생

 
MethodArgumentTypeMismatchException
 

Resolver가

 
400 BAD REQUEST
 

응답 생성


개발자가 처리 안 해도 된다.


Spring Boot 기본 예외 처리

만약

어떤 Resolver도 처리하지 못하면?


마지막으로

 
BasicErrorController
 

가 동작한다.


바로 우리가 자주 보는

 
{
  "timestamp": "...",
  "status": 500,
  "error": "Internal Server Error"
}
 

응답이다.


흐름

예외 발생

↓

Resolver 전부 실패

↓

BasicErrorController

↓

500 응답
 

실무에서 가장 많이 쓰는 패턴

예외

 
public class BusinessException
        extends RuntimeException {

    private final ErrorCode errorCode;

}
 

에러 코드

 
public enum ErrorCode {

    USER_NOT_FOUND,
    INVALID_REQUEST,
    DUPLICATE_USER

}
 

전역 처리

 
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(
            BusinessException.class
    )
    public ResponseEntity<ErrorResponse>
    handle(BusinessException e) {

        return ResponseEntity
                .badRequest()
                .body(
                        new ErrorResponse(
                                e.getErrorCode()
                        )
                );
    }
}
 

실무 프로젝트 대부분이 이런 구조다.


DispatcherServlet 기준 전체 흐름

최종적으로

HTTP 요청

↓

DispatcherServlet

↓

Controller

↓

Service

↓

Exception 발생

↓

DispatcherServlet

↓

HandlerExceptionResolver

↓

@ExceptionHandler

또는

@ControllerAdvice

↓

Response 생성

↓

HTTP 응답
 

지금까지의 연결

이번 시리즈에서 배운 것들이 전부 연결된다.

Annotation

↓

Reflection

↓

Spring Bean 생성

↓

Proxy 생성

↓

DispatcherServlet 호출

↓

ExceptionResolver

↓

@ExceptionHandler 실행
 

 
@RestControllerAdvice

@ExceptionHandler
 

도 결국

Spring이 Reflection으로 찾고 실행하는 구조다.


정리

Spring MVC의 예외 처리는

try-catch
 

가 아니라

DispatcherServlet

↓

HandlerExceptionResolver
 

구조 위에서 동작한다.

실무에서는

커스텀 예외

↓

@ControllerAdvice

↓

공통 ErrorResponse
 

패턴을 거의 표준처럼 사용한다.

그리고 이 구조를 이해하면

Spring Security 예외 처리

Spring Validation 예외 처리

Spring Transaction Rollback

까지 훨씬 쉽게 이해할 수 있다.

다음 글부터는 11. 실무 성능 최적화 카테고리로 넘어간다.

첫 번째 주제는 객체 생성 비용 줄이기(Object Creation Cost) 이다. 왜 new를 많이 한다고 무조건 느려지는 것은 아닌지, JVM이 객체 생성을 실제로 어떻게 최적화하는지부터 시작해본다.

반응형

댓글