[MindDiary] 이슈 3. 예외처리 전략

Dayeon myeong·2021년 8월 26일
1

Mind Diary

목록 보기
3/10

API 처리 중 실패 상황에 대해 어떻게 처리할 것인가 고민이 있었습니다.

예를 들어,

  • DB 에러가 발생할 경우
  • API request 시 필요한 body 데이터가 오지 않았을 경우
  • JWT토큰이 만료한 경우
  • 회원 가입 시 중복 닉네임인 유저의 경우 등등

이런 여러 상황이 있고, 각 상황에 맞춰 클라이언트에게 응답을 해줘야 클라이언트는 무엇때문에 요청이 실패했는지 알 수 있겠죠.

변경 전 코드

// 컨트롤러 코드
 @PostMapping("/join")
  public ResponseEntity join(@RequestBody @Valid UserJoinRequestDTO userJoinRequestDTO) {
    boolean flag = userService.join(userJoinRequestDTO);
    if (!flag) {
      return new ResponseEntity(HttpStatus.CONFLICT);
    }
    return new ResponseEntity(HttpStatus.OK);
  }

// 서비스 코드

  @Override
  public boolean join(UserJoinRequestDTO userJoinRequestDTO) {

    if (isEmailDuplicate(userJoinRequestDTO.getEmail())) {
      return false; // 이메일이 중복될 경우 false 반환
    }

    if (isNicknameDuplicate(userJoinRequestDTO.getNickname())) {
      return false; // 닉네임이 중복될 경우 false 반환
    }

    ...
    return true;
  }

이 코드는 회원가입에 대한 초기 코드입니다. 서비스 클래스에서 boolean 값을 리턴하여 이 boolean 값을 통해 컨트롤러에서는 실패 상황을 체크합니다.
(이외에도 null 값을 리턴하여 컨트롤러에서 체크하는 코드도 있습니다.)

이렇게 모든 실패 상황을 null이나 boolean 과 같이 리턴값을 통해 처리하는 방식은 문제가 있습니다.

  • 어떤 이유 때문에 실패했는지 알 수 없다.
  • 실패 상황에 대해 리턴값을 명확하게 관리하지 않으면 이후에 혼란이 올 수 있다.
  • 리턴값을 계속해서 확인하는 if문이 발생할 수 있다.

이 문제를 해결하기 위해 각 예외 상황에 따른 처리 전략이 필요했습니다.

리팩토링 과정

예외처리 클래스 구조

우선 예외 상황을 두가지로 분리했습니다.

  • 비즈니스 로직에 대한 예외
  • Jwt 예외나 DB 예외 같이 기술적인 예외로, 자바에서 이미 제공하는 예외들

위 두가지 상황에 맞춰서 예외 처리 전략을 세웠습니다.

  1. 비즈니스 로직에 대한 예외의 경우 최상위 Business Exception이 있습니다. 그리고 이를 상속받는 NicknameDuplicatedException, EmailDuplicatedException, 등등 에 세부 비즈니스 예외가 있습니다.

  2. JwtException, DataAccessException과 같은 예외들은 이미 자바에서 예외를 제공해줍니다. 그래서 이 예외를 그대로 활용하기로 했습니다. DataAccessException과 같이 이미 DB 예외를 추상화해놓은 런타임 예외는 그대로 사용합니다. 하지만, checked Exception은 런타임 예외로 포장해서 예외를 처리하도록 합니다.

모든 예외를 RuntimeException으로 처리하는 이유

  • DB 에러가 발생할 경우
  • API request 시 필요한 body 데이터가 오지 않았을 경우
  • JWT토큰이 만료한 경우
  • 회원 가입 시 중복 닉네임인 유저의 경우 등등

위와 같은 여러 예외가 있습니다. 이런 예외들은 서버쪽에서 try catch나 throws를 활용하여 예외를 처리하고 복구 하는 것보다 바로 바로 클라이언트에게 어떤 예외가 났다고 통보하는 것이 낫다고 생각합니다. 따라서 대부분의 예외를 runtimeException으로 처리하도록 했습니다.

그래서 만약 자바에서 제공하는 예외들이 checked Exception이면 이를 runtime Exception 으로 중첩해서 처리하도록 했습니다.

하지만, 이후에 예외를 처리해서 무언가 대응 할 필요한 경우가 생기면 checked Exception을 활용해야 할 듯 합니다.

checked exception , unchecked exception

오류에는 3가지가 존재한다.

  • error
    • 프로그램 밖에서 발생한 예외
    • 런타임시에 발생
    • 예외발생시 프로그램이 아예 멈춰버림
    • OutOfMemory 메모리 예외 , IO예외 같은 게 있음
  • checked exception
    • 예외처리를 반드시 해야함 try catch , throws 해줘야함
    • 컴파일시 체크가 가능한 예외
      • 만약 컴파일 타임에서 예외 발생하면 exception이 터진다.
    • 예외가 발생시에 트랜잭션을 rollback하지 않는다.
      • 예외처리를 try catch같은 걸로 반드시 해야할 텐데 이때 개발자가 알아서 복구하라고.
  • unchecked exception, RuntimeException
    • 예외처리하지않아도됨
    • 런타임시에 발생하는 예외
    • 예외발생시 트랜잭션을 rollback한다.

@ExceptionHandler

특정한 컨트롤러에 예외처리할 때 사용하는 어노테이션으로, 이 때 발생한 예외는 ExceptionHanderExceptionResolver에 의해 처리가 된다.

Dispatcher Servlet에는 DI로 확장 가능한 여러 전략이 있는데 그 중에 이 Exception관련 Resolver도 있다.
예외 발생시 Dispatcher Servlet이 이 예외 관련 Resolver에게 예외 처리를 위임한다.

@ControllerAdvice

ExceptionHandler는 특정 컨트롤러에만 예외가 처리되므로 다른 컨트롤러에서도 중복된 에러 처리 코드가 있을 수 있다.
문제를 해결하기 위해서는 @ControllerAdvice를 사용하는 것이다. 이것은 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용할 수 있도록 해준다. AOP 방식으로 동작해서 예외를 처리한다고 나와있다.

주의 사항은

  • 한 프로젝트 당 하나의 ControllerAdvice만 관리. 만약 여러 Controller Advice가 필요하다면 basePackages나 annotations 등을 지정해야 한다.

스프링의 예외 처리 흐름

예외들은 예외 관련 Resolver에 의해서 처리된다.
컨트롤러가 예외가 던져졌을 때
1. 컨트롤러 안에 있는 @ExceptionHandler가 있는지 검사.있으면 에러 처리
2. @ControllerAdvice를 찾고 안에 @ExceptionHandler가 있는지 검사.에러처리.
3. @ControllerAdvice를 찾고 안에 @ResponseStatus가 있는지 검사.에러처리.

스프링은 기본적인 예외처리방식이 BasicErrorController로, RespoonseStatus예외는 이 BasicErrorController를 거친다. 에러 응답을 직접 반환하지 않기 때문이다.

Business Exception을 상속하도록 한 이유

이번 예외 처리시에 ControllerAdvice를 활용하여 예외를 처리합니다. ControllerAdvice에서 BusinessException 하나만 handler를 두면, 통일감 있는 비즈니스 예외를 처리할 수 있습니다.

아래와 같은 코드로 controllerAdvice를 구현합니다.
발생하는 모든 세부 비즈니스 예외는 handleBusinessException 에서 동일하게 처리됩니다.


@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

...
  @ExceptionHandler(BusinessException.class)
  protected ResponseEntity<ErrorResponseDTO> handleBusinessException(BusinessException e) {
    ErrorCode errorCode = e.getErrorCode();
    ErrorResponseDTO response = ErrorResponseDTO.of(errorCode);
    return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
  }


}

변경 후 코드 - ControllerAdvice


@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(BusinessException.class)
  protected ResponseEntity<ErrorResponseDTO> handleBusinessException(BusinessException e) {
    ErrorCode errorCode = e.getErrorCode();
    log.info(errorCode.getMessage());
    ErrorResponseDTO response = ErrorResponseDTO.of(errorCode);
    return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
  }

  @ExceptionHandler(InvalidJwtException.class)
  protected ResponseEntity<ErrorResponseDTO> handleJwtException(InvalidJwtException e) {
    ErrorResponseDTO response = ErrorResponseDTO.of(HttpStatus.FORBIDDEN.value(), e.getMessage());
    return new ResponseEntity<>(response, HttpStatus.FORBIDDEN);
  }

}

위와 같이 전역레벨에서 예외를 처리합니다.

  • handleBusinessException : 모든 비즈니스 예외에 대해 통일감있는 형태로 응답을 제공합니다.
  • handleJwtException : 자바에서 제공하는 여러 JwtException을 런타임 예외로 중첩한 InvalidJwtException을 다룹니다

변경 후 코드 - ErrorCode

회원가입 시에 중복 닉네임을 썻으면
응답시에는

{
	status : 409,
    message : "닉네임이 중복되었습니다."
}

위와 같이 클라이언트에게 전달이 되어야 합니다.

여러 예외 상황에 맞는 status code와 message가 다양하게 존재할 텐데 이런 것들을 한 곳에서 관리할 공간으로 ErrorCode라는 enum을 사용합니다.

public enum ErrorCode {
 
  NICKNAME_DUPLICATED(409, "닉네임이 중복되었습니다"),
  EMAIL_DUPLICATED(409, "이메일이 중복되었습니다"),
  INVALID_EMAIL_TOKEN(400, "이메일 토큰이 유효하지 않습니다"),
  NOT_MATCHED_PASSWORD(400, "비밀번호가 일치하지 않습니다"),
  NOT_MATCHED_ID(403, "ID가 일치하지 않습니다");

  private int status;
  private String message;

  ErrorCode(int status, String message) {
    this.status = status;
    this.message = message;
  }
  ...
}

변경 후 코드 - ErrorResponseDTO


@Getter
@Setter
public class ErrorResponseDTO {
  private int status;
  private String message;
  ...
}

응답시 전달할 DTO 클래스입니다. status code와 message를 담고 있습니다.

변경 후 코드 - 세부 비즈니스 예외

public class NicknameDuplicatedException extends BusinessException {
  @Override
  public ErrorCode getErrorCode() {
    return ErrorCode.NICKNAME_DUPLICATED;
  }
}

세부 비즈니스 예외들은 위와 같이 BusinessException을 상속받도록 합니다.

변경 후 코드 - 런타입 예외 중첩

public class InvalidJwtException extends RuntimeException {

  public InvalidJwtException(Throwable e) {
    super(e);
  }
}

Jwt와 관련해서 MalformedJwtException 등 여러 예외가 있습니다. 이들을 런타임 예외로 중첩하여 사용합니다.

Throwable은 에러 메시지나 에러 경로 등을 활용할만한 여러 메서드가 제공되기 때문에 사용했습니다.

변경 후 코드 - UserService

 @Override
  public void join(UserJoinRequestDTO userJoinRequestDTO) {

    if (isEmailDuplicate(userJoinRequestDTO.getEmail())) {
      throw new EmailDuplicatedException(); // 이메일이 중복 될 경우 
    }

    if (isNicknameDuplicate(userJoinRequestDTO.getNickname())) {
      throw new NicknameDuplicatedException(); // 닉네임이 중복 될 경우
    }

   ...

  }

초기 코드에 비해 어떤 예외 상황이 발생했는지 클라이언트도, 개발자도 모두 알 수 있게 되었습니다.

참고 문헌

토비의 스프링 4장 예외

https://cheese10yun.github.io/spring-guide-exception/

profile
부족함을 당당히 마주하는 용기

1개의 댓글

comment-user-thumbnail
2021년 8월 29일

https://enterprisecraftsmanship.com/posts/error-handling-exception-or-result/

저도 옛날?에 비슷한 고민을 했던 기억이 나서 당시에 즐겨찾기 해두었던 링크 공유드려요

답글 달기