이코에코(Eco²)/Clean Architecture Migration
이코에코(Eco²) Clean Architecture #7: Application Layer 정제
mango_fr
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.py |
CSRF 방어용 state 관리 |
exceptions/oauth.py |
OAuth 특화 예외 (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.py |
issuer + 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 audit
TO-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 | 이유 |
|---|---|
ProfileQueryService |
Port 호출 + DTO 구성만 수행. usecase와 책임 중복 |
CharacterQueryService |
Port 호출 + 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.py |
auth_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 보유 |