-
이코에코(Eco²) Clean Architecture #7: Application Layer 정제이코에코(Eco²)/Clean Architecture Migration 2026. 1. 2. 05:20

Clean Architecture 초안을 바탕으로 Opus 4.5, GPT 5.2와의 문답, 자료조사, 학습을 거치며 Application 계층을 정제하고 적용한 과정을 기록합니다.
1. 문제 정의
AS-IS 구조
apps/auth/application/ ├── common/ │ ├── dto/ ← 모든 DTO │ ├── exceptions/ ← 모든 예외 │ └── ports/ ← 모든 Port (14개 파일) ├── commands/ ← 기능 구분 없이 모든 Command └── queries/ ← 기능 구분 없이 모든 Query문제점
문제 설명 common 비대화 14개의 Port가 common에 혼재. 기능별 응집도 낮음 기능 파악 불가 blacklist_event_publisher.py가 token 관련인지 폴더 구조로 알 수 없음Service 역할 모호 Service가 Port를 직접 호출하여 usecase와 책임 중복 Query/Command 패턴 불일치 Query만 Service를 경유, Command는 Port 직접 호출
2. 설계 원칙
2.1 구성 요소별 역할
구성 요소 역할 책임 Port 외부 시스템 인터페이스 Infrastructure 계층과의 계약 정의 Service 도메인 로직 캡슐화 순수 비즈니스 로직 또는 외부 시스템 Facade Command Write usecase 상태 변경 오케스트레이션 Query Read usecase 조회 오케스트레이션 2.2 Port 호출 원칙
원칙: usecase(Command/Query)가 Port를 직접 호출한다. 이유: 트랜잭션 경계, flush, retry, 멱등성 등 애플리케이션 제어 흐름을 usecase가 관장허용되는 예외: Facade Service
Service가 여러 Port를 조합하여 하나의 개념을 캡슐화하는 경우에만 Port 보유를 허용한다.
판단 기준 Facade 일반 Service 여러 Port를 조합하여 하나의 개념을 형성하는가? O X 조합이 분리되면 의미가 소실되는가? O X usecase가 세부 구현을 알아야 하는가? X O
3. 폴더 구조 설계
3.1 기능별 폴더 분리
AS-IS:
common/ports/ ├── blacklist_event_publisher.py ← token 관련 ├── state_store.py ← oauth 관련 ├── token_service.py ← token 관련 ├── user_query_gateway.py ← users 관련 └── ... (14개 파일)TO-BE:
oauth/ports/ ← OAuth 인증 관련 Port token/ports/ ← 토큰 관리 관련 Port users/ports/ ← 사용자 관리 관련 Port audit/ports/ ← 감사 로그 관련 Port common/ports/ ← 트랜잭션 등 진짜 공통 Port만3.2 폴더 구조로 기능 역할 표현
기능별 폴더를 펼쳤을 때, 해당 기능이 어떤 책임을 가지는지 즉시 파악할 수 있어야 한다.
oauth 폴더 구조
oauth/ ├── commands/ │ ├── authorize.py ← OAuth 인증 URL 생성 │ └── callback.py ← OAuth 콜백 처리 ├── dto/ │ └── oauth.py ← OAuthAuthorizeRequest, OAuthCallbackResponse 등 ├── exceptions/ │ └── oauth.py ← InvalidStateError, OAuthProviderError ├── ports/ │ ├── provider_gateway.py ← OAuth 프로바이더 통신 │ └── state_store.py ← CSRF state 저장/검증 └── services/ └── oauth_flow_service.py ← OAuth 플로우 Facade읽어낼 수 있는 정보:
항목 판단 commands/존재,queries/부재Write 전용 기능 (OAuth는 인증 "수행"만 함) services/oauth_flow_service.py복잡한 플로우를 캡슐화하는 Facade 존재 ports/provider_gateway.py외부 OAuth 프로바이더와 통신 ports/state_store.pyCSRF 방어용 state 관리 exceptions/oauth.pyOAuth 특화 예외 (InvalidStateError 등) token 폴더 구조
token/ ├── commands/ │ ├── logout.py ← 로그아웃 (토큰 폐기) │ └── refresh.py ← 토큰 갱신 ├── dto/ │ └── token.py ← LogoutRequest, RefreshTokensResponse 등 ├── exceptions/ │ └── auth.py ← AuthenticationError ├── ports/ │ ├── issuer.py ← JWT 발급/검증 │ ├── blacklist_store.py ← 블랙리스트 조회 │ ├── session_store.py ← 세션 저장/조회 │ └── blacklist_event_publisher.py ← 블랙리스트 이벤트 발행 ├── queries/ │ └── validate.py ← 토큰 유효성 검증 └── services/ └── token_service.py ← 토큰 시스템 Facade읽어낼 수 있는 정보
항목 판단 commands/+queries/모두 존재Read/Write 모두 수행 queries/validate.py토큰 검증은 조회 성격 (상태 변경 없음) commands/logout.py,commands/refresh.py토큰 폐기/갱신은 상태 변경 ports/blacklist_event_publisher.py메시지 큐로 이벤트 발행 (비동기 처리) services/token_service.pyissuer + session + blacklist 조합 Facade 3.3 queries 유무로 Read/Write 판단
queries/ 존재 → Read usecase 보유 queries/ 부재 → Write 전용 기능도메인 commands queries 판단 oauth O X Write 전용 (인증 수행) token O O Read + Write (검증 + 폐기/갱신) audit X X 보조 기능 (Service만 제공) profile (users) O O Read + Write (조회 + 수정/삭제) character (users) X O Read 전용 (캐릭터 조회)
4. Exceptions 분리
AS-IS
# common/exceptions/auth.py에 모든 예외 집중 class InvalidStateError(ApplicationError): ... # oauth 관련 class OAuthProviderError(ApplicationError): ... # oauth 관련 class AuthenticationError(ApplicationError): ... # token 관련 class UserServiceUnavailableError(ApplicationError): ... # users 관련TO-BE
oauth/exceptions/oauth.py ← InvalidStateError, OAuthProviderError token/exceptions/auth.py ← AuthenticationError users/exceptions/gateway.py ← UserServiceUnavailableError common/exceptions/base.py ← ApplicationError (베이스만) common/exceptions/gateway.py ← GatewayError, DataMapperError (인프라 공통)원칙: 예외는 발생 도메인에 위치시킨다. common에는 베이스 예외만 둔다.
5. Service 역할 정의
5.1 Facade Service (Port 보유 허용)
여러 Port를 조합하여 하나의 개념을 캡슐화하는 Service다.
원자적인 도메인 응집성이 요구되는 로직(etc. OAuth)의 경우 Port 보유를 허용한다.
OAuthFlowService
class OAuthFlowService: def __init__(self, state_store, provider_gateway): self._state_store = state_store self._provider_gateway = provider_gateway async def validate_and_fetch_profile(self, provider, code, state, ...): # 1. State 검증 및 소비 (Redis) state_data = await self._state_store.consume(state) if state_data is None: raise InvalidStateError() # 2. OAuth 프로바이더 통신 (External HTTP) profile = await self._provider_gateway.fetch_profile(...) # 3. 결과 반환 return OAuthFlowResult(profile, state_data)Facade 판단:
state consume+provider 통신+profile fetch가 "OAuth 인증"이라는 하나의 단위- 분리 시 usecase가 OAuth 프로토콜 세부사항을 알아야 함
- 캡슐화가 적절함
TokenService
class TokenService: def __init__(self, issuer, session_store, blacklist_store): self._issuer = issuer self._session_store = session_store self._blacklist_store = blacklist_store async def issue_and_register(self, user_id, provider, ...): # 1. 토큰 발급 token_pair = self._issuer.issue_pair(user_id=user_id, provider=provider) # 2. 세션 등록 await self._session_store.register(user_id=user_id, jti=token_pair.refresh_jti, ...) return TokenIssuanceResult(...) async def is_blacklisted(self, jti): return await self._blacklist_store.contains(jti)Facade 판단:
토큰 발급+세션 등록이 항상 함께 수행되어야 함- 블랙리스트 확인도 토큰 검증의 일부
- "토큰 관리"라는 개념으로 캡슐화
5.2 순수 Service (Port 미보유)
통상의 Service는 순수 비즈니스 로직만 수행하도록 작성했다. Serivce는 Port의 존재를 모르며, 실질적인 로직은 적합한 Port와 Service를 조립한 usecase(write: command, read: query)의 형태로 구현된다.
LoginAuditService
AS-IS (문제):
class LoginAuditService: def __init__(self, audit_gateway): # Port 의존 self._audit_gateway = audit_gateway def record_login(self, user_id, provider, access_jti, ...): audit = LoginAudit(id=uuid4(), user_id=user_id, ...) self._audit_gateway.add(audit) # Service가 Port 호출 return auditTO-BE (개선):
class LoginAuditService: # Port 의존성 없음 def create_login_audit(self, user_id, provider, access_jti, ...) -> LoginAudit: return LoginAudit( id=uuid4(), user_id=user_id, provider=provider, jti=access_jti, issued_at=datetime.now(timezone.utc), ... )usecase에서 Port 직접 호출:
# OAuthCallbackInteractor audit = self._audit_service.create_login_audit(...) self._audit_gateway.add(audit) # usecase가 Port 호출판단:
- 단일 Port만 사용 (조합 없음)
- 엔티티 생성과 저장은 논리적으로 분리 가능
- UseCase가 직접 제어해도 문제없음
ProfileBuilder
class ProfileBuilder: def __init__(self, user_service: UserService): self._user_service = user_service # Domain Service만 의존 def build(self, user, accounts, current_provider) -> UserProfile: display_name = self._user_service.resolve_display_name(user) nickname = self._user_service.resolve_nickname(user, display_name) return UserProfile( display_name=display_name, nickname=nickname, ... )판단:
- Port 의존 없음
- 순수 DTO 구성 로직
- 여러 UseCase에서 재사용 가능
5.3 삭제된 Service
Port만 호출하던 Service는 삭제하고 usecase가 직접 Port를 호출하도록 변경.
삭제된 Service 이유 ProfileQueryServicePort 호출 + DTO 구성만 수행. usecase와 책임 중복 CharacterQueryServicePort 호출 + DTO 변환만 수행. usecase와 책임 중복
6. Query 패턴 통일
AS-IS (문제)
# Query가 Service를 경유 class GetProfileQuery: def __init__(self, profile_service: ProfileQueryService): self._profile_service = profile_service async def execute(self, user_id, provider): return await self._profile_service.get_user_profile(user_id, provider) # Service가 Port 호출 → UseCase와 책임 중복 class ProfileQueryService: def __init__(self, profile_gateway, social_account_gateway, profile_builder): ... async def get_user_profile(self, user_id, provider): user = await self._profile_gateway.get_by_id(user_id) accounts = await self._social_account_gateway.list_by_user_id(user_id) return self._profile_builder.build(user, accounts, provider)TO-BE (개선)
class GetProfileQuery: def __init__( self, # Ports (인프라) profile_gateway: ProfileQueryGateway, social_account_gateway: SocialAccountQueryGateway, # Services (순수 로직) profile_builder: ProfileBuilder, ): self._profile_gateway = profile_gateway self._social_account_gateway = social_account_gateway self._profile_builder = profile_builder async def execute(self, user_id: UUID, provider: str) -> UserProfile: # 1. UseCase가 직접 Port 호출 user = await self._profile_gateway.get_by_id(user_id) if user is None: raise UserNotFoundError(user_id) # 2. UseCase가 직접 Port 호출 accounts = await self._social_account_gateway.list_by_user_id(user_id) # 3. Service는 순수 로직만 return self._profile_builder.build(user, accounts, provider)변경 사항:
- ProfileQueryService 삭제
- Query가 직접 Port 호출
- ProfileBuilder는 순수 DTO 구성만 담당
7. 더미 코드 삭제
파일 삭제 이유 common/ports/outbox_gateway.pyauth_relay 서비스가 outbox 처리 담당. auth에서 미사용 infrastructure/persistence_redis/outbox_redis.py위와 동일. 구현체도 미사용
8. 최종 구조
Auth 도메인
apps/auth/application/ ├── common/ │ ├── exceptions/ │ │ ├── base.py ← ApplicationError │ │ └── gateway.py ← GatewayError, DataMapperError │ ├── ports/ │ │ ├── flusher.py │ │ └── transaction_manager.py │ └── services/ │ └── oauth_client.py ← Protocol │ ├── oauth/ ← Write 전용 (queries/ 없음) │ ├── commands/ │ │ ├── authorize.py │ │ └── callback.py │ ├── dto/ │ ├── exceptions/ │ ├── ports/ │ │ ├── provider_gateway.py │ │ └── state_store.py │ └── services/ │ └── oauth_flow_service.py ← Facade │ ├── token/ ← Read + Write (queries/ 존재) │ ├── commands/ │ │ ├── logout.py │ │ └── refresh.py │ ├── dto/ │ ├── exceptions/ │ ├── ports/ │ │ ├── issuer.py │ │ ├── blacklist_store.py │ │ ├── session_store.py │ │ └── blacklist_event_publisher.py │ ├── queries/ │ │ └── validate.py │ └── services/ │ └── token_service.py ← Facade │ ├── users/ │ ├── exceptions/ │ └── ports/ │ └── audit/ ← 보조 기능 (commands/, queries/ 없음) ├── ports/ └── services/ └── login_audit_service.py ← 순수 엔티티 팩토리Users 도메인
apps/users/application/ ├── common/ │ ├── exceptions/ │ │ └── base.py │ └── ports/ │ └── transaction_manager.py │ ├── profile/ ← Read + Write │ ├── commands/ │ │ ├── update_profile.py │ │ └── delete_user.py │ ├── dto/ │ ├── exceptions/ │ ├── ports/ │ ├── queries/ │ │ └── get_profile.py │ └── services/ │ └── profile_builder.py ← 순수 DTO 구성 │ ├── character/ ← Read 전용 (commands/ 없음) │ ├── dto/ │ ├── ports/ │ └── queries/ │ └── get_characters.py │ └── identity/ └── ports/
9. 요약
원칙 적용 Port 호출은 UseCase가 직접 Command/Query가 Port 호출, Service는 순수 로직만 Facade Service만 Port 보유 OAuthFlowService, TokenService 순수 Service는 Port 미보유 ProfileBuilder, LoginAuditService 도메인별 폴더 분리 oauth/, token/, users/, audit/ common은 공통만 Flusher, TransactionManager, ApplicationError queries 유무로 Read/Write 판단 queries/ 존재 시 Read UseCase 보유 '이코에코(Eco²) > Clean Architecture Migration' 카테고리의 다른 글
이코에코(Eco²) Clean Architecture #9: Presentation Layer 정제 (0) 2026.01.02 이코에코(Eco²) Clean Architecture #8: Infrastructure Layer 정제 (0) 2026.01.02 이코에코(Eco²) Clean Architecture #6: My->Users 리팩토링.MD (0) 2026.01.01 이코에코(Eco²) Clean Architecture #5: Message Consumer (1) 2025.12.31 이코에코(Eco²) Clean Architecture #4 Auth Persistence Offloading (0) 2025.12.31