본문 바로가기
트러블슈팅과 고민/트러블슈팅

레디스 장애 시 서킷브레이커와 로컬 캐시를 활용한 실시간 인기글 API 개선

by 정재익 2026. 1. 30.

목차

1. 문제 상황

    1.1 테스트 시나리오

    1.2 레디스 정상 상황 테스트

    1.3 DB 타격 상황 : 문제 - 성능 하락

    1.4 DB 타격 상황 : 문제 - 실시간 인기글의 퀄리티 하락

2. 결과 요약

3. 고민

    3.1 쿼리최적화 

    3.2 센티널, 리플리카 도입

    3.3 서킷 브레이커

4. 레디스 장애시 서킷브레이커로 경로 제어

    4.1 서킷 브레이커 아키텍처

    4.2 서킷브레이커 실시간 인기글 퀄리티 테스트 동기화 X

    4.3 동기화 구현

    4.4 서킷브레이커 실시간 인기글 퀄리티 테스트 동기화 O

    4.5 서킷브레이커 성능 테스트

 


제가 운영하는 비밀로그는 SNS 서비스입니다.
SNS의 특성상 실시간 인기글은 사람의 유행을 활성화하고 서로 의견을 나누며 서비스를 활성화시키는 매우 중요한 로직입니다.
그러나 제 실시간 인기글은 레디스에만 의존하고있어 레디스의 장애 상황에서 DB를 타격합니다.

1. 문제 상황

1.1 테스트 시나리오

DB 타격 시 문제 상황을 파악하기 위해서 테스트를 구성하였습니다.

도커로 실제 운영환경과 비슷한 환경을 구성합니다.
MYSQL 메모리제한 1GB CPU2개
SPRING 메모리제한 1GB CPU1개
REDIS 메모리제한 100MB CPU1개

 

테스트 시나리오는 실시간인기글조회 75% 글 조회 25%로 구성되어있습니다.
글을 조회하면 Redis에서 해당 글의 점수가 상승하고 그것이 실시간 인기글에 영향을 줍니다.
 
전체 요청수 : 4만 8천건
테스트 시간 : 10분
피크 요청량 : 초당 160건
시작부터 종료까지 선형적으로 요청량이 증가합니다.

테스트 데이터
유저 5000명
1만
댓글 10만
글 추천 2000
댓글 추천 50만

 

1.2 레디스 정상 상황 테스트

우선 정상 상황일때의 성능을 측정하였습니다.

실시간 인기글 조회는 P99가 81ms입니다.

 

커넥션은 여유롭습니다.

 

 

스카우터로 측정한 API별 리소스 점유입니다.

 

실시간 조회는 디비를 조회하기에 SQL타임이 0이며, 상세글 조회시에 4ms가 걸렸습니다.

중간에 있는것은 비동기 로직으로 실시간 인기글 조회 시 실시간 인기글 목록 캐시와 실제 ZSET의 실시간 인기글과의 변동을 맞추는 로직입니다. 실시간인기글의 순위와 목록이 다를때만 시행됩니다.

보통은 글 조회가 인기글 위주로 되지만 이 테스트 시나리오에서는 모든 글에 공평하게 조회하기 때문에 순위가 자주 바뀌어 거의 항상 실행되었고 그때마다 4ms가 소요되었습니다. 

 

스레드 풀 상태


스레드 풀 상태도 여유롭습니다.

 

1.3 DB 타격 상황 테스트 : 문제 - 성능 하락

레디스를 다운시켜 장애상황을 만들어 테스트하였습니다. 

 

모든 요청이 DB를 타격하니 상세 글 조회도 덩달아 느려졌습니다.

 

실시간인기글은 SQL타임이 26ms 글 상세 조회는 11ms 소요됩니다.

 

그래도 초당 95건까진 150ms 미만으로 응답합니다.

 

커넥션 풀은 잘 버티다가 초당 120건부터 부하가 상승합니다.

 

커넥션 습득시간이 1ms수준인것을 보니 커넥션 대기로 인한 지연은 아슬하게 일어나지 않습니다.

 

테스트 후반부에 runnable과 waiting 상태의 스레드가 급격히 증가하는데, 이는 부하 증가에 비해 서버의 처리 능력이 따라가지 못하면서 스레드 풀이 포화 상태에 이른 것으로 해석할 수 있습니다.

왜냐하면 모든 스레드들이 레디스앞에서 30ms기다리고 실시간 인기글 점수 변동을 담당하는 비동기 스레드들도 일단 30ms 기다리고 종료됩니다. 

 

1.4 DB 타격 상황 : 문제 - 실시간 인기글의 퀄리티 하락

실시간 인기글을 DB를 조회할 때 Redis만큼 정확하게 조회하는 방법은 없습니다.
그렇기에 유사 실시간 인기글을 사용하는데 1시간이내의 글 중에서 (추천수 x 30) + 조회 수의 상위 5개를 반환합니다.

 

진짜 실시간인기글과 유사한지가 걱정되었습니다.
지프의 법칙과 자카드 유사도를 알게되었고 그 개념들을 이용한 테스트를 만들었습니다.

지프의 법칙이란?
어떤 대규모 데이터 집합에서 항목의 빈도와 순위가 일정한 수학적 관계를 가진다는 경험적 법칙입니다.
80:20 법칙이라고 불리는 파레토 법칙과도 유사하며 SNS등 웹 사이트 트래픽과도 매우 강한 유사관계가 있습니다.
순위와 빈도는 반비례 관계를 가지며 수식은 빈도 = 1 / 순위 입니다.
SNS특성상 유사관계가 강하기 때문에 수식을 빈도 = 1.2 / 순위로 잡았습니다.

 

자카드 유사도란?
두 집합이 얼마나 겹치는지 수치로 나타내는 지표입니다.
레디스에서 가져온 진짜 실시간인기글과 DB에서 가져온 유사 실시간인기글의 정확도를 수치로 표현하기 위해 도입하였습니다.
수식은 A와 B의 교집합 / A와 B의 합집합 입니다.
예를들어 A가 글ID {1, 2, 3} B의 글ID {1, 3, 4}면 교집합은 {1, 3} 합집합은 {1, 2, 3, 4} 자카드 유사도는 0.5 입니다.

 

지프의 법칙이 SNS에도 적용된다는 근거는 와이카토대학의 Zipf’s Law across social media라는 논문입니다. 이 논문에서는 트위터 인스타그램 페이스북등의 SNS를 조사했고 지프의 법칙이 강하게 나타낸다고 말하고 있습니다.

 

테스트시나리오는 초기에 추천과 조회수가 무작위인 글을 15개 생성합니다.
그리고 지프의 법칙 공식에 글 숫자와 1.2를 삽입합니다.

아래는 지프의 법칙 공식입니다.

    private double[] buildZipfSkewedWeights(int count, double exponent) {
        double[] weights = new double[count];
        double total = 0;
        for (int i = 0; i < count; i++) {
            weights[i] = 1.0 / Math.pow(i + 1, exponent);
            total += weights[i];
        }
        double cumulative = 0;
        for (int i = 0; i < count; i++) {
            cumulative += weights[i] / total;
            weights[i] = cumulative;
        }
        return weights;
    }

 

그리고 지프의 법칙의 산출 값에 따라 100번 글의 점수를 증가시킵니다.

for (int round = 1; round <= 100; round++) {

    int viewEvents = ThreadLocalRandom.current().nextInt(5, 11);
    for (int e = 0; e < viewEvents; e++) {
        int postIdx = pickWeightedIndex(weights);
        Post post = testPosts.get(postIdx);
        redisRealTimePostAdapter.incrementRealtimePopularScore(post.getId(), VIEW_SCORE);
    }

 

10번째마다 DB에서 유사 실시간 인기글을 조회, Redis에서 실시간 인기글을 조회 하여 자카드유사도를 파악합니다.

if (round % COMPARE_INTERVAL == 0) {
    entityManager.flush();
    entityManager.clear();

    redisTop = 레디스 탑5 글
    dbTop = DB 탑5 글

    double jaccard = jaccardSimilarity(redisTop, dbTop);
    similarities.add(jaccard);

 

아래는 자카드 유사도 공식입니다.

private double jaccardSimilarity(List<Long> a, List<Long> b) {
    if (a.isEmpty() && b.isEmpty()) return 1.0;
    if (a.isEmpty() || b.isEmpty()) return 0.0;
    Set<Long> setA = new HashSet<>(a);
    Set<Long> setB = new HashSet<>(b);
    Set<Long> intersection = new HashSet<>(setA);
    intersection.retainAll(setB);
    Set<Long> union = new HashSet<>(setA);
    union.addAll(setB);
    return (double) intersection.size() / union.size();
}

 
테스트결과 두개의 합집합에서 교집합은 단 하나 즉 5개의 실시간인기글 중에서 1개만 같은 결과가 나왔습니다.
한마디로 유사도가 20%라는 뜻 입니다.

라운드  10: Redis=[16696, 16697, 16698, 16701, 16699], DB=[16710, 16703, 16697, 16701, 16709], 유사도=0.2500
라운드  20: Redis=[16696, 16697, 16698, 16699, 16706], DB=[16710, 16703, 16697, 16701, 16709], 유사도=0.1111
라운드  30: Redis=[16696, 16697, 16698, 16699, 16702], DB=[16710, 16703, 16697, 16701, 16709], 유사도=0.1111
라운드  40: Redis=[16696, 16697, 16698, 16699, 16700], DB=[16710, 16703, 16697, 16701, 16709], 유사도=0.1111
라운드  50: Redis=[16696, 16697, 16698, 16700, 16699], DB=[16710, 16703, 16697, 16701, 16709], 유사도=0.1111
라운드  60: Redis=[16696, 16697, 16698, 16700, 16699], DB=[16710, 16703, 16697, 16701, 16709], 유사도=0.1111
라운드  70: Redis=[16696, 16697, 16698, 16700, 16699], DB=[16710, 16703, 16697, 16701, 16709], 유사도=0.1111
라운드  80: Redis=[16696, 16697, 16698, 16700, 16699], DB=[16710, 16703, 16697, 16701, 16709], 유사도=0.1111
라운드  90: Redis=[16696, 16697, 16698, 16699, 16700], DB=[16710, 16703, 16697, 16701, 16709], 유사도=0.1111
라운드 100: Redis=[16696, 16697, 16698, 16699, 16700], DB=[16710, 16703, 16697, 16701, 16709], 유사도=0.1111
[결과] 비교 횟수: 10, 평균 자카드 유사도: 0.1250, 평균 오차율: 0.8750

 

 

글이 1000개 일때도 실험하였는데 글이 1000개 일때는 유사도가 0%가 나올정도로 심각합니다.

라운드  10: Redis=[163847, 163848, 163849, 163850, 163854], DB=[164518, 164723, 164689, 164753, 164699], 유사도=0.0000
라운드  20: Redis=[163847, 163848, 163849, 163850, 163856], DB=[164518, 164723, 164689, 164753, 164699], 유사도=0.0000
라운드  30: Redis=[163847, 163848, 163850, 163849, 163852], DB=[164518, 164723, 164689, 164753, 164699], 유사도=0.0000
라운드  40: Redis=[163847, 163848, 163849, 163850, 163851], DB=[164518, 164723, 164689, 164753, 164699], 유사도=0.0000
라운드  50: Redis=[163847, 163848, 163849, 163850, 163851], DB=[164518, 164723, 164689, 164753, 164699], 유사도=0.0000
라운드  60: Redis=[163847, 163848, 163849, 163850, 163851], DB=[164518, 164723, 164689, 164753, 164699], 유사도=0.0000
라운드  70: Redis=[163847, 163848, 163849, 163850, 163851], DB=[164518, 164723, 164689, 164753, 164699], 유사도=0.0000
라운드  80: Redis=[163847, 163848, 163849, 163850, 163851], DB=[164518, 164723, 164689, 164753, 164699], 유사도=0.0000
라운드  90: Redis=[163847, 163848, 163849, 163850, 163851], DB=[164518, 164723, 164689, 164753, 164699], 유사도=0.0000
라운드 100: Redis=[163847, 163848, 163849, 163850, 163851], DB=[164518, 164723, 164689, 164753, 164699], 유사도=0.0000
[Redis vs DB 폴백] 측정 라운드: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
[Redis vs DB 폴백] 유사도 목록: [0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000]
[Redis vs DB 폴백] 평균 유사도: 0.0000, 평균 오차율: 100.0%

 

2. 결론 요약

서킷브레이커와 로컬캐시를 활용하여 퀄리티와 응답시간을 모두 잡았습니다.

구분 P99 응답시간 메모리 결과값 유사도 특징 결론
레디스 정상 81ms 42MB 증가 100% 장애시 무력 기각
레디스 장애 DB 조회 6034ms 112MB 증가 20% 유사도 결함 기각
레디스 장애 서킷브레이커 102ms 52MB 증가 93% 장애시 대비 가능 채택

 

3. 고민

3.1 쿼리최적화 

쿼리를 최적화하여 응답 시간을 개선하면 스레드를 빠르게 순환시킬 수 있으므로, 이 방법을 먼저 검토했습니다.

하지만 아래와 같은 한계가 있었습니다.

한계 상세
데이터 일치율 개선 불가 결과 값이 변하는 것이 아니기 때문에 데이터 일치율은 유지됩니다.
장애 전파 하나의 API장애가 DB를 타격하는 본질은 유지됩니다.
미미한 성능 개선 이미 WHERE절은 인덱스 범위 스캔으로 처리되고 있어 개선 여지는 FileSort뿐입니다. 최근 1시간 이내 작성글로 필터링되므로 1초마다 게시글이 작성된다고 하더라도 소트 버퍼에 들어가는 데이터는 최대 3,600건 수준으로, 정렬 부담 자체가 낮습니다.
스레드 풀 개선 불가 기존에 커넥션 풀은 포화되지 않았고, 커넥션 획득 대기 시간도 1ms 이하였습니다. 이는 쿼리 자체가 병목이 아님을 의미하며, 스레드 풀 병목의 원인은 Redis 타임아웃(30ms) 동안의 대기에 있습니다

 

3.2 센티널, 리플리카 도입

 

Redis의 레플리카로 복제본을 만들고 센티널로 자동 페일오버를 설정하면 Redis에 장애가 발생하는 상황을 막을 수 있습니다.

하지만 아래와 같은 한계가 있었습니다.

한계 상세
100%의 가용성 불가 100%의 가용성은 존재하지 않습니다. 장애는 예상치 못한 곳에서 발생할 수 있으므로 폴백 로직은 항상 필요하며, 이 경우 기존 DB 조회의 문제는 여전히 남습니다.
비용 발생 EC2 6대(마스터 1 + 레플리카 2 + 센티널 3)로 월 30달러의 서버 비용이 발생합니다

 

3.3 서킷 브레이커 

서킷브레이커란 실패한 곳에 계속 요청을 보내지 않게 전기 차단기를 내리듯 연결을 막는용도로 쓰입니다. 저는 여기서 스위치를 당기면 기차의 선로가 바뀌는 것에 착안하여 로컬캐시와 레디스의 경로를 제어해보자는 생각을 했습니다.

 

서킷브레이커는 대상 시스템의 장애를 감지하여 요청 경로를 제어하고, 대상이 회복할 시간을 확보할 수 있습니다. 또한 Redis 장애 시 로컬 캐시를 활용하면 실시간 인기글의 품질을 유지할 수 있습니다.

장점 상세
응답 시간 개선 Redis 장애 시 타임아웃만큼 대기하지 않아 스레드를 빠르게 순환 시킬 수 있습니다.
Redis의 회복 시간 부여 장애인 것이 확정되면 요청을 보내지 않아 Redis가 회복에 전념할 수 있습니다.
장애격리 DB에 접근하지 않고 실시간 인기글을 담당하는 로컬 캐시만 접근하여 API 하나의 장애가 애플리케이션 전역에 전파되는 것을 방지합니다.
데이터 일치율 개선 로컬 캐시를 활용해 Redis에 준하는 결과를 반환합니다
비용 절감 새로운 리소스를 추가하지 않아 비용을 절감할 수 있고 개발 생산성을 증가시킵니다.

 

하지만 여전히 단일 레디스 노드에 의존하기 때문에 장애율이 증가할 수 있다는 단점이 있습니다.

 

4. 레디스 장애 시 서킷브레이커로 경로 제어

4.1 서킷 브레이커 아키텍처

아래는 간략화한 아키텍처입니다.

실시간 인기글 아키텍처

 

레디스 앞에 서킷을 달아 레디스의 예외를 수신하고 5개 중에서 3개이상 실패할 경우 서킷을 엽니다. 서킷이 열리면 레디스로 가는 루트는 닫히고 카페인으로 가는 폴백 로직이 열립니다.


서킷이 닫혀있는 정상 상황에서는 레디스에서 글 점수증가를 시키고 서킷이 열려있으면 카페인에 글 점수증가를 시킵니다.
게시글 조회도 닫혀있으면 레디스에서 글ID를 가져오고 열려있으면 카페인에서 글ID를 가져옵니다.


글 점수증가와 실시간 인기글 조회는 같은 서킷을 공유해야합니다. 레디스에 쓰고 조회하냐 카페인에 쓰고 조회하냐의 일관성을 가지기 위해서입니다.


다만 레디스의 경우에는 실시간 인기글 목록 캐시가 있는데 카페인의 경우엔 실시간 인기글 목록 캐시가 없어 DB를 경유해야합니다. 하지만 PK를 이용한 조회라 이전보다 매우 빠릅니다.

 

로컬 캐시와 Redis의 동기화에 대해 고민했지만 처음에는 실시간 인기글의 특성상 필요없다는 결론을 내렸습니다.
서킷이 열고 닫혀도 지프의 법칙에따라 사람들의 행동은 일관적이고 동기화를 하지않아도 비슷한 결과가 나올것이라 생각했습니다. 그래서 정말 비슷한 결과가 나오는지 테스트해보겠습니다.

 

4.2 서킷브레이커 실시간 인기글 퀄리티 테스트 동기화 X

이번 테스트는 200라운드를 돌며 지프의 법칙에 따라 적절하게 점수증가를 하며 20라운드마다 서킷을 반복 개폐합니다.
서킷이 열려있을 때의 그 중간점인 30, 70, 110 ~~ 190라운드에 레디스의 진짜 실시간인기글과 카페인의 실시간인기글의 자카드유사도를 검사합니다.
 

아래는 글 15개 중에서 5개를 선정할때의 자카드 유사도 결과입니다. 

라운드  30: Redis=[1, 2, 4, 3, 6], Caffeine=[1, 2, 3, 6, 8], 유사도=0.6667
라운드  70: Redis=[1, 2, 3, 4, 6], Caffeine=[1, 2, 4, 6, 3], 유사도=1.0000
라운드 110: Redis=[1, 2, 3, 4, 5], Caffeine=[1, 2, 4, 3, 5], 유사도=1.0000
라운드 150: Redis=[1, 2, 3, 4, 6], Caffeine=[1, 2, 4, 3, 7], 유사도=0.6667
라운드 190: Redis=[1, 2, 3, 4, 6], Caffeine=[1, 2, 4, 3, 6], 유사도=1.0000
[결과] 전환 횟수: 5, 평균 자카드 유사도: 0.8668, 평균 오차율: 0.1332 (Zipf s=1.2)

 
DB조회의 경우에는 유사도가 0.125가 나왔지만 이번에는 0.8668이 나왔습니다
자카드 유사도는 5개가 겹치면 1.0, 4개가 겹치면 0.667이 나옵니다. 실시간인기글 목록 5개로 환산하면 4.64개가 겹칩니다. 실시간인기글과 유사도가 93%라는 뜻 입니다. 어떤 경우는 100%의 유사도가 나오기도 했습니다.
서킷의 개폐시 굳이 복잡한 동기화로직을 넣지않아도 사람들의 행동 에따라 비슷한 결과가 나옵니다.

그러나 글을 1000개로 지정하고 5개를 도출 했을 때는 유사도가 70%로 하락했습니다.

이는 동기화가 되지 않은 문제로 레디스의 캐시는 초기화되지 않아 긴 생명주기를 가지고 있고 반면 카페인은 매 테스트마다 초기화되어 서킷의 OPEN 이후 10번의 라운드만으로 레디스와 격차가 날 수 밖에 없습니다.

 

4.3 동기화 구현

서킷의 OPEN 상태에서는 글의 삭제가 일어나면 카페인에서는 삭제되지만 레디스에서는 삭제되지 않습니다. 따라서 OPEN -> CLOSE 직후 그동안 별도의 자료구조에 삭제된 글의 ID를 모아두고 CLOSE시에 파이프라인으로 레디스에도 삭제를 시키고 카페인의 점수를 ZSET에 증분 시킵니다.

 

그리고 CLOSE 상태에서는 1분마다 레디스의 상위 100개의 글을 카페인에 삽입하여 서킷이 OPEN되어도 인기글의 퀄리티를 유지하려합니다.

 

이렇게하면 레디스의 지수 감소 로직, 글 삭제, 점수 상승을 모두 로컬 캐시에 반영할 수 있습니다. 1분간의 데이터 차이가 있지만 장애 상황인만큼 허용 가능한 레벨이라고 생각합니다.

 

4.4 서킷브레이커 실시간 인기글 동기화 테스트 O

글 1000개 대상으로 다시 테스트를 시행하였습니다.

 

서킷 OPEN 직후 레디스와 카페인의 유사도 테스트

라운드  20 [OPEN 직후]: Redis=[1, 2, 4, 3, 5], Caffeine=[1, 2, 4, 3, 5], 유사도=1.0000
라운드  60 [OPEN 직후]: Redis=[1, 2, 4, 3, 6], Caffeine=[1, 2, 4, 3, 6], 유사도=1.0000
라운드 100 [OPEN 직후]: Redis=[1, 2, 3, 4, 6], Caffeine=[1, 2, 3, 4, 6], 유사도=1.0000
라운드 140 [OPEN 직후]: Redis=[1, 2, 3, 4, 7], Caffeine=[1, 2, 3, 4, 7], 유사도=1.0000
라운드 180 [OPEN 직후]: Redis=[1, 2, 3, 4, 6], Caffeine=[1, 2, 3, 4, 6], 유사도=1.0000
[OPEN 직후] 측정 라운드: [20, 60, 100, 140, 180]
[OPEN 직후] 유사도 목록: [1.0000, 1.0000, 1.0000, 1.0000, 1.0000]
[OPEN 직후] 평균 유사도: 1.0000, 평균 오차율: 0.0000

직후에는 유사도가 100%가 나왔습니다. 

 

서킷 OPEN 중간 레디스와 카페인의 유사도 테스트

라운드  30 [OPEN 중간]: Redis=[1, 2, 4, 3, 5], Caffeine=[1, 2, 3, 4, 5], 유사도=1.0000
라운드  70 [OPEN 중간]: Redis=[1, 2, 4, 3, 6], Caffeine=[1, 2, 3, 4, 6], 유사도=1.0000
라운드 110 [OPEN 중간]: Redis=[1, 2, 3, 4, 6], Caffeine=[1, 2, 3, 4, 5], 유사도=0.6667
라운드 150 [OPEN 중간]: Redis=[1, 2, 3, 4, 7], Caffeine=[1, 2, 3, 4, 5], 유사도=0.6667
라운드 190 [OPEN 중간]: Redis=[1, 2, 3, 4, 6], Caffeine=[1, 2, 3, 4, 6], 유사도=1.0000
[OPEN 중간] 측정 라운드: [30, 70, 110, 150, 190]
[OPEN 중간] 유사도 목록: [1.0000, 1.0000, 0.6667, 0.6667, 1.0000]
[OPEN 중간] 평균 유사도: 0.8667, 평균 오차율: 0.1333

중간에는 유사도가 93%가 나왔습니다.

 

4.5 서킷브레이커 성능 테스트

DB조회보다 성능이 빨라졌습니다. 글ID를 이용해서 결과를 가져오는 쿼리다 보니 추천수와 조회수를 이용해서 결과를 구하는 유사 실시간 인기글보다 훨씬 빠릅니다.

 


실시간인기글 조회는 5ms 상세 글 조회는 3ms 소모됩니다. 단순 DB조회보다 빨라졌습니다. 

캐싱 정상상황보다는 좀 느립니다.

스레드 풀의 경우 DB를 조회하는 유사실시간인기글보다 훨씬 상황이 좋아졌습니다. runnable 스레드가 일정하게 유지되고, timed-waiting이 지배적인 것으로 보아 스레드 풀에 충분한 여유가 있음을 확인할 수 있습니다.