종목 검색 api

[종목 검색 API] 3. API 부하 테스트: debounce, Redis Cache, Redis 장애

kyoulho 2026. 5. 22. 14:55

1편에서는 PostgreSQL 검색 쿼리의 실행 계획을 확인했다.

이번에는 실제 자동완성 API에 부하를 걸어봤다. 단순히 “Redis를 붙이면 빨라지는가?”를 확인하려는 실험은 아니다.


자동완성 API에서 더 중요한 질문은 따로 있다.

사용자가 검색창에 입력할 때 서버에는 어떤 요청이 발생하는가?
Redis Cache는 DB 부하를 얼마나 줄이는가?
Redis가 운영 중 내려가면 병목은 어디로 이동하는가?


결론부터 말하면, 자동완성 검색 API의 1차 방어선은 Redis가 아니라 debounce였다.

Redis는 반복 요청을 흡수한다. 하지만 잘못된 입력 패턴 자체를 줄이지는 못한다.


자동완성 API의 요청 경로는 조회 중심으로 설계했다.

Redis
→ local stock_master
→ response


자동완성 API는 사용자의 입력 과정에서 반복 호출된다. 따라서 요청 경로에는 Redis 조회와 DB 검색처럼 예측 가능한 작업만 둔다.

stock_master는 검색 요청 경로 밖에서 배치를 통해 주기적으로 갱신한다.


현재 검색 API 구조

현재 검색 API는 Redis cache-aside 구조로 동작한다. 요청 흐름은 다음과 같다.

trim
  → len < 2 → emptyList, cache/DB 미사용
  → normalize → cache key 생성
  → Redis get
      hit → return
      miss / get error / CB bypass → local stock_master search
  → non-empty → cache put (TTL 300s)
  → empty     → cache put (TTL 30s)
  → return


Cache key는 다음과 같다.

stock-search:{symbol}:{nameKo}:{nameEn}:{limit}


TTL 정책도 단순하다.

결과 조건 TTL
결과 있음 local stock_master hit 300초
결과 없음 local stock_master empty 30초
검색어 길이 < 2 요청 차단 저장 안 함


이 흐름에서 검색 API의 책임은 명확하다.

저장된 stock_master를 조회한다.
반복 요청은 Redis로 흡수한다.
Redis가 실패하면 DB로 fallback한다.


검색 요청은 stock_master를 갱신하지 않는다. stock_master는 검색 요청 경로 밖에서 주기적으로 갱신한다.

따라서 local DB 검색 결과가 없으면 짧은 TTL로 empty result를 캐시한다.

local search result exists
→ 300초 캐시

local search result empty
→ 30초 negative cache

query length < 2
→ cache/DB 모두 사용하지 않음


자동완성 API의 요청 경로는 조회 중심이어야 한다.


측정 방법

API 부하는 k6로 만들었다. k6에서 본 값은 다음이다.

지표 의미
http_reqs API 요청 수
http_req_duration p(95) 대부분의 사용자가 체감하는 응답 시간
http_req_duration p(99) 느린 요청이 얼마나 튀는지
http_req_failed HTTP 실패율


DB query count는 pg_stat_statements로 확인했다.


테스트 전 통계를 초기화했다.

SELECT pg_stat_statements_reset();


테스트 후 검색 쿼리 호출 수를 확인했다.

SELECT calls,
       total_exec_time,
       mean_exec_time,
       rows,
       query
FROM pg_stat_statements
WHERE query ILIKE '%stock_master%'
ORDER BY calls DESC;


여기서 가장 중요한 값은 calls다.

calls = 실제 PostgreSQL 검색 쿼리 실행 수


k6 http_reqs와 pg_stat_statements.calls는 같을 필요가 없다.

Redis hit이 발생하면 API 요청은 존재하지만 DB query는 발생하지 않는다. 그래서 다음 비율을 함께 봤다.

DB query/request 비율 = DB search query count / k6 http_reqs


DB CPU는 Docker 기준으로 확인했다.

docker stats pwm-postgres


Redis hit / miss / error / bypass는 Micrometer Counter로 확인했다.

pwm.stock_search.cache.hit
pwm.stock_search.cache.miss
pwm.stock_search.cache.get_error
pwm.stock_search.cache.set_error
pwm.stock_search.cache.bypassed


테스트 시나리오

naive 입력 패턴

첫 번째는 debounce가 없다고 가정했다. 즉 사용자가 입력하는 중간 상태가 모두 서버로 전달된다. 자동완성에서 흔히 실수하는 방식이다.

테스트 요청은 한글 종목명, 한국 숫자 티커, 미국 티커, 영문 검색어, 존재하지 않는 검색어를 섞었다.

한글 종목명/테마
삼 → 삼ㅅ → 삼서 → 삼성
반 → 반ㄷ → 반도 → 반돛 → 반도체
카 → 캌 → 카카 → 카캌 → 카카오

한국 숫자 티커
0 → 00 → 005 → 0059 → 00593 → 005930
0 → 00 → 000 → 0006 → 00066 → 000660
0 → 03 → 035 → 0357 → 03572 → 035720

미국 티커
A → AA → AAP → AAPL
T → TS → TSL → TSLA
N → NV → NVD → NVDA

영문 검색어
ap → app → appl → apple
te → tes → tesl → tesla
sa → sam → sams → samsung

없는 검색어
없 → 없는 → 없는종 → 없는종목
z → zz → zzz → zzzz → zzzznotfound
테 → 텟 → 테스 → 테스트 → 테스트검색어
1 → 12 → 123 → 1234 → 12345 → 123456



debounced 입력 패턴

두 번째는 debounce가 적용되었다고 가정했다. 중간 입력 상태는 서버로 보내지 않고 최종 검색어만 요청한다. 다만 삼성, 레버리지, AAPL만 반복하지 않았다. 그렇게 하면 Redis hit이 지나치게 잘 나오는 이상적인 테스트가 된다.

그래서 debounced 테스트도 현실적인 검색어 그룹을 섞었다.

한국 종목명/테마
삼성, 레버리지, 반도체, 현대차, 카카오

한국 숫자 티커
005930, 000660, 035420, 035720, 373220

미국 티커
AAPL, TSLA, NVDA, MSFT, GOOGL

영문 검색어
apple, tesla, samsung, semiconductor, energy

없는 검색어
없는종목, zzzznotfound, 삼성없는, 123456, 테스트검색어

 

운영 중 Redis 장애

세 번째는 Redis 장애 테스트다. Redis를 테스트 전에 끄지 않았다. k6 부하가 진행 중인 상태에서 Redis를 중단했다. 이 테스트의 목적은 단순히 API가 실패하는지 보는 것이 아니다. 운영 관점에서는 Redis가 내려갔을 때 병목이 어디로 이동하는지가 더 중요하다.

장애 경로는 단순하다.

Redis get 실패
→ local DB fallback
→ Redis set 실패
→ response


즉 봐야 할 것은 이것이다.

Redis 장애가 API 실패로 전파되는가?
Redis 장애 후 tail latency가 얼마나 튀는가?
DB가 fallback 부하를 감당하는가?
Redis 실패를 계속 기다리는 구조인가?


naive 입력 패턴 결과

항목
k6 http_reqs 4,946
p95 latency 53.8ms
p99 latency 172.98ms
http_req_failed 0.00%
DB search query count 1,065회
DB search query/request 비율 21.5%
DB mean_exec_time 27.04ms
DB CPU max 174.98%
Redis hit count 2,724
Redis miss count 1,065
Redis hit ratio 71.9%
Redis error count 0


naive 입력 패턴에서는 4,946건의 API 요청이 발생했다. 이 중 실제 검색 쿼리는 1,065회 실행되었다.

Redis hit은 2,724건, miss는 1,065건이었다. Redis hit ratio는 71.9%였다.

수치만 보면 Redis가 어느 정도 일을 했다. 하지만 naive 패턴의 문제는 여전히 남아 있었다.

사용자 입력 중간 상태가 모두 서버로 들어오기 때문이다.

 

debounced 입력 패턴 결과

항목
k6 http_reqs 1,770
p95 latency 22.93ms
p99 latency 219.35ms
http_req_failed 0.00%
DB search query count 501회
DB search query/request 비율 28.3%
DB mean_exec_time 1.26ms
DB CPU max 38%
Redis hit count 1,268
Redis miss count 501
Redis hit ratio 71.7%
Redis error count 0


naive와 비교하면 다음과 같다.

항목 naive debounced 변화
k6 http_reqs 4,946 1,770 -64.2%
p95 latency 53.8ms 22.93ms -57.4%
p99 latency 172.98ms 219.35ms +26.8%
DB search query count 1,065 501 -53.0%
DB CPU max 174.98% 38% -78.3%
Redis hit ratio 71.9% 71.7% 거의 동일


Debounce 적용 후 API 요청 수는 4,946건에서 1,770건으로 줄었다. 요청 수는 64.2% 감소했다.

DB 검색 쿼리도 1,065회에서 501회로 줄었다.

DB CPU max는 174.98%에서 38%로 낮아졌다.

중요한 점은 Redis hit ratio가 크게 올라간 것이 아니라는 점이다. naive와 debounced의 Redis hit ratio는 각각 71.9%, 71.7%로 거의 비슷했다.

 

이번 개선의 핵심은 hit ratio 상승이 아니었다. 요청 수 자체를 줄여서 Redis miss와 DB query의 총량을 줄인 것이다. 다만 p99는 오히려 172.98ms에서 219.35ms로 증가했다. p99는 일부 느린 요청의 존재를 보여준다. 평균이나 p95만 보고 끝내면 이런 요청을 놓친다.


정리하면 다음과 같다.

현실적인 검색 패턴에서는 debounce가 DB 부하를 크게 줄인다.
하지만 p99 문제를 완전히 제거하지는 못한다.
Redis hit ratio만 볼 것이 아니라, miss 경로와 tail latency를 함께 봐야 한다.


운영 중 Redis 장애 테스트 결과

Redis가 정상 동작하는 상태에서 debounced 입력 패턴으로 실행했다. 그 다음 테스트 중간에 Redis를 중단했다.

측정 결과는 다음과 같았다.

항목
k6 http_reqs 1,254
p95 latency 2.56s
p99 latency 3.55s
max latency 4.26s
http_req_failed 0.00%
DB search query count 812회
DB mean_exec_time 12.05ms
DB CPU max 103.65%
Redis error count 1,079


HTTP 실패율은 0%였다. 즉 Redis 장애가 API 5xx로 직접 전파되지는 않았다.

하지만 성능은 크게 흔들렸다. Redis 정상 상태의 debounced 테스트에서는 p95가 22.93ms였다. Redis 장애 시 p95는 2.56초까지 증가했다. 이것은 단순히 DB 쿼리가 느려져서 생긴 문제로 보기 어렵다. DB 검색 쿼리의 평균 실행 시간은 12.05ms였다.

즉 tail latency의 상당 부분은 다음 경로에서 발생했을 가능성이 높다.

Redis get 실패 대기
→ DB fallback
→ Redis set 실패 대기


로그에서도 Redis get/set error가 반복적으로 발생했다.

Lettuce는 Redis가 내려간 뒤에도 재연결을 계속 시도했다.

Cannot reconnect to localhost:6379


Redis 장애 테스트에서 확인한 것은 다음이다.

질문 결과
API가 실패했는가? 아니다. http_req_failed=0.00%
Redis 장애 후 tail latency가 튀었는가? 그렇다. p95 2.56s, p99 3.55s
DB가 완전히 병목이 되었는가? DB CPU max 103.65%, DB mean 12.05ms로 DB만의 문제는 아니었다.
Redis timeout / 재연결 대기가 영향을 줬는가? 가능성이 높다. Redis error 1,079회와 Lettuce reconnect 로그가 반복되었다.
fallback만으로 충분한가? 아니다. 실패율은 막았지만 latency는 막지 못했다.


정리하면 다음과 같다.

현재 구조는 Redis 장애 시 API 실패는 막는다.
하지만 tail latency는 막지 못한다.
fallback만으로는 부족하고, Redis 장애를 감지한 뒤 일정 시간 Redis 호출을 우회하는 circuit breaker가 필요하다.


최종 정리

테스트 https_reqs p95 p99 DB query count DB cpu max Redis hit ratio 비고
naive 4,946 53.8ms 172.98ms 1,065 174.98% 71.9% 입력마다 요청
debounced 1,770 22.93ms 219.35ms 501 38% 71.7% 최종 검색어만 요청
Redis 장애 1,254 2.56s 3.55s 812 103.65% 측정 제외 운영 중 Redis 중단


이번 실험의 결론은 세 가지다.


첫째, 자동완성 검색 API에서 가장 먼저 줄여야 할 것은 DB 쿼리가 아니라 불필요한 입력 이벤트 요청이다. 현실적인 naive 입력 패턴에서는 API 요청이 4,946건 발생했고, DB 검색 쿼리는 1,065회 실행되었다. Debounce 적용 후 API 요청은 1,770건으로 줄었고, DB 검색 쿼리도 501회로 감소했다.


둘째, Redis hit ratio만 보면 안 된다. 현실적인 시나리오에서는 naive와 debounced의 Redis hit ratio가 각각 71.9%, 71.7%로 거의 비슷했다. 하지만 요청 수 자체가 줄어들면서 DB query 총량과 DB CPU는 크게 낮아졌다.


셋째, Redis fallback만으로는 운영 안정성이 충분하지 않다. Redis 장애 시 HTTP 실패율은 0%였지만, p95는 2.56초, p99는 3.55초까지 증가했다. 즉 fallback은 API 실패를 막았지만, tail latency는 막지 못했다.


다음 병목 해결 방향

이번 실험 이후 해결해야 할 병목은 Redis 장애 시 tail latency였다.

Redis timeout 단축

Redis 장애 후 p95가 2.56초까지 튀었다.

DB 검색 쿼리 평균 실행 시간은 낮았으므로, 전체 지연의 대부분은 DB 자체보다 Redis 실패 대기와 재연결 영향일 가능성이 높았다.

따라서 Redis command timeout을 짧게 가져가야 한다.

spring:
  data:
    redis:
      timeout: 100ms

Redis circuit breaker

Redis가 죽은 상태에서도 매 요청마다 Redis get/set을 시도하면 fallback은 성공해도 p99는 계속 흔들린다.

Redis error가 반복되면 일정 시간 Redis 호출을 생략해야 한다.

Redis error 반복
→ CircuitBreaker OPEN
→ Redis get/set 우회
→ DB fallback으로 바로 진행

핵심은 Redis 장애를 매 요청마다 다시 확인하지 않는 것이다.


마무리

이번 실험은 Redis Cache를 붙인 뒤에도 자동완성 API 병목이 사라지지 않는다는 것을 보여준다. Redis는 효과가 있다.

하지만 Redis보다 먼저 입력 요청 패턴을 줄여야 한다. 그리고 Redis는 장애가 발생하면 또 다른 병목 지점이 된다.