레벨 업그레이드 작업 중 에러가 발생하여 일부 유저만 수정되었다면 어떻게 처리해야할까?

고객들의 불만을 일으키지 않기 위해, 원상태로 돌려놓아야 한다.

즉, upgradeLevels()에 하나의 작업단위인 트랜잭션을 적용해야 한다.

_이전 포스팅_에서의 UserService의 upgradeLevels() : 실행 중 에러가 나서 일부 유저만 수정된다면, 그 상태가 유지되며 rollback되지 않는다. 이는 하나의 트랜잭션 안에서 동작하지 않았기 떄문이다.

트랜잭션이란?
중간에 예외가 발생해서 작업을 완료할 수 없다면
아예 작업이 시작되지 않은 것처럼 초기 상태로 돌려놓는 것

1. 트랜잭션 경계설정

복잡한 로직의 흐름 사이에서 정확하게 트랜잭션 경계를 설정하는 일은
매우 중요한 작업이다.

DB는 그 자체로 완벽한 트랜잭션을 지원한다. 하지만 여러개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 취급해야 하는 경우도 있다 (비즈니스 로직).

  • 문제가 발생할 경우
    • 트랜잭션 롤백 : 앞에서 처리한 SQL 작업도 취소
  • 문제가 발생하지 않는 경우
    • 트랜잭션 커밋 : 성공적인 결과를 DB에 알려줘서 작업을 확정

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

트랜잭션 경계설정 : setAutoCommit(false)로 트랜잭션의 시작을 선언하고 commit() 또는 rollback()으로 트랜잭션을 종료하는 작업

트랜잭션의 경계는 하나의 Connection이 만들어지고 닫히는 범위 안에 존재한다는 점도 기억해두자.

Connection c = dataSource.getConnection();

c.setAutoCommit(false); // 트랜잭션 시작

try { // 트랜잭션 하나의 작업
  PreparedStatement st1 = c.prepareStatement("update users ... ");
  st1.executeUpdate();

  PreparedStatement st2 = c.prepareStatement("delete users ... ");
  st2.executeUpdate();

  c.commit();// 트랜잭션 커밋
} catch (Exception e) {
  c.rollback();
}

그렇다면 비즈니스 로직에 트랜잭션 적용을 위해 Connection을 추가해야할까?

답은 NO. 지금까지 성격과 책임이 다른 코드를 분리하고, 느슨하게 연결해서 확장성을 좋게 하려고 많은 수고를 해왔는데, 여기서 트랜잭션 문제를 해결한답시고 비즈니스 로직과 데이터 로직을 한데 묶어버리는 건 용납할 수 없다.

그렇다면 트랜잭션의 시작과 종료를 담당하는 Connection 오브젝트를 파라미터 인자로 가져오게 만드는 것은 어떨까?

UserService와 UserDao를 이런식으로 수정하면, 책임이 다른 코드를 분리해둔 채로 트랜잭션 문제를 해결할 순 있지만, 여러가지 문제가 발생한다.

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

Connection 오브젝트를 인자로 넘긴다면 무슨 문제가 있을까?

  1. DB 커넥션을 비롯한 리소스의 깔끔한 처리를 가능하게 했던 JdbcTemplate을 더 이상 활용할 수 없다.
  2. DAO의 메소드와 비즈니스 로직을 담고 있는 UserService의 메소드에 Connection 파라미터가 추가돼야 한다. 해당 메소드에 걸쳐서 Connection 오브젝트가 계속 전달돼야 한다… 극혐. 지저분함.
  3. Connection 파라미터가 UserDao 인터페이스 메소드에 추가되면 UserDao는 더이상 데이터 액세스 기술에 독립적일 수 없다.
    Jpa나 하이버네이트로 구현방식을 변경하려고 하면 Connection대신 EntityManager나 Session오브젝트를 UserDao 메소드가 전달받도록 해야 한다. 결국 코드가 계속 수정돼야 한다.
  4. 테스트 코드에도 영향을 미친다. 직접 Connection 오브젝트를 일일이 만들어서 DAO 메소드를 호출하도록 모두 변경해야 한다.

비즈니스 로직을 담고 있는 UserService 메소드 안에서 트랜잭션의 경계를 설정해 관리하려면 지금까지 만들었던 깔끔하게 정리된 코드를 포기해야 할까? 아니면, 트랜잭션을 포기해야 할까?

스프링은 이 딜레마를 해결할 수 있는 ‘트랜잭션 동기화‘라는 멋진 방법을 제공해준다.

2. 트랜잭션 동기화

트랜잭션 동기화란 UserService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소에 보관해두고, 이후에 호출되는 DAO의 메소드에서는 저장된 Connection을 가져다가 사용하게 하는 것이다.
정확히는 DAO가 사용하는 JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 하는 것이다.

트랜잭션 동기화 적용 UserService

public class UserService {
    ...
    public void upgradeLevels() throws Exception {
      TransactionSynchronizationManager.initSynchronization();
      // 트랜잭션 동기화 관리자를 이용해 동기화 작업을 초기화
    
      Connection c = DataSourceUtils.getConnection(dataSource); 
      // DB 커넥션 생성과 동기화를 함께 해주는 유틸리티 메소드
      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 {
        DataSourceUtils.releaseConnection(c, dataSource); // DB 커넥션을 안전하게 닫는다.
    
        // 동기화 작업 종료 및 정리
        TransactionSynchronizationManager.unbindResource(this.dataSource);
        TransactionSynchronizationManager.clearSynchronization();
      }
    }
    public void setDataSource(DataSource dataSource) {
      this.dataSource = dataSource;
    }
}
  • DataSourceUtils.getConnection() : Connection 오브젝트를 생성해줄 뿐만 아니라 트랜잭션 동기화에 사용하도록 저장소에 바인딩 해줌.

동기화 준비가 됐으면 트랜잭션을 시작하고 Dao의 메소드를 사용하는 트랜잭션 내의 작업을 진행한다. 트랜잭션 동기화가 되어 있는 채로 JdbcTemplate을 사용하면 JdbcTemplate의 자업에서 동기화시킨 DB 커넥션을 사용하게 된다.

결국 UserDao를 통해 진행되는 모든 JDBC작업은 upgradeLevels() 메소드에서 만든 Connection 오브젝트를 사용하고 같은 트랜잭션에 참여하게 된다.

이로써 지저분한 Connection파라미터의 문제를 해결!

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

지금까지 데이터 엑세스 부분과 비즈니스 로직을 잘 분리, 유지할 수 있게 만든 코드를 만들었다.

하지만, 고객측에서 DB연결 방법을 바꾸고 싶다는 요구가 온다면?

  • DataSource 인터페이스와 DI를 적용한 덕분에 UserDao,UserService는 수정하지 않아도 된다.
  • UserService에서 JDBC의 Connection을 이용한 트랜잭션 방식인 로컬 트랜잭션으로는 다른 DB의 트랜잭션을 보장해주지 못한다. 즉, 별도의 트랜 잭션 관리자를 통해 글로벌 트랜잭션이 필요하다.

하나의 트랜잭션 안에서 여러 개의 DB에 데이터를 넣는 작업을 해야 할 때는 JTA를 사용해야 한다.

JTA

JTA를 이용해 트랜잭션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다. 이는 11장에서 자세히 설명한다.

JTA를 이용한 트랜잭션 코드 구조

public void sample(){
  
  // JNDI를 이용해 서버의 UserTransaction 오브젝트를 가져온다.
  InitialContext ctx = new InitialContext();
  UserTransaction tx = ctx.lookup(USER_TX_JNDI_NAME);
  
  tx.begin();
  // JNDI로 가져온 dataSource를 사용한다.
  Connection c = dataSource.getConnection();  
  
  try{
    // 데이터 엑세스 코드
    tx.commit();
  }catch (Exception e){
    tx.rollback();
    throw e;
  }finally{
    c.close();
  }
}

JTA 의 의존관계 문제와 해결책

UserService는 UserDAO 인터페이스에만 의존하는 구조였다.

  • DAO 클래스의 구현기술이 JDBC, 하이버네이트, 여타 기술로 바뀌더라도 UserService 코드는 영향을 받지 않는다. ( OCP 원칙 코드 )
  • 문제는 JDBC에 종속적인 Connection을 이용한 트랜잭션 코드가 UserService에 등장하는 것이다.

트랜잭션도 데이터액세스 기술처럼 특정 기술에 의존적이지 않고 독립적일 수 있게 할 수 있는 ‘추상화’ 방법이 있다.

추상화란?
하위시스템의 공통점을 뽑아내서 분리시키는 것.

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

다행히 트랜잭션의 트랜잭션 경계설정을 담당하는 코드는 일정한 패턴을 갖는 유사한 구조다. 즉, 추상화가 필요하다.

스프링의 트랜잭션 서비스 추상화 방법을 이용한 개선코드

public void upgradeLevels() {

  // JDBC 트랜잭션 추상 오브젝트 사용
  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);// 트랜잭션 커밋

트랜잭션 기술 설정의 분리

위의 코드를 보면 UserService가 어떤 트랜잭션 매니저 구현 클래스를 사용할지를 스스로 알고 있다. 이는 DI 원칙에 위배된다.
자신이 사용할 구체적인 클래스를 스스로 결정하고 생성하지 말고 컨테이너를 통해 외부에서 제공받게 하는 스프링의 DI 방식으로 바꾸자.

개선코드

public class UserService {
  private PlatformTransactionManager transactionManager;

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

  public void upgradeLevels() {

    // DI 받은 트랜잭션 매니저를 공유해서 사용한다. ( 멀티스레드 환경에서도 안전 )
    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;
    }
  }
<bean id="userService" class="springbook.user.service.UserService">
 <property name="userDao" ref="userDao" />
 <property name="dataSource" ref="dataSource" />
 <property name="transactionManager" ref="transactionManager" />
</bean>

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

댓글남기기