-
이코에코(Eco²) ORM Mapping Registry 통일 분석 리포트이코에코(Eco²)/Reports 2026. 1. 7. 05:13

작성일: 2026-01-07
목적: Clean Architecture 기반 장기 유지보수를 위한 ORM 매핑 전략 통일
Model: Opus 4.5
Agents: Cursor
PR: https://github.com/eco2-team/backend/pull/306feat(apps): ORM Registry 통일 + 멀티스테이지 빌드 by mangowhoiscloud · Pull Request #306 · eco2-team/backend
주요 변경사항 1. ORM Registry 통일 (Imperative Mapping) character 도메인 마이그레이션 ✅ Before After Declarative (Base + Model) Imperative (registry + tables + mappings) 삭제: base.py, models/, mappers.py ...
github.com
1. 현재 상태 분석
1.1 도메인별 ORM 매핑 방식 비교
도메인 매핑 방식 Base/Registry 위치 도메인-ORM 결합도 start_mappers() auth Imperative persistence_postgres/registry.py🟢 분리됨 ✅ start_all_mappers()users Imperative 각 mapping 파일 내 🟢 분리됨 ✅ start_mappers()character Declarative persistence_postgres/base.py🔴 결합됨 ❌ 없음 (암묵적) location Declarative models.py내🔴 결합됨 ❌ 없음 (암묵적) character_worker Raw SQL N/A 🟢 분리됨 ❌ N/A users_worker Raw SQL N/A 🟢 분리됨 ❌ N/A 1.2 현재 구조 상세
✅ auth (Imperative - 모범 사례)
apps/auth/infrastructure/persistence_postgres/ ├── registry.py # mapper_registry = registry() ├── mappings/ │ ├── __init__.py # start_all_mappers() │ ├── users.py # Table(...) + map_imperatively() │ ├── users_social_account.py │ └── login_audit.py ├── adapters/ │ └── *.py └── session.py특징:
- 도메인 엔티티가 순수 Python 클래스 (
Entity[UserId]상속) - ORM 결합은
map_imperatively()에서만 발생 start_all_mappers()호출로 초기화 시점 통제
✅ users (Imperative)
apps/users/infrastructure/persistence_postgres/ ├── mappings/ │ ├── __init__.py # start_mappers() │ ├── user.py # metadata + registry + Table + map_imperatively │ ├── user_character.py │ └── user_social_account.py ├── adapters/ │ └── *.py ├── constants.py └── session.py특징:
- 도메인 엔티티가
@dataclass(순수 Python) - 각 파일마다 별도
metadata+registry생성 (🔸 개선 필요) start_mappers()호출로 초기화 시점 통제
⚠️ character (Declarative)
apps/character/infrastructure/persistence_postgres/ ├── base.py # class Base(DeclarativeBase) ├── models/ │ ├── __init__.py │ └── character.py # class CharacterModel(Base) - ORM 모델 ├── mappers.py # model → entity 변환 함수 └── *.py # adapters특징:
CharacterModel(Base): ORM에 결합된 모델 클래스- 도메인 엔티티
Character는 별도@dataclass mappers.py에서 model → entity 수동 변환- 문제: 모델 import 시점에 SQLAlchemy 메타데이터 암묵적 등록
⚠️ location (Declarative)
apps/location/infrastructure/persistence_postgres/ ├── models.py # Base + NormalizedLocationSite(Base) └── location_reader_sqla.py # 내부 변환특징:
models.py에 Base와 모델이 함께 정의- 도메인 엔티티
NormalizedSite는 별도 - Reader 내부에서
_to_domain()변환
2. 문제점 분석
2.1 Declarative 방식의 문제
Import Side-Effect
# character/infrastructure/persistence_postgres/models/character.py from character.infrastructure.persistence_postgres.base import Base class CharacterModel(Base): # 👈 import 시점에 메타데이터 등록! __tablename__ = "characters"영향:
- 테스트에서 격리 어려움
- Worker/CLI에서 예상치 못한 DB 연결 시도
- 마이그레이션 스크립트에서 충돌
ORM-Domain 결합
# Declarative: 모델 자체가 ORM에 결합 class CharacterModel(Base): id: Mapped[UUID] = mapped_column(...) # SQLAlchemy 타입 # vs Imperative: 도메인은 순수 @dataclass class User: id: UUID nickname: str | None2.2 현재 혼합 상태의 리스크
- 일관성 부재: 신규 개발자 온보딩 시 혼란
- 테스트 복잡도: 각 도메인마다 다른 fixture 전략 필요
- Alembic 통합: metadata 분산으로 마이그레이션 설정 복잡
- 확장성: LLM/Scan 등 새 도메인 추가 시 어떤 패턴을 따를지 불명확
3. 통일 전략 제안
3.1 왜 Imperative로 통일해야 하는가
기준 Declarative Imperative 도메인 순수성 ❌ ORM 결합 ✅ 완전 분리 Import side-effect ❌ 암묵적 등록 ✅ 명시적 start_mappers()테스트 격리 ❌ 어려움 ✅ clear_mappers()가능Value Object 지원 ❌ 제한적 ✅ 자유로운 매핑 도메인 이벤트 ❌ 어려움 ✅ 쉽게 통합 Clean Architecture ❌ 위반 ✅ 준수 3.2 목표 구조
apps/<domain>/infrastructure/persistence_postgres/ ├── registry.py # 공유 또는 도메인별 registry ├── tables.py # Table(...) 정의만 ├── mappings.py # map_imperatively() + start_mappers() ├── adapters/ │ ├── repository.py │ └── unit_of_work.py └── session.py3.3 공용 Registry 옵션
# shared/infrastructure/persistence_postgres/registry.py from sqlalchemy.orm import registry mapper_registry = registry() metadata = mapper_registry.metadata장점: Alembic에서 단일 metadata 참조
단점: 서비스 간 의존성 발생권장: 도메인별 registry 유지하되, Alembic 전용 통합 스크립트 작성
4. 도메인별 마이그레이션 계획
4.1 character (Declarative → Imperative)
Before
# models/character.py class CharacterModel(Base): __tablename__ = "characters" id: Mapped[UUID] = mapped_column(...)After
# tables.py from sqlalchemy import Table, Column, String, Text from character.infrastructure.persistence_postgres.registry import mapper_registry characters_table = Table( "characters", mapper_registry.metadata, Column("id", UUID(as_uuid=True), primary_key=True), Column("code", String(64), unique=True, nullable=False), Column("name", Text, nullable=False), ... schema="character", ) # mappings.py from character.domain.entities import Character from character.infrastructure.persistence_postgres.registry import mapper_registry from character.infrastructure.persistence_postgres.tables import characters_table def start_character_mapper() -> None: if hasattr(Character, "__mapper__"): return mapper_registry.map_imperatively( Character, characters_table, )4.2 location (Declarative → Imperative)
Before
# models.py class Base(DeclarativeBase): pass class NormalizedLocationSite(Base): ...After
# tables.py location_sites_table = Table( "location_normalized_sites", mapper_registry.metadata, Column("positn_sn", BigInteger, primary_key=True), ... schema="location", ) # mappings.py from location.domain.entities import NormalizedSite def start_location_mapper() -> None: mapper_registry.map_imperatively(NormalizedSite, location_sites_table)4.3 auth (이미 Imperative - 정리만)
- 현재 구조 유지
- naming convention 통일:
start_all_mappers()→start_mappers()
4.4 users (이미 Imperative - Registry 통합)
- 각 파일의 개별
metadata+registry→ 공용registry.py로 통합
5. 테스트 전략
5.1 Unit Test Fixture
# conftest.py import pytest from sqlalchemy.orm import clear_mappers @pytest.fixture(autouse=True) def clear_orm_mappers(): yield clear_mappers() @pytest.fixture def setup_mappers(): from character.infrastructure.persistence_postgres.mappings import start_mappers start_mappers()5.2 Integration Test
@pytest.fixture(scope="session") def db_session(): from character.infrastructure.persistence_postgres.mappings import start_mappers start_mappers() # ... session setup
6. 결론
통일 목표
- Imperative Mapping으로 전체 통일
- 도메인 순수성 유지 (Clean Architecture 준수)
- 명시적 초기화 (
start_mappers()) 패턴 표준화
예상 효과
- 온보딩 단축: 일관된 ORM 패턴으로 학습 곡선 감소
- 테스트 안정성: 격리된 테스트 환경
- 확장성: 신규 도메인 (chat, scan-v2) 추가 시 명확한 템플릿
- 유지보수성: import side-effect 제거로 예측 가능한 동작
예상 비용
- 개발: ~5일
- 리스크: 낮음 (기존 동작 유지, 구조만 변경)
- 테스트: 기존 테스트 수정 필요
부록: 표준 템플릿
registry.py
"""SQLAlchemy Mapper Registry.""" from sqlalchemy.orm import registry mapper_registry = registry()tables.py
"""Table Definitions.""" from sqlalchemy import Column, Table, String, Text from sqlalchemy.dialects.postgresql import UUID from <domain>.infrastructure.persistence_postgres.registry import mapper_registry <entity>_table = Table( "<table_name>", mapper_registry.metadata, Column("id", UUID(as_uuid=True), primary_key=True), # ... columns schema="<schema>", )mappings.py
"""ORM Mappings.""" from <domain>.domain.entities import <Entity> from <domain>.infrastructure.persistence_postgres.registry import mapper_registry from <domain>.infrastructure.persistence_postgres.tables import <entity>_table def start_<entity>_mapper() -> None: """<Entity> 매퍼 시작.""" if hasattr(<Entity>, "__mapper__"): return mapper_registry.map_imperatively(<Entity>, <entity>_table) def start_mappers() -> None: """모든 매퍼 시작.""" start_<entity>_mapper() # ... other mappers'이코에코(Eco²) > Reports' 카테고리의 다른 글
이코에코(Eco²) RabbitMQ Queue Strategy Report (0) 2026.01.08 이코에코(Eco²) Scan-Worker:CA 배포 전 정합성 점검 리포트 (0) 2026.01.07 이코에코(Eco²) LLM 파이프라인 의사결정 리포트 (0) 2026.01.05 Scan API 600 VUs Load Test: 처리량 포화 분석 리포트 (0) 2025.12.29 - 도메인 엔티티가 순수 Python 클래스 (