JAVA Exception 처리 잘하는 방법

JAVA Exception 처리 잘하는 방법

Tags
JAVA
Spring
Published
May 3, 2022
Property

배경

면접 도중 Exception에 관련된 질문을 받게 되었는데, 부족한 부분을 많이 발견하여서 공부하는 겸 다시 정리해보았다. Art Run에서 Exception을 사용한 사례도 첨부하였다.

Question: Exception은 어떻게 처리해야하는가?

토비의 스프링에 따르면, Exception은 무책임하게 상위 메서드로 throws되어서는 안되고 적절하게 처리되어야 한다. 예외 처리전략은 크게 3가지가 있지만, 본 글에서는 주로 예외 전환, 불필요한 예외 회피 코드를 예외 전환으로 변경하는 것에 대해 다룬다.

예외 처리 전략

예외 복구

예외 상황에서 문제를 해결하고 정상 처리한다.

예외 회피

예외 처리를 본인이 담당하지 않고 호출한 쪽으로 넘긴다.

예외 전환

  • 일반적으로 체크 예외를 계속 throws를 사용해 넘기는 건 무의미하다. 메소드 선언은 지저분해지고 아무런 장점이 없다.
  • 어차피 복구가 불가능한 예외라면 가능한 한 빨리 런타임 예외로 포장해 던지게 해서 다른 계층의 메소드를 작성할 때 불필요한 throws 선언이 들어가지 않도록 해줘야 한다.

예외 전환 처리 - 사례

public class wktToGeometry {
    public static Geometry wktToGeometry(String wellKnownText) {
        try {
            return new WKTReader().read(wellKnownText);
        } catch(ParseException e) {
            throw new InvalidWktException("invalid wellKnownText: " + wellKnownText);
        }
    }
}
Art Run 내에서 사용하는 wktGeomtry 클래스
Global Util 클래스에서 사용하는 WKTReader 라이브러리에서 ParseException은 CheckedException이지만, 예외 전환을 통해서 InvalidWktException(Unchecked Exception)을 반환하도록 한다.
Global Util 클래스이기 때문에, Spring 앱의 여러 곳에서 사용될 것인데 ParseException을 예외 전환 처리하여서 복구 불가능한 예외가 무차별적으로 전파되지 않도록 했다. 예외 전환을 하지 않았다면 controller, service의 메서드마다 throws를 붙여줘야할 것이다.
 

Checked Exception vs Unchecked Exception

Unchecked Exception은 Runtime Exception을 상속하는 것이고, Checked Exception은 그 외의 것이다. 위의 예외 전환 코드는 Unchecked Exception으로 예외 전환을 하였다.

그럼 트랜잭션 롤백이 되는 Exception은 무엇인가?

처음에 잘 이해하지 못했는데,@Transactional 내에서 Checked Exception이 발생할 경우 롤백이 되지 않고, Unchecked Exception이 발생할 경우에만 롤백이 된다.
이유는 여러 글들에 따르면 스프링의 기본 트랜잭션 정책 상 Checked Exception은 EJB의 관습에 따라 Rollback되지 않는다.
cf. 트랜잭션 간 중첩되는 경우, 트랜잭션 내에서 RuntimeException을 예외 복구를 하려는 경우 좀 까다롭다. @Transactional 의 기본 정책에 대해서 잘 알아둘 필요 있을 것 같다. 최소한 다시 찾아보면 이해하고 쓸 수 있을 정도로!
 
트랜잭션 내에서 예외가 발생했을 경우 롤백되는 게 당연하다고만 생각하고 코드를 작성할 경우, 당연히 롤백된다고 생각하던 코드가 롤백되지 않을 수 있다!
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {

  private final MemberRepository memberRepository;

  // (1) RuntimeException 예외 발생
  public Member createUncheckedException() {
    final Member member = memberRepository.save(new Member("yun"));
    if (true) {
      throw new RuntimeException();
    }
    return member;
  }

  // (2) IOException 예외 발생
  public Member createCheckedException() throws IOException {
    final Member member = memberRepository.save(new Member("wan"));
    if (true) {
      throw new IOException();
    }
    return member;
  }
}
예) 1번의 경우 롤백되지만, 2번의 경우 롤백되지 않는다.
그러나 현실적으로 Checked Exception이 있을 경우 복구 전략을 사용할 수 있는 경우는 많지 않다. 그냥 Runtime Exception으로 처리한 뒤, Rollback 시켜주는 것이 더 적절할 때가 많다. 이러한 점에서 Checked Exception을 Unchecked Exception으로 프로젝트 차원에서 전환하여 사용하는 방식이 등장한 것으로 보인다.

Answer: Global Exception 전략

Yun Blog의 글을 모티브로 하여 프로젝트에 Exception 전략을 적용하였다.
  • Checked Exception을 포함한 모든 Exception을 BusinessException으로 예외 전환이 가능하다.
  • 예외 처리 및 응답 코드를 매번 직접 컨트롤러에서 작성할 필요 없이 일관성 있는 에러 응답을 구현할 수 있다.

GlobalExceptionHandler 구현

notion image
이와 같이 최상위 BusinessException을 두고, GlobalExceptionHandler에서 catch해주는 방식으로 통일성 있는 예외 처리가 가능하다.
@ExceptionHandler(BusinessException.class)
    protected ResponseEntity<ErrorResponse> handleBusinessException(final BusinessException e) {
        log.error("handleBusinessException", e);
        final ErrorCode errorCode = e.getErrorCode();
        final ErrorResponse response = ErrorResponse.of(errorCode);
        return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
    }

BusinessException

BusinessException은 비즈니스 로직에서의 예외(요구사항에 맞게 개발자가 발생시키는 예외)이다. BusinessException을 활용하여 코드를 작성해야하는 이유는, 객체에게 더 적절하게 책임을 부여하고, 더 간결하게 로직을 구성하기 위해서이다. 예를 들어, 쿠폰이 만료되었을 경우 이후의 결제 로직을 진행하지 않고 예외 발생 후 로직을 중단하는 것이다.
클린 코드 : 오류 코드 보다 예외를 사용하라 리팩토링
 

+) 공통 Error Response 구현

Error Response를 통일된 형태로 보내주는 것이 클라이언트 측에서도 예외 처리를 하기 좋다.
또 에러코드를 함께 이용하면 체계적으로 관리할 수 있다. ArtRun에서는 인증 부분에 에러코드가 적용되어 있다.
public enum ErrorCode {
    ...
    // Auth
    EMAIL_DUPLICATION(400, "A001", "Email is Duplication"),
    LOGIN_INPUT_INVALID(400, "A002", "Login input is invalid"),
    UNAUTHORIZED(401, "A003", "UnAuthorized");

    private final String code;
    private final String message;
    private int status;

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

돌아보며

이러한 Exception 구조를 단순히 좋다고만 생각하면서 관습적으로 사용하고 있었던 것 같다. 글을 쓰면서 왜 이렇게 써야하는지, 왜 필요한지에 대해서 다시 생각해볼 수 있었다. 특히 트랜잭션 내에서 Checked Exception이 롤백되지 않는다는 점은 꼭 명심하자.

참고