이코에코(Eco²)/Eventual Consistency

이코에코(Eco²) Eventual Consistency #1: ext-authz Blacklist 로컬 캐시 및 Fanout 구현

mango_fr 2025. 12. 30. 11:48

이전 글: ext-authz 로컬 캐싱 설계


개요

설계 문서에서 제안한 로컬 캐시 + MQ 브로드캐스트 아키텍처를 구현하고 검증한 과정을 기록합니다.


1. 구현 결과 요약

1.1 메트릭 검증

ext_authz_blacklist_cache_size              7   ← 캐시에 7개 유지
ext_authz_blacklist_cache_evictions_total   0   ← eviction 없음
ext_authz_mq_events_received_total          1   ← MQ 이벤트 수신
ext_authz_mq_events_processed_total         1   ← 처리 성공

1.2 동작 흐름

┌─────────────────────────────────────────────────────────────────────────┐
│                        검증된 E2E 흐름                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  [1] Bootstrap (Pod 시작)                                                │
│      ext-authz ──SCAN──▶ Redis ──7 entries──▶ Local Cache               │
│                                                                          │
│  [2] 로그아웃 이벤트 전파                                                  │
│      auth-api ──logout──▶ Redis (SETEX)                                  │
│          │                                                               │
│          └──publish──▶ RabbitMQ (blacklist.events)                       │
│                              │                                           │
│                              ├──▶ ext-authz Pod 1: cache.Add()           │
│                              └──▶ ext-authz Pod 2: cache.Add()           │
│                                                                          │
│  [3] 인증 요청 처리                                                        │
│      Client ──▶ ext-authz ──O(1)──▶ Local Cache                          │
│                     │                    │                               │
│                     │                    └── Hit: 401 Forbidden          │
│                     │                    └── Miss: Allow                 │
│                     │                                                    │
│                     └── Redis 호출 없음 (100% 로컬)                        │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

2. ext-authz 구현 (Go)

2.1 프로젝트 구조

domains/ext-authz/
├── internal/
│   ├── cache/
│   │   ├── blacklist.go       # sync.Map 기반 로컬 캐시
│   │   ├── blacklist_test.go  # 단위 테스트
│   │   └── bootstrap.go       # Redis 초기 로드
│   ├── mq/
│   │   └── consumer.go        # RabbitMQ Fanout Consumer
│   ├── config/
│   │   └── config.go          # 환경변수 (LOCAL_CACHE_ENABLED, AMQP_URL)
│   └── server/
│       └── server.go          # gRPC Check 메서드 통합
└── main.go                    # 초기화 및 Graceful Shutdown

2.2 BlacklistCache 구현

// internal/cache/blacklist.go

package cache

import (
    "sync"
    "time"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

// Prometheus 메트릭
var (
    cacheSize = promauto.NewGauge(prometheus.GaugeOpts{
        Name: "ext_authz_blacklist_cache_size",
        Help: "Current number of entries in the blacklist cache",
    })
    cacheHits = promauto.NewCounter(prometheus.CounterOpts{
        Name: "ext_authz_blacklist_cache_hits_total",
        Help: "Total number of cache hits",
    })
    cacheMisses = promauto.NewCounter(prometheus.CounterOpts{
        Name: "ext_authz_blacklist_cache_misses_total",
        Help: "Total number of cache misses",
    })
    cacheAdditions = promauto.NewCounter(prometheus.CounterOpts{
        Name: "ext_authz_blacklist_cache_additions_total",
        Help: "Total number of entries added to cache",
    })
    cacheEvictions = promauto.NewCounter(prometheus.CounterOpts{
        Name: "ext_authz_blacklist_cache_evictions_total",
        Help: "Total number of expired entries evicted",
    })
)

// BlacklistCache는 JTI → 만료시간 매핑을 저장하는 스레드 안전 캐시
type BlacklistCache struct {
    items           *sync.Map     // map[string]time.Time
    cleanupInterval time.Duration
    done            chan struct{}
}

// NewBlacklistCache는 주기적 정리 goroutine과 함께 캐시 생성
func NewBlacklistCache(cleanupInterval time.Duration) *BlacklistCache {
    c := &BlacklistCache{
        items:           &sync.Map{},
        cleanupInterval: cleanupInterval,
        done:            make(chan struct{}),
    }
    go c.cleanupLoop()
    return c
}

// IsBlacklisted는 O(1) 로컬 조회 (Redis 호출 없음)
func (c *BlacklistCache) IsBlacklisted(jti string) bool {
    val, ok := c.items.Load(jti)
    if !ok {
        cacheMisses.Inc()
        return false
    }

    expireAt := val.(time.Time)
    if time.Now().After(expireAt) {
        // Lazy deletion: 만료된 항목 제거
        c.items.Delete(jti)
        cacheEvictions.Inc()
        c.updateSizeMetric()
        cacheMisses.Inc()
        return false
    }

    cacheHits.Inc()
    return true
}

// Add는 MQ 이벤트 수신 시 호출
func (c *BlacklistCache) Add(jti string, expireAt time.Time) {
    c.items.Store(jti, expireAt)
    cacheAdditions.Inc()
    c.updateSizeMetric()
}

// cleanupLoop는 주기적으로 만료된 항목 정리
func (c *BlacklistCache) cleanupLoop() {
    ticker := time.NewTicker(c.cleanupInterval)
    defer ticker.Stop()

    for {
        select {
        case <-c.done:
            return
        case <-ticker.C:
            c.cleanup()
        }
    }
}

func (c *BlacklistCache) cleanup() {
    now := time.Now()
    c.items.Range(func(key, value any) bool {
        if now.After(value.(time.Time)) {
            c.items.Delete(key)
            cacheEvictions.Inc()
        }
        return true
    })
    c.updateSizeMetric()
}

핵심 설계 결정:

결정 이유
sync.Map 사용 Go 표준 라이브러리, 읽기 최적화, GC 친화적
Lazy Deletion 조회 시 만료 체크로 메모리 효율 + 정확성
Background Cleanup 메모리 누수 방지, 만료 항목 주기적 정리
Prometheus 메트릭 운영 가시성, 캐시 효율 모니터링

2.3 Bootstrap 구현

// internal/cache/bootstrap.go

package cache

import (
    "context"
    "strings"
    "time"

    "github.com/redis/go-redis/v9"
)

const (
    blacklistPrefix = "blacklist:"
    scanBatchSize   = 1000
)

// BootstrapFromRedis는 Pod 시작 시 Redis에서 전체 블랙리스트 로드
func BootstrapFromRedis(
    ctx context.Context,
    client *redis.Client,
    cache *BlacklistCache,
) (int, error) {
    var cursor uint64
    loaded := 0

    for {
        // SCAN으로 배치 조회 (KEYS 대신 - 프로덕션 안전)
        keys, nextCursor, err := client.Scan(
            ctx, cursor, blacklistPrefix+"*", scanBatchSize,
        ).Result()
        if err != nil {
            return loaded, err
        }

        for _, key := range keys {
            // TTL 조회
            ttl, err := client.TTL(ctx, key).Result()
            if err != nil || ttl <= 0 {
                continue // 만료됨 또는 에러
            }

            jti := strings.TrimPrefix(key, blacklistPrefix)
            expireAt := time.Now().Add(ttl)
            cache.Add(jti, expireAt)
            loaded++
        }

        cursor = nextCursor
        if cursor == 0 {
            break
        }
    }

    return loaded, nil
}

Bootstrap 시퀀스:

┌─────────────────────────────────────────────────────────────────┐
│                    Pod 시작 시 Bootstrap                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ext-authz Pod                          Redis                    │
│       │                                    │                     │
│       │──── SCAN blacklist:* ─────────────▶│                     │
│       │◀─── [key1, key2, ...] ─────────────│                     │
│       │                                    │                     │
│       │──── TTL key1 ──────────────────────▶│                     │
│       │◀─── 3600 (seconds) ────────────────│                     │
│       │                                    │                     │
│       │  cache.Add(jti1, now+3600s)        │                     │
│       │                                    │                     │
│       │──── SCAN (cursor) ─────────────────▶│                     │
│       │◀─── [key3, key4, ...] ─────────────│                     │
│       │          ...                       │                     │
│       │                                    │                     │
│       │  ✅ Bootstrap 완료 (7 entries)      │                     │
│       │                                    │                     │
└─────────────────────────────────────────────────────────────────┘

2.4 MQ Consumer 구현

// internal/mq/consumer.go

package mq

import (
    "encoding/json"
    "time"

    amqp "github.com/rabbitmq/amqp091-go"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var (
    mqEventsReceived = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "ext_authz_mq_events_received_total",
        Help: "Total number of events received from RabbitMQ",
    }, []string{"type"})
    mqEventsProcessed = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "ext_authz_mq_events_processed_total",
        Help: "Total number of events successfully processed",
    }, []string{"type"})
)

const exchangeName = "blacklist.events"

// BlacklistEvent는 auth-api에서 발행하는 이벤트 구조
type BlacklistEvent struct {
    Type     string    `json:"type"`       // "add"
    JTI      string    `json:"jti"`        // JWT ID
    ExpireAt time.Time `json:"expires_at"` // 만료 시간
}

type BlacklistConsumer struct {
    amqpURL string
    cache   BlacklistCache
    done    chan struct{}
}

func (c *BlacklistConsumer) connect() error {
    conn, err := amqp.Dial(c.amqpURL)
    if err != nil {
        return err
    }

    ch, err := conn.Channel()
    if err != nil {
        return err
    }

    // Fanout Exchange 선언 (이미 존재하면 무시)
    err = ch.ExchangeDeclare(
        exchangeName, // name
        "fanout",     // type
        true,         // durable
        false,        // auto-deleted
        false,        // internal
        false,        // no-wait
        nil,          // arguments
    )
    if err != nil {
        return err
    }

    // Anonymous Exclusive Queue (Pod별 고유)
    q, err := ch.QueueDeclare(
        "",    // name (auto-generated: amq.gen-xxx)
        false, // durable
        true,  // delete when unused
        true,  // exclusive
        false, // no-wait
        nil,   // arguments
    )
    if err != nil {
        return err
    }

    // Queue를 Exchange에 바인딩
    err = ch.QueueBind(
        q.Name,       // queue name
        "",           // routing key (fanout은 무시)
        exchangeName, // exchange
        false,
        nil,
    )
    if err != nil {
        return err
    }

    // 메시지 소비 시작
    msgs, err := ch.Consume(
        q.Name, // queue
        "",     // consumer tag
        true,   // auto-ack
        true,   // exclusive
        false,  // no-local
        false,  // no-wait
        nil,    // arguments
    )
    if err != nil {
        return err
    }

    // 메시지 처리 루프
    for msg := range msgs {
        c.handleMessage(msg.Body)
    }

    return nil
}

func (c *BlacklistConsumer) handleMessage(body []byte) {
    var event BlacklistEvent
    if err := json.Unmarshal(body, &event); err != nil {
        return
    }

    mqEventsReceived.WithLabelValues(event.Type).Inc()

    switch event.Type {
    case "add":
        c.cache.Add(event.JTI, event.ExpireAt)
        mqEventsProcessed.WithLabelValues("add").Inc()
    }
}

Fanout Exchange 동작:

┌─────────────────────────────────────────────────────────────────────────┐
│                     RabbitMQ Fanout Exchange                             │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│                      blacklist.events (fanout)                           │
│                              │                                           │
│              ┌───────────────┼───────────────┐                           │
│              │               │               │                           │
│              ▼               ▼               ▼                           │
│     ┌────────────────┐ ┌────────────────┐ ┌────────────────┐             │
│     │ amq.gen-xxx    │ │ amq.gen-yyy    │ │ amq.gen-zzz    │             │
│     │ (Pod 1 Queue)  │ │ (Pod 2 Queue)  │ │ (Pod 3 Queue)  │             │
│     └───────┬────────┘ └───────┬────────┘ └───────┬────────┘             │
│             │                  │                  │                      │
│             ▼                  ▼                  ▼                      │
│     ┌────────────────┐ ┌────────────────┐ ┌────────────────┐             │
│     │ ext-authz      │ │ ext-authz      │ │ ext-authz      │             │
│     │ Pod 1          │ │ Pod 2          │ │ Pod 3          │             │
│     │ cache.Add()    │ │ cache.Add()    │ │ cache.Add()    │             │
│     └────────────────┘ └────────────────┘ └────────────────┘             │
│                                                                          │
│  ✅ 모든 Pod가 동일한 이벤트 수신 (Broadcast)                              │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

3. auth-api 구현 (Python)

3.1 Publisher 구현

# domains/auth/services/blacklist_publisher.py

import json
import logging
from datetime import datetime
from typing import Optional

import pika

from domains.auth.core.config import get_settings

logger = logging.getLogger(__name__)

_publisher: Optional["BlacklistEventPublisher"] = None


def get_blacklist_publisher() -> Optional["BlacklistEventPublisher"]:
    """싱글톤 Publisher 인스턴스 반환"""
    global _publisher
    if _publisher is None:
        settings = get_settings()
        if settings.amqp_url:
            try:
                _publisher = BlacklistEventPublisher(settings.amqp_url)
                logger.info("Blacklist event publisher initialized")
            except Exception as e:
                logger.warning(f"Failed to initialize publisher: {e}")
                return None
    return _publisher


class BlacklistEventPublisher:
    """RabbitMQ Fanout Exchange로 블랙리스트 이벤트 발행"""

    EXCHANGE_NAME = "blacklist.events"
    EXCHANGE_TYPE = "fanout"

    def __init__(self, amqp_url: str):
        self.amqp_url = amqp_url
        self._connection = None
        self._channel = None

    def _ensure_connection(self):
        """Lazy 연결 생성"""
        if self._connection is None or self._connection.is_closed:
            params = pika.URLParameters(self.amqp_url)
            self._connection = pika.BlockingConnection(params)
            self._channel = self._connection.channel()
            self._channel.exchange_declare(
                exchange=self.EXCHANGE_NAME,
                exchange_type=self.EXCHANGE_TYPE,
                durable=True,
            )

    def publish_add(self, jti: str, expires_at: datetime) -> bool:
        """블랙리스트 추가 이벤트 발행"""
        try:
            self._ensure_connection()

            event = {
                "type": "add",
                "jti": jti,
                "expires_at": expires_at.isoformat(),
            }

            self._channel.basic_publish(
                exchange=self.EXCHANGE_NAME,
                routing_key="",  # Fanout은 routing key 무시
                body=json.dumps(event),
                properties=pika.BasicProperties(
                    content_type="application/json",
                    delivery_mode=2,  # Persistent
                ),
            )
            return True

        except Exception as e:
            logger.error(f"Failed to publish: {e}")
            self._connection = None
            self._channel = None
            return False

3.2 TokenBlacklist 통합

# domains/auth/services/token_blacklist.py

from datetime import datetime
from redis.asyncio import Redis

from domains.auth.services.blacklist_publisher import get_blacklist_publisher


class TokenBlacklist:
    def __init__(self, redis: Redis):
        self.redis = redis

    async def add(self, payload: TokenPayload, reason: str = "logout") -> None:
        expires_at = datetime.fromtimestamp(payload.exp, tz=timezone.utc)
        ttl = compute_ttl_seconds(expires_at)
        if ttl <= 0:
            return

        # 1. Redis에 저장 (Primary Source of Truth)
        data = {
            "user_id": payload.sub,
            "reason": reason,
            "blacklisted_at": now_utc().isoformat(),
            "expires_at": expires_at.isoformat(),
        }
        await self.redis.setex(
            f"blacklist:{payload.jti}",
            ttl,
            json.dumps(data),
        )

        # 2. MQ 이벤트 발행 (ext-authz 로컬 캐시 동기화)
        publisher = get_blacklist_publisher()
        if publisher:
            publisher.publish_add(payload.jti, expires_at)

이중 쓰기 패턴:

┌─────────────────────────────────────────────────────────────────────────┐
│                      Logout 처리 흐름                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  auth-api                                                                │
│     │                                                                    │
│     │  [1] Redis SETEX (Primary)                                         │
│     │──────────────────────────────────▶ Redis                           │
│     │                                    blacklist:{jti} = {...}         │
│     │                                    TTL = token.exp - now           │
│     │                                                                    │
│     │  [2] MQ Publish (Broadcast)                                        │
│     │──────────────────────────────────▶ RabbitMQ                        │
│     │                                    blacklist.events                │
│     │                                    {type: "add", jti, expires_at}  │
│     │                                                                    │
│     │  [3] Response                                                      │
│     │◀───────────────────────────────── 200 OK                           │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

4. Kubernetes 매니페스트

4.1 RabbitMQ Exchange 정의

# workloads/rabbitmq/base/topology/exchanges.yaml

apiVersion: rabbitmq.com/v1beta1
kind: Exchange
metadata:
  name: blacklist-events
  namespace: rabbitmq
spec:
  name: blacklist.events
  type: fanout
  durable: true
  autoDelete: false
  vhost: eco2
  rabbitmqClusterReference:
    name: eco2-rabbitmq
    namespace: rabbitmq

4.2 ext-authz Deployment 환경변수

# workloads/domains/ext-authz/base/deployment.yaml

env:
  # 로컬 캐시 활성화
  - name: LOCAL_CACHE_ENABLED
    value: "true"
  - name: LOCAL_CACHE_CLEANUP_INTERVAL
    value: "60"
  # RabbitMQ 연결
  - name: AMQP_URL
    valueFrom:
      secretKeyRef:
        name: auth-secret
        key: AUTH_AMQP_URL

4.3 NetworkPolicy

# workloads/network-policies/base/allow-rabbitmq-egress.yaml

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-rabbitmq-egress
  namespace: auth
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: rabbitmq
      ports:
        - protocol: TCP
          port: 5672

5. 트러블슈팅

5.1 필드명 불일치 버그

증상:

ext_authz_blacklist_cache_additions_total    5
ext_authz_blacklist_cache_evictions_total    5   ← 즉시 eviction!
ext_authz_blacklist_cache_size               0

원인:

auth-api (Python):  "expires_at"  (s 있음)
ext-authz (Go):     "expire_at"   (s 없음)  ← 불일치!

Go에서 파싱 시 ExpireAt이 zero value (0001-01-01)가 되어 즉시 만료 처리

수정:

// Before
ExpireAt time.Time `json:"expire_at"`

// After
ExpireAt time.Time `json:"expires_at"`

5.2 pika 연결 타임아웃

증상:

Failed to publish blacklist event: Transport indicated EOF

원인: pika BlockingConnection이 idle 상태에서 연결 끊김

대응: 에러 발생 시 연결 리셋 후 재시도

except Exception as e:
    self._connection = None
    self._channel = None
    return False

향후 개선: heartbeat 설정 또는 connection recovery 구현


6. 검증 결과

6.1 Prometheus 메트릭

kubectl port-forward -n auth svc/ext-authz 9090:9090 &
curl -s http://localhost:9090/metrics | grep -E 'cache|mq_events'
ext_authz_blacklist_cache_size              7   ✅
ext_authz_blacklist_cache_additions_total   7
ext_authz_blacklist_cache_evictions_total   0   ✅
ext_authz_blacklist_cache_hits_total        0
ext_authz_blacklist_cache_misses_total      3

ext_authz_mq_events_received_total{type="add"}   1   ✅
ext_authz_mq_events_processed_total{type="add"}  1   ✅
ext_authz_mq_events_failed_total                 0

6.2 E2E 검증 시퀀스

┌─────────────────────────────────────────────────────────────────────────┐
│                         E2E 검증 결과                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  [1] Pod 시작 → Bootstrap                                                │
│      ✅ Redis SCAN → 7 entries 로드                                      │
│      ✅ cache_size = 7                                                   │
│                                                                          │
│  [2] 로그인 → 로그아웃                                                    │
│      ✅ auth-api: Redis SETEX 성공                                       │
│      ✅ auth-api: MQ publish 성공 (에러 로그 없음)                         │
│      ✅ ext-authz: MQ 이벤트 수신 (mq_events_received = 1)                │
│      ✅ ext-authz: cache.Add() 성공 (mq_events_processed = 1)            │
│                                                                          │
│  [3] 캐시 상태                                                            │
│      ✅ cache_size = 7 (eviction 없음)                                   │
│      ✅ 새 항목 정상 유지                                                  │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

7. 테스트

7.1 Go 테스트 커버리지

$ go test ./... -cover

ok      .../internal/jwt      coverage: 98.0% of statements   ← ✅ 
ok      .../internal/cache    coverage: 66.2% of statements
ok      .../internal/store    coverage: 50.0% of statements
ok      .../internal/mq       coverage: 32.8% of statements

패키지별 커버리지:

패키지 커버리지 설명
jwt 98.0% JWT 검증, 클레임 파싱, 스코프 체크
cache 66.2% 블랙리스트 캐시, TTL 관리
store 50.0% Redis 클라이언트 래퍼
mq 32.8% RabbitMQ 컨슈머 (connect는 통합 테스트 필요)

핵심 함수 커버리지:

함수 커버리지 테스트
NewBlacklistCache 100%
IsBlacklisted 100%
Add 100%
LoadBulk 100%
Size 100%
Stop 100%
cleanup 100%
handleMessage 100%
Verify (jwt) 100%
scopeContains 100%
matchesIssuer 100%
matchesAudience 100%
connect 0% ⚠️ Integration Test 필요

7.2 테스트 케이스

cache/blacklist_test.go:

func TestBlacklistCache_IsBlacklisted(t *testing.T)     // 기본 동작
func TestBlacklistCache_ExpiredEntry(t *testing.T)      // 만료 처리
func TestBlacklistCache_LoadBulk(t *testing.T)          // 벌크 로드
func TestBlacklistCache_Size(t *testing.T)              // 크기 계산
func TestBlacklistCache_Stop(t *testing.T)              // 정상 종료
func TestBlacklistCache_Cleanup(t *testing.T)           // 백그라운드 정리
func TestBlacklistCache_ConcurrentAccess(t *testing.T)  // 동시성 (100 goroutines)
func TestBlacklistCache_Overwrite(t *testing.T)         // 덮어쓰기
func TestBlacklistCache_EmptyJTI(t *testing.T)          // 빈 JTI 처리

mq/consumer_test.go:

func TestBlacklistEvent_JSONParsing(t *testing.T)       // JSON 파싱
func TestBlacklistConsumer_HandleMessage(t *testing.T)  // 메시지 처리
func TestNewBlacklistConsumer(t *testing.T)             // 생성
func TestBlacklistConsumer_Stop(t *testing.T)           // 종료

jwt/verify_test.go:

func TestNewVerifier_Validation(t *testing.T)           // 생성자 검증
func TestVerifier_Verify(t *testing.T)                  // 토큰 검증 (Valid, Expired, Invalid)
func TestVerifier_MissingJTI(t *testing.T)              // JTI 누락 처리
func TestVerifier_InvalidIssuer(t *testing.T)           // 발급자 검증
func TestVerifier_InvalidAudience(t *testing.T)         // 대상 검증
func TestVerifier_AudienceAsArray(t *testing.T)         // 배열 형태 aud 처리
func TestVerifier_NoScopeRequired(t *testing.T)         // 스코프 없음 허용
func TestScopeContains(t *testing.T)                    // 스코프 검색
func TestMatchesIssuer(t *testing.T)                    // 발급자 매칭
func TestMatchesAudience(t *testing.T)                  // 대상 매칭

store/redis_test.go:

func TestNewWithClientNil(t *testing.T)                 // nil 클라이언트 처리
func TestIsBlacklistedTrue(t *testing.T)                // 블랙리스트 조회 (존재)
func TestIsBlacklistedFalse(t *testing.T)               // 블랙리스트 조회 (없음)
func TestIsBlacklistedRedisError(t *testing.T)          // Redis 에러 처리
func TestClose(t *testing.T)                            // 정상 종료
func TestCloseError(t *testing.T)                       // 종료 에러 처리
func TestCloseNilStore(t *testing.T)                    // nil Store 처리
func TestCloseNilClient(t *testing.T)                   // nil Client 처리
func TestBlacklistKey(t *testing.T)                     // 키 생성

7.3 벤치마크

$ go test -bench=. ./internal/cache/...

BenchmarkIsBlacklisted-8               12000000       100 ns/op
BenchmarkIsBlacklisted_Miss-8          15000000        80 ns/op
BenchmarkAdd-8                          8000000       150 ns/op
BenchmarkConcurrentIsBlacklisted-8      5000000       250 ns/op

결과: O(1) 조회 성능 확인 (~100ns/op)

7.4 Python 테스트

# test_blacklist_publisher.py (11 tests)
class TestBlacklistEventPublisher:
    def test_init(self): ...
    def test_ensure_connection_success(self): ...
    def test_publish_add_success(self): ...
    def test_publish_add_connection_failure(self): ...
    def test_publish_add_publish_failure(self): ...
    def test_close(self): ...
    def test_close_without_connection(self): ...
    def test_reconnect_on_closed_connection(self): ...

class TestGetBlacklistPublisher:
    def test_returns_none_when_no_amqp_url(self): ...
    def test_returns_singleton(self): ...
    def test_returns_none_on_init_failure(self): ...

# test_token_blacklist.py (8 tests)
class TestTokenBlacklist:
    async def test_add_stores_in_redis(self): ...
    async def test_add_skips_expired_token(self): ...
    async def test_add_publishes_event(self): ...
    async def test_add_handles_publish_failure(self): ...
    async def test_contains_returns_true_when_exists(self): ...
    async def test_contains_returns_false_when_not_exists(self): ...
    def test_key_format(self): ...

8. 관련 PR

PR 설명 상태
#236 ext-authz Local Cache 구현 (Go) ✅ Merged
#238 auth-api Publisher 구현 (Python) ✅ Merged
#239 NetworkPolicy 추가 ✅ Merged
#240 expires_at 필드명 수정 ✅ Merged

9. References