-
이코에코(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 Shutdown2.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 False3.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: rabbitmq4.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_URL4.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 06.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패키지별 커버리지:
패키지 커버리지 설명 jwt98.0% JWT 검증, 클레임 파싱, 스코프 체크 cache66.2% 블랙리스트 캐시, TTL 관리 store50.0% Redis 클라이언트 래퍼 mq32.8% RabbitMQ 컨슈머 ( connect는 통합 테스트 필요)핵심 함수 커버리지:
함수 커버리지 테스트 NewBlacklistCache100% ✅ IsBlacklisted100% ✅ Add100% ✅ LoadBulk100% ✅ Size100% ✅ Stop100% ✅ cleanup100% ✅ handleMessage100% ✅ Verify(jwt)100% ✅ scopeContains100% ✅ matchesIssuer100% ✅ matchesAudience100% ✅ connect0% ⚠️ 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
'이코에코(Eco²) > Eventual Consistency' 카테고리의 다른 글
이코에코(Eco²) Eventual Consistency #4: Blacklist Relay Worker 구현 (0) 2025.12.30 이코에코(Eco²) Eventual Consistency #3: ext-authz 로컬 캐시 일관성 보장 설계 (0) 2025.12.30 이코에코(Eco²) Eventual Consistency #2: ext-authz 2500 VUs 부하 테스트 (0) 2025.12.30 이코에코(Eco²) Eventual Consistency #0: ext-authz 로컬 캐싱 설계 (0) 2025.12.29