Post

왜 스레드 풀 사이즈가 동시성 테스트의 신뢰도를 결정하나?

내 테스트는 정말 동시성을 검증하고 있는가?

동시성 제어 로직을 작성하고 나면, 반드시 테스트 코드를 통해 이를 검증해야 한다. 그런데 이때 스레드 풀(Thread Pool)의 크기를 대충 설정하면, 로직에 버그가 있음에도 불구하고 테스트가 통과되는 문제가 발생할 수 있다.

왜 이런 일이 발생하는지, 그리고 적절한 스레드 숫자는 어떻게 정해야 하는지에 대해 이번 포스트에서 다루려고 한다.

1. 스레드 10개면 충분한가?

작업하던 프로젝트에서 ‘사용자가 10명이니까 스레드도 10개면 되겠지?’ 단순히 생각했다.

배경 코드 (테스트 시나리오)
10명의 유저가 각각 충전/사용 요청을 25회씩(총 50회), 전체 500번의 요청을 동시에 보내는 상황을 가정했다.

1
2
// 수정 전: 막연하게 설정한 스레드 풀
ExecutorService executorService = Executors.newFixedThreadPool(10);

스레드 풀의 크기가 10이라는 것은 한 번에 최대 10개의 작업만 물리적으로(컨텍스트 스위칭을 하며) 실행된다는 뜻이다. 나머지 490개의 작업은 스레드 풀 내부의 작업 큐(Blocking Queue)에서 대기한다.

문제는 여기서 발생한다. 먼저 들어간 10개의 작업 중 일부가 로직을 수행하고 락(Lock)을 해제한 뒤에야, 큐에서 대기하던 다음 작업이 스레드에 할당된다. 즉, 작업들이 ‘동시’에 락을 획득하려고 경합하는 게 아니라, 스레드 풀에 의해 이미 순차적으로 제어되어 들어가는 꼴이다.

결과적으로 락 경합(Lock Contention)이 거의 발생하지 않게 되고, 동시성 처리가 안 되어 있는 코드임에도 불구하고 테스트가 성공하는 문제가 발생할 수 있다.

2. 왜 스레드 개수를 작업 수만큼 늘려야 하나?

동시성 테스트의 목적은 혹독한 상황에서도 데이터 정합성이 유지되는가를 확인하는 것이다.

  • 수정 전: newFixedThreadPool(10) → 작업 대부분이 큐에서 대기 (경합 낮음)
  • 수정 후: newFixedThreadPool(500) → 500개 태스크가 모두 스레드에 할당되어 즉시 실행 대기 (경합 극대화)

스레드 개수를 전체 작업 수(유저 수 X 요청 수)에 맞추면, OS 스케줄러에 의해 모든 스레드가 거의 동시에 실행을 시도한다. 이때 우리가 구현한 ReentrantLock이나 synchronized의 동작을 체크할 수 있다.

동시성 압박을 가해야만 숨어있던 Race Condition 버그가 고개를 들기 때문이다.

3. 스레드 500개, 무조건 많을수록 좋은가?

테스트 환경에서 스레드 500개를 생성하는 것이 안전할까?

시스템 자원의 한계

JVM 스펙(HotSpot VM 기준)을 보면, 64비트 Linux/AMD64 환경에서 스레드당 기본 스택 사이즈(-Xss)는 1024KB(1MB)다. 즉, 500개의 스레드를 생성하면 순수하게 스택 메모리로만 약 500MB를 점유한게 된다.

테스트 환경의 메모리가 부족하다면 OutOfMemoryError를 만날 수 있고, 과도한 컨텍스트 스위칭으로 인해 테스트 실행 속도가 급격히 느려질 수도 있다. 따라서 무작정 늘리기보다는 주어진 테스트 환경의 리소스를 고려하여 최적의 스레드 수를 정해야 한다.

Java 21 이상을 사용한다면?
최근 Java 21에서 정식 스펙이 된 Virtual Threads를 사용하면 메모리 걱정 없이 수만 개의 스레드를 생성해 테스트할 수 있다. Executors.newVirtualThreadPerTaskExecutor()를 사용하면 스레드 풀 사이즈에 대한 고민 자체를 지워버릴 수 있으니, 가능한 경우 도입을 고려한다.

더 세련된 해결책: CountDownLatch

무작정 스레드 풀만 키우는 것보다 더 확실한 방법이 있다. 바로 CountDownLatch를 사용하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 모든 스레드가 준비될 때까지 기다리는 출발선
CountDownLatch latch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(totalCount);

for (int i = 0; i < totalCount; i++) {
    executorService.submit(() -> {
        try {
            latch.await(); // 여기서 모든 스레드가 대기
            // 비즈니스 로직(작업) 실행
        } finally {
            doneLatch.countDown();
        }
    });
}

latch.countDown(); // 스레드가 동시에 비즈니스 로직(작업) 시작
doneLatch.await(); // 모든 작업 완료 대기

위 코드를 활용하면 스레드 풀의 크기가 아주 크지 않더라도(물론 어느 정도는 커야 함) 모든 스레드가 특정 시점에 동시에 달려들게 만들어 락 경합을 인위적으로 극대화할 수 있다.

[장점]

  • 스레드 생성 비용 무시: 스레드가 생성될 때의 미세한 시간차 때문에 경합이 안 일어날 확률 제거
  • 밀도 높은 경합: 여러 스레드가 동시에 락을 요청하니까, 구현해 놓은 동시성 제어 로직(예: ReentrantLock의 Fair 모드 등)이 제대로 도는지 훨씬 가혹하게 테스트 가능

4. Conclusion: 테스트의 신뢰도는 ‘압박’에서 나온다

  1. 스레드 풀이 너무 작으면: 스레드 풀 자체가 동기화 역할을 해버려서, 정합성 버그를 발견하지 못할 확률이 높다.
  2. 스레드 풀이 적절히 크면: 실제 운영 환경과 유사한 경합 상황을 재현할 수 있다.
  3. 최적의 설정: 전체 작업 수에 근접하게 스레드 풀을 설정하되, 시스템 리소스가 걱정된다면 CountDownLatchCyclicBarrier 같은 동기화 도구를 병행하여 “동시성 밀도”를 높이는 것이 좋다.
항목 적은 스레드 수 많은 스레드 수
실행 속도 빠름(컨텍스트 스위칭 적음) 느림
버그 발견 확률 낮음(경합 상황 재현 어려움) 높음(동시성 압박 극대화)
** 테스트 신뢰도** 낮음(우연히 통과될 수 있음) 높음(정합성 보장)

테스트 실행 속도가 조금 느려지더라도, “동시성 문제가 없다”는 것을 확실히 보장하는 테스트가 훨씬 가치 있다. 신뢰할 수 없는 테스트는 안 짜느니만 못하기 때문이다.

한 줄 요약!!
동시성 테스트에서 스레드 개수는 성능 최적화의 대상이 아니라, 검증의 강도를 결정하는 핵심 변수다. 경합을 두려워하지 말고 시스템이 허용하는 한 최대한의 압박을 가하자.

마치며

이번 과정을 통해 테스트 코드는 프로덕션 코드만큼이나 정교하게 설계되어야 한다는 것을 느꼈다. 특히 동시성 제어 처럼 실행 타이밍에 의존하는 로직일 수록 더 그런 것 같다.

스레드 숫자를 정할 때 단순히 ‘적당한 숫자’를 넣고 있지 않은 지 스스로 질문해보자. 가하는 압박의 크기에 따라 시스템의 안정성이 달라질 수 있다!!

References

  • https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html
  • https://www.baeldung.com/java-countdown-latch
  • Claude Sonet 4.5
  • Gemini 3.1 Pro
This post is licensed under CC BY 4.0 by the author.