전파 속성을 갑자기 공부하게 된 이유는
개발 중 트랜잭션을 달았는데도 원자성이 보장이 안되는 이슈가 발생했기 때문이다.
처음에는 명확한 이유를 알지 못한 채 DB레벨의 락을 구현해서 넣었으나 여전히 같은 이슈 발생.
그래서 어떤 기준으로 달아야하는 지 명확히 알아야 할 것 같았다.
먼저 원하는 로직의 구현을 가정해보자.
A -> B
나는 이 두 함수를 연달아 연결하고 싶어서,
@Transactional 어노테이션을 달아 메서드 단위로 트랜잭션을 실행했다. (하나의 요청 스레드)
C라는 함수를 만들고 그 안에 두 함수를 try-catch로 이어놓았으며
예외 처리도 잘 해두었다.
그런데 여전히 둘은 따로 노는 문제가 발생.
정확히 말하면, 평소에는 묶어서 잘 실행되다가도
빠르게 요청하면 데이터가 따로 논다.
그 이유는 DB요청을 각자 스레드 워커가 실행하면 순서가 꼬이기 때문이다.
- apple-service 업데이트 = 트랜잭션 A
- pineapple-service 업데이트 = 트랜잭션 B (다른 서비스)
3-1. 1은 커밋되었는데 2 저장 실패 시 → 불일치
3-2. 1이 오기 전 2가 먼저 실행 → 1기반으로 2를 저장해야하므로 업데이트 실패
두 개의 별도 호출이라서 중간에 다른 트랜잭션이 끼어들 수가 있다.
나는 그럼 궁금해졌다.
삭제-생성이 같은 메서드 안에서 트랜잭션 처리되니 한 요청인데,
그럼 (A삭제-생성) 요청1 과 (B삭제-생성) 요청2를
묶어서 (요청1-요청2) 요청3
이렇게 트랜잭션으로 처리할 수 없는가 말이다...
근데 이건 불가능했다.
@Transactional은 스프링 AOP를 기반으로 하기 때문에
프록시 형태로 실행되며, 같은 클래스가 아니라면 클라이언트가 프록시 객체를 통해서 호출한 것이 아니기 때문에 제어할 수 없기 때문이다.
즉,
다른 빈(서비스) 호출 = 프록시 통과 = 새로운 트랜잭션 취급
즉, 같은 트랜잭션으로 보지 않게 된다.
나 역시도 B서비스의 함수를 가져와 썼는데
알고 보니 내부적으로 프록시를 통과해서 다른 트랜잭션을 타고 있었다.
그래서 찾다보니 @Transaction 어노테이션에 속성이 있었다.
Spring에서 @Transactional을 사용할 때 가장 많이 헷갈리는 부분 중 하나가 전파(Propagation) 이다.
트랜잭션 전파란, 메서드 실행 시 기존 트랜잭션을 어떻게 이어받거나 새로 만들지 결정하는 방식이다.
스프링은 총 7개의 전파 옵션을 제공한다. 하나씩 예시와 함께 살펴보자.
1. REQUIRED (기본값)
이미 트랜잭션이 있으면 참여하고, 없으면 새로 시작한다.
가장 기본적이고 흔히 쓰이는 방식이다.
@Transactional
public void serviceA() {
serviceB(); // 같은 트랜잭션에 포함
}
2. REQUIRES_NEW
항상 새로운 트랜잭션을 시작한다.
기존 트랜잭션은 잠시 보류(suspend)된다.
@Transactional
public void serviceA() {
serviceB(); // 별도 트랜잭션 (A와 독립적으로 커밋/롤백)
}
3. MANDATORY
반드시 기존 트랜잭션이 있어야 한다.
없으면 예외를 던진다.
@Transactional
public void serviceA() {
serviceB(); // OK
}
@Transactional(propagation = Propagation.MANDATORY)
public void serviceB() {
// serviceA 트랜잭션에 무조건 참여해야 함
}
4. SUPPORTS
트랜잭션이 있으면 참여, 없으면 그냥 트랜잭션 없이 실행한다.
읽기 전용 조회 로직에서 종종 쓰인다.
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true)
public List<User> findUsers() {
return userRepo.findAll();
}
5. NOT_SUPPORTED
트랜잭션을 무시하고 항상 트랜잭션 없이 실행한다.
기존 트랜잭션이 있으면 잠시 보류한다.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void bigSelectJob() {
// 트랜잭션 없이 대용량 조회 수행
}
6. NEVER
트랜잭션이 존재하면 예외를 던진다.
절대 트랜잭션 안에서 실행되면 안 되는 경우에 사용한다.
@Transactional(propagation = Propagation.NEVER)
public void nonTxLogic() {
// 트랜잭션 있으면 IllegalTransactionStateException 발생
}
7. NESTED
이미 트랜잭션이 있으면 중첩 트랜잭션을 시작한다. (savepoint 기반)
내부 롤백 시 외부 트랜잭션은 유지된다.
DB/드라이버에서 savepoint를 지원해야 동작한다.
@Transactional
public void serviceA() {
try {
serviceB(); // 실패 시 savepoint까지만 롤백
} catch (Exception e) {
// serviceA는 계속 진행 가능
}
}
@Transactional(propagation = Propagation.NESTED)
public void serviceB() {
// 중첩 트랜잭션
}
다른 서비스라고 트랜잭션이 분리되는 것이 아니라, 선행 된 트랜잭션 A에 참여할 수 있도록 propagation = MANDATORY를 추가했다.
(MANDATORY 옵션은 트랜잭션 A에 참여한다는 뜻)
// 트랜잭션 A
@Transactional
public void A() {
// A의 DB 커넥션: conn1
aorepo.save(...);
// 다른 서비스 호출이지만 같은 트랜잭션 참여
aoservice.B(); // 트랜잭션 A 참여!
}
// 트랜잭션 A 참여 (새 트랜잭션 없음)
@Transactional(propagation = MANDATORY)
public boolean B() {
// A의 DB 커넥션: conn1 (같은 커넥션 재사용!)
aorepo2.save(...);
// A에서 함께 커밋
}
@Override
@Transactional // ← 하나의 트랜잭션
public List<...> () {
try {
// 1. B 삭제 (MANDATORY)
B.delete(locked);
// 2. A 삭제
A.deleteById(.getId());
}
}
// 3. 새 A 데이터 생성
List<> ...
// 4. 새로 생성 된 A 데이터에 대해 B 데이터 생성 (MANDATORY)
}
} catch (Exception e) {
throw e; // 전체 롤백
}
}
@Transactional updateAB()
├── B 삭제 (MANDATORY - 같은 트랜잭션 참여)
├── A 삭제
├── A 생성
├── B 생성 (MANDATORY - 같은 트랜잭션 참여)
└── 성공시 커밋 / 실패시 전체 롤백