ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 이코에코(Eco²) Eventual Consistency #1: ext-authz Blacklist 로컬 캐시 및 Fanout 구현
    이코에코(Eco²)/Eventual Consistency 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

    댓글

ABOUT ME

🎓 부산대학교 정보컴퓨터공학과 학사: 2017.03 - 2023.08
☁️ Rakuten Symphony Jr. Cloud Engineer: 2024.12.09 - 2025.08.31
🏆 2025 AI 새싹톤 우수상 수상: 2025.10.30 - 2025.12.02
🌏 이코에코(Eco²) 백엔드/인프라 고도화 중: 2025.12 - Present

Designed by Mango