ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 이코에코(Eco²) Clean Architecture #8: Infrastructure Layer 정제
    이코에코(Eco²)/Clean Architecture Migration 2026. 1. 2. 12:39

    Opus 4.5, GPT 5.2와의 문답, 자료조사, 학습을 거치며 Infrastructure 계층을 기술 단위로 분리하고, gRPC를 Infrastructure Layer에서 Presentation Layer로 재정의한 과정을 기록합니다.


    1. 계층별 분류 철학

    Application Layer와 Infrastructure Layer는 서로 다른 기준으로 폴더를 분류한다.

    1.1 Application Layer: 기능(Feature) 기준

    application/
    ├── oauth/          ← "OAuth 인증"이라는 비즈니스 기능
    ├── token/          ← "토큰 관리"라는 비즈니스 기능
    ├── users/          ← "사용자 관리"라는 비즈니스 기능
    └── audit/          ← "감사 로그"라는 비즈니스 기능
    관점 설명
    분류 기준 비즈니스 기능(Feature), 도메인 개념
    질문 "이 시스템은 무엇을 하는가?"
    가시성 폴더 구조만으로 시스템의 비즈니스 역량 파악
    응집 단위 하나의 기능에 필요한 Port, Service, Command, Query, DTO, Exception

    폴더 구조가 드러내는 정보:

    token/
    ├── commands/    ← Write 연산 존재 (logout, refresh)
    ├── queries/     ← Read 연산 존재 (validate)
    ├── ports/       ← 4개의 외부 의존성 (issuer, blacklist, session, event)
    └── services/    ← Facade 존재 (복잡한 조합 캡슐화)

    commands/queries/ 유무로 Read/Write 특성 즉시 파악

    1.2 Infrastructure Layer: 기술(Technology) 기준

    infrastructure/
    ├── persistence_postgres/   ← PostgreSQL 기술
    ├── persistence_redis/      ← Redis 기술
    ├── grpc/                   ← gRPC 프로토콜
    ├── messaging/              ← RabbitMQ 메시징
    ├── oauth/                  ← OAuth 프로토콜/HTTP
    └── security/               ← JWT/암호화
    관점 설명
    분류 기준 기술 스택, 통신 방식, 외부 시스템
    질문 "이 시스템은 어떻게 외부와 연결되는가?"
    가시성 폴더 구조만으로 기술 스택 파악
    응집 단위 동일 기술을 사용하는 어댑터, 매핑, 클라이언트, 상수

    폴더 구조가 드러내는 정보:

    persistence_postgres/
    ├── adapters/    ← Port 구현체들 (6개 Gateway)
    ├── mappings/    ← ORM 매핑 정의
    ├── session.py   ← 커넥션 관리
    └── registry.py  ← 매퍼 등록

    → PostgreSQL과 관련된 모든 관심사가 한 곳에 집중

    1.3 분류 철학 비교

    관점 Application Layer Infrastructure Layer
    분류 축 기능(What) 기술(How)
    폴더 이름 비즈니스 용어 기술 스택 이름
    변경 이유 비즈니스 요구사항 변경 기술 스택 교체/추가
    의존 방향 Port(인터페이스) 정의 Port 구현체 제공
    테스트 전략 Mock Port로 단위 테스트 실제 인프라로 통합 테스트

    1.4 왜 다른 기준을 사용하는가?

    Application Layer는 "안정적인 비즈니스 개념"을 중심으로:

    • 비즈니스 로직은 기술보다 천천히 변한다
    • "OAuth 인증"이라는 개념은 HTTP→gRPC로 바뀌어도 유지된다
    • 기능 단위 응집으로 변경 영향 범위 최소화

    Infrastructure Layer는 "교체 가능한 기술 구현"을 중심으로:

    • Redis→Memcached 전환 시 persistence_redis/ 폴더만 교체
    • PostgreSQL→MySQL 전환 시 persistence_postgres/ 폴더만 교체
    • 기술 단위 응집으로 기술 교체 용이성 확보
    [Application]                    [Infrastructure]
        │                                   │
        │  기능별 Port 인터페이스 정의      │  기술별 Adapter 구현 제공
        │       oauth/ports/                │       persistence_redis/adapters/
        │       token/ports/                │       persistence_postgres/adapters/
        │       users/ports/                │       grpc/adapters/
        │                                   │
        ▼                                   ▼
       "무엇을 할 것인가"              "어떻게 구현할 것인가"

    2. 문제 정의

    2.1 AS-IS 구조 (Auth)

    apps/auth/infrastructure/
    ├── adapters/                  ← 기술 무관하게 어댑터 혼재
    │   ├── flusher_sqla.py
    │   ├── user_reader_sqla.py
    │   └── users_management_grpc_adapter.py
    ├── grpc/
    │   ├── client.py
    │   └── users_pb2.py
    ├── oauth/
    │   └── providers/*.py
    ├── persistence_postgres/
    │   └── mappings/*.py
    ├── persistence_redis/
    │   └── state_store_redis.py   ← 어댑터가 기술 폴더 최상위에 혼재
    └── security/
        └── jwt_token_service.py

    2.2 AS-IS 구조 (Users)

    apps/users/infrastructure/
    ├── grpc/                      ← gRPC 서버가 infrastructure에 위치
    │   ├── server.py
    │   └── servicers/
    │       └── users_servicer.py  ← DB 직접 접근하는 Fat Servicer
    └── persistence_postgres/
        └── adapters/
            └── user_gateway_sqla.py  ← 단수형 네이밍

    2.3 문제점

    문제 설명
    어댑터 배치 불일치 일부는 최상위 adapters/, 일부는 기술별 폴더 내 위치
    하드코딩된 상수 Redis key prefix, schema 이름이 코드 전체에 산재
    gRPC 서버 위치 모호 전달 계층(delivery)인 gRPC 서버가 infrastructure에 위치
    Fat Servicer gRPC servicer가 DB 직접 접근, 트랜잭션 관리까지 수행
    네이밍 불일치 user_ vs users_ 혼용

    3. 설계 원칙

    3.1 기술별 폴더 내 역할 분리

    persistence_postgres/
    ├── adapters/      ← Port 구현체 (Gateway 어댑터)
    ├── mappings/      ← ORM 매핑 정의
    ├── constants.py   ← 스키마/테이블 상수
    ├── session.py     ← 커넥션/세션 팩토리
    └── registry.py    ← 매퍼 등록 (Imperative Mapping)

    3.2 어댑터 명명 규칙

    패턴: {도메인}_{port_역할}_{기술}.py
    
    예시:
      users_query_gateway_sqla.py      ← users 도메인, 조회 게이트웨이, SQLAlchemy
      users_management_gateway_grpc.py ← users 도메인, 관리 게이트웨이, gRPC
      token_blacklist_redis.py         ← token 도메인, 블랙리스트, Redis

    3.3 gRPC 배치 원칙

    역할 위치 이유
    gRPC 서버 presentation/ HTTP와 동등한 전달 계층 (delivery mechanism)
    gRPC 클라이언트 infrastructure/ 외부 서비스 호출은 infrastructure 관심사

    3.4 Thin Adapter 패턴 (gRPC Servicer)

    gRPC Servicer의 책임:
      1. Request → DTO 변환
      2. UseCase.execute(dto) 호출
      3. Result → Protobuf 응답 변환
      4. 예외 → gRPC status 매핑
    
    금지:
      - DB 직접 접근
      - 트랜잭션 관리
      - 비즈니스 로직

    4. Auth Infrastructure 정제

    4.1 TO-BE 구조

    apps/auth/infrastructure/
    ├── common/
    │   └── adapters/
    │       └── users_id_generator_uuid.py   ← 기술 무관 어댑터
    │
    ├── grpc/                                 ← gRPC 클라이언트 (users 서비스 호출)
    │   ├── adapters/
    │   │   └── users_management_gateway_grpc.py
    │   ├── client.py                         ← gRPC 채널/스텁 관리
    │   └── schemas/
    │       ├── users_pb2.py
    │       └── users_pb2_grpc.py
    │
    ├── messaging/
    │   └── adapters/
    │       └── blacklist_event_publisher_rabbitmq.py
    │
    ├── oauth/
    │   ├── client.py                         ← OAuthClientService 구현체
    │   ├── providers/
    │   │   ├── base.py
    │   │   ├── google.py
    │   │   ├── kakao.py
    │   │   └── naver.py
    │   └── registry.py                       ← Provider DI 레지스트리
    │
    ├── persistence_postgres/
    │   ├── adapters/
    │   │   ├── flusher_sqla.py
    │   │   ├── login_audit_gateway_sqla.py
    │   │   ├── social_account_query_gateway_sqla.py
    │   │   ├── transaction_manager_sqla.py
    │   │   ├── users_command_gateway_sqla.py
    │   │   └── users_query_gateway_sqla.py
    │   ├── mappings/
    │   │   ├── login_audit.py
    │   │   ├── users_social_account.py
    │   │   └── users.py
    │   ├── registry.py
    │   └── session.py
    │
    ├── persistence_redis/
    │   ├── adapters/
    │   │   ├── state_store_redis.py
    │   │   ├── token_blacklist_redis.py
    │   │   └── users_token_store_redis.py
    │   ├── client.py
    │   └── constants.py                      ← Redis key prefix 상수
    │
    └── security/
        └── jwt_token_service.py

    4.2 Redis 상수 파일

    AS-IS (하드코딩):

    # state_store_redis.py
    key = f"oauth:state:{state}"
    
    # token_blacklist_redis.py
    key = f"blacklist:{jti}"
    
    # users_token_store_redis.py
    user_key = f"user:tokens:{user_id}"
    meta_key = f"token:meta:{jti}"

    TO-BE (상수화):

    # constants.py
    STATE_KEY_PREFIX = "oauth:state:"
    BLACKLIST_KEY_PREFIX = "blacklist:"
    USER_TOKENS_KEY_PREFIX = "user:tokens:"
    TOKEN_META_KEY_PREFIX = "token:meta:"
    
    # state_store_redis.py
    from apps.auth.infrastructure.persistence_redis.constants import STATE_KEY_PREFIX
    
    key = f"{STATE_KEY_PREFIX}{state}"

    4.3 gRPC 클라이언트 구조

    Auth 도메인의 gRPC는 클라이언트 역할:

    grpc/
    ├── client.py                              ← gRPC 채널, 스텁, Circuit Breaker
    ├── adapters/
    │   └── users_management_gateway_grpc.py   ← Port 구현체
    └── schemas/
        ├── users_pb2.py                       ← Protobuf 정의
        └── users_pb2_grpc.py

    UsersManagementGatewayGrpc:

    class UsersManagementGatewayGrpc(UsersManagementGateway):
        """gRPC를 통한 UsersManagementGateway 구현."""
    
        def __init__(self, client: UsersGrpcClient) -> None:
            self._client = client
    
        async def get_or_create_from_oauth(self, ...) -> OAuthUserResult | None:
            # gRPC 호출
            response = await self._client.get_or_create_from_oauth(...)
    
            # Response → Domain DTO 변환
            return OAuthUserResult(
                user_id=UUID(response.user.id),
                username=response.user.username or None,
                ...
            )

    5. Users Infrastructure 정제

    5.1 TO-BE 구조

    apps/users/
    ├── infrastructure/                       ← 외부 시스템 구현체만
    │   └── persistence_postgres/
    │       ├── adapters/
    │       │   ├── identity_gateway_sqla.py
    │       │   ├── social_account_gateway_sqla.py
    │       │   ├── transaction_manager_sqla.py
    │       │   ├── users_character_gateway_sqla.py
    │       │   └── users_gateway_sqla.py
    │       ├── mappings/
    │       │   ├── user.py
    │       │   ├── user_character.py
    │       │   └── user_social_account.py
    │       ├── constants.py                  ← 스키마/테이블 상수
    │       └── session.py
    │
    └── presentation/                         ← 전달 계층
        ├── grpc/                             ← gRPC 서버
        │   ├── server.py
        │   ├── servicers/
        │   │   └── users_servicer.py
        │   ├── users_pb2.py
        │   └── users_pb2_grpc.py
        └── http/
            ├── controllers/
            └── schemas/

    5.2 PostgreSQL 상수 파일

    # constants.py
    USERS_SCHEMA = "users"
    AUTH_SCHEMA = "auth"  # cross-schema query용
    
    ACCOUNTS_TABLE = "accounts"
    SOCIAL_ACCOUNTS_TABLE = "social_accounts"
    USER_CHARACTERS_TABLE = "user_characters"
    # mappings/user.py
    from apps.users.infrastructure.persistence_postgres.constants import (
        USERS_SCHEMA,
        ACCOUNTS_TABLE,
    )
    
    users_table = Table(
        ACCOUNTS_TABLE,
        metadata,
        Column("id", UUID(as_uuid=True), primary_key=True),
        ...,
        schema=USERS_SCHEMA,
    )

    5.3 gRPC 서버 → Presentation 이동

    이유:

    관점 설명
    일관성 HTTP controllers도 presentation/http/에 위치
    가시성 폴더 구조만으로 지원 프로토콜 파악 (HTTP, gRPC)
    역할 명확화 gRPC 서버는 "전달 계층"이지 "외부 시스템 구현"이 아님
    presentation/
    ├── grpc/      ← gRPC 프로토콜
    └── http/      ← HTTP 프로토콜

    5.4 Fat Servicer → Thin Adapter 리팩토링

    AS-IS (Fat Servicer):

    class UsersServicer(users_pb2_grpc.UsersServiceServicer):
        def __init__(self, session_factory):
            self._session_factory = session_factory
    
        async def GetOrCreateFromOAuth(self, request, context):
            async with self._session_factory() as session:
                # DB 직접 쿼리
                result = await session.execute(
                    select(User).where(...)
                )
                user = result.scalar_one_or_none()
    
                if user is None:
                    # 직접 생성
                    user = User(...)
                    session.add(user)
                    await session.commit()
    
                # Protobuf 변환
                return users_pb2.Response(...)

    TO-BE (Thin Adapter):

    class UsersServicer(users_pb2_grpc.UsersServiceServicer):
        """Thin Adapter - UseCase 호출만 담당."""
    
        def __init__(
            self,
            session_factory,
            use_case_factory: "GrpcUseCaseFactory",
        ) -> None:
            self._session_factory = session_factory
            self._use_case_factory = use_case_factory
    
        async def GetOrCreateFromOAuth(self, request, context):
            try:
                # 1. Request → DTO 변환
                dto = OAuthUserRequest(
                    provider=request.provider,
                    provider_user_id=request.provider_user_id,
                    email=request.email if request.HasField("email") else None,
                    ...
                )
    
                # 2. 세션 생성 및 UseCase 실행
                async with self._session_factory() as session:
                    command = self._use_case_factory.create_get_or_create_from_oauth_command(session)
                    result = await command.execute(dto)
                    await session.commit()
    
                # 3. Result → Protobuf 응답 변환
                return users_pb2.GetOrCreateFromOAuthResponse(
                    user=self._result_to_user_proto(result),
                    is_new_user=result.is_new_user,
                )
    
            except ValueError as e:
                await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
            except Exception:
                await context.abort(grpc.StatusCode.INTERNAL, "Internal server error")

    5.5 GrpcUseCaseFactory

    gRPC는 FastAPI Depends를 사용할 수 없으므로 팩토리 패턴 적용:

    class GrpcUseCaseFactory:
        """gRPC 서버용 UseCase 팩토리."""
    
        def __init__(self, session_factory) -> None:
            self._session_factory = session_factory
    
        def create_get_or_create_from_oauth_command(
            self,
            session: AsyncSession,
        ) -> GetOrCreateFromOAuthCommand:
            return GetOrCreateFromOAuthCommand(
                query_gateway=SqlaIdentityQueryGateway(session),
                command_gateway=SqlaIdentityCommandGateway(session),
                transaction_manager=SqlaTransactionManager(session),
            )
    
        def create_get_user_query(
            self,
            session: AsyncSession,
        ) -> GetUserQuery:
            return GetUserQuery(
                query_gateway=SqlaUsersQueryGateway(session),
            )

    6. gRPC 배치 비교

    도메인 gRPC 역할 위치 이유
    Auth 클라이언트 infrastructure/ users 서비스 호출 (외부 의존성)
    Users 서버 presentation/ 요청 수신 (전달 계층)

    7. 설정값 외부화

    7.1 OAuth 클라이언트 타임아웃

    AS-IS:

    async with httpx.AsyncClient(timeout=10.0) as client:

    TO-BE:

    # settings.py
    class Settings(BaseSettings):
        oauth_client_timeout_seconds: float = Field(default=10.0)
    
    # client.py
    class OAuthClientImpl:
        def __init__(self, registry, timeout_seconds: float) -> None:
            self._timeout = timeout_seconds
    
        async def fetch_profile(self, ...):
            async with httpx.AsyncClient(timeout=self._timeout) as client:

    7.2 토큰 만료 시간

    AS-IS:

    estimated_issued_at = current_time - timedelta(seconds=900)  # 하드코딩

    TO-BE:

    estimated_issued_at = current_time - timedelta(
        seconds=self._issuer.access_token_expire_minutes * 60
    )

    8. 최종 구조 비교

    Auth Infrastructure

    카테고리 AS-IS TO-BE
    어댑터 위치 최상위 adapters/ 혼재 기술별 {tech}/adapters/
    상수 코드 내 하드코딩 constants.py 중앙화
    gRPC grpc/ (역할 불명확) grpc/adapters/, grpc/client.py
    네이밍 user_reader_sqla.py users_query_gateway_sqla.py

    Users Infrastructure/Presentation

    카테고리 AS-IS TO-BE
    gRPC 서버 위치 infrastructure/grpc/ presentation/grpc/
    Servicer 패턴 Fat Servicer (DB 직접 접근) Thin Adapter (UseCase 호출)
    DI 방식 없음 GrpcUseCaseFactory
    네이밍 user_gateway_sqla.py users_gateway_sqla.py

    9. 요약

    원칙 적용
    어댑터는 기술별 폴더 내 배치 persistence_postgres/adapters/, grpc/adapters/
    상수는 기술별 constants.py Redis key prefix, PostgreSQL schema
    gRPC 서버는 presentation users/presentation/grpc/
    gRPC 클라이언트는 infrastructure auth/infrastructure/grpc/
    Servicer는 Thin Adapter Request→DTO→UseCase→Result→Response
    네이밍은 도메인 복수형 users_*, users.py
    설정값은 Settings에서 주입 oauth_client_timeout_seconds, 토큰 만료시간

    댓글

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