-
싱글 스레드에서 멀티스레드 성능 최적화: 스레드 풀과 큐의 역할Spring 2025. 8. 29. 13:49
이번 프로젝트에서 결제 승인 처리 기능을 구현하게 되었습니다.
승인 처리는 매번 실시간으로 API를 호출하는 방식이 아니라, 스케줄러가 주기적으로 배치로 실행되며 특정 조건을 만족한 카드들을 모아서 승인 처리를 진행합니다.
승인 로직은 외부 결제망(예: Cybersource)과 직접 연동되기 때문에 네트워크 지연이나 타임아웃이 빈번하게 발생할 수 있습니다.초기에는 단순히 new Thread(...)를 통해 워커 스레드를 직접 띄워 처리했는데, 이 방식에는 다음과 같은 한계가 있었습니다.
- 매 요청 마다 스레드를 생성 및 종료하는 작업은OS의 Kernel 레벨에서 이루어지기 때문에 성능에 매우 좋지 않음
- 스레드를 만드는 과정은 단순한 객체 생성(new)과 달리, 커널에 진입해 스택 메모리 확보, TCB(Thread Control Block) 초기화, 스케줄러 등록 등을 해야 해서 비용이 큼
- 스레드가 불필요하게 많이 생기면 CPU 스케줄러가 이를 번갈아 실행하기 때문에 컨텍스트 스위칭 비용이 급격히 증가
- 갑자기 수천 개 요청이 몰릴 겨우 그만큼 스레드가 동시에 생성됨
- 이 과정에서 스레드 스택 메모리가 대량으로 할당되므로 OutOfMemory 에러가 발생하거나, CPU가 스케줄링만 하느라 응답 지연이 될 수 있음
해결 방법
위 문제를 해결하기 위해 ThreadPoolTaskExecutor(스레드 풀 기반 구조) + LinkedBlockingDeque를 사용하여 문제를 개선했습니다.
ThreadPoolTaskExecutor와 LinkedBlockingDeque를 사용한 이유는 다음과 같습니다.
1. ThreadPoolTaskExecutor (스레드 풀)
ThreadPoolTaskExecutor는 단순히 스레드를 직접 생성하는 대신, 스레드 풀(Thread Pool)을 제공하는 고수준 API입니다.
- 스레드 풀 관리: 미리 정해둔 개수의 스레드를 만들어 두고, 요청이 오면 이 풀에서 꺼내 사용 -> 매번 new Thread 생성 필요 없음
- 자동 복구: 풀 내부 스레드가 종료되더라도 풀은 자동으로 새 스레드를 만들어 정해진 poolSize를 유지
- 병렬 처리: corePoolSize 만큼 동시에 실행 가능
- 종료 관리: shutdown() / shutdownNow()로 안전한 서비스 종료 가능
- Future 반환: submit(Callable) 사용 시 결과 값 + 예외 처리 가능
2. LinkedBlockingDeque
승인 요청은 LinkedBlockingDeque 에 카드 데이터를 적재한 뒤, 워커 스레드가 큐에서 하나씩 꺼내 순차적으로 처리하도록 구현했습니다.
- Thread-Safe: 내부적으로 ReentrantLock으로 동기화 → 동시에 여러 스레드가 접근해도 안전
- Blocking 지원: take() 메서드를 사용하면 큐가 비었을 때 자동으로 대기, 데이터 들어오면 재개
- FIFO + Deque 특성: 기본적으로 FIFO(Queue) 방식, 필요 시 양방향 삽입/삭제 가능
- race condition 방지: 동일 카드 요청이 동시에 들어와도 큐에서 순차적으로 처리
- 성능: Deque 방식은 list 보다 속도가 빠르고, 쓰레드 환경에서 안전
- pop(0)와 같은 메서드를 수행할 때 리스트의 경우 O(N)연산을 수행하지만 deque는 O(1) 연산을 수행

Deque 삽입/삭제 
Thread Pool 아키텍처 참고:
https://jee-young.tistory.com/31
[자료구조] 데크(Deque)이해하기
1. Deque의 개념과 구조Deque(데크)는 double-ended-queue의 줄임말로, 양방향에서 데이터를 처리할 수 있는 queue형 자료구조이다.아래 그림과 같이, 양방향에서 엘리먼트를 추가, 삭제할 수 있는 양방향
jee-young.tistory.com
Thread Pool 크기에 대한 고민
스레드 풀 크기는 성능과 안정성에 직접적인 영향을 주기 때문에 스레드 풀 크기에 대한 고민이 있었습니다.
스레드 풀 크기 고민은 다음과 같습니다.
- 너무 작으면 → 작업이 몰릴 때 큐에 대기 시간이 길어짐 → 응답 지연
- 너무 크면 → CPU 컨텍스트 스위칭 비용 증가, 메모리 자원 고갈 위험
- 스레드 풀 크기는 어느 정도가 적당할까?
우선 스레드 풀을 정할 때 CPU 작업과 I/O 작업을 명확하게 정해야 합니다. 그 이유는 CPU 작업과 I/O 작업은 기다리는 방식이 서로 다르기 때문에 스레드 풀도 따로 사용해야합니다.
1. CPU-bound 작업
CPU-bound 작업의 경우 숫자 계산, 암호화, 머신러닝 연산이고, CPU를 계속 사용하는 작업에서는 대기(I/O)가 거의 없으므로, 스레드가 많다고 해서 성능이 무조건 올라가지 않습니다.
따라서 최적 스레드 수는 코어 수 혹은 코어 수 + 알파가 적당합니다. 그 이유는 아래와 같습니다.
- 스레드가 많아지면 동시에 실행되지 못하는 스레드가 늘어남
- CPU가 이를 교체하기 위해 컨텍스트 스위칭(Context Switching) 작업을 수행
- 컨텍스트 스위칭 자체가 CPU에서 실행되므로 불필요한 오버헤드 발생
- 결과적으로 성능이 오히려 떨어질 수 있음
2. I/O-bound 작업
I/O-bound 작업의 경우 DB 조회, 네트워크 요청, 파일 읽기와 같은 작업은 CPU는 거의 일을 하지 않고, 하드웨어 응답을 기다리는 시간이 길게 발생합니다. 이때 운영체제(OS)는 I/O 작업 중인 스레드를 잠시 재우고 다른 스레드를 실행할 수 있습니다.
따라서 최적 스레드 수는 코어 수 × 1.5 ~ 3이 적당합니다. 그 이유는 아래와 같습니다.
- 스레드 중 일부가 I/O 대기 상태에 들어가더라도, 다른 요청을 즉시 처리할 수 있어야 함
- I/O 대기 시간이 CPU 계산 시간보다 훨씬 길어, 스레드 수가 많아도 실제 CPU 사용률은 크게 늘어나지 않음
- 결과적으로 코어 수보다 많은 스레드를 두면, I/O로 잠든 스레드를 대신해 다른 스레드가 즉시 CPU를 활용할 수 있어 동시성과 처리량이 향상됨
이번 결제 처리 기능은 I/O-bound 성향이 강하기 때문에 코어 * 2로 스레드 풀 크기를 스레드 풀 크기를 정했습니다.
참고:
https://kau.sh/blog/io-cpu-bound-threads
IO vs CPU operations - Kaushik Gopal's Website
This is a fantastic post by Erik where he explains the nuance between IO-bound and CPU-bound operations in programming. … libraries have dedicated APIs for I/O scheduling work, separate from other types of operations …. but why is this the case? Why do
kau.sh
https://castlejune.tistory.com/55
스레드 풀(Thread pool) 제대로 이해하기
Thread per request model 백엔드 API 서버에서 요청을 처리하는 여러가지 방식이 있는데 그 중 하나가 Thread per request model 이다. Thread per request model 에서는 하나의 API Request 는 하나의 Thread가 처리하게 되
castlejune.tistory.com
Blocking Queue 사이즈 관리에 대한 고민
ExecutorService나 ThreadPoolTaskExecutor를 사용하면, 내부적으로 작업 요청은 BlockingQueue에 쌓이게됩니다.
하지만 큐 사이즈에 제한이 없다면 아래와 같은 심각한 문제가 생길 수 있습니다.
- 트래픽이 폭증하면 요청이 무한정 큐에 적재됨
- 큐가 점점 많은 메모리를 차지 -> OutOfMenory 에러 발생가 발생할 수 있음
- 불필요한 객체가 계속 쌓이면서 GC 부하 증가
- 결과적으로 시스템 전체가 불안정해질 수 있음
따라서 반드시 큐 사이즈에 제한을 두고, 초과 요청에 대한 거절 정책(RejectedExecutionHandler)을 설정해야 합니다.
이를 통해 얻을 수 있는 이점은 다음과 같습니다:- 시스템 메모리 고갈 방지
- 일정 이상 쌓이면 거절 정책(RejectedExecutionHandler)을 적용
- 일부 요청이 실패하더라도 전체 시스템 장애를 예방
- 고부하 상황에서 시스템이 느려지기보다는 일부 요청이 빠른 실패 전략으로 안정성을 확보하는게 더 중요
결제 승인 배치 작업은 실시간이 아닌 스케줄러 기반으로 동작하며, 한 번 실행될 때 수천~수만 건의 승인 요청이 몰릴 수 있습니다. 큐 크기를 너무 작게 잡으면 금방 꽉 차 작업이 거절될 위험이 있고, 반대로 무제한으로 두면 메모리 고갈과 GC 부하로 시스템 전체가 불안정해질 수 있습니다. 이에 따라 시스템 리소스와 실제 처리량을 종합적으로 고려해 10,000건을 상한으로 설정했습니다.
Thread Pool 설정 및 Blocking Queue 설정
@Bean public ThreadPoolTaskExecutor batchExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); int cores = Runtime.getRuntime().availableProcessors(); executor.setCorePoolSize(cores); // 최소 스레드 수 executor.setMaxPoolSize(cores * 2); // 코어 + 2 executor.setQueueCapacity(10000); // BlockingQueue 크기 지정 executor.setKeepAliveSeconds(120); // 60초 executor.setThreadNamePrefix("BatchExecutor-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 결제 승인 로직 특성상 요청이 유실되면 안됨 executor.initialize(); return executor; }성능 테스트
테스트 항목 모드 요청 수 평균 처리 시간 (ms/op) 총 처리 시간 (ms) 처리량
(ops/ms)싱글 스레드 thrpt / avgt 1 57.7 - 0.018 멀티 스레드 thrpt / avgt 1 56.0 - 0.149 싱글 스레드 (1000건 요청) ss 1000 - 537,719 (약 538초) 멀티 스레드 (1000건 요청) ss 1000 - 4,670 (약 4.7초) Benchmark Mode Cnt Score Error Units issuerAuth.IssuerAuthTaskBenchmark.multiThreadPerformanceTest thrpt 6 0.149 ± 0.002 ops/ms issuerAuth.IssuerAuthTaskBenchmark.singleThreadPerformanceTest thrpt 6 0.018 ± 0.001 ops/ms issuerAuth.IssuerAuthTaskBenchmark.multiThreadPerformanceTest avgt 6 56.048 ± 1.298 ms/op issuerAuth.IssuerAuthTaskBenchmark.singleThreadPerformanceTest avgt 6 57.655 ± 2.029 ms/op issuerAuth.IssuerAuthTaskBenchmark.multiThread_1000Requests ss 6 4670.160 ± 534.216 ms/op issuerAuth.IssuerAuthTaskBenchmark.singleThread_1000Requests ss 6 537719.583 ± 8304.726 ms/op이번 벤치마크는 GC 동작과 JIT 컴파일 최적화로 인한 편차를 최소화하기 위해 충분한 워밍업 단계를 거친 후 본 측정을 수행하고, 여러 번의 포크를 통해 독립적인 JVM 환경에서 반복 실행하도록 설계했습니다. 그 결과, 멀티스레드 방식은 싱글스레드 대비 처리량이 약 8배 향상되었으며, 1,000건 요청 처리 시 싱글스레드가 약 538초(9분) 이상 소요된 반면 멀티스레드는 약 4.7초 만에 완료되어 100배 이상의 전체 처리 성능 개선을 확인할 수 있었습니다. 이를 통해 멀티스레드 구조가 GC 환경에서도 안정적으로 동작하며, 대량 처리 작업에 최적화된 아키텍처임을 입증했습니다.
결론 및 성과
이번 프로젝트에서 결제 승인 처리 기능을 구현하면서 단순 new Thread() 기반의 비효율적인 구조를 ThreadPoolTaskExecutor + LinkedBlockingDeque 기반 구조로 개선하였습니다. 이를 통해 스레드 생성/종료 오버헤드와 메모리 낭비 문제를 해결하고 성능까지 크게 개선할 수 있었습니다. 특히, 성능 테스트 결과 싱글 스레드 환경에서는 1,000건 처리에 약 538초가 소요된 반면, 멀티 스레드 환경에서는 약 4.7초 만에 처리되어 100배 이상의 성능 향상을 확인했습니다. 이는 네트워크 I/O 지연이 많은 외부 결제망 연동에서 멀티 스레드 구조가 큰 효과가 있다는 것을 보여줍니다.
마인드맵 정리

Thread 관련 내용 마인드맵 정리 Reference
'Spring' 카테고리의 다른 글
캐싱 최적화: Redis를 사용하지 않고 Local Cache로 캐싱하는 방법 (0) 2025.08.25 성능 개선: 대량 데이터 Batch Insert 최적화와 MyBatis 캐시 문제 해결 (0) 2025.08.24 Enum find 메서드 최적화: 10배 빠르게 만드는 HashMap 활용법 (2) 2025.08.12 Transaction: ACID, 격리 수준, 락, 전파 옵션 (4) 2025.08.12 Jackson JSON ↔ DTO 직렬화·역직렬화 (0) 2025.08.12 - 매 요청 마다 스레드를 생성 및 종료하는 작업은OS의 Kernel 레벨에서 이루어지기 때문에 성능에 매우 좋지 않음