[동시성제어] 한 좌석에 100명이 몰리면? - CountDownLatch로 JPA 락 테스트하기

y001·2025년 4월 18일
0
post-thumbnail

1. 왜 동시성 테스트가 중요한가?

예매 시스템은 본질적으로 다중 사용자 요청이 동시에 들어오는 환경을 전제로 설계되어야 한다. 단일 사용자의 테스트로는 '정상 흐름'만을 검증할 수 있을 뿐, 동시 요청 시 발생할 수 있는 Race Condition, 트랜잭션 충돌, 캐시 무효화 타이밍 이슈 등은 감지할 수 없다.

특히 예약 시스템에서는 좌석 1개에 대한 2건 이상의 예약이 동시에 들어오면 안 된다는 것이 중요한 요구사항이다. 이 조건을 만족하려면 DB나 Redis에 락을 걸거나, 중복 검증 로직이 정확하게 작동해야 한다. 따라서 이 글에서는 비관적 락 기반의 로컬 환경 동시성 테스트를 통해 실제 코드가 이 요구사항을 만족하는지 검증한다.


2. 테스트 환경 및 전략 요약

버전방식주요 기술테스트 도구
v2로컬 락JPA @Lock(PESSIMISTIC_WRITE)JUnit + CountDownLatch, invokeAll
v3분산 락Redisson 기반 @DistributedLockK6 부하 테스트 (10편에서 다룸)

이 글에서는 v2: 로컬 비관적 락을 중심으로, 어떻게 테스트를 설계하고, 어떤 방식으로 검증했는지를 집중적으로 설명한다.


3. CountDownLatch를 활용한 정밀 동시성 테스트

@Test
fun testReserveSeatWithLocalLock() {
    val latch = CountDownLatch(1)
    val results = ConcurrentHashMap.newKeySet<String>()

    (1..100).forEach { i ->
        executor.submit {
            latch.await() // 모든 스레드가 대기
            try {
                transactionExecutor.execute {
                    val command = ReserveSeatCommand(userId = i.toLong(), scheduleId = 1, seatId = 1)
                    localLockReservationService.reserve(command)
                    results.add("SUCCESS: $i")
                }
            } catch (e: Exception) {
                results.add("FAILURE: $i (${e.message})")
            }
        }
    }

    latch.countDown() // 모든 스레드 시작
    executor.shutdown()
    executor.awaitTermination(10, TimeUnit.SECONDS)

    println("성공 수: ${results.count { it.startsWith("SUCCESS") }}")
    println("실패 수: ${results.count { it.startsWith("FAILURE") }}")
}

핵심 아이디어

  • 동시에 트랜잭션을 시작할 수 있도록 CountDownLatch로 요청 타이밍을 제어
  • @Lock(PESSIMISTIC_WRITE)가 붙은 JPA Repository를 통해 seat row에 락을 시도
  • 성공은 단 하나, 나머지는 중복 예약 예외가 발생해야 한다

4. invokeAll 방식과의 차이

아래는 같은 테스트를 invokeAll() 방식으로 실행한 예시다.

val tasks = (1..100).map { i ->
    Callable {
        transactionExecutor.execute {
            localLockReservationService.reserve(
                ReserveSeatCommand(userId = i.toLong(), scheduleId = 1, seatId = 1)
            )
        }
    }
}

val futures = executor.invokeAll(tasks)

차이점 비교

방식특징
CountDownLatch모든 스레드가 정확히 동시에 시작 → 정밀한 Race Condition 재현 가능
invokeAll태스크를 순차적으로 제출하고 실행 → 완전한 동시성은 보장되지 않음

즉, 락의 경합이 실제로 발생하는 순간을 확인하고자 한다면 CountDownLatch 방식이 훨씬 유리하다.


5. 로그 기반 결과 분석

정상적으로 락이 동작하는 경우, 아래와 같은 Hibernate 쿼리를 확인할 수 있다:

select se1_0.id ... from seats se1_0 where se1_0.id=? for update

이 쿼리는 seat 테이블의 특정 row를 대상으로 락을 거는 동작을 의미한다. 그 후 insert into reservations가 단 1건만 발생하며, 나머지 요청은 예외를 발생시키고 rollback된다.

[DEBUG] select se1_0.id... where se1_0.id=? for update
[INFO] 예약 성공: userId=37
[WARN] 예약 실패: userId=41 - 이미 예약된 좌석입니다
...

테스트 결과, 단 1건의 성공과 99건의 실패가 발생했으며, 이는 우리가 기대한 시나리오와 정확히 일치했다.

6. 💥 트랜잭션 설계 시 주의할 점 — 테스트 코드에 @Transactional을 붙이면 안 되는 이유

동시성 테스트에서는 종종 다음과 같이 @Transactional을 테스트 클래스 또는 테스트 메서드에 선언하게 된다:

@SpringBootTest
@Transactional
class CountDownLatchTest { ... }

보통 이 어노테이션은 테스트가 끝난 후 자동으로 DB를 롤백시키기 위해 사용된다. 하지만, CountDownLatch나 ExecutorService.invokeAll() 등을 통해 멀티 스레드 환경에서 테스트를 진행할 경우에는 오히려 치명적인 문제가 발생한다.

❗ 어떤 문제가 발생하나?

해당 테스트는 내부적으로 다음과 같은 흐름을 가진다:

  • 테스트 메서드 전체가 하나의 트랜잭션으로 감싸짐
  • 내부 쓰레드는 FunctionalTransactionExecutor.execute { ... } 등을 통해 새로운 트랜잭션을 생성
  • 이때 Spring은 "이미 트랜잭션이 열려있는데, 안에서 또 트랜잭션을 열려고 한다"고 판단
  • 결과적으로 내부 트랜잭션 커밋 시 UnexpectedRollbackException 또는 IllegalStateException 등의 예외 발생

✅ 그럼 어떻게 설계해야 하나?

  • 테스트 클래스나 메서드에는 @Transactional을 제거한다
  • 서브 쓰레드 안에서만 트랜잭션을 명시적으로 시작한다
    (예: transactionExecutor.execute { ... } 형태로 감싼다)
  • 테스트 데이터 롤백은 수동으로 처리하거나, 테스트 환경에서는 임베디드 H2 DB를 매번 새로 초기화해서 대체한다

6. 결론

  • 비관적 락은 존재하는 row에 대해서만 유효하므로, 예약의 기준이 되는 reservation이 아닌 seat row에 락을 걸어야 한다.
  • 동시성 테스트는 invokeAll()보다 CountDownLatch 기반이 더 신뢰성이 높다.
  • 테스트가 설계대로 작동하는지를 검증하기 위해 SQL 로그와 트랜잭션 흐름까지 반드시 분석해야 한다.

0개의 댓글