Redis에 장애가 발생해도 메시지를 지킬 수 있을까?
Published: Aug 26, 2026
앞선 다른 편에서 언급했지만, Minichat은 채팅 서버(Spring Server)만 다중 인스턴스로 운영된다. 유저가 어느 방에 있는지, 어느 서버에 붙어 있는지에 대한 세션 정보는 Redis를 통해 중앙에서 관리된다. 또한 메시지 한 건이 처리되는 과정에서 Redis에 의존한 여러 로직을 거치게 된다.
메세지를 전송하는 로직에서 Redis를 거쳐야하는 경로는 두 곳이다.
메시지 접수:- RateLimit 확인- 현재 사용자의 chatId 조회
메시지 배달:- 방 멤버 조회- 각 사용자의 서버 위치 조회(MGET)Redis 하나에 이렇게 의존하면, 만약의 상황에서 Redis가 죽었을 때 서비스에 얼만큼의 악영향을 미칠까? try-catch로 대비를 하긴 했지만 사후 처리코드가 완전하지 않아, 이번 기회에 장애 대응까지 검증해보기로 했다.
확인하고 싶었던 건 두 가지다. 하나, Redis가 죽으면 정확히 무슨 일이 벌어지는가. 둘, 그걸 막으려고 넣은 방어 코드(Fail-Open)가 정말 작동하는가.
실험 방법
섹션 제목: “실험 방법”먼저 어떻게 측정했는지부터 정리했다. 뒤에 나오는 “150건이 사라졌다” 같은 숫자가 어떻게 나온 건지 알아야 이후 설명할 내용들을 더 잘 이해할 수 있기 때문이다.
이번 실험에서는 Docker를 사용해 Redis를 60초간 정지시켜 인위적으로 장애를 주입했다.
부하 조건
섹션 제목: “부하 조건”| 항목 | 값 |
|---|---|
| 앱 서버 | 2대 |
| 사용자 | 150명 (k6 VU 150) |
| 채팅방 | 1개 (전원 입장) |
| 송신 | VU당 1초에 1건 (약 150건/초 = 약 9,000건/분) |
| 테스트 시간 | 6분 |
| 장애 주입 | 부하 중 docker stop redis → 60초 유지 → docker start redis |
모든 단계는 동일한 부하 및 Redis 장애 주입 환경에서 측정했다.
메시지 유실 측정 방법
섹션 제목: “메시지 유실 측정 방법”유실 여부는 k6의 WebSocket 수신량으로 판단하지 않았다. 테스트 전후의 messages 테이블 행 수를 직접 비교했다.
① 테스트 시작 전 messages COUNT 기록 ↓② k6 부하 시작 ↓③ docker stop redis (60초 장애) ↓④ docker start redis ↓⑤ k6 종료 후 Kafka 처리 완료까지 대기 ↓⑥ messages COUNT 재측정 ↓⑦ DB 증가량 vs k6 송신 수 비교이때 로직상, 방을 만들 때 “OO님이 입장했습니다” 같은 시스템 메시지 1건이 자동 생성된다. 이건 유저가 보낸 게 아니므로 비교에서 제외한다.
이후 Timeout + Fallback을 적용한 재실험에서는 다음과 같이 측정했다.
k6 송신 메시지 수 = 13,350messages 증가량 = 13,351시스템 메시지 = 1유저 메시지 증가량 = 13,351 - 1 = 13,350
→ 13,350 = 13,350 (유실 0)k6에서 송신한 13,350건과 DB에 저장된 유저 메시지 13,350건이 일치했고, 이 실험에서는 메시지 유실이 발생하지 않았다.
Kafka LAG 확인
섹션 제목: “Kafka LAG 확인”Kafka consumer group의 상태도 함께 확인했다.
$ kafka-consumer-groups --bootstrap-server localhost:9092 --describe --group chat-group
CURRENT-OFFSET LOG-END-OFFSET LAG (동일) (동일) 0CURRENT-OFFSET(컨슈머가 처리한 위치)과 LOG-END-OFFSET(토픽에 기록된 마지막 위치)의 차이인 LAG = 0이었다. 따라서 테스트 종료 시점에 Kafka에 처리되지 않고 남아 있는 메시지는 없었다.
최종적인 메시지 유실 여부는 Kafka LAG이 아닌 messages 테이블의 저장 건수로 판단했다. Kafka LAG은 Kafka에 메시지가 밀려 있는지를 확인하고, DB COUNT는 최종적으로 메시지가 저장되었는지를 확인하는 지표로 사용했다.
이 서버에는 이미 Redis 장애를 우회하기 위한 Fail-Open 코드가 있었다. Redis 호출을 try-catch로 감싸고, 예외가 발생하면 Redis 의존 로직을 우회해 계속 진행하는 구조였다.
// 실제 구현을 단순화한 예시try { redisTemplate.execute(...); // Redis 조회} catch (Exception e) { log.error(...); // 실패해도 여기서 잡고}return joinPoint.proceed(); // 메시지 처리는 계속그런데 실제 장애에서는 이 방어 코드가 빠르게 작동하지 않았다. catch가 실행되기까지 너무 오래 걸렸기 때문이다.
Redis가 정지된 동안 Redis 명령은 즉시 실패하지 않고, command timeout까지 응답을 기다렸다. Lettuce의 command timeout 기본값이 60초였기 때문에, Redis 장애가 발생하면 명령이 최대 60초까지 대기한 뒤 timeout 예외가 발생했다.
Reference: Redis Lettuce Ref
Redis 정지 ↓즉시 예외가 나지 않음 ↓command timeout(60초)까지 대기 ↓그제서야 예외 ↓catch 도달즉, Fail-Open 자체가 없었던 것이 아니라, Fail-Open이 실행되기 전에 60초를 기다려야 하는 구조였다. try-catch는 예외가 발생한 뒤에야 실행되기 때문에, Redis 장애를 빠르게 우회하는 역할을 하지 못했다.
게다가 메시지 접수 과정에서도 Redis에 의존하는 로직이 여러 곳에 있었다. Redis가 응답하지 않자 이 호출들이 timeout을 기다리면서 메시지 접수 자체가 지연됐고, 결과적으로 Redis 장애 구간의 DB 저장량은 정상적인 약 9,000건/분에서 1,500건/분까지 떨어졌다.
지표 확인
섹션 제목: “지표 확인”Redis를 60초 동안 중단했지만 HTTP 요청 실패율은 **0%**였고, WebSocket 연결도 유지됐다. k6의 전달 지연 역시 메시지를 정상적으로 수신한 경우만 측정하기 때문에, 메시지 유실 자체는 보여주지 못했다.
하지만 테스트 종료 후 k6의 송신량과 DB의 실제 저장량을 비교하자 150건의 차이가 확인됐다.
왜 지표에서는 드러나지 않은 유실이 발생한 걸까?
테스트 과정을 보면 k6는 WebSocket으로 메시지를 수신했을 때만 전달 지연을 측정한다.
socket.onmessage = (e) => { received.add(1);
...
if (inner.t) { deliveryLatency.add(Date.now() - inner.t); }};따라서 측정 구조가 다음과 같다.
전송 → 메시지 도착 → 전달 지연 측정메시지가 도착하지 않으면 전달 지연 자체가 측정되지 않는다. 전달된 메시지만 보면 지연이 정상적으로 보이더라도, 일부 메시지가 처리되지 않은 상황은 놓칠 수 있다.
이번 테스트에서는 Kafka consumer의 LAG이 0이라는 수치가 나왔고, DB의 저장량은 k6 송신량보다 150건 적었다. 이를 바탕으로 Kafka에 처리되지 않은 메시지가 남아 있던 것이 아니라, Redis 장애 구간에서 일부 메시지가 정상적으로 접수되지 않은 것으로 판단했다.
| 지표 | 값 |
|---|---|
| k6 송신 메시지 (app_msgs_sent) | 58,350건 |
| DB 저장 메시지 | 58,201건 |
| 시스템 메시지 (방 생성 시 자동) | 1건 |
| 유저 메시지 저장량 | 58,200건 |
| 송신량과 차이 (유실) | 150건 |
| Kafka LAG | 0 |
LAG이 0이므로, 테스트 종료 시점에 Kafka에는 처리되지 않은 메시지가 남아 있지 않았다.
반면 k6 송신량은 58,350건이었고, DB에 저장된 유저 메시지는 58,200건이었다.
이 150건은 Kafka에 밀려 있던 게 아니라, Kafka로 발행되기 전에 처리되지 못한 메시지로 보았다.
DB 저장량 변화
섹션 제목: “DB 저장량 변화”DB의 분당 메시지 저장량을 확인하면 Redis 장애의 영향이 더 명확하게 나타난다.
| 시간 | 분당 저장량 | 상황 |
|---|---|---|
| 14:32 | 9,000 | 정상 |
| 14:33 | 1,500 | Redis 장애 |
| 14:34 | 16,350 | Redis 복구 |
| 14:35 | 9,000 | 정상 |
| 14:36 | 9,000 | 정상 |
| 14:37 | 8,850 | 정상 |
정상적으로는 분당 약 9,000건이 저장됐지만, Redis 장애가 발생한 14:33에는 1,500건까지 떨어졌다. 정상 처리량의 약 17% 수준이다.
Redis를 다시 시작한 14:34에는 16,350건이 저장되면서 장애 구간 이후 처리량이 일시적으로 증가했고, 이후 다시 약 9,000건 수준으로 돌아왔다.
즉, Redis 장애가 발생한 14:33에는 정상 처리량보다 약 7,500건 적게 저장됐고, Redis가 복구된 14:34에는 저장량이 16,350건까지 증가했다. 장애 구간에 처리되지 못했던 메시지가 복구 직후 일부 몰려 처리된 것으로 볼 수 있다.
최종적으로 남은 차이는 150건이었다.
Timeout 단축 + Fallback
섹션 제목: “Timeout 단축 + Fallback”두 가지를 고쳤다.
- Redis 응답 대기(command timeout)를 60초 → 2초로 줄였다. Redis가 죽으면 2초 후 timeout 예외가 발생하도록 했다.
- Redis 조회에 실패하면 다른 곳에서 정보를 가져오도록 했다. 유저의 chatId는 세션 캐시에서, 방 멤버 정보는 DB에서 조회하도록 폴백을 추가했다.
Redis 실패 ↓2초 후 timeout ↓세션 캐시 / DB에서 조회 ↓메시지 처리 계속실제 부하 테스트 결과는 다음과 같았다.
| 지표 | Redis 장애 대응 전 | Timeout + Fallback |
|---|---|---|
| 메시지 유실 | 150건 | 0건 |
| 장애 구간 DB 저장량 | 1,500건 (정상의 17%) | 6,450건 (정상의 72%) |
| 배달 완료율 | 99.74% | 100% |
| p95 지연 | 54.66초 | 54.54초 |
폴백을 추가하자 메시지 유실은 150건에서 0건으로 줄었고, 장애 구간 DB 저장량도 정상의 17%에서 72%까지 회복됐다.
하지만 p95 지연은 54.66초에서 54.54초로 거의 변하지 않았다.
반복 Timeout
섹션 제목: “반복 Timeout”이상했다.
Timeout을 60초에서 2초로 줄였는데, 왜 p95는 여전히 54.54초일까?
2초면 충분히 빠르게 실패하고 fallback으로 넘어갈 거라고 생각했다. 그런데 메시지 하나가 처리되는 동안 Redis를 한 번만 호출하는 게 아니었다.
메시지 처리 ↓Redis 호출 ① → 2초 대기 → 실패 → fallback ↓Redis 호출 ② → 또 2초 대기 → 실패 → fallback ↓Redis 호출 ③ → 또 2초 대기 → 실패 → fallback...Redis가 죽은 것을 이미 확인했더라도, 다음 Redis 호출에서는 다시 Redis에 접근하고 또 timeout을 기다리고 있었다.
결국 Redis 장애가 지속되는 동안 같은 timeout 비용을 여러 Redis 호출에서 반복해서 지불하고 있었던 것이다.
그렇다면 Redis가 죽었다는 것을 한 번 확인한 뒤에는, 일정 시간 동안 Redis를 아예 호출하지 않으면 되지 않을까?
경량 Circuit Breaker 적용
섹션 제목: “경량 Circuit Breaker 적용”이번 구현에서는 별도 라이브러리 없이 AtomicLong에 차단 종료 시각을 저장하는 방식으로 경량 Circuit Breaker를 직접 구현했다.
그래서 Redis 호출이 실패하면 일정 시간 동안 해당 호출을 차단하고 바로 fallback으로 넘어가도록 바꿨다.
첫 Redis 실패 ↓차단 종료 시각 기록 (현재 + 5초) ↓이후 5초 동안: Redis 호출 skip → 바로 fallback ↓5초 경과 후 다시 한 번 Redis 시도 ↓성공 → 정상 복귀 / 실패 → 다시 차단이렇게 하면 장애가 지속되는 동안에는 요청마다 2초씩 기다릴 필요가 없다.
[경량 Circuit Breaker 적용 후]
요청 1 → Redis → 실패 → fallback요청 2 → Redis 호출 안 함 → fallback요청 3 → Redis 호출 안 함 → fallback요청 4 → Redis 호출 안 함 → fallback...
5초 후요청 N → Redis 재시도차단 종료 시각이 지나지 않았다면 Redis를 호출하지 않고 바로 fallback으로 넘어가고, 시간이 지나면 Redis를 다시 시도하도록 했다.
단계별 경량 Circuit Breaker 적용 결과
섹션 제목: “단계별 경량 Circuit Breaker 적용 결과”그렇다면 Redis 호출 지점에 Circuit Breaker를 적용하면 얼마나 개선될까?
Redis 호출 지점마다 Circuit Breaker를 하나씩 추가하며, 동일한 Redis 장애를 반복 주입해 p95 변화를 측정했다.
| 단계 | 적용 위치 | p95 |
|---|---|---|
| ② | 폴백만 적용 (서킷 없음) | 54.54초 |
| ③ | 방 멤버 조회 + 서버 위치 MGET | 49.4초 |
| ④ | RateLimit Lua 실행 | 32.12초 |
| ⑤ | 사용자 chatId 조회 | 31.8초 |
서킷 적용 범위를 넓힐수록 p95가 54.54초 → 49.4초 → 32.12초 → 31.8초로 감소했다.
장애 상황에서의 처리 기준
섹션 제목: “장애 상황에서의 처리 기준”이번 장애 대응에서는 아래와 같은 기준을 선택했다.
Redis 장애 시 실시간성을 일부 포기하더라도 메시지 유실은 막는다.
Redis 장애 상황에서 모든 메시지를 실시간으로 전달하는 것을 목표로 잡지는 않았다.
Redis가 중단되면 Redis에 저장된 사용자의 서버 위치 정보를 확인할 수 없다. 따라서 서버 위치를 확인할 수 없는 경우에는 실시간 배달보다 DB 저장을 우선하도록 했다.
Redis 장애 ↓사용자의 서버 위치 확인 불가 ↓원격 유저에게 즉시 전달하지 않음 ↓메시지는 DB에 저장 ↓Redis 복구 후 클라이언트가 다시 조회결과적으로 이번 실험에서는 실시간성보다 메시지 보존을 우선했다. 전달 지연은 장애가 끝난 뒤에도 회복할 수 있지만, DB에 저장되지 않은 메시지는 나중에 복구할 수 없기 때문이다.
마치며
섹션 제목: “마치며”이번 실험을 하면서 두 가지를 경험했다.
첫째, 모니터링 지표만으로는 장애를 발견하지 못할 수도 있다는 것이다.
HTTP 요청 실패율이나 WebSocket 연결 상태에는 문제가 나타나지 않았지만, 실제로는 메시지 150건이 DB에 저장되지 않았다. 결국 이번 실험에서는 지표를 보는 것만으로 끝내지 않고, 송신량과 최종 저장량을 직접 비교하면서 문제를 확인했다.
둘째, 이번 실험에서 가장 크게 느낀 점은 장애가 발생했을 때 모든 기능을 정상적으로 유지하는 것이 항상 최선은 아니라는 것이다.
Redis 장애 상황에서는 실시간 배달을 계속 유지하는 것보다 메시지를 DB에 남기는 것을 우선했고, 필요하다면 클라이언트 재접속이나 DB 조회를 통해 서비스를 우회할 수도 있었다.
여러모로 감회가 새로운 테스트였다. 이전에는 어떤 기술을 선택해서 문제를 해결할지를 주로 고민했다. 하지만 해당 실험에선 장애 상황에서 무엇을 우선하고, 무엇을 포기하며, 포기한 기능을 어떤 방식으로 보완할 것인지까지 고민을 해야한다는 것을 경험했다.