본문 바로가기

Spring/Spring

Transaction 동작 원리, JDBC 기본에 충실한 과정(JPA, Hibernate, Spring's @Transactional)

반응형

JDBC 트랜잭션에 대한 이해

Transaction 기본 동작 원리를 익히기 위해서 점진적인 접근을 할 것입니다. (두괄식X 미괄식O)

궁극적으로는 @Transactional 이 어떻게 돌아가는지를 알아볼 것이지만, 우선은 그것보다도 중요한 JDBC에서 트랜잭션을 사용하는 방법에 대해서 이해하고 넘어갈 것입니다.

Spring의 @Transactional 애노테이션을 쓰거나, JPA(Hibernate), JOOQ 등을 쓰면 사실 라이브러리내에서 관리되므로 아래와 같은 문제는 일어날 일이 없습니다.

앞서 언급한대로 원활한 이해를 위해 아래 JDBC가 트랜잭션을 다루는 코드를 설명하겠습니다.

import java.sql.Connection;

Connection connection = dataSource.getConnection(); // (1)

try (connection) {
    connection.setAutoCommit(false); // (2)
    // execute some SQL statements...
    connection.commit(); // (3)

} catch (SQLException e) {
    connection.rollback(); // (4)
}
  1. 데이터베이스을 쓰려면 연결부터 해야합니다. (data-source 설정했고 data-source를 통해서 Connection을 가져왔다고 가정합니다.)
  2. 자바에서 데이터베이스의 트랜잭션을 시작하는 유일한 방법입니다. setAutoCommit(true)는 모든 SQL statement를 래핑합니다.(JDBC 라이브러리 룰에 따라 자동으로 커밋, 롤백 즉 트랜잭션이 이뤄집니다.) setAutoCommit(false)는 이와 반대로 트랜잭션의 주인은 내가 됩니다. 즉 제어를 내가하고 내가 원할 때 커밋 또는 롤백합니다.
  3. commit을 합니다.
  4. 또는 롤백을 합니다. 물론 예외가 발생했을 때에만.

이것이 JDBC의 기본적인 트랜잭션 방법이자, Spring의 @Transactional 의 전부입니다.

위에서 언급한대로 '자바에서 트랜잭션을 시작하는 유일한 방법'이기 때문에 Spring의 @Transactional 도 똑같이 동작합니다. 어떻게 동작하는지는 잠시 후에 설명합니다.

JDBC isolation levels and savepoints

Spring의 @Transactional 을 쓰려고하면 아래와 같은 코드를 보게됩니다.

@Transactional(propagation=TransactionDefinition.NESTED,
               isolation=TransactionDefinition.ISOLATION_READ_UNCOMMITTED)

트랜잭션 격리 레벨이 중첩되었을 때의 동작보다 격리 레벨(파라미터)이 아래와 같은 기본 JDBC 코드로 정리된다는 것을 아는게 중요합니다.

import java.sql.Connection;

// isolation=TransactionDefinition.ISOLATION_READ_UNCOMMITTED
connection.setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED); // (1)

// propagation=TransactionDefinition.NESTED
Savepoint savePoint = connection.setSavepoint(); // (2)
...
connection.rollback(savePoint);
  1. 트랜잭션의 고립 레벨을 지정하는 방법이다.
  2. NESTED 전파옵션의 트랜잭션은 데이터베이스의 savepoints로 동작합니다.

위에서 봤듯 JDBC를 이용한 Transaction isolation, propagation처리를 하는데, 스프링에서는 직접 JDBC와 같은 코드를 개발자가 사용하지않고 편리하게 사용할 수 있도록 다양한 방법을 제공합니다.

Spring Transaction Management의 동작 방식

잘 안 쓰는 방법인 프로그래밍스타일 트랜잭션 관리 방법부터 알아보겠습니다.

@Service
public class UserService {

    @Autowired
    private TransactionTemplate template;

    public Long registerUser(User user) {
        Long id = template.execute(status ->  {
            // SQL 실행
            // ex) inserts the user into the db and returns the autogenerated id
            return id;
        });
    }
}

위와 같이 TransactionTemplate을 사용하거나 직접 PlatformTransactionManager를 이용하면 된다.

  • JDBC 예제와의 비교
    • 데이터베이스 커넥션(Connection)을 직접 열고 닫을 필요가 없습니다. 대신에 트랜잭션 콜백을 사용합니다.
    • SQLExceptions를 잡을 필요가 없습니다. 스프링이 알아서 RuntimeException으로 변환해줍니다.
    • 스프링 환경에 더 적절하고, TransactionTemplate은 내부적으로 PlatformTransactionManager를 사용합니다. 모든 것이 Spring context configuration에서 지정해야하는 빈(Bean)들이지만, 나중에 수정할 필요가 없습니다.

다시 말하지만 프로그래밍적인 방식은 잘 사용을 안 합니다. 그런가보구나 하고 넘어갑니다.

XML방식도 있는데 이 역시도 잘 안 쓰는 방식이라 생략합니다.

@Transactional annotation

public class UserService {

    @Transactional
    public Long registerUser(User user) {
       // execute some SQL that e.g.
        // inserts the user into the db and retrieves the autogenerated id
        // userDao.save(user);
        return id;
    }
}

잘 쓰는 방식인 선언적 트랜잭션 방식입니다.

  • Spring Configuration에 @EnableTransactionManagement 애노테이션을 붙입니다. (스프링 부트에서는 자동으로 해줍니다.)
  • Spring Configuration에 트랜잭션 매니저를 지정합니다.
  • 그러면 스프링은 @Transactional 애노테이션이 달린 public 메서드에 대해서 내부적으로 데이터베이스 트랜잭션 코드를 실행해줍니다.
@Configuration
@EnableTransactionManagement
public class MySpringConfig {

    @Bean
    public PlatformTransactionManager txManager() {
        return yourTxManager; // more on that later
    }

}

위의 설정을 따를시 @Transactional이 쓰인 UserService코드를 간단히 변환하면 아래와 같습니다.

public class UserService {

    public Long registerUser(User user) {
        Connection connection = dataSource.getConnection(); // (1)
        try (connection) {
            connection.setAutoCommit(false); // (1)

            // execute some SQL that e.g.
            // inserts the user into the db and retrieves the autogenerated id
            // userDao.save(user); <(2)

            connection.commit(); // (1)
        } catch (SQLException e) {
            connection.rollback(); // (1)
        }
    }
}
  1. 그냥 @Transactional 이 있으면 JDBC에서 필요한 코드를 자동 삽입해줍니다. Connection 가져오고, setAutoCommit(false)해주고, 메소드 끝나면 커밋, 예외 발생하면 롤백!
  2. 내가 작성한 코드 부분

아주 간단합니다.

그렇다면 스프링이 이 코드를 어떻게 넣는 것일까요? 진짜 동작을 알아보겠습니다.

스프링이 Transaction 코드를 넣는 방법

스프링이 실제로 내가 작성한 자바 코드에 추가로 재 작성할 수 없습니다. (바이트 코드 위빙같은 고급기술이 아닌 이상...)

대신에 스프링은 IoC Container로 장점(Bean을 만들고 연결(autowire)하는 방법)을 활용합니다.

어떻게 하냐면, UserService를 인스턴스화할 뿐만 아니라 UserService의 트랜잭션 '프록시'도 인스턴스화합니다.

CGlib 라이브러리의 도움을 받아 프록시를 통하는 방식을 통해서 마치 코드를 넣는 것 처럼 동작하게 합니다.

내가 작성한 public 메소드 앞/뒤에 JDBC 코드를 프록시 객체에 직접 넣는 건 아닙니다.

모든 트랜잭션(open, commit, close)를 처리하는 것은 프록시 자체에서가 아니라 트랜잭션 매니저에 위임하여 처리하는 것입니다.

모든 트랜잭션 매니저는 "doBegin" 또는 "doCommit" 같은 메소드를 가집니다.

단순화해서 표현하면 아래와 같습니다.

public class DataSourceTransactionManager implements PlatformTransactionManager {

    @Override
    protected void doBegin(Object transaction, TransactionDefinition definition) {
        Connection newCon = obtainDataSource().getConnection();
        // ...
        newCon.setAutoCommit(false);
        // yes, that's it!
    }

    @Override
    protected void doCommit(DefaultTransactionStatus status) {
        // ...
        Connection connection = status.getTransaction().getConnectionHolder().getConnection();
        try {
            con.commit();
        } catch (SQLException ex) {
            throw new TransactionSystemException("Could not commit JDBC transaction", ex);
        }
    }
}
  1. 스프링은 @Transactional 애노테이션을 발견하면 그 빈의 다이나믹 프록시를 만들 것입니다.
  2. 그 프록시 객체는 트랜잭션 매니저에 접근하고 트랜잭션이나 커넥션을 열고 닫도록 요청할 것입니다.
  3. 트랜잭션 매니저는 JDBC방식으로 코드를 실행해줄 것입니다.

물리적 트랜잭션과 논리적 트랜잭션 차이

  • 물리적 트랜잭션 : 실제 JDBC 트랜잭션
  • 논리적 트랜잭션 : @Tansactional 로 중첩된 메소드

@Transactional Propagtion Level

트랜잭션 전파 레벨에는 여러가지가 있습니다. @Transactional 이 어떻게 동작하는지(JDBC 코드 이용)를 파악했으니 예상해볼 수 있습니다.

  • Required(default) : 메소드는 트랜잭션을 필요로 해. 트랜잭션을 새로 하나 열든지, 기존에 있던 거를 쓰든지 할거야 = getConnection(); setAutoCommit(false); commit();
  • Supports : 상관안해 트랜잭션 열든지말든지. 그냥 잘 실행할 수 있어. = JDBC는 아무것도 안함
  • Mandatory : 스스로 트랜잭션을 열진 않을거지만, 아무도 트랜잭션을 열지 않으면 울 거야 = JDBC는 아무것도 안함
  • Required_new : 온전히 내 소유의 트랜잭션이 필요해. = getConnection(); setAutoCommit(false); commit();
  • Not_Supported : 트랜잭션 싫어. 이미 실행중인 트랜잭션이 있으면 중지시킬거야 = JDBC는 아무것도 안함
  • Never : 울거야. 누군가 트랜잭션을 시작시킨다면. → JDBC는 아무것도 안함
  • Nested : 복잡한데... 저장점을 잡아줄게! → savepoints...?

결과적으로는 어떤 전파 옵션을 선택했을 때 JDBC코드가 들어가느냐 안 들어가느냐만 이해하면 됩니다.

@Transactional Isolation Level

@Transactional(isolation = Isolation.REPEATABLE_READ)

앞서 언급했듯, 위와 같이 사용하면 아래와 같이 프록시에서 대신 실행해줍니다.

connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);

데이터베이스 격리 수준은 복잡한 주제입니다.

트랜잭션 중에 격리 수준을 전환할 때, 데이터베이스나 JDBC 드라이버에서 기능이 지원되는지를 분명하게 먼저 확인해야할 필요가 있습니다.

프록시를 통하지 않고 다시말해 Spring IoC Container에 제어권을 넘기지 않고 @Transactional 이 붙은 내부 메소드 호출할 때 생기는 프록시 문제는 뭐 이제 다들 알기 때문에 생략하도록 하겠습니다. @Transactional 은 아니지만 스프링 프록시와 관련된 문제를 다룬 포스트에서 참고하시면 좋겠습니다.

Spring과 JPA(Hibernate) Transaction Management 동작

목적 : 스프링의 @TransactionalHibernate/JPA 동기화

public class UserService {

    @Autowired
    private SessionFactory sessionFactory; // (1)

    public void registerUser(User user) {

        Session session = sessionFactory.openSession(); // (2)

        // lets open up a transaction. remember setAutocommit(false)!
        session.beginTransaction();

        // save == insert our objects
        session.save(user);

        // and commit it
        session.getTransaction().commit();

        // close the session == our jdbc connection
        session.close();
    }
}
  1. 하이버네이트 쿼리의 시작인 SessionFactory를 사용합니다.
  2. 직접 세션을 관리하고 하이버네이트 API를 통해 트랜잭션을 관리합니다.

위 코드에는 거대한 문제가 있습니다.

  • 하이버네이트는 스프링의 @Transactional 애노테이션을 모릅니다.
  • 스프링의 @Transactional 은 하이버네이트 트랜잭션을 모릅니다.

맞나요? 아닙니다. 반전으로 두 트랜잭션은 서로 알고 있습니다. 둘 다 유일한 방법인 JDBC 기본 방식을 사용하기 때문이죠. 다만 서로 인지할 수 있게 스프링과 하이버네이트의 통합은 이뤄져야 합니다.(사실 스프링에서 이미 다 자동화해놓았습니다...)

@Service
public class UserService {

    @Autowired
    private SessionFactory sessionFactory; // (1)

    @Transactional
    public void registerUser(User user) {
        sessionFactory.getCurrentSession().save(user); // (2)
    }

}
  1. 이전과 같이 같은 SessionFactory를 씁니다.
  2. 그러나 더 이상 직접 상태를 관리하지 않습니다. 대신 getCurrentSession()@Transactional 이 동기화합니다.

어떻게 그렇게 될까요?

HibernateTransactionManager 사용

두 트랜잭션을 통합하는 문제를 고치는 건 굉장히 간단합니다.

DataSourcePlatformTransactionManager 를 쓰는 대신, HibernateTransactionManager 를 쓰면 됩니다. JPA를 통해 Hibernate를 사용한다면 JpaTransactionManager 를 쓰면 됩니다.

HibernateTransactionManager는 하이버네이트를 직접 사용할 때 트랜잭션을 관리하고, JpaTransactionManager는 JPA를 통해서 간접적으로 사용할 때 트랜잭션을 관리합니다.

스프링에서는 spring-boot-starter-data-jpa같은 라이브러리를 쓰면 자동으로 JpaTransactionManager를 씁니다.

핵심

Hibernate를 쓰든 JPA를 쓰든 @Transactional 을 쓰든 JDBC 기본(getConnection(), setAutoCommit(false), commit())방식으로 접근합니다.

이 뼈대(JDBC 기본 접근 방식)만 알고 있으면 트랜잭션을 조작할 때, 추가적으로 어떤 일이 일어나는지에 대해서 이해하기 조금 더 쉬워집니다.


핵심적인 내용은 전달되었겠으나 아직 더 알아볼게 있습니다.

실제로 구현도 확인해야합니다. 이 부분은 공부를 한 뒤에 추가로 아래에 작성하도록 하겠습니다.

  • 추가해야 할 사항
    • JpaTransactionManager 구현
    • TrasactionInterceptor 구현

참고로, 아래 참고 사이트를 제 마음대로 해석한 포스트입니다.

원문으로 이해하시려면 아래 링크를 이용해주시면 됩니다.

→ 참고 사이트

https://www.marcobehler.com/guides/spring-transaction-management-transactional-in-depth?fbclid=IwAR1PsHPKHyLGmiKORaTsvXV6EwIwe5f2RTCkz52QLZFnDdI7QzArXLil4PQ

반응형