본문 바로가기

Spring/토비의 스프링 3.1

5.2 트랜잭션 서비스 추상화


정기 사용자 레벨 관리 작업을 수행하는 도중에 네트워크가 끊기거나 서버에 장애가 생겨서 작업을 완료할 수 없다면, 그 때까지 변경된 사용자의 레벨은?

  • 작업이 도중에 중단된다면 그때까지 진행된 변경 작업도 모두 취소시키도록 결정

5.2.1 모 아니면 도

현재의 상태는? 테스트를 통해 확인

  • 시스템 예외상황을 인위적으로 만드는 것은 거의 불가능할 뿐더러 자동화된 테스트가 불가능하므로 좋지 않음
  • 예외 상황을 의도적으로 만들어 테스트

테스트용 UserService의 대역

테스트용으로 특별히 만든 UserService의 대역을 사용하는 방법이 좋음

  • 테스트를 위해 소스코드를 함부로 건드리는 것은 좋지 않음
  • UserService를 상속해서 일부 메소드를 오버라이딩
  • 테스트에서만 사용할 클래스의 경우 클래스 내부에 스태틱 클래스로 만들면 간편함
  • upgradeLvl() 메소드가 현재 private으로 접근 불가하므로 protected로 바꿔줌(어쩔 수 없음)
protected void upgradeLvl(User user) { ... }

UserServiceTest.java

  • UserService를 상속한 TestUserService를 Test 클래스 내부에 static 클래스로 선언한다.
  • UserService의 upgradeLvl()을 오버라이드하며, 지정된 id의 User 오브젝트가 발견되면 예외를 던져서 작업을 강제로 중단한다.
  • 예외 역시 클래스 내부에 static 클래스로 정의한다.
public class UserServiceTest {
    // ...

    static class TestUserService extends UserService {
        private String id;

        private TestUserService(String id) {
            this.id = id;
        }

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

    static class TestUserServiceException extends RuntimeException {
        private static final long serialVersionUID = 1L;
    }
}

강제 예외 발생을 통한 테스트

레벨 업그레이드 도중 예외 발생 시 이전에 업그레이드 된 사용자가 다시 원래 상태로 돌아가는지 테스트

public class UserServiceTest {
    @Test
    public void upgradeAllOrNothing() {
        // 예외를 발생시킬 네 번째 사용자의 id를 넣어서 테스트용 UserService 대역 오브젝트를 생성한다.
        UserService testUserService = new TestUserService(users.get(3).getId());
        // userDao를 수동 DI 해준다.
        testUserService.setUserDao(this.userDao);

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

        // TestUserService는 업그레이드 작업 중 예외가 발생해야한다.
        // 정상 종료라면 문제가 있으니 강제로 실패
        try {
            testUserService.upgradeLvls();
            fail("TestUserServiceException expected");
        // TestUserService가 던져주는 예외를 잡아서 계속 진행하도록 한다.
        // 그 외의 예외라면 테스트 실패
        } catch (TestUserServiceException e) {
        }

        // 예외가 발생하기 전 레벨 변경이 있었던 사용자의 레벨이 처음 상태로 바뀌었나 확인
        checkLvlUpgraded(users.get(1), false);
    }
}

테스트 프로세스

  1. TestUserService의 오브젝트 생성
    • 생성자 파라미터 : 예외를 발생시킬 사용자의 id
  2. UserDao를 수동으로 DI
    • 테스트 메소드에서 특별한 목적으로 사용되므로 스프링 빈으로 등록할 필요 없음
  3. 다섯 개의 사용자 정보를 등록
  4. 4번째 사용자 오브젝트 차례가 되면 TestUserServiceException 발생
  5. 예외가 발생하지 않고 정상 종료되면 테스트 실패이므로 강제로 테스트를 실패
  6. 두 번째 사용자 레벨이 변경됐는지 확인

테스트 실패의 원인

트랙잭션 : 더 이상 나눌 수 없는 단위 작업

모든 사용자에 대한 업그레이드 작업은 전체 다 성공하든지 전체 다 실패해야 한다.

5.2.2 트랜잭션 경계설정

DB는 그 자체로 완벽한 트랜잭션을 지원하지만 여러 개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 취급해야 하는 경우에는 따로 설정을 해주어야 한다.

  • 트랜잭션의 완료 : 트랜잭션 롤백 / 트랜잭션 커밋

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

트랜잭션은 시작하는 지점과 끝나는 지점이 있다.

  • 트랜잭션의 시작은 한 가지 방법
  • 트랜잭션이 끝나는 지점
    1. 롤백 : 모든 작업을 무효화
    2. 커밋 : 모든 작업을 확정

트랙잭션을 사용한 JDBC 코드의 예

// DB 커넥션 시작
Connection c = dataSource.getConnection();

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

try {
    PreparedStatement st1 = c.preparedStatement("update users ...");
    st1. executeUpdate();

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

    // 트랜잭션 커밋
    c.commit();
} catch (Exception e) {
    // 트랜잭션 롤백
    c.rollback();
}
// 트랙잭션 종료

c.close();
// DB 커넥션 종료

JDBC 트랜잭션은 하나의 Connection을 가져와 사용하다가 닫는 사이에서 일어난다.

  • 트랜잭션 시작 방법 : 자동 커밋을 false로 변경
  • JDBC 기본 설정은 DB 작업 직후 자동으로 커밋이 되도록 되어 있기 때문

트랜잭션 경계설정(Transaction Demarcation)
트랜잭션의 시작(autoCommit = false)과 끝(commit/rollback)을 설정해주는 것
로컬 트랜잭션(Local Transaction)
하나의 DB 커넥션 안에서 만들어지는 트랜잭션

UserService와 UserDao의 트랜잭션 문제

JDBC 트랜잭션 경계 설정 메소드는 모두 Connection 오브젝트를 사용하도록 되어 있음.

  • Connection 객체는 어디에 있지?
  • 일반적으로 트랜잭션은 커넥션보다도 존재 범위가 짧다.
  • 따라서 템플릿 메소드가 호출될 때마다 트랜잭션이 새로 만들어지고 메소드를 빠져나오기 전에 종료된다.
  • JdbcTemplate의 메소드를 사용하는 UserDao는 각 메소드마다 하나씩의 독립적인 트랜잭션으로 실행될 수 밖에 없다.

테스트가 실패하는 이유

  • update()가 호출될 때마다 작업이 성공하면 그 결과는 이미 트랜잭션이 종료되면서 커밋이 됨
  • 즉, 1,2,3번째 update()는 모두 커밋이 되고 그 결과는 DB에 남음

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

결국은 트랜잭션의 경계설정 작업을 UserService 쪽으로 가져와야 한다.

  • 프로그램의 흐름상 upgradeLvls() 메소드의 시작과 함께 트랜잭션이 시작되어야 하고, 메소드를 빠져나올 때 종료되어야 함
  • DB 커넥션 역시 이 메소드 안에서 만들고, 종료시켜야 함

upgradeLvls() 트랜잭션 경계설정의 구조

public void upgradeLvls() throws Exception {
    // (1) DB Connection 시작
    // (2) 트랜잭션 시작
    try {
        // (3) DAO 메소드 호출
        // (4) 트랜잭션 커밋
    } catch (Exception e) {
        // (5) 트랜잭션 롤백
        throw e;
    } finally {
        // (6) DB Connection 종료
    }
}

그런데 Connection 오브젝트를 가지고 데이터 액세스 작업을 진행하는 코드는 UserDao의 update() 메소드 안에 있어야 한다.

  • UserDao의 update() 메소드는 upgradeLvls() 메소드에서 만든 Connection을 사용해야 한다.
  • 따라서 Connection 오브젝트를 파라미터로 전달해줘야 한다.

UserDao 메소드의 구조

public interface UserDao {
    public void add(Connection c, User user);
    public User get(Connection c, String id);
    // ...
    public void update(Connection c, User user1);
}

게다가 UserService의 upgradeLvls()는 UserDao의 update()를 직접 호출하지 않는다.

  • UserDao를 사용하는 upgradeLvl() 메소드에서도 Connection 객체를 파라미터로 전달해주어야 한다.
class UserService {
    public void upgradeLvls() throws Exception {
        Connection c = ...;
        // ...
        try {
            // ...
            upgradeLvl(c, user);
            // ...
        }
        // ...
    }

    protected void upgradeLvl(Connection c, User user) {
        user.upgradeLvl();
        userDao.update(c, user);
    }
}

interface UserDao {
    public update(Connection c, User user);
    // ...
}

헬게이트다.

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

  1. JdbcTemplate을 더 이상 활용할 수 없다.
    • JDBC API를 직접 사용하는 초기 방식으로 되돌아감
  2. UserService의 메소드에 Connection 파라미터가 추가되어야 한다.
    • upgradeLvls()에서 사용하는 메소드 어딘가에서 DAO를 필요로 한다면 그 사이의 모든 메소드에 걸쳐 Connection 파라미터를 전달해주어야 함.
    • UserService는 싱글톤이므로 해당 Connection을 어딘가 저장해뒀다가 다른 메소드에서 사용하는 것도 불가능
  3. UserDao가 더 이상 데이터 액세스 기술에 독립적일 수 없다.
  4. 테스트 코드에 영향을 미친다.
    • 테스트 코드에서 직접 Connection 오브젝트를 일일이 만들어서 DAO 메소드를 호출하도록 변경해야 한다.

5.2.3 트랜잭션 동기화

당연히 스프링은 이러한 문제점을 해결할 방법을 제공해준다.

Connection 파라미터 제거

일단 문제의 원인인 Connection 파라미터를 제거하자.

  • 그럼에도 불구하고, upgradeLvls() 메소드가 트랜잭션 경계설정을 해야함
  • 독립적인 트랜잭션 동기화(Transaction Syncronization) 방식을 사용

트랜잭션 동기화
UserService에서 트랜잭션을 시작하기 위해 만든 Connection을 특별한 저장소에 보관해두고, 이후에 호출되는 DAO 메소드에서는 저장된 Connection을 가져다 사용하는 방법

DAO가 사용하는 JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 하는 것

  1. UserService는 Connection을 생성
  2. Connection을 동기화 저장소에 저장해두고 트랜잭션을 시작시킨 후 DAO의 기능을 이용
  3. 첫 번째 update() 호출
  4. 현재 시작된 트랜잭션을 가진 Connection 오브젝트가 존재하는지 확인하고 2.upgradeLvls() 메소드 시작 부분에서 저장해 둔 Connection을 발견하고 이를 가져옴
  5. Connection을 이용하여 PreparedStatement를 만들어 수정 SQL을 실행
    • 트랜잭션 동기화 저장소에서 DB 커넥션을 가져온 경우 Connection을 닫지 않은 채로 작업을 마침
  6. 두 번째 update()가 호출되면
  7. 트랜잭션 동기화 저장소에서 Connection을 가져와
  8. 사용한다.
  9. 마지막 update()도
  10. 같은 트랜잭션을 가진 Connection을 가져와
  11. 사용한다.
  12. 트랜잭션 내 모든 작업이 정상적으로 끝나면 commit()
  13. 트랜잭션 저장소가 더 이상 Connection 오브젝트를 저장해두지 않고록 제거

작업 중 예외가 발생하면 즉시 rollback 호출 후 트랜잭션 종료

트랜잭션 동기화 적용

멀티스레드 환경에서 안전한 트랜잭션 동기화를 구현하는 일은 기술적으로 쉽지 않지만 스프링이 제공해 줌

UserService.java

public class UserService {
    private DataSource dataSource;

    // Connection을 생성할 때 사용할 dataSource를 DI 받도록 한다.
    public void setDataSource(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void upgradeLvls() throws Exception {
        // 트랜잭션 동기화 관리자를 이용해 동기화 작업을 초기화한다.
        TransactionSynchronizationManager.initSynchronization();

        // DB 커넥션을 생성하고 트랜잭션을 시작한다.
        // 이후의 DAO 작업은 모두 여기서 시작한 트랜잭션 안에서 진행된다.

        // DB 커넥션 생성과 동기화를 함께 해주는 유틸리티 메소드
        Connection c = DataSourceUtils.getConnection(dataSource);
        // 트랜잭션 시작
        c.setAutoCommit(false);

        try {
            List<User> users = userDao.getAll();
            for (User user : users) {
                if (canUpgradeLvl(user)) {
                    upgradeLvl(user);
                }
            }

            // 정상적으로 작업을 마치면 트랜잭션 커밋
            c.commit();
        } catch (Exception e) {
            // 예외 발생 시 롤백
            c.rollback();
            throw e;
        } finally {
            // 스프링 유틸리티 메소드를 이용해 DB 커넥션을 안전하게 닫음
            DataSourceUtils.releaseConnection(c, dataSource);
            // 동기화 작업 종료 및 정리
            TransactionSynchronizationManager.unbindResource(this.dataSource);
            TransactionSynchronizationManager.clearSynchronization();
        }
    }
}

코드 분석

  1. UserService에서 DB 커넥션을 직접 다룰 때 DataSource가 필요하므로 DataSource 빈에 대한 DI 설정을 해둬야 함
  2. 스프링이 제공하는 트랜잭션 동기화 관리 클래스 : TransactionSycronizationManager
  3. 트랜잭션 동기화 작업 초기화하도록 요청(TransactionSyncroniztionManager)
  4. DB 커넥션 생성(DataSourceUtils.getConnection())
    • 일반 커넥션이 아니라 DataSourceUtils의 메소드를 쓰는 이유 : Connection 오브젝트 생성과 Connection 객체를 저장소에 바인딩 해 줌
  5. 트랜잭션 시작
  6. 트랜잭션 내의 작업 진행
  7. 작업을 정상적으로 마치면 commit()
  8. 예외 발생 시 rollback()
  9. DB 커넥션 닫기와 동기화 중단

트랜잭션 테스트 보완

UserDaoTest.java

  • upgradeAllOrNothing() 메소드 내 dataSource 빈을 가져와 주입해주는 코드 추가
public class UserDaoTest {
    @Test
    public void upgradeAllOrNothing() throws Exception {
        UserService testUserService = new TestUserService(users.get(3).getId());
        testUserService.setUserDao(this.userDao);
        testUserService.setDataSource(this.dataSource);

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

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

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

다른 테스트도 문제없이 성공하게 만들기 위해서는 설정 파일을 수정해주어야 함

  • upgradeAllOrNothing()은 TestUserService를 직접 구성
  • upgradeLvls()은 스프링 컨테이너가 초기화한 userService를 사용해야 함

JdbcTemplate과 트랜잭션 동기화

JdbcTemplate의 동작과정

  • 동기화 저장소에 등록된 DB 커넥션이나 트랜잭션이 없는 경우, 직접 DB 커넥션을 만들고 트랜잭션을 시작
  • 트랜잭션 동기화가 시작되었다면 동기화 저장소에 있는 Connection을 가져와 사용

5.2.4 트랜잭션 서비스 추상화

JDBC만 이용하는 로컬 트랜잭션을 사용한다면 가장 깔끔한 수준의 코드

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

한 개 이상의 DB로의 작업을 하나의 트랜잭션을 만들어야 하는 경우

  • JDBC의 Connection을 이용하는 로컬 트랜잭션으로는 불가능
  • 로컬 트랜잭션은 하나의 DB Connection에 종속됨

글로벌 트랜잭션(Global Transaction)
한 개 이상의 DB로의 작업을 하나의 트랜잭션으로 만드는 기술

자바에서 제공하는 글로벌 트랜잭션 지원 매니져 : JTA(Java Transaction API)

JTA를 사용해 트랜잭션을 관리하는 코드의 구조

// JDNI를 이용해 서버의 UserTransaction 오브젝트를 가져온다.
InitialContext ctx = new InitialContext();
UserTransaction tx = (UserTransaction)ctx.lookup(USER_TX_JDNI_NAME);

tx.begin();
// JDNI로 가져온 dataSource를 사용해야한다.
Connection c = dataSource.getConnection();

try {
    // 데이터 액세스 코드
    tx.commit();
} catch (Exception e) {
    tx.rollback();
    throw e;
} finally {
    c.close();
}

전체적인 구조는 JDBC를 사용했을 때와 비슷함

  • 문제는 글로벌 트랜잭션을 사용하기 위해서는 UserService의 코드를 수정해야 함
  • 즉, UserService가 자신의 로직이 바뀌지 않았음에도 불구하고 기술환경에 따라 코드가 변경되어야 한다.

만약 다른 고객은 Hibernate를 이용하길 원한다면?

  • 하이버네이트는 JDBC나 JTA의 코드와는 또 다름

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

UserService가 특정 데이터 액세스 기술에 종속되는 구조가 되어 버림

  • 다행히 트랜잭션의 경계설정을 담당하는 코드는 일정한 패턴을 갖는 유사한 구조임
  • 여러 기술의 사용 방법에 공통점이 있다면 추상화를 생각해볼 수 있음

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

스프링이 제공하는 트랜잭션 추상화 방법을 적용한 UserService.java

public class UserService {
    public void upgradeLvls() throws Exception {
        PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            List<User> users = userDao.getAll();
            for (User user : users) {
                if (canUpgradeLvl(user)) {
                    upgradeLvl(user);
                }
            }
            transactionManager.commit(status);
        } catch (Exception e) {
            transactionManager.rollback(status);
            throw e;
        }
    }
}

소스 분석

  1. PlatformTransactionManager : 스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스
    • DataSourceTransactionManager : JDBC 로컬 트랜잭션을 사용하는 경우
    • 사용할 DB의 dataSource를 파라미터로 넘겨줌
  2. getTransaction() : 트랜잭션 시작
    • DefaultTransactionDefinition() : 트랜잭션에 대한 속성(후술)
  3. TransactionStatus : 시작된 트랜잭션이 저장됨
    • 트랜잭션에 대한 조작이 필요한 때 PlatformTransactionManager 메소드의 파라미터로 전달해주면 됨

트랜잭션 기술 설정의 분리

JTA 또는 하이버네이트를 이용하는 글로벌 트랜잭션으로 변경하려면?

  • DataSourceTransactionManager를 JTATransactionManager/HibernateTransactionManager로 변경
PlatformTransactionManager txManager = new JTATransactionManager();
PlatformTransactionManager txManager = new HibernateTransactionManager();

어떤 트랜잭션을 사용할 지 UserService 코드가 알고 있는 것은 DI 원칙에 위배되므로 스프링 DI 방식으로 바꾸어야 함

  • DataSourceTransactionManager를 스프링 빈으로 등록하고 UserService가 DI 방식으로 사용

스프링 빈 등록 시

  • 싱글톤으로 만들어져 여러 스레드에서 동시에 사용해도 괜찮은 지를 확인해야 함
  • PlatformTransactionManager의 경우 싱글톤으로 사용이 가능

스프링 빈으로 등록

  • UserService에 PlatformTransactionManager 인터페이스 타입의 인스턴스 변수 선언
  • 수정자 메소드를 추가
  • PlatformTransactionManager의 경우 관례적으로 transactionManager라는 변수명을 사용함

UserService의 DataSource 변수와 수정자 메소드는 제거해도 됨

  • PlatformTransactionManager만 있으면 Connection 생성과 트랜잭션 경계 설정을 모두 이용 가능
  • PlatformTransactionManager 타입 오브젝트를 생성하는 코드도 제거

UserService.java

public class UserService {
    private PlatformTransactionManager transactionManager;
    
    public void setTransactionManager(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }
    
    public void upgradeLvls() throws Exception {
        TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            // ...
            this.transactionManager.commit(status);
        } catch (Exception e) {
            this.transactionManager.rollback(status);
            // ...
        } 
    }
}

transactionManager 빈을 설정파일에 등록

<bean id="userService" class="com.david.tobysspring.user.service.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>
  1. DataSourceTransactionManager
    • dataSource 빈으로부터 Conntection을 가져와 처리를 해야하므로 dataSource 프로퍼티를 가짐
  2. userService
    • 기존 dataSource 프로퍼티를 없애고 새롭게 추가한 transactionManager 빈을 받도록 프로퍼티를 설정

테스트 수정

  • 트랜잭션 예외 상황을 위해 수동 DI하는 upgradeAllOrNothing() 수정 필요
  • transactionManager를 Autowired로 주입받게 하고 이를 직접 DI 해야함
public class UserServiceTest {
    @Autowired PlatformTransactionManager transactionManager;

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

        // ...
    }
}

UserService는 트랜잭션 기술에 완전히 독립적인 코드가 됨

  • JTA로 변경하고 싶다면 설정파일만 변경해주면 됨
<bean id="transactionManager" class="org.springframwork.transaction.jta.JtaTransactionManager" />


' Spring > 토비의 스프링 3.1' 카테고리의 다른 글

5.4 메일 서비스 추상화  (0) 2019.02.07
5.3 서비스 추상화와 단일 책임 원칙  (0) 2019.02.06
5.1 사용자 레벨 관리 기능 추가  (0) 2019.02.06
5장. 서비스 추상화  (0) 2019.02.06
4.3 정리  (0) 2019.01.25