-
이코에코(Eco²) Agent #1: Domain Layer이코에코(Eco²)/Agent 2026. 1. 13. 02:24

Chat Worker의 Domain Layer — 외부 의존성 없는 순수 비즈니스 개념 정의
항목 값 작성일 2026-01-14 (업데이트) 커밋 32af7717
1. 왜 Domain Layer부터 시작하는가?
Clean Architecture에서 Domain Layer는 가장 안쪽 원입니다.
┌─────────────────────────────────────────┐ │ Infrastructure │ │ ┌───────────────────────────────┐ │ │ │ Application │ │ │ │ ┌───────────────────┐ │ │ │ │ │ Domain │ │ │ ← 여기서 시작 │ │ │ (Enums, │ │ │ │ │ │ Value Objects) │ │ │ │ │ └───────────────────┘ │ │ │ └───────────────────────────────┘ │ └─────────────────────────────────────────┘Domain Layer를 먼저 정의하는 이유:
- 의존성 방향: 모든 외부 계층이 Domain을 바라봄
- 비즈니스 로직 중심: 프레임워크/라이브러리와 무관
- 불변성 보장: Value Object는 frozen dataclass로 구현
2. 디렉토리 구조
apps/chat_worker/domain/ ├── __init__.py ├── enums/ │ ├── __init__.py │ ├── intent.py # Intent Enum │ ├── query_complexity.py # QueryComplexity Enum │ └── input_type.py # InputType Enum (HITL) └── value_objects/ ├── __init__.py ├── chat_intent.py # ChatIntent VO └── human_input.py # HITL Value Objects초기 설계 vs 최종 구현
항목 초기 설계 최종 구현 변경 이유 LLMProvider Enum 삭제 Infrastructure Layer에서 처리 ChatState TypedDict 삭제 LangGraph에서 dict 직접 사용 QueryComplexity - 추가 Subagent 분기 결정 InputType - 추가 Human-in-the-Loop 패턴 ChatIntent - 추가 Intent 분류 결과 VO HumanInput - 추가 HITL 요청/응답 VO
3. Intent Enum: 의도 분류의 첫 번째 관문
Chat 서비스의 핵심은 사용자 의도 파악입니다.
# apps/chat_worker/domain/enums/intent.py class Intent(str, Enum): """사용자 질문 의도. LangGraph의 Intent Node에서 분류되어 라우팅 결정에 사용됩니다. """ WASTE = "waste" """분리배출 질문. - 이 쓰레기 어떻게 버려? - 플라스틱 분리배출 방법 - (이미지 첨부) 이거 뭐야? """ CHARACTER = "character" """캐릭터 관련 질문. - 이 쓰레기로 어떤 캐릭터 얻어? - 플라스틱 버리면 누가 나와? """ LOCATION = "location" """위치 기반 질문. - 근처 재활용센터 어디야? - 제로웨이스트샵 찾아줘 """ GENERAL = "general" """일반 대화 (그 외). - 안녕 - 환경 보호에 대해 알려줘 """ @classmethod def from_string(cls, value: str) -> "Intent": """문자열에서 Intent 생성. Returns: 매칭된 Intent, 없으면 GENERAL """ try: return cls(value.lower()) except ValueError: return cls.GENERAL왜 4가지인가?
Intent 근거 파이프라인 waste핵심 기능, scan 서비스와 연계 RAG → Answer character게이미피케이션 요소 Character gRPC → Answer location실용적 가치, 차별화 Location gRPC → Answer general폴백, 환경 관련 일반 지식 LLM 직접 답변 CHARACTER_PREVIEW를 제거한 이유
초기 설계: character_preview → "버리면 뭐 얻어?" 패턴 → Vision/Text → Match 최종 결정: character 의도에 통합. "버리면 뭐 얻어?"와 "캐릭터 정보" 질문은 동일한 Character gRPC 호출로 처리. 불필요한 분기 제거.ECO 의도를 제외한 이유
초기 설계: eco → eco_rag_node → 환경 관련 정보 RAG 최종 결정: GPT-5.2, Gemini 3.0 등 최신 모델은 제로웨이스트, 업사이클링 등 기본 환경 정보를 이미 학습. 별도 RAG 없이 general로 처리.
4. QueryComplexity Enum: Subagent 분기 결정
# apps/chat_worker/domain/enums/query_complexity.py class QueryComplexity(str, Enum): """질문 복잡도. LangGraph의 라우팅 결정에 사용됩니다. - SIMPLE: 직접 응답 - COMPLEX: Subagent 호출 """ SIMPLE = "simple" """단순 질문 - 직접 응답 가능. - 단일 의도 - 단일 Tool 호출 또는 LLM만으로 해결 - 예: "플라스틱 어떻게 버려?" """ COMPLEX = "complex" """복잡한 질문 - Subagent 필요. - 다중 의도 또는 다중 단계 - 여러 Tool 조합 필요 - 예: "이 쓰레기 버리면 어떤 캐릭터 얻고, 근처 재활용센터도 알려줘" """ @classmethod def from_bool(cls, is_complex: bool) -> "QueryComplexity": """bool에서 QueryComplexity 생성.""" return cls.COMPLEX if is_complex else cls.SIMPLE복잡도 판단 기준
단순 질문: "페트병 어떻게 버려?" → QueryComplexity.SIMPLE → 단일 노드 (waste_rag → answer) 복잡한 질문: "페트병이랑 캔 어떻게 버려? 근처 센터도 알려줘" → QueryComplexity.COMPLEX → Subagent 분해 (decomposer → experts 병렬 → synthesizer)복잡도 판단 로직 (IntentClassifier):
COMPLEX_KEYWORDS = ["그리고", "또한", "차이", "비교", "여러", "같이"] MAX_SIMPLE_LENGTH = 100 def _determine_complexity(self, message: str) -> QueryComplexity: # 1. 복잡도 키워드 포함 if any(kw in message for kw in COMPLEX_KEYWORDS): return QueryComplexity.COMPLEX # 2. 메시지 길이 if len(message) > MAX_SIMPLE_LENGTH: return QueryComplexity.COMPLEX return QueryComplexity.SIMPLE
5. InputType Enum: Human-in-the-Loop
Human-in-the-Loop(HITL) 패턴에서 사용자에게 요청할 수 있는 입력 종류입니다.
# apps/chat_worker/domain/enums/input_type.py class InputType(str, Enum): """Human-in-the-Loop 입력 타입.""" LOCATION = "location" """위치 정보 요청 (Geolocation API).""" CONFIRMATION = "confirmation" """확인 요청 (Yes/No).""" SELECTION = "selection" """선택 요청 (Multiple Choice).""" CANCEL = "cancel" """사용자 취소.""" @classmethod def from_string(cls, value: str) -> "InputType": """문자열에서 InputType으로 변환.""" value_lower = value.lower().strip() for input_type in cls: if input_type.value == value_lower: return input_type raise ValueError(f"Invalid input type: {value}") def requires_data(self) -> bool: """이 입력 타입이 추가 데이터를 필요로 하는지.""" return self in {InputType.LOCATION, InputType.SELECTION}HITL 흐름
┌────────────────────────────────────────────────────────────┐ │ Location Subagent │ │ │ │ 1. 위치 정보 확인 │ │ user_location = state.get("user_location") │ │ │ │ │ ▼ │ │ 2. 위치 없음 → HITL 요청 │ │ InputType.LOCATION │ │ "📍 주변 센터를 찾으려면 위치 정보가 필요해요." │ │ │ │ │ (SSE → Frontend) │ │ │ │ │ 3. Frontend에서 위치 수집 │ │ POST /chat/{job_id}/input │ │ │ │ │ 4. 파이프라인 재개 │ │ LocationData(latitude=37.5, longitude=127.0) │ └────────────────────────────────────────────────────────────┘
6. ChatIntent Value Object: 의도 분류 결과
Intent와 QueryComplexity를 묶은 불변 객체입니다.
# apps/chat_worker/domain/value_objects/chat_intent.py @dataclass(frozen=True, slots=True) class ChatIntent: """분류된 사용자 의도 (Immutable). IntentClassifier 서비스의 출력으로 사용되며, LangGraph의 라우팅 결정에 활용됩니다. Attributes: intent: 분류된 의도 complexity: 질문 복잡도 confidence: 분류 신뢰도 (0.0 ~ 1.0) """ intent: Intent complexity: QueryComplexity confidence: float = 1.0 def __post_init__(self) -> None: """Validation.""" if not 0.0 <= self.confidence <= 1.0: object.__setattr__( self, "confidence", max(0.0, min(1.0, self.confidence)) ) @property def needs_subagent(self) -> bool: """Subagent 호출이 필요한지 여부.""" return self.complexity == QueryComplexity.COMPLEX @property def is_high_confidence(self) -> bool: """높은 신뢰도인지 여부 (>= 0.8).""" return self.confidence >= 0.8 @classmethod def simple_waste(cls, confidence: float = 1.0) -> "ChatIntent": """단순 분리배출 질문 생성.""" return cls( intent=Intent.WASTE, complexity=QueryComplexity.SIMPLE, confidence=confidence, ) @classmethod def simple_general(cls, confidence: float = 1.0) -> "ChatIntent": """단순 일반 질문 생성.""" return cls( intent=Intent.GENERAL, complexity=QueryComplexity.SIMPLE, confidence=confidence, ) def to_dict(self) -> dict: """딕셔너리로 변환.""" return { "intent": self.intent.value, "complexity": self.complexity.value, "confidence": self.confidence, "needs_subagent": self.needs_subagent, }frozen=True를 사용한 이유
# Mutable (문제) class ChatIntent: intent: Intent confidence: float intent = ChatIntent(Intent.WASTE, 0.9) intent.confidence = 0.1 # ❌ 외부에서 변경 가능 # Immutable (권장) @dataclass(frozen=True) class ChatIntent: intent: Intent confidence: float intent = ChatIntent(Intent.WASTE, 0.9) intent.confidence = 0.1 # ❌ FrozenInstanceErrorValue Object 불변성의 이점:
- 스레드 안전
- 예측 가능한 상태
- 디버깅 용이
7. Human Input Value Objects: HITL 요청/응답
# apps/chat_worker/domain/value_objects/human_input.py @dataclass(frozen=True) class LocationData: """위치 정보 (Geolocation API 형식).""" latitude: float longitude: float def is_valid(self) -> bool: """유효한 좌표인지 검증.""" return -90 <= self.latitude <= 90 and -180 <= self.longitude <= 180 @dataclass(frozen=True) class HumanInputRequest: """사용자 입력 요청. Worker가 사용자에게 추가 입력을 요청할 때 사용. """ job_id: str input_type: InputType message: str timeout: int = 60 options: tuple[str, ...] | None = None # SELECTION용 def __post_init__(self): """유효성 검증.""" if self.input_type == InputType.SELECTION and not self.options: raise ValueError("SELECTION type requires options") if self.timeout <= 0: raise ValueError("timeout must be positive") @dataclass(frozen=True) class HumanInputResponse: """사용자 입력 응답.""" input_type: InputType data: dict[str, Any] | None = None cancelled: bool = False timed_out: bool = False @property def is_successful(self) -> bool: """성공적인 응답인지.""" return not self.cancelled and not self.timed_out and self.data is not None def get_location(self) -> LocationData | None: """위치 데이터 추출.""" if self.input_type != InputType.LOCATION or not self.data: return None try: return LocationData.from_dict(self.data) except (KeyError, ValueError): return None @classmethod def cancelled_response(cls, input_type: InputType) -> "HumanInputResponse": """취소 응답 생성.""" return cls(input_type=input_type, cancelled=True) @classmethod def timeout_response(cls, input_type: InputType) -> "HumanInputResponse": """타임아웃 응답 생성.""" return cls(input_type=input_type, timed_out=True) @classmethod def success_response( cls, input_type: InputType, data: dict[str, Any] ) -> "HumanInputResponse": """성공 응답 생성.""" return cls(input_type=input_type, data=data)
8. ChatState TypedDict를 제거한 이유
초기 설계
class ChatState(TypedDict, total=False): job_id: str user_id: str session_id: str message: str messages: Annotated[list, add_messages] intent: Literal["waste", "character", ...] | None is_complex: bool | None classification_result: dict | None disposal_rules: dict | None tool_results: dict[str, Any] | None subagent_results: dict[str, Any] | None answer: str | None token_usage: dict | None제거 이유
- LangGraph와의 통합: LangGraph는 기본적으로
dict를 상태로 사용 - 유연성: 노드별로 다른 키를 추가/제거 가능
- 타입 검증 위치: Domain이 아닌 Application Layer에서 검증
# 현재 구현: LangGraph factory에서 dict 직접 사용 def create_chat_graph(...) -> StateGraph: graph = StateGraph(dict) # ChatState 대신 dict async def intent_node(state: dict) -> dict: # 타입 검증은 Application Service에서 chat_intent = await classifier.classify(state["message"]) return {**state, "intent": chat_intent.intent.value}
9. LLMProvider Enum을 제거한 이유
초기 설계
class LLMProvider(str, Enum): OPENAI = "openai" GEMINI = "gemini" ANTHROPIC = "anthropic"제거 이유
- Infrastructure 관심사: 모델 선택은 비즈니스 로직이 아님
- LLM Policy 분리:
infrastructure/llm/policies/에서 처리 - 설정 기반: 환경변수로 모델 선택
# 현재 구현: Infrastructure Layer에서 처리 # apps/chat_worker/infrastructure/llm/config.py MODEL_CONTEXT_WINDOWS = { "gpt-5.2-turbo": 128_000, "gemini-3-flash-preview": 1_000_000, "gemini-3-pro-preview": 2_000_000, }
10. 공개 인터페이스
# apps/chat_worker/domain/__init__.py from chat_worker.domain.enums import InputType, Intent, QueryComplexity from chat_worker.domain.value_objects import ( ChatIntent, HumanInputRequest, HumanInputResponse, LocationData, ) __all__ = [ # Enums "InputType", "Intent", "QueryComplexity", # Value Objects "ChatIntent", "HumanInputRequest", "HumanInputResponse", "LocationData", ]사용 예시
# Application Layer에서 Domain 타입 사용 from chat_worker.domain import Intent, ChatIntent, QueryComplexity class IntentClassifier: async def classify(self, message: str) -> ChatIntent: intent_str = await self._llm.generate(...) intent = Intent.from_string(intent_str) complexity = self._determine_complexity(message) return ChatIntent( intent=intent, complexity=complexity, confidence=1.0, )
11. 의사결정 요약
결정 선택 근거 Intent 종류 4개 (waste, character, location, general) eco, character_preview 제거 QueryComplexity 추가 Subagent 분기 결정 명확화 InputType 추가 HITL 패턴 도메인 모델링 ChatIntent 추가 Intent 분류 결과 캡슐화 ChatState 제거 LangGraph dict 직접 사용 LLMProvider 제거 Infrastructure 관심사로 이동 불변성 frozen=True 스레드 안전, 예측 가능성
12. 테스트
# tests/unit/domain/test_intent.py def test_intent_from_string(): assert Intent.from_string("waste") == Intent.WASTE assert Intent.from_string("WASTE") == Intent.WASTE assert Intent.from_string("unknown") == Intent.GENERAL # tests/unit/domain/test_chat_intent.py def test_chat_intent_immutable(): intent = ChatIntent.simple_waste(confidence=0.9) with pytest.raises(FrozenInstanceError): intent.confidence = 0.1 def test_needs_subagent(): simple = ChatIntent(Intent.WASTE, QueryComplexity.SIMPLE) complex = ChatIntent(Intent.WASTE, QueryComplexity.COMPLEX) assert simple.needs_subagent is False assert complex.needs_subagent is True
파일 구조 최종
apps/chat_worker/domain/ ├── __init__.py │ └── __all__ = [Intent, QueryComplexity, InputType, │ ChatIntent, HumanInputRequest, ...] ├── enums/ │ ├── __init__.py │ ├── intent.py # Intent: WASTE, CHARACTER, LOCATION, GENERAL │ ├── query_complexity.py # QueryComplexity: SIMPLE, COMPLEX │ └── input_type.py # InputType: LOCATION, CONFIRMATION, ... └── value_objects/ ├── __init__.py ├── chat_intent.py # ChatIntent (Intent + Complexity + Confidence) └── human_input.py # HumanInputRequest, HumanInputResponse, LocationData'이코에코(Eco²) > Agent' 카테고리의 다른 글
이코에코(Eco²) Agent #5: Checkpointer & State (0) 2026.01.13 이코에코(Eco²) Agent #4: Event Relay & SSE (0) 2026.01.13 이코에코(Eco²) Agent #3: Taskiq 기반 비동기 큐잉 시스템 (0) 2026.01.13 이코에코(Eco²) Agent #2: Subagent 기반 도메인 연동 (0) 2026.01.13 이코에코(Eco²) Agent #0: LangGraph 기반 클린 아키텍처 초안 (0) 2026.01.13