이코에코(Eco²)/Eventual Consistency
이코에코(Eco²) Eventual Consistency #3: ext-authz 로컬 캐시 일관성 보장 설계
mango_fr
2025. 12. 30. 15:12

1. 문제 정의
1.1 현재 아키텍처에서 발생할 수 있는 불일치
┌─────────────────────────────────────────────────────────────────────────────┐
│ 현재 아키텍처 (Best Effort) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ User Logout │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │
│ │ auth-api │ ──────► │ RabbitMQ │ ──────► │ ext-authz │ │
│ │ (블랙리스트 │ │ (blacklist. │ │ (local cache) │ │
│ │ 등록 + 발행) │ │ events) │ │ │ │
│ └─────────────────┘ └─────────────────┘ └───────────────┘ │
│ │ │
│ │ │
│ ❌ 실패 시 │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 메시지 손실 │ │
│ │ (ext-authz │ │
│ │ 캐시 미갱신) │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1.2 불일치 시나리오
| 보안 취약점 | MQ 발행 실패 | 블랙리스트 토큰이 JWT 만료까지 계속 유효 |
| 불일치 지속 시간 | JWT 만료까지 | 최대 1시간 (access_token TTL) |
| 탐지 불가 | 로그 확인 필요 | 실시간 모니터링 어려움 |
1.3 Eventual Consistency의 범위
ext-authz 로컬 캐시는 Eventual Consistency 모델을 따르지만, 불일치의 원인과 범위를 명확히 구분해야 합니다.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Eventual Consistency 발생 지점 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 정상적인 전파 지연 (허용) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ auth-api → RabbitMQ → ext-authz │ │
│ │ │ │
│ │ 지연 시간: ~10-50ms │ │
│ │ 영향: 무시 가능 │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ 2. MQ 발행 실패 (문제) ◄── 이번 설계의 해결 대상 │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ auth-api → ❌ RabbitMQ │ │
│ │ │ │
│ │ 불일치 지속: JWT 만료까지 (최대 1시간) │ │
│ │ 영향: 보안 취약점 │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ 3. ext-authz Pod 재시작 (별도 처리 완료) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 새 Pod는 Redis에서 Bootstrap │ │
│ │ │ │
│ │ 지연 시간: Pod 시작 시 1회 │ │
│ │ 영향: 없음 (이미 구현됨) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. 검토한 선택지
2.1 Option A: Redis Fallback Lookup
개념: 로컬 캐시에 없으면 Redis에서 조회
ext-authz 요청 처리:
1. Local Cache에서 JTI 확인
2. Cache Miss → Redis 조회 (Fallback)
3. Redis에서 발견 → 블랙리스트
4. Redis에도 없음 → 정상 토큰
장점:
- 구현 간단 (Relay Worker 불필요)
- 항상 최신 상태 보장
❌ 반려: 로컬 캐시의 의미 상실
┌─────────────────────────────────────────────────────────────────────────────┐
│ Redis Fallback의 근본적 문제 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 블랙리스트 토큰 비율 (실제 운영 환경) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 전체 요청 중 블랙리스트 토큰: ~0.01% 미만 │ │
│ │ │ │
│ │ ├── 정상 토큰: 99.99%+ ────────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ 모든 요청이 Cache Miss → Redis 조회 │ │ │
│ │ │ │ │ │
│ │ └──────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ └── 블랙리스트 토큰: ~0.01% ─────┐ │ │
│ │ │ │ │
│ │ 로컬 캐시 Hit → Redis 불필요 │ │ │
│ │ │ │ │
│ └────────────────────────────────────┘───────────────────────────┘ │
│ │
│ 결과: │
│ - 99.99%의 요청이 Redis를 조회함 │
│ - 로컬 캐시의 이점 완전 상실 │
│ - ext-authz 지연 시간: ~0.05ms → ~2-5ms (40-100x 증가) │
│ - Redis가 ext-authz의 병목이 됨 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
결론: 로컬 캐시를 도입한 목적 자체가 무효화됨. 불채택
2.2 Option B: Negative Cache
개념: "블랙리스트에 없음"도 캐싱
ext-authz 요청 처리:
1. Local Cache 확인
- "blacklisted" → 차단
- "not_blacklisted" → 통과 (캐시된 부재 정보)
- Cache Miss → Redis 조회 후 결과 캐싱
장점:
- Redis Fallback의 Cache Miss 문제 해결
- 한 번 조회 후 캐싱
❌ 반려: 로컬 메모리 리소스 과소비
┌─────────────────────────────────────────────────────────────────────────────┐
│ Negative Cache의 메모리 문제 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 시나리오: 1,000명의 활성 사용자, 각 사용자 평균 10개 토큰 │
│ │
│ 블랙리스트만 캐싱: │
│ ├── 로그아웃 비율: ~5%/일 │
│ ├── 블랙리스트 토큰: ~500개 │
│ └── 메모리: ~50KB │
│ │
│ Negative Cache 포함: │
│ ├── 모든 정상 토큰도 캐싱: ~9,500개 │
│ ├── 메모리: ~1MB │
│ └── 문제: 캐시 만료 전 새 토큰 발급 시 무한 증가 │
│ │
│ 대규모 시스템: │
│ ├── 100,000 활성 사용자 × 10 토큰 = 1,000,000 엔트리 │
│ └── 메모리: ~100MB+ (ext-authz Pod당) │
│ │
│ 추가 복잡성: │
│ - Negative Cache 만료 정책 필요 │
│ - 새 토큰 발급 시 Negative Cache 무효화 필요 │
│ - 결국 전체 토큰 저장소가 됨 (설계 의도 벗어남) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
결론: 복잡도 대비 이점 없음. 블랙리스트만 캐싱하는 현재 설계가 더 효율적. 불채택
2.3 Option C: 즉시 재시도 (Immediate Retry)
개념: MQ 발행 실패 시 즉시 재시도
def publish_add(self, jti: str, expires_at: datetime) -> bool:
for attempt in range(3):
try:
self._publish(event)
return True
except Exception:
time.sleep(0.1 * (attempt + 1)) # Exponential backoff
return False # 3회 실패 시 메시지 손실
장점:
- 구현 간단
- 대부분의 일시적 오류 해결
❌ 문제점
| UX 지연 | 로그아웃 응답 시간 300ms+ 증가 |
| 완전한 해결 불가 | 3회 실패 시 여전히 메시지 손실 |
| 장애 상황 악화 | MQ 장애 시 모든 로그아웃 요청 지연 |
결론: 단독으로 사용 불가. 보조 수단으로만 적합. 부분 채택
2.4 Option D: Observability 기반 재발행
개념: 실패 로그 기반 수동/자동 재발행
1. MQ 발행 실패 시 로그 기록 (error level)
2. 알림 발송 (Slack, PagerDuty)
3. 운영자가 수동으로 재발행 스크립트 실행
또는
CronJob이 실패 로그 파싱 후 재발행
장점:
- 구현 간단
- 실패 가시성 확보
❌ 문제점:
| 수동 개입 필요 | 운영 부담 증가 |
| 지연 시간 | 알림 → 확인 → 조치까지 수 분~수 시간 |
| 로그 파싱 복잡 | CronJob 구현 시 로그 포맷 의존 |
| 원자성 부재 | 재발행 중 중복 발생 가능 |
결론: 휴먼 에러 위험성 내재, 스크립트에 의존해 정합성을 해칠 우려 다분
2.5 Option E: Outbox Pattern ✅
개념: 실패한 이벤트를 Redis Outbox에 저장, Relay Worker가 백그라운드 재발행
┌─────────────────────────────────────────────────────────────────────────────┐
│ Outbox Pattern 아키텍처 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ User Logout │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ auth-api │ │
│ │ (blacklist_ │ │
│ │ publisher) │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ RabbitMQ 발행 시도 (1차) │ │
│ └────────────────┬───────────────┘ │
│ │ │
│ ┌───────────┴───────────┐ │
│ │ │ │
│ ▼ 성공 (~99%) ▼ 실패 (~1%) │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ RabbitMQ │ │ Redis Outbox │ │
│ │ (blacklist. │ │ (outbox: │ │
│ │ events) │ │ blacklist) │ │
│ └──────┬──────┘ └────────┬────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────┐ │
│ │ │ auth-relay │ ◄── 1초마다 폴링 │
│ │ │ (Relay Worker) │ │
│ │ └────────┬────────┘ │
│ │ │ │
│ │ ▼ 재발행 │
│ │ ┌─────────────────┐ │
│ └──────────────►│ RabbitMQ │ │
│ │ (blacklist. │ │
│ │ events) │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ ext-authz │ │
│ │ (local cache) │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
장점:
| 정상 경로 무영향 | 99%+ 요청은 기존과 동일하게 처리 |
| UX 무영향 | 실패해도 로그아웃 응답 즉시 반환 |
| At-Least-Once | 메시지 손실 없음 보장 |
| 복구 가능 | DLQ로 문제 이벤트 분리 |
| 확장 가능 | Relay Worker 수평 확장 가능 |
단점:
| 추가 컴포넌트 | Relay Worker 운영 필요 | 경량 구현 (~50MB 이미지) |
| 지연 | 최대 1초 추가 지연 | 폴링 주기 조절 가능 |
| 복잡도 | DLQ 모니터링 필요 | 알림 설정 |
3. 선택지 비교 요약
| Redis Fallback | 없음 | 낮음 | 100% | ❌ 무효화 | 불채택 |
| Negative Cache | 없음 | 중간 | 100% | △ 복잡 | 불채택 |
| 즉시 재시도 | 300ms+ | 낮음 | ~95% | ✅ | 부분 채택 |
| Observability | 없음 | 낮음 | 수동 | ✅ | 불채택 |
| Outbox Pattern | 없음 | 중간 | ~100% | ✅ | ✅ 채택 |
4. Redis Fallback을 포기한 핵심 이유
4.1 로컬 캐시 도입 목적 회고
ext-authz에 로컬 캐시를 도입한 이유:
┌─────────────────────────────────────────────────────────────────────────────┐
│ 로컬 캐시 도입 목적 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Before (Redis 직접 연결): │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ext-authz → Redis (매 요청마다) │ │
│ │ │ │
│ │ 지연: ~2-5ms │ │
│ │ 병목: Redis 연결 수, 네트워크 │ │
│ │ 확장성: Redis가 SPOF │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ After (로컬 캐시): │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ext-authz → Local Cache (Go sync.Map) │ │
│ │ │ │
│ │ 지연: ~0.01-0.05ms (40-200x 개선) │ │
│ │ 병목: 없음 │ │
│ │ 확장성: 무제한 (각 Pod 독립) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 핵심 가치: │
│ - 인증 지연 시간 최소화 │
│ - Redis 부하 제거 │
│ - ext-authz 수평 확장 자유도 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4.2 Redis Fallback이 목적을 훼손하는 이유
┌─────────────────────────────────────────────────────────────────────────────┐
│ Redis Fallback 적용 시 결과 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 요청 처리 흐름: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 토큰 검증 요청 │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Local Cache 조회 │ │
│ │ │ │ │
│ │ ├── Hit (블랙리스트) → 차단 (~0.01ms) │ │
│ │ │ 발생 빈도: ~0.01% │ │
│ │ │ │ │
│ │ └── Miss (정상 토큰) → Redis Fallback (~2-5ms) │ │
│ │ 발생 빈도: ~99.99% ◄── 문제 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 결과: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 평균 지연 = 0.01% × 0.01ms + 99.99% × 3ms = ~3ms │ │
│ │ │ │
│ │ → 로컬 캐시 없을 때와 동일! │ │
│ │ → 로컬 캐시 도입 이유가 사라짐 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 비유: │
│ "집에 냉장고를 샀는데, 음식이 있는지 확인할 때마다 │
│ 마트에 가서 확인한다" │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4.3 핵심 아이디어
블랙리스트는 예외다. 대부분의 토큰(99.99%+)은 블랙리스트에 없다.
따라서 "없음"을 확인하기 위해 매번 Redis를 조회하면, 로컬 캐시의 존재 이유가 사라진다.
Outbox Pattern은 이 문제를 회피한다.
- 정상 경로(99%+)는 로컬 캐시만 사용
- MQ 발행 실패(1% 미만)만 Outbox로 처리
- 로컬 캐시의 성능 이점 100% 유지
5. 최종 설계: Outbox Pattern + Retry
5.1 채택한 전략
┌─────────────────────────────────────────────────────────────────────────────┐
│ 하이브리드 전략 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1차 방어: RabbitMQ 직접 발행 │
│ ├── 성공률: ~99%+ │
│ └── 지연: ~10-50ms │
│ │
│ 2차 방어: Redis Outbox 적재 (발행 실패 시) │
│ ├── 적재 성공률: ~99.9%+ (Redis는 MQ보다 안정적) │
│ └── Relay Worker가 백그라운드 재발행 │
│ │
│ 3차 방어: DLQ (Dead Letter Queue) │
│ ├── Relay 재발행도 실패 시 DLQ로 이동 │
│ └── 수동 처리 또는 알림 │
│ │
│ 메시지 손실 확률: │
│ = MQ 실패 × Redis 실패 × Relay 실패 │
│ = 0.01 × 0.001 × 0.001 │
│ = 0.00000001% (사실상 0) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5.2 구현 원칙
| Non-blocking | 로그아웃 응답에 재시도/Outbox 적재 시간 포함 안 함 |
| FIFO | Outbox는 LPUSH, Relay는 RPOP으로 순서 보장 |
| At-Least-Once | 중복 가능하나 손실 없음 (ext-authz에서 중복 허용) |
| Graceful Degradation | Relay 장애 시에도 auth-api 정상 동작 |
5.3 Redis 키 설계
| Key | Type | 설명 | TTL |
|---|---|---|---|
outbox:blacklist |
List | 실패한 이벤트 (FIFO) | 영구 |
outbox:blacklist:dlq |
List | 재발행도 실패한 이벤트 | 영구 |
6. 트레이드오프 수용
6.1 수용한 트레이드오프
| 추가 컴포넌트 (Relay Worker) | 경량 (~50MB), 단순 로직, 기존 노드에 배치 |
| 최대 1초 추가 지연 | MQ 장애 시에만 발생, 정상 시 0 |
| At-Least-Once (중복 가능) | ext-authz가 중복 이벤트 허용 (idempotent) |
6.2 거부한 트레이드오프
| Redis Fallback | 로컬 캐시 무효화, 성능 40-200x 저하 |
| Negative Cache | 메모리 폭발, 복잡도 증가 |
| 수동 개입 | 운영 부담, 지연 시간 보장 불가 |
7. 결론
7.1 설계 결정 요약
┌─────────────────────────────────────────────────────────────────────────────┐
│ 설계 결정 요약 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 문제: MQ 발행 실패 시 ext-authz 로컬 캐시 불일치 (보안 취약점) │
│ │
│ 검토한 선택지: │
│ ├── Redis Fallback → 불채택 (로컬 캐시 무효화) │
│ ├── Negative Cache → 불채택 (메모리 폭발) │
│ ├── 즉시 재시도 → 부분 채택 (UX 영향) │
│ ├── Observability → 불채택 (수동 개입) │
│ └── Outbox Pattern → ✅ 채택 │
│ │
│ 선택 이유: │
│ ├── 정상 경로 무영향 (99%+) │
│ ├── 로컬 캐시 성능 이점 유지 │
│ ├── At-Least-Once 메시지 보장 │
│ └── 복구 가능 (DLQ) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7.2 기대 효과
| MQ 발행 실패 시 메시지 손실 | 100% | ~0% |
| 로컬 캐시 성능 (p99) | ~0.05ms | ~0.05ms (유지) |
| 운영 부담 | 수동 확인 | 자동 복구 + DLQ 알림 |
| 보안 취약점 지속 시간 | JWT 만료까지 (최대 1시간) | ~1초 |