본문 바로가기
Java

Spring 동시성 이슈 테스트 코드 재현

by 옹알이옹 2026. 1. 23.
목차

1. 재현 상황

2. CountDownLatch를 이용한 동시성 테스트

3. 예시 코드

 

 1. 상황

 

현재 진행 중인 사이드 프로젝트에서 모임에 대한 '가입 신청' 기능과 그에 대한 '수락' 기능이 존재한다.
이떄 수락에 대해 해당 모임에서 권한이 있는 사람은 모두 할 수 있기때문에 '동시 수락'에 대한 위험이 다분한 상황.

 

테스트 코드 작성 시 확실하게 상황을 재현하고 싶어 멀티 스레드 관련 기능을 제공하는 
CountDownLatch를 사용하였다.

 

 동시 수락 상황을 재현하기 위한 조건

1. 정원이 5명인 모임에서 4명이 가입되어 있음

2. 두 개의 가입 신청이 수락 대기중

3. 두 명의 관리자가 두 개의 신청에 대해 동시에 수락

-> 이때 서로 다른 스레드에서 동시에 수락하는 행위를 구현 하기 위해 CountDownLatch를 사용

 

- 동시에 수락  => 정원이5명인 모임에 대해 6명까지 가입됨.

- 동시에 수락X (일반적인 흐름) => 2번째 신청에 대해 수락 하는 상황에서  '정원 초과' 예외가 발생

 

 2. CountDownLatch를 이용한 동시성 테스트

 

2-1. CoundDownLatch 역할

여러 스레드의 실행 순서를 조정하는 객체로 스레드를 동시에 호출 및 처리할 수 있게 기능을 제공함.
테스트 코드에서 해당 객체를 사용하여 두 개의 스레드를 동시에 실행 시키는데 사용.

2-2. 테스트에서 사용한 CountDownLatch 함수와 역할

CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
ready == 생성할 작업 스레드가 사용할 객채(스레드 수 만큼 생성)
start == 메인 스레드(테스트 코드) 가 사용할 객체로 1개만 생성
ready.countDown() 각 스레드가 준비 완료임을 알리는 데 사용된다. 두 스레드가 ready.countDown()을 호출해야 ready.await() 이후 테스트 스레드가 start 신호를 보내줄 수 있다.
ready.await() 메인 스레드가 두 스레드가 준비됐는지 확인하기 위해 사용.
두 스레드가 모두 ready.countDown()을 호출해 카운터가 0이 되면
ready.await()에서 대기 중인 메인 스레드가 해제된다.
start.countDown() 두 스레드가 동시에 출발할 수 있도록 start 래치의 카운터를 감소시켜 0으로 만든다. 두 스레드는 start.await()에서 기다리고 있다가 start.countDown() 호출 후 동시에 실행을 시작한다.
start.await() 각 스레드가 실행 전에 대기하는 메서드. 메인 스레드에서 start.countDown()이 호출될 때까지 각 스레드는 처리 로직(승인)을 실행하지 않고 대기한다.

 

결국 이것들을 사용하여 여러 스레드에서 특정 메서드를 동시에 실행시킬 수 있음

 

 3. 예시 코드

 

3-1. 두 가입 신청에 대해 각각 동시에 수락했을 경우 

 @Test
    void 동시에_승인되어_정원_초과() throws Exception {
        // 테스트 데이터 준비
       	// ... 해당 부분은 생략      	

        // 동시성 재현 준비
        // ready: 각 스레드가 출발선에 도착했는지 확인
        // start: 두 스레드를 같은 순간에 출발시키는 신호
        CountDownLatch ready = new CountDownLatch(2);
        CountDownLatch start = new CountDownLatch(1);
        ExecutorService pool = Executors.newFixedThreadPool(2);

        Future<?> f1 = pool.submit(() -> {
            System.err.println("쓰레드1 실행 : "+System.currentTimeMillis());
            // thread_1 준비 완료 표시
            ready.countDown();
            // start 신호를 받을 때까지 대기
            await(start);
            // thread_1: 승인 처리
            System.err.println("쓰레드1 실제 출발 : "+System.currentTimeMillis());
            processJoinRequestService.process(group.getStudyGroupId(), jr1.getJoinRequestId(), owner.getMemberId(), JoinRequestStatus.APPROVED);
        });
        Future<?> f2 = pool.submit(() -> {
            System.err.println("쓰레드2 실행 : "+System.currentTimeMillis());

            // 해당 코드가 있어도 실제 thread_2가 준비될 때까지 실행을 대기
            sleep(300);
            // thread_2 준비 완료 표시
            ready.countDown();
            // start 신호를 받을 때까지 대기
            await(start);
            // thread_2 승인 처리
            // thread_1과  동일하게 currentCount=4를 기준으로 +1 처리할 수 있다.
            System.err.println("쓰레드2 실제 출발 : "+System.currentTimeMillis());		
            processJoinRequestService.process(group.getStudyGroupId(), jr2.getJoinRequestId(), owner.getMemberId(), JoinRequestStatus.APPROVED);
        });

        // 두 스레드를 동시에 출발
        // ready.await()는 동시 시작을 보장
        ready.await();
        start.countDown();
        f1.get();
        f2.get();
        pool.shutdown();

        // 실제 멤버 수가 capacity를 초과했는지 확인
        // 영속성 컨텍스트 초기화 (DB 최신 상태 재조회 목적)
        em.clear();

        StudyGroup studyGroup = em.createQuery("""
              select sg
              from StudyGroup sg
              left join fetch sg.members
              where sg.studyGroupId = :id
          """, StudyGroup.class)
                .setParameter("id", group.getStudyGroupId())
                .getSingleResult();
        
        // 현재원이 최대 정원보다 큰지 확인
        assertThat(studyGroup.getMembers().size()).isGreaterThan(studyGroup.getCapacity());
    }

 

 

실제 로깅 내용

각각의 스레드의 시작 시간은 다르지만 
최종적으로 스레드가 타겟 메서드를 호출하는 시간은 동일하게 찍히는 것을 확인할 수 있다.


앞으로 동시성 이슈 테스트를 위한 상황 재현 시 활용해봅시다. 

'Java' 카테고리의 다른 글

(Java) 패키지 양방향 의존 문제점과 해결 방안  (0) 2024.05.11
Session과 JSESSIONID  (2) 2023.11.20
객체지향 캡슐화 이해하기  (0) 2023.09.15
[Java] 입출력(I/O) 정리  (0) 2023.08.24
[Java] Generic 사용법  (0) 2023.08.06