기존에 진행하는 프로젝트에서는
Spring Boot로 REST API를 구현하면서 Service들에서 발생하는 여러 예외를
Controller의 메서드들에서 try/catch 문을 만들어 처리해 줬습니다
이렇게 만들어두니 Controller는 복잡해지고 중복되는 코드들이 생겼으며,
예외가 발생될 때마다 해당 예외에 대한 자세한 메시지를 작성해주어야 해서 관리가 어려웠습니다.
이렇게 예외에 따른 처리를 Spring Boot에서 @RestControllerAdvice를 활용해 처리해 주는 방법을 정리해보고자 합니다!
기존 코드
@RestController
@RequestMapping("/api/v1/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
private final UserService userService;
@GetMapping
public ResponseEntity<?> findPostList(Pageable pageable) {
Page<PostCoverResponseDTO> response;
try {
response = postService.findList(pageable);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
} catch(Exception e){
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
return ResponseEntity.ok(response);
}
@GetMapping("/{postId}")
public ResponseEntity<?> findPostDetail(@PathVariable Long postId) {
PostDetailResponseDTO response;
try {
response = postService.findDetail(postId);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
} catch(Exception e){
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
return ResponseEntity.ok(response);
}
//...
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostServiceImpl implements PostService {
private final PostRepository postRepository;
@Override
public Page<PostCoverResponseDTO> findList(Pageable pageable) {
Page<PostCoverResponseDTO> postCoverPage = postRepository.findPostCoverPage(pageable);
if (postCoverPage.isEmpty()) {
throw new IllegalArgumentException("해당 페이지의 게시글이 존재하지 않습니다.");
}
return postCoverPage;
}
@Override
public PostDetailResponseDTO findDetail(Long postId) {
Post post = postRepository.findById(postId).orElseThrow(() ->
new IllegalArgumentException("해당 게시글이 존재하지 않습니다."));
post.addViewCount();
return PostDetailResponseDTO.builder()
.post(post).build();
}
// ...
}
간단하게 이러한 컨트롤러를 구현해 봤습니다.
저는 이번 글을 통해 중복되는 try/catch 문을 @RestControllerAdvice를 활용하여
모든 컨트롤러에서 발생하는 예외를 정의해 두고 그에 알맞은 응답을 줄 수 있도록 기존의 코드를 수정해 보겠습니다.
발생하는 예외들 커스텀하여 정의하기
기존 Service에서 발생하는 예외는 HTTP 요청 값으로 DB에서 조회를 했을 때 데이터가 찾아지지 않을 때 IllegalArgumentException을 발생시켰습니다.
또한 메시지에 자세하게 어떤 이유로 발생된 예외인지를 알려주었습니다.
컨트롤러 하위 단에서 예외가 발생했을 때,
기존에 정의한 예외를 발생시킴으로써 일관성 있는 메시지를 보내보도록 하겠습니다.
다음과 같이 예외 코드와 메세지를 함께 enum 타입으로 구현했습니다.
public interface ExceptionCode {
String name();
HttpStatus getHttpStatus();
String getMessage();
}
@Getter
@RequiredArgsConstructor
public enum CustomExceptionCode implements ExceptionCode {
PAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당되는 페이지를 찾을 수 없습니다."),
POST_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"),
;
private final int httpStatus;
private final String message;
}
이제 예외가 발생되었을 때 처리해 줄 RuntimeException 클래스를 상속받은 예외 클래스를 추가합시다.
( RuntimeException을 추가하여 Spring이 롤백되도록 합니다. )
@Getter
@RequiredArgsConstructor
public class RestApiException extends RuntimeException {
private final ExceptionCode exceptionCode;
}
또한, 이러한 예외에 따라서 컨트롤러에서 응답되는 Body 포맷에 맞게 예외 응답 클래스를 정의해 줍시다.
@Getter
@Builder
@RequiredArgsConstructor
public class ExceptionResponse {
private final String code;
private final String message;
}
@RestControllerAdvice
위에서 준비된 클래스들을 사용해서 컨트롤러에서 발생되는 예외들을 처리해 주는 클래스를 만들어줍시다.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(RestApiException.class)
public ResponseEntity<?> handleCustomException(RestApiException e) {
ErrorCode errorCode = e.getErrorCode();
return handleExceptionInternal(errorCode);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<?> handleIllegalArgument(IllegalArgumentException e) {
log.warn("handleIllegalArgument", e);
ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;
return handleExceptionInternal(errorCode, e.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleAllException(Exception ex) {
log.warn("handleAllException", ex);
ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR;
return handleExceptionInternal(errorCode);
}
private ResponseEntity<?> handleExceptionInternal(ErrorCode errorCode) {
return ResponseEntity.status(errorCode.getHttpStatus())
.body(makeErrorResponse(errorCode));
}
private ErrorResponse makeErrorResponse(ErrorCode errorCode) {
return ErrorResponse.builder()
.code(errorCode.name())
.message(errorCode.getMessage())
.build();
}
private ResponseEntity<?> handleExceptionInternal(ErrorCode errorCode, String message) {
return ResponseEntity.status(errorCode.getHttpStatus())
.body(makeErrorResponse(errorCode, message));
}
private ErrorResponse makeErrorResponse(ErrorCode errorCode, String message) {
return ErrorResponse.builder()
.code(errorCode.name())
.message(message)
.build();
}
}
커스텀 된 예외가 발생되었을 때 RestApiException 클래스를 통해 위 클래스의 handleCustomException 메서드에서 응답객체를 생성하여 처리합니다.
이외에도 IllegalArgumentException과 Exception 등이 발생했을 때 또한 처리될 수 있도록 추가할 수 있습니다.
기존 코드에 적용
이제 기존 코드를 수정해서 예외를 처리할 수 있도록 해보겠습니다.
@RestController
@RequestMapping("/api/v1/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
private final UserService userService;
@GetMapping
public ResponseEntity<?> findPostList(Pageable pageable) {
Page<PostCoverResponseDTO> response;
response = postService.findList(pageable);
return ResponseEntity.ok(response);
}
@GetMapping("/{postId}")
public ResponseEntity<?> findPostDetail(@PathVariable Long postId) {
PostDetailResponseDTO response;
response = postService.findDetail(postId);
return ResponseEntity.ok(response);
}
//...
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostServiceImpl implements PostService {
private final PostRepository postRepository;
@Override
public Page<PostCoverResponseDTO> findList(Pageable pageable) {
Page<PostCoverResponseDTO> postCoverPage = postRepository.findPostCoverPage(pageable);
if (postCoverPage.isEmpty()) {
throw new RestApiException(CustomExceptionCode.PAGE_NOT_FOUND);
}
return postCoverPage;
}
@Override
public PostDetailResponseDTO findDetail(Long postId) {
Post post = postRepository.findById(postId).orElseThrow(() ->
new RestApiException(CustomExceptionCode.POST_NOT_FOUND));
post.addViewCount();
return PostDetailResponseDTO.builder()
.post(post).build();
}
// ...
}
이렇게 try/catch 문을 없애면서 중복된 코드들이 사라졌고,
모든 컨트롤러에서 발생되는 예외들을 하나의 클래스를 통해 관리할 수 있게 변경했습니다.
예외 처리에 관한 기능들을 모두 분리하여 각각의 역할에 맞는 클래스로 구현하면서 관리가 훨씬 용이해졌습니다!
Reference
https://mangkyu.tistory.com/204
https://mangkyu.tistory.com/205
'WEB > Spring' 카테고리의 다른 글
단위 테스트 - Repository (feat. 테스트 픽스쳐) (0) | 2024.08.27 |
---|---|
Service의 메서드들이 중복해서 사용하는 로직을 테스트하기 (0) | 2024.08.20 |
Spring Security 프레임워크로 로그인에 성공하면 JWT 발급하기 (0) | 2024.04.17 |
Spring Security 프레임워크로 OAuth2 로그인 (0) | 2024.04.05 |
Spring Security가 뭐죠? (0) | 2024.03.24 |