[Java] 동시성 테스트

Jinny·2023년 12월 4일
1

Java

목록 보기
2/2

💬 들어가며

쿠폰 프로모션 프로젝트를 진행하며 쿠폰 발급 시 동시성 문제가 발생하는지 확인하는 테스트 코드를 작성해야 했다.
그런데 그동안 순차적으로 진행되는 테스트 코드만 작성해보아서 동시성 테스트 코드를 어떻게 작성하는지 알지 못했다.
그래서 동시성 테스트 코드는 어떻게 작성하는지 공부하고 그 내용을 정리해보려고 한다.

프로젝트 레포지토리: https://github.com/woowa-coupons/woowa-coupons




⌨️ 쿠폰 발급 코드

참고차 현재 프로젝트에서 쿠폰을 발급하는 코드의 일부를 발췌해 왔다.
쿠폰 발급 시, 프로모션의 조건을 확인하고 회원이 발급받을 수 있는 조건을 확인한 후 쿠폰을 발급한다.

	@Transactional
    public void issueCoupon(CouponIssueRequest request, Member member) {
        // 프로모션의 조건을 찾고
        List<PromotionOption> promotionOptions = promotionOptionRepository.findByPromotionId(request.promotionId());
		
        // 회원이 발급받을 수 있는 쿠폰 조건 확인
        CouponGroup allMatchedCouponGroup = promotionOptions.stream()
                .filter(promotionOption -> isMemberSatisfied(member, promotionOption.getConditions()))
                .map(this::getCouponGroups)
                .findFirst()
                .flatMap(couponGroups -> couponGroups.stream()
                        .filter(this::hasRemainCoupon)
                        .filter(couponGroup -> !isExpiredCouponGroup(couponGroup))
                        .filter(couponGroup -> !isAlreadyIssued(member, couponGroup))
                        .findFirst())
                .orElseThrow(() -> new ApiException(CouponGroupException.NOT_FOUND));
		
        // 쿠폰 발급
        issueCouponInCouponGroup(allMatchedCouponGroup, member);
    }

쿠폰이 발급되면 잔여 수량에서 -1을 한다.

	@Builder
    private Coupon() {
        // 생략
    }

    public void issue() {
        if (this.remainQuantity <= 0) {
            throw new ApiException(CouponException.EXHAUSTED);
        }
        this.remainQuantity--;
    }



✔️ 동시성 테스트

그러면 이제 여러 회원이 동시에 쿠폰을 발급하면 잔여 수량만큼 발급되는지 확인이 필요하다.
동시성 테스트를 위한 방법을 찾아보았는데 CountDownLatchExecutorService를 활용한 예제 코드가 많았다.

우선 예제 코드를 먼저 보고 CountDownLatchExecutorService에 대해 알아보자.


테스트 코드

테스트 코드 흐름을 정리해보면 다음과 같다.

  1. ExecutorService를 통해 스레드 풀 생성
  2. 생성된 스레드 풀에서 비동기로 쿠폰 발급 요청
  3. 요청이 성공했을 경우 successCount +1, 실패했을 경우 failCount +1
  4. CountDownLatch를 통해 비동기 작업이 끝났는지 확인
  5. 테스트 결과 학인
	@Test
    void issueCoupon() throws InterruptedException {
        // ---- 테스트를 위한 데이터 준비 ----
        // given
        Long promotionId = savePromotion(); // 쿠폰 발급을 위해 프로모션 생성

        int couponAmount = CouponFixture.추석_쿠폰_신규.getInitialQuantity(); // 쿠폰 개수
        int memberCount = couponAmount + 100; // 동시 요청하는 회원수

        ExecutorService executorService = Executors.newFixedThreadPool(30); // 스레드 풀 생성
        CountDownLatch latch = new CountDownLatch(memberCount); // CountDownLatch 생성

        AtomicInteger successCount = new AtomicInteger();
        AtomicInteger failCount = new AtomicInteger();
		
        // 쿠폰 발급에 필요한 회원 가입
        List<Member> members = new ArrayList<>();
        for (int i = 0; i < memberCount; i++) {
            Member member = 랜덤_회원_가입();
            members.add(member);
        }
		
        // ---- 쿠폰 발급 ----
        // when
        for (int i = 0; i < memberCount; i++) {
            Member member = members.get(i);
            
            executorService.execute(() -> {
                try {
                    memberCouponService.issueCoupon(new CouponIssueRequest(promotionId), member);
                    successCount.incrementAndGet(); // 쿠폰 발급에 성공하면 successCount 증가
                } catch (Exception e) {
                    failCount.incrementAndGet(); // 쿠폰 발급에 실패하면 failCount 증가
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
		
        // ---- 테스트 결과 확인 ----
        // then
        List<MemberCoupon> memberCoupons = supportRepository.findAll(MemberCoupon.class);
        
        assertThat(memberCoupons.size()).isEqualTo(couponAmount); // 발급 받은 쿠폰 수가 발급된 쿠폰 개수와 일치하는지 확인
        assertThat(successCount.get()).isEqualTo(couponAmount);
        assertThat(failCount.get()).isEqualTo(100);
    }

ExecutorService

ExecutorServicejava.util.concurrent에서 제공하는 클래스다.

공식 문서에 따르면 ExecutorServiceExecutor를 상속받은 인터페이스로, 동시에 여러 작업들을 싱행시키는 메서드를 제공하고 하나 이상의 비동기 작업 진행 상태를 추적하고 관리할 수 있는 메서드를 제공해주는 클래스이다.

An Executor that provides methods to manage termination and methods that can produce a Future for tracking progress of one or more asynchronous tasks.

비동기 작업을 지원하는 메서드 일부를 살펴보면 execute()submit()이 있다.

  • execute():
    • 인자: Runnable 인터페이스
    • 리턴 타입: void
  • submit():
    • 인자: RunnableCallable 인터페이스
    • 리턴 타입: Future 객체

테스트 코드 예제를 찾아보았을 때 두가지 메서드를 많이 사용하고 있었는데,
작업 결과가 필요하면 submit()을 쓰고 비동기 작업만 실행할 것이라면 execute()를 사용하면 될 것 같다는 생각이다.

💡 참고:

  • execute()Executor 인터페이스에 정의 되어 있고, submit()ExecutorService 인터페이스에 정의되어 있다.
  • ExecutorServiceExecutor를 상속받았기 때문에 두 메서드 모두 호출할 수 있다.

CountDownLatch

CountDownLatchjava.util.concurrent에서 제공하는 클래스다.

공식 문서에 따르면 CountDownLatch는 1개 혹은 그 이상의 스레드가 다른 스레드의 작업이 완료될 때까지 기다릴 수 있게 도와주는 클래스이다.

A synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes.


CountDownLatch 메서드

테스트 코드에 활용한 메서드를 살펴보자.

  • 생성자
    • 인자로 Latch 숫자 전달
CountDownLatch latch = new CountDownLatch(memberCount);

  • countDown()
    • 호출 시, Latch 숫자가 1씩 감소
latch.countDown();

  • await()
    • Latch의 숫자가 0이 될 때까지 대기
latch.await();

동시성 테스트할 때 왜 CountDownLatch가 필요할까?

쿠폰 발급을 비동기 작업으로 여러 스레드에서 처리하면 모든 스레드의 작업이 언제 끝났는지 알 수가 없다.
따라서 CountDownLatch를 활용해 모든 스레드의 작업이 끝났는지 확인이 필요하다.

CountDownLatch를 생성할 때 Latch 수를 작업 횟수만큼 설정하고, 작업이 한번 실행할 때마다 latch.countDown()를 호출한다.

latch.await()를 통해 작업이 끝날 때까지 대기 후, Assertions으로 테스트 결과를 확인한다.




🔗 참고

profile
블로그 이사갔어요. https://jinny-l.tistory.com/

1개의 댓글

comment-user-thumbnail
2024년 6월 20일

잘읽었습니다 좋은 글 감사합니다👍

답글 달기