사이드 프로젝트/순수 자바로 스프링 만들기

순수 자바로 @Transactional 구현하기

마늘냄새폴폴 2025. 6. 11. 23:06

이번 포스팅에선 Spring의 @Transactional를 깊이있게 파헤쳐보고 정리해보도록 하겠습니다. 코드는 깃허브를 참고해주시면 감사하겠습니다. 코드는 웬만하면 적지 않을 예정입니다. 제 블로그 포스팅은 기본적으로 코드를 되도록 적지 않는데 그 이유는 코드가 많아지면 설명해야할게 많아지고 그럼 가독성이 떨어진다고 생각하기 때문입니다.

 

또한, 저도 공부하면서 포스팅을 수백개 봤지만 사실 코드를 잘 안봅니다... 그리고 깃허브를 들어가서 실제 코드를 보면 아시겠지만 변수명과 함수명이 실제 스프링 프로젝트와 다른 부분이 있습니다. 그 이유는 

 

  1. 실제 스프링 프로젝트는 훨씬 더 복잡한 계층 구조와 다양한 팩토리 클래스를 가지고 있습니다. 그래서 제 프로젝트는 최대한 가볍게 만들기 위해서 변수명을 조정했습니다. 
  2. 사실 코드는 별로 중요하지 않다고 생각하고 흐름만 공부하면 좋겠다고 생각했습니다. 
  3. AI의 도움을 받긴 했지만 커서와 같은 AI는 도움만 주었고 실제 코드 작성은 제가 하는 편입니다. 그 중에서 AI가 적어준 변수명보다 제가 이해하기 편한 변수명을 사용한 것도 있습니다. 

https://github.com/garlicpollpoll/springlite

 

GitHub - garlicpollpoll/springlite: 순수 자바로 스프링 만들기 프로젝트입니다.

순수 자바로 스프링 만들기 프로젝트입니다. Contribute to garlicpollpoll/springlite development by creating an account on GitHub.

github.com

 

이제 본격적으로 시작해보겠습니다. 

 

순수 자바로 @Transactional 구현하기

Spring에서는 트랜잭션을 굉장히 세밀하게 관리합니다. 일반적인 동작 방식은 기존 AOP의 구조에 대한 학습이 선행되어있다면 크게 어렵진 않지만 조금씩 변수가 생기면 복잡해지는 것 같습니다. 

 

일단 기본적인 구조와 흐름도로 정리하고 예외상황에 대해서 살펴보도록 하겠습니다. 

 

@Transactional 구조

@Transactional은 크게 다섯가지로 구분됩니다. 

 

  • TransactionAspect : @Transactional이 붙어있는 메서드를 AOP를 이용해서 실행하는 역할을 합니다. 우리가 구현한 AOP에서 사용하는 어노테이션들이 사용됩니다. 
  • TransactionDefinition : @Transactional 어노테이션에 붙어있는 값들 (isReadOnly, propagation 등) 을 가져오는 역할을 합니다. 
  • TransactionManager : commit, rollback등 실제 트랜잭션이 실행되는 로직을 담고 있습니다. 
  • TransactionStatus : 트랜잭션에 대한 정보를 가지고 있는 클래스입니다. 해당 클래스에 Connection이 안전하게 보관되고 있습니다. 
  • TransactionException : 실제 롤백에 사용될 트리거인 예외입니다. 이 예외가 감지되면 롤백하는 것으로 간주합니다. 

 

여기서 가장 중요한 클래스는 아무래도 TransactionManager와 TransactionAspect가 아닐까 싶습니다. TransactionAspect에서 AOP가 적용되는데 이 AOP를 기반으로 REQUIRES_NEW 전파단계도 처리할 수 있기 때문입니다. 

 

TransactionManager는 사실 스프링에선 여러개의 구현체를 가지고 있지만 저는 JdbcTemplate을 사용할 것이기 때문에 JdbcTransactionManager를 구현했습니다. 

 

TransactionManager는 전파 단계에 따라 다른 로직을 타기 때문에 이는 이 뒤의 섹션에서 다루도록 하겠습니다. 

 

@Transactional 흐름도

@Transactional의 흐름도 이전 AOP와 마찬가지로 단순화해서 제가 이해하기 편한대로 정리해보도록 하겠습니다. 

 

전파 단계가 REQUIRED인 경우 (디폴트)

 

  1. 메서드 호출
  2. AOP 프록시가 인터셉트
  3. 기존 트랜잭션이 있는지 확인 ( getTransaction() )
    1. 있으면 기존 트랜잭션에 편입
    2. 없으면 새로운 트랜잭션 생성 (setAutoCommit(true)를 호출 -> 원자성 / isolation 설정 -> 격리성)
  4. 트랜잭션 안에 모든 로직이 실행되다가 예외 터지면 롤백 -> 일관성
  5. 모든 로직 실행 후 commit 호출 -> 내구성

 

이렇게 트랜잭션의 ACID를 모두 만족하게 됩니다. 

 

전파 단계가 REQUIRES_NEW인 경우

 

  1. 메서드 호출
  2. AOP 프록시가 인터셉트
  3. 기존 트랜잭션이 있는지 확인 ( getTransaction() )
    1. 있으면 현재 트랜잭션을 삭제하고 새로운 트랜잭션을 생성 (기존 트랜잭션은 스택에 보관, Connection도 새로 생성)
    2. 로직 실행 후 성공하면 커밋, 실패하면 롤백
    3. 현재 트랜잭션 삭제 후 스택에서 pop한 트랜잭션을 현재 트랜잭션으로 세팅
    4. 기존 로직 이어서 실행
  4. 트랜잭션 안에 모든 로직이 실행되고 예외 터지면 롤백
  5. 모든 로직 실행 후 commit 호출

 

@Transactional과 ThreadLocal

트랜잭션이 REQUIRES_NEW에 의해 새로 생성될 때 기존 트랜잭션을 잠깐 옆으로 밀어두고 새로운 트랜잭션을 실행시켜야 하기에 스프링에서는 내부적으로 ThreadLocal을 사용합니다. 

 

위에서 스택에 보관, 삭제후 세팅 이런 것들이 전부 ThreadLocal에서 이루어지고 있던 것이죠. 

 

ThreadLocal은 현재 스레드가 바라보고 있는 포인터로서 이 값을 변경해주고 재활용하면 현재 스레드가 어떤 트랜잭션을 바라보고 메서드를 실행해야하는지 알 수 있습니다. 

 

    private static final ThreadLocal<TransactionStatus> currentTransaction = new ThreadLocal<>();
    
    // REQUIRES_NEW를 위한 중단된 트랜잭션 스택
    private static final ThreadLocal<Stack<TransactionStatus>> suspendedTransactions = new ThreadLocal<>();

 

이렇게 두 종류의 ThreadLocal을 사용하고 순서는 흐름은 다음과 같습니다. A트랜잭션이 B트랜잭션을 호출하고 B는 REQUIRES_NEW로 되어있다고 가정해보겠습니다. 

 

  1. currentTransaction을 A트랜잭션으로 생성
  2. currentTransaction.get()으로 A트랜잭션 가져와서 A메서드 실행
  3. B트랜잭션이 등장
  4. currentTransaction.remove()로 현재 트랜잭션 삭제 후 suspendedTransactions.set(stack.push(A트랜잭션)) 이렇게 잠시 밀어둠
  5. currentTransaction.set(B트랜잭션) B트랜잭션으로 세팅
  6. B메서드 실행 후 종료
  7. currentTransaction.remove() 로 B트랜잭션 삭제
  8. suspendedTransactions.get() 으로 스택가져온 뒤 stack.pop으로 트랜잭션 꺼내기
  9. currentTransaction.set(pop한 트랜잭션) 잠깐 밀어뒀던 A트랜잭션 가져오기
  10. A메서드 마저 실행

여기서 제가 궁금했던 것은 A트랜잭션을 스택으로 관리해서 가져온다고해서 어떻게 로직까지 같이 돌아갈 수 있을까? 였는데요. 

 

AOP에서 JoinPoint로 메서드가 어디서 실행될지 정했던 거 기억하시나요? 

 

JoinPoint를 설정해주고 메서드를 마저 실행하면 어차피 getTransaction() 메서드는 현재 트랜잭션인 currentTransaction을 바라보고 있기 때문에 A트랜잭션을 가져와서 무사히 메서드를 마저 실행시킬 수 있는 것입니다. 

 

REQUIRES_NEW의 롤백 전략

만약 우리가 methodA에서 methodB를 호출했는데 methodB가 REQUIRES_NEW인 경우에 methodB에서 예외가 발생하면 어떻게 될까요? 

 

보통 REQUIRES_NEW로 methodB를 설정했다는 것은 methodA와 다른 격리된 트랜잭션을 사용하고 싶어서일 것인데요. 

 

그럼 우리는 여기서 methodB만 롤백되고 methodA는 그대로 커밋되는 것을 기대해볼 수 있습니다. 

 

하지만 상황은 그렇게 흘러가지 않습니다. 

 

methodB에서 예외가 터진 것이 A로 전파되면서 UnexpectedRollbackException이 터지고 rollbackMarked가 되어 methodA에서도 롤백이 됩니다. 

 

이 때문에 methodA는 커밋, methodB는 롤백을 원한다면 이 방법을 쓰면 됩니다. 

 

  • methodA에서 try / catch로 예외를 잡는다.

 

methodA에서 예외를 잡아버리면 methodB에서 전파된 예외가 처리되면서 methodB는 정상적으로 롤백, methodA는 예외를 처리했으므로 커밋이 되게 됩니다. 

 

만약 methodB에서 잡으면 어떻게 될까요? 

 

methodB에서 예외를 잡으면 methodB에서도 rollbackMarked가 되지 않아 methodB도 커밋이 됩니다. 

 

하지만 이런 경우 REQUIRES_NEW로 선언하지 않겠죠? 어차피 한몸처럼 움직일거면 REQUIRED를 할테니까요. 

 

위의 내용에 대해 예시와 함께 자세히 기술한 포스팅이 아래의 링크에 정리되어있습니다. 참고해보시면 좋을 것 같습니다. 

 

https://coding-review.tistory.com/541

 

@Transactional, 왜 이게 롤백이돼..?

이번 포스팅에선 되게 신기한 상황을 접하게 되어서 이를 분석해보고 뜯어본 결과를 공유해보고 싶어서 글을 쓰게 되었습니다.  이번 글은 물리 트랜잭션, 논리 트랜잭션, @Transactional을 사용할

coding-review.tistory.com

 

마치며

기존 스프링을 공부할 때도 제가 가장 깊이있게 공부했던 것이 바로 @Transactional이었는데요. 이번에 직접 구현하면서 클래스를 하나하나 뜯어보니 옛날 생각도 나고 많은 공부가 되었습니다.  

 

이정도로 깊이있게 클래스를 뜯어가면서 공부한 적은 없었지만 동작방식이라던가 예외 전략같은 것들을 미리 공부하고 보니 감회가 새롭네요. 

 

다음 포스팅은 아마 @Valid와 @ExceptionHandler가 될 것같습니다. 다시 MVC로 돌아가서 한번 불태워보도록 하겠습니다. 

 

오늘 포스팅은 여기서 마치도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~