들어가기 앞서..

<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)
	.
	.
	.

스크린샷 2025-08-26 오전 10.45.18.png

스크린샷 2025-08-26 오전 10.45.53.png

위와같이 에러가 뜨는 것을 확인할 수 있었다. 100명의 유저중 요청에 성공한 사람은 2명밖에 없는 것이다.

해결 과정

해결과정 1. 재시도와 실제 작업의 분리

외부 메서드에서 재시도 루프를 돌리고, 실제 로직을 분리해 매 시도마다 새 트랜잭션으로 실행해야한다.

**/* 재시도 루프 */**
@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);
}