카테고리 없음

Race Condition 검증 Test 정리

kjw81024 2026. 6. 5. 00:09

주요 비즈니스 로직에 분산 락을 적용하면서... 

튜터님께서 이렇게 고치는 이유가 뭐냐고 여쭤보셨다 

 

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 이 마지막 방어선으로 막아준다 


회고

코드를 먼저 고쳐놓고 문제를 증명하는 순서가 됐는데 (튜터님이 말해서 그제서야 테스트해봄...) ..,, 오히려 덕분에 왜 이 구조가 맞는지에 대해서 더 정확하게 설명할 수 있게 됨