[친구하자] 트랜잭션 전파(Propagation)와 비관적 락 - 같은 트랜잭션 안에서 락이 유지되는 이유
비관적 락을 적용한 통화 종료 로직에서 트랜잭션 전파가 중요한 이유, 그리고 같은 트랜잭션 안에서 DB 락이 어떻게 유지되는지 정리했습니다.
이전 일기: 비관적 락 적용기에서 통화 종료에 비관적 락을 적용했다.
그때@Transactional하나로 전체 흐름을 감쌌는데, 왜 한 트랜잭션으로 묶어야 하고, 전파 옵션을 잘못 쓰면 락이 어떻게 깨질 수 있는지를 이번에 정리해 본다.
- 서론
- 1. 트랜잭션 전파(Propagation)란?
- 2. Spring의 전파 옵션
- 3. 비관적 락과 트랜잭션 경계
- 4. 같은 트랜잭션 안에서 락이 유지되는 이유
- 5. 전파를 잘못 쓰면? (REQUIRES_NEW 사례)
- 6. 친구하자 endCall에서의 적용
- 7. 요약
서론
실시간 음성 매칭 서비스 “친구하자”에서는 통화 종료 시 비관적 락으로 동시 요청을 순차 처리한다. findByIdWithLock()으로 조회하는 순간 DB에서 락을 걸고, endCall() 처리 후 커밋될 때 락이 풀린다.
여기서 중요한 점은 “락을 건 조회”와 “상태 변경·저장”이 반드시 같은 트랜잭션 안에서 일어나야 한다는 것이다. 트랜잭션 경계를 나누면 락이 조기 해제되거나, 반대로 불필요하게 오래 잡힐 수 있다. 이 경계를 결정하는 것이 트랜잭션 전파(Propagation) 이다.
이 글에서는 전파 옵션을 간단히 정리하고, 비관적 락과 연결해 같은 트랜잭션 안에서 락이 어떻게 유지되는지까지 써 본다.
1. 트랜잭션 전파(Propagation)란?
“이 메서드를 호출했을 때, 기존 트랜잭션에 참여할지, 새 트랜잭션을 시작할지, 트랜잭션 없이 실행할지를 결정하는 규칙” 이다.
- 트랜잭션이 이미 있으면: 그 트랜잭션에 참여할 수도 있고, 무시하고 새 트랜잭션을 시작할 수도 있다.
- 트랜잭션이 없으면: 새 트랜잭션을 시작할 수도 있고, 트랜잭션 없이 실행할 수도 있다.
Spring은 @Transactional(propagation = Propagation.XXX) 로 이 동작을 지정한다. 기본값은 REQUIRED 이다.
2. Spring의 전파 옵션
| 전파 옵션 | 기존 트랜잭션 있을 때 | 기존 트랜잭션 없을 때 |
|---|---|---|
| REQUIRED (기본값) | 기존 트랜잭션에 참여 | 새 트랜잭션 시작 |
| REQUIRES_NEW | 기존 트랜잭션 일시 중단, 새 트랜잭션 시작 | 새 트랜잭션 시작 |
| NESTED | 기존 트랜잭션에 중첩( savepoint ) | 새 트랜잭션 시작 |
| MANDATORY | 기존 트랜잭션에 참여 | 예외 (트랜잭션 필수) |
| SUPPORTS | 기존 트랜잭션에 참여 | 트랜잭션 없이 실행 |
| NOT_SUPPORTED | 기존 트랜잭션 일시 중단, 트랜잭션 없이 실행 | 트랜잭션 없이 실행 |
| NEVER | 예외 (트랜잭션 금지) | 트랜잭션 없이 실행 |
비관적 락과 연결해 자주 언급되는 것은 REQUIRED 와 REQUIRES_NEW 이다.
- REQUIRED: 호출한 쪽과 같은 트랜잭션 → 락을 건 조회와 그 뒤의 작업이 한 트랜잭션으로 묶인다.
- REQUIRES_NEW: 새 트랜잭션 → 락을 건 조회를 한 트랜잭션, 나머지 작업을 다른 트랜잭션으로 나누면, 첫 트랜잭션이 끝나는 순간 락이 풀려서 동시성 보장이 깨질 수 있다.
3. 비관적 락과 트랜잭션 경계
비관적 락(PESSIMISTIC_WRITE)은 DB가 한 트랜잭션에 대해 row(또는 페이지) 단위로 락을 건다.
- 락을 거는 시점:
findByIdWithLock()처럼 락이 걸린 조회를 실행한 트랜잭션이 락을 소유한다. - 락이 풀리는 시점: 그 트랜잭션이 커밋 또는 롤백될 때까지 유지되고, 커밋/롤백 시점에 해제된다.
그래서:
- 락을 건 조회와 그 row를 수정·저장하는 작업이 같은 트랜잭션 안에 있으면 → 락이 커밋 전까지 유지되어 동시성 제어가 안전하다.
- 락을 건 조회만 한 트랜잭션을 먼저 커밋하고, 수정·저장은 다른 트랜잭션에서 하면 → 첫 트랜잭션 커밋 시 락이 풀리므로, 그 사이 다른 트랜잭션이 같은 row를 수정할 수 있어 동시성 문제가 다시 생길 수 있다.
즉, 비관적 락의 유효 범위 = 트랜잭션 경계 라고 보면 된다.
4. 같은 트랜잭션 안에서 락이 유지되는 이유
친구하자 통화 종료 로직을 단순화하면 다음과 같다.
@Transactional // 기본값: propagation = REQUIRED
public CallStatusResponse endCall(Long callId, Long userId) {
// ① 이 트랜잭션에서 락 획득
Call call = callRepository.findByIdWithLock(callId).orElseThrow();
if (call.getCallStatus() == CallStatus.COMPLETED) {
return createResponse(call);
}
// ② 같은 트랜잭션 안에서 상태 변경·저장
callService.endCall(callId);
// ... Recording 중지, Session 업데이트 등
return createResponse(call);
// ③ 메서드 정상 종료 시 이 트랜잭션 커밋 → 그때 락 해제
}
endCall()진입 시 트랜잭션 1개가 시작되고(또는 상위에서 이미 시작된 트랜잭션에 참여하고),- ①에서 그 트랜잭션이
findByIdWithLock()으로 해당Callrow에 대한 락을 건다. - ②의
callService.endCall(),save()등은 같은 트랜잭션 안에서 실행되므로, 락이 풀리지 않는다. - ③에서 메서드가 정상 종료되면 그 트랜잭션이 커밋되고, 그 시점에 락이 해제된다.
정리하면:
- 같은 트랜잭션 = 락을 건 시점부터 커밋(또는 롤백) 시점까지 동일한 DB 연결·트랜잭션 컨텍스트를 쓰는 구간이다.
- 비관적 락은 그 트랜잭션에 묶여 있기 때문에, 같은 트랜잭션 안에서는 락이 유지되고, 다른 트랜잭션은 그 row에 대한 락이 풀릴 때까지 대기하게 된다.
5. 전파를 잘못 쓰면? (REQUIRES_NEW 사례)
만약 “락만 걸어두는 메서드”와 “실제 종료 처리 메서드”를 나누고, 내부에서 REQUIRES_NEW 를 쓰면 어떻게 되는지 보자.
위험한 예시 (개념용)
// ❌ 이렇게 나누면 안 됨
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Call getCallWithLock(Long callId) {
return callRepository.findByIdWithLock(callId).orElseThrow();
// 이 메서드가 끝나면 REQUIRES_NEW 트랜잭션이 커밋 → 락 해제!
}
@Transactional
public CallStatusResponse endCall(Long callId, Long userId) {
Call call = getCallWithLock(callId); // 새 트랜잭션 → 여기서 끝나면 락 해제
// 이 시점부터는 이미 락이 풀린 상태
if (call.getCallStatus() == CallStatus.COMPLETED) {
return createResponse(call);
}
callService.endCall(callId); // 락 없이 진행 → 동시 요청 시 중복 처리 가능
return createResponse(call);
}
getCallWithLock()은 REQUIRES_NEW 이므로 새 트랜잭션에서 실행되고, 메서드가 return 되는 순간 그 트랜잭션이 커밋되며 락이 해제된다.- 그 다음
endCall()의 나머지 로직은 원래 트랜잭션에서 실행되는데, 이미 락은 풀린 상태라 다른 스레드가 같은Call을 수정할 수 있다. - 결과적으로 같은 트랜잭션 안에서 락이 유지되지 않아 비관적 락의 효과가 사라진다.
그래서 비관적 락을 쓸 때는:
- 락을 거는 조회와 그 row를 수정·저장하는 모든 작업을 하나의 트랜잭션(REQUIRED) 안에 두어야 하고,
- 그 트랜잭션이 커밋(또는 롤백)될 때까지 락이 유지되도록 설계하는 것이 맞다.
6. 친구하자 endCall에서의 적용
실제 친구하자 코드에서는:
endCall()하나에@Transactional(기본 REQUIRED)만 걸고,- 그 안에서
findByIdWithLock()→ 상태 검사 →callService.endCall()→ 응답 생성까지 전부 같은 트랜잭션에서 수행한다.
그래서:
- 락 범위가 명확함: 락 획득부터 커밋까지 한 트랜잭션이므로, 그 사이 다른 트랜잭션은 해당
Callrow에서 대기한다. - 전파 설정을 따로 안 해도 됨: 기본값 REQUIRED로, 상위에 트랜잭션이 없으면 여기서 하나 시작하고, 있으면 참여하므로 추가 설정이 필요 없다.
- REQUIRES_NEW를 쓰지 않음: 통화 종료처럼 “한 번에 원자적으로 처리해야 하는” 흐름은 락 구간과 수정 구간을 나누지 않는 것이 안전하다.
정리하면, “같은 트랜잭션 안에서 락이 유지된다” 는 점을 지키기 위해, 비관적 락을 사용하는 메서드는 한 트랜잭션(REQUIRED) 으로 묶고, 그 안에서 조회(락) → 비즈니스 로직 → 저장까지 처리하도록 한 것이다.
7. 요약
| 항목 | 내용 |
|---|---|
| 트랜잭션 전파 | 메서드가 기존 트랜잭션에 참여할지, 새 트랜잭션을 쓸지 결정하는 규칙. 기본값은 REQUIRED. |
| 비관적 락과 트랜잭션 | 락은 트랜잭션 단위로 유지된다. 락을 건 트랜잭션이 커밋/롤백될 때 락이 해제된다. |
| 같은 트랜잭션 안에서 락 유지 | findByIdWithLock() 과 그 뒤의 수정·저장을 한 트랜잭션(REQUIRED) 에 두면, 커밋 전까지 락이 유지되어 동시성 제어가 안전하다. |
| 주의할 점 | 락을 거는 부분만 REQUIRES_NEW 등으로 별도 트랜잭션으로 나누면, 그 트랜잭션이 끝날 때 락이 풀려 동시성 보장이 깨질 수 있다. |
| 친구하자 endCall | @Transactional 하나로 전체를 묶고, 락 조회 ~ 종료 처리까지 같은 트랜잭션에서 수행하도록 설계했다. |
비관적 락 적용 시 “어디서 트랜잭션이 시작되고 끝나는지” 를 의식하고, 락을 건 구간과 수정 구간이 같은 트랜잭션에 포함되도록 전파를 두면, “같은 트랜잭션 안에서 락이 유지되는” 동작을 기대한 대로 얻을 수 있다.