안녕하세요!
Spring Boot 기반 API를 개발하고 있는 쩡이입니다.
오늘은 제가 실제로 고민한 Spring API 예외 처리 구조 리팩토링 경험을 공유해보려고 해요.
강의 자료와 클린 코드 책을 바탕으로 더 스프링답고 유지보수 쉬운 구조로 개선해보았습니다. 😊
이번 글에서는 Spring 예외 처리의 기초 개념과 전역 처리 구조에 대해 설명합니다. 클린 코드 관점에서 이를 어떻게 리팩토링했는지는 다음 포스트에서 이어서 다룰 예정입니다! :)
📘 Part 1. 서블릿 기반 예외 처리와 오류 페이지
스프링은 기본적으로 서블릿 스펙을 기반으로 예외를 처리합니다.
처음엔 아래 두 방식으로 예외를 던질 수 있죠.
throw new RuntimeException("에러!");
response.sendError(500, "에러 발생");
🔥 문제는?
예외가 톰캣(WAS)까지 도달하면 Whitelabel Error Page 같은 HTML이 그대로 노출됩니다.
이는 사용자 입장에서 보안적으로도, UX 측면에서도 바람직하지 않죠.
💡 사용자 정의 오류 페이지 등록
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
factory.addErrorPages(
new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404"),
new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500")
);
}
}
따라서 위처럼 사용자 정의 오류 페이지를 등록하고 오류 페이지 컨트롤러를 작성합니다.
🚫 필터/인터셉터 주의점
WAS → Servlet → Controller → 예외 → DispatcherServlet → WAS 오류 페이지 (500.html 등)
이때,WAS에서 에러를 받으면 다시 해당 페이지를 호출하는 방식이랍니다. 그렇기때문에, 결국 오류 요청이 비효율적으로 한 번 더 처리되지 않도록, Interceptor에서는 excludePathPatterns("/error-page/**")로 오류 경로를 제외해주고, Filter 에서는 DispatcherType.ERROR를 명확히 설정해줘야 합니다.
📗 Part 2. API 환경에서는 어떻게 해야 할까?
HTML과 달리 API는 JSON 응답 + 도메인 별 세심한 응답 로직이 필수입니다.
1.오류 페이지에서 JSON 반환
✅ produces = "application/json" 설정으로 Accept 헤더에 따라 JSON 응답이 가능합니다.
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request) {
Exception ex = (Exception) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
Map<String, Object> result = Map.of(
"status", 500,
"message", ex.getMessage()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
2. HandlerExceptionResolver?
Spring은 예외를 잡아 다른 방식으로 처리할 수 있는 ExceptionResolver를 제공합니다.하지만, JSON을 직접 쓰거나 상태 코드 처리에 번거로움이 있죠.
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
public ModelAndView resolveException(...) {
if (ex instanceof IllegalArgumentException) {
response.sendError(400, "잘못된 요청");
return new ModelAndView(); // 예외를 정상 흐름으로 바꿈!
}
return null; // 다음 Resolver로 넘어감
}
}
3. 실무에서 가장 많이 쓰는 방식: @ExceptionHandler
따라서 @ExceptionHandler 방식을 보통 실무에서 많이 쓰는데요,컨트롤러 내부에서 예외마다 깔끔하게 처리 가능하고 JSON 응답도 자동으로 구성됩니다!
모든 컨트롤러에 적용하고 싶다면? → @RestControllerAdvice를 사용할 수도 있습니다! 이제는 어떤 예외든 전역에서 처리 가능하고 응답 형식도 통일되고, 중복된 try-catch도 사라집니다.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
return new ErrorResult("BAD", e.getMessage());
}
HTML 오류 (웹 화면) | ErrorPage, BasicErrorController |
API 오류 (JSON) | @ExceptionHandler, @ControllerAdvice |
예외 상태 코드 변경 | @ResponseStatus, ExceptionResolver |
공통 예외 처리 | @RestControllerAdvice 사용 |
❗ 문제의 시작: 전통적인 예외 처리
처음에 우리 프로젝트는 아래처럼 예외 처리를 하고 있었어요.
참고로 일단 개판으로 짜고 리팩토링하는 과정을 보이려고 고의적으로 더럽게 짠게 맞습니다...
응답API도 일단 기본으로 해놨고 추후 리팩토링하려고 합니다 애교로 봐주세용
// controller.java
@PutMapping("/{id}")
public ResponseEntity<?> updateUser(@PathVariable Long id, @RequestBody User updatedUser) {
try {
User user = userService.updateUser(id, updatedUser);
return ResponseEntity.ok(user);
} catch (RuntimeException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("User not found");
}
}
// service.java
@Transactional
public User updateUser(Long id, User updatedUser) {
return userRepository.findById(id)
.map(user -> {
user.setUsername(updatedUser.getUsername());
...
return userRepository.save(user);
}).orElseThrow(() -> new RuntimeException("User not found"));
}
✔️ 작동은 합니다. 하지만 시간이 지날수록...
- 모든 컨트롤러에 try-catch가 반복되고,
- 응답 포맷도 여기저기 흩어지고,
- 공통 예외 처리가 어렵고,
- 실수로 catch를 빠뜨리는 경우도 생기더군요 😓
📖 교안에서 배운 것: Spring의 예외 처리 메커니즘
공부했던 [스프링 MVC 2 강의 교안]과 [자바 예외처리] 부분을 찾아보면서 다음과 같은 개념들을 다시금 익혔어요.
- 예외는 @RestControllerAdvice로 전역 처리할 수 있다.
- @ExceptionHandler를 이용하면 예외마다 다른 응답을 만들 수 있다.
- 예외는 체크 예외(Exception)보다 언체크 예외(RuntimeException)가 스프링과 더 잘 어울린다.
- 실패/성공 여부를 HTTP 상태 코드와 응답 메시지로 분리해 표현할 수 있다.
🔁 그래서 이렇게 리팩토링했습니다
1️⃣ 예외 클래스는 RuntimeException 상속
스프링 프레임워크는 언체크 예외(RuntimeException 계열)를 중심으로 설계되어 있습니다. 따라서 예외 처리 흐름을 자연스럽게 연결하려면 RuntimeException을 상속하는 것이 더 일관되고 효율적입니다.
@Getter
public class BaseException extends RuntimeException {
private final BaseResponseStatus status;
private final String customMessage;
...
}
2️⃣ 응답 DTO 구조 유지 + 커스텀 메시지 지원
@Getter
@AllArgsConstructor
@JsonPropertyOrder({"isSuccess", "code", "message", "result"})
public class BaseResponse<T> {
private final Boolean isSuccess;
private final String message;
private final int code;
@JsonInclude(JsonInclude.Include.NON_NULL)
private T result;
public BaseResponse(BaseResponseStatus status, T result) {
this(status.isSuccess(), status.getMessage(), status.getCode(), result);
}
}
3️⃣ BaseResponseStatus로 상태 관리 통합
이전에는 응답에서 상태코드를 따로 처리했지만 이제 상태코드, 메시지, HTTP 상태까지 하나의 Enum에서 관리합니다.
@Getter
public enum BaseResponseStatus {
SUCCESS(true, 1000, "요청에 성공하였습니다.", HttpStatus.OK),
FORBIDDEN(false, 2003, "권한이 없는 유저입니다.", HttpStatus.FORBIDDEN),
ERROR(false, 2000, "서버 오류 발생", HttpStatus.INTERNAL_SERVER_ERROR);
...
}
4️⃣ 전역 예외 핸들러는 모든 걸 커버
예외가 발생하면 던지기만 하면 됩니다. 핸들러에서 모든 응답을 처리해주니까요!
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BaseException.class)
public ResponseEntity<BaseResponse<Object>> handleBase(BaseException ex) {
return ResponseEntity
.status(ex.getStatus().getHttpStatus())
.body(new BaseResponse<>(ex.getStatus()));
}
}
✨수정 후
// controller.java
@PutMapping("/{id}")
public ResponseEntity<BaseResponse<User>> updateUser(
@PathVariable Long id, @RequestBody User updatedUser) {
User user = userService.updateUser(id, updatedUser);
return ResponseEntity.ok(new BaseResponse<>(user));
}
// service.java
@Transactional
public User updateUser(Long id, User updatedUser) {
return userRepository.findById(id)
.map(user -> {
user.setUsername(updatedUser.getUsername());
...
return userRepository.save(user);
}).orElseThrow(() -> new BaseException(BaseResponseStatus.USER_NOT_FOUND));
}
// BaseResponseStatus.java
//USER_NOT_FOUND(false, 2025, "사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
이제 수정 후입니다. 예외 발생 시 컨트롤러 내부에서 핵심 로직만 유지하고, 처리 책임은 @ExceptionHandler로 위임합니다.
따라서 리팩토링된 코드에서는 예외가 발생해도 컨트롤러는 핵심 비즈니스 로직에만 집중할 수 있습니다.
예외 처리는 더 이상 try-catch로 직접 감싸지 않고, 예외를 throw만 해주면 됩니다.
그 이후의 예외 응답 생성과 HTTP 상태 코드 지정은 모두 @ExceptionHandler를 통해 전역 예외 핸들러가 일괄 처리하게 됩니다.
덕분에 컨트롤러는 로직 처리에만 집중하고, 예외는 자동으로 BaseResponse 형식으로 감싸져 클라이언트에 일관된 형태로 전달됩니다.
이는 코드의 단일 책임 원칙(SRP)을 잘 지키면서도, 예외 응답의 일관성과 유지보수성을 동시에 확보할 수 있는 구조입니다!
이제 클린코드에 맞춰 더 개선해봐야겠죠? 다음글은 클린코드 글로 뵙겠습니다~!
'개발프로젝트' 카테고리의 다른 글
[CleanCode] 실제 프로젝트에서 Clean Code 원칙을 적용한 "좋은 경계" (0) | 2025.06.02 |
---|---|
[CleanCode] 실제 프로젝트에서 Clean Code 원칙을 적용한 예외 처리 리팩토링 경험기 (1) | 2025.05.26 |
[CleanCode] 형식 맞추기 – 형식 규칙에 대한 깊은 고찰,클린코드를 읽고... (1) | 2025.05.22 |
[CleanCode] 주석은 변명이 아니다 – 네이버 쇼핑 API 응용 코드로 알아보는 클린 코드 (3) | 2025.05.19 |
[CleanCode] 함수 – 클린코드 원칙대로 리뷰 기능 함수를 리팩토링 해보자 (1) | 2025.05.19 |