여태까지 공부했던 내용을 응용하기 위해 기존에 진행했던 프로젝트인 큐터디 프로젝트에 대한 고도화 작업을 시작하게 되었다.
고도화 작업을 위한 첫 번째 관문은 개별 예외 처리를 전역 예외 처리 방식으로 변경하는 것이었다.
이를 위해 Spring에서 제공하는 @RestControllerAdvice를 사용하게 되었으며, 이번 포스팅은 해당 과정을 정리한 글이다.
기존 예외 처리 방식
PostController
@Slf4j
@RequiredArgsConstructor
@RestController
public class PostController {
private final PostsService postsService;
private final AuthService authService;
@PatchMapping("/posts")
public ResponseEntity<PostsResponseDto> patchPost(@RequestParam("postId") Long postId, @RequestHeader(value="Authorization") String token, @RequestBody PostsRequestDto requestDto) {
Long kakao_uid;
try {
kakao_uid = authService.getKakaoUserInfo(token).getId();
if (kakao_uid == null)
return PostsResponseDto.noAuthentication();
} catch (Exception exception) {
log.info(exception.getMessage());
return PostsResponseDto.databaseError();
}
ResponseEntity<PostsResponseDto> response = postsService.patchPost(postId, kakao_uid, requestDto);
return response;
}
}
- 위 코드는 PostController에서 게시물을 수정하는 메서드이다.
- 요청 헤더로 로그인한 사용자의 토큰을 받고, 게시물 수정을 위한 DTO 객체를 받아 사용자의 권한을 확인한다.
- 이 과정에서 권한이 없는 경우 권한 오류 메시지와 에러 코드를 반환하고, 다른 예외가 발생하면 데이터베이스 오류를 반환하도록 try-catch문을 이용해 예외 처리를 진행했다. 최종적으로 예외가 발생하지 않으면 게시물을 수정하고 응답을 리턴한다.
PostService
@Slf4j
@RequiredArgsConstructor
@Service
public class PostsService {
// 생략
@Transactional
public ResponseEntity<? super PostsResponseDto> patchPost(Long postId, Long kakao_uid, PostsRequestDto dto) {
try {
Optional<Posts> postOptional = postsRepo.findById(postId);
if (!postOptional.isPresent()) return PostsResponseDto.notExistedPost();
Posts post = postOptional.get();
if (userRepo.findByKakaoId(kakao_uid) == null) return PostsResponseDto.notExistUser();
Long writerId = post.getKakaoId();
if (!writerId.equals(kakao_uid)) return PostsResponseDto.noPermission();
// 생략
String summary = summaryService.summary(dto.getContent());
post.patchPost(dto, summary);
postsRepo.save(post);
} catch (Exception exception) {
exception.printStackTrace();
return ResponseDto.databaseError();
}
return PostsResponseDto.success(postId);
}
}
- PostService에서도 게시물을 수정하기 전, 해당 사용자가 데이터베이스에 존재하지 않거나 기타 예외가 발생하는 경우 try-catch문으로 예외 처리를 하고 있다.
- 이처럼 try-catch문을 반복적으로 사용하게 되면 코드의 가독성이 저하되며, 코드 유지보수도 어려워진다. 또한, 각 서비스와 컨트롤러마다 개별적으로 예외 처리를 해야 하므로, 중복된 코드가 많아져 비효율적이다.
@RestControllerAdvice
- RestControllerAdvice는 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용해 준다.
- RestControllerAdvice 어노테이션이 붙은 클래스는 스프링 빈으로 등록되며, 우리는 전역적으로 에러를 핸들링하는 클래스를 만들어 어노테이션을 붙여줌으로써 에러 처리를 위임할 수 있다.
이점
- 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능함
- 직접 정의한 에러 응답을 일관성 있게 클라이언트에게 내려줄 수 있음
- 별도의 try-catch문이 없어 코드의 가독성이 높아짐
→ @RestControllerAdvice를 사용함으로써 기존 코드에 있던 try-catch문을 없애고, 하나의 클래스로 직접 정의한 에러 응답을 전역적으로 일관성 있게 내려줌으로써 코드의 가독성을 높일 수 있도록 하였다.
@RestControllerAdvice를 이용한 예외 처리 방법
에러 코드 정의
public interface ErrorCode {
String name();
HttpStatus getHttpStatus();
String getMessage();
}
- 클라이언트에게 보내줄 에러 코드를 정의한다.
- 에러 이름과 HTTP 상태 및 메시지를 가지고 있는 에러 코드 클래스를 만들기 위해 ErrorCode 인터페이스를 정의해 주었다.
- ErrorCode의 구현체로 애플리케이션에서 전역적으로 사용되는 CommonErrorCode와 특정 도메인에 대해 구체적으로 발생하는 기타 클래스들을 정의한다.
@Getter
@RequiredArgsConstructor
public enum CommonErrorCode implements ErrorCode{
INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid parameter included"),
RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "Resource not exists"),
DUPLICATED_VALUE(HttpStatus.BAD_REQUEST, "Value already exists"),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"),
NO_PERMISSION(HttpStatus.FORBIDDEN, "Do not have permission"),
;
private final HttpStatus httpStatus;
private final String message;
}
@Getter
@RequiredArgsConstructor
public enum PostErrorCode implements ErrorCode {
NOT_EXISTED_POST(HttpStatus.BAD_REQUEST, "This post does not exist");
private final HttpStatus httpStatus;
private final String message;
}
예외 클래스 정의하기
- 에러 코드를 정의했다면 발생한 예외를 처리해 줄 예외 클래스를 추가해야 한다.
- RuntimeException을 상속받는 예외 클래스를 다음과 같이 추가해 주었다.
@Getter
@RequiredArgsConstructor
public class CommonException extends RuntimeException {
private final ErrorCode errorCode;
}
@Getter
@RequiredArgsConstructor
public class PosttException extends RuntimeException {
private final ErrorCode errorCode;
}
예외 응답 클래스 생성하기
- 클라이언트로 다음과 같은 형식의 에러를 던져주어야 한다고 가정해 보자.
{
"code": "NOT_EXISTED_POST",
"message": "This post does not exist"
}
@Getter
@Builder
@RequiredArgsConstructor
public class ErrorResponse {
private final String code;
private final String message;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private final List<ValidationErrorResponse> errors;
public static ErrorResponse of(ErrorCode errorCode) {
return ErrorResponse.builder()
.code(errorCode.name())
.message(errorCode.getMessage())
.build();
}
public static ErrorResponse of(ErrorCode errorCode, String message) {
return ErrorResponse.builder()
.code(errorCode.name())
.message(message)
.build();
}
public static ErrorResponse of(List<ValidationErrorResponse> validationErrorResponses, ErrorCode errorCode) {
return ErrorResponse.builder()
.code(errorCode.name())
.errors(validationErrorResponses)
.message(errorCode.getMessage())
.build();
}
}
@Getter
@Builder
@RequiredArgsConstructor
public class ValidationErrorResponse {
private final String field;
private final String message;
public static ValidationErrorResponse of(FieldError fieldError) {
return ValidationErrorResponse.builder()
.field(fieldError.getField())
.message(fieldError.getDefaultMessage())
.build();
}
}
- 에러 응답을 위한 code, message 필드를 두고 @Valid 필드에서 어느 에러가 발생했는지 응답을 위한 ValidationErrorResponse 리스트를 필드로 갖는다. 만약 errors가 없다면 응답으로 내려가지 않도록 @JsonInclude 어노테이션을 추가하였다.
@RestControllerAdvice 구현
- 전역적으로 에러를 처리해 주는 @RestControllerAdvice를 구현하기 위해 GlobalExceptionHandler 클래스를 추가하고, ResponseEntityExceptionHandler 클래스를 상속받도록 하였다.
- 우리가 이전에 만들었던 CommonException, PostException 예외와 @Valid에 의한 유효성 검증에 실패했을 때 발생하는 MethodArgumentNotValidException 예외와 잘못된 파라미터를 넘겼을 경우 발생하는 IllegalArgumentException 에러를 처리해 주도록 하였다.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { // 스프링 예외를 미리 처리해둔 ResponseEntityExceptionHandler 상속
@ExceptionHandler(CommonException.class)
public ResponseEntity<Object> handleCommonException(CommonException e) {
ErrorCode errorCode = e.getErrorCode();
return handleExceptionInternal(errorCode);
}
@ExceptionHandler(PostException.class)
public ResponseEntity<Object> handleUserException(PostException e) {
ErrorCode errorCode = e.getErrorCode();
return handleExceptionInternal(errorCode);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Object> handleIllegalArgument(IllegalArgumentException e) { // 잘못된 파라미터에 의한 예외
log.warn("handleIllegalArgument", e);
ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;
return handleExceptionInternal(errorCode, e.getMessage());
}
@Override
public ResponseEntity<Object> handleMethodArgumentNotValid( // @Valid에 의한 유효성 검증 실패 예외
MethodArgumentNotValidException e,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
log.warn("handleIllegalArgument", e);
ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;
return handleExceptionInternal(e, errorCode);
}
@ExceptionHandler({Exception.class})
public ResponseEntity<Object> handleAllException(Exception e) {
log.warn("handleAllException", e);
ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR;
return handleExceptionInternal(errorCode);
}
private ResponseEntity<Object> handleExceptionInternal(ErrorCode errorCode) {
return ResponseEntity.status(errorCode.getHttpStatus())
.body(ErrorResponse.of(errorCode));
}
private ResponseEntity<Object> handleExceptionInternal(ErrorCode errorCode, String message) {
return ResponseEntity.status(errorCode.getHttpStatus())
.body(ErrorResponse.of(errorCode, message));
}
private ResponseEntity<Object> handleExceptionInternal(BindException e, ErrorCode errorCode) {
List<ValidationErrorResponse> validationErrorResponses = e.getBindingResult()
.getFieldErrors()
.stream()
.map(ValidationErrorResponse::of)
.collect(toList());
return ResponseEntity.status(errorCode.getHttpStatus())
.body(ErrorResponse.of(validationErrorResponses, errorCode));
}
}
에러 응답 확인
- 실제로 PostController와 PostService를 수정해 우리가 원하는 대로 에러 응답이 내려오는지 확인해 보자.
@Slf4j
@RequiredArgsConstructor
@RestController
public class PostController {
private final PostsService postsService;
private final AuthService authService;
@PostMapping("/posts")
public ResponseEntity<PostsResponseDto> save(@RequestHeader(value="Authorization") String token, @RequestBody PostsRequestDto requestDto) {
Long kakao_uid = authService.getKakaoUserInfo(token).getId();
return postsService.savePost(kakao_uid, requestDto);
}
}
@Slf4j
@RequiredArgsConstructor
@Service
public class PostsService {
// 생략
@Transactional
public ResponseEntity<PostsResponseDto> patchPost(Long postId, Long kakao_uid, PostsRequestDto dto) {
Posts post = postsRepo.findById(postId).orElseThrow(() -> new PostException(PostErrorCode.NOT_EXISTED_POST));
if (userRepo.findByKakaoId(kakao_uid) == null) {
throw new UserException(UserErrorCode.NOT_EXISTED_USER);
}
if (!post.getUser().getKakaoId().equals(kakao_uid)) {
throw new CommonException(CommonErrorCode.NO_PERMISSION);
}
// 생략
String summary = summaryService.summary(dto.getContent());
post.patchPost(dto, summary);
postsRepo.save(post);
return PostsResponseDto.success(postId);
}
}
- 이전 코드와 비교해 봤을 때, 불필요한 try-catch문 제거를 통해 가독성이 향상된 모습을 알 수 있다.
- 실제 postman을 사용해 존재하지 않는 게시글에 대한 수정을 시도해 보고, 결과를 확인해 보자.
- 요청 결과 원하는 결과대로 에러에 대한 응답이 내려오는 것을 확인할 수 있다.
정리
- 기존의 개별 예외 처리 방식을 전역 예외 처리 방식으로 변경하였다.
- @RestControllerAdvice를 사용하여 공통된 예외 처리 로직을 구현하였다.
- 특정 예외(IllegalArgumentException 등)에 대한 커스텀 에러 메시지 반환을 추가하였다.
- 코드 가독성과 유지보수성을 높이기 위해 try-catch 구문 제거 및 전역 예외 핸들러를 사용하였다.
참고
[Spring] @RestControllerAdvice를 이용한 Spring 예외 처리 방법 - (2/2)
예외 처리는 robust한 애플리케이션을 만드는데 매우 중요한 부분을 차지한다. Spring 프레임워크는 매우 다양한 에러 처리 방법을 제공하는데, 앞선 포스팅에서 @RestControllerAdvice를 사용해야 하는
mangkyu.tistory.com
'Spring' 카테고리의 다른 글
[Spring] 동시성 문제를 해결하는 방법 (0) | 2024.09.19 |
---|---|
[Spring JPA] 연관관계 매핑 (4) (0) | 2024.05.16 |
[Spring JPA] 객체와 테이블 매핑 (3) (0) | 2024.05.13 |
[Spring JPA] 영속성 컨텍스트 (2) (0) | 2024.05.12 |
[Spring JPA] JPA 소개 (1) (0) | 2024.05.12 |