본문 바로가기

연재작/Database

Transaction Deep Dive (4)

 

지난 번 글에서는 가장 저수준 방식인 JDBC API를 통해

connection을 직접 다루면서 Transaction을 동기화 하는 작업을 소개했다.

 

지금부터는 이러한 작업을 토대로 추상화를 진행해 차차 고수준으로 올려갈 것이다.

우선 첫 번째는 connection을 감추는 작업이다. connection을 감춘다기보다는,

Transaction Synchronization을 위해서

필수적으로 connection의 단위가 transaction의 단위가 동일시 되어야 하기 때문에
Transaction에 connection을 내재화 해서 추상화가 되도록 진행하는 방식이 된다.

 

 

1. 앞으로 사용할 용어의 정리

 

예시를 들때도 부모 Transaction, 자식 Transaction 이렇게 쓰는 것도 부정확해보이고,

Service, Repository라고 얘기하기에는 Service에 Service에... 와 같이 nested된 Service를 다룰 수 있기 때문에

앞으로 사용할 단어를 통일하고자 한다. 

  • 물리 트랜잭션 : Connection의 commit과 rollback의 단위.
    • 이론상으로는 그렇지 않지만 하나의 물리 트랜잭션은 하나의 connection으로 이루어져있다고 봐도 무방
    • 앞으로는 Transaction Synchronization 상황이 되는 트랜잭션상으로 유의미한 상황을 다룰 것이기 때문에
      하나의 Connection과 연결되어있다고 보면 된다.
    • 하나의 트랜잭션이 시작되어서 commit 혹은 rollback이 되기 전까지의 생명주기를 가진다고 보면 된다.
  • 논리 트랜잭션 : Connection의 commit, rollback을 하지 않는 단위.
    • 하나의 Connection에 연결된 물리 트랜잭션의 commit, rollback을 하지는 않지만
      이에 영향을 주는 단위이다. commit이라면 트랜잭션의 context에 영향을 주지는 않으나
      rollback이라면 트랜잭션의 context에 영향을 주어 전체 물리 트랜잭션 rollback을 유발한다.
    • 하나의 물리 트랜잭션은 여러 논리 트랜잭션으로 이루어져 있다고 봐도 무방하다.

 

 

2. Spring이 제공하는 Transaction과 Transaction Synchronization의 추상화 방식 선요약

 

  • Transaction 동기화를 추상화한 TransactionSynchronizationManager를 제공
  • Transaction의 관리를 추상화한 PlatformTransactionManager를 제공
    • 로컬 트랜잭션, 글로벌 트랜잭션 등 모든 종류의 트랜잭션의 대한 공통적인 기능을 추상화 해 제공.
  • JDBC API의 쿼리 수행 및 논리 트랜잭션의 참여를 추상화한 JdbcTemplate를 제공

이 세 가지 추상화 기술로 connection -> statement -> resultSet의 과정을 감추고, 

고수준 transaction을 통해 간단히 트랜잭션 형성 -> commit or rollback만 하면 되도록 간편화한다.

 

 

 

3. TransactionSynchronizationManager + DataSourceUtils

 

 

이 방식은 물리 트랜잭션에서 논리 트랜잭션에 connection을 파라미터로 넘겨주지 않아도

논리 트랜잭션에서 자신이 속한 물리 트랜잭션의 connection을 사용해 트랜잭션 동기화를 할 수 있는 방법이다.

TransactionSynchronizationManager는 기본적으로 스레드 기반으로 Connection을 저장한다.

구체적으로는 resources라는 ThreadLocal에 Map을 두고, 이 Map에 여러 resource들을 저장한다.

ThreadLocal을 사용해서 connection을 보관하기에, 멀티 스레드 상황에 대한 Thread-Safe한 접근을 가능하게 한다.

그렇기 때문에 멀티 스레드를 사용한다고 하더라도 connection에 대한 동시성 문제를 따로 고려하지 않아도 된다.

다만 여기서는 TransactionSynchronizationManager와 connection의 관리를 동시에 진행해주어야 하기에

많은 변화가 있지는 않다. (물리 트랜잭션에 connection이 내재화된 수준까지는 아니다.)

 

이를 사용한 물리 트랜잭션에서의 코드는 아래와 같다.

public UserResponseDto save(UserCreateRequestDto request) {
    TransactionSynchronizationManager.initSynchronization();
    Connection connection = DataSourceUtils.getConnection(dataSource);

    try {
        connection.setAutoCommit(false);
        
        User savedUser = userRepository.createUser(...);
        messageRepository.save(savedUser.getName() + "님 가입을 환영합니다.", targetId);
        
        connection.commit();
        return UserResponseDto.from(savedUser, messages);
    } catch (SQLException e) {
        try {
            connection.rollback();
        } catch (SQLException ignored) {
        }
        throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 문제가 있습니다.");
    } finally {
        TransactionSynchronizationManager.clearSynchronization();
        DataSourceUtils.releaseConnection(connection, dataSource);
    }

}

 

다만, ThreadLocal을 사용해 저장하는 것이 TransactionSynchronization이라는 객체이기 때문에

만약 어떤 스레드에서 initSynchronization()을 통해 트랜잭션 동기화를 열어두고 이미 transaction을 수행했다면

여전히 ThreadLocal에는 해당 스레드가 사용했던 TransactionSynchronization이 남아 있다.

그래서 이를 다시 사용하는 것은 불가능해 에러가 뜬다.

➡️ 따라서 이럴 경우 ThreadLocal에 남아있는 TransactionSynchronization을 지워 줄

TransactionSynchronization.clearSynchronization() static 메서드를 통해 ThreadLocal.remove() 가 수행되도록 해야 한다.

 

논리 트랜잭션에서의 코드는 아래와 같다.

 

public User createUser(String name, Integer age, String job, String specialty)
        throws SQLException {
    Connection connection = null;
    PreparedStatement statement = null;
    ResultSet resultSet = null;

    try {
        connection = DataSourceUtils.getConnection(dataSource);
        ...
    } catch (SQLException e) {
        throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 문제가 있습니다.");
    } finally {
        if (resultSet != null) {
            resultSet.close();
        }
        if (statement != null) {
            statement.close();
        }
    }
}

 

다만 논리 트랜잭션에서는 DataSourceUtils.getConnection(dataSource)을 통해 connection을 얻어올 뿐,

connection을 따로 닫아주거나 해서 관리할 필요는 없어진다.

 

 

 

4. PlatformTransactionManager + DataSourceUtils

 

PlatformTransactionManager는 transaction의 기본 동작인
트랜잭션 생성, commit, rollback 이 세 개에 대한 동작을 추상화한다.

기본적인 구현체는 DataSourceTransactionManager이고, 
Spring Data JPA를 사용할 경우 JpaTransactionManager를 통해서 구현된다.

참고로 AbstractPlatformTransactionManager를 통해서 미리 구현된 메소드도 있기에 이를 표현하면 아래와 같다.

 

한편, connection을 직접 구현하지 않고 dataSource를 파라미터로 집어넣어서 PlatformTransactionManager를 생성하기 때문에 이를 통해 connection의 더 이상 connection의 관리는 필요 없어진다.

이때 같이 사용되는 것이 TransactionStatus라는 것인데, 여기에는 트랜잭션 세부 옵션이 포함된 상태가 포함된다.

propagation, isolation level, timeout, read-only와 같은 것을 설정할 수 있다.

 

아래와 같이 적용되고 반환되는 TransactionStatus 객체를

transactionManager의 commit 혹은 rollback시에 파라미터로 넣어줘야 한다.

public UserResponseDto save(UserCreateRequestDto request) {
    PlatformTransactionManager transactionManager = new DataSourceTransactionManager(
            dataSource);
    TransactionStatus status = transactionManager.getTransaction(
            new DefaultTransactionDefinition());

    try {
        User savedUser = userRepository.createUser(request.getName(),
                request.getAge(), request.getJob(), request.getSpecialty());
        Long targetId = savedUser.getId();
        messageRepository.save(savedUser.getName() + "님 가입을 환영합니다.", targetId);
        List<Message> messages = messageRepository.findByUserId(targetId);

        transactionManager.commit(status);
        return UserResponseDto.from(savedUser, messages);
    } catch (SQLException e) {
        log.warn("error", e);
        transactionManager.rollback(status);
        throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 문제가 있습니다.");
    }

}

 

추가적으로 기존의 TransactionSynchronizationManager를 통해 진행 했을 경우,

트랜잭션 동기화를 닫아줘야하는 clear메서드를 사용해주어야 했지만

 

PlatformTransactionManager의 경우, 

AbstractPlatformTransactionManager에서 commit -> doCommit -> processCommit 메서드가

마지막에 아래 cleanupAfterCompletion메서드를 호출하고

} finally {
    this.cleanupAfterCompletion(status);
}

 

cleanupAfterCompletion메서드를 통해

private void cleanupAfterCompletion(DefaultTransactionStatus status) {
    status.setCompleted();
    if (status.isNewSynchronization()) {
        TransactionSynchronizationManager.clear();
    }
    ...

TSM 의 clear를 진행하기 때문에 트랜잭션의 관리 마저 내재화 한것을 볼 수 있다.

간편하게 transaction 생성 -> commit -> rollback만을 생각하면 된다.

이로서 물리 트랜잭션에서 connection의 내재화가 완성되었다.

 

 

 

5. PlatformTransactionManager + JdbcTemplate

 

마지막으로, 논리 트랜잭션에서 connection을 내재화 하기 위해서 Spring Transaction이 제공하는 것이 

JdbcTemplate이다.

JdbcTemplate은 connection의 내재화 뿐만 아니라, statement, resultSet까지의 모든 JDBC API의 추상화를

한 번 더 추상화해서 transaction 실행 코드를 획기적으로 줄여준다. 

이때 Try-with-Resources를 사용하는데, error가 발생 시 기존의 Closeable 객체들은

close() 실행을 하지 못하는 상황이 발생했지만,

AutoCloseable 객체를 사용하고 Try-with-Resources를 사용할 경우, error가 발생하더라도 close()가 실행하도록 해

자원 관리를 자동적으로 해주는 구문을 사용할 수가 있게 되었다.

https://mangkyu.tistory.com/217

 

[Java] try-with-resources란? try-with-resources 사용법 예시와 try-with-resources를 사용해야 하는 이유

이번에는 오랜만에 자바 문법을 살펴보고자 합니다. Java7부터는 기존의 try-catch를 개선한 try-with-resources가 도입되었는데, 왜 try-catch가 아닌 try-with-resources를 사용해야 하는지, 어떻게 사용하는지

mangkyu.tistory.com

 

뿐만 아니라, 기존에 connection.commit()과 같은 메서드를 사용할 경우,

SQLException을 반드시 예외 처리해주어야 하는 상황이 발생했다. (SQLException이 checked Exception이기 때문)

하지만 JdbcTemplate은 이 과정 또한 처리해주기 때문에 코드가 엄청나게 줄어든다.

마지막으로 논리 트랜잭션이 물리 트랜잭션에 참여하는 것 또한 자동으로 진행해준다.

 

정리하자면 JdbcTemplate을 사용해 얻는 이점은

  1. try-with-resources를 통한 connection, statement, resultSet 보일러 플레이트 코드 필요 제거
  2. SQLException의 예외처리 필요 제거
  3. 논리 트랜잭션의 물리 트랜잭션 참여 내재화

이를 통해 기존의 JDBC API를 통한 저수준 코드에서 탈피할 수 있게 되는 것이다.

코드의 사용방식은 아래와 같다.

 

public User createUser(...) {
    String createUserQuery = "INSERT INTO \"user\" (name, age, job, specialty, created_at) VALUES (?, ?, ?, ?, now())";
    Object[] createUserParams = new Object[]{ 위의 ?에 들어갈 object들 순서대로 };
    
    this.jdbcTemplate.update(createUserQuery, createUserParams);

    String getLastIdQuery = "SELECT lastval()";
    Long createdUserId = this.jdbcTemplate.queryForObject(getLastIdQuery, Long.class);

    String getCreatedUserQuery = "SELECT * FROM \"user\" WHERE id = ?";
    return this.jdbcTemplate.queryForObject(
            getCreatedUserQuery,
            (resultSet, rowNum) -> new User(
                    resultSet.getLong("id"),
                    resultSet.getString("name"),
                    resultSet.getInt("age"),
                    resultSet.getString("job"),
                    resultSet.getString("specialty"),
                    resultSet.getTimestamp("created_at")
                            .toInstant()
                            .atZone(ZoneId.systemDefault())
                            .toLocalDateTime()
            ),
            createdUserId
    );
}

 

특이할 점이라면, rowMapper라는 함수형 인터페이스를 통해 DB로 부터 받아오는 정보를 받아오는데,

위와 같이 사용한다면 크게 문제점은 없을 것이다.

 

참고로 SELECT 쿼리를 통해 얻어오는 데이터가 단일 row라면 queryForObject 메서드를, 

여러 row라면 queryForStream을 통해서 받아와야 한다.

 

그러나 아직 갈길이 남았다.

실무에서 사용하는 Transaction은 이것보다 고수준 추상화된 방식인

@Transactional(선언형 트랜잭션), TransactionTemplate(프로그래밍형 트랜잭션) 을 사용한다.

이에 대해서 다음 글에서 다루도록 하겠다.

 

 

 

https://aaronryu.github.io/2018/12/30/a-introduction-to-design-patterns/

 

1. 디자인 패턴에 앞서

디자인 패턴은 대학교에서 간단하게만 배웠던 기억이 닙니다. 대학원에서도 입사 준비 때도 주변에서는 디자인 패턴이 중요하다지만 실제로 잘 사용하는 사람은 없었고, 이게 왜 중요한지에 대

aaronryu.github.io

 

'연재작 > Database' 카테고리의 다른 글

Persistence Context의 Entity 상태 관리, Life-Cycle  (1) 2024.11.28
Transaction Deep Dive (5)  (0) 2024.11.28
Transaction Deep Dive (3)  (0) 2024.11.23
Transaction Deep Dive (2)  (0) 2024.11.22
Transaction Deep Dive (1)  (0) 2024.11.20