트랜잭션의 동기화와 추상화(with JDBC)

mylime·2024년 6월 25일
2

이 포스팅은 2024.06.26에 작성되었습니다.



서론

트랜잭션의 전파에 대해 공부하다가 트랜잭션의 동기화와 추상화까지 내려왔다.
JDBC로 Connection을 직접 사용하여 트랜잭션을 만지는 방법부터, 많이 사용하는 Spring @Transactional 까지 나아가보려고 한다.

https://hudi.blog/spring-transaction-synchronization-and-abstraction/
위 블로그를 많이 참고하여 작성합니다! 읽어보시는 것 추천드립니다.



JDBC로 원자적으로 로직 실행하기

workbench, sql 콘솔 등으로 데이터베이스 쿼리를 날릴 때, INSERT문 하나만 단독으로 실행해도 테이블에 반영이 된다. 이는 내부적으로 자동으로 커밋해주는 auto commit기능 덕분이다.

하지만 우리는 여러 개의 쿼리문을 한 번에 트랜잭션으로 처리해줘야하는 경우가 많다.
ex) 사용자가 회원가입을 하는 동시에, 사용자의 계좌가 생성되어야함


위 예시의 경우 사용자가 생성되었는데 계좌가 생성되지 않거나, 계좌만 생성되고 사용자가 생성되면 안된다. 두 로직은 `원자적`으로 실행되어야한다. 그래서 각각의 단일 쿼리가 자동으로 커밋되면 곤란하다.

JDBC를 사용하면서 여러 개의 쿼리문을 원자적으로 묶어 실행하고 싶은 경우, auto commit 기능을 비활성화하고, 커밋과 롤백 시점을 직접 지정해줄 수 있다.



아래 코드는 회원가입 시에 회원이 생성되는 동시에, 메시지가 동시에 같이 생성되는 코드이다.

public class MemberDao {
    public void saveMember(final Connection connection, final String name) {
        try {
            String sql = "insert into member(name) values (?)";
            PreparedStatement preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setString(1, name);
            preparedStatement.execute();
        } catch (final SQLException e) {
            e.printStackTrace();
        }
    }
}
public class MessageDao {
    public void saveMessage(final Connection connection, final String name) {
        try {
            String sql = "insert into seat(message) values (?)";
            PreparedStatement preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setString(1, name + "님 환영합니다");
            preparedStatement.execute();
        } catch (final SQLException e) {
            e.printStackTrace();
        }
    }
}

member를 저장하면서 message를 저장하는 로직을 트랜잭션으로 묶어줘야하기 때문에, 각 dao의 메서드들은 같은 connection을 공유해야한다. 그래서 Dao의 메서드들은 connection을 외부에서 주입받아야한다.

public class MemberService {
    ...

    public void register(final String name) {
        Connection connection = null;
        try {
            connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
            connection.setAutoCommit(false);

            memberDao.saveMember(connection, name);
            messageDao.saveMessage(connection, name);

            connection.commit();
        } catch (final SQLException e) {
            try {
                connection.rollback();
            } catch (final SQLException ignored) {
            }
        } finally {
            try {
                connection.close();
            } catch (final SQLException ignored) {
            }
        }
    }
}

dao를 사용하는 서비스코드이다.

서비스 레이어의 관심사는 비즈니스 로직이지만, memberDao와 messageDao에서 실행되는 쿼리를 트랜잭션으로 묶어줘야하기 때문에 서비스에서 connection을 관리하고 있는 모습이다. 그래서 connection을 생성/관리하는 로직 + JDBC를 사용할 때 따라오는 try/catch/finally 보일러 플레이트 코드 모두 서비스 코드에 포함되었다.

✅ 서비스에서는 커넥션 생성, 트랜잭션 경계를 설정하는 코드와 비즈니스 로직이 뒤섞이게 되고
✅ Dao에서는 데이터 엑세스 기술이 Service에 종속된다



트랜잭션 동기화

위와 같은 문제를 해결하기 위해 Spring에서는 트랜잭션 동기화를 이용해 데이터베이스 커넥션을 관리한다. 트랜잭션을 시작하기 위해 생성한 Connection 객체를 별도의 특별한 공간에 보관하고, 이 커넥션이 필요한 곳에서 커넥션을 꺼내 쓴다.

+) 이 동기화 작업은 쓰레드마다 독립적으로 Connection 객체를 보관하므로 멀티 쓰레드 환경에서도 걱정없이 사용할 수 있다


스프링에서는 `TransactionSynchronizationManager`라는 클래스를 사용하여 트랜잭션을 동기화한다. 이를 사용하여 코드를 개선해보겠다.
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/txtest?serverTimezone=Asia/Seoul
    username: user
    password: user

커넥션을 가져올때 DataSource 가 필요하므로, 데이터 소스에 대한 설정을 application.yml 에 추가한다.


public void register(final String name) {
    //트랜잭션 동기화 초기화
    TransactionSynchronizationManager.initSynchronization();

    //커넥션 획득
    Connection connection = DataSourceUtils.getConnection(dataSource);

    try {
        connection.setAutoCommit(false);

        memberDao.saveMember(name);
        messageDao.saveMessage(name);

        connection.commit();
    } catch (final SQLException e) {
        try {
            connection.rollback();
        } catch (final SQLException ignored) {
        }
    } finally {
        // 커넥션 자원 해제
        DataSourceUtils.releaseConnection(connection, dataSource);
    }
}

MemberService 는 TransactionSynchronizationManager 클래스를 사용하여 트랜잭션 동기화 작업을 활성화하고, DataSourceUtils 라는 헬퍼 클래스를 통해 커넥션을 가져온다.

이제 connection은 DataSourceUtils에서 꺼내올 수 있기 때문에 service가 직접 생성/관리하고 dao 메서드에 넘겨주지 않아도 된다.

하지만 memberDao와 messageDao의 트랜잭션 경계를 설정해줘야하기 때문에 connection에 대한 커밋/롤백은 서비스에서 관리해야한다.


@RequiredArgsConstructor
@Repository
public class MessageDao {
    private final DataSource dataSource;

    public void saveMessage(final String name) {
        Connection connection = DataSourceUtils.getConnection(dataSource);

        try {
            String sql = "insert into seat(message) values (?)";
            PreparedStatement preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setString(1, name + "님 환영합니다");
            preparedStatement.execute();
        } catch (final SQLException e) {
            e.printStackTrace();
        }
    }
}
@RequiredArgsConstructor
@Repository
public class MemberDao {
    private final DataSource dataSource;

    public void saveMember( final String name) {
        Connection connection = DataSourceUtils.getConnection(dataSource);

        try {
            String sql = "insert into member(name) values (?)";
            PreparedStatement preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setString(1, name);
            preparedStatement.execute();
        } catch (final SQLException e) {
            e.printStackTrace();
        }
    }
}

dao에서도 이제 connection을 외부에서 주입받지 않고, DataSourceUtils를 통해서 주입받는다.



트랜잭션 추상화

위에서는 connection을 통해 직접적으로 트랜잭션 경계를 설정하였다. 이 경우 데이터 엑세스 기술이 Service에 종속된다는 문제점이 있다.

스프링에서는 트랜잭션 추상화를 위해 PlatformTransactionManager 인터페이스를 제공한다. 이 인터페이스를 통해 connection을 사용하여 트랜잭션 경계를 직접 지정하는 코드를 추상화할 수 있다.

public interface PlatformTransactionManager extends TransactionManager {
   TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
         throws TransactionException;

   void commit(TransactionStatus status) throws TransactionException;

   void rollback(TransactionStatus status) throws TransactionException;
}

스프링은 트랜잭션 전파라는 특징을 가지고 있어, 자유롭게 트랜잭션을 서로 조합하고 트랜잭션의 경계를 확장할 수 있다. 그래서 begin()이라는 네이밍 대신, 현재 트랜잭션을 가져온다는 의미의 getTransaction()을 사용한다.(실제로 PROPAGATION_REQUIRED 전파속성을 사용하는 경우, 직접 지정해준 트랜잭션이 아닌 상위 트랜잭션의 속성을 가져오는 경우가 있다)

PlatformTransactionManager 의 구현 클래스는 다음과 같은 것들이 있다.

  • DataSourceTransactionManager
  • JpaTransactionManager
  • HibernateTransactionManager
  • JmsTransactionManager
  • JtaTransactionManager

아래에서는 DataSourceTransactionManager를 사용하였다.

public void register(final String name) {
    PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

    try {
        memberDao.saveMember(name);
        messageDao.saveMessage(name);

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

connection 객체를 직접 사용하여 트랜잭션 경계를 설정하는 코드가 사라지니 훨씬 코드가 간결해진 걸 볼 수 있다. 또한 트랜잭션 동기화를 위한 connection 객체를 얻고, 자원을 해제하는 코드도 PlatformTransactionManager 를 통해 추상화되었다.

  • TransactionDefinition: 트랜잭션의 속성을 나타내는 인터페이스
    • Propagation, Isolation Level, Timeout, Read Only
  • TransactionStatus: 현재 참여하고 있는 트랜잭션의 ID와 구분 정보를 담고 있음. 커밋과 롤백시 이 정보를 통해 트랜잭션을 식별함



+) 트랜잭션의 전파

public void registerOnlyMember(final String name) {
    PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

    try {
        memberDao.saveMember(name);
        transactionManager.commit(status);
    } catch (RuntimeException e) {
        log.info("에러발생, rollback");
        transactionManager.rollback(status);
    }
}

public void callRegister(final String name) {
    PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

    try {
        registerOnlyMember(name);
        messageDao.saveMessage(name);
        //throw new RuntimeException();
        transactionManager.commit(status);
    } catch (RuntimeException e) {
        transactionManager.rollback(status);
        e.printStackTrace();
    }
}

callRegister에서 registerOnlyMember를 호출하여 트랜잭션 전파를 확인해봤다.


  • 1) registerOnlyMember 메서드가 잘 동작되고, callRegister에서 예외가 발생한 경우

    • 두 메서드에서 모두 commit하는 코드를 작성 해줬지만, 두 로직은 모두 롤백되었다.
      트랜잭션이 전파되며 원자적으로 잘 동작한 것이다.
  • 2) registerOnlyMember 메서드에서 예외가 발생한 경우

    • 호출되는 내부 메서드에서 exception이 발생하여 rollback된 경우, 외부 메서드에서 commit하는 순간 다음과 같은 예외가 발생한다.
    • 에러메시지는 아래와 같다

org.springframework.transaction.IllegalTransactionStateException:
Transaction is already completed - do not call commit or rollback more than once per transaction

IllegalTransactionStateException의 경우 트랜잭션의 존재여부가 illegal state일 때 발생하는 예외라고 설명되어있다.



AOP를 이용한 트랜잭션 분리

트랜잭션 추상화까지만 해도 충분히 코드가 간결해졌지만, 트랜잭션 코드를 비즈니스 로직과 분리하고 싶은 욕심이 생길 수 있다.

스프링은 트랜잭션 코드와 같은 부가 기능 코드가 존재하지 않는 것처럼 보이기 위해 해당 로직을 클래스 밖으로 빼내 별도의 모듈로 만드는 AOP를 고안 및 적용하였고, 이를 적용한 트랜잭션 어노테이션 @Transactional을 지원하게 되었다.

@Transactional
public void register(final String name) {
    memberDao.saveMember(name);
    messageDao.saveMessage(name);
}

너무 깔끔하게 비즈니스로직만 남은 걸 확인할 수 있다.



마치며...

이번에 JDBC를 이용해 Connection을 직접 만져보고 개선해나가면서 트랜잭션에 대해 더 깊게 이해할 수 있었던 것 같다. 트랜잭션은 깊이 알아볼수록 더 재밌는 것 같다.
트랜잭션의 전파속성, @Transactional의 동작방식 등 더 공부하고 정리하고 싶은 내용이 아직 너무 많아서 얼른 포스팅하고 싶다.



참고자료

https://hudi.blog/spring-transaction-synchronization-and-abstraction/
https://mangkyu.tistory.com/154

profile
깊게 탐구하는 것을 좋아하는 백엔드 개발자 지망생 lime입니다! 게시글에 틀린 정보가 있다면 지적해주세요. 감사합니다. 이전블로그 주소: https://fladi.tistory.com/

0개의 댓글