티스토리 뷰

들어가며

이전 글들에서 CPU의 멀티코어 전환 배경, 컨텍스트 스위칭의 비용 구조, 그리고 C++과 Java에서의 구현 방식을 살펴봤습니다. 시리즈의 마지막인 이 글에서는 가장 실무적인 질문을 다룹니다. "멀티스레딩을 도입해야 하는가, 도입했다면 제대로 동작하는지 어떻게 검증하는가."

멀티스레딩은 성능을 개선할 수 있는 강력한 도구이지만, 정확한 측정 없이 도입하면 복잡성만 증가시키고 성능은 오히려 나빠질 수 있습니다. 2편에서 살펴본 것처럼 CPU 바운드 작업에서 코어 수를 초과하는 스레드는 해로우며, 3편에서 다룬 동기화 메커니즘은 잘못 사용하면
데드락이나 레이스 컨디션이라는 재현 불가능한 버그를 만들어냅니다.


이 글에서는 멀티스레딩 도입 전에 확인해야 할 핵심 지표들을 정리하고, 도입의 트레이드오프를 짚은 뒤, C++ 게임 애플리케이션과 Java 웹 애플리케이션에서 각각 운영 안정성을 어떻게 검증하는지를 비교해 보겠습니다.



도입 전에 측정해야 할 것들

멀티스레딩을 적용하기 전에 가장 먼저 해야 할 일은 현재 시스템의 병목이 어디에 있는지를 측정하는 것입니다. "아마 여기가 느릴 것이다"라는 감으로 시작하면, 실제로는 병목이 아닌 곳에 멀티스레딩을 적용하여 복잡성만 늘리는 결과로 이어지기 쉽습니다.

코어별 CPU 사용률

전체 CPU 사용률이 25%라고 해서 여유가 있는 것이 아닙니다. 4코어 시스템에서 1개 코어만 100%이고 나머지 3개가 유휴 상태라면 평균은 25%이지만, 실제로는 단일 스레드가 하나의 코어를 포화시키고 있는 상황입니다. 이것은 병렬화의 가장 명확한 신호입니다. 단일 코어에서 실행 중인 작업을 분할하여 나머지 코어에 분배하면 성능 향상을 기대할 수 있습니다.

반대로, 모든 코어가 이미 높은 사용률을 보이고 있다면 멀티스레딩은 답이 아닙니다. 코어를 추가하지 않는 한 스레드를 늘려도 컨텍스트 스위칭만 증가합니다. 이 경우에는 알고리즘 최적화나 불필요한 연산 제거가 우선입니다.


Linux에서는 htop이나 mpstat -P ALL로 코어별 사용률을 확인할 수 있고, 게임 엔진에서는 Tracy Profiler나 Unreal Insights가 코어별 스레드 활동을 타임라인으로 시각화해 줍니다. Java 애플리케이션에서는 JFR의 CPU Load 이벤트가 프로세스 수준과 머신 수준의 CPU 사용률을 시계열로 기록합니다.


병렬화 가능 비율

2편에서 살펴본 Amdahl의 법칙을 실무에 적용하려면, 전체 실행 시간에서 독립적으로 실행 가능한 구간의 비율을 먼저 측정해야 합니다. 프로파일러로 핫스팟(hot spot)을 찾고, 그 중에서 데이터 의존성 없이 분할 가능한 구간이 얼마나 되는지를 판단합니다.

병렬화 가능 비율이 90%라면 10코어 투입 시 이론적으로 약 5.3배의 속도 향상을 기대할 수 있습니다. 하지만 이 비율이 50% 미만이라면, 코어를 아무리 늘려도 최대 2배를 넘을 수 없습니다. 이런 경우에는 멀티스레딩 도입의 복잡성 대비 효과가 미미하므로, 순차 구간의 알고리즘을 개선하는 것이 더 나은 투자입니다.


스레드 경합률

이미 멀티스레딩이 적용된 시스템에서 성능이 기대에 못 미친다면, 스레드 경합을 의심해 볼 필요가 있습니다. 여러 스레드가 동일한 락을 획득하기 위해 대기하는 시간이 전체 실행 시간에서 차지하는 비율이 경합률입니다.


Java에서는 JFR(Java Flight Recorder)의 JavaMonitorWait와 JavaMonitorEnter 이벤트로 경합을 정량적으로 측정할 수 있습니다. 특정 모니터에서 대기 시간이 누적되고 있다면, 스레드를 더 늘리는 것은 해결책이 아닙니다. 오히려 락의 범위를 좁히거나(fine-grained locking), 임계 구역 내 작업을 최소화하거나, 3편에서 다룬 lock-free 구조로 전환하는 것이 우선입니다.


C++에서는 Helgrind이 락 경합 패턴을 분석해 주고, Tracy Profiler의 락 타임라인이 어느 뮤텍스에서 대기가 발생하는지를 시각적으로 보여줍니다.

I/O 대기 비율

vmstat이나 iostat로 I/O wait 시간을 확인하면, 해당 작업이 I/O 바운드인지를 판단할 수 있습니다. I/O 대기 비율이 높은 작업이라면 전통적인 멀티스레딩보다 비동기 I/O나 Java의 Virtual Threads가 더 효과적일 수 있습니다. 2편에서 정리한 것처럼 I/O 바운드에서는 코어 수를 크게 넘는 스레드가 유효하지만, Virtual Threads를 사용하면 스레드 풀 크기 튜닝 자체를 줄일 수 있습니다.


메모리 대역폭

간과하기 쉬운 병목이 메모리 대역폭입니다. 대규모 배열을 순회하거나 행렬 연산을 수행하는 작업에서는, 코어를 추가해도 메모리 버스가 포화되어 성능이 선형으로 늘지 않는 현상이 발생합니다. 코어 수에 비례해 성능이 올라가야 하는데 4코어 이후로 개선 폭이 급격히 줄어든다면, 메모리 대역폭이 병목일 가능성이 높습니다.

Linux에서는 perf stat으로 LLC(Last Level Cache) 미스율을 확인할 수 있습니다. LLC 미스가 빈번하다면 데이터가 캐시에 수용되지 못하고 메인 메모리를 반복 접근하고 있다는 의미이며, 이 경우에는 스레드를 늘리기보다 데이터 구조를 캐시 친화적으로 재배치하는 것이 효과적입니다. 1편에서 다룬 ECS의 데이터 지향 설계가 이 문제를 아키텍처 수준에서 해결하는 접근이기도 합니다.



멀티스레딩 적용의 트레이드오프

지표를 확인해서 멀티스레딩이 효과적이라는 결론에 도달했더라도, 도입에 따르는 비용을 함께 고려해야 합니다.


복잡성 증가

순차 코드에서 프로그램의 실행 흐름은 위에서 아래로 한 가지입니다. 하지만 동시성 코드에서는 스레드 N개가 동시에 실행되면서 가능한 실행 순서의 조합이 기하급수적으로 늘어납니다. 스레드 2개가 각각 3단계 작업을 수행하면 가능한 인터리빙(interleaving)은 20가지지만, 스레드가 4개로 늘어나면 수만 가지를 넘어갑니다.


이 상태 공간의 폭발은 코드 리뷰, 유지보수, 디버깅의 난이도를 근본적으로 끌어올립니다. 순차 코드에서는 "이 시점에 이 변수의 값은 무엇인가"라는 질문에 명확한 답이 있지만, 동시성 코드에서는 "다른 스레드가 이 변수를 이미 수정했을 수도 있다"는 가능성을 항상 고려해야 합니다.


디버깅 난이도

동시성 버그의 가장 까다로운 특성은 재현 불가능성입니다. 레이스 컨디션은 특정 타이밍에서만 발생하고, 데드락은 특정 스레드 스케줄링 순서에서만 나타납니다. 더 난감한 것은, 디버거를 붙이면 실행 타이밍이 변하면서 버그가 사라지는 "하이젠버그(Heisenbug)" 현상입니다. 관찰 행위 자체가 대상을 변화시키는 것입니다.

이런 특성 때문에 동시성 버그는 개발 환경에서는 발견되지 않다가 프로덕션의 높은 부하 상황에서 간헐적으로 발생하는 경우가 많습니다. 발견된 뒤에도 원인을 특정하기 어려워 수정에 많은 시간이 소요됩니다.


테스트의 한계

단위 테스트로 동시성 버그를 잡는 것은 본질적으로 어렵습니다. 테스트가 통과했다는 것은 "이 특정 실행 순서에서는 문제가 없었다"는 의미일 뿐, "모든 가능한 실행 순서에서 안전하다"는 보장이 아닙니다. 동시성 테스트를 1,000번 실행해서 모두 통과했더라도, 1,001번째에서 실패할 수 있습니다.


이 한계 때문에 동시성 버그의 검출은 단위 테스트보다 정적 분석 도구와 런타임 검증 도구에 의존하는 비중이 커집니다. 아래에서 다룰 ThreadSanitizer, JFR 같은 도구들이 이 역할을 합니다.


의사결정 프레임워크

멀티스레딩 도입 여부를 결정할 때 일관된 프로세스를 따르면 불필요한 복잡성 도입을 줄일 수 있습니다.

첫째, 현재 성능 병목을 프로파일러로 측정합니다. 둘째, 병목 구간이 병렬화 가능한지 판단합니다(데이터 의존성 분석). 셋째, Amdahl의 법칙으로 이론적 최대 개선폭을 추정합니다. 넷째, 개선폭이 도입 비용(복잡성, 테스트, 유지보수)을 정당화하면 프로토타입을 만들어 실측합니다. 다섯째, 실측 결과가 기대에 부합하면 본격 적용하고, 그렇지 않으면 다른 최적화 방법을 탐색합니다.

핵심은 "감이 아닌 데이터 기반 의사결정"입니다. 프로파일링 결과 없이 "이 부분이 느릴 것 같으니 멀티스레딩을 적용하자"라고 시작하면, 실제 병목이 아닌 곳에 불필요한 복잡성을 추가하게 됩니다.

 



C++ 게임 애플리케이션의 안정성 검증

게임에서 멀티스레딩 관련 버그는 프레임 드롭, 화면 깨짐, 크래시로 직결됩니다. 사용자 경험에 직접적인 영향을 미치므로 검증 과정이 특히 중요합니다.


레이스 컨디션 탐지

C++ 환경에서 레이스 컨디션을 탐지하는 가장 효과적인 도구는 ThreadSanitizer(TSan)입니다. Clang과 GCC에 내장되어 있으며, 컴파일 시 -fsanitize=thread 플래그를 추가하면 런타임에 데이터 레이스를 탐지하여 리포트합니다.

clang++ -fsanitize=thread -g -O1 game_physics.cpp -o physics_test
./physics_test


TSan은 메모리 접근을 추적하여, 두 스레드가 동일한 메모리 위치에 접근하면서 하나 이상이 쓰기이고 적절한 동기화가 없는 경우를 리포트합니다. 실행 속도는 2~5배 느려지지만, 프로덕션 빌드가 아닌 테스트 빌드에서 실행하므로 실무적으로 큰 문제가 되지 않습니다.


Helgrind은 Valgrind 기반의 스레드 오류 탐지 도구로, TSan보다 느리지만(10~30배 감속) 더 넓은 범위를 커버합니다. 락 순서 위반을 탐지하여 잠재적 데드락을 보고하고, POSIX 스레드 API의 잘못된 사용을 잡아냅니다. TSan은 실제로 발생한 레이스만 탐지하지만, Helgrind은 "이 락 순서가 계속되면 데드락이 발생할 수 있다"는 잠재적 문제까지 경고합니다.


두 도구를 병행하는 것이 이상적입니다. TSan으로 빈번한 테스트를 돌리고, Helgrind으로 주기적인 심층 분석을 수행하는 조합입니다.


프레임 타임 프로파일링

게임에서 멀티스레딩 성능을 평가할 때 가장 중요한 지표는 평균 FPS가 아니라 프레임 타임의 일관성입니다. 평균 60fps라도, 1초에 한 번씩 프레임 타임이 50ms로 튀면 사용자는 끊김(스터터링)을 체감합니다.

핵심 지표는 99th percentile(p99) 프레임 타임입니다. 60fps 목표라면 p99 프레임 타임이 16.6ms 이하여야 합니다. 멀티스레딩 도입 전후로 이 수치를 비교하면 병렬화의 실질적 효과를 판단할 수 있습니다. 평균 프레임 타임은 개선되었지만 p99가 나빠졌다면, 동기화 비용이나 스레드 간 작업 불균형이 간헐적 지연을 만들고 있다는 신호입니다.


Tracy Profiler는 오픈소스 프레임 프로파일러로, 스레드별 작업 타임라인을 나노초 단위로 시각화합니다. 어떤 스레드가 어떤 시점에 무슨 작업을 하고 있었는지, 락 대기가 어디서 발생했는지를 한눈에 파악할 수 있습니다. Unreal Engine을 사용한다면 Unreal Insights가 엔진 수준의 프로파일링을 제공합니다.


스트레스 테스트

동시성 버그는 부하가 높을 때 발생 확률이 올라갑니다. 게임에서는 최대 부하 시나리오를 자동화하여 장시간 실행하는 방식으로 잠재적 문제를 드러냅니다. 대규모 전투에서 수백 NPC가 동시에 활동하는 상황, 수천 오브젝트의 물리 시뮬레이션이 동시에 실행되는 상황 등을 스크립트로 재현하고, 수 시간 동안 반복 실행합니다.

이 과정에서 크래시 발생 여부, 메모리 누수 패턴, 프레임 타임 편차를 모니터링합니다. 크래시가 발생하면 덤프 파일에서 스택 트레이스를 확인하고, AddressSanitizer와 ThreadSanitizer 빌드로 동일 시나리오를 재실행하여 근본 원인을 특정합니다.



Java 웹 애플리케이션의 안정성 검증

Java 웹 애플리케이션에서 멀티스레딩 문제는 응답 지연, 처리량 저하, 서비스 행(hang)으로 나타납니다. 게임과 달리 프레임 타임이 아닌 응답 시간과 처리량이 핵심 지표이며, JVM이 제공하는 풍부한 모니터링 도구가 검증의 기반이 됩니다.


스레드 덤프 분석

Java 웹 애플리케이션에서 응답이 느려지거나 서비스가 멈추면 가장 먼저 확인하는 것이 스레드 덤프입니다. jstack <pid> 명령으로 현재 JVM의 모든 스레드 상태를 스냅샷으로 확보할 수 있습니다.

스레드 덤프에서 주목해야 할 것은 BLOCKED 상태의 스레드들입니다. 다수의 스레드가 동일한 모니터를 기다리며 BLOCKED 상태에 있다면, 해당 모니터가 경합 지점(contention point)입니다. 이 패턴이 반복된다면 락의 범위를 줄이거나 동기화 전략을 재설계해야 합니다.


JVM은 synchronized 기반의 데드락을 자동으로 탐지합니다. 스레드 덤프에 "Found one Java-level deadlock"이라는 메시지와 함께 관련 스레드의 대기 관계가 출력됩니다. 하지만 ReentrantLock 기반의 데드락은 자동 탐지되지 않으므로, 주기적으로 스레드 덤프를 수집하고 분석하는 모니터링이 필요합니다.


JFR과 JMC를 활용한 상시 모니터링

Java Flight Recorder(JFR)는 프로덕션 환경에서도 사용할 수 있는 저오버헤드 프로파일링 도구입니다. Oracle의 공식 문서에 따르면 일반적인 설정에서 성능 오버헤드는 1% 미만입니다. 이 수준이면 프로덕션에서 상시 활성화해 둘 수 있습니다.

JFR이 기록하는 이벤트 중 멀티스레딩 관련으로 중요한 것들은 다음과 같습니다. JavaMonitorEnter 이벤트는 synchronized 블록 진입 시 대기가 발생한 경우를 기록하고, JavaMonitorWait 이벤트는 Object.wait() 호출을 기록합니다. ThreadPark 이벤트는 LockSupport.park()(ReentrantLock 등의 내부 구현)에 의한 대기를 기록합니다. 이 이벤트들의 빈도와 지속 시간을 분석하면, 어떤 락에서 얼마나 오래 기다리고 있는지를 시계열로 파악할 수 있습니다.


Java Mission Control(JMC)은 JFR 데이터를 시각적으로 분석하는 도구입니다. 스레드 상태 전이(RUNNABLE → BLOCKED → WAITING → RUNNABLE)를 타임라인으로 보여주어, 특정 시점에 스레드들이 어떤 상태에 있었는지를 직관적으로 파악할 수 있습니다. GC 일시정지와 스레드 상태를 겹쳐 보면, GC에 의한 STW(Stop-The-World)가 멀티스레딩 성능에 미치는 영향도 확인할 수 있습니다.


부하 테스트

멀티스레딩 관련 설정(스레드 풀 크기, 커넥션 풀 크기 등)의 효과를 정량적으로 검증하려면 부하 테스트가 필수입니다. JMeter 또는 Gatling으로 동시 사용자 수를 점진적으로 증가시키며 응답 시간, 처리량(TPS), 에러율을 측정합니다.


스레드 풀 크기를 조정하면서 같은 부하 시나리오를 반복 실행하면, 어느 설정에서 처리량이 최대화되고 응답 시간이 안정되는지를 데이터로 확인할 수 있습니다. 2편에서 다룬 스레드 수 산정 공식(CPU 바운드: N_cores, I/O 바운드: N_cores × (1 + W/C))이 시작점을 제공하고, 부하 테스트가 최종 값을 결정합니다.


부하 테스트 중 JFR을 함께 수집하면, 특정 부하 수준에서 경합이 급증하는 지점이나 스레드 풀이 고갈되는 시점을 정확히 특정할 수 있습니다.



두 환경의 검증 전략 비교

C++ 게임 애플리케이션과 Java 웹 애플리케이션에서 멀티스레딩 안정성을 검증하는 접근에는 공통점과 차이점이 있습니다.

공통점은 명확합니다. 두 환경 모두 레이스 컨디션과 데드락이 주요 위험이며, 정적 분석만으로는 부족하고 런타임 검증이 필수적입니다. 그리고 프로덕션 수준의 부하에서 테스트해야 실제 문제를 드러낼 수 있다는 점도 동일합니다.


차이점은 환경의 특성에서 비롯됩니다. 게임은 프레임 타임 일관성이 핵심 지표입니다. 평균이 아닌 p99 프레임 타임이 목표치를 충족해야 하며, 간헐적 스파이크도 사용자 경험을 해칩니다. 반면 웹 서버는 처리량(TPS)과 응답 시간(latency)이 핵심이며, 동시 사용자 수 증가에 따른 성능 변화 곡선이 중요합니다.


도구의 생태계도 다릅니다. C에서는 ThreadSanitizer와 Helgrind 같은 외부 도구에 의존하고, 프레임 프로파일링을 위해 Tracy나 엔진 내장 도구를 사용합니다. Java에서는 JVM 자체가 JFR, 스레드 덤프, 데드락 탐지 등 강력한 모니터링을 내장하고 있어, 별도 도구 없이도 상당한 수준의 진단이 가능합니다. 프로덕션 모니터링 측면에서도 Java는 APM 도구(Datadog, Pinpoint 등)와의 통합이 성숙해 있고, JFR 상시 수집이 실용적인 반면, C 게임은 자체 텔레메트리 시스템과 크래시 리포트 수집에 의존하는 경우가 많습니다.

 

항목 C++ 게임 앱 Java 웹 앱
레이스 컨디션 탐지 ThreadSanitizer, Helgrind JFR 스레드 경합 이벤트, 스레드 덤프
데드락 탐지 Helgrind, 커스텀 락 순서 검증 JVM 내장 탐지, JMX 모니터링
성능 프로파일링 프레임 타임(p99), Tracy/Unreal Insights JFR/JMC, 응답 시간 p99
부하/스트레스 테스트 최대 부하 시나리오 자동 재생 JMeter/Gatling 점진적 부하
프로덕션 모니터링 텔레메트리 대시보드, 크래시 리포트 APM(Datadog, Pinpoint 등), JFR 상시 수집
핵심 지표 프레임 타임 일관성(스터터링 방지) 처리량(TPS)과 응답 시간(latency)

 

 


 

시리즈를 마치며

네 편에 걸쳐 멀티스레딩의 배경, 비용, 구현, 검증을 정리했습니다. CPU가 왜 멀티코어로 전환했는지에서 시작하여, 컨텍스트 스위칭의 비용 구조를 이해하고, C++과 Java의 구현 차이를 코드로 비교하고, 마지막으로 실무에서의 의사결정과 검증 방법까지 살펴봤습니다.

 

이 과정에서 반복적으로 확인된 것은, 멀티스레딩이 "적용하면 빨라지는 기술"이 아니라 "적절한 조건에서 올바르게 적용해야 효과를 발휘하는 기술"이라는 점입니다. 코어별 CPU 사용률, 병렬화 가능 비율, 스레드 경합률 같은 지표를 측정하지 않고 시작하면 복잡성만 늘어나고, ThreadSanitizer나 JFR 같은 검증 도구 없이 운영하면 재현 불가능한 버그에 시달리게 됩니다.

 

C++ 게임 개발과 Java 백엔드 개발을 예로 들어 교차하며 정리하는 과정에서, 언어와 도메인이 달라도 멀티스레딩의 근본적인 원리와 주의점은 동일하다는 것을 다시 확인하게 되었습니다. 측정하고, 판단하고, 검증하는 순서를 지키는 것이 결국 가장 중요한 원칙이라는 생각을 정리하며 시리즈를 마칩니다.

 


 

참고 출처