[Spring Boot] 예외 처리 방법
프로젝트를 진행하던 중 예외처리를 해야되는 상황이 놓였다. 각 상황별로 에러코드, 메세지를 전부 다루어야 했다. 좀 효율적인 설계를 위해 자바에서 하는 예외처리 방법을 살펴봤다.
1. Controller 레벨 예외 처리 (@ExceptionHandler)
특정 컨트롤러에서 발생하는 예외를 개별적으로 처리하는 방법입니다.
@RestController
public class MyController {
@GetMapping("/example")
public String example() {
throw new IllegalArgumentException("잘못된 요청입니다.");
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
예전에 내가 이렇게 한 기억이 있다. 이렇게 하면 단점은 Controller코드가 더러워지고 한번에 관리하기 어려운 점이 있다.
2. 글로벌 예외 처리 (@ControllerAdvice)
애플리케이션 전역에서 발생하는 예외를 공통적으로 처리하는 방법입니다. 여러 컨트롤러에서 발생하는 예외를 하나의 클래스에서 처리할 수 있습니다.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException e) {
return ResponseEntity.badRequest().body("잘못된 요청입니다: " + e.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleException(Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("서버 오류: " + e.getMessage());
}
}
@ControllerAdvice는 전역에서 예외를 처리할 수 있도록 도와주는 방식. 모든 컨트롤러에서 에러가 난 것을 IllegalArgumentException과 Exception을 각각 다른 방식으로 처리할 수 있습니다. 편하지만 상세하게 에러를 알려줄 수 없는 점에서 내가 원하는 코드가 아니라고 생각했다.
다른 사람들의 코드를 참고해 공부하니 거의 자바에서 예외처리를 계층구조로 나눠 처리하고 있었다. 생각해보니 계층구조로 하면 여러 장점이 있었다. 예외처리 순서는 아래와 같다.
- 구체적인 하위 예외를 먼저 처리:
- 예외 처리 핸들러 메서드에서 구체적인 하위 예외(예: FileNotFoundException, IOException, MethodArgumentNotValidException 등)를 먼저 처리해야 합니다.
- 이를 통해 특정 예외에 대한 맞춤형 응답이나 로직을 실행할 수 있습니다.
- 상위 예외 처리:
- 그 다음에 일반적인 상위 예외를 처리합니다. 예를 들어, 모든 예외를 포괄하는 Exception 클래스나 RuntimeException 같은 상위 클래스 예외는 마지막에 처리해야 합니다.
- 이 경우 구체적인 예외가 처리되지 않으면, 상위 예외 핸들러가 호출되어 기본적인 오류 응답을 반환하게 됩니다.
정리하면 크게 나누고 그 아래 상세한 에러를 좀 더 알려줄 수 있는 방법이다.
내가 본 많은 프로젝트에서 스프링에서의 예외처리는 위 2가지 방법을 섞어서 사용했다.
우선 다양하고 유연한 예외처리를 위해 상위 예외처리(ex: RuntimeException (이걸 받는 이유는 unchecked, checked))에서 상속받아 새로운 클래스 생성 ->
RestControllerAdvice에서 전역예외처리를 진행할때 상속받아 새로운 클래스로 예외처리 던지기 ->
각각의 도메인에 예외 클래스를 생성 후 새로운 클래스를 상속받아 자세한 예외 생성 ->
서비스계층에서 도메인예외로 throw
이와 같은 형태로 정리할 수 있을 것 같다.
public abstract class BaseException extends RuntimeException {
public BaseException(){
}
public abstract BaseExceptionType exceptionType();
}
런타임 상속
public class ExceptionControllerAdvice {
@ExceptionHandler(BaseException.class)
ResponseEntity<ExceptionResponse> handleException(HttpServletRequest request, BaseException e) {
BaseExceptionType type = e.exceptionType();
log.info("잘못된 요청이 들어왔습니다. URI: {}, 내용: {}", request.getRequestURI(), type.errorMessage());
return ResponseEntity.status(type.httpStatus())
.body(new ExceptionResponse(type.errorMessage()));
}
@ExceptionHandler(MissingServletRequestParameterException.class)
ResponseEntity<ExceptionResponse> handleMissingParams(MissingServletRequestParameterException e) {
String errorMessage = e.getParameterName() + " 값이 누락 되었습니다.";
log.info("잘못된 요청이 들어왔습니다. 내용: {}", errorMessage);
return ResponseEntity.status(BAD_REQUEST)
.body(new ExceptionResponse(errorMessage));
}
@ExceptionHandler(Exception.class)
ResponseEntity<ExceptionResponse> handleException(HttpServletRequest request, Exception e) {
log.error("예상하지 못한 예외가 발생했습니다. URI: {}, ", request.getRequestURI(), e);
return ResponseEntity.internalServerError()
.body(new ExceptionResponse("알 수 없는 오류가 발생했습니다."));
}
}
전역처리
@RequiredArgsConstructor
public class DepartmentException extends BaseException {
private final DepartmentExceptionType exceptionType;
@Override
public BaseExceptionType exceptionType() {
return exceptionType;
}
}
도메인 별 예외 생성
@RequiredArgsConstructor
public enum DepartmentExceptionType implements BaseExceptionType {
NOT_FOUND_DEPARTMENT(NOT_FOUND, "부서을 찾을 수 없습니다"),
;
private final HttpStatus httpStatus;
private final String errorMessage;
@Override
public HttpStatus httpStatus() {
return httpStatus;
}
@Override
public String errorMessage() {
return errorMessage;
}
}
enum으로 상세 메세지 제공
private Department findDepartmentById(Long departmentId) {
return departmentRepository.findById(departmentId)
.orElseThrow(() -> new DepartmentException(NOT_FOUND_DEPARTMENT));
}
서비스 로직 적용