예매 시스템은 본질적으로 다중 사용자 요청이 동시에 들어오는 환경을 전제로 설계되어야 한다. 단일 사용자의 테스트로는 '정상 흐름'만을 검증할 수 있을 뿐, 동시 요청 시 발생할 수 있는 Race Condition, 트랜잭션 충돌, 캐시 무효화 타이밍 이슈 등은 감지할 수 없다.
특히 예약 시스템에서는 좌석 1개에 대한 2건 이상의 예약이 동시에 들어오면 안 된다
는 것이 중요한 요구사항이다. 이 조건을 만족하려면 DB나 Redis에 락을 걸거나, 중복 검증 로직이 정확하게 작동해야 한다. 따라서 이 글에서는 비관적 락 기반의 로컬 환경 동시성 테스트를 통해 실제 코드가 이 요구사항을 만족하는지 검증한다.
버전 | 방식 | 주요 기술 | 테스트 도구 |
---|---|---|---|
v2 | 로컬 락 | JPA @Lock(PESSIMISTIC_WRITE) | JUnit + CountDownLatch, invokeAll |
v3 | 분산 락 | Redisson 기반 @DistributedLock | K6 부하 테스트 (10편에서 다룸) |
이 글에서는 v2: 로컬 비관적 락
을 중심으로, 어떻게 테스트를 설계하고, 어떤 방식으로 검증했는지를 집중적으로 설명한다.
@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에 락을 시도아래는 같은 테스트를 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 방식이 훨씬 유리하다.
정상적으로 락이 동작하는 경우, 아래와 같은 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건의 실패가 발생했으며, 이는 우리가 기대한 시나리오와 정확히 일치했다.
@Transactional
을 붙이면 안 되는 이유동시성 테스트에서는 종종 다음과 같이 @Transactional
을 테스트 클래스 또는 테스트 메서드에 선언하게 된다:
@SpringBootTest
@Transactional
class CountDownLatchTest { ... }
보통 이 어노테이션은 테스트가 끝난 후 자동으로 DB를 롤백시키기 위해 사용된다. 하지만, CountDownLatch나 ExecutorService.invokeAll()
등을 통해 멀티 스레드 환경에서 테스트를 진행할 경우에는 오히려 치명적인 문제가 발생한다.
해당 테스트는 내부적으로 다음과 같은 흐름을 가진다:
FunctionalTransactionExecutor.execute { ... }
등을 통해 새로운 트랜잭션을 생성@Transactional
을 제거한다transactionExecutor.execute { ... }
형태로 감싼다)존재하는 row
에 대해서만 유효하므로, 예약의 기준이 되는 reservation
이 아닌 seat
row에 락을 걸어야 한다.invokeAll()
보다 CountDownLatch
기반이 더 신뢰성이 높다.