주요 비즈니스 로직에 분산 락을 적용하면서...
튜터님께서 이렇게 고치는 이유가 뭐냐고 여쭤보셨다
TX 시작 -> Lock 획득 -> 비즈니스로직 -> Lock 해제 -> TX commit 이 이루어지면
Lock 해제와 TX commit 사이에 빈 공간이 생기기 때문에 해제된 락을 다른 스레드가 잡아서 읽어버리면 TX commit 전이기 때문에 바뀌기 전의 정보를 읽는다.... 라고 대답했는데
그걸 직접 확인을 해봤냐고 여쭤보셔서... 아니라구 함
그냥 널리 알려져있는 문제라 내가 꼭 확인을 해야하나? 라는 생각도 들었다
그래도 테스트 하기 ....
테스트 1
확인 목적: 실제로 락이 트랜잭션 안쪽이면 문제가 생기는가?
안티패턴 시나리오
| [ANTI-PATTERN] A: @Transactional 시작 → 락 획득 → expire() → 락 해제 → sleep(후속 로직) → commit B: ↑ 이 시점에 깨어남 → findById → PENDING 읽음 (A 아직 commit 전) → cancel() → commit (v=1) A: → commit 시도 → WHERE version=0 → 0 rows → OptimisticLockException |
운영 패턴 시나리오
| [FIXED] A: 락 획득 → (tx 시작 → expire() → commit) → 락 해제 B: ↑ 이 시점에야 락 획득 가능 → findById → EXPIRED 읽음 → cancel 거부 (상태 가드) |
CountDownLatch 로 A 가 락 해제한 직후, commit 전 시점을 B 의 시작시점으로 잡음
시작시점까지 Sleep 으로 잡으니까 계속 애매하게 되다가 안 되다가 함....
LockTransactoinRaceConditionTest.java
@SpringBootTest
@ActiveProfiles("test")
class LockTransactionRaceConditionTest {
private static final String LOCK_INSIDE_TX_KEY_PREFIX = "lock:inside-tx:";
private static final String CORRECT_LOCK_KEY_PREFIX = "lock:reservation:";
@BeforeEach
void setup() {
// reservation 생성, reservation Id 가져옴
}
@AfterEach
void cleanup() {
// lock key 만들어진거 delete, reservationRepository 도 비워줌
}
@Test
@DisplayName("[ANTI-PATTERN] 락이 트랜잭션 안쪽 — B는 stale PENDING으로 cancel commit, A의 expire는 @Version에 막혀 손실됨")
void race_condition_when_lock_inside_transaction() throws Exception {
CountDownLatch aReleasedLock = new CountDownLatch(1);
AtomicReference<Throwable> aError = new AtomicReference<>();
AtomicReference<Throwable> bError = new AtomicReference<>();
AtomicBoolean bCommitted = new AtomicBoolean(false);
// A: expire (락 해제 후 sleep -> 나중에 commit 시도)
Thread tA = new Thread(() -> {
try {
lockInsideTxExpireService.expireWithLockInside(reservationId, aReleasedLock);
} catch (Throwable t) {
aError.set(t);
}
});
// B: A 가 락 해제하자마자 -> stale PENDING 읽고 cancel commit
Thread tB = new Thread(() -> {
try {
aReleasedLock.await();
cancelService.cancel(reservationId);
bCommitted.set(true); // B 입장에선 정상 종료
} catch (Throwable t) {
bError.set(t);
}
});
tA.start();
tB.start();
tA.join();
tB.join();
// (1) B 는 PENDING 을 읽고 cancel 까지 commit 했다 — 락 직렬화 실패의 직접 증거
assertThat(bError.get()).isNull();
assertThat(bCommitted.get()).isTrue();
// (2) A 의 commit 은 @Version 이 막아서 OptimisticLockException
// == race 가 @Version 까지 도달했다 == 분산 락만으론 못 막은 케이스
assertThat(aError.get()).isInstanceOf(ObjectOptimisticLockingFailureException.class);
// (3) 최종 DB 는 B 의 cancel 이 살아남고, A 의 expire 는 흔적 없음
Reservation saved = reservationRepository.findById(reservationId).orElseThrow();
assertThat(saved.getStatus()).isEqualTo(ReservationStatus.CANCELED);
assertThat(saved.getCanceledBy()).isNotNull();
assertThat(saved.getCanceledAt()).isNotNull();
assertThat(saved.getCancelReason()).isNotNull();
assertThat(saved.getExpiredAt()).isNull();
}
@Test
@DisplayName("[FIXED] 락이 트랜잭션 바깥 — B는 EXPIRED를 읽고 취소 자체가 거부된다")
void no_race_when_lock_outside_transaction() throws Exception {
CountDownLatch aAcquiredLock = new CountDownLatch(1);
AtomicReference<Throwable> bError = new AtomicReference<>();
AtomicReference<Throwable> error = new AtomicReference<>();
// A: 락 획득 -> expireOne (commit) -> 락 해제
Thread tA = new Thread(() -> {
try {
reservationLockService.executeWithReservationLock(reservationId, () -> {
aAcquiredLock.countDown();
reservationExpireService.expireOne(reservationId);
return null;
});
} catch (Throwable t) {
error.set(t);
}
});
// B: A 락 해제 후 락 획득 -> 이미 EXPIRED -> cancel 거부
Thread tB = new Thread(() -> {
try {
aAcquiredLock.await();
acquireLockAndCancel(reservationId);
} catch (Throwable t) {
bError.set(t); // 이건 기대하는 예외
}
});
tA.start();
tB.start();
tA.join();
tB.join();
assertThat(error.get()).isNull(); // A는 정상
assertThat(bError.get())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("락 획득 타임아웃");
Reservation saved = reservationRepository.findById(reservationId).orElseThrow();
assertThat(saved.getStatus()).isEqualTo(ReservationStatus.EXPIRED);
// 데이터가 오염되지 않았다
assertThat(saved.getCanceledBy()).isNull();
assertThat(saved.getCanceledAt()).isNull();
assertThat(saved.getCancelReason()).isNull();
}
private void acquireLockAndCancel(Long id) throws InterruptedException {
long deadline = System.currentTimeMillis() + 3_000;
while (System.currentTimeMillis() < deadline) {
try {
reservationLockService.executeWithReservationLock(id,
() -> cancelService.cancel(id));
return;
} catch (ReservationException e) {
// 락 획득 실패 (A가 아직 안 풀었음) → 재시도
Thread.sleep(10);
}
// ReservationException 이 아닌 예외 (INVALID_STATUS 등) 는 위로 전파
}
throw new IllegalStateException("락 획득 타임아웃");
}
// ─────────────────────────────────────────────────────────────────
// 안티패턴 테스트 전용 빈
// ─────────────────────────────────────────────────────────────────
// 많은 생략
static class LockInsideTxExpireService {
@Transactional
public void expireWithLockInside(Long id, CountDownLatch lockReleasedSignal) {
//@Transactional 메서드 내부에서 락을 잡고/해제
}
static class LockOutsideReader {
// 락 바깥에서 호출 - 자체 read-only 트랜잭션을 시작해서 commit 된 값 읽기
}
static class CancelService {
@Transactional
public Void cancel(Long id) {
// 예약 취소 로직
}
}
}
결과
B 는 PENDING 을 읽고 cancel commit 까지 성공
A 의 expire 는 @Version 에 의해 ObjectOptimisticLockingFailureException 으로 손실
즉 환불 처리, Proposal 복구 같은 후속 작업이 싹 다 누락될 수 있음
테스트 2
확인 목적: 락이 트랜잭션 안쪽이면 커넥션 자원도 낭비되는가?
케이스 A
| @Transactional 진입 → (DB 접근 X) → tryLock 실패 → rollback → 커넥션 획득 안 됨 → 낭비 0 |
케이스 B
| @Transactional 진입 → findById → tryLock 실패 → rollback → 커넥션 점유 + 무의미한 rollback 비용 발생 |
TransactionConnectionWasteTest.java
@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource(properties = {
// lazy connection acquisition 을 진짜로 동작시키기 위한 조합
"spring.datasource.hikari.auto-commit=false",
"spring.jpa.properties.hibernate.connection.provider_disables_autocommit=true"
})
class TransactionConnectionWasteTest {
@BeforeEach
void setup() {
// reservation 과 reservation id 초기화
}
@AfterEach
void cleanup() {
// repository 비움
}
@Test
@DisplayName("[A] @Transactional 진입만으론 커넥션 미획득 — DB 접근 전 락 실패 = 낭비 0")
void connection_not_acquired_when_lock_fails_before_db_access() {
// given
HikariPoolMXBean pool = hikariDataSource.getHikariPoolMXBean();
int baseline = pool.getActiveConnections();
AtomicInteger activeAtFailure = new AtomicInteger(-1);
// when - DB 접근 없이 락 실패 시뮬레이트
assertThatThrownBy(() -> probe.failLockWithoutDbAccess(activeAtFailure))
.isInstanceOf(SimulatedLockFailure.class);
// then
// (1) 락 실패 직전, 트랜잭션은 진입했지만 커넥션은 아직 안 잡혀 있었음
assertThat(activeAtFailure.get())
.as("DB 접근 전이면 @Transactional 진입만으론 커넥션 획득 X")
.isEqualTo(baseline);
// (2) 사후에도 baseline 유지
assertThat(pool.getActiveConnections()).isEqualTo(baseline);
}
@Test
@DisplayName("[B] DB 조회 후 락 실패 — 커넥션 점유 + 무의미한 rollback (낭비 발생)")
void connection_acquired_and_wasted_when_lock_fails_after_db_access() {
// given
HikariPoolMXBean pool = hikariDataSource.getHikariPoolMXBean();
int baseline = pool.getActiveConnections();
AtomicInteger activeAtFailure = new AtomicInteger(-1);
// when - findById 후 락 실패 시뮬레이트
assertThatThrownBy(() -> probe.failLockAfterDbAccess(activeAtFailure, reservationId))
.isInstanceOf(SimulatedLockFailure.class);
// then
// (1) 락 실패 시점에 커넥션이 이미 점유 중 -> rollback 비용까지 그대로 낭비됨
assertThat(activeAtFailure.get())
.as("DB 접근 후엔 커넥션 점유. 락 실패해도 rollback 까지 못 풀어줌")
.isEqualTo(baseline + 1);
// (2) rollback 종료 시점엔 풀로 반환됨
assertThat(pool.getActiveConnections()).isEqualTo(baseline);
}
// ────────────────────────────────────────────────────────────
// 테스트 전용 빈 + 시뮬레이션 예외
// ────────────────────────────────────────────────────────────
static class SimulatedLockFailure extends RuntimeException {
// 실제 Redis 락 실패 대신 던지는 예외
}
@TestConfiguration
static class Config {
@Bean
ConnectionWasteProbeService connectionWasteProbeService(
ReservationRepository repo, HikariDataSource ds) {
return new ConnectionWasteProbeService(repo, ds);
}
}
static class ConnectionWasteProbeService {
@Transactional
public void failLockWithoutDbAccess(AtomicInteger activeRecorder) {
// Case A: 트랜잭션 진입 -> DB 접근 X -> 락 실패
}
@Transactional
public void failLockAfterDbAccess(AtomicInteger activeRecorder, Long id) {
// Case B: 트랜잭션 진입 -> findById -> 락 실패
}
}
}
실제 Redis 호출 대신 SimulatedLockFailure 로 lock 실패를 시뮬레이션, HikariPoolMXBean.getActiveConnections() 로 커넥션 수 측정
트러블슈팅: https://kjw81024.tistory.com/102
| 케이스 | 락 실패 지점 | 커넥션 상태 |
| A : DB 접근 전 | try Lock 실패 | 미점유 - 낭비 0 |
| B : DB 접근 후 | try Lock 실패 | 점유중 (roll back 까지 낭비) |
테스트 3
확인 목적: 분산 락이 뚫렸을 때 @Version 이 단독으로 정합성을 보호하는가?
시나리오 — 락 없이 두 트랜잭션이 동시에 같은 reservation 을 confirm
| A: tx 시작 → read (v=0) → [barrier, 둘 다 read 완료 동기화] → confirm → commit (v=1) B: tx 시작 → read (v=0) → [barrier] → A commit 대기 → confirm → commit 시도 → UPDATE ... WHERE version=0 → 0 rows → StaleObjectStateException → Spring 이 ObjectOptimisticLockingFailureException 으로 래핑 |
CyclicBarrier 로 두 스레드가 같은 v=0 을 읽은 시점을 동기화, CountDownLatch 로 A commit 이후에만 B 가 write 하도록 순서 고정
OptimisticLockFallbackTest.java
@SpringBootTest
@ActiveProfiles("test")
class OptimisticLockFallbackTest {
@BeforeEach
void setup() {
// rservation, reservationId 초기화
}
@AfterEach
void cleanup() {
// repository 클린
}
@Test
@DisplayName("[FALLBACK] 동시에 confirm 시도 — @Version 이 늦은 쪽을 OptimisticLock 으로 막는다")
void optimistic_lock_blocks_second_writer() throws Exception {
// given
CyclicBarrier bothRead = new CyclicBarrier(2); // 두 스레드가 read 까지 완료한 시점에 동기화
CountDownLatch aCommitted = new CountDownLatch(1); // A 의 commit 후 B 가 깨어남
AtomicReference<Throwable> aError = new AtomicReference<>();
AtomicReference<Throwable> bError = new AtomicReference<>();
// when
Thread tA = new Thread(() -> {
try {
probe.readBarrierConfirm(reservationId, bothRead);
} catch (Throwable t) {
aError.set(t);
} finally {
aCommitted.countDown(); // A 의 @Transactional 종료(commit/rollback) 이후에 시그널
}
});
Thread tB = new Thread(() -> {
try {
probe.readBarrierWaitConfirm(reservationId, bothRead, aCommitted);
} catch (Throwable t) {
bError.set(t);
}
});
tA.start();
tB.start();
tA.join();
tB.join();
// then
// (1) A 는 성공
assertThat(aError.get())
.as("먼저 commit 한 A 는 정상 종료")
.isNull();
// (2) B 는 OptimisticLockException 으로 막힘
assertThat(bError.get())
.as("늦게 commit 시도한 B 는 version 불일치로 막힘")
.isInstanceOf(ObjectOptimisticLockingFailureException.class);
// (3) 최종 상태는 A 가 쓴 것 그대로, version 도 정확히 +1
Reservation finalState = reservationRepository.findById(reservationId).orElseThrow();
assertThat(finalState.getStatus()).isEqualTo(ReservationStatus.CONFIRMED);
assertThat(finalState.getVersion()).isEqualTo(1L);
}
// ─────────────────────────────────────────────────────────────────
// 테스트 전용 빈
// ─────────────────────────────────────────────────────────────────
@TestConfiguration
static class Config {
@Bean
OptimisticLockProbe optimisticLockProbe(ReservationRepository repo) {
return new OptimisticLockProbe(repo);
}
}
static class OptimisticLockProbe {
@Transactional
public void readBarrierConfirm(Long id, CyclicBarrier bothRead) throws Exception {
// read → 둘 다 read 한 시점 동기화 → confirm → (메서드 종료 시 commit)
}
@Transactional
public void readBarrierWaitConfirm(Long id, CyclicBarrier bothRead,
CountDownLatch aCommitted) throws Exception {
// B: read → 동기화 → A 의 commit 이 끝날 때까지 대기 → confirm → (메서드 종료 시 commit 실패)
}
}
}
결과
A: 정상 commit, status = CONFIRMED, version = 1
B: ObjectOptimisticLockingFailureException 으로 차단
최종 DB: A 의 write 만 반영, version = 1 정확히 일치
결론
해결 - 락을 트랜잭션 바깥으로
운영 코드에서 경계를 뒤집는 두 가지 방법
(a) Service 분리
ReservationLockService.executeWithReservationLock(id, () -> {
reservationExpireService.expireOne(id); // @Transactional → 이 안에서 commit 까지
});
// executeWithReservationLock 의 finally 에서 락 해제
// 즉 락 해제 시점 = commit 이후
(b) AOP — @DistributedLock + @Order(HIGHEST_PRECEDENCE)
@DistributedLock(key = "'reservation:' + #reservationId")
@Transactional
public void someMethod(Long reservationId) { ... }
@Order(HIGHEST_PRECEDENCE) 덕분에 락 Advice 가 트랜잭션 Advice 보다 바깥에서 감싼다. 두 방법의 효과는 같음
추가 - 락이 뚫려도 @Version 이 마지막 방어선
분산 락은 강력하지만 절대적이지 않다. TTL 만료, 락 키 누수, 신규 코드의 락 우회 등으로 두 트랜잭션이 동시에 진입할 수 있다.
그래서 Reservation 엔티티에 @Version 을 추가했다.
@Version
private Long version;
Hibernate 는 UPDATE 시 WHERE id=? AND version=? 를 붙인다. 먼저 commit 한 쪽이 version 을 올리면, 나중 쪽의 UPDATE 는 0 rows 가 되어 ObjectOptimisticLockingFailureException 으로 막힌다.
정리 - 2중 방어선
| 방어선 | 수단 | 역할 |
| 1차 | 분산 락 (락이 트랜잭션 바깥) | 대부분의 경합을 직렬화. race 자체를 차단 |
| 2차 | @Version 낙관락 | 락이 어떻게든 뚫렸을 때 정합성 보호 |
락을 트랜잭션 안에 두면 1차 방어선이 제 역할을 못 하고, 결국 2차 방어선까지 race 가 도달한다. 2차 방어선은 "막아주기는 하지만 A 의 작업이 손실된다" 는 부작용이 있다. 1차에서 제대로 막혀야 2차가 보조 역할만 하게 된다.
정리
진행한 테스트는 다음과 같다
테스트 1 : LockTransactionRaceCondition (정합성 관점 - 후속 비즈니스 로직 복구 누락 위험)
테스트 2 : OptimisticLockFallback (정합성 관점 - 2차 방어선)
테스트 3 : TransactionConnectionWaste (자원 관점 - 커넥션 낭비 + Hikari autoCommit 함정)
| 테스트 | 질문 | 결론 |
| 1 [anti] | 락이 안쪽이면 실제로 race 가 생기는가? | stale read → @Version 까지 닿음 → expire 손실 |
| 1 [fixed] | 락을 바깥으로 빼면 race 가 차단되는가? | B 가 락을 잡는 순간 이미 commit 완료 → race 없음 |
| 2 | 락이 뚫려도 @Version 이 혼자 막을 수 있는가? | 늦은 commit 을 OptimisticLockException 으로 차단 |
| 3 [A] | DB 접근 전 락 실패는 커넥션을 낭비하는가? | 낭비 없음 |
| 3 [B] | DB 접근 후 락 실패는 커넥션을 낭비하는가? | 점유 + rollback 비용 낭비 |
정합성 관점과 자원관점 전체에서 봤을 때 락은 반드시 트랜잭션 바깥에 있어야 함
구현방법 1: ReservationLockService 분리, 2: @DistributedLock + @Order
그래도 뚫리는 극단적인 케이스는 @Version 이 마지막 방어선으로 막아준다
회고
코드를 먼저 고쳐놓고 문제를 증명하는 순서가 됐는데 (튜터님이 말해서 그제서야 테스트해봄...) ..,, 오히려 덕분에 왜 이 구조가 맞는지에 대해서 더 정확하게 설명할 수 있게 됨