검색 API 최적화 여행기

2025. 4. 11. 01:22·Tech/Develop

들어가기 앞서..

개발이 끝난 후, 코드를 검증해보기 위해 성능테스트를 진행하고 끔찍한 쿼리의 응답속도와 비효율적인 코드에 의한 병목을 발견하고 개선해나가는 경험을 쓴 기록입니다.
해당 경험에 앞서 효율적인 최적화를 위해 MySQL 공식문서의 최적화 문서를 읽고 고민했습니다.
약 1주일간 성능테스트와 리팩토링을 진행하며 경험과 느낀점을 공유하고자 합니다.

성능테스트를 진행한 이유

스마일게이트 데브캠프를 진행하며 코드리뷰 플랫폼 개발을 진행했습니다.

 

플랫폼이란 많은 사람이 쉽게 이용하거나 다양한 목적으로 사용되는 공간을 차용해서 일컫습니다.

IT 서비스에서 가장 중요한 가치는 편리성이라고 생각합니다.

 

애플리케이션 레벨에서의 편리성은 많은 사람을 받아들일 수 있는 능력과 받아들인 모두에게 좋은 경험을 제공하는 능력 이라고 생각합니다.

 

실제로 많은 기업에서는 고객의 좋은 경험을 위해 심지어 이미 배포된 API여도 성능테스트를 하며 리팩토링을 통해 개선해 나갑니다.

 

Ref. 카카오페이 온라인 결제 서비스 2.5배 성능 개선기

 

카카오페이 온라인 결제 서비스 2.5배 성능 개선기 | 카카오페이 기술 블로그

카카오페이 온라인 결제 시스템의 성능 개선 기록을 공유합니다.

tech.kakaopay.com

 

인용한 사례처럼 많은 경우에서 성능테스트를 필요로 합니다.

 

앞으로의 상황을 미리 대비하여 부하 테스트를 수행함으로써 시스템의 안정성을 확보할 수 있기 때문입니다.

 

부하 테스트를 통해 시스템이 동시에 많은 사용자를 처리할 수 있는지를 확인하고, 필요한 경우 시스템을 최적화까지 가능합니다.

 

저의 경우에는 기업과 같이 고객의 경험을 위해서 진행한 테스트는 아니지만, 저의 코드가 동시성을 고려해야 하는 환경에서도 정상적으로 실행 하는지, 사이드 이펙트가 발생하는 것은 아닌지 즉, 의도치 않은 곳에서 다르게 동작하는 것이 아닌지를 검증하고 싶었습니다.

 

테스트 환경 및 상황

테스트 환경으로는 초기에는 Pinpoint + nGrinder를 통해 진행하였습니다.

Pinpoint

애플리케이션 성능 측정 (Application Performance Management: APM) 도구

  • 분산된 애플리케이션의 메세지를 추적할 수 있는 분산 트랜잭션 추적 가능
  • 애플리케이션 구성을 파악할 수 있는 애플리케이션 토폴로지 자동 발견
  • 대규모 서버군 지원을 통한 수평 확장성
  • 코드 수준의 가시성 제공

Pinpoint을 사용한 이유

  • 메서드 단위의 병목 모니터링이 가능하기 때문에 문제점을 빠르게 찾아낼 수 있음
  • 각 요청이 어떤 서비스들을 거치는지 흐름을 한눈에 파악가능
  • API에서 호출이 지연되거나 과도한 DB 쿼리가 발생하는 지점 정확히 도출 가능
  • 다른 APM (grafana+prometheus,JENNIFER APM)과 비교
    • 오픈소스이므로 무료로 사용이 가능하다
    • 분산된 서비스 간의 흐름을 트랜잭션 단위로 추적가능
    • 전체 요청 경로 시각화

 

nGrinder

스크립트 생성, 테스트 실행, 모니터링, 결과 보고서 생성을 동시에 수행 가능한 스트레스 테스트 플랫폼

  • Groovy/Jython 기반 스크립트 작성으로 유연한 테스트 시나리오 구성
  • 사용자 정의 라이브러리(jar, py, Maven) 연동 가능 – 확장성 뛰어남
  • 웹 기반 UI를 통한 테스트 시나리오 관리, 실행, 모니터링, 보고
  • 분산 에이전트 기반 부하 테스트 – 여러 지역에서 동시 실행 가능
  • 대상 서버 및 에이전트 상태 실시간 모니터링 지원

nGrinder를 사용한 이유

  • Java를 기반으로 스크립트를 쓸 수 있기 때문에 접근에 용이
  • 스크립트 작성을 통해 실제 사용자 행동을 시뮬레이션 할 수 있음

 

테스트 환경

테스트 환경은 다음과 같이 구성했습니다.

  • Pinpoint Collector와 Pinpoint Web은 GCP를 통해 배포
  • Local 환경에서 Pinpoint Agent와 함께 시스템 환경 구축
    • Spring 서버 1대
    • MySQL 8.0.33
    • nGrinder Agent
    • nGrinder Controller

 

테스트 환경을 이렇게 구성한 이유는?

원래 모든 구성을 로컬에서 하려고 했습니다..만 당연하게도 다음과 같은 문제가 발생했습니다.

  • 모니터링 환경 및 Agent의 리소스 점유
  • Pinpoint의 저장소인 HBase의 CPU 자원 부족으로 인한 프로세스 다운
  • 리소스 부족으로 인한 정확한 성능 측정 불가

따라서 APM 툴인 Pinpoint는 클라우드 환경에 구성하여 모니터링을 하고자 했고,

로컬에서도 모니터링하기 위해 nGrinder는 로컬에서 docker-compose를 통해 시스템과 함께 띄웠습니다.

 

테스트 구성

먼저 DB 구성입니다.

각 테이블에는 약 100만개의 데이터를 bulk 했습니다.

이 중, question 테이블과 review 테이블의 컬럼 중 content컬럼은 약 1500자의 데이터를 삽입했습니다.

 

부하는 다음과 같이 구성했습니다.

Total Vusers 100
Agent 1
Processes / Threads 4 / 25
Durations 00:05:00

 

1차 테스트

테스트 진행

테스트의 흐름은 다음과 같습니다.

질문 생성 -> 질문 조회 -> 리뷰 생성 -> 리뷰 조회 -> 질문 검색 -> 좋아요 Top 10 질문 조회 -> 코멘트 작성

 

1차 테스트는 원할하게 동작하는지를 먼저 확인하기 위해 기대 부하의 10%인

Total Vusers 10
Agent 1
Processes / Threads 2 / 5
Durations 00:01:00

 

로 설정하여 본격적인 부하테스트 전 실험으로 돌려보았습니다. 결과는 다음과 같습니다.

 

 

다음과 같이 부하가 없다시피한데도 불구하고 특정 요청에서 10초가 넘는 응답속도가 발생했습니다.

 

 

Pinpoint를 통해 메서드 단위로 응답속도를 확인해본 결과 검색요청에 문제가 있는 것을 확인할 수 있었습니다.

 

결과 분석

 

메서드 분석을 보면 모든 병목은 DB의 쿼리에서 발생한다는 것을 알 수 있었습니다.

해당 쿼리는 다음과 같이 이루어져있었습니다.

 

@Override
public Page<Question> findQuestionsByKeyword(String keyword, Pageable pageable) {
    List<Question> results = queryFactory
            .select(question)
            .from(question)
            .where(question.content.contains(keyword)
                    .or(question.metaData.title.contains(keyword)))
            .orderBy(question.createdAt.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    long totalCount = Optional.ofNullable(
            queryFactory.select(question.count())
                    .from(question)
                    .where(question.content.contains(keyword)
                            .or(question.metaData.title.contains(keyword)))
                    .fetchOne()
    ).orElse(0L);

    return new PageImpl<>(results, pageable, totalCount);
}

 

QueryDSL을 통해 코드로 작성한 해당 쿼리를 SQL 문으로 바꾸면 다음과 같습니다.

 

-- 데이터 조회
SELECT q.*
FROM question q
WHERE q.content LIKE '%클라우드%'
   OR q.title LIKE '%클라우드%'
ORDER BY q.created_at DESC
LIMIT 10 OFFSET 10;

-- 전체 개수 조회
SELECT COUNT(*)
FROM question q
WHERE q.content LIKE '%클라우드%'
   OR q.title LIKE '%클라우드%';

 

MySQL에서 검색을 하기 위해 LIKE 연산을 사용하였고, 페이징을 하기 위해 전체 행의 수를 카운트 하기 위해 두번의 쿼리를 날렸습니다.

하지만 해당 쿼리가 동작하는데 무려 31초가 걸렸고, 이를 분석하기 위해 쿼리 실행 계획은 분석했습니다.

 

-- 검색 및 페이지 오프셋 쿼리
-> Limit/Offset: 10/10 row(s)  (cost=420920 rows=10) (actual time=9059..9059 rows=0 loops=1)
    -> Sort: question.created_at DESC, limit input to 20 row(s) per chunk  (cost=420920 rows=649146) (actual time=9059..9059 rows=0 loops=1)
        -> Filter: ((question.content like '%클라우드%') or (question.title like '%클라우드%'))  (cost=420920 rows=649146) (actual time=9059..9059 rows=0 loops=1)
            -> Table scan on question  (cost=420920 rows=649146) (actual time=0.528..3084 rows=1e+6 loops=1)

-- 전체 행 카운트 쿼리
-> Aggregate: count(0)  (cost=452309 rows=1) (actual time=9117..9117 rows=1 loops=1)
    -> Filter: ((q.content like '%apple%') or (q.title like '%apple%'))  (cost=420920 rows=136228) (actual time=9116..9116 rows=0 loops=1)
        -> Table scan on q  (cost=420920 rows=649146) (actual time=0.544..2940 rows=1e+6 loops=1)

 

해당 실행 계획을 분석하면 다음과 같습니다.

  1. 테이블에 대하여 전체 스캔
  2. 모든 행에 대하여 title과 content 컬럼 LIKE 연산
  3. created_at을 기준으로 내림차순 정렬
  4. 해당 데이터들 중 10개 행을 골라 출력

 

계획을 보면 알 수 있듯 쿼리의 문제점이 두곳에서 드러납니다.

  1. 테이블에 대하여 전체 스캔
  2. 모든 행에 대하여 title과 content 컬럼 LIKE 연산

 

MySQL은 LIKE 연산을 진행할 때 2자 이하일 경우 브루트포스 알고리즘으로 탐색하고,

3자 이상일 경우 Turbo Boyer-Moore 알고리즘을 통해 패턴 매칭을 진행합니다.

https://dev.mysql.com/doc/refman/8.0/en/index-btree-hash.html

 

MySQL :: MySQL 8.0 Reference Manual :: 10.3.9 Comparison of B-Tree and Hash Indexes

10.3.9 Comparison of B-Tree and Hash Indexes Understanding the B-tree and hash data structures can help predict how different queries perform on different storage engines that use these data structures in their indexes, particularly for the MEMORY storage

dev.mysql.com

 

보이어 무어의 시간복잡도는 문자열의 길이가 n, 패턴의 길이가 m 일때, O(mn)입니다.

따라서 제 쿼리의 연산 속도를 빅오(O(n)) 표기법으로 대략 계산하면 다음과 같습니다.

row 수 (r): 1,000,000 (100만)
텍스트 길이 (n): 2,000자
패턴 길이 (m): 4자

O(n x m x r) = O(1,000,000 × 2,000 × 4) = O(8,000,000,000)

 

최악의 경우에는 O(8,000,000,000) 이라는 시간 복잡도가 나오기 때문에 앞서 쿼리가 31초라는 시간이 걸리는 것이 이해가 되었습니다.

 

개선 과정

결국 제가 개선해야할 부분은 다음 두가지로 줄어들었습니다.

  1. 전체 테이블 스캔
  2. O(mn)으로 패턴을 비교하고 있는 문자열 검색

이 두가지를 한번에 해결하기 위해 ngram parser를 기반으로한 Full-Text Index를 적용했습니다.

 

Full-Text Index

Full-Text Index란 말그대로 모든 text를 인덱싱 하여 빠르게 해당 단어를 가지고 있는 행을 탐색하는 전략입니다.

한국어와 같은 체언과 조사 등 복잡한 문법을 가지고 있는 언어는 문자 단위의 ngram parser 통해 인덱싱을 하는 것이 효과적입니다.

ALTER TABLE question
ADD FULLTEXT INDEX ft_title_content (title, content)
WITH PARSER ngram;

 

해당 쿼리를 통해 content와 title 두 가지 열을 한번에 Full-Text를 인덱싱 하는 쿼리를 실행하였습니다.

더보기

작은 이슈

 

100만개의 데이터를 가지고 있는 테이블에 한번에 FULLTEXT 인덱스를 만들려고 하니 에러가 발생했습니다.

그런데, 실제로는 MySQL과의 연결이 타임아웃으로 끊긴 것이었고 DDL 명령은 MySQL 백그라운드에서 실행되고 있었습니다.

로그를 꼭 잘 확인하는 습관을 가지고 있자! (안 읽었으면 삽파고 있을 뻔..)

 

FULLTEXT 인덱스를 생성하고 쿼리를 다시 작성했습니다. FULLTEXT 인덱스를 사용하기 위해서는 MATCH-AGAINST를 통해 검색을 해야합니다.

SELECT *
FROM question
WHERE MATCH(content, title) AGAINST ('클라우드')
ORDER BY created_at DESC
LIMIT 10;

 

 

앞선 쿼리의 실행 계획을 다시한번 EXPLAIN ANALYZE를 통해 분석하였습니다.

-- MATCH-AGAINST 쿼리
-> Limit/Offset: 10/20 row(s)  (cost=1.09 rows=0) (actual time=0.945..0.972 rows=10 loops=1)
    -> Sort row IDs: question.created_at DESC, limit input to 30 row(s) per chunk  (cost=1.09 rows=1) (actual time=0.889..0.969 rows=30 loops=1)
        -> Filter: (match question.title,question.content against ('제목'))  (cost=1.09 rows=1) (actual time=0.395..0.705 rows=59 loops=1)
            -> Full-text index search on question using ft_title_content (title = '제목')  (cost=1.09 rows=1) (actual time=0.324..0.628 rows=59 loops=1)

-- 결과 전체 카운트 쿼리
'-> Rows fetched before execution  (cost=0..0 rows=1) (actual time=84e-6..84e-6 rows=1 loops=1)\n'

 

해당 실행 계획을 분석하면 다음과 같습니다.

  1. Full-text index 스캔
  2. MATCH-AGAINST 연산을 통한 검색
MATCH-AGAINST 연산이란?
InnoDB 또는 MyISAM 엔진에서 지원하는 연산자로 키워드를 통해 문서를 찾는 역색인 구조의 연산입니다.
따라서, 실제로 B-Tree 형태로 구성된 역색인을 사용하기 때문에 key 검색 시 O(log N)의 시간복잡도를 가집니다.
또한, 기본모드(자연어 모드)에서는 검색결과를 단어의 빈도수를 기준으로 점수를 매기는 TF-IDF 기반으로 관련도 점수를 매겨 정렬합니다.

https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html#function_match

 

MySQL :: MySQL 8.0 Reference Manual :: 14.9 Full-Text Search Functions

14.9 Full-Text Search Functions MATCH (col1,col2,...) AGAINST (expr [search_modifier]) search_modifier: { IN NATURAL LANGUAGE MODE | IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION | IN BOOLEAN MODE | WITH QUERY EXPANSION } MySQL has support for full-text i

dev.mysql.com

 

분석을 통해 알 수 있듯이

  • Full Table scan -> Full-text index scan (O(r) -> O(log r))
  • LIKE 연산 -> MATCH-AGAINST 연산 (O(mn) -> O(1), 역색인이므로 문자열 매칭을 하지 않음)

두가지를 개선하여 20s -> 970ms 로 응답속도를 개선하였습니다.

 

그런데.. 아직 문제가 남아있었습니다.

 

Full-Text Index의 허점 아닌 허점

Full-Text Index를 적용하여 20s -> 970ms 로 개선하는 기염을 토했습니다!

하지만 한가지 허점이 있었는데요.

모든 행에서 비슷한 문맥을 가지고 있다면, Full-Text Index는 Full-Table scan과 다를게 없다.

 

당연한 소리겠지만, 빈도수가 높은 단어로 검색을 진행할 경우 결국 모든 테이블을 탐색하는 것과 다를 것이 없었습니다.

 

물론, O(mn)의 시간복잡도를 가진 LIKE 연산보다 O(log i)의 시간복잡도를 가진 MATCH-AGAINST가 아직까진 더 빠르지만, 근본적으로 가장 높은 비용을 가진 Full-Table scan을 야기해버립니다.

 

이렇게 빈도수가 높은 단어를 계속해서 요청한다면 결국은 부하를 견딜 수 없을 것입니다.

이 문제를 해결하기 위해 조금 더 거시적인 관점에서 바라보았습니다.

 

구조적으로 Full-Text Index를 사용하더라도, 모든 행이 인덱싱의 대상이 되면 해결할 수 없다.
해결할 수 없다면, 첫 번째 쿼리만 오버헤드를 감수하고 두 번째 요청부터는 캐싱된 데이터를 내보내자!

 

그렇게 캐싱을 도입하게 되었습니다.

 

캐시 도입

캐시 도입에 앞서 다음과 같은 고민을 했습니다.

로컬 캐시, 글로벌 캐시 ... 로컬 캐시는 Spring Cache, Caffeine Cache... 글로벌 캐시는 Redis, Memcached ...
아.. 너무 많다! 왜 쓰는지 모를 땐 일단 만들어보자!

 

캐시 도입에 대한 이야기는 다음 링크를 참고해주세요

https://marstech.tistory.com/18

 

MySQL과 같은 전문 검색을 강력하게 지원하지 않는 DBMS에서는 방대한 데이터를 검색하기에는 한계가 있습니다.

이로인해 검색 DB를 분리하게 된다면, 자연스럽게 확장성에 대한 고려를 하지 않을 수 없었습니다.

 

따라서, 확장성을 고려하기 위해 글로벌 캐시를 선택하였습니다.

그리고! 이제 대망의 부하 테스트를 진행해보았습니다.

 

2차 테스트

2차 테스트는 1차 테스트 진행 도중 쿼리 최적화 이슈로 못다했던 테스트를 진행했기 때문에 앞선 환경과 동일 + Redis가 추가 되었습니다.

 

테스트 부하

Total Vusers 100
Agent 1
Processes / Threads 4 / 25
Durations 00:05:00

 

Redis 구성 이유

캐시를 빠르게 쓰기, 읽기를 수행하기 위해 인메모리 기반의 Redis를 사용하였습니다. Memcached와 같은 다른 시스템도 있지만 객체를 저장하기 때문에 직렬화 역직렬화가 용이한 Redis를 선택했습니다.

 

Redis를 사용하기 위한 자바 구현체로 Jedis와 lettuce를 비교했습니다.

항목 Jedis Lettuce
기반 방식 블로킹 I/O (BIO) 넌블로킹 I/O (NIO / Netty 기반)
멀티스레드 환경 연결 객체를 여러 스레드가 공유 불가
(커넥션 당 1스레드)
Thread-safe (커넥션 공유 가능)
비동기 지원 없음 (동기 전용) 완전 지원 (async, reactive, future)
Spring 기본 클라이언트 수동 설정 필요 Spring Data Redis 기본 클라이언트
성능 (동시성) 적은 스레드, 단순 구조에선 빠름 고부하 환경에서 우수한 확장성
커넥션 풀 필요 (JedisPool) 기본적으로 필요 없음 (Netty event loop)

 

비즈니스 로직에서 ThreadLocal을 사용하지 않기 때문에 비동기로 요청을 날려도 상관이 없고, 오히려 비동기를 통해 네트워크 비용이 발생해도 이를 최소화 해야하기 때문에 Lettuce 구현체를 사용하였습니다.

테스트 진행

평균 응답시간 3초로 MySQL과 Redis의 응답속도에 비하여 애플리케이션의 평균 응답속도가 긴 것을 알 수 있습니다.

역시 Full-Text Index의 허점으로 인하여 캐싱이 되기 전 처음 쿼리에는 병목이 발생하다가 해소가 되는 것을 볼 수 있습니다.

테스트의 TPS 그래프 입니다. 평균 21.3의 TPS가 나오고 있습니다. 첫 병목이 해결된 후 평균 약 40TPS 이상을 기록하고 있습니다.

 

결과 분석

제 예상으로는 첫 요청 이후에는 병목이 해소되고 안정화 될 것이라고 생각했습니다.

하지만 예상과는 달리 병목이 다소 해소 되었지만 평균 응답속도가 두 DB의 응답속도에 비하여 현저히 떨어지는 것을 볼 수 있었습니다.

 

이를 분석하기 위해 다시한번 메서드 단위로 응답속도를 모니터링 했습니다.

커넥션 지연 문제 발생

 

메서드 단위로 분석하여 원인을 한번에 찾을 수 있었습니다.

바로 커넥션 지연 문제였습니다.

 

로직도 빠르게 진행되었고, DB 역시 쿼리에 대한 응답을 빠르게 주었습니다.

하지만 getConnection() 메서드에서 무려 7초 이상의 지연이 발생했습니다.

 

커넥션 지연에 대한 문제를 모니터링 자료를 통해 두 가지 알 수 있었고 해결에 들어갔습니다.

 

개선 과정

@Transactional(readOnly = true) 문제

 

해당 요청은 자료를 보면 알 수 있듯 Cache HIT이 되어 MySQL을 사용하지 않고 Redis에 요청에 대한 응답 데이터를 반환합니다.

하지만 HikariDataSource에서 getConnection()을 호출하여 커넥션을 잡아먹는 것을 볼 수 있었습니다.

이 문제는 바로 트랜잭션 설정 범위에 있었습니다.

    @Transactional(readOnly = true)
    public Page<QuestionResponse> findQuestionsByKeyword(String keyword, int page, int size) {
        return questionQueryService.findQuestionsByKeyword(keyword, page, size);
    }

 

기존 코드에서는 유지보수를 위해 usecase 레이어에서 트랜잭션을 일괄적으로 관리하고 있었습니다.

 

하지만 서비스 레이어에서 캐시 미스, 히트 시 로직을 처리하고 있었기 때문에 일단 트랜잭션을 열고 캐시 히트인지 미스인지 판단해버리고 있었습니다.

 

리팩토링 과정에서 트랜잭션에 관한 코드가 API 모듈에 있는 것 역시 바람직하지 못하다고 생각하여 모두 도메인 모듈로 메서드 별로 트랜잭션을 관리하도록 리팩토링 하였습니다.

 

HikariCP의 커넥션 풀 부족

위 사진에서도 볼 수 있듯이 기본적으로 커넥션이 부족하여 getConnection() 메서드에서 병목이 발생했습니다.

캐싱이 되기 전 검색의 첫 요청에서 병목 발생 + 앞선 트랜잭션 문제로 커넥션에 병목이 발생한 것으로 판단했습니다.

 

트랜잭션 문제는 해결했지만 요청에 비하여 HikariCP의 최대 커넥션풀의 갯수가 너무 적다고 생각하여 HikariCP 설정을 조정하고자 했습니다.

HikariCP

HikariCP는 높은 비용의 Connection 객체를 미리 할당하고 가져다 쓰는 JDBC Connection Pool 한 종류의 구현체입니다.
HikariCP의 대표적인 설정으로 두가지가 있습니다.

maximumPoolSize: 풀에서 동시에 사용할 수 있는 최대 커넥션 수
minimumIdle: 풀에서 미리 확보해두는 유휴 커넥션 수

 

HikariCP 전략으로, 부하를 견뎌야 하기 때문에 minimumIdle을 maximumPoolSize과 맞추어 처음부터 전부 할당하고 시작하면 중간에 병목이 생길 일이 없다고 생각했습니다.

 

maximumPoolSize은 현재 MySQL의 평균 응답 속도가 31ms이고, 부하는 1초에 100번의 사용자 흐름이 부하되기 때문에

단순 계산으로 1초에 200번의 MySQL 요청 * 요창 1개 당 31ms = 6200ms 이므로 0.1ms 미만의 응답속도를 얻기 위해서는 maximumPoolSize을 60개로 가져가면 되겠다 라고 생각했습니다.

 

이것이 로컬환경임을 인지하지 않고 말이죠..

 

HikariCP Maximum 60개로 증가

결과를 보면 알 수 있듯이 오히려 TPS가 떨어져 버렸습니다..

왜그런걸까요?

현재 로컬환경에서는 docker를 이용하여 docker-compose를 통해
spring 서버, mysql, redis, pinpoint agent, ngrinder agent, ngrinder collector를 띄우고 있습니다.
또한, docker의 리소스 설정으로 cpu 11코어 램 12G 로 제한되어 있습니다.

따라서, 하드웨어 리소스가 받쳐주지 않는 상황에서 무리하게 Connection Pool을 늘렸기 때문에 
오히려 컨텍스트 스위칭의 비용과 동기적 처리 방식으로 인하여 병목이 심해진 것입니다.

 

욕심으로 인해 하드웨어 리소스를 고려하지 않고 무리하게 늘린 대가를 치뤘습니다..

그럼 어떻게 적절한 Max Connection Pool Size를 정할 수 있을까요?

 

HikariCP 공식 Github에 해당 공식이 있었습니다.

https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing

 

About Pool Sizing

光 HikariCP・A solid, high-performance, JDBC connection pool at last. - brettwooldridge/HikariCP

github.com

공식 문서에 따르면 벤치마킹을 찍어본 결과 최적의 커넥션 수 공식은 다음과 같습니다.

connections = ((core_count * 2) + effective_spindle_count)

 

이 공식을 저의 시스템 환경에 대입하면

 

connections = ((11 * 2) + 0) = 22 로 추정되었습니다.

(minimumIdle == maximumPoolSize이므로 effective_spindle_count = 0)

 

하지만, 저의 로컬 환경에는 서버만 있는 것이 아니기 때문에 여유롭게 하기 위하여 최종적으로 15로 설정하였습니다.

 

3차 테스트

2차 테스트에서 발견한 이슈들을 모두 해결하고 드디어 대망의 3차 테스트를 진행하였습니다.

테스트의 신뢰도를 올리기 위해 2차 테스트의 환경과 부하를 그대로 가지고 진행했습니다.

 

테스트 진행

시스템이 안정적으로 부하를 견디는 것을 확인했습니다!

초반 병목을 제외하면 1초 미만의 응답속도를 나타내고 있습니다.

첫 병목 1분을 제외하면 평균 TPS가 80이상으로 부하에 알맞는 성능을 나타내고 있습니다.

 

최종 결과 정리

지금까지 저와 함께한 API 최적화 여정을 통해 다음과 같은 성과를 얻었습니다.

 

테스트 부하

Total Vusers 100
Agent 1
Processes / Threads 4 / 25
Durations 00:05:00

 

  • TPS 변화 추이
    • 평균 TPS: 21.9 -> 71.5
    • 최대 TPS: 66.3 -> 98
  • 평균 응답 속도 변화 추이
    • 6.1초 -> 1.5초
    • 1.5초는 아직까지 해결하지 못한 Full-Text Index로 처리하지 못하는 빈도수가 높은 검색어 처리에 의한 평균 증가로, 첫 요청 이후에는 평균 응답속도를 0.4초 이하로 뽑아내고 있습니다.

느낀점

지금까지 코드를 짜면서 제 코드에 대하여 검증을 해볼 기회가 없었습니다. 정확히 말하면 시간에 쫓겨 할 생각을 못했던 것 같습니다.
그러다 문득 실무에 나가기 위해서는 제 코드를 검증하고 개선해 나갈정도의 실력은 돼야하지 않을까 라는 생각을 하여 리팩토링을 진행했습니다. 그리고 이 글과 같은 경험을 얻게 되었습니다.

막상 해보니 리팩토링을 하길 정말 잘했다는 생각이 듭니다. 아무 생각 없이 보기 이쁘게 모아놨던 어노테이션의 사이드 이펙트, 늘 건드리지 않았던 커넥션 풀, 캐시를 사용하는 이유, 이보다 최선이 없다고 생각했던 쿼리의 개선 등등.. 많은 것을 경험해 볼 수 있었습니다.

모든 문제들이 이번 경험처럼 성공적으로 진행되지 않을 수 있겠지만, 이번 경험을 토대로 문제를 분석하고 해결해나갈 수 있는 능력을 갖춘 개발자가 되었다는 생각이 들었습니다.

긴 글 읽어주셔서 감사합니다!!

'Tech > Develop' 카테고리의 다른 글

검색엔진 적용까지의 기록 - Elasticsearch  (0) 2025.05.07
캐시를 적용해보자! 본격 캐시 적용기  (0) 2025.04.13
[Spring Boot] '멀티 모듈이.. 뭐지..?' - 멀티 모듈 적용기(1)  (0) 2025.02.26
MSA 환경 CQRS 패턴 적용기 - (2) 프로젝트에 적용해보기  (0) 2024.08.07
MSA 환경 CQRS 패턴 적용기 - (1) CQRS  (0) 2024.08.04
'Tech/Develop' 카테고리의 다른 글
  • 검색엔진 적용까지의 기록 - Elasticsearch
  • 캐시를 적용해보자! 본격 캐시 적용기
  • [Spring Boot] '멀티 모듈이.. 뭐지..?' - 멀티 모듈 적용기(1)
  • MSA 환경 CQRS 패턴 적용기 - (2) 프로젝트에 적용해보기
마스9
마스9
마스의 개발블로그
  • 마스9
    Mars Tech
    마스9
  • 전체
    오늘
    어제
    • 분류 전체보기 (17)
      • Tech (6)
        • Spring (0)
        • Develop (6)
      • Study (6)
        • CS (5)
        • 알고리즘 (1)
      • Reference (5)
        • MySQL (5)
      • Do (0)
      • Daily (0)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
마스9
검색 API 최적화 여행기
상단으로

티스토리툴바