본문 바로가기
Java/Spring

스프링 트랜잭션

by oneny 2023. 10. 5.

트랜잭션

트랜잭션은 하나 이상의 작업을 논리적인 작업 단위로 묶은 것으로, 이 작업들은 모두 성공적으로 완료되거나 실패할 수 있다. 데이터베이스 관리 시스템(DBMS)에서 특히 중요하며, 데이터베이스에서 데이터를 안전하게 조작하고 관리하기 위해 사용된다.

 

트랜잭션 ACID

트랜잭션의 주요 특징은 다음과 같다.

  • 원자성(Atomicity): 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 모두 실패해야 한다.
  • 일관성(Consistency): 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어, 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
  • 격리성(Isolation): 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. 예를 들어 동시에 같은 데이터를 수정하지 못하도록 해야 한다. 격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준(Isolation level)을 선택할 수 있다.
  • 지속성(Durability): 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 복구해야 한다.

 

트랜잭션은 원자성, 일관성, 지속성을 보장한다. 문제는 격리성인데 트랜잭션 간에 격리성을 완벽히 보장하려면 트랜잭션을 거의 순서대로 실행해야 하지만 그렇게 하면 동시 처리 성능이 매우 나빠진다. 이런 문제로 ANSI 표준은 트랜잭션의 격리 수준(Isolation level)을 4단계로 나누어 정의했다.

  • READ UNCOMMITED(커밋되지 않은 읽기)
  • READ COMMITED(커밋된 읽기)
  • REPEATABLE READ(반복 가능한 읽기)
  • SERIALIZABLE(직렬화 가능)

 

데이터베이스 연결 구조와 DB 세션

커넥션 풀이 10개의 커넥션을 생성하면 세션도 10개 생성된다.

사용자는 웹 애플리케이션 서버(WAS)를 통해서 데이터베이스 서버에 접근할 수 있다. 이때 WAS에서는 데이터베이스 서버에 연결을 요청하면 커넥션을 맺게 되고, 데이터베이스는 내부에 세션이라는 것을 만든다. 그리고 앞으로 해당 커넥션을 통한 모든 요청은 이 세션을 통해서 실행하게 된다. 즉, SQL을 전달하면 현재 커넥션에 연결된 세션이 SQL을 실행하는 것이다.

 

이러한 데이터베이스 세션은 여러 트랜잭션을 포함할 수 있으며, 각 트랜잭션은 데이터베이스 세션 내에서 실행되어 하나의 작업을 원자적으로 처리하고, 데이터베이스의 무결성과 일관성을 유지하기 위해 사용된다.

예를 들어, 하나의 주문 처리 트랜잭션은 재고 차감, 결제, 주문 생성이라는 하나의 논리적 단위를 이루고 있을 수 있다. 세션 내에서 트랜잭션이 실행되는 중간에 결제에서 실패하면 이전 상태로 롤백하여 일관성 및 원자성을 유지할 수 있고, 모든 단계가 성공적으로 완료될 때에만 커밋처리가 된다.

 

DB 락 - 변경

세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋을 수행하지 않았는데, 세션2에서 동시에 같은 데이터를 수정하게 되면 트랜잭션의 원자성 및 격리성이 깨질 수 있다. 이런 문제를 방지하기 위해서는 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야 한다.

데이터베이스는 이런 문제를 해결하기 위해 락(Lock)이라는 개념을 제공한다. 위 그림에서 볼 수 있듯이 세션1이 먼저 트랜잭션을 시작하여 lock을 획득했다. 그리고 update를 실행하는 중간에 세션2에서도 트랜잭션이 시작되었지만 락은 이미 세션1이 가지고 있기 때문에 세션2는 lock 획득을 대기하고 있는다. 그리고 세션1이 commit하여 락을 반납하면 lock 획득하기를 대기했던 세션2가 lock을 획득하면서 update 쿼리문을 실행하고 커밋을 수행하여 트랜잭션을 종료하고 락을 반납한다.

참고로 세션2가 락을 무한정 대기하는 것은 아니다. 락 대기 시간을 넘어가면 설정된 락 타임아웃 오류가 발생한다.

 

DB 락 - 조회

데이터베이스마다 다르지만 일반적으로 조회는 락을 획득하지 않고 바로 데이터를 조회한다. 예를 들어, 세션1이 락을 획득하고 데이터를 변경하고 있어도, 세션2에서 데이터를 조회는 할 수 있다. 하지만 데이터를 조회할 때도 락을 획득하고 싶을 때는 select for update 구문을 사용하면 된다. 이렇게 하면 세션1이 조회 시점에 락을 가져가버리기 때문에 다른 세션에서 해당 데이터를 변경할 수 있다.

예를 들어, 애플리케이션 로직에서 memberA의 금액을 조회한 다음에 이 금액 정보로 애플리케이션에서 어떤 계산을 수행한다. 그런데 이 계산이 돈과 관련된 매우 중요한 계산이어서 계산을 완료할 때까지 memberA의 금액을 다른 곳에서 변경하면 안되기 때문에 이러한 경우에 조회 시점에 락을 획득하도록 만들 수 있다.

 

 

트랜잭션 적용

위 그림은 애플리케이션 구조를 나타낸다. 가장 단순하면서 많이 사용하는 방법으로 역할에 따라 3가지 계층으로 나눈 것이다.

  • 프레젠테이션 계층: UI와 관련된 처리 담당, 웹 요청과 응답, 사용자 요청을 검증
    • 주 사용 기술: 서블릿과 HTTP 같은 웹 기술, 스프링 MVC
  • 서비스 계층: 비즈니스 로직을 담당
    • 주 사용 기술: 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성
  • 데이터 접근 계층: 실제 데이터베이스에 접근하는 코드
    • 주 사용 기술: JDBC, JPA, File, Redis, Mongo, ...

 

트랜잭션은 어떤 계층에 걸어야 할까? 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 비즈니스 로직이 잘못되면 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문이다. 또한 트랜잭션을 시작하려면 커넥션이 필요하기 때문에 서비스 계층에서 커넥션을 만들고, 같은 세션을 사용하기 위해서 트랜잭션을 사용하는 동안 같은 커넥션을 유지하다 트랜잭션 커밋한 이후에 커넥션을 종료해야 한다.

 

여기서 모순이 있는데 위에서 서비스 계층에는 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성해야 한다면서 트랜잭션을 적용하기 위해 java.sql.Connection을 서비스 계층에 생성하게 되면 서비스 계층은 JDBC 구현 기술에 의존하게 된다. 이는 추후에 JDBC에서 JPA로 변경하게 되면 데이터 접근 기술마다 트랜잭션을 사용하는 방법이 다르기 때문에 서비스 계층의 트랜잭션 관련 코드도 모두 함께 수정해야 한다.

  • JDBC: connection.setAutoCommit(false)
  • JPA: transaction.begin()

그렇다면 이 문제를 어떻게 해결할 수 있을까?

 

트랜잭션 추상화

이 문제를 해결하기 위해서는 위 그림처럼 트랜잭션 기능을 추상화하면 된다. 즉, 서비스는 특정 트랜잭션 기술에 직접 의존하는 것이 아니라 추상화된 인터페이스에 의존하여 원하는 구현체를 DI를 통해서 주입하면 된다. 만약 JDBC 트랜잭션 기능이 필요하다면DataSourceTransactionManager를 서비스에 주입하고, JPA 트랜잭션 기능으로 변경해야 하면 JpaTransactionManager를 주입하면 된다. 스프링 트랜잭션 추상화의 핵심은 org.springframework.transaction.PlatformTransactionManager 인터페이스이다!

클라이언트인 서비스는 인터페이스에 의존하고 DI를 사용한 덕분에 OCP 원칙을 지키게 되었다. 이제 트랜잭션을 사용하는 서비스 코드를 전혀 변경하지 않고, 트랜잭션 기술을 마음껏 변경할 수 있다.

여기에 더해서 스프링 부트는 어떤 데이터 접근 기술을 사용하는지를 자동으로 인식해서 적절한 트랜잭션 매니저를 선택해서 스프링 빈으로 등록해주기 때문에 트랜잭션 매니저를 선택하고 등록하는 과정도 생략할 수 있다. 예를 들어, JdbcTemplate, MyBatis를 사용하면 DataSourceTransactionManager(JdbcTransactionManager)를 스프링 빈으로 등록하고, JPA를 사용하면 JpaTransactionManager를 스프링 빈으로 등록해준다.

 

참고: JdbcTransactionManager
스프링 5.3부터는 JDBC 트랜잭션을 관리할 때 DataSourceTransactionManager를 상속받아서 약간의 기능을 확장한 JdbcTransactionManager를 제공한다.

 

  • getTransaction(): 트랜잭션을 시작한다.
    • 이름이 getTransaction()인 이유는 기존에 이미 진행중인 트랜잭션이 있는 경우 해당 트랜잭션에 참여할 수 있기 때문이다.
    • 트랜잭션 참여, 전파에 대해서는 뒤에서 살펴보자.
  • commit(): 트랜잭션을 커밋한다.
  • rollback(): 트랜잭션을 롤백한다.

 

트랜잭션 동기화

스프링이 제공하는 트랜잭션 매니저(PlatformTransactionManager)는 크게 2가지 역할을 한다.

  • 트랜잭션 추상화
  • 리소스 동기화

 

트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야 한다. 결국 같은 커넥션을 동기화하기 위해서 스프링에서는 트랜잭션 동기화 매니저를 제공한다. 트랜잭션 동기화 매니저는 스레드 로컬(ThreadLocal)을 사용하기 때문에 멀티스레드 상황에 안전하게 커넥션을 동기화한다. 따라서 서비스 계층에서 데이터 접근 계층의 메서드에 파라미터를 통해 커넥션을 전달하지 않아도 된다. 동작 방식을 설명하면 다음과 같다.

  1. 트랜잭션을 시작하려면 커넥션이 필요하다. 트랜잭션 매니저(PlatformTransactionManager)는 데이터소스를 통해 커넥션을 만들고 트랜잭션을 시작한다.
  2. 트랜잭션 매니저(PlatformTransactionManager)는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  3. 데이터 접근 계층은 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 따라서 서비스 계층에서 파라미터로 커넥션을 전달하지 않아도 된다.
  4. 트랜잭션이 종료되면 트랜잭션 매니저(PlatformTransactionManager)는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고, 커넥션도 닫는다.

 

멀티스레드 환경에서도 안전한 트랜잭션 동기화 방법을 구현하는 작업은 기술적으로 어렵지만, 스프링은 이런 트랜잭션 동기화 기능을 지원하는 간단한 유틸리티 메서드를 제공하고 있다. 따라서 이 유틸리티 메서드의 도움을 받아 커넥션을 동기화할 수 있다.

 

트랜잭션 동기화 매니저 적용

@Slf4j
public class MemberRepository {

    private final DataSource dataSource;

    public MemberRepository(DataSource dataSource) {
       this.dataSource = dataSource;
    }

    public Member save(Member member) throws SQLException {
       String sql = "insert into member(member_id, money) value (?, ?)";

       Connection con = null;
       PreparedStatement pstmt = null;

       try {
          con = getConnection();
          pstmt = con.prepareStatement(sql);
          pstmt.setString(1, member.getMemberId());
          pstmt.setInt(2, member.getMoney());
          pstmt.executeUpdate();
          return member;
       } catch (SQLException e) {
          log.error("db error", e);
          throw e;
       } finally {
          close(con, pstmt, null);
       }
    }

    public Member findById(String memberId) throws SQLException {
       String sql = "select * from member where member_id = ?";

       Connection con = null;
       PreparedStatement pstmt = null;
       ResultSet rs = null;

       try {
          con = getConnection();
          pstmt = con.prepareStatement(sql);
          pstmt.setString(1, memberId);

          rs = pstmt.executeQuery();

          if (rs.next()) {
             Member member = new Member();
             member.setMemberId(rs.getString("member_id"));
             member.setMoney(rs.getInt("money"));
             return member;
          } else {
             throw new NoSuchElementException("member not found memberId = " + memberId);
          }
       } catch (SQLException e) {
          log.error("db error", e);
          throw e;
       } finally {
          close(con, pstmt, rs);
       }
    }

    public void update(String memberId, int money) throws SQLException {
       String sql = "update member set money = ? where member_id = ?";

       Connection con = null;
       PreparedStatement pstmt = null;

       try {
          con = getConnection();
          pstmt = con.prepareStatement(sql);
          pstmt.setInt(1, money);
          pstmt.setString(2, memberId);
          pstmt.executeUpdate();
       } catch (SQLException e) {
          log.error("db error", e);
          throw e;
       } finally {
          close(con, pstmt, null);
       }
    }

    public void delete(String memberId) throws SQLException {
       String sql = "delete from member where member_id = ?";

       Connection con = null;
       PreparedStatement pstmt = null;

       try {
          con = getConnection();
          pstmt = con.prepareStatement(sql);
          pstmt.setString(1, memberId);
          pstmt.executeUpdate();
       } catch (SQLException e) {
          log.error("db error", e);
          throw e;
       } finally {
          close(con, pstmt, null);
       }
    }

    private void close(Connection con, Statement pstmt, ResultSet rs) {
       JdbcUtils.closeResultSet(rs);
       JdbcUtils.closeStatement(pstmt);
       // 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
       DataSourceUtils.releaseConnection(con, dataSource);
    }

    private Connection getConnection() throws SQLException {
       // 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
       Connection con = DataSourceUtils.getConnection(dataSource);
       log.info("get connection={}, class={}", con, con.getClass());
       return con;
    }
}

여기서 주목할 부분은 MemberRepository에서 서비스 계층에서 파라미터를 통해 넘어오는 Connection이 없는 것을 확인할 수 있다. 즉, 트랜잭션 동기화 매니저를 통해 동기화를 할 수 있기 때문이다. 트랜잭션 동기화 매니저를 사용하는 코드는 다음과 같다.

  • DataSourceUtils.getConnection()
    • 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환한다.
    • 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 새로운 커넥션을 생성해서 반환한다.
  • DataSourceUtils.releaseConnection()
    • con.close()를 사용해서 직접 닫으면 커넥션이 유지되지 않기 때문에 DataSourceUtils.releaseConnection() 메서드를 사용하여 동기화된 커넥션을 닫지 않고 그대로 유지할 수 있다.
    • 만약 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우에는 해당 커넥션을 그냥 닫아준다.

 

서비스 계층에 트랜잭션 매니저(PlatformTransactionManager) 적용

@Slf4j
@RequiredArgsConstructor
public class MemberService {

    private final PlatformTransactionManager transactionManager;
    private final MemberRepository memberRepository;

    public void accountTransfer(String fromId, String toId, int money) {
       TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

       try {
          // 비즈니스 로직
          bizLogic(fromId, toId, money);
          transactionManager.commit(status); // 성공 시 커밋
       } catch (Exception e) {
          transactionManager.rollback(status); // 실패 시 롤백
          throw new IllegalStateException(e);
       }
    }

    private void bizLogic(String fromId, String toId, int money) throws SQLException {

       Member fromMember = memberRepository.findById(fromId);
       Member toMember = memberRepository.findById(toId);

       memberRepository.update(fromId, fromMember.getMoney() - money);
       validation(toMember);
       memberRepository.update(toId, toMember.getMoney() + money);
    }

    private void validation(Member toMember) {
       if (toMember.getMemberId().equals("ex")) {
          throw new IllegalStateException("이체중 예외 발생");
       }
    }
}
  • private final PlatformTransactionManager transactionManager
    • 트랜잭션 매니저를 주입받는다. JDBC 기술을 사용한다면 DataSourceTransactionManager 구현체를, JPA 같은 기술을 사용한다면 JpaTransactionManager를 주입받으면 된다.
  • transactionManager.getTransaction()
    • 트랜잭션을 시작하고, TransactionStatus status를 반환하는데 현재 트랜잭션의 상태 정보가 포함되어 있어 이후 트랜잭션을 커밋, 롤백할 때 필요하다.
  • new DefaultTransactionDefinition(): 트랜잭션과 관련된 옵션을 지정할 수 있다.
  • transactionManager.commit(status): 트랜잭션이 성공하면 커밋한다.
  • transactionManager.rollback(status): 문제가 발생하면 트랜잭션을 롤백한다. 

 

트랜잭션 매니저의 동작 흐름

 

  1. 서비스 계층에서 transactionManager.getTransaction()을 호출해서 트랜잭션을 시작한다.
  2. 트랜잭션을 시작하려면 먼저 데이터베이스 커넥션이 필요하다. 트랜잭션 매니저는 내부에서 데이터소스를 사용해서 커넥션을 생성한다.
  3. 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.
  4. 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  5. 트랜잭션 동기화 매니저는 스레드 로컬에 커넥션을 보관한다. 따라서 멀티스레드 환경에서 안전하게 커넥션을 보관할 수 있다.
  6. 서비스는 비즈니스 로직을 실행하면서 레포지토리의 메서드들을 호출한다. 이때 커넥션을 파라미터로 전달하지 않는다.
  7. 레포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하다. 레포지토리는 DataSourceUtils.getConnection()을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이 과정을 통해서 자연스럽게 같은 커넥션을 사용하고, 트랜잭션도 유지된다.
  8. 획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달해서 실행한다.

9. 비즈니스 로직이 끝나고 트랜잭션을 종료한다. 트랜잭션은 커밋하거나 롤백하면 종료된다.

10. 트랜잭션을 종료하려면 동기화된 커넥션이 필요하다. 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다.

11. 획득한 컨넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.

12. 전체 리소스를 정리한다.

  • 트랜잭션 동기화 매니저를 정리한다. 스레드 로컬은 사용 후 꼭 정리해야 한다.
  • con.setAutoCommit(true)로 되돌린다. 커넥션 풀을 고려해야 한다.
  • con.close()를 호출해서 커넥션을 종료한다. 커넥션 풀을 사용하는 경우 con.close()를 호출하면 커넥션 풀에 반환된다.

 

이러한 방식을 통한 트랜잭션 추상화 덕분에 서비스 코드는 이제 JDBC 기술에 의존하지 않게 되었다. 따라서 DataSourceTransactionManager에서 JpaTransactionManager로 변경하게 되면 서비스 계층은 그대로 유지하면서 데이터 접근 계층만 변경하면 된다.

하지만 서비스 계층의 메서드들마다 트랜잭션을 사용하면 계속해서 패턴이 반복하게 된다. 이것을 해결하기 위해서는 어떻게 해야할까?

 

트랜잭션 로직 반복 문제 해결 - 트랜잭션 템플릿

트랜잭션 템플릿은 템플릿 콜백 패턴을 적용하여 트랜잭션 로직이 반복되는 문제를 해결해준다. 물론 예외 같은 처리도 해주는데 트랜잭션에 대해서 좀 더 자세히 살펴보자. 먼저, 템플릿 콜백 패턴은 디자인 패턴 중 하나로 개인적으로 이해한 바로는 말 그대로 콜백을 다른 객체(템플릿)에게 전달, 위임하여 해당 객체에서 실행 권한을 결정하는 DI 방식의 한 종류로 이해했다.\

 

TransactionTemplate을 적용하면 다음과 같이 반복되는 트랜잭션 로직이 사라진 것을 확인할 수 있다. 어떻게 가능한지 TransactionManager가 내부적으로 어떻게 구현되어 있는지 살펴보자.

 

위 TransactionTemplate의 execute() 메서드를 보면 트랜잭션을 적용한 로직과 유사하다는 것을 확인할 수 있다. MemberService의 생성자에서 JdbcTemplate 객체를 생성할 때 전달한 transactionManager를 통해서 transaction.getTransaction()을 실행하는 것을 확인할 수 있고, action.doInTransaction() 메서드를 실행한다.

action 객체는 TransactionCallback 함수형 인터페이스의 익명 구현 객체로 execute의 파라미터로 전달된 콜백이다. 그러면 이 콜백을 실행시키는 제어권은 execute가 가지게 되면서 트랜잭션을 시작한 후 비즈니스 로직을 시작하도록 구현되어 있는 것을 확인할 수 있다. 이러한 템플릿 콜백 패턴을 통해 트랜잭션이 반복되는 로직을 해결할 수 있게 되었다.

 

MemberService의 accountTransfer 메서드에서 txTemplate.executeWithResult 메서드를 사용했는데 executeWithResult 메서드는 TransactionOperations 인터페이스에 정의되어 있으며 execute 메서드를 return null로 실행하는 것을 확인할 수 있다.

 

트랜잭션 로직 반복 문제 해결 - 트랜잭션 AOP 이해

트랜잭션 템플릿을 통해 트랜잭션을 반복적으로 처리하는 코드는 해결할 수 있지만 맨 처음 서비스 계층에 비즈니스 로직만 담당한다는 것에는 아직 미치지 못했다. 서비스 계층에 순수한 비즈니스 로직만 남기기 위해서는 스프링이 제공하는 AOP 기능을 사용하면 프록시를 매우 편리하게 적용하여 문제를 해결할 수 있다. 물론 스프링 AOP를 직접 사용해서 트랜잭션을 처리해도 되지만, 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공한다.

위 코드를 보면 스프링이 제공하는 트랜잭션 AOP를 적용하기 위해 @Transactional 어노테이션을 추가했다. 이렇게 트랜잭션 처리가 필요한 곳에 @Transactional 어노테이션만 붙여주면 해당 어노테이션을 인식해서 트랜잭션 프록시를 적용해준다. 이것이 프레임워크를 사용하는 이유이다. 스프링이 제공해주는 @Transactional 어노테이션 기능 덕분에 서비스 계층에는 순수한 비즈니스 로직만이 남게 되었다.

 

AOP를 통해 프록시를 적용한 트랜잭션 로직의 전체 흐름은 위와 같다.

 

 

출처

스프링 DB 1편 - 데이터 접근 핵심 원리