Framework/Spring (Java)

트랜잭션 커밋 단위 축소 - @Transactional

joyCHAE 2021. 8. 1. 23:25

 

들어가며


@Transactional을 주의깊게 보게 된 계기는 배치 서버의 데이터 업데이트 때문이었다. 일별로 업데이트 해야 하는 데이터의 양이 증가하게 되면서, 스케쥴러들의 실행 시간이 겹치게 되었다.

 

이전에는 3:00AM, 4:00AM 등 한 시간 간격으로 스케쥴러 시간을 설정하였어도 충분히 스케쥴러를 겹치지 않게 돌릴 수 있었다. 하지만 데이터의 양이 증가하며 스케쥴러 실행 시간이 한시간이 넘어가게 되며 문제가 시작되었다. 여러 스케쥴러들의 실행 시간이 겹치고, 겹치게 실행되는 스케쥴러들이 동일한 DB table을 업데이트 하려다보니 업데이트 중 table lock이 걸려 timeout에러를 자주 맞이하게 되었다.

 

table lock 이슈 때문에 timeoutException이 발생하는 이유는 트랜잭션 단위가 너무 거대해졌기 때문이었다. 기존에는 스케쥴러가 데이터 업데이트를 촉발하기 위해 사용하는 가장 상위메서드에 @Transactional을 붙여서 사용하고 있었다. (이해를 돕기 위한 가상의 예시코드입니다.)

 

SchdulerService.java

@Scheduled(cron = "0 15 10 15 * ?")
public void updateUserInfo() {
    userService.updateUserInfoSchedulerStart();
}

 

UserService.java

@Transactional
public void updateUserInfoSchedulerStart() {
    ...
    updateUsers(address);
}

private void updateUsers(String address) {
    List<User> users = userRepository.findAllByAddress(address);

    for(User user: users) {
        updateEachUser(user);
    }

}

private void updateEachUser(User user) {
    ...
}

상황이 이렇다 보니 트랜잭션 커밋 단위가 스케쥴러 단위가 되며 엄청나게 거대해졌다.

 

그렇다보니 업데이트 시 DB table을 점유하는 시간도 길어졌고, 스케쥴러 단위로 데이터 업데이트가 통쨰로 진행되지 않는 상황도 자주 발생하게 되었다.

 

 

 

 

트랜잭션 재사용 불가


거대해진 스케쥴러, 덩달아 거대해진 트랜잭션 커밋 단위를 최소화 하는 작업이 필요했다.

 

@Transactional은 해당 어노테이션이 붙은 클래스 혹은 메서드 단위로 트랜잭션을 시작하고, 커밋하게 된다. 따라서 위의 코드를 예시로 들자면, users에 포함된 user1을 업데이트 하려다가 에러가 발생하면, 해당 트랜잭션은 재사용이 불가능하기 때문에 전체 users 리스트의 업데이트 내용이 데이터베이스에 커밋되지 않는다. 즉, user2, user3, user4, user5, ... , user10 전부 업데이트 되지 않는다.

 

아래의 에러로그를 보면 알 수 있다.

 

Transaction silently rolled back because it has been marked as rollback-only라는 에러로그를 뱉으며 최종 커밋이 이루어질 타이밍에 롤백을 하며 DB 업데이트를 하지 않는다.

Transaction 내에서 한 번이라도 에러가 발생하면 전체가 롤백 되는 게 @Transactional의 디폴트 값이기 때문이다.

[2021-07-16 08:04:18.540] - [ERROR] [o.s.s.s.TaskUtils$LoggingErrorHandler] [EXPENSE-SCHEDULER-7]: Unexpected error occurred in scheduled task
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:633)
        at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:386)
        at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:118)
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:749)
        at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:691)

 

 

 

트랜잭션 단위 최소화


트랜잭션 커밋 단위를 최소화 하는 것이 시급했다.

 

이유는 다음과 같이 예상했다.

  1. 트랜잭션 단위를 작게 만들면 DB Table 점유 시간이 짧아져 timeoutException이 발생하는 빈도가 줄어들 것이다.
  2. 설사 timeoutException이 발생해 해당 트랜잭션이 롤백 되더라도, users 리스트의 전체 업데이트가 되지 않는 최악의 상황은 막을 수 있을 것이다.

 

따라서 적용한 코드는 다음과 같았다.


public void updateUserInfoSchedulerStart() {
    ...
    updateUsers(address);
}

private void updateUsers(String address) {
    List<User> users = userRepository.findAllByAddress(address);

    for(User user: users) {
        updateEachUser(user);
    }

}

@Transactional(propagation = REQUIRES_NEW)
private void updateEachUser(User user) {
    ...
}

 

 

전체 user 단위가 아닌 개별 user 단위로 트랜잭션 단위를 쪼개면 관리가 용이할 것이라는 판단 하에, @Transactional 어노테이션을 개별 유저를 업데이트 하는 updateEachUser라는 하위 메서드로 이동하였다.

 

이후 에러 상황을 재현해본 결과 다음과 같은 에러가 발생하였다.

[2021-07-20 18:14:21.472] [ERROR] - [Test worker] [c.g.e.s.e.ExpenseApprovalHistServiceTest] : could not initialize proxy [com.test.batch.entity.user.Team#8919] - no Session 
org.hibernate.LazyInitializationException: could not initialize proxy [com.test.batch.entity.user.Team#8919] - no Session
    at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:170)
    at org.hibernate.proxy.AbstractLazyInitializer.getImplementation(AbstractLazyInitializer.java:310)
    at org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor.intercept(ByteBuddyInterceptor.java:45)
    at org.hibernate.proxy.ProxyConfiguration$InterceptorDispatcher.intercept(ProxyConfiguration.java:95)

could not initialize proxy 문제인데, 지연 로딩 때문에 발생하는 문제이다.

 

따라서 엔티티 간의 연관관계 설정 때문에 발생하는 문제이기 때문에, 문제가 되는 조인 칼럼을 찾아 FetchType.LAZY를 FetchType.EAGER로 바꾸어 지연로딩에서 즉시로딩 방식을 취하면 해결된다. 다만, 실무에서는 EAGER를 사용하기엔 성능 측면의 위험 부담이 크고, 의도하지 않는 쿼리가 마구 발생할 수 있게 때문에 다른 해결 방법을 찾아야 한다.

 

 

프록시 객체는 내부 호출이 되지 않고, 외부 호출을 해야 한다는 개념을 적용하자 문제를 풀 수 있었다.

@RequiredArgsConstructor
public class UserSchedulerBatchService {

        private final UserScheduler userScheduler;

        public void updateUserInfoSchedulerStart() { 
            ...
            updateUsers(address);
        }

        private void updateUsers(String address) {
            List<User> users = userRepository.findAllByAddress(address);

            for(User user: users) {
                userScheduler.updateEachUser(user);
            }

        }
}

// =====================================================================================

public class UserScheduler {

    public void updateUserInfoScheduler() {

        @Transactional(propagation = REQUIRES_NEW)
        private void updateEachUser(User user) {
            ...
        }
    }

}

JPA는 프록시 객체를 불러온다. 프록시 객체는 내부에서 호출할 수 없다. 외부에서만 호출할 수 있다.

 

따라서 @Transactional을 붙인 하위 메서드의 상위서 해당 메서드를 호출하는 메서드는 다른 클래스를 만들어 이동시켜주었다. 외부에서 호출 할 수 있도록 만든 것이다.

그러자 트랜잭션 단위를 줄인 것도 성공적으로 동작하여 timeout 에러도 해결하였고, 지연 로딩에서 파생되는 프록시 문제도 해결하였다!

 

 

 

결론적으로, 트랜잭션 단위를 전체 유저가 아닌 유저 개별로 줄여 DB 테이블 점유 시간을 줄였다. 따라서 DB 테이블 점유 시간 단위가 짧아졌다. 따라서 다른 트랜잭션과 한 테이블 동시 작업이 생김으로서 발생하는 timeout 에러를 줄였을 뿐만 아니라, 혹시나 timeout 에러가 발생하더라도 전체 업데이트 분량이 한꺼번에 업데이트 되지 못하는 리스크를 줄일 수 있었다.