스프링의 예외 처리의 핵심 전략
스프링은 예외가 발생했을 때 가로채서 적절한 응답으로 변환하는 전략 패턴을 사용한다.
크게 세 가지 수준에서 처리가 이루어진다.
컨트롤러 수준 @ExceptionHandler
특정 컨트롤러 내부에서 발생하는 예외를 직접 잡아서 처리한다.
해당 컨트롤러 내에 메서드를 만들고 어노테이션을 붙이면, 그 컨트롤러에서 터지는 예외는 이 메서드가 우선적으로 처리된다.
@RestController
public class ProductController {
@GetMapping("/products/{id}")
public Product getProduct(@PathVariable String id) {
if ("none".equals(id)) {
throw new IllegalArgumentException("존재하지 않는 상품입니다.");
}
return new Product(id, "상품명");
}
// 이 컨트롤러 내부에서 발생한 IllegalArgumentException만 잡아 처리함
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> handleIllegalArgs(IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
}
만약 상품을 검색했는데, 존재하지 않는 상품을 검색했을 경우, throw를 통해 IllegalArgumentException 예외를 날린다.
그 예외를 @ExceptionHandler가 IllegalArgumentException 예외인 것만 받아서 오류 메시지와 함께 BAD_REQUEST 상태값인 400을 보낸다.
전역 수준 @RestControllerAdvice
애플리케이션 자체에서 발생하는 예외를 한 곳에서 관리한다.
여러 컨트롤러에서 공통으로 발생하는 예외(예: EntityNotFoundException)를 통해 처리할 때 매우 유용하다.
// 1. 공통 에러 응답 객체 정의
public record ErrorResponse(int code, String message) {}
// 2. 전역 예외 처리기
@RestControllerAdvice
public class GlobalExceptionHandler {
// 모든 컨트롤러에서 발생하는 RuntimeException을 여기서 처리
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException e) {
ErrorResponse response = new ErrorResponse(500, "서버 내부 오류: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
// 특정 커스텀 예외 처리
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
ErrorResponse response = new ErrorResponse(404, e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
}
모든 컨트롤러에서 발생하는 예외들은 모두 이 곳에서 관리한다.
(커스텀도 마찬가지.)
서블릿 수준 @HandlerExceptionResolver
스프링 MVC의 핵심 컴포넌트로, 컨트롤러 밖으로 던져진 예외를 어떻게 처리할지 결정하는 해결사 역할을 한다.
@Service
public class UserService {
public User findUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."
));
}
}
별도의 핸들러 클래스를 만들지 않고, 로직 안에서 즉시 HTTP 상태 코드를 반환하고 싶을 때 사용한다.
이는 스프링의 ResponseStatusExceptionResolver가 처리한다.
예외 처리의 흐름과 구조
스프링 MVC에서 예외가 발생하면 다음과 같은 순서로 전파되고 처리된다.
- 예외 발생: 컨트롤러(Handler) 혹은 그 하위 비즈니스 로직(Service)에서 예외가 발생한다.
- 전파: 예외가 DispatcherServlet까지 전달된다
- DispatcherServlet은 해결사인 HandlerExceptionResolver들에게 이 예외를 어떻게 처리할 수 있는지 물어본다.
- 우선순위:
- ExceptionHandlerExceptionResolver: 우리가 주로 사용하는 @ExceptionHandler를 처리한다.
- ResponseStatusExceptionResolver: HTTP 상태 코드 관련 어노테이션을 처리한다.
- DefaultHandlerExceptionResolver: 스프링 내부 기본 예외들을 처리한다.
- 응답 반환: 적절한 처리기를 찾으면 예외를 JSON이나 HTML 뷰로 변환하여 클라이언트에게 응답한다.
주요 컴포넌트 상세
| 컴포넌트 | 설명 |
| @ExceptionHandler | 메서드 수준에서 특정 예외 타입을 지정해 처리. |
| @RestControllerAdvice | @ControllerAdvice + @ResponseBody. 전역 예외 처리를 위한 컨트롤러 보조 클래스. |
| ResponseStatusException | 별도의 클래스 없이 코드 상에서 즉각적으로 상태 코드와 메시지를 담아 던지는 예외. |
스프링이 예외를 찾는 순서
- 가장 구체적인 것부터: 현재 컨트롤러에 @ExceptionHandler가 있는가?
- 전역 처리기로 확장: @RestControllerAdvice에 해당 예외 처리기가 있는가?
- 부모 타입으로 확장: 해당 예외의 부모 클래스(RuntimeException 등) 처리기가 있는가?
- 표준 리졸버로 전송: 스프링 기본 리졸버(DefaultHandlerExceptionResolver)가 처리할 수 있는가?
커스텀 예외 처리
1단계: 에러 코드 정의(ErrorCode Enum)
먼저 애플리케이션에서 발생할 수 있는 에러들을 하나의 Enum으로 통합 관리한다.
이렇게 하면 HTTP 상태 코드와 비즈니스 메시지를 한눈에 파악할 수 있다.
public enum ErrorCode {
// 공통 에러
INVALID_INPUT_VALUE(400, "C001", " 잘못된 입력값입니다."),
METHOD_NOT_ALLOWED(405, "C002", " 허용되지 않은 메서드입니다."),
// 유저 관련 에러
USER_NOT_FOUND(404, "U001", " 해당 유저를 찾을 수 없습니다."),
EMAIL_DUPLICATION(400, "U002", " 이미 존재하는 이메일입니다.");
private final int status;
private final String code;
private final String message;
ErrorCode(int status, String code, String message) {
this.status = status;
this.code = code;
this.message = message;
}
// Getter 생략
}
2단계: 최상위 커스텀 예외 생성(BusinessException)
모든 비즈니스 예외의 부모가 될 클래스를 만든다.
RuntimeException을 상속받아 언체크 예외(Unchecked Exception)로 만드는 것이 일반적이다.
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
이제 이 클래스를 상속받아 구체적인 예외들을 만든다.
public class UserNotFoundException extends BusinessException {
public UserNotFoundException() {
super(ErrorCode.USER_NOT_FOUND);
}
}
3단계: 전역 예외 핸들러 구현(@RestControllerAdvice)
모든 컨트롤러에서 던져진 커스텀 예외를 가로채서 JSON 응답으로 변환한다.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 1. 우리가 정의한 BusinessException 처리
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
log.error("BusinessException", e);
ErrorCode errorCode = e.getErrorCode();
ErrorResponse response = new ErrorResponse(
errorCode.getStatus(),
errorCode.getCode(),
errorCode.getMessage()
);
return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
}
// 2. 그 외 예상치 못한 모든 예외 처리 (Exception.class)
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("Exception", e);
ErrorResponse response = new ErrorResponse(500, "SERVER_ERROR", "서버 내부 오류입니다.");
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
이러한 구조의 장점은?
- 일관성: 모든 에러 응답이 { "status": 404, "code": "U001", "message": "..." }와 같은 동일한 포맷을 유지된다.
- 유지보수: 에러 메시지를 수정해야 할 때 컨트롤러나 서비스 로직을 뒤질 필요 없이 ErrorCode Enum만 수정하면 된다.
- 가독성: 비즈니스 로직에서 throw new UserNotFoundException() 처럼 예외의 의도를 명확히 드러낼 수 있다.
- 분리: 에러 처리 로직이 비즈니스 로직(Service)에서 완전히 분리되어 코드가 깔끔해진다.
커스텀 예외 설계 시 주의할 점들
- 너무 많은 예외 클래스: 모든 에러 상황마다 클래스를 만들면 클래스 폭발이 일어날 수 있다. BusinessException 하나만 쓰고 생성자로 ErrorCode만 갈아 끼우는 방식과 적절히 섞어서 사용하는 것이 좋다.
- Stack Trace: 로그를 남길 때 log.error("message", e) 처럼 예외 객체를 넘겨야 추후 디버깅 시 어디서 에러가 시작되었는지 추적할 수 있다.
실무에서 사용하는 회원가입 예외 처리 방식
실무에서 회원가입 예외 처리는 단순히 "아이디 중복"을 체크하는 것을 넘어, 입력 데이터 검증(Validation), 비즈니스 로직 예외, 그리고 DB 제약 조건 위반 등을 종합적으로 처리해야 한다.
회원가입 에러 코드 정의(Enum)
회원가입 시 발생할 수 있는 구체적인 상황들을 정의한다.
public enum ErrorCode {
// Validation
INVALID_INPUT_VALUE(400, "COMMON_001", "입력값이 올바르지 않습니다."),
// Member
DUPLICATE_EMAIL(400, "MEMBER_001", "이미 존재하는 이메일입니다."),
DUPLICATE_NICKNAME(400, "MEMBER_002", "이미 존재하는 닉네임입니다."),
INVALID_PASSWORD_FORMAT(400, "MEMBER_003", "비밀번호 형식이 올바르지 않습니다.");
private final int status;
private final String code;
private final String message;
// 생성자 및 Getter 생략
}
입력값 검증용 DTO(@Valid 활용)
서비스 로직에 들어가기 전, 컨트롤러 레벨에서 형식적인 에러(이메일 형식, 빈 값 등)를 먼저 걸러낸다.
public record SignUpRequest(
@Email(message = "이메일 형식이 아닙니다.")
@NotBlank(message = "이메일은 필수입니다.")
String email,
@NotBlank(message = "비밀번호는 필수입니다.")
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).{8,}$", message = "비밀번호는 8자 이상, 영문/숫자 조합이어야 합니다.")
String password,
@Size(min = 2, max = 10, message = "닉네임은 2~10자 사이여야 합니다.")
String nickname
) {}
서비스 계층의 커스텀 예외 발생
비즈니스 규칙(중복 확인 등)에 어긋나면 우리가 만든 커스텀 예외를 던진다.
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional
public void signUp(SignUpRequest request) {
// 1. 이메일 중복 체크
if (memberRepository.existsByEmail(request.email())) {
throw new BusinessException(ErrorCode.DUPLICATE_EMAIL);
}
// 2. 닉네임 중복 체크
if (memberRepository.existsByNickname(request.nickname())) {
throw new BusinessException(ErrorCode.DUPLICATE_NICKNAME);
}
// 3. 회원 저장 로직...
}
}
전역 예외 처리(Global Exception Handler)
실무에서는 @Valid에서 발생하는 MethodArgumentNotValidException과 우리가 던진 BusinessException을 모두 처리해야 하므로 예외 처리 클래스를 따로 생성해서 잡아줘야 한다.
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* @Valid 검증 실패 시 호출됨 (400 Bad Request)
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
// 필드 에러가 여러 개일 수 있으므로 첫 번째 에러 메시지를 가져옴
String message = e.getBindingResult().getFieldErrors().get(0).getDefaultMessage();
ErrorResponse response = new ErrorResponse(400, "VALIDATION_ERROR", message);
return ResponseEntity.badRequest().body(response);
}
/**
* 비즈니스 로직 예외 처리 (중복 이메일 등)
*/
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
ErrorCode errorCode = e.getErrorCode();
ErrorResponse response = new ErrorResponse(errorCode.getStatus(), errorCode.getCode(), errorCode.getMessage());
return ResponseEntity.status(errorCode.getStatus()).body(response);
}
}
왜 이렇게 할까요?
- Fail-Fast: @Valid를 쓰면 DB 근처에도 가기 전에 입구(Controller)에서 잘못된 요청을 차단할 수 있어 자원 낭비를 줄인다.
- 데이터 무결성: 서비스 계층에서 한 번 더 중복 체크를 하여 비즈니스 로직의 정합성을 보장한다.
- 사용자 경험: 클라이언트에게 "중복된 이메일입니다"라는 정확한 메시지를 전달하여 사용자가 무엇을 수정해야 할지 명확히 알려준다.
'Daily Dev Q&A 정리 템플릿' 카테고리의 다른 글
| 25.12.31 Spring Security의 인증과 인가에 대해. (0) | 2025.12.31 |
|---|---|
| 25.12.30 @SpringBootApplication 어노테이션이 꼭 필요한가요? (0) | 2025.12.30 |
| 25.12.26 스프링 어노테이션 (@Component, @Service, @Repository, @Controller) (0) | 2025.12.26 |
| 25.12.26 Spring Bean은 무엇인가요? (0) | 2025.12.26 |
| 25.12.24 스프링 부트은 스프링과 다른 건가요? (0) | 2025.12.24 |