이코에코(Eco²)/Auth Offloading (ext-authz)

이코에코(Eco²) Auth Offloading: 도메인 공통 모듈 제거

mango_fr 2025. 12. 13. 17:17

Python, HCL, Go. 나름 다채로워진 Languages 분포다. 클러스터+Gitops 구축 당시엔 HCL+Shell이 가장 높았다.

 
지난 포스팅에 더해 Auth Offloading을 이어서 작성하겠다.
Auth Offloading이 필요했던 이유와 가능해진 배경, 의사결정 과정, 구현, 부하 테스트까지를 다뤘다면
이 글에선 Auth Offloading으로 얻을 수 있던 소소한 개선과 한계를 서술한다. 
 

기존 구조의 문제점

domains/
├── _shared/
│   └── security/          # 공통 모듈
│       ├── jwt.py         # TokenPayload, extract_token_payload
│       └── dependencies.py # build_access_token_dependency
├── scan/
│   └── api/dependencies.py → import from _shared/security
├── my/
│   └── security.py        → import from _shared/security
├── location/
│   └── security.py        → import from _shared/security
...

 
ext-authz 개발기에서 언급했듯 MSA에서 공통 모듈은 분산 모놀리스를 만든다.
모듈을 수정하면 모든 도메인이 영향을 받고, 도메인 간 독립 배포가 어려워진다.
그간 공통 모듈에 의존했던 이유는 각 도메인에서 JWT 파싱 로직으로 유저 정보를 검증, 추출했기 때문이다.

# 기존: JWT를 직접 파싱해야 했음
def extract_token_payload(token: str) -> TokenPayload:
    payload = jwt.get_unverified_claims(token)  # jose 라이브러리
    return TokenPayload(
        sub=payload["sub"],
        jti=payload["jti"],
        type=TokenType(payload["type"]),
        exp=payload["exp"],
        iat=payload["iat"],
        provider=payload["provider"],
    )

ext-authz 도입 후

ext-authz가 JWT 검증을 완료하고, 필요한 정보를 HTTP 커스텀 헤더로 주입한다.

ext-authz가 주입하는 헤더:

헤더 출처 값 예시
x-user-id JWT sub claim 8b8ec006-2d95-45aa-bdef-e08201f1bb82
x-auth-provider JWT provider claim google, kakao

 


변경 후: 도메인 독립 구현

이제 각 도메인에서 단순 헤더 추출만 하면 된다. JWT 파싱 라이브러리(python-jose)도 필요 없다.

# domains/{도메인}/security.py (또는 dependencies.py)

from uuid import UUID
from typing import Optional

from fastapi import Header, HTTPException, status
from pydantic import BaseModel


class UserInfo(BaseModel):
    """ext-authz가 주입한 헤더에서 추출된 사용자 정보"""
    user_id: UUID
    provider: str


async def _extract_user_info(
    x_user_id: Optional[str] = Header(default=None, alias="x-user-id"),
    x_auth_provider: Optional[str] = Header(default=None, alias="x-auth-provider"),
) -> UserInfo:
    """
    ext-authz가 주입한 헤더에서 사용자 정보 추출.

    Headers:
        x-user-id: JWT sub claim (사용자 UUID)
        x-auth-provider: JWT provider claim (google/kakao/naver)

    Note:
        - ext-authz가 JWT 검증과 블랙리스트 조회를 완료한 상태
        - 이 헤더들은 클라이언트가 직접 설정할 수 없음 (ext-authz가 덮어씀)
    """
    if not x_user_id:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Missing x-user-id header",
        )

    try:
        user_id = UUID(x_user_id)
    except ValueError as exc:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid x-user-id format",
        ) from exc

    return UserInfo(
        user_id=user_id,
        provider=x_auth_provider or "unknown",
    )


# 로컬 개발/테스트용 auth bypass
if settings.auth_disabled:
    async def get_current_user() -> UserInfo:
        return UserInfo(
            user_id=UUID("00000000-0000-0000-0000-000000000000"),
            provider="disabled",
        )
else:
    get_current_user = _extract_user_info

엔드포인트에서 사용

from fastapi import APIRouter, Depends
from domains.scan.api.dependencies import get_current_user, UserInfo

router = APIRouter(prefix="/scan", tags=["scan"])

@router.post("/classify")
async def classify(
    payload: ClassificationRequest,
    user: UserInfo = Depends(get_current_user),  # 헤더에서 자동 추출
    service: ScanService = Depends(),
):
    return await service.classify(payload, user.user_id)

헤더 신뢰성

// ext-authz server (Go)
func allowResponse(userID, provider string) *authv3.CheckResponse {
    return &authv3.CheckResponse{
        // ...
        Headers: []*corev3.HeaderValueOption{
            {
                Header: &corev3.HeaderValue{
                    Key:   "x-user-id",      // 클라이언트가 설정해도
                    Value: userID,            // ext-authz 값으로 덮어씀
                },
            },
            {
                Header: &corev3.HeaderValue{
                    Key:   "x-auth-provider",
                    Value: provider,
                },
            },
        },
    }
}

ext-authz의 allowResponse에서 헤더를 설정할 때, Envoy는 기존 헤더를 덮어쓴다.
따라서 백엔드 서비스에서 받는 x-user-id, x-auth-provider항상 ext-authz가 검증 후 주입한 값이 된다.


의존성 분리 정리

항목 AS-IS TO-BE
의존성 _shared/security import 없음
JWT 라이브러리 python-jose 필요 불필요
코드 라인 3개 파일 참조 ~30줄 자체 구현
도메인 독립성 ❌ 공유 모듈에 의존 ✅ 완전 독립
빌드/배포 모듈 변경 시 전체 영향 도메인별 독립 배포

삭제된 파일

rm domains/_shared/security/__init__.py
rm domains/_shared/security/jwt.py
rm domains/_shared/security/dependencies.py

ext-authz로 인증/인가 로직이 비즈니스 레이어에서 분리되면서

  1. JWT 파싱 불필요 → 헤더 추출만으로 충분
  2. 공통 모듈 불필요 → 각 도메인이 ~30줄로 자체 구현
  3. 도메인 완전 독립 → MSA 원칙 준수
  4. 의존성 감소python-jose 제거 가능

 

한계: Global Choke Point

 
모든 인증 필요 트래픽이 ext-authz 서버를 경유하며, 검증 서버 장애 시 Fail-closed 정책에 따라 전 서비스 접근이 차단된다. 따라서 ext-authz의 처리량이 곧 시스템 전체의 상한이 된다. (merics, healths 등 비인증/인가 엔드포인트 제외)
FastAPI Auth 서버 중 JWT 검증(AuthN/AuthZ)만 분리되어 경량화된 go+gRPC 서버로 SOF가 옮겨간 것 뿐이다.
현재는 되려 서비스 API의 병목에 걸려, 유저수가 늘어나도 ext-authz 서버에 280 RPS 이상 부하를 주기 어렵지만..
유저 1000명의 환경에서 p99 20-80ms, avg 2ms가 유지되는 건 상당히 고무적이다.


 
Auth Offloading은 연산 분리를 넘어, 이코에코 도메인 간 코드 레벨의 결합도를 낮추는 개선으로 이어졌다.
클라우드 네이티브를 전제로 온전한 도메인 분리를 이루고자 K8s, GitOps, Istio, gRPC까지 긴 여정이었지만 차근차근 이뤄내니 기쁘다. 24년 6월부터 직접 구축하고 싶던 구조였으나, 역량 부족 + 취준과 업무에 바빠 개인 프로젝트는 docker-compose로 간단히 분리해 올리는 일이 많았다. (경험상 K8s - MSA 프로덕션에서 개발하는 일과 직접 구축하는 건 아무래도 다른 일 같긴 하다.)
아직 DB 레이어의 도메인 분리로 나아가기까지는 MQ가 과제로 남아있지만, 이제 그 기반이 갖춰졌으니 조만간 이뤄질 듯 싶다.
다음은 로깅 시스템, Jaegger, Kaili 등 MQ 도입 전 Observability를 강화할 예정이다. 너무 늦지 않게 기록하겠다.