들어가기앞서…

<aside> 📄

실습 전 읽어보면 도움되는 문서화 노트입니다.

Lock

</aside>

이제 본격적으로 공부한 내용을 프로젝트에 적용해보고자한다.

필자가 맡고있는 이벤트 기능의 주요 서비스는 여러명의 사용자가 포인트를 지불하고 이벤트에 참여하면 이벤트에 누적 금액이 쌓이게되고, 목표 금액에 달성한 사용자만이 티켓을 얻게되는 이벤트이다.

때문에 동시에 여러명이 요청할 가능성이 있는 이벤트 응모 요청은 동시성 처리가 필수이다.


동시성을 잠금으로 안전하게 처리해보자.

동시성 처리를 안하면 어떤 문제가 발생할까?

@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
@Sql(scripts = "classpath:sql/cleanup.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:sql/schema.sql",  executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:sql/data.sql",    executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
class EventServiceImplTest{
    @DisplayName("티켓 이벤트에 여러 사용자가 동시에 응모할 수 있다.")
    @Test
    void applyTicketEventWithMultipleMembers() throws InterruptedException {
        // given
        int memberCount = 30;
        final long eventId = 6L;
        final int perPrice = 10000;        // 이벤트 per price
        int eventAmount = 60000;

        Event event = eventRepository.findById(eventId).orElseThrow();

        ExecutorService executorService = Executors.newFixedThreadPool(memberCount);
        CountDownLatch latch = 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;
            executorService.submit(() -> {
                try {
                    // 스레드 별 로그인 컨텍스트 세팅
                    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 {
                    latch.countDown();
                }
            });
        }
        latch.await();

        // then
        log.info("successCount = " + successCount.get());
        log.info("failCount = " + failCount.get());

        Event updated = eventRepository.findById(event.getId()).orElseThrow();
        assertThat(updated.getAccrued()).isEqualTo(eventAmount);
    }
}

위와 같이 스레드 풀을 생성하고 여러 스레드가 동시에 요청을 보내도록

스크린샷 2025-08-25 오후 5.34.55.png

JPQL 벌크 원자 업데이트로 동시성 해결해보기

두 벌크 UPDATE가 DB에서 원자적으로 실행하고, 행 단위 락조건절로 레이스를 막아보자.

이론 설명을 하자

@DisplayName("티켓 이벤트에 여러 사용자가 동시에 응모할 수 있다.")
    @Test
    void applyTicketEventWithMultipleMembers() throws InterruptedException {
        // given
        int memberCount = 30;
        final long eventId = 6L;
        final int perPrice = 10000;        // 이벤트 per price
        int eventAmount = 60000;

        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);
    }