영속성 컨텍스트를 공부하는 과정에서 트랜잭션을 정리할 필요성을 느꼈고, 겸사겸사 락도 공부하면 좋을 것 같았다.
그런데 트랜잭션 내용만으로 이미 너무 방대해서, 내가 스프링을 사용하면서 많이 사용했던 @Transactional 애노테이션에 대해 정리하려 한다.
💉 트랜잭션이 뭐야?
트랜잭션은 “db의 상태를 변경시키는 작업의 단위” 이다. 내가 가장 와닿았던 설명은 "트랜잭션 작업 단위는 모두 성공하거나 모두 실패해야 한다"는 것이었다.
가장 흔하게 사용되는 비유는 송금 기능이다. A 통장에서 B 통장으로 송금하는 과정은 A 통장에서 출금, B 통장에 입금이라는 두 단계로 이루어진다. 그런데 만약 A 통장에서 출금만 성공하고 B 통장에 입금에 실패한다면 명백한 오류 상황이다. 출금, 입금은 모두 실패하거나, 모두 성공해야 하고, 이 두 단계를 하나의 트랜잭션 단위라고 할 수 있다.
💉 트랜잭션 특성 - ACID
트랜잭션의 존재 이유는 트랜잭션의 핵심 개념인 ACID를 살펴보면 알 수 있다.
- Atomicity(원자성) - 모두 성공하거나 모두 실패해야 한다. 부분적으로 성공하는 일은 있으면 안 된다.
- Consistency(일관성) - 트랜잭션이 끝난 후 데이터는 일관된 상태(정확하고 신뢰할 수 있는 상태)여야 한다.
- Isolation(고립성) - 동시에 실행되는 각 트랜잭션끼리 서로 간섭하면 안 된다.
- Durability(지속성) - 트랜잭션이 성공적으로 완료되면, 결과가 영구적으로 보존된다.
💉JPA에서 트랜잭션
EntityManager와 트랜잭션은 밀접하게 연관되어 있다. 나도 JPA를 공부하는 과정에서 트랜잭션을 접할 수 있었다.
JPA에서 Entity Manager를 통해 트랜잭션에 접근하고 관리할 수 있다.
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("jpa-example");
EntityManager entityManager = entityManagerFactory.createEntityManager();
try {
entityManager.getTransaction().begin();
entityManager.persist(firstEntity);
entityManager.persist(secondEntity);
entityManager.getTransaction().commit();
} catch (Exception e) {
entityManager.getTransaction().rollback();
}
참고로 EntityManagerFactor를 사용하려면, persistence.xm에 관련 정보를 적어야 한다.
✔️ Transactional
트랜잭션을 시작하고, try - catch에서 성공하면 commit, 실패하면 rollback시키는 코드는 매 트랜잭션마다 반복될 것이다. 그런데 우리는 Spring Data JPA를 이용하면 @Transactional 애노테이션만 붙여주면 된다. 스프링이 알아서 메서드를 실행하기 전에 트랜잭션을 실행시키고, 메서드를 수행한 후 커밋 혹은 롤백을 수행한다.
주의할 점은, 언체크 예외가 발생할 때만 롤백이 일어난다는 것이다. 체크 예외가 던져졌을 때는 롤백이 일어나지 않는다.(체크 예외는 개발자가 충분히 예상한 예외이고, 처리할 것이라 기대하는 예외이기 때문)
근데 어떻게 메서드 실행 전, 후에 트랜잭션 로직을 수행할 수가 있을까?
✔️ 프록시
@Transactional → 트랜잭션은 여기저기 중복으로 사용되는 기능이다. 이를 관점지향으로 생각하면 트랜잭션을 관리하는 코드를 재사용한다면, 우리의 비즈니스 로직이 담긴 코드에서 트랜잭션 관련 코드를 분리하할 수 있다. 스프링은 이걸 프록시를 이용해 구현한다.
@Transactional이 붙어있는 메서드가 존재하거나, 클래스에 @Transactional이 붙어있다면, 스프링은 해당 클래스를 프록시로 생성해서 주입한다. 따라서 우리가 트랜잭션 로직을 호출하면, 핵심 로직을 실행하기 전에 트랜잭션을 시작하고, 로직이 끝난 후 커밋 / 롤백을 수행할 수 있다.
그래서 private 메서드에 @Transactional을 붙일 수 없다. 같은 클래스 메서드에서의 내부 호출은 intercept해서 트랜잭션 관련 로직을 수행할 수 없다. 왜냐하면 한 프록시 객체 내부에서 다른 메서드를 호출하면, 또 프록시를 호출하는 게 아니라 실제 클래스의 로직이 호출되기 때문이다.(this를 통한 메서드 호출은 프록시 객체가 아닌 실제 인스턴스의 메서드를 호출하기 때문) 그래서 같은 클래스 내의 메서드 호출에 @Transactional이 동작하지 않는다. (처음에 이걸 모르고 private 메서드에 @Transactional 붙이면 오류나길래 그냥 public으로 바꾸고 쓰면 되는 줄 알았다.)
영속성 컨텍스트를 직접 관리하기 위해서는 @PersistenceContext을 통해 EntityManager를 주입받아 사용한다. 그리고 이걸 @Transactional이 붙지 않은 곳에서 호출하면 에러가 발생한다. @PersistenceContext를 통해 만들어 주입해주는 EntityManager도 프록시이기 때문이다.
jakarta.persistence.TransactionRequiredException: No EntityManager with actual transaction available for current thread
TransactionRequiredException은 "현재 쓰레드에 트랜잭션이 바인딩되어 있지 않은데 EntityManager가 호출되었다"는 것을 의미한다.
이 부분에 대해서는 잘 모르지만, Entity Manager는 일단 프록시로 존재하고, @Transactional이 붙은 메서드에서 entity manager 관련 로직을 수행할 때, 해당 트랜잭션을 관리하는 entity manager에 로직을 위임하는 것으로 알고 있다. 그래서 각 트랜잭션은 고유한 영속성 컨텍스트를 이용해 엔티티의 영속성을 관리하게 된다.
스프링이 각 쓰레드에 EntityManager와 트랜잭션 동기화 객체를 바인딩하는 메커니즘으로 쓰레드 로컬(ThreadLocal)을 사용한다고 한다. 여기에 대해 더 알아봐도 좋을 것 같다.
✔️ 트랜잭션 격리 수준
@Transactional(isolation = Isolation.SERIALIZABLE)
트랜잭션 격리 수준은 이 트랜잭션에서 수행하는 작업이 다른 트랜잭션으로부터 얼마나 간섭을 안 받는가, 얼마나 독립적인가, 얼마나 격리되어 있는가를 나타낸다.
속성을 통해 격리 수준을 설정할 수 있다. Read Uncommitted, Read Committed, Repeatable Read, Serializable의 네 가지 수준으로 분리된다. 높은 수준으로 올라갈수록 데이터의 일관성과 안정성이 보장된다.
이 중 Serializable은 가장 높은 수준의 격리인데, 커밋되지 않은 데이터를 다른 트랜잭션에서 읽을 수 없게(Dirty Read), 같은 트랜잭션에서 같은 데이터를 읽었을 때 값이 달라지지 않게(Non-Repeatable Read), 특정 조건으로 쿼리한 데이터 집합이 재실행 시 다른 수의 행으로 바뀌지 않게(Phantom Read) 한다.
✔️ Read-Only 트랜잭션
만약 특정 트랜잭션에서 쓰기 연산이 일어나지 않는다면, 더티 체킹 연산은 쓸모없는 자원 낭비가 될 것이다. 따라서 readOnly = true가 되면 트랜잭션이 종료되어도 flush를 하지 않고, 더티 체킹도 하지 않는다.
쓰기 연산이 없는, 읽어오기만 하는 메서드에는 readOnly 옵션을 적용해주자.
✔️ 트랜잭션 전파 옵션
트랜잭션 단위는 한 메서드가 될 수도 있고, 여러 메서드가 될 수도 있다.
예를 들어, @Transactional이 붙은 A 메서드에서 @Transactional이 붙은 B 메서드를 호출하면, 두 로직은 하나의 트랜잭션에서 동작하게 된다. A 메서드와 B 메서드는 각각 다른 논리적 트랜잭션이지만, 하나의 물리적 트랜잭션에서 수행된다.
하나의 트랜잭션에서 수행되는 이유는 전파 옵션이 기본적으로 PROPAGATION_REQUIRED이기 때문이다. 각 전파 옵션의 특징에 대해 정리해보자.
- PROPAGATION_REQUIRED
- 외부 트랜잭션이 존재하면 합류하고, 없다면 새로운 트랜잭션을 실행한다.
- 내부 트랜잭션은 외부 트랜잭션의 옵션에 따른다. 내부 트랜잭션에 readOnly, isolation level 등 어떤 속성을 설정해줘도 외부 트랜잭션의 속성이 적용된다.
- 결국 물리적으로 하나의 트랜잭션이기 때문에, 내부 트랜잭션에서 예외를 던지고 외부 트랜잭션에서 잡아서 처리하는 로직은 롤백을 일으킨다. 아래의 코드는 커밋이 아닌 롤백이 된다.
@Transactional
public A() {
try {
bService.B();
} catch (RuntimeException e) {
log.warn();
}
}
@Transactional
public B() {
throw new IllegalStateException();
}
- PROPAGATION_REQUIRES_NEW
- PROPAGATION_REQUIRED와 다르게, 새로운 물리적 트랜잭션을 생성한다.
- 그런데 물리적 트랜잭션은 DB Connection 를 점유하기 때문에, 여러 개 생성하면 고갈 문제가 발생하여 데드락이 발생할 수 있다.
- PROPAGATION_NESTED
- PROPAGATION_REQUIRED 처럼 기존 물리적 트랜잭션에 참여하는데, savepoint가 존재해서 특정 시점으로 돌아갈 수 있다.
- 내부 작업에 대해 부분 롤백 가능, 내부에서 롤백되어도 외부에서 커밋 가능
- PROPAGATION_NOT_SUPPORTED
- 이 옵션이 붙은 부분만 트랜잭션에서 제외된다. (트랜잭션이 이 로직을 수행하는 동안 중지된다)
- 진행하고 있던 트랜잭션에 대한 컨텍스트를 분리해놓고, 새로운 스레드에서 PROPAGATION_NOT_SUPPORTED 에 대한 로직을 수행한다. 그리고 끝나면 기존 트랜잭션 수행하던 스레드에 다시 연결한다.
나는 이 전파옵션과 각 특징을 잘 모르고 사용했었다. 그런데 전파 옵션에 대해 정리하다보니 내가 잘못 이해하고 사용한 코드가 있다는 것을 깨달았다. 나는 트랜잭션이 중지되는 동안 DB connection이 다시 반납되는 줄 알았다. 그래서 "외부 api때문에 트랜잭션 점유(DB Connection 점유) 시간이 길어진다"는 이유로, 외부 api 호출부를 PROPAGATION_NOT_SUPPORTED로 설정했었다. 그런데 connection을 빌려오고 반납하는 것 자체가 비용 소모가 크기 때문에, 중지 동안에도 connection을 들고 있다고 한다. 그래서 결국 내가 작성했던 코드는, 불필요한 자원 점유의 관점에서는 의미가 없었던 것이다! -> 정확하지 않은 내용 수정 예정
사실 이 포스팅 하는데 엄청 오래 걸렸다. 아직 포스팅을 할만큼 잘 이해하지 못한 것 같지만 질질 끌고싶지 않아서 일단 포스팅했다. 이제 락에 대해 더 공부해봐야겠다!
'스프링' 카테고리의 다른 글
| [Spring] 테스트코드를 어떻게 작성할 수 있을까? (계층 테스트, 통합 테스트) (2) | 2025.06.22 |
|---|