아무리 스레드를 늘려도 성능 개선에 소용없던 이유 (MySQL CPU 사용률 99%)

2025. 4. 4. 18:12·트러블슈팅

TL;DR

단일 스레드에서 멀티스레드로 전환했음에도 배치 성능이 개선되지 못했던 이유
MySQL CPU 사용률 99%가 병목의 원인이었으며, 이는 비즈니스 요구사항 때문에 불가피했던 LIKE 패턴 매칭 쿼리 때문이었습니다. 문제 해결을 위해 LIKE 쿼리를 IN 절로 변환하는 작업을 진행했고, 그 결과 CPU 사용률은 20% 이하로 떨어지고 배치 처리 시간은 10분에서 단 25초로 단축되어 사용자들이 최신 데이터에 즉시 접근할 수 있게 되었습니다.

 

"배치 성능 개선? 그거 멀티스레드 쓰면 해결되는 거 아니었어?"

아마 많은 개발자분들이 저와 같은 생각을 해보셨을 겁니다.


저는 사용자에게 최신 데이터를 제공하는 신규 기능을 개발하고 있었습니다. 
이 신규 기능의 핵심은 사용자가 원하는 특정 기준으로 데이터를 커스텀하게 그룹화하고 집계하여 확인할 수 있게 하는 것이었습니다.
매월 한 번 업데이트되는 원본 데이터가 들어오면, 사용자들이 직접 만든 커스텀 그룹 데이터도 자동으로 업데이트되어야 했습니다.

 

처음 이 기능을 개발할 때는 단일 스레드로 시작했습니다. 하지만 곧 문제가 발생했습니다.
원본 데이터가 업데이트되어도, 사용자들이 최신 커스텀 그룹 데이터를 확인하기까지는 무려 10분이라는 긴 시간을 기다려야 했습니다. 그동안은 과거 데이터만 볼 수밖에 없었으니, 사용자 경험은 최악이었습니다.

 

당연히 해결책은 멀티스레드 전환이라고 생각했습니다. 
"스레드 수를 늘리면, 동시에 작업할 수 있는 작업자가 많아지니 성능은 비례해서 빨라지겠지?"라는 기대를 품고 10개의 스레드를 20개로 늘려보고, 커넥션 풀도 그에 맞춰 확장했습니다.
하지만 결과는 저의 예상과 다르게 10개 스레드일 때와 20개 스레드일 때 전혀 차이가 없었습니다.

 

현재 DB가 이미 처리량이 한계치에 도달했거나, 네트워크, CPU, 디스크 I/O 같은 다른 자원에서 병목 현상이 발생하고 있다고 생각하게 되었습니다.

 

멀티스레드의 발목을 잡던 진범을 찾아서

멀티스레드를 적용했음에도 성능이 개선되지 않자, 저는 곧바로 병목 지점을 찾기 위한 분석에 돌입했습니다. 
가장 먼저, 업데이트된 원본 수출 데이터를 'SELECT'해오는 부분에서 문제가 발생하는지, 아니면 최신 데이터를 사용자 커스텀 그룹에 'INSERT'하는 지점에서 문제가 발생하는지를 파악했습니다.
그리고 병목 현상이 최신 데이터를 'SELECT' 해오는 지점에서 발생하고 있음을 확인했습니다.

 

해당 'SELECT' 쿼리의 실행 계획을 분석했을 때, 모든 조건에서 인덱스를 잘 활용하고 있는 것으로 나타났습니다. 
"인덱스를 잘 쓰고 있는데 왜 느리지?" 라는 의문이 들었습니다. 추가적으로 Read IOPS를 확인했을 때도 낮은 수치로 측정되는 것을 보아, 디스크 I/O가 병목의 원인이 아닐 것이라는 추론을 할 수 있었습니다.

 

ReadIOPS 지표

 

하지만, CPU 사용률을 확인했을 때 사용률이 99%까지 치솟아 있는 것을 발견했습니다. 
쿼리 성능 저하의 원인이 디스크 I/O 같은 비용이 아니라, 높은 연산 비용으로 인한 CPU 사용량이라는 것을 깨닫게 되었습니다.

 

CPU 사용률 지표

 

그렇다면 어떤 연산이 이토록 CPU를 잡아먹고 있을까요? 
저는 'SELECT' 쿼리 내에서 연산 비용이 높을 것으로 추정되는 부분을 집중적으로 살펴보았습니다.  그리고 마침내 'LIKE' 패턴 매칭 구문을 발견했습니다. 

특히 'hscode LIKE CONCAT(hscode, '%')'와 같은 형태의 조건이 문제였습니다. 이 조건은 단순한 문자열 비교가 아닙니다. 각 행마다 'CONCAT' 함수를 통해 문자열을 결합해야 하고, 그 결과 문자열에 대해 다시 패턴 매칭을 수행해야 하므로, CPU 연산 비용이 높을 수밖에 없었습니다.


바로 이 'LIKE' 패턴 매칭이 CPU를 99%까지 끌어올리며 멀티스레드의 발목을 잡고 있던 진범이었던 것입니다.

 

기존 쿼리에서 `LIKE`를 사용할 수밖에 없었던 이유

신규 기능의 비즈니스 요구사항 때문에 'LIKE' 패턴 매칭이 가장 직관적이고 유일한 해결책처럼 보였습니다.

가장 큰 이유는 바로 계층적 상품 분류 체계인 HS 코드에 있었습니다. HS 코드는 국제적으로 상품을 분류하는 코드로, 앞 4자리는 큰 상위 카테고리를, 8자리는 아주 세부적인 상품을 나타냅니다. 예를 들어, '5201'은 면화 전체를, '52010100'은 특정 면화 품목을 의미합니다.

여기에 사용자들의 유연한 데이터 등록 방식이라는 요구사항이 더해졌습니다. 사용자들은 관심 있는 상품을 등록할 때, '5201(면화 전체)'처럼 4자리만 입력할 수도 있고, '52010100(면화 특정 품목)'처럼 8자리 전체를 입력할 수도 있어야 했습니다. 이처럼 코드 길이가 가변적이었기 때문에 정확히 일치하는 비교만으로는 데이터를 찾아낼 수 없었습니다.

만약 어떤 사용자가 4자리 HS 코드 '5201'을 등록했다면, 시스템은 '5201'로 시작하는 '52010100', '52010200' 등 모든 하위 품목의 수출 데이터를 합산해서 보여줘야 했습니다. 이는 곧 'PREFIX' 기반의 검색이 필수적이라는 의미였습니다.

이러한 상황에서, `LIKE '5201%'`와 같은 패턴은 해당 HS 코드로 시작하는 모든 하위 코드를 직관적으로 검색하는 가장 명확하고 단순한 방법이었습니다. 비록 나중에 CPU 병목의 주범으로 밝혀졌지만, 당시에는 비즈니스 로직을 가장 쉽게 구현할 수 있는 불가피한 선택이었습니다.

 

LIKE 패턴 매칭을 IN으로 바꿔 CPU 99% → 20% 으로 사용률 감소

CPU 99% 병목의 주범이 LIKE 패턴 매칭이라는 것을 파악한 후, 저는 이를 어떻게 제거할 수 있을지에 대한 방법을 고민하기 시작했습니다. 핵심은 최대한 연산을 줄이고 단순 문자열 비교를 활용하는 것이었습니다.

그리고 답은 IN 절에 있다고 판단했습니다. IN 절은 특정 값들의 리스트 내에 데이터가 포함되는지 여부만 확인하므로, LIKE보다 훨씬 효율적인 비교 연산이 가능합니다.

 

문제는 IN 절을 사용하려면 원본 상품의 8자리 HS 코드 전체가 필요하다는 것이었습니다. 사용자가 4자리 HS 코드를 선택했더라도, 실제 비교 대상은 그 하위에 속하는 모든 8자리 HS 코드여야 했습니다.

저는 다음과 같은 해결책을 구상했습니다.

  1. 사용자 커스텀 그룹별 HS 코드 리스트 사전 확보: 원본 수출 데이터를 가져오기 전에, 먼저 사용자가 생성한 커스텀 그룹과 HS 코드가 매핑된 테이블에서 각 그룹에 해당하는 모든 8자리 HS 코드 리스트를 미리 가져오는 작업을 수행했습니다. 이는 LIKE 패턴 매칭을 통해 이루어졌지만, 기존과는 큰 차이점이 있었습니다. 이전에는 원본 데이터 전체에 대해 LIKE 연산을 수행했지만, 이제는 업데이트된 최신 데이터만 필터링한 후, 그 데이터 범위 내에서만 LIKE 패턴 매칭을 수행했습니다. 
  2. IN 절을 활용한 멀티스레드 처리: 각 커스텀 그룹별로 확보된 8자리 HS 코드 리스트는 이제 IN 절의 인자로 활용될 수 있었습니다. 이제 멀티스레드 환경에서 각 스레드가 특정 커스텀 그룹에 할당된 HS 코드 리스트를 가지고, 업데이트된 원본 데이터로부터 해당 HS 코드를 IN 절로 효율적으로 SELECT 해 올 수 있게 되었습니다.

다음과 같은 형태로 구현되었습니다.

// 핵심 로직 개념 (실제 구현은 더 복잡할 수 있습니다)
// 1. 최신 거래일자 데이터 조회
LocalDate tradeDate = tradeMapper.selectLatestDataDate();

// 2. 사용자가 커스텀하게 정의한 모든 HS 코드와 그룹 매핑 정보 조회
// 이 과정에서 'LIKE 패턴 매칭'이 사용되지만, 대상 데이터가 훨씬 적고 (매핑 테이블),
// 이후 원본 데이터 조회 시에는 IN 절을 사용할 수 있도록 데이터를 미리 준비하는 역할을 합니다.
List<TradeGroupHscode> hscodes = tradeMapper.selectAllHscodesByTradeDate(tradeDate);
Map<Integer, List<String>> hscodeMap = hscodes.stream()
    .collect(Collectors.groupingBy(
        TradeGroupHscode::getGroupId, // 커스텀 그룹 ID
        Collectors.mapping(TradeGroupHscode::getHscode, Collectors.toList()) // 해당 그룹의 모든 8자리 HS 코드 리스트
    ));

// 3. 멀티스레드 환경에서 각 그룹별로 데이터 업데이트 실행
ExecutorService executor = Executors.newFixedThreadPool(THREAD_NUMBER); // 20개 스레드 풀 (환경별로 개수는 다르게 설정)
List<CompletableFuture<Void>> futures = hscodeMap.keySet().stream().map(groupId ->
    CompletableFuture.runAsync(() -> {
        // 각 스레드에서 특정 커스텀 그룹 ID와 해당 그룹의 HS 코드 리스트를 가지고
        // 원본 데이터에서 IN 절 쿼리를 통해 효율적으로 데이터를 가져와 업데이트 수행
        customTradeUpdateService.getCustomDataByTradeDateAndGroupIdAndHscode(
            tradeDate, groupId, hscodeMap.get(groupId)
        );
    }, executor)
).toList();
// 모든 스레드 작업 완료 대기 로직 추가

 

이처럼 LIKE 패턴 매칭을 IN 절로 변환하고 멀티스레드를 활용하니 MySQL의 CPU 사용률은 99%에서 20% 이하로 뚝 떨어졌고, 이제 스레드 수 증가에 따른 실제적인 성능 향상을 체감할 수 있게 되었습니다.

 

개선 이후 CPU 사용률

 

 

배치 성능 10분 → 25초, 사용자 경험 개선!

이러한 개선의 결과는 배치 처리 시간의 대폭 단축으로 이어졌습니다. 기존에 무려 10분이 걸리던 사용자 커스텀 그룹 데이터 업데이트 배치가, 이제는 25초 만에 완료되었습니다.

최신 수출 데이터가 업데이트되면 이제는 빠르게 자신이 설정한 커스텀 그룹의 최신 데이터를 확인할 수 있게 된 것입니다. 

 

이 일련의 문제 해결 과정을 통해 저는 단순히 멀티스레드나 scale-up 으로 성능 개선이 무조건 이루어진다는 게 아니라는 점을 깨달았습니다.

성능 개선을 위해서는 시스템의 병목 지점을 정확히 파악하고, 그 근본 원인을 해결해야 한다는 것을 깨닫게 되었습니다.

 

특히, 애플리케이션의 성능 병목을 진단할 때 CPU, 디스크 I/O, 네트워크 등 다양한 지표를 단계적으로 확인하고 추론하는 능력을 기를 수 있었습니다. 

'트러블슈팅' 카테고리의 다른 글

Redis 캐싱했는데도 느렸던 이유, RTT가 숨은 범인이었다  (2) 2025.04.22
멀티 스레드로 병렬 처리하는것은 항상 성능에 이점이 있을까?  (0) 2025.01.29
LLM 기반 보고서 자동 요약 프롬프트 최적화 전략 (내용 누락 트러블 슈팅)  (0) 2024.11.27
주식 시황 피드의 종가 오류 해결기(데이터 일관성 개선)  (0) 2024.07.30
Confluent Schema Reference 관련 문제  (0) 2024.07.24
'트러블슈팅' 카테고리의 다른 글
  • Redis 캐싱했는데도 느렸던 이유, RTT가 숨은 범인이었다
  • 멀티 스레드로 병렬 처리하는것은 항상 성능에 이점이 있을까?
  • LLM 기반 보고서 자동 요약 프롬프트 최적화 전략 (내용 누락 트러블 슈팅)
  • 주식 시황 피드의 종가 오류 해결기(데이터 일관성 개선)
seungjjun
seungjjun
  • seungjjun
    개발이야기
    seungjjun
  • 전체
    오늘
    어제
    • 분류 전체보기
      • 성장이야기
        • TIL
        • 주간회고
      • Java
        • Spring
        • Spring Security
      • 트러블슈팅
      • Kafka
      • OS
      • Network
      • 메가테라
      • Database
      • Algorithm
      • Git
      • HTML
      • CSS
      • 독서
      • 컴퓨터 이해하기
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    redis
    이커머스 프로젝트
    항해플러스
    메가테라
    graphQL
    개발일지
    주간회고
    항해99
    Til
    메가테라 주간회고
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
seungjjun
아무리 스레드를 늘려도 성능 개선에 소용없던 이유 (MySQL CPU 사용률 99%)
상단으로

티스토리툴바