본문 바로가기

개발/자바&스프링

공식, 이론, 실제를 모두 확인하며 병렬 프로그래밍 성능 개선하기!

1. Intro
2. 원인 분석
2. 해결 방법

📌  INTRO

멀티스레딩으로 집계 기능을 개발한 뒤 운영 서버에서 성능 테스트를 수행했습니다. 

로컬에서 테스트와 달리 성능 저하가 발생했습니다. 

 

성능 개선의 공식, 이론, 실제를 모두 측정 및 확인하고 개선한 경험을 공유하겠습니다. 

📌  원인 분석

 

멀티스레드를 사용한 이유는 I/O와 CPU 작업을 분리하고 병렬 처리가 가능한 부분에는 CPU를 최대한 활용하기 위해서입니다.  

 

개발 완료 후 성능테스트를 하며 CPU 사용량을 모니터링했습니다.

80% ~ 90% 사용량을 보여 CPU가 알차게 사용되었다 판단했으나 처리시간이 단일스레드와 별 차이가 없었습니다. 

 

CPU 사용량 중에 CPU 모드 별로 얼마나 차지하는지 확인이 필요했습니다.

System 모드라면 OS 커널의 작업을 처리하는 것이고, User 모드라면 사용자 애플리케이션을 처리하는 중입니다. 

 

리눅스의 sar 패키지를 설치하면 CPU 사용 정보를 확인할 수 있습니다. 

sar 명령어를 통해 아래 항목을 확인 가능합니다.
  • %user : 유저 모드 작동한 비율
  • %sys : 커널 모드에서 작동한 비율
  • %idle : idle 상태로 있던 비율
  • %iowait : io wait 상태로 있던 비율

 

sar 명령어를 시각화한 결과로 분석해 보겠습니다. 

파란색 - %user 그래프를 봐주세요!

 

병렬 Stream 으로 처리 결과

 

 

병렬 스트림으로 집계를 처리했을 때 CPU의 User 모드로 있었던 시간이 평균 80~90%로 나왔습니다. 

CPU가 사용자 프로세스를 처리하는 시간이 80% ~ 90% 이상이었다는 의미입니다. 

병렬 스트림에서는 JVM이 CPU 자원을 대부분을 점유해 집계 코드를 수행하고 있었습니다. 

 

멀티스레드 처리 결과

멀티스레드도 병렬 스트림처리와 같은 양상을 예상했으나 CPU 유저 모드로 50%로만 작동하고 있었습니다.

CPU 자원이 제대로 활용되지 않고 있었습니다.

 

그렇다면 싱글 스레드의 그래프는 어떨까 궁금했습니다.

 

싱글스레드 처리 결과

 

멀티스레드와 싱글스레드의 CPU 사용 양상이 비슷합니다. 뭔가 문제가 있음이 확실해졌습니다.

 

멀티스레드가 싱글스레드와 CPU 사용률이 비슷하다는 점에서 멀티스레드에 사용한 스레드풀 사이즈가 문제임을 의심했습니다.

 

 

ioBoundWorker 스레드 풀 사이즈는 "코어 수 / 2"로 지정하고 있습니다. 

제 로컬 머신의 코어는 9 여서 4가 지정되어 멀티스레드가 잘 작동했으나,

성능 테스트를 수행한 서버의 코어는 2였습니다. 즉, 2/2 = 1개의 스레드만 생성되어 싱글스레드처럼 작동한 것입니다.

 

 

📌  해결 방법

 

이제 해결방법은 스레드 풀의 사이즈를 적절하게 지정하는 것입니다. 

 

그렇다면 적절한 사이즈는 어떻게 구할까요?

 

저는 자바 병렬 프로그래밍에서 학습한 암달의 법칙을 사용했습니다. 

 

암달의 법칙을 사용하면 병렬 작업과 순차 작업의 비율에 따라 하드웨어 자원을 추가로 투입했을 때 이론적으로 속도가 얼마나 빨라질지에 대한 예측 값을 얻을 수 있다.
암달의 법칙에 따르면, 순차적으로 실행돼야 하는 작업의 비율을  F라고 하고 하드웨어에 꽂혀 있는 프로세서의 개수를 N이라고 할 때, 다음의 수식에 해당하는 정도까지 속도를 증가시킬 수 있다.  

출처 : 자바 병렬 프로그래밍

 

실행돼야 하는 작업의 비율

집계 로직에서 순차 처리 비율을 알아내야 합니다. 

VisualVM의 스레드 덤프로 일정 간격으로 스레드의 내용을 확인했습니다. 

 

제가 병렬 처리한 부분은 DB로 데이터 조회 부분이었습니다. 대부분이 I/O 작업으로 생각하고 병렬 처리를 했습니다. 

그러나 실제 스레드 덤프로 CPU 비율도 상당하다는 걸 알게 되었습니다. 

 

네트워크로 전달 받은 데이터를 객체로 맵핑

 

아래 메서드 호출 내역을 보면 네트워크로 수신한 데이터를 객체로 역직렬화 하는데, 이는 CPU bound 작업입니다. 

 

DB와 네트워크 통신

 

암달의 법칙 공식으로 계산해 보기

여기까지 내용을 근거로 순차적으로 실행돼야 하는 작업의 비율인 F 변수 값을 0.5로 가정하고 

스레드를 2개씩 증가시켰을 때 성능 향상 계산을 GhatGPT에게 부탁했습니다. 

 

스레드 6개부터는 처리 속도가 비슷비슷합니다. 스레드 100개를 만들어도 성능향상을 기대할 수 없다는 의미입니다. 

스레드 개수 Speedup 전 레코드 대비 증가율
2 1.333
4 1.6 26.70%
6 1.714 11.40%
8 1.778 6.40%
10 1.818 4.00%
12 1.846 2.80%
16 1.882 3.60%
20 1.905 2.30%
24 1.923 1.80%
30 1.935 1.20%
40 1.95 1.50%
50 1.961 1.10%
60 1.969 0.80%
80 1.977 0.80%
100 1.982 0.50%

 

아래는 위 테이블을 시각화한 차트입니다. 

 

암달의 법칙 공식 계산 결과

 

4개 이후부터는 그래프 경사도가 점점 수평에 가까워집니다. 

 

 

암달의 법칙 공식과 실제 결과 비교하기

스레드를 2개씩 늘려가며 성능 향상률이 암달의 법칙 결과와 비슷한지 확인해 봤습니다.

수치까지 일치하진 않았지만 특정 스레드 개수 이후로 성능 향상률이 감소하는 건 일치했습니다. 

 

 

이렇게 실제 테스트 결과를 근거로 스레드가 4일 때 최적의 성능 향상률을 근거로

스레드 풀 사이즈를 4로 변경했습니다. 

 

 

개선 결과 확인

조정된 스레드 풀로 실행 시 sar 명령어 결과로 마지막 확인을 해보겠습니다. 

유저 모드 사용 비율이 45% -> 85%로 개선되었습니다!

 

📌  결론

 

멀티스레드, 멀티프로세스 프로그래밍에서 CPU 사용률보다 CPU가 유저 모드로 최대한 사용되고 있는지가 중요함을

공식, 이론, 실제를 통해 알게 되었습니다. 

 

병렬 처리를 위해서는 OS 레벨에서 동작 원리와 영향까지 고려해야 돼서 복잡합니다.

I/O bound 작업은 CPU 커널 모드에서 수행되므로 CPU의 상태 변경으로 인한 문맥 교환이 필연적으로 발생합니다. 

이를 개선하기 위해 비동기 프레임워크나 Java21의 버추얼 스레드 등장 이유도 어느 정도 몸소 깨달을 수 있었습니다. 

 

 

 


참고