반응형

1. 개요

 정기 사용자 레벨 관리 작업을 수행하는 도중 네트워크가 끊기거나 중간 로직에서 예외가 발생한다면, 그때까지 변경된 사용자의 레벨은 그대로 둘까? 모두 롤백할까?

 당연히 모두 롤백해야 한다. 일부 사용자만 레벨이 조정됐다면 사용자의 반발이 우려되기 때문이다. 롤백 후 다시 일정을 잡아 해당 작업을 수행해야 한다. 현재 JdbcTemplate을 사용해 정기 사용자 레벨 관리 작업에 대한 데이터 처리 작업을 하고있다. 작업 중간에 예외가 발생했을때 실제로 모두 롤백이 되는지 확인해보고, 롤백이 되지 않는다면 원인을 파악해보자.


2. 롤백 테스트

 2.1. 테스트 케이스

  정기 사용자 레벨 관리 작업은 모든 유저 조회, 레벨업 조건 만족 여부 체크, 레벨업 데이터 처리 과정을 거친다. 이에따라 조건을 만족하는 N개의 유저 픽스처를 미리 생성한 후 조건을 만족하는 특정 유저에 대해 레벨업 처리 데이터 처리 시 RuntimeException을 발생시키도록 하는 테스트 케이스를 구현하였다.

 교재에서는 서비스 클래스를 상속받고, 특정 유저 ID로 레벨 업 처리 시 예외가 발생하도록 하였으나, 필자는 실제 로직은 건들고 싶지 않아 테스트 코드만으로 처리하였다.

 

2.2. 테스트 코드

    @Autowired
    UserService userService;

    @SpyBean
    DefaultUserLevelUpgradePolicy spyUserLevelUpgradePolicy;

    @SpyBean
    IUserDao spyUserDao;
    
    List<User> users; // 테스트 픽스처
    
    @BeforeEach
    void setUp(){
       users = Arrays.asList(
               new User("test1","테스터1","pw1", Level.BASIC, 49, 0),
               new User("test2","테스터2","pw2", Level.BASIC, 50, 0),
               new User("test3","테스터3","pw3", Level.SILVER, 60, 29),
               new User("test4","테스터4","pw4", Level.SILVER, 60, 30),
               new User("test5","테스터5","pw5", Level.GOLD, 100, 100),
               new User("test6","테스터6","pw6", Level.PLATINUM, 100, 100)
       );
    }

    @Test
    @DisplayName("레벨업 처리 도중 예외 발생 테스트")
    void exceptionDuringLevelUp(){
       	userService.setUserLevelUpgradePolicy(spyUserLevelUpgradePolicy); // spyBean 설정

		// Stubbing : userDao.getAll() 호출 시 users 리턴
        given(spyUserDao.getAll()).willReturn(users); 
       
        // Stubbing : 네번째 유저에 대한 레벨업 메서드 호출 시 RuntimeException 발생
        given(spyUserLevelUpgradePolicy.upgradeLevel(users.get(3))).willThrow(new RuntimeException());
	
    	// 모든 유저 삭제
        spyUserDao.deleteAll();
        
        // 유저 등록
        users.forEach(user -> spyUserDao.add(user));

		// 정기 레벨업 작업
        userService.upgradeLevels();

        checkLevelUpgraded(users.get(1),false);
        checkLevelUpgraded(users.get(3),false); 
    }
    
    private void checkLevelUpgraded(User user, boolean upgraded){
        User userUpdate = userDao.get(user.getId());

        if(upgraded){
            assertThat(userUpdate.getLevel()).isEqualTo(user.getLevel().getNexeLevel());
        }else{
            assertThat(userUpdate.getLevel()).isEqualTo(user.getLevel());
        }
    }
...

  네번째 유저에 대해 RuntimeException이 발생하도록 하고, checkLevelUpgraded 메서드를 통해 유저의 레벨 업 여부를 체크한다. 첫번째 파라미터는 체크할 유저, 두번째 파라미터는 레벨업 여부이다. 만약 users.get(1) 유저가 레벨업이 된다면 테스트는 실패할 것이다.

 

2.3. 테스트 결과

 users.get(1) 유저가 레벨업 되었기때문에 테스트는 실패했다. 이로써 기본적인 jdbcTemplate을 사용할 경우 중간에 예외가 발생해도 롤백이 되지 않는다는 사실을 알게 되었다.

 


3. 롤백이 되지 않는 이유

 

3.1. 트랜잭션

 그렇다면 롤백이 되지 않는 이유는 뭘까? 바로 트랜잭션 때문이다. 모든 사용자의 레벨을 업그레이드하는 작업인 upgradeLevels() 메서드가 하나의 트랜잭션 안에서 동작하지 않았기 때문이다. 

 

트랜잭션이란?
데이터베이스의 상태를 변화시키는 하나의 논리적 기능을 수행하기 위한 작업의 단위를 말한다.

 

 만약 upgradeLevels()이 하나의 트랜잭션으로 구성되었다면 '하나의 트랜잭션은 모두 성공하거나 또는 모두 실패해야한다'는 트랜잭션 원자적 성질에 따라 모두 실패했겠지만, 여러개의 트랜잭션으로 구성되었기 때문에 모두 롤백될 수가 없었다. 결국 이를 해결하기 위해서는 트랜잭션의 원자적 성질을 적용시키기 위해upgradeLevels() 메서드에서 일어나는 모든 데이터 처리에 대해 하나의 트랜잭션으로 구성되도록 설정해야한다. 아래와 같이 말이다.

public void upgradeLevels() {

    // 트랜잭션 시작
    
    // 비지니스 로직(모든 유저 조회, 레벨업 조건 만족 여부 체크, 레벨업 처리)
    
    // 트랜잭션 종료
}

 

3.2. JdbcTemplate의 트랜잭션

 JdbcTemplate의 메서드 호출 한번에 한 개의 DB 커넥션이 만들어지고 닫힌다. 일반적으로 트랜잭션은 커넥션보다도 존재 범위가 짧은데, 템플릿 메서드가 호출될 때마다 트랜잭션이 만들어지고, 메서드를 빠져 나오기 전에 종료되게 된다. 즉, jdbcTemplate의 메서드를 사용하는 UserDao는 각 메서드마다 하나의 독립적인 트랜잭션으로 실행되었기 때문에 롤백이 되지 않았던 것이다.

 아래는 JdbcTemplate 메서드 실행 시 호출되는 execute 메서드이다. Connection이 생성되고, 반환되는 것을 확인할 수 있다. 

그런데 조금 특이한 부분이 있다. 일반적으로 Connection을 생성할 때는 DataSource 객체를 사용하는데 여기서는 DataSourceUtils 객체를 사용하여 호출하고 있다. 이에 대해 구글링하니 다음과 같은 답변을 찾아볼 수 있었다.

DataSource.getConnection() 는 DataSource 또는 연결 풀에서 얻은 새 연결을 반환합니다.
DataSourceUtils.getConnection() 현재 스레드에 대한 활성 트랜잭션이 있는지 확인합니다.
있는 경우 활성 트랜잭션에 대한 Connection을 반환하고, 없을 경우 DataSource와 같은 방식으로 작동합니다.

출처 - https://stackoverflow.com/questions/9642643/datasourceutils-getconnection-vs-datasource-getconnection

스레드에 활성 트랜잭션이 있을 경우 이 트랜잭션에 대한 Connection을 사용한다는 뜻이다. JdbcTemplate 메서드 호출 시 무조건 새로운 Connection을 생성하지 않았다. 이 활성 트랜잭션을 활용하는 방법이 있는데 이게 바로 트랜잭션 동기화이다.


4. 트랜잭션 동기화

 

4.1. 트랜잭션 동기화란

 스프링은 트랜잭션을 시작하기 위해 만든 Connection을 동기화 저장소에 보관해두고, 이후에 호출되는 다른 메서드에서 Connection을 사용해야 할 경우 저장된 Connection을 가져다 사용하게 하여 하나의 트랜잭션으로 관리할 수 있는 방법을 제공한다. 이를 트랜잭션 동기화라고 한다.

 

4.2. 트랜잭션 동기화 적용

 스프링은 멀티스레드 환경에서도 안전하게 트랜잭션을 동기화하는 기능을 지원한다. TransactionSynchronizationManager와 앞서 찾아보았던 DataSourceUtils이다.

TransactionSynchronizationManager를 이용해 트랜잭션 동기화 작업을 초기화하도록 요청하고, DataSourceUtils에서 제공하는 getConnection() 메서드를 통해 DB 커넥션을 생성한다. DataSource에서 Connection을 직접 가져오지 않고, 이를 이용하는 이유는 Connection 오브젝트를 생성해줄 뿐만 아니라 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주기 때문이다.

 

 트랜잭션 동기화가 되어 있는 채로 JdbcTemplate을 사용하면 JdbcTemplate 메서드 호출 시 동기화시킨 Connection을 사용하게 된다. 즉, upgradeLevels() 에서 트랜잭션 동기화 작업을 한다면, 내부에서 호출되는 JdbcTemplate은 upgradeLevels()에서 만든 Connection을 사용하게 되어 하나의 트랜잭션으로 돌아가게 된다.

 

4.3. 코드 적용

 DataSourceUtils 메서드에 필요한 dataSource를 사용하기 위해 수정자 메서드를 추가 및 DI 설정 후, 트랜잭션 동기화 로직을 추가하였다. 이제 테스트 코드를 통해 롤백이 되었는지 확인해보자. 아마 롤백이 되어 테스트가 성공할 것이다.

public class UserService {
	...
    private DataSource dataSource; // dataSource 추가

    public void setDataSource(DataSource dataSource){ // dataSource 수정자 메서드 추가
        this.dataSource = dataSource;
    }
    ...
	public void upgradeLevels() {
        TransactionSynchronizationManager.initSynchronization(); // 동기화 작업
        Connection c = DataSourceUtils.getConnection(dataSource); // 커넥션 생성 및 저장소 바인딩

        try{
            c.setAutoCommit(false);

            List<User> users = userDao.getAll();

            for(User user : users) {

                if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
                    userLevelUpgradePolicy.upgradeLevel(user);
                }
            }
            c.commit();
        }catch(Exception e){
            try{
                c.rollback();
            }catch (SQLException ex){
                e.printStackTrace();
            }
        }finally {
            DataSourceUtils.releaseConnection(c, dataSource); // DB Connection close
            TransactionSynchronizationManager.unbindResource(this.dataSource); // 트랜잭션 저장소에서 데이터소스 반환
            TransactionSynchronizationManager.clearSynchronization(); // 동기화 작업 종료
        }
    }

 


5. 트랜잭션 서비스 추상화

 그렇다면 2개 이상의 DB를 하나의 트랜잭션으로 묶을 수 있을까?

 현재 방법으로는 불가능하다. 트랜잭션이 하나의 DB Connection에 종속되는 방식인 로컬 트랜잭션 방식을 사용하기 때문이다. 이 경우 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션 방식을 사용해야 한다. 자바는 글로벌 트랜잭션을 지원하는 API인 JTA(Java Transaction API)를 제공한다.

 하지만 다짜고짜 JTA를 적용하는 것도 문제가 된다. JTA를 적용하려면 서비스 로직이 수정되어야하는데 기존 로컬 트랜잭션 방식을 사용하는 경우 굳이 수정할 필요가 없기 때문이다.

 스프링은 이러한 다른 트랜잭션 방식에 대해서도 추상화하여 사용할 수 있도록 트랜잭션 추상화 기술을 제공하고 있다. 이를 이용하면 로컬, 글로벌과 같은 트랜잭션 방식 뿐 아니라 JDBC, JPA, 하이버네이트 등과 같은 기술들의 각 트랜잭션 API를 이용하지 않고도 일관된 방식으로 트랜잭션 제어가 가능해진다.

 

5.1. PlatformTransactionManager

 스프링이 제공하는 트랜잭션 처리를 위한 추상 인터페이스이다. JDBC의 로컬 트랜잭션을 이용한다면 PlatformTransactionManager를 구현한 DataSourceTransactionManager를 사용하고 JTA를 이용하는 글로벌 트랜잭션을 이용한다면 JTATransactionManager로 구현체를 바꿔 사용하면 된다.

 

5.2. DataSourceTransactionManager 적용

 

5.2.1) UserService.java

public class UserService {

    private IUserDao userDao;


    private PlatformTransactionManager transactionManager;

    public void setTransactionManager(PlatformTransactionManager transactionManager){
        this.transactionManager = transactionManager;
    }

    ...

    public void upgradeLevels() {

        // DI 받은 트랜잭션 매니저를 공유해서 사용한다.
        TransactionStatus status = transactionManager.getTransaction(
                new DefaultTransactionDefinition()
        );

        try{
            List<User> users = userDao.getAll();

            for(User user : users) {

                if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
                    userLevelUpgradePolicy.upgradeLevel(user);
                }
            }
            transactionManager.commit(status);
        }catch(Exception e){
            transactionManager.rollback(status);
        }
    }

    public void add(User user) {
        if(user.getLevel() == null){
            user.setLevel(Level.BASIC);
        }
        userDao.add(user);
    }
}

 

5.2.2) applicationContext.xml

<bean id = "userService" class = "org.example.user.service.UserService">
    <property name="userDao" ref = "userDao"></property>
    <property name="userLevelUpgradePolicy" ref = "userLevelUpgradePolicy"></property>
    <property name="transactionManager" ref="transactionManager"></property>
</bean>

<bean id = "transactionManager" class ="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="dataSource"></property>
</bean>

...

 

 필자의 경우 DataSource를 통한 로컬 트랜잭션 방식을 사용해야 하므로 transactionManager의 구현체 클래스를 DataSourceTransactionManager로 하는 Bean을 생성하였다. 해당 클래스 생성 시 dataSource가 필요하므로 property를 통해 주입시켰다.

 마지막으로 테스트를 통해 transactionManager가 적용됐음을 확인해보자.

 

 

 

 

반응형

+ Recent posts