이코에코(Eco²)
이코에코(Eco²) MQ 도입 전, 코드 품질 개선: Character API 리팩토링
mango_fr
2025. 12. 20. 11:08


Character API는 재활용 스캔 리워드 시스템의 핵심 서비스입니다.
사용자가 재활용품을 스캔하면 해당 분류에 맞는 캐릭터를 리워드로 지급합니다.
캐릭터를 결과와 매칭, 지급, 관리, 기록하는 도메인이며 Scan(폐기물 이미지 분석), My(유저 정보)와 연동됩니다.
Character API에서 진행한 품질 개선 기준과 절차를 전 도메인에 적용합니다. (ext-authz[Go] 제외, 점검 결과 코드 품질 우수)
리워드 지급 흐름

리팩토링 전 문제점
- Race Condition: 동시 요청 시 중복 캐릭터 지급
- Dead Code: 미사용 메서드 및 예외 핸들러
- 하드코딩: 일부 설정값이 코드에 직접 기입
- 테스트 부족: 단위 테스트 미비
- Observability 부재: gRPC 분산 추적 미지원
1차 개선 (P0-P5)
| 우선순위 | 이슈 | 해결 방법 |
|---|---|---|
| P0 | Race Condition | DB UniqueConstraint + IntegrityError 핸들링 |
| P1 | Dead Code | Strategy 패턴으로 평가 로직 분리 |
| P2 | 하드코딩 상수 | core/constants.py 분리 |
| P3 | 불완전한 타입 힌트 | 전체 타입 정리 |
| P4 | 테스트 부족 | 단위 테스트 추가 |
| P5 | Circuit Breaker 미적용 | aiobreaker 기반 구현 |
P0: Race Condition 해결
문제: 동시에 같은 캐릭터 지급 요청 시 중복 저장
해결: Optimistic Locking + IntegrityError 처리
# domains/character/services/character.py
async def _apply_reward(self, user_id: UUID, matches: list[Character], source: str):
for character in matches:
try:
await self._grant_and_sync(user_id, character, source)
return character, False # 성공
except IntegrityError:
await self.session.rollback()
return character, True # 이미 소유
P5: Circuit Breaker 적용
문제: My 도메인 gRPC 호출 실패 시 연쇄 장애
해결: aiobreaker 기반 Circuit Breaker
# domains/character/rpc/my_client.py
class MyUserCharacterClient:
def __init__(self, settings: Settings):
self._circuit_breaker = CircuitBreaker(
name="my-grpc-client",
fail_max=settings.circuit_fail_max, # 5회
timeout_duration=settings.circuit_timeout_duration, # 30초
)
2차 개선 (코드 품질 심층)
| 우선순위 | 이슈 | 해결 방법 |
|---|---|---|
| P0 | warmup_catalog_cache 리소스 누수 |
finally 블록에서 engine.dispose() |
| P1 | Circuit Breaker 설정 하드코딩 | Settings에서 주입 |
| P1 | EvaluationContext 책임 과다 | 순수 함수 기반으로 변경 |
| P2 | Registry 모듈 로드 side effect | Lazy Initialization |
| P2 | 테스트 fixture __new__ 사용 |
Factory Method 패턴 |
| P3 | should_evaluate async 불필요 |
sync로 변경 |
| P3 | grant_character 인자 과다 (7개) |
DTO 캡슐화 |
P1: EvaluationContext 책임 분리
Before: Evaluator가 DB 접근
# AS-IS
class ScanRewardEvaluator:
async def match_characters(self, ctx: EvaluationContext):
return await ctx.character_repo.find_by_match_label(...)
After: Service에서 데이터 전달, Evaluator는 순수 함수
# TO-BE
class ScanRewardEvaluator:
def match_characters(self, payload, characters: list[Character]):
return [c for c in characters if c.match_label == match_label]
P3: DTO 캡슐화
Before: 7개 인자
await client.grant_character(
user_id, character_id, code, name, type, dialog, source
)
After: DTO로 캡슐화
@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
await client.grant_character(request)
3차 개선 (복잡도 개선)
Radon 분석 결과 C등급(복잡도 11-20) 함수 7개를 모두 개선.
| 함수 | 파일 | 개선 방법 |
|---|---|---|
_record_reward_metrics |
character.py | _determine_reward_status 분리 |
upsert_from_profile |
user_repository.py | _resolve_or_create_user, _link_social_account, _sync_user_profile 분리 |
_build_reward_request |
scan.py | _extract_classification 공통 헬퍼 추출 |
warmup_catalog_cache |
cache.py | _load_and_cache_catalog, _character_to_profile 분리 |
load_catalog |
import_character_catalog.py | _parse_catalog_row 분리 |
_get_request_origin |
auth.py | _resolve_host, _resolve_scheme, _first_value 분리 |
resolve_csv_path |
_csv_utils.py | _build_candidates, _find_by_keywords 분리 |
아키텍처 패턴
Strategy Pattern
리워드 평가 로직을 소스별로 분리하여 확장성 확보.
services/evaluators/
├── base.py # RewardEvaluator 추상 클래스
├── registry.py # Evaluator 등록/조회
└── scan.py # ScanRewardEvaluator
Circuit Breaker Pattern
외부 서비스(My 도메인) 장애 시 fail-fast로 시스템 안정성 확보.
상태 전이:
CLOSED → (5회 실패) → OPEN → (30초 후) → HALF_OPEN → (성공) → CLOSED
Cache-Aside Pattern
Redis 캐시 + Graceful Degradation.
async def catalog():
cached = await get_cached(CATALOG_KEY)
if cached:
return cached
data = await repo.list_all()
await set_cached(CATALOG_KEY, data, ttl=300)
return data
Factory Method Pattern
테스트 의존성 주입을 위한 팩토리 메서드.
class CharacterService:
@classmethod
def create_for_test(cls, session, character_repo=None, ownership_repo=None):
service = cls.__new__(cls)
service.session = session
service.character_repo = character_repo or CharacterRepository(session)
service.ownership_repo = ownership_repo or CharacterOwnershipRepository(session)
return service
테스트 전략
테스트 구조
tests/
├── conftest.py # 공통 fixture
├── test_character_service.py # 서비스 단위 테스트
├── test_evaluators.py # Strategy 패턴 테스트
├── test_my_client.py # gRPC 클라이언트 테스트
├── test_cache.py # 캐시 레이어 테스트
├── integration/ # testcontainers 기반
└── e2e/ # API 엔드포인트 테스트
Mock 전략
- DB Session:
AsyncMock으로 대체 - Repository:
MagicMock으로 메서드 단위 mock - gRPC Client:
AsyncMock으로 응답 시뮬레이션 - Redis:
MagicMock으로 캐시 동작 시뮬레이션
실측 데이터
Radon 복잡도 분석
실행: radon cc domains/ -a -s --total-average
결과:
- 총 블록: 1,004개
- 평균 복잡도: A (2.51)
- C등급: 0개 (개선 전 7개)
- D/F등급: 0개
테스트 커버리지
실행: pytest domains/character/tests/ --cov=domains/character/services
결과:
- services/character.py: 91%
- services/evaluators/base.py: 100%
- services/evaluators/scan.py: 97%
- services/evaluators/registry.py: 83%
- 전체 services/: 92%
테스트 수
| 단위 테스트 | 0개 | 64개 |
| 통합 테스트 | 0개 | 별도 구성 |
| E2E 테스트 | 0개 | 별도 구성 |
주요 성과
- 안정성 향상: Race Condition 해결, Circuit Breaker 적용
- 유지보수성 개선: 복잡도 A등급, 패턴 적용
- 테스트 커버리지: 0% → 92% (서비스 레이어)
- Observability: Jaeger 분산 추적 커버리지 확장 (Character gRPC <-> My gRPC)