-
이코에코(Eco²) Clean Architecture #6: My->Users 리팩토링.MD이코에코(Eco²)/Clean Architecture Migration 2026. 1. 1. 01:38

작성일: 2025-12-31
Opus 4.5와의 문답을 거치며 작성한 문서이며 My->Users 리팩토링 과정의 초안이 됩니다.핵심 변경 사항
변경 AS-IS TO-BE 도메인명 myusers라우팅 /api/v1/user/me/api/v1/users/meUser 소유권 auth + my (분산) users (단일) auth -> 회원가입 직접 DB 접근, Auth에서 수행 gRPC, Users에서 수행 마이그레이션 목표
목표 설명 계층 분리 Presentation, Application, Domain, Infrastructure 분리 의존성 역전 Port/Adapter 패턴으로 외부 의존성 추상화 gRPC 통합 auth ↔ users 도메인 간 gRPC 통신, 도메인 명확히 분리 CQRS 적용 읽기(Query)와 쓰기(Command) 분리 데이터 소유권 명확화 중복 테이블 통합, 단일 소유
현재 구조 분석
데이터 흐름

데이터 흐름 설명:
- HTTP API: 클라이언트에서 프로필 조회/수정/삭제, 캐릭터 목록 조회
- gRPC Server:
character도메인에서 캐릭터 지급 시GrantCharacterRPC 호출 (sync scan, deprecated) - Celery Worker:
scan도메인에서 보상 처리 시my.save_character태스크 발행 → 배치로 DB 저장 (async scan) - Auth 참조:
auth도메인의users,user_social_accounts테이블을 읽기 전용으로 참조
핵심 구성요소
구성요소 역할 프로토콜 my-api 프로필 CRUD + 캐릭터 조회 HTTP my-grpc 캐릭터 지급 gRPC my-worker 캐릭터 배치 저장 Celery 현재 디렉토리 구조
domains/my/ ├── main.py # FastAPI 앱 ├── api/v1/endpoints/ # HTTP 엔드포인트 │ ├── profile.py │ └── characters.py ├── services/ # 비즈니스 로직 │ ├── my.py # 프로필 서비스 │ └── characters.py # 캐릭터 서비스 ├── repositories/ # 데이터 액세스 │ ├── user_repository.py │ ├── user_character_repository.py │ └── user_social_account_repository.py ├── models/ # SQLAlchemy ORM │ ├── user.py │ ├── user_character.py │ ├── auth_user.py # auth 참조 │ └── auth_user_social_account.py ├── rpc/ # gRPC │ ├── server.py # gRPC 서버 │ ├── character_client.py # character gRPC 클라이언트 │ └── v1/ │ └── user_character_servicer.py ├── tasks/ # Celery 태스크 │ └── sync_character.py ├── schemas/ # Pydantic 스키마 └── proto/ # Protobuf 정의
스키마 분리의 배경과 문제점
원래 설계 의도
초기 설계에서는 마이크로서비스 지향 아키텍처를 염두에 두고 스키마를 분리했다.
원칙 의도 Bounded Context 각 도메인이 자신의 데이터를 완전히 소유 스키마 분리 나중에 DB 분리를 쉽게 하기 위해 느슨한 결합 user_profile.users.auth_user_id로 논리적 참조만원래 의도한 데이터 분리:
auth.users: 최소한의 인증 정보 (id, 소셜 계정 연결용)user_profile.users: 확장된 프로필 정보 (닉네임, 전화번호, 프로필 이미지)
실제로 발생한 문제
문제 1: 데이터 중복

중복 발생 원인
시점 상황 결과 OAuth 구현 OAuth에서 닉네임/이미지 제공 → auth.users에 저장auth에 프로필 정보 추가 프로필 기능 my 도메인도 프로필 필요 → user_profile.users생성같은 정보 중복 저장 문제 2: 애플리케이션 레벨 동기화 필요
# my/services/my.py — 프로필 수정 시 양쪽 자동 업데이트 async def _apply_update(self, user: User, payload: UserUpdate) -> User: # 1. user_profile.users 업데이트 updated = await self.repo.update_user(user, update_data) # 2. auth.users도 자동 업데이트 (애플리케이션에서 처리) if "phone_number" in update_data: await self.repo.update_auth_user_phone( user.auth_user_id, update_data.get("phone_number") ) await self.session.commit()문제
- 사용자 입장에선 자동이지만, 명시적으로 구현해 유지보수 부담 상승
- 새 필드 추가 시 동기화 로직도 함께 추가해야 함
- 다른 도메인(예: admin)에서 수정 시 동기화 누락 위험
- 트랜잭션 범위가 두 스키마에 걸쳐있음
문제 3: 불분명한 데이터 소유권
질문 답변 불가 닉네임은 누구 책임? auth.users.nicknamevsuser_profile.users.nickname프로필 수정 시 어디를 업데이트? 둘 다? 두 값이 다르면 어느 게 진짜? 명확하지 않음 적용 방향
- users 도메인이 User 전체 소유
- auth는 인증/토큰만, auth.user 스키마 삭제
- gRPC로 통신, DB 직접 참조 X
현재 스키마 구조 (ER Diagram)

ER 다이어그램 설명:
- auth.users: 인증 도메인의 핵심 사용자 테이블 — OAuth 로그인 시 생성
- auth.user_social_accounts: 소셜 로그인 계정 — 1:N 관계로 다중 소셜 계정 지원
- user_profile.users: 프로필 확장 테이블 —
auth_user_id로 auth.users 참조 - user_profile.user_characters: 캐릭터 인벤토리 — FK 없이
user_id로 논리적 참조
중복 필드 테이블
필드 auth.users user_profile.users 문제 username✅ ✅ 중복 nickname✅ ✅ 중복 phone_number✅ ✅ 중복 profile_image_url✅ ✅ 중복 name❌ ✅ 확장 필드 email❌ (소셜 계정에 있음) ✅vice 확장 필드 현재 동기화 로직 (
MyService._apply_update):# phone_number 수정 시 양쪽 테이블 자동 동기화 (애플리케이션 레벨) if "phone_number" in update_data: await self.repo.update_auth_user_phone( user.auth_user_id, update_data.get("phone_number") ) await self.session.commit()→ 애플리케이션 레벨 동기화로 인한 유지보수 부담 및 불일치 위험
gRPC 기반 도메인 통합 설계
목표 아키텍처

아키텍처 설명
- auth 도메인: 인증/토큰만 담당, User 데이터 직접 접근 ❌
- users 도메인: User 전체 소유, gRPC 서버 제공
- 통신 방식: auth → users는 gRPC 동기 호출 (OAuth 콜백은 이벤트/비동기 처리 불가, 즉시 응답이 가능한 강결합)
OAuth 콜백 플로우 (gRPC)

동기 호출 근거 설명 토큰 발급 필요 JWT에 user_id클레임 포함 필수즉시 응답 필요 사용자에게 토큰과 함께 리다이렉트 롤백 필요 사용자 생성 실패 시 로그인 실패
최종 Users 스키마

profile_images는 확장성을 위한 필드, 현재 이미지 서버와 로직, S3 prefix 존재 ERD 설명
관계 유형 설명 users ↔ user_social_accountshas(1:N, FK)실제 FK, CASCADE 삭제 users ↔ user_charactersowns(1:N, FK)실제 FK, CASCADE 삭제 user_characters ↔ characters논리 FK FK 없음, 도메인 독립성 도메인별 책임 정리
도메인 역할 테이블 접근 users User 전체 소유, CRUD, 프로필, 캐릭터 Read/Write auth 인증, 토큰 발급 gRPC 통해서만 character 캐릭터 지급 요청 gRPC 통해서만 scan 보상 처리 후 캐릭터 지급 Celery/gRPC 제약조건 요약
테이블 제약조건 컬럼 목적 usersuq_users_phonephone_number전화번호 중복 방지 user_social_accountsuq_social_identity(provider, provider_user_id)소셜 계정 중복 방지 user_social_accountsfk_social_useruser_idCASCADE 삭제 user_charactersuq_user_character(user_id, character_code)캐릭터 중복 소유 방지 user_charactersfk_character_useruser_idCASCADE 삭제 user_characterschk_statusstatus값 검증 (owned/burned)
스키마 통합 마이그레이션 계획
AS-IS vs TO-BE

마이그레이션 단계
Phase 작업 테이블 변경 다운타임 1 코드 이동 (auth → users) + gRPC 추가 ❌ ❌ 2 스키마 생성 및 테이블 이동 ✅ 짧음 3 중복 데이터 병합 ✅ 짧음 4 이전 테이블/스키마 삭제 ✅ ❌ Phase 2: 스키마 마이그레이션 SQL
-- Step 1: 새 스키마 생성 CREATE SCHEMA IF NOT EXISTS users; -- Step 2: auth 테이블 이동 ALTER TABLE auth.users SET SCHEMA users; ALTER TABLE auth.user_social_accounts SET SCHEMA users; -- Step 3: user_profile.users 데이터 병합 UPDATE users.users u SET name = up.name, email = COALESCE(u.email, up.email) FROM user_profile.users up WHERE u.id = up.auth_user_id; -- Step 4: user_characters 이동 ALTER TABLE user_profile.user_characters SET SCHEMA users; -- Step 5: 중복 테이블 삭제 DROP TABLE user_profile.users; DROP SCHEMA user_profile; -- Step 6 (선택): auth 스키마 삭제 DROP SCHEMA auth;
기능 분석
API 엔드포인트
엔드포인트 메서드 설명 서비스 /user/meGET 현재 사용자 프로필 조회 UserService/user/mePATCH 닉네임, 전화번호 수정 UserService/user/meDELETE 계정 삭제 UserService/user/me/charactersGET 소유 캐릭터 목록 UserCharacterService/user/me/characters/{name}/ownershipGET 특정 캐릭터 소유 여부 UserCharacterServiceGrantCharacter(gRPC)- 캐릭터 지급 (character 도메인에서 호출) UserCharacterServicer캐릭터 저장 흐름 (Celery Batches)

현재 async Scan 보상 로직, 현행 유지 (/api/v1/scan) Celery Batches 흐름 설명:
- 메시지 발행:
scan도메인에서 보상 처리 시my.save_character태스크 발행 - 배치 축적:
flush_every=50(50개 모이면) 또는flush_interval=5s(5초 경과) 시 배치 처리 - BULK UPSERT:
ON CONFLICT (user_id, character_code) DO UPDATE로 멱등성 보장 - Self-Healing:
character_id캐시 불일치 시에도character_code기준으로 정확히 저장
gRPC 서버 (GrantCharacter, deprecated)

character도메인에서 동기 스캔 처리 시GrantCharacterRPC 호출- Optimistic Locking으로 동시 요청 시에도 중복 지급 방지
IntegrityError발생 시already_owned=true반환
외부 도메인 참조

동기 Scan 보상 로직, deprecated (/api/v1/scan/classify) 외부 참조 설명:
참조 대상 용도 방식 auth.users사용자 기본 정보 (닉네임, 이메일) PostgreSQL 직접 조회 (읽기 전용) auth.user_social_accounts소셜 계정 정보 (provider, last_login) PostgreSQL 직접 조회 (읽기 전용) character도메인기본 캐릭터 정보 조회 gRPC 클라이언트 (Circuit Breaker 적용)
Clean Architecture 마이그레이션
목표 아키텍처

아키텍처 설명:
- Presentation: HTTP Controller와 gRPC Servicer가 공존 — 프로토콜 추상화
- Application: CQRS 패턴 적용 — Commands(쓰기), Queries(읽기) 분리
- Domain: 순수 비즈니스 로직 — 외부 의존성 없음
- Infrastructure: Port 구현체 — SQLAlchemy, gRPC 클라이언트
의존성 방향

규칙 설명 안쪽으로만 모든 의존성은 Domain을 향함 Port/Adapter Application이 Port 정의, Infrastructure가 구현 DIP 적용 상위 모듈이 하위 모듈에 의존하지 않음
계층별 상세 설계
Presentation Layer
HTTP Controllers
Legacy Clean Architecture 역할 api/v1/endpoints/profile.pypresentation/http/controllers/profile.py프로필 CRUD api/v1/endpoints/characters.pypresentation/http/controllers/characters.py캐릭터 조회 # presentation/http/controllers/profile.py @router.get("/me", response_model=ProfileResponse) async def get_profile( user: UserInfo = Depends(get_current_user), query: GetProfileQuery = Depends(), ) -> ProfileResponse: dto = await query.execute( GetProfileRequest(user_id=user.user_id, provider=user.provider) ) return ProfileResponse.from_dto(dto)gRPC Servicer
Legacy Clean Architecture 역할 rpc/v1/user_character_servicer.pypresentation/grpc/user_character_servicer.py캐릭터 지급 RPC # presentation/grpc/user_character_servicer.py class UserCharacterServicer(user_character_pb2_grpc.UserCharacterServiceServicer): def __init__(self, grant_command: GrantCharacterCommand): self._command = grant_command async def GrantCharacter(self, request, context) -> GrantCharacterResponse: dto = GrantCharacterRequest.from_proto(request) result = await self._command.execute(dto) return result.to_proto()Application Layer
Commands (쓰기)
Use Case 설명 의존 Port UpdateProfileCommand프로필 수정 UserGatewayDeleteUserCommand계정 삭제 UserGatewayGrantCharacterCommand캐릭터 지급 (gRPC) UserCharacterGateway# application/commands/grant_character.py @dataclass class GrantCharacterRequest: user_id: UUID character_id: UUID character_code: str character_name: str character_type: str | None character_dialog: str | None source: str | None class GrantCharacterCommand: def __init__(self, gateway: UserCharacterGateway): self._gateway = gateway async def execute(self, request: GrantCharacterRequest) -> GrantCharacterResult: result = await self._gateway.upsert( user_id=request.user_id, character_id=request.character_id, character_code=request.character_code, # ... ) return GrantCharacterResult( success=True, already_owned=result.was_existing, )Queries (읽기)
Use Case 설명 의존 Port GetProfileQuery프로필 조회 UserGateway,AuthUserReaderListCharactersQuery캐릭터 목록 UserCharacterGatewayCheckOwnershipQuery소유 확인 UserCharacterGatewayPorts (인터페이스)
# application/common/ports/user_character_gateway.py class UserCharacterGateway(Protocol): async def upsert( self, *, user_id: UUID, character_id: UUID, character_code: str, character_name: str, character_type: str | None, character_dialog: str | None, source: str | None, ) -> UpsertResult: """멱등 UPSERT (character_code 기준)""" ... async def list_by_user(self, user_id: UUID) -> list[UserCharacterDTO]: ... async def check_ownership_by_name(self, user_id: UUID, name: str) -> bool: ...# application/common/ports/character_client.py class CharacterClient(Protocol): async def get_default_character(self) -> DefaultCharacterDTO | None: """기본 캐릭터 조회 (Circuit Breaker 적용)""" ...Domain Layer
Entity
# domain/entities/user.py @dataclass class User: id: int | None auth_user_id: UUID username: str | None nickname: str | None phone_number: PhoneNumber | None # Value Object profile_image_url: str | None created_at: datetime | None def update_profile( self, nickname: str | None = None, phone_number: PhoneNumber | None = None, ) -> None: if nickname is not None: self.nickname = nickname if phone_number is not None: self.phone_number = phone_numberValue Object
# domain/value_objects/phone_number.py @dataclass(frozen=True, slots=True) class PhoneNumber: value: str def __post_init__(self): normalized = self._normalize(self.value) if not self._is_valid(normalized): raise InvalidPhoneNumberError(self.value) object.__setattr__(self, 'value', normalized) @staticmethod def _normalize(raw: str) -> str: digits = re.sub(r"\D+", "", raw) if digits.startswith("82") and len(digits) >= 11: digits = "0" + digits[2:] return digits def formatted(self) -> str: """010-1234-5678 형식""" return f"{self.value[:3]}-{self.value[3:7]}-{self.value[7:]}"Value Object 설명:
- 불변(Immutable):
frozen=True로 생성 후 변경 불가 - 자가 검증: 생성 시점에 유효성 검사
- 정규화: 국제 형식(+82) → 국내 형식(010) 자동 변환
Domain Service
# domain/services/profile_service.py class ProfileService: def build_profile( self, user: User, social_accounts: list[SocialAccountInfo], current_provider: str, ) -> ProfileDTO: account = self._select_social_account(social_accounts, current_provider) username = self._resolve_username(user, account) nickname = self._resolve_nickname(user, account, username) return ProfileDTO( username=username, nickname=nickname, phone_number=user.phone_number.formatted() if user.phone_number else None, provider=account.provider if account else current_provider, last_login_at=account.last_login_at if account else None, )Infrastructure Layer
Adapters
Adapter Port 역할 SqlaUserMapperUserGatewayUser CRUD SqlaUserCharacterMapperUserCharacterGateway캐릭터 인벤토리 SqlaAuthUserReaderAuthUserReaderauth 읽기 전용 GrpcCharacterClientCharacterClientcharacter gRPC 호출 # infrastructure/adapters/user_character_mapper_sqla.py class SqlaUserCharacterMapper(UserCharacterGateway): async def upsert(self, **kwargs) -> UpsertResult: stmt = ( insert(UserCharacterModel) .values(**kwargs) .on_conflict_do_update( constraint="uq_user_character_code", set_={ "character_id": kwargs["character_id"], "character_name": kwargs["character_name"], # ... }, ) .returning(UserCharacterModel) ) result = await self._session.execute(stmt) row = result.scalar_one() return UpsertResult( was_existing=(row.updated_at != row.acquired_at), )# infrastructure/grpc_client/character_client_grpc.py class GrpcCharacterClient(CharacterClient): def __init__(self, settings: Settings): self._circuit_breaker = CircuitBreaker( name="character-grpc", fail_max=settings.circuit_fail_max, timeout_duration=settings.circuit_timeout_duration, ) async def get_default_character(self) -> DefaultCharacterDTO | None: try: return await self._circuit_breaker.call_async(self._call_impl) except CircuitBreakerError: logger.warning("Circuit breaker OPEN") return None
디렉토리 구조
apps/users (API + gRPC Server)
apps/users/ ├── main.py # FastAPI + gRPC 서버 ├── requirements.txt ├── Dockerfile │ ├── setup/ │ ├── config/settings.py │ └── dependencies.py # DI Container │ ├── presentation/ │ ├── http/ │ │ ├── controllers/ │ │ │ ├── profile.py # GET/PATCH/DELETE /user/me │ │ │ └── characters.py # GET /user/me/characters │ │ └── schemas/ │ │ ├── profile.py │ │ └── character.py │ └── grpc/ │ ├── user_character_servicer.py # GrantCharacter RPC │ └── schemas.py # Proto ↔ DTO │ ├── application/ │ ├── commands/ │ │ ├── update_profile.py │ │ ├── delete_user.py │ │ └── grant_character.py │ ├── queries/ │ │ ├── get_profile.py │ │ ├── list_characters.py │ │ └── check_ownership.py │ └── common/ │ ├── dto/ │ │ ├── profile.py │ │ └── character.py │ ├── ports/ │ │ ├── user_gateway.py │ │ ├── user_character_gateway.py │ │ ├── auth_user_reader.py │ │ └── character_client.py │ └── exceptions/ │ ├── domain/ │ ├── entities/ │ │ ├── user.py │ │ └── user_character.py │ ├── value_objects/ │ │ ├── phone_number.py │ │ └── ownership_status.py │ ├── services/ │ │ └── profile_service.py │ └── exceptions/ │ ├── infrastructure/ │ ├── adapters/ │ │ ├── user_mapper_sqla.py │ │ ├── user_character_mapper_sqla.py │ │ └── auth_user_reader_sqla.py │ ├── grpc_client/ │ │ └── character_client_grpc.py │ └── persistence_postgres/ │ └── models/ │ └── proto/ ├── user_character.proto └── character/ └── character.protoapps/users_worker (Celery 배치)
apps/users_worker/ ├── main.py # Celery 앱 ├── requirements.txt ├── Dockerfile ├── application/ │ └── commands/ │ └── save_character_batch.py # 배치 저장 UseCase └── infrastructure/ └── persistence_postgres/ └── user_character_bulk_store.py # BULK UPSERT
파일별 매핑 테이블
Services → Commands/Queries
Legacy Clean Architecture 분류 services/my.py::get_current_user()queries/get_profile.pyQuery services/my.py::update_current_user()commands/update_profile.pyCommand services/my.py::delete_current_user()commands/delete_user.pyCommand services/characters.py::list_owned()queries/list_characters.pyQuery services/characters.py::owns_character()queries/check_ownership.pyQuery rpc/v1/user_character_servicer.py::GrantCharacter()commands/grant_character.pyCommand Domain Logic 추출
Legacy Clean Architecture 역할 services/my.py::_resolve_username()domain/services/profile_service.py사용자명 결정 services/my.py::_resolve_nickname()domain/services/profile_service.py닉네임 결정 services/my.py::_select_social_account()domain/services/profile_service.py소셜 계정 선택 services/my.py::_normalize_phone_number()domain/value_objects/phone_number.py전화번호 정규화 Repositories → Adapters
Legacy Clean Architecture Port repositories/user_repository.pyadapters/user_mapper_sqla.pyUserGatewayrepositories/user_character_repository.pyadapters/user_character_mapper_sqla.pyUserCharacterGatewayrepositories/user_social_account_repository.pyadapters/auth_user_reader_sqla.pyAuthUserReadergRPC 관련
Legacy Clean Architecture 역할 rpc/character_client.pyinfrastructure/grpc_client/character_client_grpc.pycharacter gRPC 클라이언트 rpc/v1/user_character_servicer.pypresentation/grpc/user_character_servicer.pygRPC Servicer proto/*proto/*그대로 유지
마이그레이션 단계
Phase 1: Domain Layer
- Entity 정의 (
User,UserCharacter) - Value Object 정의 (
PhoneNumber,OwnershipStatus) - Domain Service 추출 (
ProfileService)
Phase 2: Application Layer
- Port 정의 (
UserGateway,UserCharacterGateway,AuthUserReader,CharacterClient) - Commands 구현 (
UpdateProfile,DeleteUser,GrantCharacter) - Queries 구현 (
GetProfile,ListCharacters,CheckOwnership)
Phase 3: Infrastructure Layer
- SQLAlchemy Adapters 구현
- gRPC Client 구현 (Circuit Breaker 포함)
- ORM Models 분리
Phase 4: Presentation Layer
- HTTP Controllers 구현
- gRPC Servicer 구현
- DI Container 구성 (
dependencies.py)
Phase 5: Worker 분리
apps/users_worker디렉토리 생성- 배치 저장 UseCase 구현
- Dockerfile 및 CI 설정
References
- Clean Architecture #2: Auth Clean Architecture 구현
- Robert C. Martin, "Clean Architecture" (2017)
- Vaughn Vernon, "Implementing Domain-Driven Design" (2013)
'이코에코(Eco²) > Clean Architecture Migration' 카테고리의 다른 글
이코에코(Eco²) Clean Architecture #8: Infrastructure Layer 정제 (0) 2026.01.02 이코에코(Eco²) Clean Architecture #7: Application Layer 정제 (0) 2026.01.02 이코에코(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