Failover Consistency
가정을 해보자. redis 마스터가 죽으면서 failover로 replica가 master로 승격했는데 비동기 복제 구조라 몇 개 쓰기가 날아가서 옛날 값을 내려주는 상황이 있다고치자.
이 상황에서 이 문제를 사전에 줄이거나 사후에 감지, 복구해야하는 환경을 구축할 수 있는데 각각 알아보겠다.
1. redis를 진짜 source of truth로 쓰지 않는것 #
쉽고 당연한 예기인데 돈, 잔액, 계약 상태같은 틀리면 안되는 값은 mysql같은 영구 db 를 소스 오브 트루스로 두고 redis는 캐시 세션 부가정보등으로 쓰는것이 안전하다.
마스터 다운 + async replication 환경에서 이미 날아간 쓰기는 기술적으로 되살릴 수 없기 대문, 새로운 마스터 입장에서는 그런 쓰기의 존재자체를 모르니
정합성이 중요한 값이라면 db commit 성공 이후 redis 업데이트를 한다 이는. 캐시 저장소의 일관성을 낮추는 의미기도하다.
2. 쓰기 시점에 틀린 값 발생 확률을 줄이는 방법 #
redis는 WAIT을 통해서 반동기 semi-sync처럼 쓴다.
redis는 기본이 비동기 복제지만, 클라이언트 단에서 wait 명령어로 이 쓰기를 복제본 n개가 받을때까지 기다려라. 최대 M ms큼 기다려라. 라고 요구가 가능하다.
-- 1) 값 쓰기
SET user:123:balance 10000
-- 2) 현재 마스터 기준, 복제본 1개 이상이 받아갈 때까지 최대 100ms 기다리기
WAIT 1 100
실제로는 SET 이후에 WAIT을 호출하거나 MULTI or lua 스크립트 안에서 묶어 처리할 수 있다.
장점으로는 마스터가 죽기 직전에 쓴 값이 복제본에도 올라가있을 확률이 올라가지만 단점은 쓰기 지연이 늘어난다.
지정한 수의 replica가 느리거나 다운되어 있으면, 쓰기가 실패 or 타임아웃이 날 수 있다.
그래도 완전히 날려먹기보다는 조금 느리더라도 안전한 쓰기가 필요한 key에서만 선택적으로 적용하는식으로 많이 사용한다.
redis 옵션에 아래같은것들이 있는데.
min-replicas-to-write 1
min-replicas-max-lag 5
최소 1개의 레플리카, 그 레플리카와의 복제 지연이 5초 이내일경우에만 쓰기를 받아라.
라는 의미이며 그 조건이 맞지 않으면 쓰기 명령에 에러를 리턴한다. 즉, 복제 상태가 나쁜 상태에서 중요한 쓰기가 들어오는것을 막는 역할이다.
이렇게하면 슬레이브가 너무 뒤처져 있는 상태에서 마스터가 막 쓰다가 죽고나서 엄청 뒤쳐진 슬레이브가 승격되는 상황을 방지할 수 있다.
읽기 시점에 이 값을 믿을 수 있는지 검증하는 패턴 #
이미 페일오버가 된 상태라면, key값이 최신인가 애플리케이션 레벨에서 검증할수도 있다.
예를 들어 레디스에서 이렇게 저장한다면
"user:123:profile" = {
"version": 42,
"updated_at": "2025-12-10T01:23:45Z",
"name": "홍길동",
"grade": "VIP"
}
애플리케이션에서 db(source of truth)는 항상 큰 version을 가지고있다고 가정한다.
레디스에서 읽은 version이 db에 있는것보단 작다면 stable. 클라이언트가 알고있는 이전 version보다 작다면 rollback 감지.
이때의 처리패턴은
- 의심가는 상태일때는 db에서 다시 한 번 읽어서 정합성을 맞춘 후
- redis를 다시 덮어 씌운다. (real-through + repair)
이렇게 하면 페일오버 후 처음 몇 번의 요청에서 어? 이 버전이 줄어들었네 하고 self healing을 할 수 있다.
redis는 cache, db는 진짜 값, 의심 나면 db 재조회 + 캐시 rebuild #
조금 더 단순해진 패턴으로는
- redis에서 read
- 비즈니스상 말이 안되는 값이면 (예: 음수 잔액, 상태 역행 등)
- db 다시 read
- redis 값 덮어쓰기
fun getUserBalance(userId: Long): Long {
val cached = redis.get("user:$userId:balance")?.toLongOrNull()
if (cached != null && cached >= 0) {
// 정상적인 값으로 보이면 그냥 사용
return cached
}
// 이상한 값이거나 없으면 DB를 소스로 사용
val dbValue = userRepository.findBalance(userId)
redis.set("user:$userId:balance", dbValue.toString())
return dbValue
}
핵심은 redis(cache)는 항상 틀릴수 있다는 가정하에 각 도메인마다 틀렸을 때 어떻게 감지하고 어디까지 롤백/복구할지 규칙을 가지는 것.
페일오버 절차 자체를 보수적으로 설계 #
운영 관점에서도 할 수 있는 것들이 있다.
- 자동 페일오버 도구 (sentinal, cluster) 사용시
- 승격 조건을 너무 공격적으로(빨리) 잡지 않고,
- 네트워크 파티션 vs 진짜 장애를 잘 구분하도록 timeouts 조정
- 페일오버 이후
- 가능하면 짧은 구간 (수 초 ~ 수십초) 동안은 특정 도메인의 read를 db우선으로 강제
- 중요 키에 대해 강제 리빌드 작업(불일치 점검 배치)를 돌리는 전략도 있음 물론 db로 트래픽이 쏠릴수도 있으니 트레이드오프
- 옛날 마스터가 살아나면
- 반드시 새 마스터를 레플리카로 재등록해 잘못된 데이터로 다시 승격되는 경우를 막아야함.
이미 잘못된 값을 내렸을 경우 #
이미 클라이언트에게 잘못된 값을 내려간 경우에는 기술적으로 원자적 롤백은 불가능하고
도메인 레벨에서 보상 로직으로 풀어야한다.
예를들어 잘못된 잔액을 보여줬지만 실체 출금은 디비 기준으로 검증해서 막거나
잚소된 상태로 비즈니스 로직이 실제 실행되었다면
- 감사 로그/이벤트 로그 기반으로 재계산
- 고객 보상, 재전송, 정산배치등, 혹은 고객 ui로 재조회 버튼을 제공해줘도 괜찮고
그래서 cache db message queue등 전체 플로우에 audit event 로그를 남겨두고
장애시 어디에서부터 어디까지 영향이 갔는지 재구성 할 수 있게 하는것이 중요함.
총 정리 #
결과적으로 CAP이론에서 C A를 조정하는 작업을 진행하는 것인데, 두개다 만족시킬수는 없다.
일관성이 중요한 작업이라면 cache로 latency를 줄이는것보단 좀 걸리더라도 일관적인 동작, 데이터의 무결성에 신경쓰는것이 중요할 것이고
데이터가 중요하지 않고 일단 많은 트래픽을 수용하는것이 중요하다면, 일관성 레벨을 조금 낮추고 이에 대응하는 도메인 로직 혹은 재시도 로직, 개발자 수기처리등으로 대응하는것이 좋다.
어느정도 둘다 필요하다면, 적절한 타협을 통해, BASE기반 시스템으로 eventually consistency를 보장하며 soft state, basic availablity를 보장하도록 설계해보자.
어찌되었건 요구사항들에 따른 트레이드 오프를 잘 고려하여 시스템을 설계하는것이 중요함.