종목 검색 api

[종목 검색 API] 4. Redis 장애 격리 실험: Timeout과 CircuitBreaker로 충분했을까?

kyoulho 2026. 5. 23. 18:17

이전 글에서는 자동완성 검색 API에 Redis Cache를 붙이고 실제 입력 패턴으로 부하 테스트를 진행했다.

자동완성 API의 1차 방어선은 Redis가 아니라 debounce였다. debounce를 적용하자 API 요청 수와 DB query count가 크게 줄었다.

하지만 Redis 장애 테스트에서는 다른 문제가 드러났다. Redis가 운영 중 내려가자 API 실패율은 0%였지만, p95와 p99가 크게 튀었다.


DB 검색 쿼리 평균 실행 시간은 12.05ms 수준이었다. 즉 전체 지연의 핵심은 DB 자체가 아니었다. 문제는 Redis 장애 시 매 요청마다 Redis get/set 실패를 기다리는 구조였다. 그래서 이번 글에서는 Redis timeout을 줄이고, CircuitBreaker를 적용했다.



기존 문제: fallback은 실패율을 막지만 latency는 막지 못한다

기존 구조는 cache-aside였다.

request
→ Redis get
    hit  → return
    miss → DB search
→ Redis set
→ response


Redis가 정상일 때는 문제가 없다. 하지만 Redis가 죽으면 흐름이 달라진다.

request
→ Redis get 실패 대기
→ local stock_master search
→ Redis set 실패 대기
→ response


fallback은 동작한다. 그래서 API는 5xx를 내지 않는다. 하지만 사용자는 느려진 응답을 그대로 맞는다.

이게 fallback만으로 부족한 이유다.

fallback = 실패율 방어
circuit breaker = 장애 전파 시간 방어


둘은 다르다. fallback은 “서비스가 죽지 않게” 만든다. CircuitBreaker는 “계속 기다리지 않게” 만든다.


적용한 개선

Redis timeout 단축

먼저 Redis command timeout을 짧게 가져갔다.

spring:
  data:
    redis:
      timeout: 100ms

 

Redis가 죽었을 때 오래 기다리지 않는다. 자동완성 API에서 Redis는 성능 최적화 계층이다.

Redis가 느리거나 죽었다고 검색 API가 Redis를 오래 기다리면 안 된다.


CircuitBreaker 적용

그 다음 Redis cache adapter 앞에 Resilience4j CircuitBreaker를 뒀다. 구조는 다음과 같다.

StockSearchCache
  └── CircuitBreakingStockSearchCache
        └── RedisStockSearchCache


동작 원칙은 다음이다.

상황 처리
Redis get 성공 + hit cache hit 반환
Redis get 성공 + miss null 반환, local stock_master 검색
Redis get 실패 null 반환, local stock_master 검색
CircuitBreaker OPEN Redis 호출 생략, local stock_master 검색
Redis put 실패 예외 삼킴, API 응답 유지
CircuitBreaker OPEN 상태의 put Redis set 생략


중요한 점은 서비스가 Redis 장애를 몰라도 된다는 것이다. StockSearchService는 여전히 이렇게만 본다.

val cached = cache.get(cacheKey)
if (cached != null) {
    return cached
}

val results = searchLocal(...)
cache.put(cacheKey, results, ttl)

return results


Redis 장애 격리는 cache adapter 내부 책임이다. 검색 서비스는 cache hit이면 반환하고, cache miss처럼 보이면 local DB를 조회한다.


Kotlin null 문제

구현 중 의외로 중요한 지점이 있었다. Redis cache miss는 정상적인 null이다.
그런데 Resilience4j의 executeSupplier에서 Kotlin null을 그대로 다루면 문제가 생길 수 있다.

그래서 miss를 별도 non-null wrapper로 감쌌다.

private sealed interface CacheLookup {
    data class Hit(val results: List<StockSearchResult>) : CacheLookup
    data object Miss : CacheLookup
}


이렇게 하면 구분이 명확해진다.

CacheLookup.Miss = 정상적인 cache miss
Exception = Redis 장애
CallNotPermittedException = CircuitBreaker OPEN


이 구분이 중요하다. cache miss를 실패로 잡으면 CircuitBreaker가 잘못 열린다. 반대로 Redis 장애를 miss처럼만 처리하면 장애 상태를 계속 반복한다.


재측정 결과

Redis timeout과 CircuitBreaker를 적용한 뒤 다시 테스트했다. 동일하게 debounced 입력 패턴으로 부하를 주고, 테스트 중간에 Redis를 중단했다. k6 결과는 다음과 같았다.

항목
k6 http_reqs 854
p95 latency 1.81s
p99 latency 30.78s
max latency 31.3s
http_req_failed 0.70%
DB search query count 601
DB mean_exec_time 15.43ms
PostgreSQL CPU avg 18.5%
PostgreSQL CPU max 76.8%
Redis CPU avg 1.3%
Redis CPU max 2.0%
Keycloak CPU avg 0.8%
Keycloak CPU max 1.9%


표면적으로 보면 애매하다. p95는 이전 Redis 장애 테스트보다 낮아졌다.

이전 p95 = 2.56s
개선 후 p95 = 1.81s


하지만 p99는 오히려 크게 튀었다.

이전 p99 = 3.55s
개선 후 p99 = 30.78s


즉 timeout과 CircuitBreaker만으로 문제가 끝나지는 않았다. 다만 이 결과를 바로 실패로 판단하면 안 된다. Redis metric을 같이 봐야 한다.


Redis Cache Metric 확인

테스트 후 Micrometer pwm.stock_search.cache.* metric은 다음과 같았다.

Metric
pwm.stock_search.cache.hit 248
pwm.stock_search.cache.miss 129
pwm.stock_search.cache.get_error 13
pwm.stock_search.cache.set_error 9
pwm.stock_search.cache.bypassed get 459
pwm.stock_search.cache.bypassed set 345


핵심은 이 부분이다.

get_error = 13
set_error = 9
bypassed = 804


Redis 장애 이후에도 Redis를 계속 때린 것이 아니다. 초반에 소수의 get/set 실패가 발생했고, 그 뒤에는 CircuitBreaker가 열리면서 Redis 호출을 우회했다.


즉 Redis CircuitBreaker 자체는 정상 동작했다. 기존에는 Redis 장애 후 매 요청마다 Redis 실패를 계속 맞았다. 이제는 장애를 감지한 뒤 Redis 호출을 생략한다.


그런데 왜 p99는 여전히 흔들렸나?

Redis CircuitBreaker는 동작했다. DB도 병목은 아니었다. PostgreSQL 검색 쿼리는 601회 실행되었고, 평균 실행 시간은 15.43ms였다.

DB search query mean_exec_time = 15.43ms


Docker stats도 비슷한 결론을 보여준다.

pwm-postgres cpu_avg = 18.5%
pwm-postgres cpu_max = 76.8%


DB가 완전히 무너진 것은 아니다. 그런데 p99는 30초까지 튀었다. 이 말은 하나다.

Redis 장애 격리만으로는 전체 tail latency를 안정화할 수 없다.


CircuitBreaker는 Redis 호출 반복을 막았다. 하지만 단일 Redis 인스턴스가 죽는 순간 cache 계층 자체는 사라진다.
Redis가 살아 있을 때는 반복 요청을 Redis가 흡수한다. Redis가 죽으면 요청은 local DB로 내려간다.

Redis 정상
→ 반복 요청은 Redis가 흡수

Redis 장애
→ Redis 우회
→ DB fallback 증가


즉 CircuitBreaker는 Redis 장애를 빠르게 우회하게 만들지만, Redis가 제공하던 부하 흡수 능력까지 대체하지는 못한다.

이번 실험의 결론

Redis timeout은 Redis 실패 대기 시간을 줄인다. CircuitBreaker는 장애 Redis 호출 반복을 줄인다. 하지만 Redis 단일 장애 자체를 없애지는 못한다. Redis가 단일 인스턴스이면, Redis 장애 순간 cache 계층 전체가 사라진다. Redis가 죽을 때마다 DB가 직접 부하를 받아야 한다면 운영 안정성은 여전히 부족하다. 따라서 다음 단계는 Redis 자체를 단일 장애점에서 빼는 것이다.