스프링 AOP와 트랜잭션

2025. 2. 12. 19:25· 백엔드/SpringBoot
목차
  1. 🪽 AOP (Aspect Oriented Programming)
  2. 🪽 @Transactoinal의 동작

해당 글은 지난 글에 이어서 작성하는 글이며, 김영한님의 '스프링 DB 1편 - 데이터 접근 핵심 원리' 강의를 참고했습니다.

 

<이전글>

 

멀티스레드 환경에서의 트랜잭션 동작 - feat. 테스트 코드

최근에 한 프로젝트에서 테스트 코드를 짜다가 트러블 슈팅 과정에서 새롭게 안 사실이 있어서 블로그를 작성하게 되었다. "동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성

judyalwayswantscarrot.tistory.com

 

 

 

지난번 글에서 "멀티스레드 환경에서의 트랜잭션 동작"을 살펴보았다! 

 

아래와 같이 테스트 코드를 짰을때, 메인 스레드인 테스트가 시작되는 메서드와 내부에서 생성된 3개의 스레드들에서 트랜잭션이 제대로 동작하고 있는지 확인보았다.

 

결과적으로는 메인 스레드인 테스트 메서드에서는 트랜잭션이 작동했고, 내부 스레드에서는 트랜잭션이 작동하고 있지 않았다. 

@SpringBootTest
class MemberRepositoryTest {

    @Autowired
    private MemberRepository memberRepository;

    @Test
    @DisplayName("동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성된다")
    @Transactional
    void joinMemberWithConcurrent() throws InterruptedException {
        //given
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        CountDownLatch latch = new CountDownLatch(3);
        List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<>());

        //when
        for (int i = 0; i < 3; i++) {
            executorService.execute(() -> {
                try {
                    Member newMember = Member.create(  // ★ 새로운 객체 생성
                            "yoon",
                            "sss@naver.com",
                            true,
                            "자기소개",
                            "image",
                            null
                    );
                    memberRepository.save(newMember);
                } catch (DataIntegrityViolationException e) {
                    exceptions.add(e);
                }
                latch.countDown();
            });
        }

        latch.await();

        //then
        List<Member> savedMember = memberRepository.findAllByEmail("sss@naver.com");
    }

}

 

 

 

어 그러면 아래와 같이 코드를 수정하면 내부의 스레드에서도 트랜잭션이 잘 동작할까? 라는 궁금증이 생겼다.

(saveMember()라는 메서드를 분리하고 @Transactional 어노테이션을 달았다.)

@SpringBootTest
class MemberRepositoryTest {

    @Autowired
    private MemberRepository memberRepository;

    @Test
    @DisplayName("동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성된다")
    @Transactional
    void joinMemberWithConcurrent() throws InterruptedException {
        //given
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        CountDownLatch latch = new CountDownLatch(3);
        List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<>());

        System.out.println("🟢 Current Thread: " + Thread.currentThread().getName());
        System.out.println("🟢 Transaction Name: " + TransactionSynchronizationManager.getCurrentTransactionName());
        System.out.println("🟢 Is Actual Transaction Active? " + TransactionSynchronizationManager.isActualTransactionActive());


        //when
        for (int i = 0; i < 3; i++) {
            executorService.execute(() -> {
                try {
                    Member newMember = Member.create(
                            "yoon",
                            "sss@naver.com",
                            true,
                            "자기소개",
                            "image",
                            null
                    );
                    saveMember(newMember);
                    Thread.sleep(1000);
                } catch (DataIntegrityViolationException e) {
                    exceptions.add(e);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                latch.countDown();
            });
        }

        latch.await();

        //then
        List<Member> savedMember = memberRepository.findAllByEmail(member.getEmail());
        assertAll(
                () -> assertThat(exceptions).hasSize(2),
                () -> assertThat(savedMember).hasSize(1)
        );    
    }

    @Transactional
    void saveMember(Member newMember) {
        System.out.println("🟢 Current Thread: " + Thread.currentThread().getName());
        System.out.println("🟢 Transaction Name: " + TransactionSynchronizationManager.getCurrentTransactionName());
        System.out.println("🟢 Is Actual Transaction Active? " + TransactionSynchronizationManager.isActualTransactionActive());
        memberRepository.save(newMember);
    }

}

 

 

결과적으로는, 이렇게 바꿔도 내부 스레드에서는 트랜잭션이 활성화되지 않았다.

🟢 Current Thread: main
🟢 Transaction Name: com.favoriteplace.app.repository.MemberRepositoryTest.joinMemberWithConcurrent
🟢 Is Actual Transaction Active? true
🟢 Current Thread: pool-4-thread-1
🟢 Transaction Name: null
🟢 Is Actual Transaction Active? false
🟢 Current Thread: pool-4-thread-2
🟢 Transaction Name: null
🟢 Is Actual Transaction Active? false
🟢 Current Thread: pool-4-thread-3
🟢 Transaction Name: null
🟢 Is Actual Transaction Active? false

 

 

이유가 무엇일까?! 답은 스프링 트랜잭션 동작 원리에서 찾을 수 있었다.

 

Spring은 @Transactional을 AOP(프록시) 방식으로 관리하고 있다. 

 

 

먼저 AOP란 무엇일까?

🪽 AOP (Aspect Oriented Programming)

흩어진 관심사를 별도의 클래스로 모듈화하여, OOP를 더욱 잘 지킬 수 있도록 도와주는 것
(여기서 흩어진 관심사란, 로깅, 트랜잭션 등 여러 클래스에서 공통적으로 사용될 수 있는 부가적인 기능을 의미한다.)

 

 

트랜잭션 프록시 코드 예시를 살펴보면서 이해해 보자.

  • 프록시 도입 전
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new
DefaultTransactionDefinition());
try {
    //비즈니스 로직
    bizLogic(fromId, toId, money);
    transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
    transactionManager.rollback(status); //실패시 롤백
    throw new IllegalStateException(e);
}

 

 

  • 프록시 도입 후
public class TransactionProxy {
    private MemberService target;

    public void logic() {
        // 트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction(..);
        try {
            // 실제 대상 호출
            target.logic();
            transactionManager.commit(status); // 성공 시 커밋
        } catch (Exception e) {
            transactionManager.rollback(status); // 실패 시 롤백
            throw new IllegalStateException(e);
        }
    }
}

 

public class Service {
    public void logic() {
        //트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
        bizLogic(fromId, toId, money);
    }
}

 

✅ 프록시 도입 전: 서비스에 비즈니스 로직과 트랜잭션 처리 로직이 함께 섞여있다.

✅  프록시 도입 후: 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져간다. 그리고 트랜잭션을 시작한 후에 실제 서비스를 대신 호출한다. 트랜잭션 프록시 덕분에 서비스 계층에는 순수한 비즈니즈 로직만 남길 수 있다.

 

 

스프링 AOP는 기본적으로 프록시 방식으로 동작한다. (*프록시 객체란, 쉽게 말해 가짜 객체로, 객체를 직접적으로 참조 하는 것이 아니라, 해당 객체를 대행(대리, proxy)하는 객체를 통해 대상객체에 접근하는 방식을 말한다.)

 

🪽 @Transactoinal의 동작

  • 스프링 트랜잭션은 AOP 방식으로 관리하고 있다.
  • 스프링 컨테이너는 @Transactional 어노테이션이 있으면, 해당 타겟 빈을 상속받은 프록시 객체를 생성한다. => 따라서 해당 클래스, 메서드에 대한 요청이 오면 프록시 객체가 대신 호출되어 트랜잭션 관리를 수행한 뒤, 실제 객체의 메서드를 호출한다,

스프링 컨테이너는 @Transactional 어노테이션이 있으면, 해당 타겟 빈을 상속받은 프록시 객체를 생성하므로, private 메서드는 상속이 불가하기 때문에 어노테이션을 붙여도 동작하지 않는다. 

 

 

스프링에서 프록시 객체를 생성하는 방법이 크게 2가지가 있다.

 

 

 

CGLib Proxy

Target 클래스를 상속 받아 프록시를 만든다.
스프링에서 proxy-target-class: true가 default라서 CGLIB이 기본으로 작동한다.

 

 

JDK Proxy

 Target의 상위 인터페이스를 상속 받아 프록시를 만든다.

 

 

아래의 테스트코드를 통해 트랜잭션 전파에 대해서 테스트해보았다.

해당 코드는 다음의 블로그를 참고했습니다.(https://coding-business.tistory.com/83)

 

@Slf4j
@SpringBootTest
public class SimpleTest {

    @Autowired
    CallService callService;

    @Test
    void externalCall() {
        //문제 상황 발생
        callService.external();
    }

    @TestConfiguration
    static class InternalCallV1Config {

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

    @Slf4j
    static class CallService {

        public void external() {
            log.info("call external");
            printTxInfo();
            //내부에 트랜잭션 처리되는 internal() 메소드 호출
            //this.internal()과 같은 의미이다.
            internal();
        }

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

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

 

 

02-12 19:00:05  INFO [c.f.a.repository.SimpleTest$CallService] call external
02-12 19:00:05  INFO [c.f.a.repository.SimpleTest$CallService] tx active=false
02-12 19:00:05  INFO [c.f.a.repository.SimpleTest$CallService] call internal
02-12 19:00:05  INFO [c.f.a.repository.SimpleTest$CallService] tx active=false

 

- outer, inner 메서드 모두 트랜잭션이 동작하지 않는다.

- internal()은 this.internal()과 같아서, 프록시 객체가 아닌 target 객체의 메서드를 호출하게 되어, 트랜잭션이 동작하지 않음

 

 

🤔 outer에 @Transactional을 붙이고 Inner에 안 붙이면?!

- 트랜잭션 전파속성에 따라 둘다 트랜잭션이 동작한다. (스프링에서 트랜잭션 전파타입의 기본값은 REQUIRED 이다.)

- internal의 트랜잭션이 external 트랜잭션에 참여하게 된다.

 

 

그렇다면 처음으로 돌아가서, 여기서는 왜 내부 스레드에서 트랜잭션이 적용되지 않은걸까?!

 

그건 바로 외부 스레드와 내부 스레드는 트랜잭션이 별개로 동작하기 때문이다. Spring의 @Transactional은 기본적으로 스레드 로컬(ThreadLocal) 기반으로 관리된다. 따라서 즉, 트랜잭션이 현재 실행 중인 스레드 내에서만 유지되게 되는것이다.

 

 

 


도움받은글

 

[Spring]같은 클래스 내의 다른 메소드에서 @Transactional이 있는 메소드를 호출하면 트랜잭션이 처리

@Transactional 어노테이션을 사용했는데 등록, 수정이 안돼요😥위는 실제로 내가 사수에게 물었던 질문이다. 때는, 스프링도 JPA도 거의 모르고 그저 일을 쳐내기 급급했던 시절...상사가 컨트롤러

conpang.tistory.com

 

 

스프링 Trasaction AOP 동작 과정과 주의 사항

관련 내용 해당 프로젝트 깃허브 순수 JDBC만을 사용한 Transaction 사용 방법 알아보기 JDBC를 사용한 Trasaction 처리와 이해 순수 JDBC-Transaction 문제 Spring TransactionManger로 해결하기 - 트랜잭션 템플릿,

coding-business.tistory.com

 

 

[Spring] AOP와 @Transactional의 동작 원리

오늘은 @Transactional의 동작 원리를 AOP와 함께 좀 더 자세하게 조사해보려고 한다.여기서 다루는 내용은 다음과 같다.AOP란 무엇이며 왜 사용하는가Spring AOP는 왜 프록시를 사용하는가@Transactional은

velog.io

 

 

 

테스트 코드 - 멀티쓰레드 환경의 트랜잭션

멀티 쓰레드 환경의 트랜잭션은 테스트 메서드의 트랜잭션과 별개다

velog.io

 

저작자표시 비영리 (새창열림)

'백엔드 > SpringBoot' 카테고리의 다른 글

[Spring Boot] HTTP 요청/응답 로깅  (0) 2025.04.05
[페이징] offset pagination vs cursor pagination  (0) 2025.02.17
멀티스레드 환경에서의 트랜잭션 동작 - feat. 테스트 코드  (0) 2025.02.10
② 실전! 스프링 부트와 JPA 활용1  (1) 2023.11.14
① 실전! 스프링 부트와 JPA 활용1  (2) 2023.11.06
  1. 🪽 AOP (Aspect Oriented Programming)
  2. 🪽 @Transactoinal의 동작
'백엔드/SpringBoot' 카테고리의 다른 글
  • [Spring Boot] HTTP 요청/응답 로깅
  • [페이징] offset pagination vs cursor pagination
  • 멀티스레드 환경에서의 트랜잭션 동작 - feat. 테스트 코드
  • ② 실전! 스프링 부트와 JPA 활용1
주디(Junior developer)
주디(Junior developer)
Hello World!😀 Hi, I'm Judy🐰(Junior Developer)
주디(Junior developer)
주디는 언제나 당근을 원해🥕
주디(Junior developer)
전체
오늘
어제
  • 분류 전체보기 (82)
    • 연합동아리 (5)
      • 멋쟁이사자처럼🦁 (2)
      • UMC (0)
      • SOPT (3)
    • 프론트엔드 (3)
      • HTML + CSS + Javascript (3)
    • 백엔드 (11)
      • Django (2)
      • SpringBoot (8)
      • Infra (1)
    • Programming study (18)
      • JAVA (3)
      • Python (14)
    • Coding (41)
      • Baekjoon(백준) (32)
      • 자료구조 (1)
      • 코딩테스트 공부 (7)
      • 프로그래머스 (0)
      • 트러블슈팅 (1)
    • github (2)
    • CS (1)
      • 운영체제 (0)
      • Database (1)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • HTML
  • 웹 프로그래밍
  • 변수
  • Baekjoon
  • 프로그래머스
  • Python
  • C
  • dfs
  • 스프링부트
  • Dear_Santa
  • Java
  • 자바
  • 백준
  • 멋사
  • 만들 수 없는 금액
  • SOPT
  • 파이썬
  • c언어
  • 트랜잭션
  • BFS
  • 코테
  • 리스트
  • 12015번
  • 장고
  • SpringBoot
  • 에라토스테네스의 체
  • CSS
  • django
  • JavaScript
  • JPA

최근 댓글

최근 글

hELLO · Designed By 정상우.v4.2.2
주디(Junior developer)
스프링 AOP와 트랜잭션
상단으로

티스토리툴바

개인정보

  • 티스토리 홈
  • 포럼
  • 로그인

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.