개발/스프링

스프링의 선언적 트랜잭션 내부 동작 이해

마코maco 2023. 4. 28. 10:47

PSA(Portable Service Abstraction)

자바에서 사용할 수 있는 라이브러리와 프레임워크 중 사용 방법과 형식은 다르지만 기능과 목적이 유사한 기술이 여럿 존재한다. 스프링은 다른 라이브러리나 프레임워크에 대한 추상화를 제공함으로써 결합을 줄이고 코드의 이식성을 높인다.

트랜잭션 구현하기

우리가 사용하는 @Transactional은 AOP와 PSA 를 통해 구현되어 있다.
트랜잭션을 직접 구현하면서 PSA가 무엇인지 어떤 장점이 있는지 직접 느껴보도록 하자.

컨트롤러에서 RequestBody로 UserDto를 전달받아 서비스를 실행한다고 해보자.

@PostMapping("/user")  
public void saveUser(@RequestBody UserDto userDto) {  
    userService.saveTwoUsers(userDto);  
}

다음은 서비스의 saveTwoUsers()의 코드이다.

public void saveTwoUsers(UserDto userDto) {  
    User user1 = new User(userDto.getName(), userDto.getAge());  
    userDao.insert(user1);  

    User user2 = new User(userDto.getName(), userDto.getAge() + 1);  
    userDao.insert(user2);  
}

위 메서드의 기능은 다음과 같다.

  • name과 age를 그대로 가지고 있는 user1을 저장한다.
  • name과 age + 1을 가지고 있는 user2를 저장한다.

만약 위 메서드에서 user1저장과 user2 저장이 atomic 해야 한다고 해보자.
쉽게 말해, user1과 user2가 모두 저장되거나, 하나라도 실패하면 롤백하여 모두 저장되지 않도록 한다.
이를 위해 트랜잭션으로 묶어줄 수 있다.

다음과 같이 Jdbc에서 제공하는 DataSourceUtils를 이용해 구현할 수 있다.

public void saveTwoUsers(UserDto userDto) throws Exception {  

    TransactionSynchronizationManager.initSynchronization();  
    Connection c = DataSourceUtils.getConnection(dataSource);  
    c.setAutoCommit(false);  

    try {  
        User user1 = new User(userDto.getName(), userDto.getAge());  
        userDao.insert(user1);  

        User user2 = new User(userDto.getName(), userDto.getAge() + 1);  
        userDao.insert(user2);  

        c.commit();  
    } catch (Exception e) {  
        c.rollback();  
        throw e;  
    } finally {  
        DataSourceUtils.releaseConnection(c, dataSource);  
        TransactionSynchronizationManager.unbindResource(dataSource);  
        TransactionSynchronizationManager.clearSynchronization();  
    }  
}

트랜잭션 경계를 설정하고, 커넥션을 받아와서 autoCommit을 하지 않도록 설정하고, 트랜잭션을 수행하고, 성공하면 커밋, 실패하면 롤백을 하고 있다.

이 예제에서는 쉽게 테스트하기 위해 Dao에서 전달받은 age가 20 이상이면 예외를 발생시키도록 설정했다.

따라서 age에 19를 넣으면 19와 20을 가진 user 모두 db에 저장될 것이다.

{
    "name" : "maco",
    "age" : "19"
}

db를 확인해 보면 user1과 user2 모두 저장된 것을 확인할 수 있었다.

다음으로 age에 20을 넣으면 age가 20인 user1 저장은 성공하고, age가 21인 user2 저장은 실패할 것이다. user2가 실패하면 트랜잭션이 롤백되어 db에 아무런 값도 저장되지 않을 것이다.

{
    "name" : "maco",
    "age" : "20"
}

트랜잭션이 롤백되어 db의 상태가 그대로인 것을 확인할 수 있다.

트랜잭션으로 묶어주는 데 성공하기는 했다. 코드를 조금 간략하게 다시 보자.

public void saveTwoUsers(UserDto userDto) throws Exception {  

    TransactionSynchronizationManager.initSynchronization();  
    Connection c = DataSourceUtils.getConnection(dataSource);  
    c.setAutoCommit(false);

    try {  
        // 비즈니스 로직 수행
        c.commit();  
    } catch (Exception e) {  
        c.rollback();  
        throw e;  
    } finally {  
        DataSourceUtils.releaseConnection(c, dataSource);  
        TransactionSynchronizationManager.unbindResource(dataSource);  
        TransactionSynchronizationManager.clearSynchronization();  
    }  
}

TransactionSynchronizationManager.initSynchronization() 을 실행하면 트랜잭션 동기화 기능을 사용할 때 필요한 초기화 작업을 수행한다.

이후에 직접 커넥션을 얻어와서 autoCommit(false)를 설정해주고 있다.
트랜잭션이 끝날 때에도 사용한 커넥션을 반환하고, 바인딩했던 리소스인 dataSource를 해제하고, 해당 트랜잭션과 관련된 동기화 객체를 제거한다.

이렇게 트랜잭션을 동기화하기 위한 여러 설정들을 개발자가 직접 컨트롤하는 것은 매우 어렵다.

이 작업을 추상화된 TransactionManager를 통해 더 쉽게 수행할 수 있다.

@Service  
public class UserService {  

    private final UserDao userDao;  
    private final PlatformTransactionManager transactionManager;  

    public UserService(UserDao userDao, PlatformTransactionManager transactionManager) {  
        this.userDao = userDao;  
        this.transactionManager = transactionManager;  
    }  

    public void saveTwoUsers(UserDto userDto) {  
        TransactionStatus status =  
                transactionManager.getTransaction(new DefaultTransactionDefinition());  
        try {  
            User user1 = new User(userDto.getName(), userDto.getAge());  
            userDao.insert(user1);  

            User user2 = new User(userDto.getName(), userDto.getAge() + 1);  
            userDao.insert(user2);  

            transactionManager.commit(status);  
        } catch (Exception e) {  
            transactionManager.rollback(status);  
            throw e;  
        }  
    }  
}

직접 커넥션과 트랜잭션 동기화 작업을 해주는 것이 아니라, 트랜잭션을 얻어오고, 트랜잭션을 커밋 또는 롤백하고 있다. 트랜잭션 관리가 훨씬 쉬워진 것을 느낄 수 있다.

위에서 사용하고 있는 PlatformTransactionManager는 여러 TxMnagaer의 추상화된 인터페이스다. 따라서 런타임에 적절한 TxManager가 DI 될 것이고 사용하는 DB가 바뀌더라도 OCP를 지킬 수 있다.

다음은 TransactionManager의 계층 구조 이해를 돕기 위한 클래스 다이어그램이다.

 

결론

트랜잭션을 얻는 과정을 경계 설정, 커넥션 관리, 리소스 관리를 모두 개발자가 관리할 수 있다.
하지만 이런 과정은 트랜잭션을 관리한다는 목적이지만 구현하기 복잡하고 사용하는 기술마다 구현 방법이 다르다.
트랜잭션 관리를 추상화하여 TransactionManager으로 더 쉽게 트랜잭션을 관리할 수 있다.
DB마다 다른 TransactionManager를 한 번 더 추상화한 PlatformTransactionManager을 사용하여 DB가 바뀔 때 마다 다른 구현체를 DI 받아 사용할 수 있다.

이렇게 스프링은 다른 api와의 경계선을 인터페이스로 추상화하여 Portable 하게 서비스를 교체해서 사용하도록 되어있다. 이를 PSA(Potable Service Abstraction)이라고 한다. 추상화된 인터페이스에 의존하고 DI를 사용함으로써 OCP를 지켜 유지보수를 향상시킨다.

스프링은 PSA를 통해 트랜잭션을 추상화하여 쉽게 사용하게 하고, 추상화된 트랜잭션 조차 AOP를 통해 트랜잭션 경계를 설정하는 등의 트랜잭션 관련 코드를 숨겨 트랜잭션을 쉽게 사용하고 비즈니스 로직에 집중할 수 있다.

참고 자료

  • 이일민, 『토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리』, 에이콘