이코에코(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초