5장. 서비스 추상화

  • 출처: 토비의 스프링 3.1 vol.1 스프링의 이해와 원리

5.2 트랜잭션 서비스 추상화

트랜잭션이란 더 이상 나눌 수 없는 단위의 작업을 의미한다. 작업을 쪼개서 더 작은 단위로 만들 수 없다는 것이 트랜잭션의 핵심 속성인 원자성을 뜻한다. 만약 어떠한 작업이 원자성을 갖는데 중간에 어떠한 예외가 발생하여 작업을 완료할 수 없게 되었다면.. 아예 작업이 시작되지 않은 것처럼 초기 상태로 돌려놔야 한다.

DB는 그 자체로 완벽한 트랜잭션을 보장해준다. SQL 쿼리문을 실행하는 과정에서 일부 로우만 변경되고 나머지는 수정이 안된 채로 완료하지 않는다.

만약 두가지 작업이 하나의 트랜잭션 안에서 수행되는데 중간에 예외가 발생하여 첫번째 수행되고 두번째는 수행되지 못했다면, 앞단의 작업을 취소하는 트랜잭션 롤백 처리한다. 그리고 모든 작업이 수행되면, 요청이 성공적으로 완료되었다고 DB에 알려서 작업을 확정짓는데 이를 트랜잭션 커밋이라고 한다.

모든 트랜잭션은 시작점과 끝점의 경계를 갖는다. 트랜잭션이 존재하는 범위를 지정하는 것을 트랜잭션 경계 설정이라고 한다. 이때 트랜잭션의 종료는 수행한 작업들을 모두 취소하는 롤백이거나 모든 작업을 확정하는 커밋으로 나눠질 것이다.

JDBC의 트랜잭션은 하나의 DB 커넥션을 가져와 사용하고 닫는 사이에 일어난다. 그래서 일반적으로 트랜잭션은 커넥션보다도 존재 범위가 짧게 된다. 그래서 앞장에서 JdbcTemplate의 메소드를 이용하는 UserDao 코드 같은 경우는 메소드가 호출될 때마다 하나의 새로운 트랜잭션이 만들어지는 구조가 된다.

JDBC에서 트랜잭션을 시작하려면 setAutoCommit(false) 메소드로 자동커밋 옵션을 false로 지정해준다. 이때의 트랜잭션은 commit() 또는 rollback() 메소드가 호출될 때까지는 하나의 트랜잭션의 경계로써 묶인다.

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

5.1장에서의 upgradeLevel() 메소드를 다시 보자.

public class UserService {
    @Autowired
    UserDao userDao;
    
    public void upgradeLevels() {
         List<Users> users = userDao.getAll();
         for(User user : users) {
             if (canUpgradeLvel(user)) {
                  upgradeLevel(user);
             }
         }
    }
    
    private void upgradeLevel(User user) {
        user.upgradeLevel();
        userDao.update(user);
    }
    
    private boolean canUpgradeLevel(User user) {
        // 생략
    }
}

public class User {
    private Level level;
    public void upgradeLevel() {
        Level nextLevel = this.level.nextLevel();
        this.level = nextLevel != null ? nextLevel : Level.GOLD;
    }
}

위와 같이 서비스 로직단에서 DB에 업데이트 요청을 여러 번 하는 경우에 이 과정이 하나의 트랜잭션으로 묶여있지 않기 때문에.. 중간에 에러 발생하면 누구는 업데이트되고, 누구는 업데이트가 안되는 상태로 남게될 것이다. 이를 방지하기 위해 업데이트 과정 전체를 하나의 트랜잭션으로 묶어줄 수는 없을까?

우선 JdbcTemplate으로는 메소드가 호출될 때마다 새로운 트랜잭션 경계를 갖는다고 했으니.. 방법은 트랜잭션의 경계 설정을 서비스 단으로 끌고 와야 할 것이다. 그렇다면 upgradeLevels() 안에서 커넥션 만들고 업데이트를 하는 방법을 생각할 수도 있지만.. 이는 그동안 앞단에서 공들여왔던 만들었던 템플릿 구조도 건드리게 된다. 게다가 로직 상으로도 직접적인 데이터 액세스 로직은 UserDao.update()으로 두는 것이 설계 상으로도 맞다.

뭐.. Connection이 문제니까 서비스에서 Connection만 만들고 이를 UserDao까지 파라미터로 넘겨서 데이터 업데이트는 Dao단에서 수행하는 방법도 생각해볼 수 있겠다. 하지만 이 또한 여러가지 문제가 있다.

우선 어쨌든 ConnectionJdbcTemplate에서 꺼내온 것이니까 여전히 JdbcTemplate를 활용할 수 없다. 게다가 UserDaoConnection을 파라미터로 직접 넘겨주는 방식은 UserDao가 데이터 액세스 기술에 의존적으로 만든다. 이는 테스트 코드에도 영향을 줄 것이고, 그동안 공들인 구조를 망가지게 한다. 꼬리처럼 계속 파라미터로 물고다니는 것 또한 보기에 좋지 않다.

Connection 파라미터 제거

스프링이 지원하는 독립적인 트랜잭션 동기화를 사용하면 Connection 오브젝트를 별도의 저장소에 보관해두고 DAO 메소드가 호출되면 저장소에 저장되어 있는 Connection을 가져다 사용할 수 있다. 그리고 TransactionSynchronizationManager은 멀티스레드 환경에서도 안전하게 트랜잭션을 구현하도록 해준다.

public class UserService {

    @Autowired
    UserDao userDao;
    @Autowired
    DataSource dataSource;
    
    public void upgradeLevels() {
         // 트랜잭션 동기화 작업을 초기화함
         TransactionSynchronizationManager.initSynchronization();
         // DB커넥션 생성과 동기화로 저장소에 저장함 
         Connection c = DataSourceUtils.getConnection(dataSource);
         // 트랜잭션 시작 
         c.setAutoCommit(false);
    
         try {
             List<Users> users = userDao.getAll();
             for(User user : users) {
                 if (canUpgradeLvel(user)) {
                     upgradeLevel(user);
                 }
             }
             // 작업이 정상적으로 모두 수행 시 트랜잭션 커밋 
             c.commit();
         } catch (Exception e) {
             // 작업이 중간에 실패 시 트랜잭션 롤백
             c.rollback();
             throw e;
         } finally {
             // DB 커넥션 close
             DataSourceUtils.releaseConnection(c, dataSource);
             // 트랜잭션 동기화 작업 종료
             TransactionSynchronizationManager.unbindResource(dataSource);
             TransactionSynchronizationManager.clearSynchronization();
         }
        
    
    private void upgradeLevel(User user) {
        user.upgradeLevel();
        userDao.update(user);
    }
    
    private boolean canUpgradeLevel(User user) {
        // 생략
    }
}

위의 코드에서 보는 것과 같이 이제는 ConnectionDataSource에서 직접 가져오지 않고, DataSourceUtils.getConnection() 메소드를 통해 커넥션이 트랜잭션 동기화에서 사용하도록 저장소에 바인딩해준다.

만약 트랜잭션 동기화 저장소에 미리 저장되어 있는 Connection이 없는 경우에는 JdbcTemplate가 직접 Connection을 만든다. 따라서 DAO 외부에서 트랜잭션을 만들고 이를 관리하고 싶다면 Connection을 생성한 다음 트랜잭션 동기화를 해주어야 하고, 트랜잭션이 필요없는 상황이라면 바로 호출해서 사용해도 된다.

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

그런데 만약 하나의 트랜잭션 안에서 여러 개의 DB를 사용해야 하는 상황이라면 어떨까? 로컬 트랜잭션은 하나의 Connection에 종속되기 때문에 문제가 생긴다. 이 때는 여러 개의 DB를 하나의 트랜잭션으로 관리해주는 별도의 트랜잭션 관리자인 글로벌 트랜잭션을 사용해야 한다. 자바에서는 JDBC 외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 메니저로 JTA (Java Transaction API)가 있다.

그런데 하이버네이트 같은 경우는 Connection을 직접 사용하지 않고 Session을 사용하고, 독자적인 트랜잭션 관리 API를 사용하는데.. 이러면 결국은 트랜잭션이 기술에 종속되는 문제가 생긴다.

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

해결책은 각 기술의 트랜잭션 API를 사용하지 않고 일관된 방식으로 트랜잭션을 제어해야 한다. 스프링에서 제공하는 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 사용하는 것이다. 다음은 스프링이 제공하는 트랜잭션 추상화 계층 구조를 나타낸 그림이다.

PlatformTransactionManager는 스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스이다. 여기서 JDBC의 로컬 트랜잭션을 이용하면 DataSourceTransactionManager을 사용하면 된다. 예를 들어 위의 추상화 계층을 예제 코드에 적용해보면..

public class UserService {
    public void upgradeLevels() {
        // JDBC 트랜잭션 추상 오브젝트 생성
        PlatformTransactionManager txManager = new DataSourceTransactionManager(dataSource);
        TransactionStatus status = txManager.getTransacion(new DefaultTransactionDefinition());
    
        try {
            ...
            txManager.commit(status)
        } catch (RuntimeException e) {
            txManager.rollback(status)
            throw e;
        }
    }
}

위에서 생성되는 트랜잭션이 TransactionManager 타입의 변수에 저장된다. 나머지 구동 방식은 기존 방식과 동일하다.

만약 그럼 JDBC가 아닌 JTA를 사용한다면? 간단하게 DataSourceTransactionManager 대신 JTATransactionManager으로 바꿔주면 된다.

그런데 보면 위의 코드는 UserService가 어떤 트랜잭션을 사용할지를 미리 알고 있어야 하는 구조가 되는 문제가 있다. 아래와 같이 DI를 적용하여 이를 분리하도록 하자.

public class UserService {
    private PlatformTransactionManager txManager;
    public void setTxManager(PlatfromTransactionManager txManager) {
        this.txManager = txManager;
    }

    public void upgradeLevels() {
        TransactionStatus status = this.txManager.getTransacion(new DefaultTransactionDefinition());
        try {
            // 비즈니스 로직
            ...
            
            this.txManager.commit(status)
        } catch (RuntimeException e) {
            this.txManager.rollback(status)
            throw e;
        }
    }
}

5.3 서비스 추상화와 단일 책임 원칙

UserDaoUserService는 각각 기능적인 관심에 따라 기능을 분리해왔는데 이는 수평적인 분리라고 할 수 있겠다. 트랜잭션은 이와 다르게 애플리케이션의 비즈니스 로직과 그 하위에서 동작하는 트랜잭션 기술 레이어를 분리한 것이니 수직적인 계층 분리가 된다.

수직적 구분이든 수평적인 구분이든.. 여기서 중요한 점은 각각의 영역을 명확히 구분함으로써 결합도를 낮추고, 변경 사항이 있을 때에도 서로 영향을 주지 않고 자유롭게 확장할 수 있다는 것이다. 그리고 이러한 구조를 만드는 데에 스프링의 DI가 핵심이었다고 할 수 있다. DI가 없었다면 추상화를 하더라도 코드 사이에 결합이 어느 정도는 남아 있게 된다.

이렇게 스프링의 의존관계 주입 기술인 DI는 모든 스프링 기술의 기반이 되는 핵심 엔진이자 원리이며, 스프링이 지지하고 지원하는, 좋은 설계와 코드를 만드는 모든 과정에서 사용되는 가장 중요한 도구다. -본문 p379


"개인적으로 공부하면서 정리한 자료입니다. 오타와 잘못된 내용이 있을 수 있습니다."

좋은 웹페이지 즐겨찾기