이번 스텝의 과제는 별로 어려울게 없었다. 크게 오래 걸리는 문제도 아니었고
하지만 나 스스로는 이러한 동시성 문제가 발생할 것이라고 상상하기가 어려웠다.
이 경험을 토대로 앞으로의 프로젝트에 동시성 테스트를 만들어봐야겠다.
https://github.com/goochul-im/HH-Lite-Architecture-Reservation-Service/pull/4
요구 사항
1. 중복 예약이 발생하지 않도록 코드 수정하고 동시성 테스트
2. 결제가 중복으로 발생하여 음수가 발생하는 문제를 코드를 수정하고 테스트
3. 예약 후 결제 지연
일단 3번은 Redis를 사용할 때 TTL을 외부에서 주입받아 테스트를 만든 부분이 있다. 그래서 이 부분은 일단 패스
그리고 2번은 결제 버튼을 '따닥!!' 하고 빠르게 여러번 누를 때 발생할 것 같다. 그런데 여러 스레드가 하나의 예약에 대해 결제를 시도하려고 할 때, 음수가 발생하는 경우가... 자주 나타날까? 잘 모르겠다.
왜냐면 각 스레드가 유저의 잔액을 차감하려면 우선 현재의 잔액을 가져와서 확인한 후에 가져와야 할 것인데, 다른 스레드가 차감한 후에 잔액을 가져오는 경우가 아니면 잔액이 두번 차감되는 경우는 없을 것이다.
하지만 동일한 예약이 아니라, 여러 예약을 동시에 결제하는 경우라면 차감이 두 번 되어야 하는데 나중에 차감된 잔액이 DB에 덮어씌워질 것이기에 문제가 될수 있을 것이다.
중복 예약 방지
일단 내가 작성한 프로젝트는 모든 날짜에 콘서트가 하나씩 있고, 각 콘서트마다 1번부터 50번까지의 자리가 있다.
하지만 엔티티는 단 두개다. '예약'과 '멤버'. 이거 두개만으로 모든 로직을 처리하고 있기 때문에 콘서트의 자리에 락을 걸 수가 없었다.
그래서 생각한 부분은 예약 자체에 락을 걸어버리는 것이다.
예약에는 세가지 상태가 있을 수 있다.
enum class ReservationStatus{
PENDING, RESERVE, CANCEL
}
이렇게 임시 예약, 예약 완료, 예약 취소
그러면 임시 예약과 예약 완료는 같은 날짜에 같은 자리에 동시에 있을 수 없다.
예약 취소는 머 여러개 있을 수 있겠지
그래서 유니크 제약 조건을 < 날짜, 자리 번호, 예약 상태 [임시 예약, 예약 완료] > 이렇게 두개에 걸면 될 것 같다.
임시 예약과 예약 완료는 IN 으로 처리해서 같은 것으로 묶어야만 한다. 그래야 예약 완료가 있을 때 임시 예약이 들어오지 않을 테니까
하지만 JPA에서는 이렇게 복잡하게는 유니크 제약 조건을 걸 수가 없다고 한다. 그러면 DB에 직접 걸어줘야 한다.
CREATE UNIQUE INDEX uk_active_reservation ON reservation (reservation_date, seat_num,
(CASE WHEN reservation_status in ('RESERVE', 'PENDING') THEN reservation_status ELSE NULL END));
나는 mysql을 쓰고 있기 때문에 이렇게 만들어줬다.
이렇게 유니크 제약 조건을 걸고, 코드를 수정했다.
기존에는 예약을 만들 때 PENDING 상태의 예약을 단순히 save 하기만 했다.
val reservation = Reservation(
date = dto.date,
seatNumber = dto.seatNumber,
status = ReservationStatus.PENDING,
reserver = memberRepository.findById(dto.memberId)
)
val save : Reservation = reservationRepository.save(reservation)
하지만 이제 유니크 제약 조건을 걸어두었기 때문에 save 시에 이미 예약이 되어있다면 예외를 catch 해야 한다.
이 예외는 DB에서 무결성 제약 조건을 위반하여 발생하는 예외기 때문에 DataIntegrityViolationException 을 catch해야 한다
val save : Reservation = try {
reservationRepository.save(reservation)
} catch (e: DataIntegrityViolationException) {
throw DuplicateResourceException("이미 예약되어있는 좌석입니다.")
}
그래서 이렇게 만들어두었다. 그러면 여러 스레드가 동시에 하나의 예약에 접근하려고 해도 먼저 예약된 자리가 있으면 예외를 발생시키고, 이를 DuplicatResourceException으로 래핑해서 던지면 ControllerAdvice가 이를 다시 잡아서 적절한 응답으로 돌려줄 것이다.
테스트 코드는 다음과 같다.
@Test
fun `동시에 30명이 같은 날짜 같은 좌석을 예약하면 1명만 성공해야 한다`() {
// given
val threadCount = 30
val executorService = Executors.newFixedThreadPool(threadCount)
val latch = CountDownLatch(threadCount)
val date = LocalDate.of(2025, 12, 25)
val seatNumber = 10
val memberIds = (1..threadCount).map {
val member = Member(
username = "tester$it",
password = "testerPassword"
)
memberRepository.save(member).id!!
}
val successCount = AtomicInteger(0)
val failCount = AtomicInteger(0)
// when
for (i in 0 until threadCount) {
val memberId = memberIds[i]
executorService.submit {
try {
reservationService.make(
ReservationRequest(
date = date,
seatNumber = seatNumber,
memberId = memberId
)
)
successCount.incrementAndGet()
} catch (e: Exception) {
failCount.incrementAndGet()
} finally {
latch.countDown()
}
}
}
latch.await()
// then
val reservations = reservationJpaRepository.findAll()
println("시도 횟수: $threadCount")
println("성공 횟수: ${successCount.get()}")
println("실패 횟수: ${failCount.get()}")
println("DB 저장된 예약 수: ${reservations.size}")
assertThat(reservations.size).isEqualTo(1)
}
처음보는 함수들이 많다 !! 하나씩 알아보자.
ExecutorService
newFixedThreadPool 은 인자로 받은 숫자만큼 미리 스레드를 만들어놓고 관리하는 풀을 만들어준다.
submit은 만들어둔 풀에서 스레드를 꺼내서 작업을 건네준다. 위 코드에서는 submit에서 람다 함수를 바로 사용하는 걸 볼 수 있다.
CountDownLatch
먼저 await 함수는 메인 스레드를 이 부분에서 잠그는 역할을 한다. 더이상 메인 스레드의 흐름이 진행되지 않도록 하는 것이다.
그러면 언제까지 잠그냐? CountDownLatch의 생성자에서 넘겨받은 숫자만큼 신호를 받을 때까지 잠근다.
그러면 신호는 어떻게 보내지? countDown 으로 이 latch에 신호를 보내주는 것이다.
위에서 submit 에 넘겨준 람다 함수를 보면 finally 블럭에서 countDown 함수를 호출하는 것이 보일 것이다.
그러면 다시 돌아와서 테스트가 통과할줄 알았는데?? 안됐다. 위에서 succesCount가 30으로 나왔다. 전부다 성공했다는 뜻이다.
reservation.size 도 30이다!! 왜이러지? 했는데...
다시 보니까 테스트에서는 테스트 전용 mysql을 따로 쓰고 있었다. 그래서 테스트 DB에는 유니크 제약조건이 걸리지 않아 실패했던 것이다. 그래서 위 제약조건을 그대로 작성 sql 파일을 만들고, 테스트 클래스의 상단에 @Sql 어노테이션을 걸어 주었다.
@Sql(scripts = ["/test-index-setup.sql"], executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
BEFORE_TEST_CLASS로 걸면 테스트 클래스 시작 전에 단 한번만 이 sql 파일을 실행한다. BEFORE_TEST_METHOD로 걸면 각 테스트 메소드가 실행될때마다 테스트 DB에 같은 이름의 유니크 제약조건을 걸기 때문에 에러가 터질 것이다.
이렇게 중복 예약 방지 끝!!
하지만 DB에 직접 이렇게 제약 조건을 거는 것이 좋은것인지 잘 모르겠다. 콘서트나 자리를 엔티티로 만들었으면 거기다가 락을 걸 수 있었을 텐데 말이다.
중복 결제 방지
이제 중복 결제를 방지해보자. 중복 결제가 생기는 경우는 하나의 주문에 대해 결제 버튼을 따닥 하고 눌러서 생길 수도 있을 것 같고, 서로 다른 주문에 대해 하나의 주문이 완료되기 전에 다른 주문이 시작되어서 문제가 생길 수 있을 것이다.
따닥을 막기 위해서는 멱등 키를 받아서 비교하는 전략을 쓴다고 하는데, 이 프로젝트에서는 프론트는 없으니까 멱등 키를 쓸 수는 없을 것 같고 잔액에 대해 낙관적 락을 걸어보자.
물론, 지갑과 같은 테이블은 없고 회원에게 잔액이 존재하므로 회원 테이블에 락을 걸어야 한다.
@Entity
@Table(name = "member")
class MemberEntity(
@Id
@GeneratedValue(strategy = GenerationType.UUID)
val id: String? = null,
@Column(nullable = false)
var point: Int = 0,
@Column
val username: String,
@Column
var password: String,
@Version
var version: Long = 0,
)
그냥 이렇게 @Version 을 달아주면 끝이다.
가 아니라 이 프로젝트는 클린 아키텍처를 적용해서 도메인 모델과 엔티티를 분리하였기 때문에, 도메인 모델에도 version을 달아주어야 한다. 그래야 save 시에 version 정보를 가져가서 변경되었는지 아닌지 확인할수 있기 때문이다.
class Member(
val id: String? = null,
var point: Int = 0,
val username: String,
var password:String,
val version: Long = 0
)
이렇게 만들고 테스트를 작성해주자.
@Test
fun `중복 결제 요청 시 포인트가 한 번만 차감되어야 한다`() {
// given
val threadCount = 5
val executorService = Executors.newFixedThreadPool(threadCount)
val latch = CountDownLatch(threadCount)
// 유저 준비 (포인트 10000원)
var member = memberRepository.save(Member(username = "payTester", password = "pw"))
member.chargePoint(10000)
member = memberRepository.save(member)
val initialPoint = member.point
// 예약 준비
val date = LocalDate.of(2025, 12, 26)
val seatNumber = 15
val reservationEntity = ReservationEntity(
date = date,
seatNumber = seatNumber,
status = ReservationStatus.PENDING,
reserver = kr.hhplus.be.server.member.infrastructure.MemberEntity.from(member)
)
val savedReservation = reservationJpaRepository.save(reservationEntity)
val reservationId = savedReservation.id!!
// Redis 임시 예약 상태 설정 (결제 검증 통과용)
tempReservationPort.save(date, reservationId, seatNumber)
val successCount = AtomicInteger(0)
val failCount = AtomicInteger(0)
// when
for (i in 0 until threadCount) {
executorService.submit {
try {
reservationService.payReservation(reservationId, member.id!!)
successCount.incrementAndGet()
} catch (e: Exception) {
println("Payment failed: ${e.message}")
failCount.incrementAndGet()
} finally {
latch.countDown()
}
}
}
latch.await()
// then
val finalMember = memberRepository.findById(member.id!!)
println("시도 횟수: $threadCount")
println("성공 횟수: ${successCount.get()}")
println("실패 횟수: ${failCount.get()}")
println("초기 포인트: $initialPoint")
println("최종 포인트: ${finalMember.point}")
assertThat(successCount.get()).isEqualTo(1)
assertThat(finalMember.point).isEqualTo(0)
}
중복 예약의 테스트와 비슷하게 만들었다. 하지만 다른 점은 successCount 또한 가져와서 비교해야 한다는 점이다. 왜냐하면 각 스레드가 회원의 잔액을 거의 동시에 가져오기 때문에 전부다 잔액을 차감하고 덮어씌워봤자 음수가 나오는 상황이 없기 때문이다.
이러면 테스트도 완성인데, 이 과제를 구현하면서 약간 껄끄러웠던 것은 도메인의 순수성이 희석되는건 아닌가 하는 점이다.
낙관적 락을 사용하기 위해 도메인 모델에 version 필드를 추가했는데, 이러면 결국 도메인이 RDB를 알게 되는 것 아닌가?
이를 해결하기 위해서 어떻게 해야 하는가? 하고 GEMINI에게 질문했는데... 필드명을 version 말고 도메인 전용으로 만들라는 납득이 가지 않는 답변이 왔다. 대체 어떻게 해야할까??
'항해 Lite' 카테고리의 다른 글
| 분산락과 캐싱으로 트래픽 대응(2) - 과제 리뷰 (0) | 2026.01.06 |
|---|---|
| 분산락과 캐싱으로 트래픽 대응 (1) (0) | 2026.01.01 |
| 동시성 문제(1) (0) | 2025.12.21 |
| 데이터 정합성 확보(4) - 인덱스로 성능 개선 (0) | 2025.12.10 |
| 데이터 정합성 확보(3) - 트러블슈팅 (0) | 2025.12.09 |