Spring

[Spring] @RestControllerAdvice을 사용해 기존의 예외 처리 방식 변경하기

아윤_ 2024. 10. 19. 23:20

여태까지 공부했던 내용을 응용하기 위해 기존에 진행했던 프로젝트인 큐터디 프로젝트에 대한 고도화 작업을 시작하게 되었다.

고도화 작업을 위한 첫 번째 관문은 개별 예외 처리를 전역 예외 처리 방식으로 변경하는 것이었다.

이를 위해 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