<aside> 📄
동시성 테스트 과정에서 발생한 트러블 슈팅입니다.
</aside>
쿠폰 이벤트의 동시성 문제를 처리하기 위해 Lock 전략 중 낙관적 락을 먼저 도입해보기로했다. 처리하기 위해 Event 엔티티에 Version 필드를 추가해주었다.
스크린샷을 첨부하여 어떤 문제가 발생하였는지 작성하자.
@Getter
@Entity
@Table(name = "event")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Event {
.
.
.
@Version
@Column(name = "event_version")
private Long version;
}
}
@Override
@Transactional
public TicketApplyResponseDto applyTicketEvent(Long eventId) {
int maxTry = 3;
for (int i = 0; i < maxTry; i++) {
try {
Event event = getEventOrThrow(eventId);
Member member = getMemberOrThrow();
Point point = member.deductPoint(event.getPerPrice(), eventTarget);
pointRepository.save(point);
event.accumulate(event.getPerPrice());
boolean isWinner = (event.getAccrued() >= event.getGoalPrice());
if (isWinner) {
Seat seat = getSeatOrThrow(event.getSeat().getId());
event.updateStatus(statusProvider.provide(StatusIds.Event.COMPLETED));
Status paidStatus = statusProvider.provide(StatusIds.Reservation.PAID);
Reservation reservation = Reservation.create(
member,
seat.getPerformance(),
paidStatus,
event.getAccrued()
);
reservation.assignSeat(seat);
Status reservedStatus = statusProvider.provide(StatusIds.Seat.RESERVED);
seat.completeReservation(member, reservedStatus, null);
reservationRepository.save(reservation);
}
return TicketApplyResponseDto.from(eventId, member.getId(), isWinner);
} catch (OptimisticLockException e) {
if (i == maxTry - 1) {
throw e;
}
try {
Thread.sleep(5L);
} catch (InterruptedException ex) {
}
}
}
throw new IllegalArgumentException("unreachable");
}
테스트를 진행해보자.
@DisplayName("티켓 이벤트에 여러 사용자가 동시에 응모할 수 있다.")
@Test
void applyTicketEventWithMultipleMembers() throws InterruptedException {
// given
int memberCount = 100;
final long eventId = 6L;
final int perPrice = 10000; // 이벤트 per price
int eventAmount = 100000;
Event event = eventRepository.findById(eventId).orElseThrow();
ExecutorService pool = Executors.newFixedThreadPool(memberCount);
CountDownLatch startGate = new CountDownLatch(1);
CountDownLatch doneGate = new CountDownLatch(memberCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// 각 멤버에게 포인트 적립(2,000)
for (long id = 1; id <= memberCount; id++) {
Member m = memberRepository.findById(id).orElseThrow();
m.addPoint(perPrice);
memberRepository.save(m);
}
// when
for (long id = 1; id <= memberCount; id++) {
final long memberId = id;
pool.submit(() -> {
try {
// 스레드 준비 완료 → 출발 신호 기다림
startGate.await();
// 스레드 별 로그인 컨텍스트 세팅
var authorities = List.of(new SimpleGrantedAuthority("MEMBER")); // 필요시 "ROLE_MEMBER"로
var principal = new CustomUserDetails(
memberId,
"user" + memberId + "@test.com",
"pw" + memberId, // 더미 비밀번호
"유저" + memberId, // 더미 닉네임
authorities
);
var authentication =
new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
TestSecurityContextHolder.setAuthentication(authentication);
eventService.applyTicketEvent(eventId);
successCount.incrementAndGet();
} catch (Exception e) {
// 실패 사유 로그
failCount.incrementAndGet();
log.error("apply failed for member {}: {}", memberId, new String[]{e.getMessage()}, e);
} finally {
doneGate.countDown();
TestSecurityContextHolder.clearContext();
}
});
}
// 모든 작업자 준비 후 동시에 출발
startGate.countDown();
// 모두 끝날 때까지 대기
doneGate.await();
pool.shutdown();
// then
log.info("successCount = " + successCount.get());
log.info("failCount = " + failCount.get());
Event updated = eventRepository.findById(event.getId()).orElseThrow();
assertThat(updated.getAccrued()).isEqualTo(eventAmount);
}
org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.profect.tickle.domain.event.entity.Event#6]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:325)
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:244)
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:566)
.
.
.


위와같이 에러가 뜨는 것을 확인할 수 있었다. 100명의 유저중 요청에 성공한 사람은 2명밖에 없는 것이다.
외부 메서드에서 재시도 루프를 돌리고, 실제 로직을 분리해 매 시도마다 새 트랜잭션으로 실행해야한다.
**/* 재시도 루프 */**
@Override
public TicketApplyResponseDto applyTicketEvent(Long eventId) {
int maxTry = 5;
for (int i = 0; i < maxTry; i++) {
try {
return applyTicketEventOnce(eventId); // 매번 새 트랜잭션
} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
if (i == maxTry - 1) throw e;
try { Thread.sleep(10L); } catch (InterruptedException ignored) {}
}
}
throw new IllegalStateException("unreachable");
}
**/* 실제 로직 */**
@Transactional(propagation = Propagation.REQUIRES_NEW)
public TicketApplyResponseDto applyTicketEventOnce(Long eventId) {
Event event = getEventOrThrow(eventId); // @Version 필드 포함
Member member = getMemberOrThrow();
Point point = member.deductPoint(event.getPerPrice(), eventTarget);
pointRepository.save(point);
// 가능하면 아래 한 줄로(방법 B) 대체 권장. 엔티티 변경을 고수한다면 여기서 예외 날 수 있음.
event.accumulate(event.getPerPrice());
boolean isWinner = (event.getAccrued() >= event.getGoalPrice());
if (isWinner) {
Seat seat = getSeatOrThrow(event.getSeat().getId());
event.updateStatus(statusProvider.provide(StatusIds.Event.COMPLETED));
Status paidStatus = statusProvider.provide(StatusIds.Reservation.PAID);
Reservation reservation = Reservation.create(member, seat.getPerformance(), paidStatus, event.getAccrued());
reservation.assignSeat(seat);
Status reservedStatus = statusProvider.provide(StatusIds.Seat.RESERVED);
seat.completeReservation(member, reservedStatus, null);
reservationRepository.save(reservation);
}
return TicketApplyResponseDto.from(eventId, member.getId(), isWinner);
}