[System Design Lab] 2. URL Shortener, 장애를 내보고 복구하기
Repository : [Github] URL-Shortener
Redis를 붙이면 끝일까
이전 단계에서는 모든 Redirect 요청이 MySQL을 조회하면서 발생하던 Connection Pool 병목을 확인하고 Redis Cache Aside를 적용했다.
Client
↓
Spring Boot
↓
Redis
↓ Cache Miss
MySQL
100 VU 기준 RPS는 약 8,500에서 17,000으로 증가했고 p95는 32.96ms에서 11.93ms로 감소했다.
성능은 좋아졌지만 구조를 다시 보니 새로운 문제가 생겼다.
기존에는 MySQL만 사용했지만 이제 Redirect 요청이 Redis에도 의존한다.
Redis가 장애가 나면 캐시를 붙이기 전보다 오히려 서비스가 더 불안정해지는 것은 아닐까?
그래서 이번에는 성능을 더 높이는 대신 실제로 구성 요소를 중단시키면서 서비스가 어떻게 동작하는지 확인하는 것을 목표로 했다.
Redis가 죽으면 MySQL로 우회하기
먼저 Redis를 직접 중단했다.
Redis에서 조회하지 못하더라도 원본 데이터는 MySQL에 남아 있기 때문에 Redirect 자체가 실패할 필요는 없었다.
그래서 Redis 접근에 실패하면 MySQL을 조회하도록 Fallback을 추가했다.
Redis 정상
→ Redis 조회
→ Redirect
Redis 장애
→ Redis 조회 실패
→ MySQL Fallback
→ Redirect
Redis는 조회 성능을 높이기 위한 Cache일 뿐이고 MySQL을 Source of Truth로 유지했기 때문에 가능한 구조였다.
100 VU로 120초 동안 요청을 보내면서 중간에 Redis를 중단하고 다시 실행했다.
0 ~ 30초 Redis 정상
30 ~ 60초 Redis 장애
60 ~ 120초 Redis 복구
전체 656,623건의 요청에서 실패는 발생하지 않았다.
Fallback을 통해 Redis 장애가 사용자 요청 실패로 이어지는 것은 막을 수 있었다.
하지만 Grafana를 확인해보니 또 다른 문제가 보였다.
Redis 장애 구간에서 p95가 약 200ms까지 증가했다.
이유는 Redis를 우회하기 전에 모든 요청이 먼저 Redis를 시도했기 때문이다.
요청
↓
Redis 접근
↓
200ms Timeout 대기
↓
실패
↓
MySQL Fallback
결국 요청은 성공했지만 Redis가 죽어 있는 동안 모든 요청이 불필요하게 Timeout을 기다리고 있었다.
계속 실패하는 Redis를 왜 계속 호출할까
Redis가 이미 장애 상태라는 것을 알고 있다면 매 요청마다 다시 접근할 필요가 없다.
이 문제를 해결하기 위해 Circuit Breaker를 적용했다.
Circuit Breaker의 상태는 세 단계로 나눴다.
CLOSED
→ Redis 정상 호출
OPEN
→ Redis 호출 차단
→ 바로 MySQL Fallback
HALF_OPEN
→ 일부 요청으로 Redis 복구 확인
→ 성공하면 CLOSED
이번 설정에서는 최근 Redis 호출의 실패율이 기준을 넘으면 Circuit을 OPEN하고, 5초 후 일부 요청을 다시 Redis로 보내 복구 여부를 확인하도록 했다.
Redis 장애 직후에는 몇 번의 실패가 발생하지만 Circuit이 OPEN된 이후에는 요청이 Redis Timeout을 기다리지 않는다.
Fallback Only
요청
→ Redis
→ 200ms 대기
→ MySQL
Circuit Breaker
요청
→ Circuit OPEN 확인
→ Redis 호출 생략
→ MySQL
같은 장애 상황에서 다시 측정하자 Redis 장애 구간의 p95가 약 200ms에서 20~30ms 수준으로 감소했다.
요청 실패율은 그대로 0%였다.
여기서 Fallback과 Circuit Breaker가 해결하는 문제가 조금 다르다는 것을 알 수 있었다.
Fallback
→ 장애가 요청 실패로 전파되는 것을 막음
Circuit Breaker
→ 이미 실패하고 있는 의존성을 반복 호출하지 않음
Fallback만으로도 가용성은 유지할 수 있었지만, 장애 상황에서의 응답 시간까지 생각하면 Circuit Breaker가 필요했다.
애플리케이션이 한 대라면?
Redis 장애를 다루면서 전체 구조를 다시 보니 Spring Boot 애플리케이션도 한 대뿐이었다.
Client
↓
App
↓
Redis / MySQL
Redis를 아무리 잘 복구하더라도 App 자체가 중단되면 모든 요청이 실패한다.
그래서 애플리케이션을 두 개로 늘리고 앞에 Nginx를 배치했다.
┌→ App1
Client → Nginx
└→ App2
Nginx는 요청을 두 App으로 분산하고 한 App에 연결할 수 없으면 다른 App으로 요청을 전달한다.
이 과정에서 단축 코드 생성 방식도 다시 고려해야 했다.
기존 Seuqeunce 방식은 MySQL의 Auto Increment ID를 기준으로 동작했지만, 여러 App이 DB와 독립적으로 ID를 생성하려면 각 인스턴스가 서로 충돌하지 않는 ID를 만들어야 한다.
그래서 다중 App 환경에서는 Snowflake의 nodeId를 각각 다르게 설정했다.
App1
nodeId = 1
↓
Snowflake ID
App2
nodeId = 2
↓
Snowflake ID
두 App이 동시에 코드를 생성해도 서로 다른 ID 공간을 사용할 수 있도록 했다.
이후 부하 테스트 중 App1을 직접 중단했다.
0 ~ 30초 App1 + App2
30 ~ 60초 App1 중단
60초 이후 App1 재시작
총 446,850건의 요청을 처리하는 동안 요청 실패율은 0%였다.
단일 App 장애가 발생해도 Nginx가 살아 있는 App으로 요청을 전달하는 것을 확인할 수 있었다.
다만 여기서 Nginx 자체는 한 대이기 때문에 새로운 SPOF로 남는다.
이번 실험의 목적은 모든 SPOF를 제거하는 것이 아니라 App 인스턴스 하나의 장애를 다른 App이 받아낼 수 있는지 확인하는 것까지로 범위를 정했다.
Fallback은 Redis를 복구한 것이 아니다
이제는 Redis가 죽어도 사용자 입장에서는 서비스가 계속 동작한다.
하지만 다시 생각해보면 Redis 문제 자체를 해결한 것은 아니다.
Redis 장애
↓
Circuit Breaker
↓
MySQL Fallback
이 구조는 Redis를 우회하고 있을 뿐이다.
Redis가 계속 죽어 있다면 조회 요청은 계속 MySQL로 몰리게 되고, 부하가 커지면 이전에 확인했던 Connection Pool 병목이 다시 발생할 수 있다.
필요한 것은:
Redis Master에 장애가 발생했을 때 다른 Redis가 자동으로 그 역할을 이어받는 것
이었다.
그래서 Redis Sentinel 환경을 구성했다.
Sentinel 1
│
Sentinel 2 ─ Redis Master ─ Sentinel 3
│
┌──────┴──────┐
Replica Replica
구성은 Redis 3대와 Sentinel 3대로 만들고 Quorum은 2로 설정했다.
Sentinel은 Redis 노드를 감시하다가 Master 장애를 확인하면 Replica 중 하나를 새로운 Master로 승격한다.
애플리케이션에서는 특정 Redis 주소를 Master로 고정하지 않고 Sentinel에게 현재 Master가 누구인지 확인하도록 했다.
Sentinel
→ 현재 Master 결정
Lettuce
→ Sentinel을 통해 Master 확인
→ 해당 Redis에 연결
그래서 Master가 바뀌어도 애플리케이션 설정을 직접 변경할 필요가 없다.
Redis Master를 직접 중단해보기
Sentinel도 실제 장애 상황에서 확인했다.
테스트 시작 당시 Master는 redis-replica-1 이었다.
부하를 보내는 중 해당 Redis를 직접 중단했다.
redis-replica-1
X
↓
Sentinel 장애 감지
↓
redis-replica-2 승격
↓
Lettuce 재연결
Sentinel이 새로운 Master를 결정하기 전까지는 Redis 요청이 실패할 수 있다.
이 구간은 앞에서 적용했던 Circuit Breaker와 MySQL Fallback이 담당한다.
전체 흐름은 다음과 같았다.
Redis Master 장애
↓
Redis 요청 실패
↓
Circuit Breaker OPEN
↓
MySQL Fallback
↓
요청 계속 처리
동시에
Sentinel 장애 감지
↓
Replica 승격
↓
새 Master 결정
↓
Lettuce 재연결
↓
Circuit Breaker CLOSED
↓
Redis 경로 복구
실험에서는 Master 중단 후 8.567초 만에 Replica가 새로운 Master로 승격됐다.
120초 동안 총 938,870건의 요청을 처리했고 요청 실패율은 0%였다.
전체 p95는 23.72ms였다.
Grafana에서도 장애 직후 Fallback과 Circuit Breaker Rejected, DB Lookup이 증가했다가 Sentinel Failover가 완료된 뒤 다시 0으로 돌아가는 것을 확인했다.
즉, 이번에는 단순히 Redis 장애를 우회한 것이 아니라 Cache 계층 자체가 자동으로 다시 정상 경로로 돌아오는 것까지 확인할 수 있었다.
다만 Failover 순간 최대 응답 시간은 약 1.98초까지 증가했다.
또한 Redis와 Sentinel을 모두 같은 Docker Host에서 실행했기 때문에 실제 운영 환경처럼 독립된 Failure Domain을 구성한 실험은 아니다.
Sentinel과 Cluster는 같은 역할일까
Redis 노드를 여러 개 사용하면서 처음에는 Redis Cluster까지 필요한지 고민했다.
하지만 두 기술의 목적은 달랐다.
Redis Sentinel
→ Master 장애 대응
→ Replica 승격
→ High Availability
Redis Cluster
→ 데이터를 여러 노드에 분산
→ 저장 용량과 처리량 확장
→ Sharding
이번 프로젝트에서 실제로 확인한 문제는 Redis Master 장애였다.
Stress Test에서 처리량 증가가 둔화되는 구간은 있었지만 그 원인이 Redis 자체의 용량이나 처리량이라고 분리해서 확인하지 않았다.
따라서 확인하지 않은 문제를 해결하기 위해 Cluster까지 추가하지 않았다.
기술을 추가하기 전에 지금 해결하려는 문제가 무엇인지 먼저 구분했다.
이번 단계에서 얻은 것
처음 Redis를 적용했을 때는 캐시가 정상적으로 동작하고 성능이 개선되는 것까지만 생각했다.
하지만 실제로 Redis를 중단해보니 하나의 장애를 해결할 때마다 다음 문제가 보였다.
Redis 장애
↓
MySQL Fallback
↓
요청은 성공하지만 Timeout 대기
↓
Circuit Breaker
↓
빠르게 MySQL로 우회
↓
하지만 Redis는 여전히 장애 상태
↓
Redis Sentinel
↓
Replica 자동 승격
↓
Cache 경로 복구
애플리케이션도 마찬가지였다.
Single App
↓
App1 + App2
↓
Nginx Failover
이 과정에서 가장 크게 느낀 것은 장애 대응과 장애 복구는 같은 문제가 아니라는 것이었다.
Fallback과 Circuit Breaker는 Redis 장애가 사용자 요청까지 전파되는 것을 막는다.
Sentinel은 장애가 발생한 Redis 계층 자체를 다시 정상 상태로 복구한다.
그리고 다중 App은 애플리케이션 한 대의 장애가 서비스 전체 장애로 이어지는 것을 막는다.
이번 URL Shortener를 통해 최종적으로 만든 흐름은 다음과 같다.
성능:
DB Only
→ Redis Cache Aside
장애 대응:
Redis Timeout
→ MySQL Fallback
→ Circuit Breaker
장애 복구:
Redis Single Master
→ Redis Sentinel
애플리케이션 확장:
Single App
→ App1 + App2
→ Nginx
물론 모든 SPOF를 제거한 것은 아니다.
Nginx와 MySQL은 여전히 단일 구성이고, 모든 인스턴스가 같은 로컬 Docker 환경에서 실행됐다.
하지만 이번 프로젝트의 목적은 처음부터 완성된 분산 시스템을 만드는 것이 아니었다.
가장 단순한 구조에서 시작하고, 실제로 병목과 장애를 발생시킨 뒤 그 문제를 해결하는 방향으로 시스템을 확장한다.
첫 번째 System Design Lab을 진행하면서 시스템 설계에서 중요한 것은 많은 기술을 사용하는 것이 아니라, 왜 다음 기술이 필요한지 설명할 수 있는 과정이라는 것을 확인할 수 있었다.
다음 단계에서는 URL Shortener에서 사용한 측정과 개선 과정을 바탕으로 두 번째 프로젝트인 Notification System을 설계한다.
3. Notification System 요구사항 정의와 아키텍처 설계