ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 이코에코(Eco²) Clean Architecture #1: Auth 문제사안 도출, 리팩토링 전략
    이코에코(Eco²)/Clean Architecture Migration 2025. 12. 31. 03:24

    기존 구조의 문제점

    좌: 기존 레이어드 아키텍처(main), 우: 클린 아키텍처 전환 후(develop)

    폴더 구조

    domains/auth/
    ├── tasks/          # 워커
    │   └── blacklist_relay.py
    ├── database/       # DB 연결/세션
    ├── interfaces/     # 추상화 인터페이스
    └── services/       # 비즈니스 로직

    문제 1: God Object

    class AuthService:
        def __init__(
            self,
            session: AsyncSession = Depends(get_db_session),
            token_service: TokenService = Depends(TokenService),
            state_store: OAuthStateStore = Depends(OAuthStateStore),
            blacklist: TokenBlacklist = Depends(TokenBlacklist),
            token_store: UserTokenStore = Depends(UserTokenStore),
            settings: Settings = Depends(get_settings),
        ):
            self.session = session
            self.token_service = token_service
            self.state_store = state_store
            self.blacklist = blacklist
            self.user_token_store = token_store
            self.settings = settings
            self.providers = ProviderRegistry(settings)  # 내부 생성
            self.user_repo = UserRepository(session)     # 내부 생성
            self.login_audit_repo = LoginAuditRepository(session)

    한 클래스에 OAuth 인증, 토큰 관리, 사용자 관리, 로그인 감사 등 모든 책임이 집중되어 있다.

    문제 2: 테스트 불가능한 구조

    # ❌ 테스트하려면 실제 DB/Redis가 필요
    def test_login():
        service = AuthService()  # Depends()가 실제 인프라에 연결
        # Mock 주입 불가
    • Depends()로 구현체 직접 주입 → Mock 교체 불가
    • 생성자 내부에서 객체 생성 → 의존성 숨김
    • 단위 테스트 작성 시 통합 테스트 환경 필요

    문제 3: 레이어 경계 불명확

    ┌─────────────────────────────────────────┐
    │  AuthService                            │
    │  ├── HTTP 요청 파싱                     │
    │  ├── 비즈니스 로직 처리                 │
    │  ├── DB 쿼리 실행                       │
    │  ├── Redis 캐시 관리                    │
    │  └── 외부 API 호출 (OAuth Provider)     │
    └─────────────────────────────────────────┘

    모든 레이어가 하나의 클래스에 섞여 있어 변경의 영향 범위를 예측하기 어렵다.


    Clean Architecture 핵심 개념

    의존성 규칙

    ┌─────────────────────────────────────────────────────┐
    │                    Frameworks                       │
    │  ┌─────────────────────────────────────────────┐   │
    │  │              Interface Adapters              │   │
    │  │  ┌─────────────────────────────────────┐   │   │
    │  │  │          Application Layer           │   │   │
    │  │  │  ┌─────────────────────────────┐   │   │   │
    │  │  │  │       Domain Layer          │   │   │   │
    │  │  │  │   (Entities, Value Objects) │   │   │   │
    │  │  │  └─────────────────────────────┘   │   │   │
    │  │  └─────────────────────────────────────┘   │   │
    │  └─────────────────────────────────────────────┘   │
    └─────────────────────────────────────────────────────┘
                ← 의존성 방향 (바깥 → 안쪽)

    핵심: 안쪽 레이어는 바깥쪽 레이어를 알지 못한다.

    의존성 역전 원칙 (DIP)

    ❌ 기존:
    ┌─────────────┐      ┌─────────────┐
    │  Service    │ ───→ │ Repository  │ (구현체)
    └─────────────┘      └─────────────┘
    
    ✅ 목표:
    ┌─────────────┐      ┌─────────────┐      ┌─────────────┐
    │  Service    │ ───→ │    Port     │ ←─── │  Adapter    │
    └─────────────┘      │ (Interface) │      │ (구현체)    │
                         └─────────────┘      └─────────────┘

    고수준 모듈(Service)이 저수준 모듈(Repository)에 직접 의존하지 않고, 추상(인터페이스)에 의존한다.

    Port & Adapter (Hexagonal Architecture)

                        ┌─────────────────────────────┐
                        │      Application Core       │
       Driving          │  ┌─────────────────────┐   │          Driven
       Adapters         │  │                     │   │          Adapters
                        │  │   Domain + UseCase  │   │
    ┌─────────┐   Port  │  │                     │   │  Port   ┌─────────┐
    │  HTTP   │ ──────→ │  └─────────────────────┘   │ ←────── │   DB    │
    │Controller│        │                             │         │ Adapter │
    └─────────┘         └─────────────────────────────┘         └─────────┘
    개념 역할 위치 예시
    Port 인터페이스 (what) Application 경계 UserCommandGateway
    Driving Adapter 외부 → 내부 Presentation HTTP Controller
    Driven Adapter 내부 → 외부 Infrastructure SqlaUserDataMapper

    CQRS (Command Query Responsibility Segregation)

    유형 역할 특징 네이밍
    Command 상태 변경 Write, Side Effect ~Interactor
    Query 조회 Read Only ~QueryService

     

    • 읽기/쓰기 최적화를 독립적으로 수행할 기반
    • 단일 책임 원칙 준수
    • 테스트 용이성 향상

    레퍼런스 분석: fastapi-clean-example

    ivan-borovets/fastapi-clean-example를 레퍼런스로 삼았다.

    폴더 구조

    src/app/
    ├── domain/                    # 핵심 비즈니스 로직
    │   ├── entities/              # Entity (식별자 있음)
    │   ├── value_objects/         # Value Object (불변, 식별자 없음)
    │   ├── ports/                 # Domain Port
    │   └── services/              # Domain Service
    ├── application/               # Use Case
    │   ├── commands/              # Command (상태 변경)
    │   ├── queries/               # Query (조회)
    │   └── common/
    │       ├── ports/             # Application Port
    │       └── dto/               # Data Transfer Object
    ├── infrastructure/            # 외부 시스템 구현
    │   ├── adapters/              # Port 구현체
    │   ├── persistence_sqla/      # SQLAlchemy 설정
    │   └── auth/                  # 인증 관련
    ├── presentation/              # HTTP Controller
    │   └── http/
    │       ├── controllers/
    │       ├── schemas/           # Pydantic Request/Response
    │       └── errors/            # HTTP Error Handler
    └── setup/                     # DI Container, Config
        └── ioc/                   # Dependency Injection

    Gateway vs Repository

    예제에서는 Gateway 패턴을 사용한다.

    패턴 특징 장점 단점
    Repository 단일 인터페이스 (CRUD) 단순함 읽기/쓰기 분리 어려움
    Gateway Command/Query 분리 CQRS 지원, 최적화 용이 인터페이스 증가
    # UserCommandGateway - 쓰기 연산
    class UserCommandGateway(Protocol):
        async def save(self, user: User) -> None: ...
        async def update(self, user: User) -> None: ...
    
    # UserQueryGateway - 읽기 연산  
    class UserQueryGateway(Protocol):
        async def find_by_email(self, email: str) -> User | None: ...
        async def find_by_id(self, user_id: UserId) -> User | None: ...

    계층별 Port 구조

    ┌─────────────────────────────────────────────────────────────────┐
    │  Domain Layer                                                   │
    │  └── ports/password_hasher.py                                  │
    │       - 도메인 로직에서 필요한 추상화                           │
    │       - 예: PasswordHasher (비밀번호 해싱은 도메인 규칙)       │
    ├─────────────────────────────────────────────────────────────────┤
    │  Application Layer                                              │
    │  └── common/ports/                                              │
    │       ├── user_command_gateway.py                               │
    │       ├── user_query_gateway.py                                 │
    │       └── flusher.py                                            │
    │       - Use Case에서 필요한 외부 시스템 추상화                  │
    │       - 예: Gateway (데이터 영속화), Flusher (트랜잭션 커밋)   │
    ├─────────────────────────────────────────────────────────────────┤
    │  Infrastructure Layer                                           │
    │  └── auth/session/ports/gateway.py                              │
    │       - 인프라 내부 추상화 (테스트, 교체 용이성)               │
    │       - 예: AuthSessionGateway (세션 저장소 추상화)            │
    └─────────────────────────────────────────────────────────────────┘

    적용 설계

    최종 폴더 구조 (apps/auth/)

    apps/auth/
    ├── domain/                         # 핵심 비즈니스 로직
    │   ├── entities/
    │   │   ├── __init__.py
    │   │   ├── base.py                 # Entity 기본 클래스
    │   │   ├── user.py                 # User 엔티티
    │   │   ├── user_social_account.py  # 소셜 계정 연결
    │   │   └── login_audit.py          # 로그인 감사 로그
    │   ├── value_objects/
    │   │   ├── __init__.py
    │   │   ├── base.py                 # ValueObject 기본 클래스
    │   │   ├── user_id.py              # UserId (UUID 래핑)
    │   │   ├── email.py                # Email (유효성 검증)
    │   │   └── token_payload.py        # JWT Payload
    │   ├── enums/
    │   │   ├── oauth_provider.py       # Google, Kakao, Naver
    │   │   └── token_type.py           # Access, Refresh
    │   ├── ports/
    │   │   └── user_id_generator.py    # Domain Port
    │   ├── services/
    │   │   └── user_service.py         # Domain Service
    │   └── exceptions/
    │       ├── base.py
    │       ├── user.py
    │       ├── auth.py
    │       └── validation.py
    │
    ├── application/                    # Use Case
    │   ├── commands/
    │   │   ├── oauth_authorize.py      # OAuth 인증 URL 생성
    │   │   ├── oauth_callback.py       # OAuth 콜백 처리
    │   │   ├── logout.py               # 로그아웃
    │   │   └── refresh_tokens.py       # 토큰 갱신
    │   ├── queries/
    │   │   └── validate_token.py       # 토큰 검증
    │   └── common/
    │       ├── ports/                  # Application Port
    │       │   ├── user_command_gateway.py
    │       │   ├── user_query_gateway.py
    │       │   ├── social_account_gateway.py
    │       │   ├── login_audit_gateway.py
    │       │   ├── token_service.py
    │       │   ├── state_store.py
    │       │   ├── token_blacklist.py
    │       │   ├── user_token_store.py
    │       │   ├── outbox_gateway.py
    │       │   ├── flusher.py
    │       │   └── transaction_manager.py
    │       ├── dto/
    │       │   └── auth.py             # Request/Response DTO
    │       ├── services/
    │       │   └── oauth_client.py     # OAuth Client Port
    │       └── exceptions/
    │           ├── base.py
    │           ├── auth.py
    │           └── gateway.py
    │
    ├── infrastructure/                 # 외부 시스템 구현
    │   ├── persistence_postgres/
    │   │   ├── session.py              # AsyncSession 팩토리
    │   │   ├── registry.py             # SQLAlchemy Registry
    │   │   └── mappings/               # ORM 매핑
    │   │       ├── user.py
    │   │       ├── user_social_account.py
    │   │       └── login_audit.py
    │   ├── persistence_redis/
    │   │   ├── client.py               # Redis 클라이언트
    │   │   ├── state_store_redis.py    # OAuth State 저장
    │   │   ├── token_blacklist_redis.py
    │   │   ├── user_token_store_redis.py
    │   │   └── outbox_redis.py         # Outbox 패턴
    │   ├── adapters/                   # Port 구현체
    │   │   ├── user_data_mapper_sqla.py
    │   │   ├── user_reader_sqla.py
    │   │   ├── social_account_mapper_sqla.py
    │   │   ├── login_audit_mapper_sqla.py
    │   │   ├── flusher_sqla.py
    │   │   ├── transaction_manager_sqla.py
    │   │   └── user_id_generator_uuid.py
    │   ├── security/
    │   │   └── jwt_token_service.py    # JWT 생성/검증
    │   └── oauth/
    │       ├── base.py                 # OAuth Provider 추상
    │       ├── google.py
    │       ├── kakao.py
    │       ├── naver.py
    │       ├── registry.py             # Provider Registry
    │       └── client.py               # OAuthClient 구현
    │
    ├── presentation/                   # HTTP Interface
    │   └── http/
    │       ├── controllers/
    │       │   ├── root_router.py
    │       │   ├── api_v1_router.py
    │       │   ├── auth/               # /api/v1/auth/*
    │       │   │   ├── router.py
    │       │   │   ├── authorize.py
    │       │   │   ├── callback.py
    │       │   │   ├── logout.py
    │       │   │   └── refresh.py
    │       │   └── general/            # /health, /metrics
    │       │       ├── router.py
    │       │       ├── health.py
    │       │       └── metrics.py
    │       ├── schemas/
    │       │   ├── auth.py
    │       │   └── common.py
    │       ├── errors/
    │       │   ├── handlers.py         # Exception → HTTP Response
    │       │   └── translators.py
    │       ├── auth/
    │       │   ├── dependencies.py     # FastAPI Depends
    │       │   └── cookie_params.py
    │       └── utils/
    │           └── redirect.py         # Frontend 리다이렉트 유틸
    │
    ├── workers/                        # Background Workers
    │   └── consumers/
    │       └── blacklist_relay.py      # Redis → RabbitMQ Relay
    │
    ├── setup/                          # 설정 및 DI
    │   ├── config/
    │   │   └── settings.py             # Pydantic Settings
    │   ├── dependencies.py             # DI Provider
    │   └── logging.py
    │
    ├── tests/                          # 테스트
    │   ├── conftest.py
    │   ├── unit/
    │   │   ├── domain/
    │   │   ├── application/
    │   │   └── infrastructure/
    │   └── integration/
    │
    ├── main.py                         # FastAPI App
    ├── Dockerfile
    ├── Dockerfile.relay
    └── requirements.txt

    쟁점: Auth-Relay(Outbox)를 Auth 도메인에 배치해야 하는가

    • Auth-Relay는 logout 이벤트가 발행 후 실패할 경우 Redis에 실패한 이벤트들을 기록하는 별도의 워커 컴포넌트다.
    • 작성일 기준 auth-canary(v2, clean architecture)는 Persistence(Redis)를 직접 WRITE한다. 현재 auth-canary는 WRITE Event만 발행하도록 Publisher로 전환한 상태이며, 실제 쓰기 작업은 별도 auth-worker가 수행한다.
    • auth-relay는 Outbox 패턴 조회용 워커로, 작성일 기준 Auth와 Persistence(Redis)를 공유하기에 동일한 도메인에 배치, 도커 파일만 분리했다. auth 도메인의 응집성을 높이는 일환이었으며, auth의 Persistence WRITE가 Worker로 이관이 확정되었고 개발이 완료된 상태다. auth-worker의 배포 e2e 검증이 종료되면, auth-relay 또한 별도 도메인으로 분리해 관리할 예정이다.

    Port-Adapter 매핑 테이블

    Layer Port (Interface) Adapter (Implementation) 역할
    Domain UserIdGenerator UuidUserIdGenerator UUID 생성
    Application UserCommandGateway SqlaUserDataMapper 사용자 저장/수정
    Application UserQueryGateway SqlaUserReader 사용자 조회
    Application SocialAccountGateway SqlaSocialAccountMapper 소셜 계정 관리
    Application LoginAuditGateway SqlaLoginAuditMapper 로그인 감사
    Application TokenService JwtTokenService JWT 생성 (검증은 ext-authz에서 수행)
    Application StateStore RedisStateStore OAuth State 저장
    Application TokenBlacklist RedisTokenBlacklist 토큰 블랙리스트
    Application UserTokenStore RedisUserTokenStore 사용자별 토큰 관리
    Application OutboxGateway RedisOutbox Outbox 패턴
    Application Flusher SqlaFlusher 트랜잭션 Flush
    Application OAuthClient OAuthClientService OAuth Provider 통합

    의존성 흐름도

    ┌─────────────────────────────────────────────────────────────────────┐
    │                         Presentation Layer                          │
    │  ┌─────────────┐     ┌─────────────┐     ┌─────────────┐           │
    │  │   Router    │ ──→ │ Controller  │ ──→ │   Schema    │           │
    │  └─────────────┘     └──────┬──────┘     └─────────────┘           │
    │                             │ Depends()                             │
    └─────────────────────────────┼───────────────────────────────────────┘
                                  ↓
    ┌─────────────────────────────────────────────────────────────────────┐
    │                        Application Layer                            │
    │  ┌─────────────────────────┐     ┌─────────────────────────┐       │
    │  │   Command (Interactor)  │     │   Query (QueryService)  │       │
    │  └───────────┬─────────────┘     └───────────┬─────────────┘       │
    │              │                               │                      │
    │              ↓                               ↓                      │
    │  ┌─────────────────────────────────────────────────────────┐       │
    │  │                    Application Ports                     │       │
    │  │  UserCommandGateway, TokenService, StateStore, ...      │       │
    │  └─────────────────────────────────────────────────────────┘       │
    └─────────────────────────────┬───────────────────────────────────────┘
                                  │ implements
                                  ↓
    ┌─────────────────────────────────────────────────────────────────────┐
    │                        Domain Layer                                 │
    │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐                 │
    │  │   Entity    │  │ Value Object│  │Domain Service│                │
    │  └─────────────┘  └─────────────┘  └──────┬──────┘                 │
    │                                           │                         │
    │  ┌─────────────────────────────────────────────────────────┐       │
    │  │                    Domain Ports                          │       │
    │  │                 UserIdGenerator                          │       │
    │  └─────────────────────────────────────────────────────────┘       │
    └─────────────────────────────────────────────────────────────────────┘
                                  ↑ implements
    ┌─────────────────────────────┴───────────────────────────────────────┐
    │                      Infrastructure Layer                           │
    │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐                 │
    │  │  SQLAlchemy │  │    Redis    │  │     JWT     │                 │
    │  │  Adapters   │  │  Adapters   │  │   Service   │                 │
    │  └─────────────┘  └─────────────┘  └─────────────┘                 │
    └─────────────────────────────────────────────────────────────────────┘

    설계 원칙 적용

    원칙 적용 내용
    SRP Command/Query 분리, 레이어별 책임 분리
    OCP 새 Adapter 추가 시 기존 코드 수정 없음 (예: MySQL → PostgreSQL)
    LSP Port 구현체들은 동일한 계약 준수
    ISP Gateway를 Command/Query로 분리, 필요한 인터페이스만 의존
    DIP Port 인터페이스 정의, Adapter가 구현, 의존성 역전

    기대 효과

    AS-IS vs TO-BE

    항목 Before After
    테스트 통합 테스트만 가능 단위 테스트 가능
    의존성 구현체 직접 의존 인터페이스 의존
    변경 영향 전체 파일 수정 해당 레이어만
    코드 크기 주요 코드 300줄 파일당 ~100줄
    가독성 복잡한 메서드 단일 책임 메서드

    트레이드오프

    장점 단점
    테스트 용이 파일/클래스 수 증가
    변경 격리 초기 학습 곡선
    명확한 경계 보일러플레이트 증가
    교체 용이 간단한 기능도 레이어 필요

     


    References

    • fastapi-clean-example
    • Robert C. Martin, "Clean Architecture" (2017)
    • Alistair Cockburn, "Hexagonal Architecture" (2005)
    • Vaughn Vernon, "Implementing Domain-Driven Design" (2013)
    • Martin Fowler, "Patterns of Enterprise Application Architecture" (2002)

    댓글

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