멀티 스레드로 병렬 처리하는 것은 항상 성능에 이점이 있을까?
업무를 진행하며 멀티 스레드로 성능 개선을 할 일이 있었는데, “멀티 스레드로 병렬 처리하는 것이 항상 성능 향상에 이점이 있을까?”라는 의문점이 생겼습니다.
가령, 스타크래프트 같은 게임에서 일꾼 수가 많을수록 더 많은 미네랄을 동시에 캐는 것처럼, 스레드가 많으면 그만큼 동시에 처리할 수 있는 작업이 많아져서 단일 스레드보다 빠르다고 생각하기 쉽습니다.
저도 처음엔 “스레드가 많으면 빠르다, 따라서 스레드 수와 애플리케이션의 성능 속도는 정비례한다”고 단순하게 생각했습니다.
하지만 실제로는 꼭 그렇지만은 않습니다.
예를 들어 단일 스레드로 동작한다고 알고 있는 Redis는 매우 빠른 처리 속도를 보여주는데, 이는 “단일 스레드 = 느리다”라는 가설이 항상 성립하는 것은 아니라는 점을 잘 보여줍니다.
이번 글에서는 왜 멀티 스레드가 항상 빠른 것만은 아닌지, 간단한 Java 예제 코드를 통해 살펴보려고 합니다.
"멀티 스레드로 병렬 처리"가 항상 빠른 것은 아니다.
왜 멀티 스레드로 병렬 처리가 항상 빠른 것이 아닌지 단순한 Java로 작성한 코드를 보면서 알아보곘습니다.
코드의 핵심은 sharedCounter라는 공유 자원을 doHeavyWork() 메서드에서만 수정하고, 이 메서드를 synchronized로 잠갔다는 점입니다. 그리고 10개의 스레드 풀을 사용하여 100개의 작업을 동시에 실행했을 때와, 단일 스레드에서 순차적으로 100개의 작업을 실행했을 때의 시간을 비교합니다.
public class MultiThreadPerformanceApplication {
// 임계 영역
private int sharedCounter = 0;
// synchronized 키워드를 사용해 메서드 락 (한번에 1개의 스레드만 접근 가능)
private synchronized void doHeavyWork() {
for (int i = 0; i < 1_000_000; i++) {
sharedCounter++;
}
}
private void runMultiThreadTask() throws InterruptedException {
long startTime = System.currentTimeMillis();
ExecutorService executorService = Executors.newFixedThreadPool(10);
// 100개의 작업을 멀티 스레드에서 실행
for (int i = 0; i < 100; i++) {
executorService.submit(this::doHeavyWork);
}
executorService.shutdown();
executorService.awaitTermination(10, TimeUnit.MINUTES);
long endTime = System.currentTimeMillis();
System.out.println("[Multi-thread] Total time: " + (endTime - startTime) + " ms");
System.out.println("[Multi-thread] Final sharedCounter: " + sharedCounter);
}
private void runSingleThreadTask() {
long startTime = System.currentTimeMillis();
// 100개의 작업을 단일 스레드(메인 스레드)에서 순차 실행
for (int i = 0; i < 100; i++) {
doHeavyWork();
}
long endTime = System.currentTimeMillis();
System.out.println("[Single-thread] Total time: " + (endTime - startTime) + " ms");
System.out.println("[Single-thread] Final sharedCounter: " + sharedCounter);
}
public static void main(String[] args) throws InterruptedException {
MultiThreadPerformanceApplication application = new MultiThreadPerformanceApplication();
System.out.println("=== Single Thread Test ===");
application.runSingleThreadTask();
// sharedCounter 값이 누적되지 않도록 새 객체 생성
application = new MultiThreadPerformanceApplication();
System.out.println("\n=== Multi Thread Test ===");
application.runMultiThreadTask();
}
}
실행 결과
=== Single Thread Test ===
[Single-thread] Total time: 10 ms
[Single-thread] Final sharedCounter: 100000000
=== Multi Thread Test ===
[Multi-thread] Total time: 26 ms
[Multi-thread] Final sharedCounter: 100000000
- 싱글 스레드로 처리했을 때가 약 10ms, 멀티 스레드로 처리했을 때가 약 26ms가 걸렸습니다.
- 결과적으로 싱글 스레드가 약 16ms 정도 더 빨랐습니다.
왜 이런 상황이 발생했을까요?
1. 락 경쟁 (Lock Contention)
- synchronized 메서드인 doHeavyWork()는 한 번에 오직 1개의 스레드만 진입할 수 있습니다.
- 10개의 스레드가 동시에 실행된다고 해도, 결국 이 메서드를 사용할 때는 차례차례 대기해야 하므로 사실상 싱글 스레드처럼 처리되는 구간이 생깁니다.
- 스레드가 늘어날수록 락을 얻기 위한 "대기"가 잦아져서 성능 이점보다는 오히려 지연이 발생하게 됩니다.
2. 컨텍스트 스위칭(Context Switching) 비용
- 여러 스레드가 실행되는 도중, 운영체제는 각각의 스레드를 번갈아가며 CPU에 할당합니다.
- 이때 스레드 레지스터, 스택 등을 저장하고 복원해야 하는데, 이를 "컨텍스트 스위칭"이라 하며 상당한 오버헤드가 발생합니다.
- 락 경쟁으로 계속 대기하는 스레드가 많아질수록 컨텍스트 스위칭도 빈번해져서 성능이 떨어지게 됩니다.
3. CPU 캐시 활용 문제
- 단일 스레드로 작업할 때는 CPU 캐시가 일관성 있게 활용되지만, 멀티 스레드일 때는 여러 스레드 간 캐시 동기화(Coherency)가 발생합니다.
4. 임계 영역(critical section)이 커서 실제 병렬성이 거의 없는 경우
- 임계 영역은 여러 스레드가 동시에 접근하면 안 되는 공유 자원(변수, 메모리 영역 등)을 사용하거나 수정하는 부분을 의미합니다.
- 예를 들어, 여러 스레드가 동시에 특정 변수의 값을 변경할 때, 해당 연산 전체가 "원자적(Atomic)"으로 처리되지 않으면 데이터 무결성이 깨질 수 있습니다.
- 이를 방지하기 위해 임계 영역에 들어가는 스레드는 락(lock)이나 뮤텍스(mutex) 같은 동기화 기법을 사용해 "한 번에 오직 1개의 스레드만 접근"하게 해야 합니다.
- 즉, 임계 영역이 많은 로직(특히 락으로 보호되는 부분)이 복잡해질수록, 멀티 스레드의 이점이 줄어들고 오히려 성능에 악영향을 줄 수도 있습니다.
정리
- “멀티 스레드 = 항상 빠르다”는 공식은 성립하지 않습니다.
- 임계 영역(공유 자원을 보호하는 구역)이 많거나 락 경쟁이 심한 경우, 멀티 스레드가 오히려 싱글 스레드보다 느릴 수 있습니다. 실제 예제에서처럼 한 번에 하나의 스레드만 doHeavyWork()를 실행할 수 있다면, 멀티 스레드의 이점을 살리지 못하고 오히려 대기와 컨텍스트 스위칭 오버헤드만 늘어납니다.
- 단순히 스레드의 개수가 아니라 작업 특성, 공유 자원 사용 여부, 시스템 구조 등이 성능에 큰 영향을 미칩니다.
- 병렬화 효과가 큰 CPU 바운드 작업이나, I/O가 많은 작업에 적절한 스레드 풀을 구성한다면 멀티 스레딩이 유리할 수 있습니다. 하지만 임계 영역에서의 동시 접근이 잦거나, 락이 많이 걸리면 멀티 스레딩의 이점이 빠르게 희미해집니다.
결국 멀티 스레드로 병렬 처리가 항상 빠른 것이 아니라, 임계 영역을 어떻게 최소화할지, 락 경쟁을 얼마나 줄일 수 있을지가 관건이라고 할 수 있습니다.
'트러블슈팅' 카테고리의 다른 글
LLM 기반 보고서 자동 요약 프롬프트 최적화 전략 (내용 누락 트러블 슈팅) (0) | 2024.11.27 |
---|---|
주식 시황 피드의 종가 오류 해결기(데이터 일관성 개선) (0) | 2024.07.30 |
Confluent Schema Reference 관련 문제 (0) | 2024.07.24 |
[E-commerce] 동시성 문제 해결하기 (비관적 락, 네임드 락, 분산 락) (0) | 2024.05.01 |
[E-commerce] 주문 결제를 이벤트 기반 아키텍처로 구축하기 (0) | 2024.04.16 |