Backend/Kori

멀티스레드와 스레드 풀을 활용한 축제 업데이트 성능 개선

컴공오지마라 2025. 1. 13. 10:00

개인 프로젝트를 개선하는 과정을 기록한 글입니다!

Kori

1. 서론

Kori 에 추가할 기능을 위해, 외부에서 제공하는 API를 바탕으로 데이터를 주기적으로 업데이트하는 기능을 개발하고 있다. 그런데, I/O가 많은 작업을 싱글스레드에서 동기방식으로 처리하는 것이 비효율적이라고 느꼈다. 이번 포스트에서는 스레드 풀을 활용한 멀티 스레드를 도입하여 작업 처리 속도를 개선한 과정을 정리하고자 한다.

 

1.1 Non-blocking I/O VS Multi-threading

싱글스레드에서 동기방식으로 I/O 작업을 처리하면, 해당 작업이 완료될 때까지 실행중인 스레드는 대기 상태가 된다.
즉, CPU는 아무런 연산을 하지 못한채 I/O가 마무리될때까지 기다리게 되므로 CPU를 낭비하는 문제가 발생한다.

이를 개선하기 위해 대표적으로 두가지 방법이 있는데, NIO 방식과 멀티스레딩 방식이 있다.

결론적으로 말하면, 두 방식중 멀티스레딩 방식을 선택했다.
스프링부트 프로젝트에서는 Webflux 혹은 SpringMVC + Tomcat 설정변경으로 NIO를 구현할 수 있지만, 이는 러닝커브가 높아 비교적 익숙한 멀티스레딩 방식으로 문제를 해결하기로 결정했다!

 

2. 멀티 스레드 도입에 따른 동기화 문제 분석

본격적으로 구현하기 전에, 멀티 스레드를 도입함에 따라 발생할 수 있는 문제들에 대해서 고민해보자.

 

멀티 스레드 프로그램에서 발생할 수 있는 대표적인 동기화 문제들은 아래와 같다.

  • Atomicity-violation bugs (Race condition)
  • Order-violation bugs (Synchronization)
  • Deadlock

이어서, 애플리케이션 및 데이터베이스에서 발생가능한 문제점들을 살펴보자!

2.1 PK race condition

여러 개의 스레드에서 동일한 데이터베이스에 insert 쿼리를 날리면 PK 중복이 발생할 수도 있다고 생각했다. 물론, 데이터베이스 차원에서 이에 대한 해결책을 이미 만들었을거라 예상했지만 이번 기회에 조사해보았다.

 

Oracle 데이터베이스

Kori는 운영 환경에서 오라클 데이터베이스를 사용하며, 이는 sequence를 이용하여 PK를 관리한다. 애플리케이션의 JPA가 엔티티의 영속화를 처리하기 전에 시퀀스를 호출하여 pk값을 미리 가져온다. 때문에, 여러 스레드가 시퀀스에 접근시 PK 중복이 발생할 수 있을 것 같지만, 결론부터 말하자면 이런 상황은 발생하지 않는다.

시퀀스는 유니크한 값을 보장하는데, 여러 세션이 동시에 접근하더라도 안정적으로 동작하도록 설계되었다. Oracle은 시퀀스의 정의와 메타 데이터를 관리하는데 메타 데이터에는 START WITH, INCREMENT BY, MAXVALUE 속성이 등이 있다. NEXTVAL이 호출되면, Oracle은 현재 값을 INCREMENT BY 속성에 따라 증가시키고 새로운 값을 반환하는데, 이 작업은 원자적(atomic)으로 수행되므로 동일한 값을 생성하지 않도록 보장한다. 즉, 여러 세션이 동시에 시퀀스 값을 요청하더라도, 각 요청에 대해 원자적으로 고유한 값을 증가시키고 반환하므로 동일한 값을 생성하지 않는다.

아래는 정리한 내용을 조사하면서 찾으면서 발견한 Oracle 공식 Q&A 이다.

 

MySQL 데이터베이스

추후에 데이터베이스를 MySQL 계열로 옮길 의향이 있기 때문에, "Real MySQL 8.0" 책을 통해 관련된 내용을 정리해보았다.

 

MySQL에서는 PK를 자동 증가하는 값으로 할당하는 경우 AUTO_INCREMENT라는 컬럼 속성을 제공한다.

위에서 언급한 동시성 문제를 방지하기 위해, InnoDB 스토리지 엔진에서 내부적으로 AUTO_INCREMENT Lock 이라고 하는 테이블 수준의 잠금을 사용한다고 한다. INSERT처럼 새로운 레코드를 저장하는 쿼리에서 트랜잭션과 관계없이 AUTO_INCREMENT값을 가져오는 순간만 락이 걸렸다가 즉시 해제된다.

위의 내용은 MySQL 5.0 이하 버전에서 사용되던 방식이며, MySQL 5.1 이상부터는 innodb_autoinc_lock _mode라는 시스템 변수를 이용해 자동 증가 락의 작동 방식을 변경할 수 있다.

 

2.2 스레드 동기화 및 데드락

데이터를 업데이트 하는 스레드들은 모두 독립적이다. 즉 변수를 공유하지 않고 스레드간 실행 순서에 따라 결과가 달라지지 않는다.
또한, 스레드들이 DB에 요청하는 작업은 INSERT 쿼리 뿐이다. 즉 스레드가 동일한 레코드에 대해 트랜잭션을 처리하는 상황이 아니므로 DB를 위한 추가적인 동기화도 필요하지 않다.

즉, 동기화 및 데드락 문제를 예방하기 위해 애플리케이션에서 추가적으로 처리해야할 작업은 없다고 할 수있다!

 

3. 멀티 스레드 적용

3.1 적정 스레드 풀 크기 계산

https://no-computer-science.tistory.com/1

 

CPU & I/O Bound Job: 최적의 스레드 개수

서론시스템 프로그래밍 과제를 해결하며 CPU bound 와 I/O bound 작업에 대해 스레드의 개수를 변화시켜가며 총 소요시간을 관찰해보았다. CPU bound작업은 스레드의 개수가 하드웨어의 코어 개수와 비

no-computer-science.tistory.com

 

위 글을 작성할 당시만 해도, I/O Bound Job은 실험을 통해 적정 스레드 개수를 찾는게 최선이라고 알고 있었다.
이번 작업을 통해 "Java Concurrency in Practice"에 소개된 아래와 같은 공식을 알게되어 적용해보았다.

  • 대기시간은 CPU를 사용하지 않는 I/O 대기시간스레드가 대기 중인 시간을 의미한다. (Wait Time)
  • 서비스 시간은 CPU가 실제로 작업을 수행하는 시간을 의미한다. (Compute Time)

Kori의 데이터 업데이트 작업은 I/O Bound 작업에 해당한다. API로 부터 응답을 받고, 이를 처리하여 DB에 저장하는 작업이 주를 이루기 때문이다.

💡 그런데, 실무에 적용하기 위해선 한걸음 더 나아가야 한다!💡

 

실제로는 HTTP 커넥션 풀 뿐만 아니라 JDBC 커넥션 풀, JMS로 부터의 요청 등 더 많은 요소들을 고려해야 한다. 따라서 여러 클래스에서 각자의 스레드 풀, 즉 여러 개의 스레드 풀이 존재한다면 각자의 워크로드에 따라 이 수치를 조정해야 한다. 이 경우 CPU 목표 사용률을 공식에 추가해 줄 수 있다.

이를 보기 쉽게 정리하면 아래와 같다.

공식을 살펴봤으니 이를 Kori에 적용해보자

  • 코어 개수: 현재 운영환경에서 Oracle Vm을 사용중이며 코어 개수는 2개이다.
  • 목표 CPU 사용률: 50%로 잡았다
  • 대기시간: 430ms x 4 + 65ms x 36 + 90ms x 10 = 4960ms
  • 서비스 시간: 5500ms - 4960ms = 540ms

따라서, 적정 스레드 개수 = 2 x ( 1 / 2 ) x ( 1 + 4960ms / 540ms ) = 10.18 이므로 대략 10개의 스레드를 사용하면 될 것 같다.

3.2 멀티 스레드 구현

3.2.1 스레드 풀 설정 

자바에서 멀티 스레드를 구현하는 방법은 다양하다. 대표적으로, 자바에서 제공하는 스레드풀을 사용하고 Task를 Runnable, Callable를 통해 넘겨주는 방식이 있다. 그렇지만 더욱 현대적인 방식이 있다!

 

Kori는 스프링부트를 이용해 개발하므로, Spring에서 java.util.concurrent.Executor을 구현한 스레드 풀인ThreadPoolTaskExecutorCompletableFuture를 이용할 것이다. 스레드 풀은 아래와 같이 설정하였다.

  • 스레드 개수는 위에서 언급한 것처럼 10개로 적용했다.
  • 각 설정이 의미하는 구체적인 내용은 다음 기회에 정리해보도록 하겠다.

3.2.2 병렬처리 코드

  • CompletableFuture를 활용하여 여러 스레드에 Task를 할당하고 있다.
  • supplyAsync를 사용할때 앞에서 Bean으로 등록한 스레드풀을 사용할 것을 명시했다.
  • 스케줄러 스레드는 작업 결과를 전달받아 로깅을 담당한다. (코드 생략)
  • 눈여겨 볼것은 thenApplyAsync를 통해 업데이트 Task를 새로운 스레드에 할당한 것인데, 이는 업데이트 작업은 주로 DB I/O 작업이며 뒤에 이어질 작업과 독립적으로 실행되어도 상관없기 때문이다. 이렇게 함으로써 스레드 풀을 더욱 효과적으로 사용할 수 있었다!

코드의 동작 과정을 간단히 시각화하면 아래와 같다.

  • 스케줄러는 CompleteFuture에 Task를 전달한다. 이때, 각 Task가 끝날때까지 기다리지 않는다.
  • CompletableFuture는 작업을 스레드 풀에 전달하며, 호출한 쪽에서 결과를 확인할 수 있도록 처리한다.
  • 스레드 풀은 전달받은 작업을 실행한다.

CompletableFuture의 자세한 내용이 궁금하다면 아래 글을 추천한다!

 

https://no-computer-science.tistory.com/10

 

CompletableFuture 자세히 들여다보기

서론자바에서 멀티 스레드 및 비동기 프로그래밍을 처리하기에 가장 현대적인 방식은 CompletableFuture이다.이번 포스트에서는 CompletableFuture 이 내부적으로 어떻게 동작하는지 정리해보자 한다.1.

no-computer-science.tistory.com

 

이어서 성능 개선 결과를 살펴보자!

 

3.4 멀티 스레드 성능 분석

  • 싱글스레드에서 약 15분이 소요되던 작업을 멀티스레드를 통해 약 2분 30초로 단축했다.
  • 멀티스레드를 도입하여 성능이 약 6.19 배 개선되었다!

3.5 CPU 사용률 비교

싱글스레드 CPU 사용률

  • 약 15분간 작업을 처리하며, 평균 CPU 사용률은 약 10.51%로 추정할 수 있다.

멀티스레드 CPU 사용률

  • 약 2분 30초간 작업을 처리하며, 평균 CPU 사용률은 약 30.83%로 추정할 수 있다.
  • 초기에 CPU 사용률이 급격하기 높아지는 점이 인상 깊었다. 

멀티 스레딩을 통해 CPU 자원을 약 3배정도 더 효율적으로 사용할 수 있었다!

 

4. 후기

이번 기회를 통해 작업을 분석하고 적절한 병렬처리를 통해 성능을 개선할 수 있었다.
특히, 대학교에서 배운 '멀티스레딩을 도입하여 CPU를 보다 효율적으로 사용함으로써 작업시간을 단축시킬 수 있다' 이 아이디어를 직접 적용할 수 있어서 뿌듯했다. 또한, 이번 개선 과정에서 ThreadPoolTaskExecutor을 알게 되었다. 이를 CompletableFuture와 함께 사용하여, 보다 현대적인 방식으로 멀티 스레딩을 구현하며 한걸음 성장한 것 같다.

 

멀티 스레딩을 활용하여 성능 개선 과정을 아직 경험해보지 않았다면 적극 추천한다!

 

5. 참고자료

스레드 풀 계산 공식 참고 

https://code-lab1.tistory.com/269

 

이상적인 스레드 풀의 적정 크기에 대하여, 스레드 풀 크기 공식, 리틀의 법칙

스레드 풀의 크기를 적절히 설정해야 하는 이유 스레드를 생성하는 것은 비용이 드는 작업이다. 플랫폼마다 오버헤드는 다르지만, 스레드가 생성될 때 요청이 처리되는 지연시간(latency)과 OS에

code-lab1.tistory.com

 

PK-Race condition 아이디어 및 ThreadPoolTaskExecutor 스레드풀 참고 

https://blogshine.tistory.com/660

 

[쿠링] Multi thread를 활용한 공지 조회속도 개선 (feat 동기화)

해당 글은 개인 프로젝트를 개선해 나가면서 내용을 정리하는 글입니다. 제가 고민한 정리글을 그냥 복붙 하는 블로그를 본적이 있습니다.... 양심 부탁드립니다... 1. 도입 배경쿠링에서는 학

blogshine.tistory.com