본문 바로가기
카테고리 없음

Spring 예외처리 (전역처리, BaseException, BaseResponse)

by ricepuppy9733 2026. 6. 12.

처음 Spring 프로젝트를 만들 때 예외 처리를 Controller마다 try-catch로 일일이 감쌌던 기억이 납니다. 어느 순간 코드가 길어지면서 "이걸 왜 매번 반복하지?"라는 생각이 들었고, 그때부터 전역 예외 처리 구조를 하나씩 개선해 나갔습니다. 이 글은 그 과정을 단계별로 정리한 기록입니다.

Spring 예외처리

전역처리와 BaseException으로 중복 코드 없애기

처음 해봤던 방식은 Service 계층에서 예외를 던지고, Controller에서 try-catch로 받는 전통적인 자바 방식이었습니다. 기능 자체는 돌아가긴 했는데, API 엔드포인트가 5개, 10개로 늘어나면서 각 Controller 메서드마다 동일한 try-catch 블록이 반복되는 게 눈에 거슬렸습니다. 같은 코드를 복붙하고 있다는 건 뭔가 잘못됐다는 신호라고 생각합니다.

그래서 Spring이 제공하는 @RestControllerAdvice를 활용하기 시작했습니다. @RestControllerAdvice란 @ControllerAdvice와 @ResponseBody를 합친 어노테이션으로, 애플리케이션 전역에서 발생하는 예외를 한 곳에서 가로채 처리할 수 있게 해주는 기능입니다. 쉽게 말해 모든 Controller에서 예외가 터지면 이 클래스로 자동으로 집결된다고 보면 됩니다. 덕분에 각 Controller에서는 예외 처리 코드를 완전히 걷어낼 수 있었습니다.

여기에 @ExceptionHandler를 붙여서 특정 예외 타입을 지정하면, 해당 타입의 예외가 발생할 때만 선택적으로 처리할 수 있습니다. @ExceptionHandler란 특정 예외 클래스를 파라미터로 받아서 그 예외가 발생했을 때만 실행되는 메서드를 지정하는 어노테이션입니다. 처음에는 그냥 RuntimeException 하나로 퉁 쳤는데, 사용자 조회 실패와 DB 연결 오류를 같은 메시지로 돌려주는 건 클라이언트 입장에서도, 디버깅하는 저 입장에서도 불편했습니다.

그래서 커스텀 예외 클래스인 BaseException을 만들었습니다. RuntimeException을 상속받아 언체크 예외(Unchecked Exception)로 동작하게 했는데, 언체크 예외란 try-catch나 throws 선언 없이도 컴파일이 통과되는 예외입니다. 체크 예외였다면 Service 메서드마다 throws를 선언해야 했을 테니, 이 선택은 코드를 훨씬 깔끔하게 만들어줬습니다.

Spring 프레임워크 공식 가이드에서도 비즈니스 로직 예외는 RuntimeException 계열로 설계하도록 권장하고 있습니다(출처: Spring Framework 공식 문서).

BaseResponse와 프론트엔드 유효성 검사의 연결

BaseException 하나로도 많이 편해졌지만, 아직 문제가 남아 있었습니다. 예외 응답의 구조가 통일되지 않아서 프론트엔드 쪽에서 에러 응답을 파싱할 때마다 구조가 달랐던 겁니다. 어떤 API는 문자열 메시지를 돌려주고, 어떤 API는 객체를 돌려주는 식이었으니 프론트 개발할 때 매번 케이스를 확인해야 했습니다. 제가 직접 프론트도 연결해보니 이게 얼마나 불편한지 체감이 됐습니다.

그래서 BaseResponse와 BaseResponseStatus를 도입했습니다. BaseResponseStatus란 모든 에러 코드와 메시지를 열거형(enum)으로 미리 정의해두는 클래스입니다. INVALID_USER_INFO, DUPLICATE_USER_EMAIL처럼 에러 상황마다 고유한 코드와 메시지를 붙여두면, 나중에 어떤 에러가 얼마나 자주 발생하는지도 파악하기 쉬워집니다.

응답 표준화를 했을 때 달라진 점은 꽤 컸습니다. 프론트엔드에서 data.success 값 하나만 보면 성공인지 실패인지 판단할 수 있고, data.code로 에러 종류를 분기할 수 있게 됐습니다. 제가 직접 Vue.js로 회원가입 폼을 연결해보면서 이 구조가 얼마나 편한지 실감했습니다.

Spring Validation에서 제공하는 @Valid 어노테이션과 DTO의 제약 어노테이션(@Pattern, @NotBlank 등)을 함께 쓰면, 요청 값이 잘못됐을 때 MethodArgumentNotValidException이 자동으로 발생합니다. MethodArgumentNotValidException이란 @Valid가 붙은 DTO의 유효성 검사가 실패했을 때 Spring이 자동으로 던져주는 예외입니다. 이걸 GlobalExceptionHandler에서 잡아서 어느 필드가 어떤 이유로 실패했는지를 Map 형태로 정리한 뒤, BaseResponse에 담아서 돌려줬습니다.

프론트엔드 유효성 검사 방식은 크게 두 가지로 나뉩니다.

  • 서버 응답 후 에러 표시: 제출 후 서버 응답이 오면 그때 에러 메시지를 보여주는 방식으로, 코드가 단순하고 빠르게 붙일 수 있습니다. 단, 사용자가 잘못 입력했더라도 버튼을 누르기 전까지는 아무 피드백이 없다는 단점이 있습니다.
  • 입력 단계 즉시 검증(@blur 이벤트 활용): 사용자가 입력 필드에서 포커스를 잃는 순간 유효성 규칙(rules)을 실행해서 바로 에러를 보여주는 방식입니다. isFormValid라는 computed 속성으로 모든 필드가 유효할 때만 제출 버튼이 활성화되도록 제어할 수 있어서 UX 측면에서 훨씬 낫습니다.

솔직히 처음에는 단순한 방식으로 빠르게 붙이고 싶었는데, 사용자 입장에서 직접 써보니 입력하고 버튼 누르고 나서야 에러를 알게 되는 경험이 꽤 불편하더라고요. 규모가 작은 프로젝트라도 @blur 기반의 즉시 검증을 적용하는 게 장기적으로는 맞는 선택이라고 봅니다.

Bean Validation 스펙은 Jakarta EE 표준으로 관리되고 있으며, Spring Boot의 spring-boot-starter-validation 의존성에 포함되어 있습니다(출처: Jakarta Bean Validation 공식 문서).

예외 처리 구조를 개선해 나가면서 느낀 건, 처음부터 완벽하게 만들려고 할 필요는 없다는 겁니다. try-catch에서 시작해서 @RestControllerAdvice, BaseException, BaseResponse로 단계적으로 발전시켜 나가다 보면 왜 이 구조가 필요한지 자연스럽게 이해가 됩니다. 지금 프로젝트의 예외 처리가 지저분하다고 느껴진다면, 일단 GlobalExceptionHandler 하나부터 만들어보는 걸 추천합니다. 생각보다 빠르게 코드가 정리되는 걸 체감할 수 있습니다.


참고: https://dltldnr2563.tistory.com/entry/%EC%BD%94%EB%94%A9%EA%B3%B5%EB%B6%8020250818-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AC%B4%EA%B2%B0%EC%84%B1-%EC%A0%9C%EC%95%BD-%EC%A1%B0%EA%B1%B4-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC%ED%94%84%EB%A1%A0%ED%8A%B8-%EB%B0%B1%EC%97%94%EB%93%9C


소개 및 문의 · 개인정보처리방침 · 면책조항

© 2026 자동식단생성 연관 블로그