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

지난 포스팅에 더해 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로 인증/인가 로직이 비즈니스 레이어에서 분리되면서
- JWT 파싱 불필요 → 헤더 추출만으로 충분
- 공통 모듈 불필요 → 각 도메인이 ~30줄로 자체 구현
- 도메인 완전 독립 → MSA 원칙 준수
- 의존성 감소 →
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를 강화할 예정이다. 너무 늦지 않게 기록하겠다.