-
이코에코(Eco²) Auth Offloading: ext-authz 서버 개발기 (Go, gRPC)이코에코(Eco²) 2025. 12. 13. 17:13

이코에코의 첫번째 Go, gRPC 기반 서버 배포 이코에코(Eco²) Service Mesh #2: gRPC 마이그레이션에서 언급된 Auth Offloading을 진행했다.
이 과정을 이해하려면 먼저 백엔드/인프라 고도화 전의 Auth, '공모전 당시 클러스터는 어떻게 동작하고 있었나.'를 알아야 한다.Eco² v1.0.0 Auth
기존 방식은 Auth의 공통 모듈을 전 도메인 서버에 심어 검증 로직과 유저 정보 추출을 수행토록 했다. 선택한 근거는 아래와 같다.
1) Istio 전 이코에코 클러스터엔 Ingress GW가 없어 세밀한 라우팅이 불가능했다. (API 접근 전, Auth 서버 우선 라우팅)
2) 당시는 빠른 API 기능 개발이 주요했기에 도메인 간 독립성을 일부 포기하더라도 서버 구현에 있어 간편한 방식을 택했다.
MSA임에도 공통 모듈에 묶이는 건 개발하는 과정에 있어 분산된 모놀리스 서버를 다루는 것과 같은 상황이기도 해 개운치 않았다.
공통 security 모듈 제거는 Istio ingress GW가 도입되는 시점에 즉시 개선할 1안으로 삼았다.
매번 NGINX 얘기를 했던 게 이 공통 모듈 때문이다. NGINX에는 auth route용 내장 함수를 제공해 모듈에 의존할 필요가 없다.
그렇지만 이미 이코에코 Control Plane에서 ALB Controller를 Ingress Controller로 사용하던 중이라 클러스터에 별도의 NGINX Controller를 배치하기엔 당위가 부족했다.(심지어 26년 3월 지원 종료라고 한다..)
그렇기에 Istio를 도입하며 Istio GW를 Bridge Ingress로 삼아 VS로 묶은 후, ALB Controller가 이를 바라보도록 방식을 택하게 된 거다. (Istio 도입 전, 빌드업 기간동안 이 파트를 설명할 타이밍이 나지 않아 참.. 답답했다.)Eco² Auth Offloading
Auth Offloading의 목표는 공통 Auth 모듈 제거, AuthN/AuthZ 검증을 가드레일로 배치, 검증 연산 이원화였다.
Auth 작업은 전 클러스터에 영향을 주는 만큼 많은 테스트와 시도를 반복하며 마이그레이션을 진행했다.
수많은 변경을 거치면서도 Istio Gateway에 custom action을 부착해 인증/인가 검증을 앞단 가드레일로 배치, ext-authz로 검증 연산을 Auth 서버에서 이관이라는 목표는 유지했다.
현 클러스터에서 의존성과 부하가 밀집되는 기능은 검증이다. 회원 기반 서비스가 전제인 이코에코에서 API에 접근하려면 우선적으로 JWT AuthN/AuthZ 검증을 거쳐야하기 때문에 1 호출당 1 검증 연산이 강제된다.External Authorization
https://istio.io/latest/docs/tasks/security/authorization/authz-custom/
External Authorization
Shows how to integrate and delegate access control to an external authorization system.
istio.io
apiVersion: security.istio.io/v1 kind: AuthorizationPolicy metadata: name: ext-authz-policy namespace: istio-system spec: selector: matchLabels: istio: ingressgateway action: CUSTOM provider: name: eco2-ext-authz rules: - to: - operation: paths: ["/api/v1/*"] notPaths: - /api/v1/auth/google - /api/v1/auth/refresh - /health ...
먼저 Auth Offloading 작업의 근간이 되는 Istio External Authorization에 대해 알아보자.
Istio의 External Authorization은 AuthorizationPolicy의 action: CUSTOM을 사용해 접근 제어를 외부 인가 시스템에 위임하는 기능이다. OPA, oauth2-proxy, 커스텀 ext-authz 서버로 검증 주체를 선택할 수 있다.
프로토콜도 HTTP, gRPC 양측 모두 선택 가능하다. 부착 지점도 selector로 특정할 수 있다. 커스텀 정책임에도 지원하는 선택지의 폭이 넓어 유연하게 활용할 수 있다. 그렇지만 Blacklist를 사용하는 한 Filter를 어느 위치에 붙이든, 결국 검증 트래픽은 ext-authz 향하기 때문에.. 현재로선 유연한 부착의 이점을 보기 어렵다
앞서 언급했듯 이코에코 클러스터의 경우, Istio Ingress GW(Bridge Ingress)에 부착해 클러스터 진입 시점에서 라우팅 전에 AuthN/AuthZ 검증을 수행하도록 구성했다. 별도로 RequestAuthentication 리소스를 추가로 부착해 Envoy Filter를 구성하면 AuthN 검증까지 Istio에 이관할 수 있다. 여러모로 Spring Security Filter가 떠오르는 방식아다. 이를 인프라 레이어에서 수행하는 점이 Envoy Filter의 특징이다.그럼 AuthN, AuthZ 어디까지 이관할 건가?

AuthN, AuthZ에 대해서 짚고 넘어가자. 간단히 설명하면 JWT 유효성을 검증하는 걸 AuthN, JWT가 '시스템에서' 유효한지를 검증하는게 AuthZ다. 앞서 설명했듯 Istio는 AuthN/AuthZ 검증 모두 지원한다. 인프라 레이어로 AuthN을 이관하는 것에 대해 고민이 생길 수 밖에 없는 지점이 Envoy Filter로 이관할 경우, 서버의 부하가 줄어들 순 있지만 그만큼 인프라로 부하가 분산된다. 인프라로 부하가 분산되는 경우 장애가 터지면 디버깅이 굉장히 까다롭다.. 그에 더해 Envoy Filter로 내장된 경우 로컬 테스트나 Observability에도 제약사항이 많이 생기기 때문에 '어플리케이션 레이어(서버)의 부하를 줄인다.'만으로 선택하기엔 주저가 되는 면이 있다.
실험차 FastAPI Auth(REST) + Envoy Filter(AuthN/AuthZ)를 Istio Ingress GW에 붙여 며칠 간 클러스터를 운영했었다.
Istio GW에 Envoy Filter를 붙여 AuthN, AuthZ 검증을 모두 이관한 형태로 구성했었으며, RequestAuthentication으로 AuthN을 수행하고, ext-authz filter에서 AuthZ를 수행했다. RequestAuthentication의 경우 RS256(비대칭키 알고리즘)가 강제되기에, AuthZ 필터에게 제공할 jwt secret을 Auth 엔드포인트로 노출해야 했다. 클러스터에서 큰 문제는 없었지만 내부 통신은 gRPC로 프로토콜을 표준화하는 작업을 함께 진행 중이었기에 REST 엔드포인트 노출이 강제되는 RequestAuthentication 대신 다른 안을 찾으려 했다.대안 1: Open Policy Agent 활용
https://www.openpolicyagent.org/docs/envoy/tutorial-istio
Tutorial: Istio | Open Policy Agent
Istio is an open source service mesh for managing the different microservices that make
www.openpolicyagent.org
처음 검토한 사안은 OPA와 같은 외부 에이전트로 인증/인가 검증을 수행하는 것이었다.
이미 OAuth2.0을 기반으로 자체 Auth 서버가 개발되어 있는 상태에서 외부 에이전트를 도입해 인증 로직을 뒤엎을 이유는 없었다. Auth는 로그인, 회원 관리와 연동된 만큼 외부 에이전트에 의존하기엔 리스크가 너무 컸다.대안 2: FastAPI 기반 ext-authz 서버
https://pypi.org/project/envoy_data_plane/
envoy_data_plane
Python dataclasses for the Envoy Data-Plane-API
pypi.org
그 대안으로 떠오른 사안이 Auth 서버 중 AuthN/AuthZ 유효성 검증로직을 분리해 별도의 서버로 운용하는 방안이었다.
gRPC 마이그레이션 포스팅을 읽었다면 Character 서버에서 클러스터 내부 서버 간 통신을 전담하는 서버를 gRPC로 이원화한 과정을 기억할 거다. 그 아이디어의 뿌리가 되는 서버가 Auth Offloading용 서버기도 했다.
기존 Auth 서버가 FastAPI 기반이었던 만큼 동일한 프레임워크에 gRPC용 파드(프로세스)를 분리해서 배포/운용하는 방식으로 개발했다.
그러나 ext-authz 서버의 경우는 Scan-Character 간 gRPC 구현과는 결이 달랐다.
gRPC의 동작 원리는 클라이언트(consumer)와 서버(provider) 사이 IDL(protobuf)를 정의하고 tcp 스트리밍을 뚫는 방식이다. 이를 위해선 계약서에 해당하는 IDL의 스펙을 정의할 주체인 channel이 필요한데 해당 데이터 스펙은 주체자인 서버(provider)에서 발급한다.
이 Auth Offloading에서 provider가 되는 서버는 Envoy Data-plane으로 개발자인 내가 구현에 관여할 수 없고 제공되는 istio proto 스펙에 따라 맞춰야 한다. 그러기 위해선 Istio Data-plane IDL 스펙을 가진 패키지로 찾아서 미리 빌드를 한 후 proto로 추가해야 한다.
Gemini 3.0 Pro와 함께 패키지를 찾은 후 파일 구조 분석을 하며 관련 proto spec을 주입하려했지만.. 결국 실패했다.
FastAPI의 경우 공식으로 지원하는 Istio Dataplane 패키지가 희소할 뿐더러 적용 사례도 많지 않아 불안정했다. (Degraded..)
결국 보다 호환성이 좋은 언어를 찾아야 했다. MSA 클러스터이면서 Auth 공통 모듈도 제거했겠다, 언어 선택의 자유도가 MAX로 치달았기에 굳이 협소한 FastAPI/Python에 묶일 필요는 없었다.대안 3: Go 기반 ext-authz 서버

드디어 다뤄보는 Golang. 오래 기다렸다.
그렇게 선택하게 된 언어가 Go다. CNCF 프로젝트의 경우 다수가 Go로 이루어져 있기에 관련된 지원과 생태계가 풍부하다.
Istio도 예외는 아니라 Envoy proto spec을 제공하는 패키지는 손쉽게 구할 수 있었다.
C, Rust와 함께 연산속도 최상단을 달리는 로우레벨 언어인 만큼 AuthN/AuthZ 검증 연산 엔진으로 사용하기도 좋다. JWT의 연산 부하가 그렇게 크진 않으나, 모든 트래픽을 받는 가드레일 역할을 하는 만큼 가볍고, 빠를 수록 좋다.
go 언어는 강타입 언어라 파이썬보다 동작 신뢰가 높다.
CI에서 린트/테스트/패키징도 빨라 개발 체감이 상당히 쾌적하다. 가상환경 올리고, 의존성 설치하고, 인터프리터까지 돌리는 python 계열에 비하면 그 속도가 정말 빠르다.. ext-authz 서버 외에도 go를 사용할 파트가 있다면 적극 기용할 생각이다. (Opus 4.5, Gemini 3.0 Pro에게 감사를 전한다.)ext-authz를 포함한 AuthN / AuthZ 검증 요청 흐름

현재 이코에코의 내부 서버들은 쿠키 기반 인증에서 헤더 기반 인증으로 리팩토링을 마친 상태다.
클라이언트(프론트) 측에서 헤더 기반 인증으로 전환하기 전까지 배포 앱이 동작하려면 하위 호환성을 유지해야 하기에 쿠키에서 값을 빼와 헤더에 추가하는 작업을 EnvoyFilter에서 추가로 수행한다(lua로 구현했다). 때문에 EnvoyFilter의 순서가 매우 중요하다. ext-authz 필터가 쿠키->헤더 변환 필터보다 앞에 배치되면 모든 요청에 401이 발생한다. 필터들을 기반으로 Ingress GW에서 검증 서버/백엔드로 라우팅을 집중적으로 수행하기에 흐름이 정돈된 면이 있다. 전반적으로 Istio Ingress GW가 NGINX와 유사하게 동작하도록 구성했다.ext-authz 구현 상세
그럼 이제 ext-authz의 구현부를 살펴보자. 경량 서버라 가볍게 구성했다. 첫 Go 기반 서버인만큼 포스팅으로 남기고 싶은 맘도 있다.
레이아웃
// 레이아웃 domains/ext-authz/ ├── main.go # 엔트리포인트 ├── go.mod └── internal/ ├── config/ │ └── config.go # 환경변수 기반 설정 ├── jwt/ │ └── verify.go # JWT 검증 로직 ├── server/ │ └── server.go # gRPC 서버 (Check 구현) └── store/ └── redis.go # Redis 블랙리스트 조회
경량 서버지만 첫 go 개발인 만큼 표준 레이아웃을 따랐다.
internal/ 레이아웃은 외부 패키지에서 import할 수 없도록 보호하는 go의 컨벤션이다.
ext-authz는 읽기 전용 서버이기에 Auth에서 발급하는 블랙리스트 포맷과 동일하게 가져가야 한다.
현재 Redis 객체는 FastAPI Auth와 동일한 소스를 바라보는 상황이다.함수 호출 흐름

EnovyFilter로부터 요청이 들어오면 Extract → Verify → Blacklist Check → Response 순서로 처리된다.
각 단계는 독립된 패키지가 담당한다. 패키지별로 하나의 책임만 갖도록 구성했다. (SRP)server/server.go
// 인터페이스 정의 (의존성 역전) type TokenVerifier interface { Verify(tokenString string) (map[string]any, error) } type BlacklistStore interface { IsBlacklisted(ctx context.Context, jti string) (bool, error) } // 구체 타입이 아닌 인터페이스에 의존 type AuthorizationServer struct { verifier TokenVerifier store BlacklistStore }
server 패키지가 구체 타입이 아닌 interface에만 의존하도록 구성했다.
각 interface는 최소 메서드만 가진다. 인터페이스 기능이 확장되더라도 server 코드 측 수정이 없도록 구성했다.
이로써 얻을 수 있는 이점은 아래와 같다.- server 패키지 단독 테스트 가능 (mock 주입)
- jwt/store 구현 변경이 server에 영향 없음
- 결합도 감소
server/server.go - check()
func (s *AuthorizationServer) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3.CheckResponse, error) { // 1. 토큰 추출 authHeader := req.Attributes.Request.Http.Headers["authorization"] if authHeader == "" { return denyResponse(Unauthorized, "Missing Authorization header"), nil } // 2. JWT 검증 (AuthN) claims, err := s.verifier.Verify(authHeader) if err != nil { return denyResponse(Unauthorized, "Invalid token"), nil } // 3. 블랙리스트 조회 (AuthZ) jti := claims[jwt.ClaimJTI].(string) blacklisted, err := s.store.IsBlacklisted(ctx, jti) if err != nil { // Fail-closed: 내부 에러 시 거부 return denyResponse(InternalServerError, "Internal Authorization Error"), nil } if blacklisted { return denyResponse(Forbidden, "Token is blacklisted"), nil } // 4. 허용 + 헤더 주입 userID := claims[jwt.ClaimSub].(string) return allowResponse(userID), nil }
Envoy ext_authz 프로토콜의 핵심은 Check 함수다. 모든 인증/인가 로직이 이 함수에서 수행된다.
Check 메서드는 아래 4단계로 동작한다:- 토큰 추출: Envoy가 전달한 요청에서 Authorization 헤더를 추출
- JWT 검증 (AuthN): 서명, 만료시간, issuer, audience 검증
- 블랙리스트 조회 (AuthZ): Redis에서 토큰의 jti가 블랙리스트에 있는지 확인
- 응답 반환: 성공 시 x-user-id 헤더를 주입하여 Envoy로 전달, 이후 Envoy Route가 백엔드 서비스로 실제 요청을 전달
Fail-closed 정책을 적용했다. Redis 에러가 발생하면 요청을 거부한다.
특정 도메인에선 유저 사용 경험을 저해시킬 수 있지만, 보안을 맡는 서버인만큼 일괄 거부하는 방향으로 잡았다.jwt/verify.go (JWT 검증 로직, AuthN)
// Claims 타입을 정의해 인터페이스 호환성 확보 type Claims = map[string]any func (v *Verifier) Verify(tokenString string) (Claims, error) { // Bearer prefix 제거 tokenString = strings.TrimPrefix(tokenString, "Bearer ") // 파서 생성 (알고리즘 검증 + 시간 오차 허용) parser := jwt.NewParser( jwt.WithValidMethods([]string{v.algorithm}), jwt.WithLeeway(v.clockSkew), // 분산 시스템 시간 오차 허용 ) claims := jwt.MapClaims{} token, err := parser.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { return v.secretKey, nil }) if err != nil || !token.Valid { return nil, fmt.Errorf("invalid token: %w", err) } // 필수 클레임 검증 if claims["sub"] == "" || claims["jti"] == "" { return nil, errors.New("missing required claims") } // issuer, audience 검증 (선택적) // ... return Claims(claims), nil }
golang-jwt 패키지를 사용했다. Python의 python-jose와 동일한 역할을 수행한다.
clockSkew를 설정할 수 있다. 분산 시스템에서 서버 간 시간 차이로 인한 토큰 만료 판정 오류를 방지한다. (5초로 설정)store/redis.go (블랙리스트 검증 로직, AuthZ)
// 인터페이스 정의 (의존성 역전) type RedisClient interface { Exists(ctx context.Context, keys ...string) *redis.IntCmd Ping(ctx context.Context) *redis.StatusCmd Close() error } // 컴파일 타임에 인터페이스 구현 검증 var _ RedisClient = (*redis.Client)(nil) type Store struct { client RedisClient // 인터페이스에 의존 } func (s *Store) IsBlacklisted(ctx context.Context, jti string) (bool, error) { key := "blacklist:" + jti // FastAPI Auth와 동일한 키 포맷 exists, err := s.client.Exists(ctx, key).Result() if err != nil { return false, fmt.Errorf("redis error: %w", err) } return exists > 0, nil }
키 포맷(blacklist:{jti})은 기존 FastAPI Auth 서버와 동일하게 맞췄다.
로그아웃 시 Auth 서버가 Redis에 토큰의 jti를 블랙리스트로 등록하면, ext-authz 서버가 이를 조회해 해당 토큰의 접근을 차단한다.
RedisClient 인터페이스를 분리해 Store는 구체적인 *redis.Client가 아닌 RedisClient 인터페이스에 의존한다.
상위 모듈(Store)이 하위 모듈(redis.Client)에 직접 의존하지 않고, 둘 다 인터페이스(RedisClient)에 의존한다.
ext-authz는 JWT 검증 연산과 블랙리스트 존재 여부만 판단하도록 역할을 분리한 서버기에 Exist 인터페이스만 구성했다.
Response Type (server/server.go)
// 허용 응답: x-user-id, x-auth-provider 헤더 주입 func allowResponse(userID, provider string) *authv3.CheckResponse { return &authv3.CheckResponse{ Status: &status.Status{Code: int32(code.Code_OK)}, HttpResponse: &authv3.CheckResponse_OkResponse{ OkResponse: &authv3.OkHttpResponse{ Headers: []*corev3.HeaderValueOption{ {Header: &corev3.HeaderValue{Key: "x-user-id", Value: userID}}, {Header: &corev3.HeaderValue{Key: "x-auth-provider", Value: provider}}, }, }, }, } } // 거부 응답 func denyResponse(statusCode typev3.StatusCode, body string) *authv3.CheckResponse { return &authv3.CheckResponse{ Status: &status.Status{Code: int32(code.Code_PERMISSION_DENIED)}, HttpResponse: &authv3.CheckResponse_DeniedResponse{ DeniedResponse: &authv3.DeniedHttpResponse{ Status: &typev3.HttpStatus{Code: statusCode}, Body: body, }, }, } }
응답에 사용하는 함수다. 성공/실패 응답만 구분했으며, 현재는 Check()에서만 사용하기에 별도의 파일로 분리하지 않았다.
main.go
func main() { // ... 초기화 생략 // goroutine 1: HTTP 서버 (메트릭 + 헬스체크) go func() { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) mux.HandleFunc("/health", healthHandler) http.ListenAndServe(":9090", mux) }() // goroutine 2: gRPC 서버 (ext-authz) grpcServer := grpc.NewServer() authv3.RegisterAuthorizationServer(grpcServer, authServer) grpcServer.Serve(lis) }
핵심은 RegisterAuthorizationServer(grpcServer, authServer)다.
Envoy의 ext_authz 프로토콜을 구현한 서버를 gRPC 서버에 등록한다. go-control-plane 패키지가 Istio/Envoy Data Plane의 proto spec을 제공해주기에 FastAPI에서 겪었던 IDL(protobuf) 호환성 문제가 깔끔하게 해결됐다.
Prometheus는 HTTP 1.1만 지원하므로 gRPC 단독으로는 메트릭 수집이 불가능하기에 별도의 goroutine을 추가로 띄운다.
동일 프로세스에 두 goroutine 서버를 띄워 DefaultRegistry(싱글톤)를 공유한다.
Go의 Runtime Scheduler가 커널 레벨이 아닌 userspace에서 스레드(goroutine)를 다루는 방식은 이코에코 Service Mesh #2 내부 호출 gRPC 마이그레이션에서 FastAPI/Python과 비교하며 서술했으니 참고하면 좋다.
이후 배포 구조에서 추가로 서술하겠지만 goroutine을 활용해 metrics용 HTTP 1.1과 클러스터용 gRPC를 이원화해 동일 프로세스에서 운용하는 방식은 CNCF 프로젝트의 표준으로 자리잡은 패턴이다.Dockerfile
# Build Stage FROM golang:1.24-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . # Run tests RUN go test ./... # Build RUN CGO_ENABLED=0 GOOS=linux go build -o ext-authz main.go # Run Stage FROM gcr.io/distroless/static-debian12 WORKDIR /app COPY --from=builder /app/ext-authz /app/ext-authz EXPOSE 50051 CMD ["/app/ext-authz"]
멀티스테이징으로 빌드와 실행과정을 분리하고 리눅스 이미지로 distroless를 택해 도커 이미지 사이즈를 압축했다.
관련 의존성 또한 pure go인 상황이라 정적 바이너리 파일을 distroless에 그대로 전달해도 정상 동작한다.패키징 결과
mango@mangoui-MacBookAir backend % docker images | grep -E "(ext-authz|eco2)" | head -20 WARNING: This output is designed for human readability. For machine-readable output, please use --format. ext-authz:test a52ae1afcf3c 45.1MB 12.9MB U mango@mangoui-MacBookAir backend % docker inspect ext-authz:test | jq '.[0] | {Architecture, Os, Size, Created, RepoTags, Config: {Cmd, User, WorkingDir, ExposedPorts}}' { "Architecture": "arm64", "Os": "linux", "Size": 12929856, "Created": "2025-12-13T03:58:52.414444381+09:00", "RepoTags": [ "ext-authz:test" ], "Config": { "Cmd": null, "User": null, "WorkingDir": null, "ExposedPorts": null } } mango@mangoui-MacBookAir backend % docker history ext-authz:test IMAGE CREATED CREATED BY SIZE COMMENT a52ae1afcf3c 33 hours ago /bin/sh -c #(nop) CMD ["/app/ext-authz"] 0B fe802d73a0ee 33 hours ago /bin/sh -c #(nop) EXPOSE 50051 0B 46d1f51a32da 33 hours ago /bin/sh -c #(nop) COPY file:37c0a921449258c7… 26.8MB 605ffee6c655 34 hours ago /bin/sh -c #(nop) WORKDIR /app 8.19kB
최종 빌드된 ext-authz 이미지를 살펴보면 virtula 45.2MB / compressed 12.9MB로 매우 가볍다.
컴파일된 go 바이너리의 크기는 26.8MB로 이미 가볍지만 반복패턴이 많아 gzip 압축률이 60%까지 달한 걸 확인할 수 있다.ext-authz 서버 최종 배포 구조

Prometheus는 네이티브는 HTTP 1.1로 scrapped해서 gRPC 단일로는 metrics 수집이 불가능했다.
Observability를 위한 메트릭 서버가 필요해 방법을 찾아보니 goroutine(경량 스레드, 2KB 남짓)을 활용하기 좋은 기회처럼 보였다.
동일한 프로세스에 두 goroutine 서버를 띄워, Prometheus의 DefaultRegistry(싱글톤)을 공유해 gRPC에서 기록한 metrics를 HTTP로 노출한다. 동일한 프로세스의 goroutine이기에 메모리 주소를 공유하므로, 별도의 IPC 없이 metrics에 접근할 수 있다.
Go로 이루어진 CNCF 프로젝트에서 표준으로 자리잡은 패턴이다.- etcd(클라이언트: gRPC, metrics: HTTP)
- CoreDNS(DNS: UDP/TCP, metrics: HTTP)
- K8s API Server(API: gRPC, metrics:HTTP)
- Envoy(admin: HTTP, XDS: gRPC)

gRPC 서버와 REST 서버가 단일 파드에 떠있는 걸 확인할 수 있다. 현재 모든 요청에 대한 AuthN/AuthZ 검증을 수행하고 있으며 1.5ms의 처리속도를 보인다 로그를 보면 현재 모든 서비스(로그인을 제외한 /api/v1/*) 호출이 gRPC 서버를 거쳐가는 걸 확인할 수 있다.
sparse한 호출에는 1.5-1.8ms의 속도(매우 빠르다..)로 AuthN/AuthZ 검증을 수행한다.
e2e 테스트 (locust)
테스트엔 LOCUST를 사용했다. ext-authz는 노출된 API가 아닌 AuthN/AuthZ 검증 엔진이라 우회해서 테스트를 진행해야 한다.
호출할 API는 최대한 가벼운 API(GET)인 /character/catalog와 /user/me를 택했다.
이전 성능 테스트를 진행했던 Scan API는 주기능이긴 하나, 호출당 비용이 청구되는 서비스라 고부하 테스트엔 적합하지 않았다.
200회 호출만해도 GPT API 토큰 값로 스타벅스 자허블 정도 비용이 나간다. 주요 병목이 GPT IO기도 하니.. 다음 MQ 때 수행해보자.
동시 접속자 200명, Ramp-ups 20명, RPS 272 - 283

users: 200, ramp-ups: 20, 30s, /user/me p99 2.4s, /character/catalog p99: 0.086s 
users: 200, Ramp-ups:20, 2m, /user/me p99 3.6s, /character/catalog p99: 0.24s
얼떨결에 /uesr/me와 character/catalog 부하 테스트도 함께 진행한 상황이 됐다.
/user/me와 /characater/catlog의 경우 단순 조회 API로 별다른 캐싱처리 없이 postgres에 읽기 작업을 수행하는 로직이다.
동시 접속자 200명, 20명/s의 속도로 증가하는 환경을 기준으로 30s, 2m 두 번 진행했다.
RPS 272.5 / 30s 기준 error 0회, RPS 283.3 / 2m 기준 error 33회로 0%대의 에러율이다.
통합 DB임에도 t3.small-medium 규모의 노드를 API당 1대로 분산 배치해서 그런지 준수한 성능을 보인다.
ext-authz p99

실시간 200명, 20명/s의 부하 환경에선 spike가 16.2ms, 일반적으론 14.3ms의 속도를 보인다.
충분히 빠른 속도지만 저부하 환경이 1.5ms였던 걸 감안하면 고부하 p99에선 10배정도 시간이 더 걸린다.
gRPC + 경량 go 서버라 언어와 프로토콜로 더 줄일 룸은 없고 단일 redis를 READ하는 방식에서 부하분산이 더해지면 개선할 여지가 있지 않을까 싶다.ext-authz JWT validation p99

spike가 0.246ms로 정말 초광속..이다. JWT 검증 연산이 CPU 위주의 작업이라도, 동시 접속자 200명 환경에선 아무런 부하가 걸리지 않은 걸로 확인된다.ext-authz read redis p99

병목 후보인 Reids 읽기 작업이다. spike가 6.5ms로 전체 수행시간의 25%정도를 차지한다.
블랙리스트 조회 결과를 로컬 캐시로 두면 개선될 여지가 있지만.. 제약이 많다. 이건 차후 성능 튜닝 포스팅에서 추가로 서술하겠다.ext-authz avg latency

로그에서 확인한 값과 동일하다. RPS 270 환경에서 1.7 - 2.28ms로 단일 유저 접속과 근사한 수치를 보인다.
ext-authz의 baseline latency가 2ms정도로 측정된다. 임계 환경까지 가려면 동시 접속자 1000+, 100명/s까지 가야하지 않을까.
그 상황에선 ext-authz가 아닌 FastAPI 서버 + DB IO에서 병목이 와 ext-authz에 부하가 몰리지 않을 가능성이 높긴 하다.ext-authz 동시 요청수

ext-authz는 부하 환경에서 병목이 되지 않는다는 근거로 활용할 만한 지표다.
동시 접속자 200명, 20명/s, RPS 270대인 환경에서 동시 요청수가 쌓이지 않을 만큼 연산이 빠르게 처리되고 있다.
Redis Connection 풀은 별도의 튜닝없이 기본 설정 10인 상태임에도 최대로 몰린 요청이 2이라 풀 자체도 넉넉하다.
ext-authz redis error rate

redis erro rate, 30s 테스트 땐 0%, 2m 테스트엔 0.007%
Redis 에러율도 0.007%로 준수하다. 커넥션 풀인 20의 10% 아래에서 동시 접속->처리가 이뤄지다보니 Redis에도 별다른 부하가 쌓이지 않는다. 아무래도 임계 처리량을 찾으려면 보다 robust한 환경을 가정해야겠다고 판단했다.동시 접속자 1000명, Ramp-ups 100명, RPS 230 - 270

동시접속자가 200명 -> 1000명으로 폭증한 상황을 가정해도 에러율은 1%로 안정적인 상황이다.
하지만 RPS가 늘지 않고, p99이 0.37s/20s/0.98s 가량으로 모든 API에서 4.5배 이상 증가했다.
동시 접속자 1000명, 100명/s에도 터지지 않는 걸 보면 튼튼한 서버인 건 맞으나, 처리량(RPS) 면에서 증가하지 않기에 병목지점이 있는 건 명확하다. DB 스키마를 조회하는 API들의 병목 해소는 MQ + 튜닝 때 진행해야할 듯 싶다. 일단 오늘은 ext-authz만 살펴보자.ext-authz, 동시 접속자 1000명, Ramp-ups 100명

ext-authz p99 20-80ms, 충분히 빠른 속도이나 14.3ms - 16ms였던 이전에 비하면 p99은 확연히 느려졌다. 
ext-authz avg 2-7ms, 평균 속도는 전과 동일한 수치이다.. spike가 존재하긴 하나 이후로도 평균 2ms 속도를 유지했다. 
동시 요청수 2로 동일하다.
예상대로 서비스 API(+ RDB IO)와 인프라 병목(네트워크 홉으로 추정)에 비해 ext-authz 엔진의 속도가 빨라 작업 부하가 쌓이지 않는다.
200명 -> 1000명으로 폭증했음에도 여전히 요청수 2, 평균속도 2ms로 동일한 수치 내에서 처리하고 있다. (동시 요청이 쌓이지 않는다.)
유저수가 늘었지만 외부 병목으로 RPS가 230-280대로 200명일 때와 동일한 환경이라.. ext-authz 입장에선 고부하로 보기 어렵다.
ext-authz 엔드포인트를 REST로 노출해 직접 부하를 측정할 수도 있겠으나, Envoy Filter gRPC 프로토콜로 트리거되는 ext-authz 서버 특성상 단독 벤치마크는 실제 운영 환경과 괴리가 있어 무의미하다고 판단했다.mango@mangoui-MacBookAir backend % ulimit -n 2560
혹시 로컬 Mac의 동시 스레드 수가 부족해 1000명의 동시 접속 환경이 만들어지지 않은 건가 싶어 확인해보니 2560으로 충분한 상태다.
현재 진행한 테스트에서 유의미한 부하가 발생하지 않은 점이 아쉽긴 하나, 그만큼 엔진 자체의 처리 속도는 충분히 빠르다는 말이기도 하니 나름 만족스럽다. 향후 서비스 API 병목을 해소하거나, 클러스터 내부에서 더 높은 RPS를 인가할 수 있는 테스트 절차가 마련되면 그 때 ext-authz에 병목이 관측될 듯 싶다.
RPS 병목에 막힌 상황이라 확언하긴 힘들지만 현재 수치만 보면(avg 2ms) ext-authz 엔진은 단독으로 2000+ 유저도 감당할 여력이 있어 보인다. 이코에코에서 발생하는 모든 API 호출은 ext-authz를 거치는 구조니 고부하 환경도 버틸 여력이 확인된 건 반가운 소식이다. 다음 포스팅은 Auth Offloading로 연장선인 리팩토링에 대해 가볍게 다룰 예정이다. 편하게 읽어줬으면 한다.'이코에코(Eco²)' 카테고리의 다른 글
이코에코(Eco²) 백엔드/인프라 코드 품질 분석기 도입 (1) 2025.12.20 [Dec.20.2025] 이코에코(Eco²) 백엔드/인프라 디자인 패턴 (0) 2025.12.20 [Dec.19.2025] 이코에코(Eco2) 백엔드/인프라 오픈소스 사용 현황 (0) 2025.12.19 이코에코(Eco²) Event Driven Architecture 전환 로드맵 (0) 2025.12.17 이코에코(Eco²) Scan API 성능 측정 및 시각화 (0) 2025.12.08