스프링 트랜잭션 이해

2025. 10. 30. 16:36·Spring

스프링 트랜잭션 소개 (복습)

 

트랜잭션 추상화

JDBC와 JPA는 트랜잭션을 사용하는 코드 자체가 다르기 때문에 스프링이 제공하는 트랜잭션추상화를 통해 동일한 방식으로 사용할 수 있다.

 

PlatformTransactionManager

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.transaction;

import org.springframework.lang.Nullable;

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

    void commit(TransactionStatus status) throws TransactionException;

    void rollback(TransactionStatus status) throws TransactionException;
}

 

 

선언적 트랜잭션과 AOP

@Transactional을 사용하면 프록시 방식의 AOP가 적용된다.

(트랜잭션 처리 객체와 비즈니스 로직을 처리하는 서비스를 명확하게 분리하기 위함)

 

 

트랜잭션 적용 확인

@Transactional을 통해 선언적 트랜잭션 방식을 사용하면 간편한 방법으로 트랜잭션을 적용할 수 있다.

그런데 이 기능은 트랜잭션 관련 코드가 눈에 보이지 않고 AOP를 기반으로 동작하기 때문에 실제로 트랜잭션이 적용되고 있는지 아닌지 확인하기 어렵다. 확인하는 방법을 알아보자

 

package hello.springtx.apply;

import jakarta.persistence.Basic;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
@SpringBootTest
public class TxBasicTest {

    @Autowired
    BasicService basicService;

    @Test
    void proxyCheck() {
        log.info("aop class={}", basicService.getClass());
        Assertions.assertThat(AopUtils.isAopProxy(basicService)).isTrue();
    }

    @Test
    void txTest() {
        basicService.tx();
        basicService.nonTx();
    }


    @TestConfiguration
    static class TxApplyBasicConfig {

        @Bean
        BasicService basicService() {
            return new BasicService();
        }
    }

    @Slf4j
    static class BasicService {

        @Transactional
        public void tx() {
            log.info("call tx");
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }

        public void nonTx() {
            log.info("call nonTx");
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }
    }
}

 

proxyCheck() 

- AopUtils.isAopProxy() : @Transactional을 메서드나 클래스에 붙이면 해당 객체는 트랜잭션 AOP 적용 대상이 된다.

클래스레벨이 아닌 메서드에 붙어있어도 스프링 부트가 뜰 때, PlatformTransactionManager, @EnableTransactionManagement 기능이 자동 활성화 되는데, 이 기능은 Bean 등록시 이 클래스에 @Transactional이 있는지 검사함. 있다면 프록시 객체로 등록

 

스프링 컨테이너에는 실제 객체 대신에 프록시 객체가 스프링 컨테이너에 등록되어있는 것이다.

클라이언트인 txBasicTest는 스프링 컨테이너에 BasicService로 의존관계 주입을 요청하는데, 여기에 스프링 컨테이너에 프록시 객체가 등록되어있기 때문에 프록시를 주입 받는다.

프록시 객체는 실제 객체를 상속해서 만들어지기 때문에 다형성을 사용할 수 있다. (주입받을 수 있다)

tx()는 @Transactional, nonTx는 @Transactional이 아니다.

tx() 호출 시 프록시의 tx()가 호출된다. 프록시는 tx()가 트랜잭션을 사용할 수 있는지 확인하고, 트랜잭션을 시작한 다음에 실제(프록시가 아닌) tx()가 호출되는 것이다. 그리고 호출이 끝나면 프록시로 리턴 -> 트랜잭션 로직 종료 (커밋,롤백)

 

nonTx()는 같은 프록시 객체에서 호출된다. 그런데 트랜잭션을 사용할 수 없다고 판단하면 실제 객체의 nonTx()를 호출하고 종료한다.

 

TransactionSynchronizationManager.isActualTransactionActicve()

- 현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능

 

트랜잭션 적용 위치

@SpringBootTest
public class TxLevelTest {

    @Autowired
    LevelService service;

    @Test
    void orderTest() {
        service.write();
        service.read();
    }

    @TestConfiguration
    static class TxLevelTestConfig {
        @Bean
        LevelService levelService() {
            return new LevelService();
        }
    }

    @Slf4j
    @Transactional(readOnly = true)
    static class LevelService {

        @Transactional(readOnly = false)
        public void write() {
            log.info("call write");
            printTxInfo();
        }

        public void read() {
            log.info("call read");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
            boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
            log.info("tx readOnly={}", readOnly);
        }
    }
}

 

별 내용 아니니 간단 요약

우선순위는 구체적이고 자세한것이 높은 우선순위를 가진다.

write()가 메서드 레벨에 붙어있기 때문에 readOnly = false로 우선순위를 가진다.

 

트랜잭션 AOP 주의사항 - 프록시 내부 호출1

트랜잭션 AOP는 기본적으로 프록시 방식의 AOP를 사용한다.

앞서 배운 것 처럼 @Transactional을 적용하면 프록시 객체가 요청을 먼저 받아서 트랜잭션을 처리하고, 실제 객체를 호출한다.

따라서 트랜잭션을 적용하려면 프록시를 통해 대상 객체 (Target)을 호출해야 한다.

이렇게 해야 프록시에서 먼저 트랜잭션을 적용하고, 이후에 대상 객체를 호출하게 된다.

만약 프록시를 거치지 않고 타겟에 직접 호출하면 AOP가 적용되지않고, 트랜잭션도 적용되지 않는다.

 

package hello.springtx.apply;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.weaver.ast.Call;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
@SpringBootTest
public class InternalCallV1Test {

    @Autowired
    CallService callService;

    @Test
    void printProxy() {
        log.info("callService class={}", callService.getClass());
    }

    @Test
    void internalCall() {
        callService.internal();
    }

    @Test
    void externalCall() {
        callService.external();
    }

    @TestConfiguration
    static class InternalCallV1TestConfig {

        @Bean
        CallService callService() {
            return new CallService();
        }
    }

    @Slf4j
    static class CallService {
        public void external() {
            log.info("call external");
            printTxInfo();
            internal();
        }

        @Transactional
        public void internal() {
            log.info("call internal");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
            boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
            log.info("tx readOnly={}", readOnly);
        }
    }
}

 

callService.external()을 호출하면, 여기서 callService는 트랜잭션 프록시이다.

external()엔 @Transactional이 없기 때문에 트랜잭션 프록시는 트랜잭션을 적용하지 않고 실제 객체 (target) 인스턴스의 external()을 호출한다. external()은 내부에서 internal()을 호출한다. 여기서 문제가 발생한다.

 

internal()메서드엔 @Transactional이 있기 때문에 트랜잭션이 작동할거라는 착각을 하게 된다.

하지만 트랜잭션이 작동되지 않는다. 왜냐면 실제 객체 내부에서 internal()을 호출하게 됐으니까

(메서드 앞에 별도의 참조가 없으면 this라는 뜻으로 자기 자신의 인스턴스를 가리킨다. 결과적으로 this.internal()이 되는 것)

 

트랜잭션 AOP 주의사항 - 프록시 내부 호출2

메서드 내부 호출 때문에 트랜잭션 프록시가 적용되지 않는 문제를 해결하기 위해 internal()메서드를 별도의 클래스로 분리하자

package hello.springtx.apply;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
@SpringBootTest
public class InternalCallV2Test {

    @Autowired
    CallService callService;

    @Test
    void printProxy() {
        log.info("callService class={}", callService.getClass());
    }

    @Test
    void externalCallV2() {
        callService.external();
    }

    @TestConfiguration
    static class InternalCallV1TestConfig {

        @Bean
        CallService callService() {
            return new CallService(internalService());
        }

        @Bean
        InternalService internalService() {
            return new InternalService();
        }
    }

    @Slf4j
    @RequiredArgsConstructor
    static class CallService {

        private final InternalService internalService;

        public void external() {
            log.info("call external");
            printTxInfo();
            internalService.internal();
        }



        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }
    }

    static class InternalService {
        @Transactional
        public void internal() {
            log.info("call internal");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }
    }
}

1. 클라이언트 테스트 코드는 callService.external() 호출

2. callService는 실제 객체 인스턴스이다.

3. callService는 주입받은 internalService.internal()을 호출

4. internalService는 트랜잭션 프록시이다.

5. 트랜잭션 적용 후 실제 internalService 객체 인스턴스의 internal() 호출

 

여러 해결 방안이 있지만 이렇게 별도의 클래스로 분리하는 방법을 주로 사용한다.

 

public 메서드만 트랜잭션 적용

스프링 트랜잭션 AOP기능은 public 메서드에만 트랜잭션을 적용하도록 기본 설정되어있다.

클래스 레벨에 트랜잭션을 적용하면 모든 메서드에 트랜잭션이 걸릴 수 있다. 그러면 트랜잭션을 의도하지 않는 곳 까지 트랜잭션이 과도하게 적용될 수 있다.

트랜잭션은 주로 비즈니스 로직의 시작점에 걸기 때문에 외부에 열어준 곳을 시작점으로 사용한다. 이런 이유로 public 메서드에만 트랜잭션을 적용하도록 설정되어있다.

(생각해보면 private이나 protected에 트랜잭션을 걸 이유가 잘 없을 것임)

 

트랜잭션 AOP 주의사항 - 초기화 시점

스프링 초기화 시점에는 트랜잭션 AOP가 적용되지 않을 수 있다.

package hello.springtx.apply;

import jakarta.annotation.PostConstruct;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.EventListener;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@SpringBootTest
public class InitTest {

    @Autowired
    Hello hello;

    @Test
    void go() {

    }

    @TestConfiguration
    static class InitTextConfig {
        @Bean
        Hello hello() {
            return new Hello();
        }
    }

    private static final Logger log = LoggerFactory.getLogger(InitTest.class);

    static class Hello {
        @PostConstruct
        @Transactional
        public void initV1() {
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("hello init @PostConstruct tx active={}", isActive);
        }

        @EventListener(ApplicationReadyEvent.class) //
        @Transactional
        public void initV2() {
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("hello init @ApplicationReady tx active={}", isActive);

        }
    }
}

 

초기화 코드 (@PostConstruct)와 @Transactional을 함께 사용하면 트랜잭션이 적용되지 않는다.

왜냐하면 초기화 코드가 먼저 호출되고, 그다음에 트랜잭션 AOP가 적용되기 때문이다.

초기화 시점에는 해당 메서드에서 트랜잭션을 획득할 수 없다.

 

가장 확실한 대안은 ApplicationReadyEvvent 이벤트를 사용하는 것이다.

@EventListener를 사용하면, 트랜잭션 AOP를 포함한 스프링 컨테이너가 완전히 생성되고 나나 다음에 이벤트가 붙은 메서드를 호출해준다. 따라서 init2()는 트랜잭션이 적용된다.

 

예외와 트랜잭션 커밋, 롤백 - 기본

예외가 발생했는데, 내부에서 예외를 처리하지 못하고 트랜잭션 범위 밖으로 예외를 던지면?

언체크 예외 : 트랜잭션 롤백

체크 예외 : 커밋

정상응답 (리턴)하면 트랜잭션 커밋

package hello.springtx.exception;

import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.*;

@SpringBootTest
public class RollbackTest {

    @Autowired
    RollbackService rollbackService;

    @Test
    void runtimeException() {
        assertThatThrownBy(() -> rollbackService.runtimeException())
                .isInstanceOf(RuntimeException.class);
    }

    @Test
    void checkedException() {
        assertThatThrownBy(() -> rollbackService.checkedException())
                .isInstanceOf(MyException.class);
    }

    @Test
    void rollbackFor() {
        assertThatThrownBy(() -> rollbackService.rollbackFor())
                .isInstanceOf(MyException.class);
    }

    @TestConfiguration
    static class RollbackTestConfig {

        @Bean
        RollbackService rollbackService() {
            return new RollbackService();
        }
    }
    @Slf4j
    static class RollbackService {

        //런타임 예외 발생: 롤백
        @Transactional
        public void runtimeException() {
            log.info("call runtimeException");
            throw new RuntimeException();
        }

        //체크 예외 발생: 커밋
        @Transactional
        public void checkedException() throws MyException {
            log.info("call checkedException");
            throw new MyException();
        }

        //체크 예외 rollbackFor 지정: 롤백
        @Transactional(rollbackFor = MyException.class)
        public void rollbackFor() throws MyException {
            log.info("call rollback");
            throw new MyException();
        }
    }

    static class MyException extends Exception {

    }
}

 

rollbackFor()

체크예외가 발생해도 커밋 대신 롤백을 할 수 있게 설정할 수 있다.

 

예외와 트랜잭션 커밋, 롤백 활용

스프링은 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임 예외는 복구 불가능한 예외로 가정한다.

- 체크 예외 : 비즈니스 의미가 있을 때

- 언체크 : 복구 불가능한 예외

참고로 이 정책을 따를 필요는 없다.

 

상품 주문 상황으로 예를 들어보자

1. 정상 : 주문시 결제를 성공하면 주문 데이터를 저장하고 결제 상태 완료처리

2. 시스템 예외 : 주문시 내부에 복구 불가능한 예외 발생시 전체 데이터 롤백

3. 비즈니스 예외 : 주문시 결제 잔고 부족 등으로 인한 예외 발생 시 주문 데이터를 저장하고, 결제 상태를 대기로 처리

- 이 경우 고객에게 잔고 부족 등 별도의 방법으로 안내

 

package hello.springtx.order;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@RequiredArgsConstructor
@Service
public class OrderService {

    private final OrderRepository orderRepository;

    @Transactional
    public void order(Order order) throws NotEnoughMoneyException {
        log.info("order 호출");

        orderRepository.save(order);

        log.info("결제 프로세스 진입");

        if (order.getUserName().equals("예외")) {
            log.info("시스템 예외 발생");
            throw new RuntimeException("시스템 예외");

        } else if (order.getUserName().equals("잔고부족")) {
            log.info("잔고 부족 비즈니스 예외 발생");
            order.setPayStatus("대기");
            throw new NotEnoughMoneyException("잔고가 부족합니다.");

        } else {
            // 정상 승인
            log.info("정상 승인");
            order.setPayStatus("완료");
        }
        log.info("결제 프로세스 완료");
    }
}

 

대략적인 흐름은 이렇게 됨

비즈니스 예외시 payStatus를 대기 상태로 처리하고 직접 만든 예외 NotEnoughMoneyException 발생.

order데이터는 커밋되도록 작성한 코드

 

정리

NotEnoughMoneyException은 시스템에 문제가 발생한 것이 아니라 비즈니스 문제 상황을 예외를 통해 알려준다.

마치 예외가 리턴값 처럼 사용된다. 이 경우에는 커밋하는것이 맞다. 롤백하면 Order 데이터 자체가 사라진다.

그런데 상황에 따라 트랜잭션을 커밋하지않고 rollbackFor로 롤백시켜버릴수도 있다.

'Spring' 카테고리의 다른 글

스프링 트랜잭션 전파1  (0) 2025.11.02
데이터 접근 기술 - MyBatis  (0) 2025.10.21
데이터 접근 기술 - 테스트  (0) 2025.10.20
데이터 접근 기술 - 스프링 JdbcTemplate  (0) 2025.10.18
스프링과 문제 해결 - 예외처리, 반복  (0) 2025.10.17
'Spring' 카테고리의 다른 글
  • 스프링 트랜잭션 전파1
  • 데이터 접근 기술 - MyBatis
  • 데이터 접근 기술 - 테스트
  • 데이터 접근 기술 - 스프링 JdbcTemplate
공부처음하는사람
공부처음하는사람
  • 공부처음하는사람
    lazzzykim
    공부처음하는사람
  • 전체
    오늘
    어제
    • 분류 전체보기 (159)
      • Kotlin (31)
      • Java (56)
      • Spring (44)
      • JPA (6)
      • Algorithm (3)
      • TroubleShooting (1)
      • 내일배움캠프 프로젝트 (14)
      • Setting (2)
      • ... (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 글쓰기
  • 링크

  • 인기 글

  • 태그

    kotlin
    김영한의 실전자바
    jpa
    java
    중첩클래스
    spring
    언체크예외
    김영한
    다형성
    트랜잭션
    싱글톤
    Di
    캡슐화
    빈 생명주기
    내일배움캠프
    김영한의 실전 자바
    OCP
    배열
    제네릭
    예외처리
  • hELLO· Designed By정상우.v4.10.3
공부처음하는사람
스프링 트랜잭션 이해
상단으로

티스토리툴바