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는 장애가 발생하면 또 다른 병목 지점이 된다.
'종목 검색 api' 카테고리의 다른 글
| [종목 검색 API] 5. Redis Sentinel과 CircuitBreaker 장애 대응 실험 (0) | 2026.05.26 |
|---|---|
| [종목 검색 API] 4. Redis 장애 격리 실험: Timeout과 CircuitBreaker로 충분했을까? (0) | 2026.05.23 |
| [종목 검색 API] 2. Local DB 검색 Baseline 실험 (0) | 2026.05.21 |
| [종목 검색 API] 1. 자동완성 검색 API는 어떻게 서버를 터뜨리는가 (0) | 2026.05.20 |