Web/정리글

Exception & Transaction rollback 정리

구름뭉치 2021. 9. 23. 15:42

Exception과 Error의 구분

예외와 에러에 대한 차이부터 알아보자. 예외(Exception)은 사용자의 잘못된 값이나 접근으로 인해 발생하는 것으로 정상적인 코드의 흐름에서 벗어나는 행동을 했을 때 발생한다. 따라서 개발자가 예외상황을 미리 예측해서 에러 핸들링을 할 수 있다.

에러(Error)는 시스템 상의 문제가 발생해서 발생되는 것으로 개발자가 따로 막을 수 없는 경우이다. 이 경우는 애플리케이션의 코드레벨에서 방어가 불가능하다. 일반적으로 OutOfMemorry, StackOverflow 등이 있다.

예외 구분

예외는 크게 Checked Exception과 Unchecked Exception으로 나눌 수 있다. 이를 구분 짓는 특징은 RuntimeException의 상속 여부이다. 상속한 경우 Unchecked Exception, 상속하지 않은 경우 Checked Exception으로 나뉜다.
스크린샷 2021-09-23 오후 2 23 12

그렇다면 이렇게 Unchecked과 Checked로 Exception을 구분지은 이유가 뭘까?
자바에서는 RuntimeException을 상속한 Unchecked Exception을 다르게 처리하기 때문이다. 아래는 처리방법의 차이를 나타내는 표이다.
스크린샷 2021-09-23 오후 2 22 51

그림을 보면 Unchecked Exception의 경우 예외처리를 명시적으로 하지 않아도 되고, 예외가 발생하면 트랜잭션의 경우 롤백해야 한다고 나와 있다.

rollback-only로 인한 문제

이 경우에서 나의 문제가 발생했었는데 스프링 부트 애플리케이션을 설계 하면서 선언한 커스텀 예외가 발생한 경우 저장하도록 했었다.

@Transactional
    public myDto findByName(String name) {
        try {
            return myService.findByName(name);
        } catch (CustomNotFoundException notFoundException) {
         //return ~;
    }
}

[myService 클래스]
@Transactional(readOnly=true)
findByName(String name) {
    // 구현 ~
    // 실패시 thorw new CustomNotFoundException();
}

구현 의도는 다음과 같다 : @Transactional의 메소드 내부에서 다른 메소드를 실행시키고 해당 메소드에는 @Transactional(readOnly=true)가 달려 있다. 내부 메소드에서 실패한 경우 RuntimeException을 상속받은 커스텀 예외를 던진다. 이를 외부 메소드에서 try~catch로 감싸서 예외를 잡아서 필요한 로직을 하도록 한다.

하지만 원하던 대로 되지 않고 rollback으로 인해 저장이 실패했다는 에러가 떳다.
스크린샷 2021-09-23 오후 2 47 50

2021-09-23 14:46:36.666 ERROR 45914 --- [-nio-443-exec-3] t.opgg.swoomi.advice.ExceptionAdvice     : org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
    at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:752)
    at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:711)
    at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:654)
    at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:407)
    at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750)
    at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:692)
    at teamc.opgg.swoomi.service.OriannaService$$EnhancerBySpringCGLIB$$b72f4ad2.SummonerFindByNameAndSave(<generated>)

즉, 정리해보자면 내부 메소드에서 RuntimeException을 상속받은 CustomException이 발생되면서 Transaction의 디폴트 규칙으로 인해 rollback-only가 켜졌고 이 상태에서 반환이 이뤄진 후 외부 메소드 로직이 수행되다가 JPA의 save()를 하고 이를 커밋하려는 순간 rollback-only로 인해 [Transaction silently rolled back because it has been marked as rollback-only]가 발생하게 되면서 에러가 나게 된 것이다.

해결

생각한 해결 방법은 세가지이다.

  1. 내부 메소드에서 원하는 값이 없으면 예외를 발생시키지 않고 특수값을 반환하도록 한다.
  2. JPA save()메소드에는 @Transactional이 달려있으므로 외부 메소드에 @Transactional을 제거한다.
  3. 내부 메소드의 Transaction의 propagation옵션을 REQUIRED에서 PROPAGATION_REQUIRES_NEW로 바꿔서 이미 존재하는 트랜잭션에 참여하지 않고 새로운 트랜잭션을 생성하도록 한다. (propagation = Propagation.REQUIRES_NEW)

spring data JPA의 save()에 @Transactional이 달려있는 모습

/*
     * (non-Javadoc)
     * @see org.springframework.data.repository.CrudRepository#save(java.lang.Object)
     */
    @Transactional
    @Override
    public <S extends T> S save(S entity) {

        Assert.notNull(entity, "Entity must not be null.");

        if (entityInformation.isNew(entity)) {
            em.persist(entity);
            return entity;
        } else {
            return em.merge(entity);
        }
    }

3번은 쓸데없이 트랜잭션을 2번 만드는 방법이라 제외하였고, 1번과 2번중에서 가장 간단한 2번으로 문제를 해결하였다.
즉, 외부의 @Transactional 을 지웠다. 하지만 만약 외부 메소드에서 save()가 아닌 직접 만든 메소드나 다른 값을 변경하는 로직이라면 1번 또는 3번의 해결 방법을 사용해야겠다.

이렇게 예외의 Checked Exception과 Unchecked Exception를 알아봤고, Uncheced Exception의 경우 트랜잭션이 rollback으로 체크된다는 것을 알았다. 또한 트랜잭션 내 트랜잭션의 경우 propagation옵션으로 인해 이미 존재하는 트랜잭션에 참여하는게 기본 값임을 알았다. 이를 해결하기 위해서 새로운 트랜잭션을 만들도록 propagation.requires_new로 하거나 트랜잭션을 분리하는 방법이 있다는 것을 알게 되었다.

반응형