-
이코에코(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 경계 UserCommandGatewayDriving Adapter 외부 → 내부 Presentation HTTP Controller Driven Adapter 내부 → 외부 Infrastructure SqlaUserDataMapperCQRS (Command Query Responsibility Segregation)
유형 역할 특징 네이밍 Command 상태 변경 Write, Side Effect ~InteractorQuery 조회 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 InjectionGateway 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 UserIdGeneratorUuidUserIdGeneratorUUID 생성 Application UserCommandGatewaySqlaUserDataMapper사용자 저장/수정 Application UserQueryGatewaySqlaUserReader사용자 조회 Application SocialAccountGatewaySqlaSocialAccountMapper소셜 계정 관리 Application LoginAuditGatewaySqlaLoginAuditMapper로그인 감사 Application TokenServiceJwtTokenServiceJWT 생성 (검증은 ext-authz에서 수행) Application StateStoreRedisStateStoreOAuth State 저장 Application TokenBlacklistRedisTokenBlacklist토큰 블랙리스트 Application UserTokenStoreRedisUserTokenStore사용자별 토큰 관리 Application OutboxGatewayRedisOutboxOutbox 패턴 Application FlusherSqlaFlusher트랜잭션 Flush Application OAuthClientOAuthClientServiceOAuth 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)
'이코에코(Eco²) > Clean Architecture Migration' 카테고리의 다른 글
이코에코(Eco²) Clean Architecture #5: Message Consumer (1) 2025.12.31 이코에코(Eco²) Clean Architecture #4 Auth Persistence Offloading (0) 2025.12.31 이코에코(Eco²) Clean Architecture #3: Auth 버전 분리, 점진적 배포 (0) 2025.12.31 이코에코(Eco²) Clean Architecture #2: Auth Clean Architecture 구현 초안 (0) 2025.12.31 이코에코(Eco²) Clean Architecture #0: Auth 리팩토링.MD (0) 2025.12.31