토비의 스프링 정리 프로젝트 #5.2 트랜잭션 서비스 추상화

Jake Seo·2021년 8월 14일
1

토비의 스프링

목록 보기
28/29

트랜잭션 서비스 추상화

사용자 레벨 작업의 특징은 사용자 데이터를 '1개씩' 조회 후에 조건에 맞는 사용자를 '1개씩' 업데이트하는 것이다.

여기서 중요한 건 '1개씩' 이라는 키워드인데 그렇다면 1000개의 데이터 중 애매하게 237개의 데이터까지 업데이트 되다가 238번째 데이터를 업데이트 하려는 순간 회사에 있던 MBC뉴스 기자가 차단기를 내려 정전이 일어나 컴퓨터가 종료되면 어떻게 될까?

현재까지의 로직으로는 237번째 데이터까지는 이미 업그레이드 된 상태이고, 238번째 데이터부터 다시 시작해야 할 것이다.

만일 은행과 같이 실제 돈을 다루는 곳에서 위와 같은 일이 일어나면 대형사고일 것이다. 누군가는 돈을 지급받고 누군가는 지급받지 못하는 현상이 발생했기 때문이다. 어떤 고객은 이를 차별대우로 느낄 수도 있다.

차라리 아예 데이터를 업데이트 안 된 깨끗한 상태로 유지하고 상황이 안정화 된 이후에 다시 데이터 업데이트를 시도하는 편이 좋을 것이다.

모 아니면 도

이번에는 테스트 과정 중 위에서 설명했던 정전과 같은 사고를 재현해야 한다. 그런데 실제 전원 선을 뽑으며 테스트할 수는 없으니 네트워크 에러 등이 발생했다고 가정하고 예외를 던져보자.

테스트용 UserService 대역

그런데 어떻게 예외를 만들어야 할까? 기존에 잘 돌아가던 코드 중간에 테스트를 위해 잠시 예외를 넣어야 할까? 테스트를 위해 기존에 잘 되던 코드를 건드리는 것은 좋은 생각은 아니다.

이런 경우에는 테스트 용으로 UserService의 대역을 만들어서 사용해보자. UserService의 코드를 복사 붙여넣기 하진 말고, 일단 상속을 받아서 변경이 필요한 메소드 부분만 오버라이드 하자.

테스트에서만 사용할 클래스라면 번거롭게 파일을 따로 만들지 말고 테스트 클래스 내부에 스태틱 클래스로 간편하게 만들어보자. 그런데 상속을 받는다고 해도 private으로 작성된 부분은 오버라이드 할 수 없으니, 기존 코드를 변경하는 게 조금 꺼림칙하긴 하지만 protected로 잠시만 변경하자.

    static class TestUserService extends UserService {
        private final String targetUserId;

        public TestUserService(String targetUserId) {
            this.targetUserId = targetUserId;
        }

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

            super.upgradeLevel(user);
        }

        static class TestUserServiceException extends RuntimeException {
        }
    }

이 테스트용 클래스는 생성할 때 targetUserId를 받아서 해당 유저가 발견되었을 때 예외를 던진다.

    @Test
    @DisplayName("업데이트 도중 예외가 발생했을 때 전부 업데이트가 취소되는지 테스트")
    public void upgradeAllOrNothing() {
        // 먼저 등록
        for (User user : users) {
            userDao.add(user);
        }

        // "joytouch" -> SILVER, "madnite1" -> GOLD
        // 두 유저는 각각 업그레이드 조건을 충족한 유저들이다.
        // "madnite1"에서 예외를 발생시켰을 때,
        // "joytouch"도 업그레이드가 되지 않은 원래 상태여야 테스트가 성공한다.
        User joytouch = users.get(1);
        User madnite1 = users.get(3);

        TestUserService testUserService = new TestUserService(madnite1.getId());
        testUserService.setUserDao(userDao);
        testUserService.setUserLevelUpgradePolicy(userService.getUserLevelUpgradePolicy());

        Assertions.assertThrows(TestUserService.TestUserServiceException.class, () -> {
            testUserService.upgradeLevels();
        });

        // 업데이트 후에 DB에서 가져온 정보
        User joytouchDb = userDao.get(joytouch.getId());
        User madnite1Db = userDao.get(madnite1.getId());

        System.out.println("joytouch = " + joytouch.getLevel());
        System.out.println("joytouchDb = " + joytouchDb.getLevel());
        System.out.println("madnite1 = " + madnite1.getLevel());
        System.out.println("madnite1Db = " + madnite1Db.getLevel());

        Assertions.assertEquals(joytouch.getLevel(), joytouchDb.getLevel());
        Assertions.assertEquals(madnite1.getLevel(), madnite1Db.getLevel());
    }

테스트는 위와 같이 생성했다. 기존 픽스쳐에 등록된 유저 중 joytouch 라는 아이디를 가진 유저와 madnite1이라는 아이디를 가진 유저는 조건을 충족하여 각각 업그레이드의 대상이다.

그런데 만일 madnite1이라는 유저를 업그레이드 하다 중간에 예외가 발생하면, joytouch도 업그레이드 되지 않은 상태를 유지하고 있어야 한다.

기존에 가지고 있는 픽스쳐와 DB에서 불러온 데이터를 비교하여 확실히 업그레이드가 되지 않았는지 검증한다.

현재는 예상대로 테스트가 실패한다. 테스트가 성공하도록 만들어보자.

중간에 예외가 발생했지만, joytouch는 업데이트된 상태이다. 우리가 원하는 동작은 예외 때문에 하나라도 업데이트 하지 못하면, 모든 데이터가 업데이트 되지 않은 상태 그대로 유지되는 것이다.

테스트 실패의 원인

DB와 JDBC에 익숙하다면 이러한 원인이 트랜잭션에 있다는 것을 알 수 있다. upgradeLevels() 메소드에서 일어나는 모든 업데이트를 하나의 작업단위인 원자성을 가진 트랜잭션 안에 넣어야 하는데 이를 만족하지 못하는 것이다.

트랜잭션의 경계 설정

DB는 사실 그 자체로 완벽한 트랜잭션을 지원한다. 우리가 SQL 명령어로 다수의 ROW를 건드렸을 때, 하나의 ROW에만 반영되고 나머지 ROW에는 SQL 명령이 들어가지 않는 경우를 본 적이 없을 것이다. 하나의 SQL명령을 처리하는 경우에는 DB가 트랜잭션을 보장해준다고 믿을 수 있다.

하지만 지금의 경우처럼 여러 개의 SQL 명령을 하나의 트랜잭션으로 취급해야 하는 경우도 있다.

이를테면, 은행 계좌에서 누군가 돈을 송금했다면, 입금 계좌의 돈은 늘어나야 하고 출금 계좌의 돈은 줄어들어야 한다. 어느 한 계좌에만 입금 혹은 출금이 적용되는 것은 말도 안 된다.

만일 입금 계좌에 돈을 늘렸는데 예외가 발생했다면, 모든 작업을 취소시켜야 하는데 이를 트랜잭션 롤백(transaction rollback)이라고 한다.

반대로 하나의 트랜잭션의 모든 작업이 정상적으로 이루어졌다면 트랜잭션 커밋(transaction commit)을 적용해서 작업을 확정시켜야 한다.

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

트랜잭션은 시작 지점과 끝 지점이 있다. 시작 지점의 위치는 한 곳이며, 끝 지점의 위치는 두 곳이다. 끝날 때는 롤백되거나 커밋될 수 있다. 애플리케이션 내에서 트랜잭션이 시작되고 끝나는 위치를 트랜잭션의 경계라고 부른다. 복잡한 로직 흐름 사이에서 정확하게 트랜잭션 경계를 설정하는 일은 매우 중요하다.

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(); // 트랜잭션 경계 끝지점 (롤백)
}

c.close();

JDBC의 트랜잭션은 위의 소스처럼 Connection 객체를 통해 일어난다. c.setAutoCommit(false)를 호출하는 순간 트랜잭션 경계가 시작되며, c.commit() 혹은 c.rollback()을 호출하는 순간 트랜잭션 경계가 끝난다.

autoCommit의 기본 값은 true여서 원래는 작업마다 커밋이 자동으로 이뤄지는데 이 설정값을 false로 만듦으로써 커밋을 수동으로 이뤄지게 만들어 commit() 혹은 rollback()으로 끝내는 원리이다.

이렇게 트랜잭션 영역을 만드는 일을 트랜잭션 경계 설정이라고 한다. 이렇게 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션(local transaction)이라고도 한다. 2개 이상의 DB에서 만들어지는 트랜잭션은 글로벌 트랜잭션(global transaction)이라고 한다.

UserService와 UserDao의 트랜잭션 문제

현재까지 만든 코드에는 어디에도 트랜잭션을 설정하는 부분이 없었을 뿐더러 스프링에서 제공하는 JdbcTemplate 객체를 이용한 뒤로는 Connection 객체도 본적이 없다.

JdbcTemplate은 이전에 우리가 작성해보았던 JdbcContext와 동작이 비슷한데, 템플릿 메소드 안에서 DataSourcegetConnection() 메소드를 호출해서 Connection 오브젝트를 가져오고 작업을 마치면 Connection을 닫아주고 템플릿 메소드를 빠져나오는 것이다.

이전에 트랜잭션 경계 설정은 Connection에서 setAutoCommit(false)를 호출해야 만들어지는 것을 배웠는데 JdbcTemplate은 지금까지 우리가 이해한대로 라면 그냥 .update().queryForObject()와 같은 메소드로 SQL문을 한번 실행할 때마다 1번의 트랜잭션을 가졌다가 자동으로 커밋된다고 볼 수 있다.

그렇다면 이전에 우리가 작성한 테스트에서도 각각의 사용자를 업데이트할 때 하나의 트랜잭션이 생겼다가 다시 사라지는 것이므로 총 5개의 트랜잭션이 생겼다가 사라지며 각각 결과를 적용하니 당연히 4번째 사용자를 수정하다가 예외가 발생해도 2번째 사용자의 업그레이드 트랜잭션은 이미 끝난 상태라 예외가 발생했을 때 이미 결과가 적용된 상태인 것이다.

3번의 업데이트가 발생하는 경우, upgradeLevels()에서 3번의 userDao.update()를 호출하게 되고, userDaoupdate()는 결국 jdbcTemplate.update()를 호출하게 된다. 그리고 jdbcTemplateupdate()는 각각 커넥션과 트랜잭션을 만들어내고 디비에 반영한다.

데이터 액세스 코드를 DAO로 분리했을 때는 결국 이처럼 DAO 메소드를 하나씩 호출할 때마다 하나의 새로운 트랜잭션이 만들어지는 구조가 될 수 밖에 없다.

그렇다면 upgradeLevels()와 같이 여러번 DB에 업데이트를 해야 하는 작업을 하나의 트랜잭션으로 만들려면 어떻게 해야 할까? DB 커넥션을 하나만 써야 한다. 그러나 현재는 UserService에서 DB 커넥션을 다룰 수 있는 방법이 없다.

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

그렇다면 커넥션을 하나만 쓰기 위해 UserService에 있는 upgradeLevels()메소드를 커넥션을 다루고 있는 UserDao안으로 옮겨보면 어떨까? 이 방식은 비즈니스 로직과 데이터 로직을 한데 묶어버리는 한심한 결과를 초래한다. 지금까지 성격과 책임이 다른 코드를 분리하고 느슨하게 연결해서 확장성을 좋게 하려고 많은 수고를 해왔는데, 여기서 트랜잭션 문제를 해결한답시고 JDBC API와 User의 업그레이드 정책을 담은 코드를 뒤죽박죽으로 만드는 건 도저히 용납할 수 없다.

이 문제를 해결하기 위해 차라리 UserDao는 같은 책임을 지고 있고 UserService 내부에 잠시 트랜잭션을 위한 최소한의 코드만 가져오는 방법을 이용해서 해결해보자.

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

위 코드는 일반적인 트랜잭션을 사용하는 JDBC 코드의 구조이다. 그런데, 여기서 생성되는 Connection 오브젝트는 책임 관계로 볼 때 UserDao에 있는게 올바르다. 순수한 데이터 엑세스 로직은 UserDao에 가는 것이 옳기 때문이다.

그런데 최소한의 코드만을 이용해서 UserService에서 트랜잭션을 구현해보기로 했으니, UserService에서 Connection을 만들고 트랜잭션 경계를 설정(c.setAutoCommit(false))한 뒤에 해당 ConnectionUserDao로 넘기는 방식으로 트랜잭션을 만들어볼 것이다.

이를 위해 UserDao의 인터페이스는 다음과 같이 변경되어야 한다.

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

이정도까지만 하면 될 것 같지만, upgradeLevels()는 직접 userDao.update()를 호출하는 것이 아니라 레벨 업그레이드가 가능한 User에 대해서만 upgradeLevel() 메소드를 통해 업그레이드 해준다. 그래서 upgradeLevels()에서 생성한 ConnectionupgradeLevel()로 넘기고 그 Connection을 다시한번 UserDao가 받아야 한다.

Connection 객체는 2번에 걸쳐 흘러가야 한다. 이렇게 Connection 오브젝트를 전달해서 사용하면, UserServiceupgradeLevels() 안에서 시작한 트랜잭션에 UserDao의 메소드들도 참여할 수 있다. upgradeLevels() 메소드 안에서 트랜잭션의 경계 설정 작업이 일어나야 하고, 그 트랜잭션을 갖고 있는 DB 커넥션을 이용하도록 해야만 별도의 클래스에 만들어둔 DAO 내의 코드도 트랜잭션이 적용될테니 결국 이 방법을 사용할 수 밖에 없다.

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

이제 트랜잭션 문제는 해결했지만 여러가지 새로운 문제가 발생하게 된다.

  • DB커넥션을 비롯한 리소스의 깔끔한 처리를 가능하게 했던 JdbcTemplate을 더이상 활용할 수 없다. 결국 JDBC API를 직접 사용하는 초기 방식으로 돌아가야 한다. try/catch/finally 블록은 이제 UserService 내에 존재하고 UserService의 코드는 JDBC 작업 코드의 전형적인 문제점을 그대로 가질 수 밖에 없다.
  • DAO의 메소드와 비즈니스 로직을 담고 있는 UserService의 메소드에 Connection 파라미터가 추가돼야 한다는 점이다. upgardeLevels()에서 사용하는 메소드의 어딘가에서 DAO를 필요로 한다면, 그 사이의 모든 메소드에 걸쳐서 Connection 오브젝트가 계속 전달돼야 한다. UserService는 스프링 빈으로 선언해서 싱글톤으로 되어 있으니 UserService의 인스턴스 변수에 이 Connection을 저장해뒀다가 다른 메소드에서 사용하게 할 수도 없다. 멀티 스레드 환경에서는 공유하는 인스턴스 변수에 스레드별로 생성하는 정보를 저장하다가는 서로 덮어쓰는 일이 발생하기 때문이다. 결국 트랜잭션이 필요한 작업에 참여하는 UserService의 메소드는 Connection 파라미터로 지저분해질 것이다.
  • Connection 파라미터가 UserDao 인터페이스 메소드에 추가되면 UserDao는 더이상 데이터 엑세스 기술에 독립적일 수 없다는 것이다. JPA나 하이버네이트로 UserDao의 구현 방식을 변경하려고 하면 Connection 대신 EntityManagerSession 오브젝트를 UserDao 메소드가 전달받도록 해야 한다. 결국 UserDao 인터페이스는 바뀔 것이고 그에 따라 UserService 코드도 함께 수정돼야 한다. 기껏 인터페이스를 사용해 DAO를 분리하고 DI를 적용했던 수고가 물거품이 되고 말 것이다.
  • DAO 메소드에 Connection 파라미터를 받게 하면 테스트코드에도 영향을 미친다. 지금까지 DB 커넥션은 전혀 신경쓰지 않고 테스트에서 UserDao를 사용할 수 있었는데, 이제는 테스트 코드에서 직접 Connection 오브젝트를 일일이 만들어서 DAO 메소드를 호출하도록 모두 변경해야 한다.

트랜잭션 동기화

UserService 메소드 안에서 트랜잭션 코드를 구현하며 위와 같은 문제점을 감내할 수 밖에 없을까? 스프링은 사실 이 문제를 해결할 수 있는 멋진 방법을 제공한다.

Connection 파라미터 제거

현재까지 문제의 핵심은 UserService에서 Connection 객체를 만들어서 해당 객체를 2번이나 전달하느라 코드가 어지럽혀졌고 그 영향이 심지어 테스트코드까지 미쳤다는 것이다.

이런 문제를 해결하기 위해 스프링이 제안하는 방법은 독립적인 트랜잭션 동기화(transaction synchronization) 방식이다. 트랜잭션 동기화란 UserService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 장소에 보관해두고, 이후에 호출되는 DAO의 메소드에서는 저장된 Connection을 가져다가 사용하게 하는 것이다. 정확히는 DAO가 사용하는 JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 하는 것이다. 그리고 트랜잭션이 모두 종료되면 그 때는 동기화를 마치면 된다.

  • (1): UserServiceConnection을 생성한다.
  • (2): 생성한 Connection을 트랜잭션 동기화 저장소에 저장한다. 이후에 ConnectionsetAutoCommit(false)를 호출해 트랜잭션을 시작시킨다.
  • (3): 첫 번째 update() 메소드를 호출한다.
  • (4): update() 메소드 내부에서 이용하는 JdbcTemplate은 트랜잭션 동기화 저장소에 현재 시작된 트랜잭션을 가진 Connection 오브젝트가 존재하는지 확인한다. ((2) 단계에서 만든 Connection 오브젝트를 발견할 것이다.)
  • (5): 발견한 Connection을 이용해 PreparedStatement를 만들어 SQL을 실행한다. 트랜잭션 동기화 저장소에서 DB 커넥션을 가져왔을 때는 JdbcTemplateConnection을 닫지 않은채로 작업을 마친다. 이렇게 첫번째 DB 작업을 마쳤고, 트랜잭션은 아직 닫히지 않았다. 여전히 Connection은 트랜잭션 동기화 저장소에 저장되어 있다.
  • (6): 동일하게 userDao.update()를 호출한다.
  • (7): 트랜잭션 동기화 저장소를 확인하고 Connection을 가져온다.
  • (8): 발견된 Connection으로 SQL을 실행한다.
  • (9): userDao.update()를 호출한다.
  • (10): 트랜잭션 동기화 저장소를 확인하고 Connection을 가져온다.
  • (11): 가져온 Connection으로 SQL을 실행한다.
  • (12): Connectioncommit()을 호출해서 트랜잭션을 완료시킨다.
  • (13): Connection을 제거한다.

위 과정 중 예외가 발생하면, commit()은 일어나지 않고 트랜잭션은 rollback()된다.

트랜잭션 동기화 저장소는 작업 스레드마다 독립적으로 Connection 오브젝트를 저장하고 관리하기 때문에 다중 사용자를 처리하는 서버의 멀티스레드 환경에서도 충돌이 날 염려는 없다.

이렇게 트랜잭션 동기화 기법을 사용하면 파라미터를 통해 일일이 Connection 오브젝트를 전달할 필요가 없어진다. 트랜잭션의 경계설정이 필요한 upgradeLevels()에서만 Connection을 다루게 하고 여기서 생성된 Connection과 트랜잭션을 DAO의 JdbcTemplate이 사용할 수 있도록 별도의 저장소에 동기화하는 방법을 적용하기만 하면 된다.

더이상 로직을 담은 메소드에 Connection 타입의 파라미터가 전달될 필요도 없고, UserDao의 인터페이스에도 일일이 JDBC 인터페이스인 Connection을 사용한다고 노출할 필요도 없다.

문제의 핵심은 트랜잭션을 이용하기 위해 Connection이라는 파라미터를 귀찮게 2단계나 전달해야 했다는 것이다. 그리고 이 과정에서 JdbcTemplate을 이용할 수 없게 되고 기존 try/catch/finally 방식의 단점이 그대로 다시 돌아왔었다.

결국 Connection을 다른 저장소에 저장해두고 쓰는 방식이 필요했는데, 멀티쓰레드 환경이라는 제약 조건과 UserService가 빈이라는 제약 조건이 있었다.

스프링의 트랜잭션 동기화 저장소는 작업 스레드마다 독립적으로 Connection 오브젝트 저장/관리 환경을 제공함으로써 이러한 문제를 해결했다.

트랜잭션 동기화 적용

트랜잭션 동기화의 아이디어 자체는 그냥 글로벌한 공간에 트랜잭션을 잠시 저장해둔다는 것으로 간단하지만, 멀티스레드 환경에서도 안전하게 트랜잭션 동기화를 구현하는 것이 기술적으로 간단하지는 않다. 다행히 스프링은 JdbcTemplate과 더불어 이런 트랜잭션 동기화 기능을 지원하는 간단한 유틸리티 메소드를 제공한다.

public class UserService {
    UserDao userDao;
    DataSource dataSource;
    ...

위와 같이 현재 DataSource의 커넥션을 얻기 위해 DataSource 빈을 주입받아야 한다.

    <bean id="userService" class="toby_spring.user.service.UserService">
        <property name="userDao" ref="userDao" />
        <property name="dataSource" ref="dataSource" />
        <property name="userLevelUpgradePolicy" ref="userLevelUpgradePolicy" />
    </bean>

xml에서 주입 설정을 해주자.

    public void upgradeLevels() throws SQLException{
        // 트랜잭션 동기화 관리자를 이용해 동기화 작업을 초기화
        TransactionSynchronizationManager.initSynchronization();
        // DB 커넥션을 생성하고 트랜잭션을 시작한다.
        // 이후의 DAO 작업은 모두 여기서 시작한 트랜잭션 안에서 진행된다.
        // 아래 두 줄이 DB 커넥션 생성과 동기화를 함께 해준다.
        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 {
            // 스프링 DataSourceUtils 유틸리티 메소드를 통해 커넥션을 안전하게 닫는다.
            DataSourceUtils.releaseConnection(c, dataSource);
            // 동기화 작업 종료 및 정리
            TransactionSynchronizationManager.unbindResource(this.dataSource);
            TransactionSynchronizationManager.clearSynchronization();
        }
    }

upgradeLevels에 위와 같은 트랜잭션 처리를 해주었다. 스프링을 사용하지 않고 JDBC를 이용해 Connection 객체를 직접 쓸 때와 다른 점은

  • 첫째로 트랜잭션 동기화 관리(TransactionSynchronizationManager)를 이용한다는 점
  • 둘째로는 커넥션을 가져올 때나 반납할 때 DataSourceUtils라는 스프링 제공 유틸리티를 사용한다는 점

두가지가 있다.

더이상 DataSource.getConnection()을 이용해 Connection을 그냥 가져오지 않는 이유는 DataSourceUtils를 이용해 커넥션을 가져오고 setAutoCommit(false) 메소드를 수행하면, DB 커넥션 생성과 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주기 때문이다.

트랜잭션 동기화가 되어 있는 채로 JdbcTemplate을 사용하면 JdbcTemplate의 작업에서 동기화시킨 DB 커넥션을 사용하게 된다. 결국 UserDao를 통해 진행되는 모든 JDBC 작업은 upgradeLevels() 메소드에서 만든 Connection 오브젝트를 사용하고 같은 트랜잭션에 참여하게 된다.

작업을 정상적으로 마치면 트랜잭션을 커밋해주고, 예외가 발생하면 롤백한다. 마지막으로는 커넥션을 안전하게 반환하고, 동기화 작업에 사용됐던 부분들을 바인드 해제한다.

JDBC의 트랜잭션 경계설정 메소드를 사용해 트랜잭션을 이용하는 전형적인 코드에 간단한 트랜잭션 동기화 작업만 붙여줌으로써, 지저분한 Connection 파라미터의 문제를 말끔히 해결했다.

JdbcTemplate과 트랜잭션 동기화

JdbcTemplate은 어떻게 트랜잭션을 이용하는지 다시한번 살펴보자. 일단, JdbcTemplate에서 update()query()와 같은 메소드를 사용했을 때 스스로 Connection을 만들었다가 반납한다는 것을 알 수 있다.

사실 JdbcTemplate에는 트랜잭션 동기화가 이미 고려된 설계가 적용되어 있다. 트랜잭션 동기화를 시작해놓았다면, 직접 DB Connection을 만드는 대신 트랜잭션 동기화 저장소에 들어있는 DB Connection을 가져와 사용한다. 이를 통해 이미 시작된 트랜잭션에 참여한다.

따라서 UserDao는 트랜잭션이 적용된다고 해서 따로 코드를 수정할 필요가 없다.

JdbcTemplate은 JDBC를 사용할 때 까다로울 수 있는

  • try/catch/finally 작업 흐름 지원
  • SQLException 예외 변환
  • 트랜잭션 동기화 관리

와 같은 작업들에 대한 템플릿을 제공하여, 개발자가 비즈니스 로직에 집중할 수 있고 애플리케이션 레이어를 설계하기 좋은 환경을 만들어준다.

귀찮게 Connection 파라미터를 물고다니지 않아도 된다. 또한, UserDao는 여전히 데이터 액세스 기술에 종속되지 않는 깔끔한 인터페이스 메소드를 유지한다. 그리고 테스트에서 DAO를 직접 호출해서 사용하는 것도 아무런 문제가 되지 않는다.

트랜잭션 서비스 추상화

지금까지 UserService, UserDao, UserDaoJdbc를 만들면서 JDBC API를 사용하고 트랜잭션도 적용해보았다. 책임과 성격에 따라 데이터 액세스 부분과 비즈니스 로직을 잘 분리, 유지할 수 있게 만든 뛰어난 코드이다. JDBC를 사용하며 이보다 더 깔끔한 코드를 만들기는 힘들 것이다.

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

여기서 여러 DB에 걸쳐 트랜잭션 경계를 만들어야 하는 글로벌 트랜잭션이라는 새로운 요구사항이 들어왔다고 가정하자. 지금까지 사용한 JDBC의 Connection을 이용한 트랜잭션 방식은 로컬 트랜잭션이라 글로벌 트랜잭션을 이용하려면 무언가 다른 방법이 필요하다.

왜냐하면 로컬 트랜잭션은 하나의 DB Connection에 종속되기 때문이다. 따라서 각 DB와 독립적으로 만들어지는 Connection을 통해서가 아니라, 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션(Global Transaction) 방식을 사용해야 한다.

글로벌 트랜잭션을 적용해야 트랜잭션 매니저를 통해 여러 개의 DB가 참여하는 작업을 하나의 트랜잭션으로 만들 수 있다. 또한 분산된 애플리케이션끼리 메세지를 받는 자바 메세지 서비스(JMS)와 같은 트랜잭션 기능을 지원하는 서비스도 트랜잭션에 참여시킬 수 있다.

자바는 JDBC 외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 매니저를 지원하기 위한 API인 JTA(Java Transaction API)를 제공하고 있다.

위 그림은 JTA를 이용해 여러 개의 DB 또는 메시징 서버에 대한 트랜잭션을 관리하는 방법을 보여준다.

  • 애플리케이션은 기존의 방법대로 DB는 JDBC, 메시징 서버라면 JMS 같은 API를 사용해서 필요한 작업을 수행한다.
  • 단, 트랜잭션은 JDBC나 JMS API를 사용하여 직접 제어하지 않고, JTA를 통해 트랜잭션 매니저가 관리하도록 위임한다.
  • 트랜잭션 매니저는 DB와 메시징 서버를 제어하고 관리하는 각각의 리소스 매니저와 XA 프로토콜을 통해 연결된다.

이를 통해 트랜잭션 매니저가 실제 DB와 메시징 서버의 트랜잭션을 종합적으로 제어할 수 있게 된다. JTA를 이용하여 트랜잭션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다. 하나 이상의 DB가 참여하는 트랜잭션을 만들려면 JTA를 사용해야 한다는 사실을 기억해두자.

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

tx.begin();
// JNDI(Java Naming and Directory Interface)로 가져온 dataSource를 사용해야 한다.
Connection c = dataSource.getConnection();
try {
  // 데이터 액세스 코드
  tx.commit();
} catch (Exception e) {
  tx.rollback();
  throw e;
} finally {
  c.close();
}

JTA를 이용한 방법으로 바뀌긴 했지만 트랜잭션 경계 설정을 위한 구조는 JDBC를 사용했을 때와 비슷하다. Connection의 메소드 대신에 UserTransaction의 메소드를 사용한다는 점을 제외하면 트랜잭션 처리 방법은 별로 달라진 것이 없다. 코드의 구조도 비슷하다.

문제는 JDBC 로컬 트랜잭션을 JTA를 이용하는 글로벌 트랜잭션으로 바꾸려면 UserService의 코드를 수정해야 한다는 점이다.

로컬 트랜잭션이면 충분한 고객에게는 JDBC를 이용한 트랜잭션 관리 코드를 다중 DB를 위한 글로벌 트랜잭션을 필요로 하는 곳에는 JTA를 이용한 트랜잭션 관리 코드를 적용해야 한다는 문제가 생긴다.

UserService는 자신의 로직이 바뀌지 않았음에도 기술환경에 따라서 코드가 바뀌는 코드가 돼버리고 말았다.

이 상황에서 UserDao 인터페이스를 하이버네이트를 이용해 구현해야 하는 요구사항이 생기고, 그에 대한 트랜잭션 관리 코드를 구현해야 하는 요구사항이 생겼다고 가정해보자.

UserDao 인터페이스를 하이버네이트로 구현해도 트랜잭션 외의 UserService 메소드들은 잘 동작할 것이다. 그런데 또 트랜잭션이 걸린다. 하이버네이트를 이용한 트랜잭션 관리 코드는 JDBC나 JTA의 코드와는 또 다르기 때문이다.

하이버네이트는 Connection을 직접 사용하지 않고 Session이라는 것을 사용하고, 독자적인 트랜잭션 관리 API를 사용한다. 그렇다면 이번엔 UserService를 하이버네이트의 SessionTransaction 오브젝트를 사용하는 트랜잭션 경계 코드로 변경할 수 밖에 없게 됐다.

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

UserService는 원래 UserDao 인터페이스에만 의존하는 구조였다. 그래서 DAO 클래스의 구현 기술이 JDBC에서 하이버네이트나 여타 기술로 바뀌어도 UserService 코드는 영향을 받지 않았다. 전형적인 OCP 원칙을 지키는 코드였다.

문제는 JDBC에 종속적인 Connection을 이용한 트랜잭션 코드가 UserService에 등장하면서부터 UserServiceUserDaoJdbc에 간접적으로 의존하는 코드가 돼버렸다는 점이다. 기껏 UserDao 인터페이스를 사이에 두고 데이터 액세스 기술의 다양한 예외도 모두 추상화하고 DI를 적용해서 구현 클래스에 대한 의존도를 완벽하게 제거했는데 트랜잭션 때문에 그동안의 수고가 허사가 되고 말았다.

UserService의 코드가 특정 트랜잭션 방법에 의존적이지 않고 독립적으로 만들려면 어떻게 해야 할까? 다행히 트랜잭션의 경계설정을 담당하는 코드는 일정한 패턴을 갖는 유사한 구조다. 이렇게 여러 기술의 사용 방법에 공통점이 있다면 추상화를 생각해볼 수 있다. 추상화란 하위 시스템의 공통점을 뽑아내서 분리시키는 것을 말한다. 이렇게 하면 하위 시스템이 어떤 것인지 알지 못해도, 또는 하위 시스템이 바뀌더라도 일관된 방법으로 접근할 수 있다.

DB에서 제공하는 DB 클라이언트 라이브러리와 API는 서로 전혀 호환이 안되는 독자적인 방식으로 만들어졌지만, SQL을 이용하는 방식이라는 공통점을 뽑아내 추상화한 것이 JDBC이다. JDBC라는 추상화 기술이 있기 때문에 자바의 프로그램 개발자는 DB의 종류에 상관없이 일관된 방법으로 데이터 액세스 코드를 작성할 수 있다.

트랜잭션 코드에도 추상화를 도입해보자. JDBC, JTA, 하이버네이트, JPA, JDO 심지어 JMS도 트랜잭션 개념을 갖고 있으니 트랜잭션 경계설정 방법에서 공통점이 있을 것이며 공통적인 특징을 모아서 추상화된 트랜잭션 관리 계층을 만들 수 있을 것이다. 그러면 특정 기술에 종속되지 않는 트랜잭션 경계 코드를 만들 수 있을 것이다.

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

스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공한다. 이를 이용하면 특정 기술에 종속되지 않고 트랜잭션 경계 설정 작업이 가능해진다.

위는 스프링이 제공하는 트랜잭션 추상화 계층을 나타낸 그림이다.

PlatformTransactionManager를 적용해보자.

public class UserService {
    UserDao userDao;
    DataSource dataSource;
    PlatformTransactionManager transactionManager;
    ...

PlatformTransactionManager를 필드에 추가했다.

    public void upgradeLevels() {
        // 트랜잭션 시작
        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;
        }
    }

upgradeLevels() 메소드는 더이상 JDBC라는 특정 기술에 의존하지 않는다.

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

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

이를 위해 transactionManager를 만들어 주입해주었다. 이름은 transactionManager이지만 실제로는 PlatformTransactionManager 인터페이스에 주입될 빈이다.

테스트도 정상적으로 실행된다.

현재는 JDBC의 로컬 트랜잭션을 이용하기 위해 DataSourceTransactionManager를 생성하여 PlatformTransactionManager 인터페이스에 주입한 형태이다.

JDBC를 이용하는 경우에는 먼저 Connection을 생성하고 나서 트랜잭션을 시작했다. 하지만 PlatformTransactionManager에서는 트랜잭션을 가져오는 요청인 getTransaction() 메소드를 호출하기만 하면 된다. 필요에 따라 트랜잭션 매니저가 DB 커넥션을 가져오는 작업도 같이 수행해주기 때문이다. 트랜잭션을 가져오면서 트랜잭션이 시작된다. 파라미터로 넘기는 DefaultTransactionDefinition 오브젝트는 트랜잭션에 대한 속성을 담고 있다.

PropagationBehavior, IsolationLevel, Timeout, ReadOnly, Name 등의 설정을 프로퍼티 setter를 통해 설정할 수 있다.

시작된 트랜잭션은 TransactionStatus 타입의 변수에 저장된다. Transactionstatus는 트랜잭션에 대한 조작이 필요할 때 PlatformTransactionManager 메소드의 파라미터로 전달해주면 된다.

스프링의 트랜잭션 추상화 기술은 앞서 살펴봤던 트랜잭션 동기화를 사용한다. 트랜잭션 동기화 저장소에 트랜잭션을 저장해두고 해당 트랜잭션을 이용해 데이터 액세스 작업을 수행 후 마지막에 commitrollback을 결정한다.

트랜잭션 기술 설정의 분리

이제는 JTAHibernate 등으로 트랜잭션을 적용해도 UserService의 코드는 변경될 필요가 없다. 단순히 빈에 주입하는 DI만 다른 클래스로 바꿔주면 된다.

위 빈 설정 부분만 수정하면 데이터 액세스 기술이 변해도 그대로 트랜잭션을 이용할 수 있다. 하이버네이트라면 HibernateTransactionManager를 주입하고, JPA라면, JPATransactionManager를 주입하면 된다.

UserService의 트랜잭션 경계설정을 위한 getTransaction(), commit(), rollback() 메소드를 사용한 코드는 전혀 손댈 필요가 없다.

어떤 클래스든 스프링의 빈으로 등록할 때 먼저 검토해야 할 것은 싱글톤으로 만들어져 여러 스레드에서 동시에 사용해도 괜찮은가 하는 점이다. 참고로 스프링이 제공하는 모든 PlatformTransactionManager의 구현 클래스는 싱글톤으로 사용 가능하다.

스프링이 PlatformTransactionManager라는 긴 이름을 붙인 이유는 단순히 JTA의 TransactionManager와 혼동되지 않도록 지은 이름이기 때문에 보통 변수명은 관례상 transactionManager로 짓는다.

참고로 이제 UserService 클래스의 멤버 중 JDBC에 의존적인 DataSource는 더이상 필요 없다. 완전히 JDBC 의존성에서 벗어날 수 있다.

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

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

오직 DataSourceTransactionManager라는 특정 구현체를 위해서 dataSource를 주입해줄 뿐이다.

profile
풀스택 웹개발자로 일하고 있는 Jake Seo입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: Spring Framework에 관심이 있습니다.

0개의 댓글