토비의 스프링 정리

토비의 스프링 - 5.2 트랜잭션 서비스 추상화

ksb-dev 2022. 10. 4. 21:55

5.2.1 모 아니면 도

  • 레벨 업그레이드 도중 문제가 발생하면 이전에 업그레이드 된 것은 초기화 되는가?
  • 일부 사용자가 차별성을 느끼기 때문에 업그레이드 도중 실패 시, 초기화 하는게 옳음
  • 짧은 업그레이드 시간 중간에 DB 서버를 다운시키거나 네트워크에 장애 발생하는 것은 불가능 하며, 테스트는 자동화되야 하므로 예외가 발생하는 상황을 의도적으로 만들 것임
  • 예외를 강제로 발생시킬 때 애플리케이션 코드를 수정하는 것은 좋지 않음
  • 기존의 UserService 내부에 UserService를 상속한 테스트용 확장 static 클래스를 만들어 사용
  • 현재 두 번째와 네 번째 사용자가 레벨 업그레이드 되므로, 네 번째 사용자 처리하는 중에 예외 발생할 것임
  • upgradeLevel() 메소드 오버라이드를 통해 네 번째 id에서 예외 발생
  • checkLevelUpgrade()메소드를 통해 업그레이드가 되었는지 확인
public class UserServiceTest {
    @Test
    public void upgradeAllOrNothing(){
        UserService testUserService = 
                new UserService.TestUserService(users.get(3).getId());
        testUserService.setUserDao(this.userDao);

        userDao.deleteAll();
        for(User user : users) userDao.add(user);

        try {
            testUserService.upgradeLevels();
            fail("TestUserServiceException expected");
        } catch (UserService.TestUserServiceException e){
        }

        checkLevelUpgraded(users.get(1), false);
    }
}

public class UserService {
    ...
    protected void upgradeLevel(User user) {
        user.upgradeLevel();
        userDao.update(user);
    }
    ...
    public static class TestUserService extends UserService {
        private String id;
        public TestUserService(String id){
            this.id=id;
        }

        @Override
        protected void upgradeLevel(User user) {
            if(user.getId().equals(this.id)) throw new TestUserServiceException();
            super.upgradeLevel(user);
        }
    }

    public static class TestUserServiceException extends RuntimeException{}
}

💡 테스트가 실패해야 정상인 것임. 즉, 중간에 에러가 발생해도 에러나기 이전 업그레이드는 DB에 새로 저장된다는 뜻임

 

5.2.2 테스트 실패 원인

  • 트랜잭션 문제임
  • 트랜잭션이란, 더이상 나눌 수 업는 작업의 단위로 원자성을 의미한다.
  • upgradeLevels() 메소드는 각각의 트랜잭션 단위로 동작을 함
  • DB 자체로 완벽한 트랜잭션을 지원하면서, 하나의 SQL 명령을 처리하는 경우 트랜잭션을 보장함
  • 별다른 설정을 하지않는 한, 트랜잭션은 SQL 실행 단위로 동작
  • 즉, 여러 개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 묶는 경계설정 작업 필요
  • 트랜잭션 롤백과 커밋
    1. 트랜잭션 롤백(Transaction Rollback)
      1. 하나의 트랜잭션 작업 도중 문제가 발생할 경우 앞서 처리해서 성공한 SQL 작업도 초기화
    2. 트랜잭션 커밋(Transaction Commit)
      1. 하나의 트랜잭션 작업을 모두 성공했을 때 DB에 알려 작업 확정

5.2.3 JDBC 트랜잭션의 트랜잭션 경계설정

  • 트랜잭션 경계란, 트랜잭션이 시작되고 종료되는 위치
  • JDBC 트랜잭션은 하나의 Connection을 가져와 사용하다 닫는 사이에 발생함
  • 트랜잭션의 시작과 종료는 Connection 오브젝트를 통해 발생
  • JDBC의 기본설정은 DB 작업을 수행한 직후 자동으로 커밋함
  • 때문에, setAutoCommit()을 false로 지정해야 함
  • 해당 설정을 하면 commit() 또는 rollback()을 만날 때 까지 하나의 트랜잭션으로 묶임
  • commit() 또는 rollback()으로 트랜잭션을 종료하는 작업을 트랜잭션 경계설정(Transaction Demarcation)이라 함
  • 이렇게 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션(Local Transaction)라 함

5.2.4 UserService와 UserDao의 트랜잭션 문제

  • 코드 어디에도 트랜잭션을 시작하고 커밋, 롤백하는 트랜잭션 경계설정 코드가 존재하지 않음
  • JDBC는 Connection 오브젝트 단위로 트랜잭션이 이뤄지는데, JdbcTemplate를 사용하면서 Connection 오브젝트를 사용하지 않음
  • JdbcTemplate는 템플릿 메소드를 실행할 때 마다 자동으로 Connection 오브젝트를 생성하기 때문에 독립적인 트랜잭션으로 실행될 수 밖에 없음
  • JDBC는 Connection 오브젝트 단위이므로, 하나의 Connection 오브젝트를 공유하면 트랜잭션을 묶을 수 있음
  • 또는, DAO 내부에 updgradeLevels()로직을 담는다면 하나의 트랜잭션으로 동작할 것임
  • 하지만, 기껏 DI 작업으로 분리한 비즈니스 로직데이터 로직을 다시 묶는 작업이 되므로 매우 바람직하지 않음

5.2.5 비즈니스 로직 내의 트랜잭션 경계설정

  • UserService와 UserDao를 그대로 둔 채 트랜잭션을 적용하려면 결국 트랜잭션의 경계설정 작업을 UserService로 가져와야 함
  • 이유는, upgradeLevels() 메소드의 시작과 함께 트랜잭션이 시작해야 하고 메소드를 종료와 함께 트랜잭션이 종료되야 하므로
  • 또한, 하나의 Connection 오브젝트로 트랜잭션이 동작하므로 upgradeLevels() 내부에 DB 커넥션도 만들고 종료를 해야함
  • 이러한 이유로 upgradesLevels()에서 사용되는 메서드들에 커넥션을 전달해 줘야함

5.2.6 UserService 트랜잭션 경계설정의 문제점

  • 네 가지 문제점이 존재함
    1. JdbcTemplate를 사용하지 못하고 , JDBC API를 사용하는 초기 방식으로 돌아가야 함
    2. Connection 오브젝트가 계속 메소드에 전달되야 함
      • UserService는 싱글톤으로 되어 있으니 UserService의 인스턴스 변수에 다른 메소드에서 사용할 수 없음
      • 멀티스레드 환경에서 공유하는 인스턴스 변수에 스레드별로 생성하는 정보를 저장하면 덮어씌워지는 경우가 발생하기 때문
    3. 더 이상 데이터 액세스 기술에 독립적일 수 없음
      • Connection이 전달되면 JPA나 하이버네이트로 구현 방식을 변경하려고 하면 Connection 대신 EntityManager나 Session 오브젝트를 UserDao 메소드가 전달받도로 해야되기 때문
    4. 테스트 코드에 영향을 끼침

5.2.7 트랜잭션 동기화

  • 스프링에서 멋진 방법을 제공하고 있음
  • upgradeLevels()메소드가 트랜잭션 경계설정을 해야하는 것은 피할 수 없음
  • 스프링이 제공하는 트랜잭션 동기화(Transaction Synchronization)을 사용하면 Connection 오브젝트가 전달되는 문제를 해결할 수 있음
  • 트랜잭션 동기화는 특별한 장소(TransactionSynchronization)에 Connection 오브젝트 보관하고, 이후 호출되는 DAO의 메소드에서 저장된 Connection을 가져다 씀
  • JdbcTemplate은 이 트랜잭션 동기화 방식을 사용하고 있음

5.2.8 JdbcTemplate 트랜잭션 동기화 순서

  1. UserService에서 Connection 생성
  2. Connection을 트랜잭션 동기화 저장소에 저장 및 setAutoCommit(fasle)를 호출
  3. 첫 번째 dao.update() 실행
  4. Connection을 생성하기 전에 트랜잭션 동기화 저장소에 Connection이 존재하는지 확인 및 가져옴
  5. 가져온 Connection을 이용해 PrepareStatement를 만들어 수정 SQL 실행
  6. Connection을 닫지않고 3번 부터 반복
  7. ...
  8. Connection의 commit()을 호출해서 트랜잭션 완료시킴
  9. 트랜잭션 동가화 저장소에서 Connection 제거

💡 트랜잭션 동기화 저장소은 작업 스레드마다 독립적으로 Connection 오브젝트를 저장하고 관리하기 때문에 멀티스레드 환경에서 충돌이 발생하지 않음

 

5.2.9 트랜잭션 동기화 적용

  • 스프링은 JdbcTemplate과 더불어 트랜잭션 동기화 기능을 제공하는 간단한 유틸리티 메소드제공하고 있음
  • UserService에서 DB 커넥션을 위해 DataSource가 필요하므로 DI 설정을 해 줘야함
  • 스프링에 제공하는 트랜잭션 동기화 관리 클래스는 TransactionSynchronizationManager가 있음
  • 해당 클래스의 initSynchronization()를 통해 초기화 요청을 함
  • DataSourceUtils에서 제공하는 getConnection()을 통해 DB 커넥션 생성 및 트랜잭션 동기화에 사용되도록 저장소바인딩
  • 해당 설정을 하면 JdbcTemplate의 작업에서 동기화 된 DB 커넥션 사용
  • JdbcTemplate는 트랜잭션 동기화 저장소에 DB 커넥션이 없을 경우에만 직접 DB 커넥션을 생성함
  • 따라서 DAO를 사용할 때 트랜잭션이 굳이 필요 없다면 바로 호출하면 되고, DAO 외부에서 트랜잭션을 만들고 관리가 필요하면 DB 커넥션을 생성하고 동기화를 해 주면 됨
public class UserService {
    protected DataSource dataSource;
    
    public void setDataSource(DataSource dataSource){
        this.dataSource = dataSource;
        }
        
    public void upgradeLevels() throws SQLException {
        TransactionSynchronizationManager.initSynchronization();
        //여기서 dataSource 필요
        Connection c = DataSourceUtils.getConnection(dataSource);
        c.setAutoCommit(false);
        try{
            List<User> users = userDao.getAll();
            for (User user : users) {
                if (canUpgradeLevel(user)) {
                    upgradeLevel(user);
                }
            }
            c.commit(); //커밋
        }catch (Exception e){
            c.rollback(); //롤백
            throw e;
        } finally {
            //스프링 유팅리티 메소드를 이용해 DB 커넥션을 안전하게 닫음
            DataSourceUtils.releaseConnection(c, dataSource);
            TransactionSynchronizationManager.unbindResource(this.dataSource);
            TransactionSynchronizationManager.clearSynchronization();
        }
    }
		...
}
public class UserServiceTest {
    @Autowired
    DataSource dataSource;
    
    @Test
    public void upgradeLevels() throws SQLException {
        ...
    }
    
    @Test
    public void upgradeAllOrNothing() throws Exception{
        UserService testUserService =
                new UserService.TestUserService(users.get(3).getId());
        testUserService.setUserDao(this.userDao);
        testUserService.setDataSource(this.dataSource);
        ...
    }
}
<beans
    ...>
    <bean id="userService" class="com.ksb.spring.UserService">
        <property name="userDao" ref="userDao"/>
        <property name="dataSource" ref="dataSource" />
    </bean>
    ...
<beans>

5.2.10 기술과 환경에 종속되는 트랜잭션 경계설정 코드

  • 새로운 문제가 발생함
  • G 회사가 사용자 관리 모듈을 구매해서 사용하고자 하는데, 하나의 트랜잭션 안에서 여러개 DB에 데이터를 넣는 작업을 할 것임
  • 하지만, 현재의 코드는 JDBC의 Connection을 이용한 트랜잭션 방식인 로컬 트랜잭션 방식임
  • 로컬 트랜잭션하나의 DB Connection에 종속되기 때문에 여러개 DB에 접근할 수 없음
  • 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션(Global Transaction) 방식 필요
  • 자바는 JDBC 외에 글로벌 트랜젝션을 지원하는 트랜잭션 매니저 APIJTA(Java Transaction API)를 제공하고 있음
  • DB는 JDBC, 메시징 서버는 JMS같은 API를 사용함
  • 글로벌 트랜잭션을 위해 JDBC나 JMS API를 직접 제어하지 않고, JTA를 통해 트랜잭션 매니저가 관리하도록 위임
  • 트랜잭션 매니저 XA 프로토콜을 통해 리소스와 연결됨
  • 이를통해 트랜잭션 매니저가 실제 DB메시징 서버의 트랜잭션을 종합적으로 제어할 수 있게 됨
  • 즉, G 회사의 요청은 트랜잭션 매니저와 트랜잭션 서비스를 사용할 테니 JDBC API가 아닌 JTA를 사용해 관리하게 해달라는 것임
  • JTA를 이용한 트랜잭션 경계설정 구조는 JDBC를 사용할 때와 비슷함
  • Connection의 메소드 대신 UserTransaction의 메소드를 사용하는 차이만 존재

💡 즉, 하나 이상의 DB가 참여하는 트랜잭션을 만들기위해 JTA 필요

 

5.2.11 기존의 UserService의 문제

  • UserService의 upgradeLevels()는 JDBC 로컬 트랜잭션을 이용한 코드임
  • JDBC 로컬 트랜잭션을 JTA를 이용하는 글로벌 트랜잭션으로 변경하려면 UserService를 수정해야 함
  • 로컬 트랜잭션을 사용면 충분한 고객은 JDBC를 이용한 트랜잭션 관리 코드를, G 회사 처럼 다중 DB를 위한 글로벌 트랜잭션을 필요하는 곳은 JTA를 이용한 트랜잭션 관리 코드를 적용해야 함
  • 즉, UserService는 로직 변경되지 않았음에도 기술적 환경에 따라 코드가 변경되야 함
  • 또, Y 회사에서 JDBC가 아닌 하이버네이트를 사용한 UserDao를 직접 구현한다고 요구함
  • 기존 DI 구조를 통해 UserService를 변경하지 않아도 XML에서 DB를 변경할 수 있음
  • 하지만, 하이버네이트를 이용한 트랜잭션 관리 코드는 JDBC나 JTA의 코드와 또 다름
  • 하이버네이트의 경우 Connection을 직접 사용하지 않고, Session을 사용함
  • 또한, 독자적인 트랜잭션 관리 API 사용함
  • Y 회사의 요구를 들어주기 위해서는 하이버네이트의 Session과 Transaction 오브젝트를 사용하는 트랜잭션 경계설정 코드를 변경할 수 밖에 없음
  • 💡 즉, UserService는 로직이 변경되지 않아도 기술에 따라 변경되는 문제 발생

5.2.12 트랜잭션 API의 의존관계 문제와 해결책

  • 기존의 UserService는 데이터 액세스 기술이 변경되더라도 UserService의 코드는 영향받지 않았음
  • 하지만, UserService에서 경계설정 코드를 추가하면서 특정 데이터 액세스 기술(JDBC)에 종속적인 구조가 되었음
  • UserService는 UserDaoJdbc애 간접적 의존하는 코드가 되었음
  • 다행히, 트랜잭션의 경계설정 담당하는 코드는 일정한 패턴을 가지는 유사한 구조임
  • 여러 트랜잭션 경계설정 담당 코드의 공통점을 추상화 하여 하위 시스템이 변경되더라도 일관된 방법으로 접근할 수 있음

5.2.13 스프링의 트랜잭션 서비스 추상화

  • 스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있음
  • 해당 기술로 각 기술의 API를 이용하지 않아도, 일관된 방식으로 트랜잭션을 제어하는 경계설정 작업이 가능함
  • 스프링이 제공하는 추상 인터페이는 PlatformTransactionManager
  • JDBC의 로컬 트랜잭션을 이용하면 dataSource를 DataSourceTransactionManager의 생성자에 전달하여 오브젝트 생성
  • getTransaction()만 해주면 필요에 따라 트랜잭션 매니저가 DB 커넥션을 가져오는 작업 수행 및 트랜젹센 시작
  • DefaultTransactionDefinition는 트랜잭션의 속성을 가짐
  • 시작된 트랜잭션은 TransactionStatus 타입의 변수에 저장됨
  • TransactionStatus는 조작이 필요할 때 메소드의 파라미터로 전달하면 됨

public class UserService {
    ...
    public void upgradeLevels() {
        PlatformTransactionManager transactionManager =
                new DataSourceTransactionManager(dataSource);
        TransactionStatus status =
                transactionManager.getTransaction(
                        new DefaultTransactionDefinition());
        try{
            List<User> users = userDao.getAll();
            for (User user : users) {
                if (canUpgradeLevel(user)) {
                    upgradeLevel(user);
                }
            }
            transactionManager.commit(status); //커밋
        }catch (Exception e){
            transactionManager.rollback(status); //롤백
            throw e;
        }
    }
		...
}

5.2.14 트랜잭션 기술 설정의 분리

  • 트랜잭션 추상화 API를 적용한 UserService 코드를 JTA를 이용하는 글로벌 트랜잭션으로 변경
  • DataSourceTransactionManager를 JTATransactionManager로 변경하면 됨
PlatformTransactionManager txManager = new JTATransactionManager();
  • 는 주요 자바 서버에서 제공하는 JTA 정보를 JNDI를 통해 자동으로 인식하는 기능을 가지고 있음
  • JTATransactionManager만 사용해도 서버의 트랜잭션 매니저/서비스와 연동해서 동작됨
  • 하지만, 어떤 트랜잭션 매니저 구현 클래스를 사용할지 UserService가 알고 있는 것은 DI 원칙에 위배
  • 컨테이너를 통해 DI 받도록 해야함
  • 모든 PlatformTransactionManager 구현 클래스는 싱글톤으로 사용 가능하므로 빈으로 등록해도 됨
  • 일반적으로 인터페이스 이름, 변수 이름, 수정자 메소드 이름은 모두 같도록 통일하지만, PlatformTransactionManager는 관례적으로 transactionManger라는 이름 사용
  • UserService에 추가한 DataSource는 더이상 사용되지 않으므로 제거 및 transactionManger 추가
  • JDBC를 사용할 것이기 때문에 XML에 PlatformTransactionManager구현 클래스인 DataSourceTransactionManager로 설정
  • JTA를 이용하려면 JtaTransactionManger로 설정
public class UserService {
    private PlatformTransactionManager transactionManager;
    ...

    public void setTransactionManager(PlatformTransactionManager
                                      transactionManager){
        this.transactionManager = transactionManager;
    }
    public void upgradeLevels() {
        TransactionStatus status =
                this.transactionManager.getTransaction(
                        new DefaultTransactionDefinition());
        try{
            List<User> users = userDao.getAll();
            for (User user : users) {
                if (canUpgradeLevel(user)) {
                    upgradeLevel(user);
                }
            }
            this.transactionManager.commit(status); //커밋
        }catch (Exception e){
            this.transactionManager.rollback(status); //롤백
            throw e;
        }
    }
}

public class UserServiceTest {
		@Autowired
    PlatformTransactionManager transactionManager;
    ...
    @Test
    public void upgradeAllOrNothing() {
        UserService testUserService =
                new UserService.TestUserService(users.get(3).getId());
        testUserService.setUserDao(this.userDao);
        testUserService.setTransactionManager(this.transactionManager);

        userDao.deleteAll();
        for(User user : users) userDao.add(user);

        try {
            testUserService.upgradeLevels();
            fail("TestUserServiceException expected");
        } catch (UserService.TestUserServiceException e){
        }

        checkLevelUpgraded(users.get(1), false);
    }
}
<beans
    ...>
    <bean id="userService" class="com.ksb.spring.UserService">
        <property name="userDao" ref="userDao"/>
        <property name="transactionManager" ref="transactionManager" />
    </bean>

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