사이드 프로젝트를 진행 중 비즈니스 로직을 담당하는 Service단에 예외 처리 로직이 들어가 관심사가 분리되지 않고, 예외 처리 로직도 로그만 남기고 예외를 다시 던지는 의미없는 예외 던지기라는 피드백을 받았다. 실제로 Service 코드엔 15개의 메서드에 동일한 try-catch 패턴이 있었다.
@Transactional
public BookResponse updateBook(Long id, BookRequest request) {
try {
// 입력값 검증
bookValidator.validateUpdateBookRequest(id, request);
// 기존 도서 조회
Book existingBook = findById(id);
// 비즈니스 규칙 검증
validateUpdateBookBusinessRules(existingBook, request);
// 카테고리 변경 시 검증
Category newCategory = null;
if (request.getCategoryId() != null &&
!request.getCategoryId().equals(existingBook.getCategory().getId())) {
newCategory = validateAndGetCategory(request.getCategoryId());
}
// 도서 정보 업데이트
updateBookFields(existingBook, request, newCategory);
Book updatedBook = bookRepository.save(existingBook);
return new BookResponse(updatedBook);
} catch (BookNotFoundException | InvalidBookDataException | DuplicateBookException e) {
log.warn("도서 수정 요청 오류 - ID: {}, 오류: {}", id, e.getMessage());
throw e;
} catch (Exception e) {
log.error("도서 수정 중 시스템 오류 발생 - ID: {}", id, e);
throw new RuntimeException("도서 수정 중 시스템 오류가 발생했습니다", e);
}
}
Controller 코드 역시 모든 api마다 try-catch를 넣으면 코드가 지저분해지고, 예외가 났을 때 어떻게 응답할지 컨트롤러마다 다를 수 있어서 유지보수가 어렵다는 점도 언급했다. Spring은 예외를 중앙에서 깔끔하게 처리할 수 있는 구조를 제공하기 때문에, 이걸 안쓰면 아깝다고.....
@GetMapping
public ResponseEntity<ApiResponse<PageResponse<BookListResponse>>> getAllBooks(
@PageableDefault(size = 10, sort = "createdAt") Pageable pageable) {
try {
...
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 오류가 발생했습니다"));
}
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<BookResponse>> getBookById(@PathVariable Long id) {
try {
...
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error(e.getMessage()));
}
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<BookResponse>> createBook(@Valid @RequestBody BookRequest request) {
try {
...
} catch (Exception e) {
log.error("도서 생성 중 오류 발생", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 오류가 발생했습니다."));
}
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<BookResponse>> updateBook(
@PathVariable Long id,
@Valid @RequestBody BookRequest request) {
try {
...
} catch (Exception e) {
log.error("도서 수정 중 오류 발생", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 오류가 발생했습니다."));
}
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<Void>> deleteBook(@PathVariable Long id) {
try {
...
} catch (Exception e) {
log.error("도서 삭제 중 오류 발생", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 오류가 발생했습니다."));
}
}
어떻게 Spring스럽게 예외처리를 할 수 있을까?
Spring의 예외 처리 구조

Controller에서 예외가 발생하면 DispatcherServlet에서 HandleExceptionResolver에게 예외 처리를 위임한다.
protected final void addDefaultHandlerExceptionResolvers(
List<HandlerExceptionResolver> exceptionResolvers,
ContentNegotiationManager mvcContentNegotiationManager) {
// 1. ExceptionHandlerExceptionResolver 생성 및 설정
ExceptionHandlerExceptionResolver exceptionHandlerResolver = createExceptionHandlerExceptionResolver();
exceptionHandlerResolver.setContentNegotiationManager(mvcContentNegotiationManager);
exceptionHandlerResolver.setMessageConverters(getMessageConverters());
exceptionHandlerResolver.setCustomArgumentResolvers(getArgumentResolvers());
exceptionHandlerResolver.setCustomReturnValueHandlers(getReturnValueHandlers());
exceptionHandlerResolver.setErrorResponseInterceptors(getErrorResponseInterceptors());
...
// 2. ExceptionHandlerExceptionResolver를 리스트에 추가 (1순위)
exceptionResolvers.add(exceptionHandlerResolver);
// 3. ResponseStatusExceptionResolver 생성 및 추가 (2순위)
ResponseStatusExceptionResolver responseStatusResolver = new ResponseStatusExceptionResolver();
responseStatusResolver.setMessageSource(this.applicationContext);
exceptionResolvers.add(responseStatusResolver);
// 4. DefaultHandlerExceptionResolver 추가 (3순위)
exceptionResolvers.add(new DefaultHandlerExceptionResolver());
}
위 코드는 `WebMvcConfigurationSupport`의 `addDefaultHandlerExceptionResolvers()`함수이다.
HandleExceptionResolver는 다음 순서로 ExceptionResolvers를 탐색한다:
- `ExceptionHandlerExceptionResolver`
- `ResponseStatusExceptionResolver`
- `DefaultHandlerExceptionResolver`
1. `ExceptionHandlerExceptionResolver`
`ExceptionHandlerExceptionResolver`는 예외가 발생한 컨트롤러 내부에 `@ExceptionHandler` 메서드가 있으면 먼저 매칭하고, 그 다음 `@ControllerAdvice`클래스들을 `@Order` / `@Priority` 값 기준으로 오름차순 정렬해서 탐색한다.
💡`@Order`과 `@Priority`의 차이
`@Order`는 Spring 전용, `@Priority`는 표준 애노테이션으로 동일한 용도이지만, 둘 중 하나만 사용해야 하고 둘 다 사용하면 `@Priority`가 무시된다.
`@Order`는 클래스, 메서드, 필드, 애노테이션에 사용이 가능하지만, `@Priority`는 클래스에만 사용 가능하다.
보통 Spring에선 `@Order`를 쓴다고 한다.
위 순서대로 가장 구체적인 예외 타입을 가진 핸들러를 선택해 처리한다.
2. `ResponseStatusExceptionResolver`
`@ResponseStatus`가 붙은 사용자 정의 예외 또는 `ResponseStatusException`을 만났을 때 지정된 HTTP 상태 코드와 reason을 응답한다. 내가 만든 예외에 HTTP 상태를 붙이고 싶을 때 사용한다.
3. `DefaultHandlerExceptionResolver`
Spring MVC 내부에서 발생하는 표준 예외들을 공통 HTTP 상태 코드로 변환한다. 예를 들어 `HttpRequestMethodNotSupportedException`은 405, `NoHandlerFoundException`은 404등으로 변환한다.
@Override
protected @Nullable ModelAndView doResolveException(
HttpServletRequest request, HttpServletResponse response,
@Nullable Object handler, Exception ex) {
try {
// ErrorResponse exceptions that expose HTTP response details
if (ex instanceof ErrorResponse errorResponse) {
ModelAndView mav = null;
if (ex instanceof HttpRequestMethodNotSupportedException theEx) {
mav = handleHttpRequestMethodNotSupported(theEx, request, response, handler);
}
else if (ex instanceof HttpMediaTypeNotSupportedException theEx) {
mav = handleHttpMediaTypeNotSupported(theEx, request, response, handler);
}
else if (ex instanceof HttpMediaTypeNotAcceptableException theEx) {
mav = handleHttpMediaTypeNotAcceptable(theEx, request, response, handler);
}
// else if 문 생략... 다 하드코딩...
return (mav != null ? mav :
handleErrorResponse(errorResponse, request, response, handler));
}
// Other, lower level exceptions
if (ex instanceof ConversionNotSupportedException theEx) {
return handleConversionNotSupported(theEx, request, response, handler);
}
else if (ex instanceof TypeMismatchException theEx) {
return handleTypeMismatch(theEx, request, response, handler);
}
else if (ex instanceof HttpMessageNotReadableException theEx) {
return handleHttpMessageNotReadable(theEx, request, response, handler);
}
// else if 문 생략... 다 하드코딩...
}
catch (Exception handlerEx) {
if (logger.isWarnEnabled()) {
logger.warn("Failure while trying to resolve exception [" + ex.getClass().getName() + "]", handlerEx);
}
}
return null;
}
예외 타입에 따라 하드코딩된 매핑으로 예외를 HTTP 상태로 변환한다.
해결 전략: `@ExceptionHandler` + `@ControllerAdvice`
피드백을 받고 수정한 코드를 먼저 보자.
public BookResponse updateBook(Long id, BookRequest req) {
// 입력값 검증
validator.validateUpdateBookRequest(id, req);
// 기존 도서 조회
Book book = findById(id);
// 비즈니스 규칙 검증
validateUpdateBusinessRules(book, req);
// 카테고리 변경 처리
Category newCat = null;
if (req.getCategoryId() != null && !req.getCategoryId().equals(book.getCategory().getId())) {
newCat = categoryService.findById(req.getCategoryId());
}
// 도서 정보 업데이트
updateFields(book, req, newCat);
// 저장 및 응답
Book saved = bookRepository.save(book);
return new BookResponse(saved);
}
try-catch 지옥을 해결했다.(가독성을 위해 로깅 코드는 빼고 비즈니스 로직만 넣었다.)
@GetMapping
public ApiResponse<PageResponse<BookListResponse>> getAllBooks(
@PageableDefault(size = 10, sort = "createdAt") Pageable pageable) {
return ApiResponse.success(bookService.getAllBooks(pageable));
}
@GetMapping("/{id}")
public ApiResponse<BookResponse> getBookById(@PathVariable Long id) {
return ApiResponse.success(bookService.getBookById(id));
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<BookResponse>> createBook(
@Valid @RequestBody BookRequest req) {
BookResponse res = bookService.createBook(req);
return ResponseEntity.created(URI.create("/api/books/" + res.getId()))
.body(ApiResponse.success("도서가 등록되었습니다.", res));
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ApiResponse<BookResponse> updateBook(
@PathVariable Long id,
@Valid @RequestBody BookRequest req) {
return ApiResponse.success("도서가 수정되었습니다.", bookService.updateBook(id, req));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ApiResponse<Void> deleteBook(@PathVariable Long id) {
bookService.deleteBook(id);
return ApiResponse.success("도서가 삭제되었습니다.", null);
}
Controller 코드 역시 try-catch 지옥에서 빠져나올 수 있었다. Service / Controller는 비즈니스 코드만 남기고 예외를 던지면, 해당 예외 처리를 위해 `ExceptionResolver`는 `GlobalExceptionHandler`에서 해당하는 예외를 찾아 처리한다.
@Slf4j
@RestControllerAdvice // @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {
@ExceptionHandler(BookException.class)
public ResponseEntity<ApiResponse<Void>> handle(BookException e) {
log.warn("[{}] {}", e.getErrorCode().getCode(), e.getMessage());
return ResponseEntity.status(e.getErrorCode().getStatus())
.body(ApiResponse.error(e.getErrorCode()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String,String>>> handleValid(MethodArgumentNotValidException e) {
Map<String,String> map = e.getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
return ResponseEntity.badRequest()
.body(ApiResponse.error("VALID-001", "입력값 오류", map));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleAll(Exception e) {
log.error("Unhandled", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(BookErrorCode.INTERNAL_ERROR));
}
}
@Getter
public class BookException extends RuntimeException {
private final BookErrorCode errorCode;
public BookException(BookErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; }
}
@Getter
@AllArgsConstructor
public enum BookErrorCode {
NOT_FOUND(HttpStatus.NOT_FOUND, "BOOK-001", "도서를 찾을 수 없습니다."),
DUPLICATE(HttpStatus.CONFLICT, "BOOK-002", "중복 도서입니다."),
INVALID_INPUT(HttpStatus.BAD_REQUEST, "BOOK-003", "입력값이 올바르지 않습니다."),
INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "BOOK-004", "재고가 부족합니다."),
INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "GLOBAL-500", "서버 오류");
private final HttpStatus status;
private final String code;
private final String message;
}
try-catch를 지운 것 뿐만 아니라 어떤 장점들이 더 있을까?
먼저 각 layer는 SRP를 지킨다. 기존 Service는 "비즈니스 로직 + 예외 포장", 기존 Controller는 "요청/응답 처리 + 에러 응답 조립"을 했다면, 개선된 Service는 "비즈니스 로직"마나 포함하며 개선된 Controller는 "요청/응답 처리"만 진행한다. 예외 매핑과 로깅은 `GlobalExceptionHandler`에서 수행한다.
SRP를 지키기 때문에 유지보수가 더 쉬워지고 HTTP 상태 코드를 변경할 때도, `BookErrorCode` 한 곳만 수정하면 된다.
기존 코드는 try-catch로 인해 stubbing이 필요했지만, 개선된 코드에선 순수 비즈니스 로직만 테스트하고 `@WebMvcTest`로 `GlobalExceptionHandler`를 한 번에 검증 가능하다.
'Spring' 카테고리의 다른 글
| [Spring] 간단한 DTO ↔ Entity 변환에도 Mapper가 필요할까? (1) | 2025.08.19 |
|---|---|
| [Spring] SSR과 CSR에서 Session 데이터 접근 방식의 차이 (1) | 2025.06.08 |
| [Spring] @Component와 그 친구들(@Controller, @Service, @Repository, @Configuration) (0) | 2025.03.20 |
| [Spring] @Controller vs @RestController (0) | 2024.09.27 |