카테고리 없음

Spring 공부. @Transactional의 동작 원리

코딩연습중 2025. 4. 11. 20:03

@Transactional의 동작 원리를 공부하기 전에 먼저 AOP에 대한 이해가 있어야 함!

2025.04.11 - [분류 전체보기] - Spring 공부. Spring AOP란?

 

Spring 공부. Spring AOP란?

전에 해커톤에서 트랜잭션 처리가 잘 이루어지지가 않아 문제가 됐던 적이 있었다. 그냥 막연하게 @Transactional 쓰면 되지~ 라고 생각했었는데 이런 태도로는 발전이 없을 것 같고 또 같은 문제가

codstudy.tistory.com

 

@Transactional?

@Transactional 어노테이션은 스프링에서 사용되는 선언적 트랜잭션 방식으로, Spring AOP를 기반으로 동작하기 때문에 내부적으로 Proxy 패턴을 사용한다. 트랜잭션 처리에 필요한 getConnection(), setAutoCommit(false), 예외 발생 시 롤백, 정상 종료 시 커밋 등의 작업이 자동으로 수행된다.

 

Proxy 객체를 통한 @Transactional의 처리 프로세스는 다음과 같다.

  1. 클라이언트로부터 메서드 호출이 들어오면, AOP Proxy가 이를 가로채서(intercept) 가져온다.
  2. 프록시 내부에서 Transaction Advisor가 동작하여 트랜잭션을 시작하고, 메서드 종료 시점에 따라 commit 또는 rollback 을 수행한다.
  3. 트랜잭션 처리 외에 다른 부가 기능이 있을 경우, 해당 기능을 담당하는 Cusotm Advisor가 순차적으로 실행된다.
  4. 모든 Advisor에서의 처리가 끝나면, 실제 Target Method(즉, 비즈니스 로직)을 수행한다.
  5. 메서드 실행이 끝나면 interceptor chain을 따라 caller에게 결과를 전달한다.

 

@Transactional 적용 위치의 중요성

@Service
public class ServiceName {

	@Autowired
	CouponGroupMapper couponGroupMapper;

	@Transactional
	public void A(CouponGroupParam param) {
		param.setStatus(CouponGroupStatus.CREATED);	
		couponGroupMapper.insertCouponGroup(param);
	}

	public void B() {
		for(int i = 0; i < 3; i++) {
			CouponGroupParam param = new CouponGroupParam();
			param.setCode("B000" + i);
			this.A(param); // 내부에서 A 호출
		}
		throw new RuntimeException(); // 예외 발생!
	}

	@Transactional
	public void C() {
		for(int i = 0; i < 3; i++) {
			CouponGroupParam param = new CouponGroupParam();
			param.setCode("C000" + i);
			this.A(param); // 내부에서 A 호출
		}
		throw new RuntimeException(); // 예외 발생!
	}
}

 

위와 같이 @Transactional을 적용시킨 메서드가 있다고 하자.

1. B()에서 예외가 발생한 경우 A()에서 수행한 insert는 롤백되는가?

2. C()에서 예외가 발생한 경우 A()에서 수행한 insert는 롤백되는가?

 

.

.

.

1. A()의 insert문은 롤백되지 않는다.

2. A()의 insert문은 롤백된다.

2번에서는 C()에 @Transactional이 붙어 있기 때문에 전체 메서드가 트랜잭션으로 감싸져 정상적으로 롤백된다.

이러한 차이가 생기는 이유는 다음과 같다.

@Transactional이 붙어있는 메서드를 호출하면 해당 클래스에 대해 proxy 객체를 만들게 되고, transaction에 대한 처리를 한 후, 원래의 메서드를 호출하게 된다. 따라서 내가 만약 컨트롤러에서 @Transactional이 붙은 메서드를 호출한 게 아니라면 트랜잭션 처리가 되지 않는다.

 

 

@Service
public class AService {

	@Autowired
	BService bService;

	@Transactional
	public void A(CouponGroupParam param) {
		bService.method();
        	throw new RuntimeException();
	}
}

@Service
public class BService {
	
    public void method() {
    	// 명령~~
    }
}

 

위 코드의 결과로 BService의 method도 트랜잭션 처리가 될까?

.

.

.

맞다.

BService의 method에 @Transactional이 없어도 A()에서 트랜잭션이 시작되었기 때문에 method 안의 DB 작업도 트랜잭션에 포함된다.

 

 

new로 생성 vs 의존성 주입(DI)

1. new로 직접 객체를 생성한 경우

public class AService {
    private BService bService = new BService(); // 직접 생성
}

위와 같이 AService에서 new를 통해 BService의 객체를 직접 생성하면 Spring Container을 통하지 않기 때문에 스프링이 관리하는 프록시 기능(@Transactional, @Aspect 등)이 작동하지 않는다. 

따라서 @Transactional을 사용하기 위해서는 의존성 주입을 해줘야 한다.

 

@Transactional에는 옵션으로 propagation및 isolation 레벨을 설정할 수 있다.

그 전에 transaction propagation과 isolation이 무엇인지 알아보겠다.

 

 


 

 

Transactional Propagation(트랜잭션 전파)?

트랜잭션이 이미 진행 중인 상태에서, 다른 메서드가 트랜잭션을 어떻게 처리할지 결정하는 정책을 말한다.

 

스프링에서 @Transactional 어노테이션은 하나의 작업 단위를 트랜잭션으로 묶기 위해 사용된다. 그런데 메서드 호출이 중첩되거나, 다른 서비스의 트랜잭션과 섞이는 경우, 트랜잭션이 어떤 방식으로 동작해야 할지는 상황마다 다를 수 있다.
이때 사용하는 개념이 바로 트랜잭션 전파(Propagation) 이다.

다시 말해, 어떤 서비스 메서드가 트랜잭션 중일 때, 그 안에서 호출된 다른 메서드도 트랜잭션에 '참여할지', '새로 만들지', '기존 것을 무시할지' 등을 정하는 것이다.

 

예를 들어, 다음과 같은 구조의 서비스가 있다고 하자.

@Service
public class OrderService {
    @Transactional
    public void createOrder() {
        saveOrder();       // order 저장
        paymentService.pay(); // 결제 처리
    }
}

 

여기서 pay()는 기존 트랜잭션에 참여? 아니면 독립적으로 처리? → 이걸 설정하는 게 Propagation이다.

 

Propagation levels

1. REQUIRED (기본값 - 생략 가능)

@Transactional(propagation = Propagation.REQUIRED)
public void doSomething() {...}

 

부모 트랜잭션 내에서 실행하며, 부모 트랜잭션이 없을 경우 새로운 트랜잭션 생성.

만약, 호출한 곳에서 이미 트랜잭션이 설정되어 있다면 기존의 트랜잭션 내에서 로직을 실행한다. (동일한 연결 안에서 실행됨)

  • 예외 발생 시 전체 트랜잭션 롤백

2. REQUIRES_NEW

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog() {...}

 

무조건 새 트랜잭션을 생성한다. 만약, 호출한 곳에서 이미 트랜잭션 설정이 되어있다면 기존의 트랜잭션은 메서드가 종료될 때까지 잠시 대기 상태로 빠진다.

  • 내부에서 예외가 발생해도 외부 트랜잭션에는 영향 없음 (두 개의 트랜잭션이 완전히 독립적)

로그 저장처럼, 실패해도 반드시 DB에 남겨야 하는 작업에 적합하다.

3. NESTED

@Transactional(propagation = Propagation.NESTED)
public void processPartially() {...}

 

해당 메서드가 부모 트랜잭션에서 진행될 경우 별개로 커밋되거나 롤백될 수 있다. 둘러싼 부모 트랜잭션이 없을 경우, REQUIRED와 동일하게 작동한다. 차이점은 SAVEPOINT를 지정한 시점까지 부분 롤백이 가능하다는 점이다. 또한 이미 진행 중인 트랜잭션이 있다면 중첩 트랜잭션을 시작한다.

  • 내부에서 예외가 나면 부분 롤백 가능
  • ❗ 단, DB에서 Savepoint를 지원해야 함

4. MANDATORY

@Transactional(propagation = Propagation.MANDATORY)
public void mustRunInTransaction() {...}
  • 반드시 기존 트랜잭션이 있어야만 실행 가능
  • 부모 트랜잭션이 없으면 IllegalTransactionStateException 발생한다.

5. SUPPORTS

@Transactional(propagation = Propagation.SUPPORTS)
public void optionalTransaction() {...}
  • 부모 트랜잭션이 있으면 참여
  • 없으면 트랜잭션 없이 실행

6. NOT_SUPPORTED

@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void runWithoutTransaction() {...}
  • 항상 트랜잭션 없이 실행
  • 기존 트랜잭션이 있으면 일시 중단

7. NEVER

@Transactional(propagation = Propagation.NEVER)
public void errorIfTransactionExists() {...}
  • 트랜잭션 없이 실행되며, 부모 트랜잭션이 존재하면 예외 발생

 

전파 레벨 언제 사용하면 좋을까?

REQUIRED 일반적인 트랜잭션 묶음 처리
REQUIRES_NEW 로그 저장, 실패해도 꼭 DB에 남겨야 하는 작업
NESTED 부분 롤백이 필요한 상황
SUPPORTS 트랜잭션 유무에 크게 상관없는 조회 등
NOT_SUPPORTED 트랜잭션 영향 없이 독립 실행해야 하는 경우
MANDATORY 반드시 트랜잭션 환경에서만 동작해야 하는 경우
NEVER 트랜잭션 안에서 실행되면 안 되는 경우 (예: 상태 초기화 등)

 

 

 


 

 

Transactional Isolation(트랜잭션 격리 수준)?

Propagation이 트랜잭션끼리 어떻게 연결될지에 대한 설정이었다면, Isolation은 트랜잭션끼리 동시에 실행될 때, 서로 어떤 데이터를 볼 수 있는지에 대한 규약이다.

트랜잭션 격리 수준이란, 여러 트랜잭션이 동시에 실행될 때, 각 트랜잭션이 다른 트랜잭션의 데이터를 얼마나 격리할지 결정하는 설정이다.

예를 들어, 내가 데이터를 읽는 중에 누군가가 데이터를 바꾼다면?

→ 허용할 수도 있고, 막을 수도 있고, 변경된 걸 무시할 수도 있다.

이걸 제어하는 게 Isolation Level이다.

 

isolation level에 대해 이해하기 전에 짚고 넘어가야 할 부분이 있다.

 

1. Dirty Read

다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽는 것이다. 만약 나중에 그 트랜잭션이 롤백된다면? 내가 읽어온 데이터는 잘못된 값이 된다.

2. Non-Repeatable Read

같은 쿼리를 두 번 날렸는데, 중간에 누가 바꿔서 결과가 달라진 것이다. 예를 들어 내가 조회하는 사이에 누군가 업데이트를 한 경우 발생할 수 있다.

3. Phantom Read

조건은 같은데 레코드 수가 다른 것이다. 에를 들어, 같은 조건으로 읽어왔는데 중간에 누가 INSERT를 해서 결과가 늘어난 경우 발생할 수 있다.

 

격리 수준에는 4가지가 있다.

  1. READ_UNCOMMITED (level 0)
  2. READ_COMMITED (level 1)
  3. REPEATABLE_READ (level 2)
  4. SERIALIZABLE (level 3)

격리 수준이 높아질 수록 동시성(Concurrency)은 높아지고, 속도는 느려진다.

따라서 이 둘의 균형을 잘 맞추는 것이 중요하다

 

 

1. READ_UNCOMMITED (커밋되지 않는 읽기)

다른 트랜잭션이 커밋하지 않은 데이터도 읽을 수 있다. 예를 들어, 어떤 사용자가 A라는 데이터를 B로 변경하는 동안 다른 사용자는 B라는 아직 완료되지 않은 (Uncommited 혹은 Dirty) 데이터 B를 읽을 수 있다.

가장 빠르지만 가장 위험하다.

▫ 실시간 분석용 로그 조회 정도에만 사용된다.

 

2. READ_COMMITED (커밋된 읽기)

커밋된 데이터만 읽을 수 있다. 예를 들어, A라는 데이터를 B로 변경되는 동안 다른 사용자는 해당 데이터에 접근할 수 없다.

Dirty Read는 막지만, 다른 트랜잭션이 중간에 값을 바꾸면 반복 읽기 결과는 다를 수 있다.(Non-Repeatable Read 발생)

대부분의 RDBMS(Oracle 등)에서의 기본값이다.

 

3. REPEATABLE_READ (커밋된 읽기)

트랜잭션이 완료될 때까지 SELECT 문장이 사용하는 모든 데이터에 shared lock이 걸린다. 따라서, 다른 사용자는 해당 영역에 대한 데이터 수정이 불가능하다. 선행 트랜잭션이 읽은 데이터는 트랜잭션이 종료될 때까지 후행 트랜잭션이 갱신하거나 삭제하는 것을 불허함으로써 같은 트랜잭션 안에서 같은 쿼리는 항상 같은 결과를 보장한다. (같은 쿼리로 조회했을 때 일관성 보장) 예를 들어, A라는 데이터를 B로 변경되는 동안 다른 사용자는 해당 데이터에 접근할 수 없다.

UPDATE/DELETE에 강하지만 다른 트랜잭션이 INSERT하면 팬텀 읽기는 발생한다.

MySQL의 기본값이다.

 

4. SERIALIZABLE (직렬화 가능)

가장 강력한 격리 수준으로, 다른 트랜잭션이 영향을 줄 수 없다. (읽기/쓰기 모두 차단)

하지만 가장 느리며, 동시성이 떨어진다.

거의 사용되지 않는다. 

데이터 일관성 및 동시성을 위해 MVCC를 사용하지 않는다.

 

MVCC (Multi Version Concurrency Control)
 - 다중 사용자 DB 성능을 위한 기술. 데이터 조회 시 Lock을 걸지 않고, 데이터의 버전을 관리해 데이터의 일관성 및 동시성을 높이는 기술이다.

 

격리 수준 Dirty Read Non-Repeatable Read Phantom Read
READ_UNCOMMITTED ✅ 발생 ✅ 발생 ✅ 발생
READ_COMMITTED ❌ 방지 ✅ 발생 ✅ 발생
REPEATABLE_READ ❌ 방지 ❌ 방지 ✅ 발생
SERIALIZABLE ❌ 방지 ❌ 방지 ❌ 방지

 

트랜잭션 중에 격리 수준을 전환할 때, 사용하고 있는 DB나 JDBV 드라이버에서 기능이 지원되는지를 분명하게 먼저 확인해야 한다. 각 DB 회사마다 지원하는 기본 격리 수준이 다르기 때문이다.

 

 

 

참고:

- https://velog.io/@jinyeong-afk/%EA%B8%B0%EC%88%A0-%EB%A9%B4%EC%A0%91-Spring-Transactional-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%ACfeat.-AOP

- https://blogshine.tistory.com/291

- https://jeong-pro.tistory.com/228

- https://velog.io/@salgu1998/Spring-Transaction-propagation%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%A0%84%ED%8C%8C

- https://n1tjrgns.tistory.com/266

- https://n1tjrgns.tistory.com/267