본문 바로가기

Spring DB

[Spring DB] 스프링의 트랜잭션 추상화 – 트랜잭션 매니저, 동기화 매니저, 트랜잭션 템플릿, @Transactional

🧩 순수한 서비스 계층의 중요성과 문제점

 

애플리케이션을 설계할 때 일반적으로는 역할에 따라 세 가지 계층으로 나눈다.

  • 프레젠테이션 계층 (Controller)
    • 역할: UI 처리, 웹 요청 검증 및 응답
    • 사용 기술: 서블릿, 스프링 MVC
  • 서비스 계층 (Service)
    •  역할: 비즈니스 로직 처리
    • 사용 기술: 특정 기술에 의존하지 않은 순수 자바 코드
  • 데이터 접근 계층 (Repository)
    • 역할: 데이터베이스 접근
    • 사용 기술: JDBC, JPA, File, Redis

 

⚠️ 이 구조에서 가장 중요한 것은 서비스 계층의 순수성을 유지하는 것이다.

즉, 서비스 계층은 특정 기술(예: JDBC, JPA)에 종속되지 않고 순수 자바 코드로 작성되어,

데이터 저장 기술이 바뀌거나 UI가 변경되어도 비즈니스 로직은 영향을 받지 않아야 한다.

 

 

🚨 하지만 이전의 JDBC 트랜잭션 처리 방식(MemberServiceV2)은 서비스 계층의 순수성을 해치는 다음과 같은 문제들을 가지고 있다.

  • 기술의 누수:
    • 트랜잭션을 처리하기 위해 java.sql.Connection, SQLException  JDBC 전용 기술이 서비스 계층 코드에 직접 노출되었다.
    • 만약 데이터 기술을 JDBC에서 JPA로 변경한다면, 서비스 계층의 코드도 전부 수정해야 했다.
  • 커넥션 동기화의 어려움:
    • 하나의 커넥션을 유지하기 위해 서비스 계층에서 생성한 Connection 객체를 리포지토리 메서드에 파라미터로 계속 넘겼다.
    • 이는 코드를 지저분하게 만들고, 트랜잭션용 메서드와 아닌 메서드를 따로 만들어야 하는 불편함을 초래했다.
  • 반복적인 코드:
    • 트랜잭션을 시작하고, 커밋 또는 롤백하는 try-catch-finally 구조가 모든 비즈니스 로직마다 반복적으로 등장했다.

 


✅ 스프링의 해결책1: 트랜잭션 추상화 (PlatformTransactionManager)

 

스프링은 트랜잭션 기술 자체를 추상화한, PlatformTransactionManager라는 인터페이스를 제공한다.

public interface PlatformTransactionManager extends TransactionManager {

    // 트랜잭션 시작 (전파 동작에 따라 현재 활성화된 트랜잭션 또는 새로운 트랜잭션 반환)
    TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;

    // 트랜잭션 커밋
    void commit(TransactionStatus status) throws TransactionException;

    // 트랜잭션 롤백
    void rollback(TransactionStatus status) throws TransactionException;
}

 

이를 통해 트랜잭션 기술(JDBC, JPA 등)과 서비스 계층 코드를 분리할 수 있다.

서비스 계층 (비즈니스 로직)  →  PlatformTransactionManager (트랜잭션 기술)
                              /                \
                JdbcTransactionManager    JpaTransactionManager
                       (JDBC)                     (JPA)
  • 이제 서비스 계층은 PlatformTransactionManager라는 추상화된 인터페이스에만 의존한다.
  • 데이터 접근 기술(예: JDBC, JPA)이 바뀌어도, 서비스 코드는 전혀 변경할 필요가 없다.

 


✅ 스프링의 해결책2: 트랜잭션 동기화 (TransactionSynchronizationManager)

 

커넥션을 파라미터로 계속 넘겨야 했던 문제는 트랜잭션 동기화 매니저를 통해 해결한다.

비즈니스 로직  →  트랜잭션 매니저      { 트랜잭션 동기화 매니저 }        리포지토리
                   ↓                                           ↓
               (트랜잭션 시작)  →  ThreadLocal에 커넥션 보관  →  (커넥션 사용)
                   ↓
               (트랜잭션 종료)  →  보관된 커넥션 해제, 닫기
  1. 트랜잭션 매니저가 트랜잭션을 시작하면, 사용될 커넥션을 트랜잭션 동기화 매니저 내부의 ThreadLocal에 보관한다.
  2. 이후 리포지토리에서는 DataSourceUtils.getConnection()을 통해 ThreadLocal에 보관된 커넥션을 꺼내 사용한다.
  3. 트랜잭션이 종료되면, 트랜잭션 동기화 매니저가 보관했던 커넥션을 해제하고 닫는다.
ThreadLocal은 각각의 쓰레드마다 별도의 저장 공간을 제공하므로 멀티 쓰레드 환경에서도 안전하다.

 

 

이 메커니즘 덕분에 더 이상 커넥션을 파라미터로 지저분하게 전달할 필요가 없어졌다.

 


📜 PlatformTransactionManager와 TransactionSynchronizationManager를 적용한 예시 코드

@RequiredArgsConstructor
public class MemberRepositoryV3 {

    private final DataSource dataSource;

    // 등록
    public Member save(Member member) throws SQLException { ... }

    // 조회
    public Member findById(String memberId) throws SQLException { ... }

    // 수정
    public void update(String memberId, int money) throws SQLException { ... }
    
    // 삭제
    public void delete(String memberId) throws SQLException { ... }

    // 커넥션 획득
    private Connection getConnection() throws SQLException {
        // 트랜잭션 동기화 매니저가 ThreadLocal에 보관한 커넥션을 가져옴 (없으면 새로 생성)
        return DataSourceUtils.getConnection(dataSource);
    }

    // 역순으로 리소스 정리
    private void close(Connection connection, Statement statement, ResultSet resultSet) {
        JdbcUtils.closeResultSet(resultSet);
        JdbcUtils.closeStatement(statement);
//        JdbcUtils.closeConnection(connection); // 커넥션을 유지해야 하므로, 닫으면 안 됨
        DataSourceUtils.releaseConnection(connection, dataSource);
    }
}

 

⚠️ 트랜잭션 동기화 매니저를 사용하려면 스프링의 DataSourceUtils를 사용해야 한다.

  • DataSourceUtils.getConnection():
    • 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 → 해당 커넥션을 반환한다.
    • 트랜잭션 동기화 매니저가 관리하는 커넥션이 없으면 → 새로운 커넥션을 생성해서 반환한다.
  • DataSourceUtils.releaseConnection():
    • 트랜잭션을 사용하기 위해 동기화된 커넥션이면 → 닫지 않고 그대로 유지해 준다.
    • 트랜잭션 동기화 매니저가 관리하는 커넥션이 아니면 → 해당 커넥션을 바로 닫는다.
    • ⚠️ 커넥션을 직접 닫을 경우(JdbcUtils.closeConnection()), 커넥션이 유지되지 않는 문제가 발생한다.
    • 따라서 커넥션은 트랜잭션 종료(커밋, 롤백) 시점까지 유지되어야 한다.
@RequiredArgsConstructor
public class MemberServiceV3_1 {

    private final PlatformTransactionManager transactionManager;
    private final MemberRepositoryV3 memberRepository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        // 1. 트랜잭션 시작 (트랜잭션 매니저는 내부적으로 DataSource를 통해 커넥션을 생성함)
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            // 2. 비즈니스 로직 수행
            doLogic(fromId, toId, money);

            // 3.1. 성공 시 커밋
            transactionManager.commit(status);
        } catch (Exception e) {
            // 3.2. 실패 시 롤백
            transactionManager.rollback(status);
            throw new IllegalStateException(e);
        }
        // 트랜잭션 매니저가 알아서 커넥션을 종료해줌
    }

    private void doLogic(String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(fromId);
        Member toMember = memberRepository.findById(toId);

        memberRepository.update(fromId, fromMember.getMoney() - money);
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체 중 예외 발생");
        }
        memberRepository.update(toId, toMember.getMoney() + money);
    }
}
  • transactionManager.getTransaction(new DefaultTransactionDefinition()):
    • 트랜잭션을 시작하고, TransactionStatus를 반환한다.
    • TransactionStatus에는 현재 트랜잭션의 상태 정보가 포함되어 있다. (이후 트랜잭션을 커밋, 롤백할 때 필요하다.)
    • TransactionDefinition 파라미터를 통해 트랜잭션과 관련된 옵션을 지정할 수 있다.
  • transactionManager.commit(status): 트랜잭션을 커밋한다.
  • transactionManager.rollback(status): 트랜잭션을 롤백한다.

 


🗺️ 전체 과정 요약

// 1. 트랜잭션 시작

  서비스 계층 코드  →  { 트랜잭션 매니저 }
                          ↓
            트랜잭션 시작 (tm.getTransaction())
             (DataSource를 통해 커넥션 획득)
              (커넥션을 수동 커밋 모드로 전환)
                          ↓
                 { 트랜잭션 동기화 매니저 }
                          ↓
                ThreadLocal에 커넥션 보관


// 2. 비즈니스 로직 실행

  서비스 계층 코드  →  비즈니스 로직  →  { 리포지토리 getConnection() }
                                      ↓              ↑
                              동기화된 커넥션 조회     ThreadLocal에 
                DataSourceUtils.getConnection()   보관된 커넥션 반환
                                      ↓              ↑ 
                                  {  트랜잭션 동기화 매니저  }

           
// 3. 트랜잭션 종료 (커밋 / 롤백)

  서비스 계층 코드  →  { 트랜잭션 매니저 }
                          ↓
         커밋 (tm.commit()) 또는 롤백 (tm.rollback())
          (동기화된 커넥션 획득, 커밋 또는 롤백 수행)
        (ThreadLocal 정리, 자동 커밋 활성화, 커넥션 종료)
                          ↓
                 { 트랜잭션 동기화 매니저 }

 


✅ 스프링의 해결책3: 트랜잭션 템플릿 (TransactionTemplate)

 

서비스 계층에서 트랜잭션을 위한 반복적인 try-catch 코드를 해결하기 위해, 스프링은 TransactionTemplate 클래스를 제공한다.

public class TransactionTemplate extends DefaultTransactionDefinition
        implements TransactionOperations, InitializingBean {

    private PlatformTransactionManager transactionManager;

    // 응답 값이 있을 때 사용
    public <T> T execute(TransactionCallback<T> action) throws TransactionException { ... }

    // 응답 값이 없을 때 사용
    void executeWithoutResult(Consumer<TransactionStatus> action)
            throws TransactionException {
    ...
}

 

이는 템플릿 콜백 패턴을 활용한 것으로, 개발자는 반복적인 트랜잭션 처리(시작, 커밋, 롤백)는 템플릿에 맡기고,

순수한 비즈니스 로직콜백 함수로 제공하면 된다.

public class MemberServiceV3_2 {

    private final TransactionTemplate txTemplate;
    private final MemberRepositoryV3 memberRepository;

    public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
        this.txTemplate = new TransactionTemplate(transactionManager); // transactionManager 주입
        this.memberRepository = memberRepository;
    }

    public void accountTransfer(String fromId, String toId, int money) {
        // 트랜잭션 템플릿 안에서 트랜잭션을 시작함
        txTemplate.executeWithoutResult(transactionStatus -> {
            // 비즈니스 로직 수행 (람다는 체크 예외(SQLException) 발생 시 밖으로 던질 수 없으므로 try-catch 필요)
            try {
                doLogic(fromId, toId, money);
            } catch (SQLException e) {
                throw new IllegalStateException(e); // 언체크 예외(RuntimeException)로 변환하여 던지기
            }
        }); // 언체크(런타임) 예외 발생 시 롤백, 그 외에는 커밋 (체크 예외 포함)
    }

    // throws SQLException을 제외하면 순수한 자바 코드만 남은 비즈니스 로직
    private void doLogic(String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(fromId);
        Member toMember = memberRepository.findById(toId);
        
        memberRepository.update(fromId, fromMember.getMoney() - money);
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체 중 예외 발생");
        }
        memberRepository.update(toId, toMember.getMoney() + money);
    }
}​
  • 트랜잭션 템플릿은 생성자의 파라미터로 TransactionManager가 필요하다.
  • 트랜잭션 템플릿의 기본 동작은 다음과 같다.
    • 언체크 예외(RuntimeException 등) 발생 → 롤백 ❌
    • 정상 수행되거나 체크 예외(SQLException 등) 발생 → 커밋 ✅

 


✅ 스프링의 해결책4: 선언적 트랜잭션 관리 (AOP + @Transactional)

 

가장 궁극적인 해결책은 스프링 AOP프록시 패턴 활용하는 것이다.

 

@Transactional 애노테이션을 메서드나 클래스에 붙이면, 스프링은 해당 객체에 대한 프록시(Proxy)를 생성한다.

이 프록시는 비즈니스 로직을 담은 원본 객체를 대신하여 호출을 받는다.

 클라이언트   → \ -   서비스 메서드
      (가로챔) ↓        ↑
         { 프록시 } → 원본 객체의 메서드 호출
             ↓
         트랜잭션 시작
             ↓
      { 트랜잭션 매니저 }
  1. 클라이언트가 서비스 메서드를 호출하면, 먼저 트랜잭션 프록시가 호출을 가로챈다.
  2. 프록시는 스프링 컨테이너를 통해 트랜잭션 매니저를 획득하여 트랜잭션을 시작한다.
  3. 트랜잭션이 시작되면 프록시는 실제 비즈니스 로직을 담은 원본 객체의 메서드를 호출한다.
  4. 비즈니스 로직이 성공적으로 끝나면 프록시가 트랜잭션을 커밋한다.
  5. 예외가 발생하면 프록시가 트랜잭션을 롤백한다.

 

이 방식을 "선언적 트랜잭션 관리"라고 부르며, 기존의 트랜잭션 매니저나 트랜젝션 템플릿을 사용해 트랜잭션 관련 코드를 직접 작성했던  "프로그래밍 방식의 트랜잭션 관리"에 비해 훨씬 간편하고 실용적이기 때문에 실무에서는 대부분 이를 사용한다.

@RequiredArgsConstructor
public class MemberServiceV3_3 {

    private final MemberRepositoryV3 memberRepository;

    @Transactional // 클래스에 선언 시 모든 public 메서드에 다 적용됨
    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        doLogic(fromId, toId, money);
    }

    private void doLogic(String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(fromId);
        Member toMember = memberRepository.findById(toId);

        memberRepository.update(fromId, fromMember.getMoney() - money);
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체 중 예외 발생");
        }
        memberRepository.update(toId, toMember.getMoney() + money);
    }
}
  • 이제 트랜잭션 관련 코드는 @Transactional 애노테이션밖에 없다.
  • @Transactional을 클래스 레벨에 선언할 경우, 외부에서 호출 가능한 모든 public 메서드에 적용된다.

 

테스트 코드 예시:

@SpringBootTest // @Transactional 사용하려면 스프링 컨테이너가 필요함
class MemberServiceV3_3Test {

    @Autowired
    private MemberServiceV3_3 memberService;

    @Autowired
    private MemberRepositoryV3 memberRepository;

    @AfterEach
    void tearDown() throws SQLException {
        memberRepository.delete("memberA");
        memberRepository.delete("memberB");
        memberRepository.delete("ex");
    }

    @Test
    void 정상_이체() throws SQLException {
        Member memberA = new Member("memberA", 10000);
        Member memberB = new Member("memberB", 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberB);

        memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 5000);

        Member foundMemberA = memberRepository.findById(memberA.getMemberId());
        Member foundMemberB = memberRepository.findById(memberB.getMemberId());
        assertThat(foundMemberA.getMoney()).isEqualTo(5000);
        assertThat(foundMemberB.getMoney()).isEqualTo(15000);
    }

    @Test
    void 이체_중_예외_발생() throws SQLException {
        Member memberA = new Member("memberA", 10000);
        Member memberEx = new Member("ex", 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        assertThatThrownBy(() ->
                memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 5000)
        ).isInstanceOf(IllegalStateException.class);

        Member foundMemberA = memberRepository.findById(memberA.getMemberId());
        Member foundMemberB = memberRepository.findById(memberEx.getMemberId());
        assertThat(foundMemberA.getMoney()).isEqualTo(10000); // 롤백되어야 함
        assertThat(foundMemberB.getMoney()).isEqualTo(10000);
    }

    @TestConfiguration
    static class TestConfig {

        @Bean
        DataSource dataSource() {
            return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        }

        @Bean
        PlatformTransactionManager transactionManager() {
            return new DataSourceTransactionManager(dataSource());
        }

        @Bean
        MemberRepositoryV3 memberRepositoryV3() {
            return new MemberRepositoryV3(dataSource());
        }

        @Bean
        MemberServiceV3_3 memberServiceV3_3() {
            return new MemberServiceV3_3(memberRepositoryV3());
        }
    }
}
  • @TestConfiguration: 테스트 클래스 안에 선언 시 스프링 부트가 자동 생성하는 빈 외에 추가적으로 필요한 빈을 등록할 수 있다.
  • transactionManager(): 스프링의 트랜잭션 AOP는 스프링 빈으로 등록된 트랜잭션 매니저를 찾아서 사용한다.

 

👉 이제는 @Transactional이라는 선언 하나만으로 트랜잭션 관련 코드를 비즈니스 로직에서 완벽하게 분리할 수 있다.

이로써 서비스 계층에는 순수한 비즈니스 로직만 남게 되어 유지보수성과 테스트 용이성이 극대화된다.

 


🧩 스프링 부트의 자동 리소스 등록

 

과거에는 DataSourcePlatformTransactionManager를 개발자가 직접 XML 파일이나 자바 설정 파일(@Configuration) 등을 통해 스프링 으로 등록해야 했다. 하지만 스프링 부트는 이 과정을 모두 자동화했다.

👉 개발자는 단지 application.properties 파일에 데이터베이스 연결 정보만 입력하면 된다!

# application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=

 

스프링 부트는 위 속성을 이용하여 다음과 같이 동작한다.

  • 기본적으로 HikariCP 기반의 커넥션 풀을 제공하는 DataSource인, HikariDataSource를 생성하여 스프링 빈으로 등록한다.
  • 또한 현재 라이브러리에 맞는 PlatformTransactionManager를 스프링 빈으로 등록해 준다.

 

즉, 위 속성만 입력하면 스프링 부트가 자동으로 DataSource, 트랜잭션 매니저를 스프링 빈으로 등록해 준다.

따라서 개발자는 의존 관계 주입을 통해 편리하게 해당 빈을 사용하면 된다.