4교시 · AI Agent 구조 설계
1일차 14:00–15:00 · 이론/실습 선행: 3교시(4계층·의존성 규칙·DIP)
🎯 학습 목표
- "AI Agent"가 이 프로젝트에서 구체적으로 무엇을 뜻하는지 정의한다.
- 에이전트의 데이터 흐름과 의존 관계를 구분해서 도식으로 그린다.
- 도메인 엔티티(SalesRecord, AnalysisResult, Report)와 포트의 뼈대(skeleton) 를 코드로 작성한다. (검증 로직의 정식 구현은 5·7·9교시 TDD에서)
📖 개념 1 — 여기서 말하는 "Agent"란 무엇인가
"AI Agent"라는 말은 넓게 쓰이지만, 이 과정에서는 다음과 같이 구체적으로 정의합니다.
입력을 인지하고, 정해진 단계를 스스로 이어서 수행해, 최종 산출물을 만들어 내는 자동화된 처리 흐름. 그 중 "결과를 해석해 사람이 읽을 인사이트로 바꾸는 추론 단계" 를 LLM이 담당한다.
우리 에이전트의 단계는 이렇습니다.
입력 인지 → 데이터 정리 → 분석(계산) → 도구 사용(차트) → AI 추론(해석) → 결과 산출
여기서 가장 중요한 설계 결정이 있습니다. 역할 분담입니다.
| 단계 | 담당 | 이유 |
| 총매출·상위 상품 같은 숫자 계산 | 결정적 코드(유스케이스) | 코드는 같은 입력에 항상 같은 답을 낸다. 정확함 |
| "노트북이 매출을 견인했다" 같은 자연어 해석 | LLM(어댑터) | 자연어 통찰은 LLM이 잘한다 |
🔍 관찰 포인트 — 계산을 LLM에게 시키지 않습니다. LLM은 그럴듯한 문장을 잘 만들지만, 산수를 자주 틀립니다. "총매출이 얼마인가"를 LLM에게 물으면 틀린 숫자를 자신 있게 답할 수 있습니다. 그래서 우리는 숫자는 코드가 계산하고, 그 확정된 숫자를 LLM에게 건네 "해석만" 시킵니다. 이 경계가 곧 계층 경계와 일치합니다(유스케이스=계산, LLM 어댑터=해석). 이것이 "신뢰할 수 있는 AI 시스템"의 핵심 설계입니다.
📖 개념 2 — 두 가지 도식을 구분하기 (데이터 흐름 vs 의존 관계)
같은 시스템을 두 가지 관점으로 그릴 수 있습니다. 이 둘을 섞으면 혼란스러우니 명확히 구분합니다.
(A) 데이터 흐름 도식 — "값이 어디로 가는가"
실행 중에 데이터가 이동하는 순서입니다. 화살표는 "데이터가 이쪽으로 흘러간다"는 뜻입니다.
[CSV 파일]
│ (파일 경로)
▼
CsvSalesRepository ──(list[SalesRecord])──▶ AnalyzeSalesUseCase
│ (AnalysisResult)
▼
┌──────────────────────────────────────┐
│ GenerateReportUseCase │
│ AnalysisResult를 받아 아래 둘을 호출 │
└──────────────────────────────────────┘
│ │
(AnalysisResult) ▼ ▼ (AnalysisResult)
MatplotlibChartRenderer OpenAiInsightGateway
│ (이미지 파일 경로) │ (요약 텍스트)
└───────────┬───────────────────┘
▼
[Report] ──▶ report.md (다운로드)
위 화살표는 데이터 흐름입니다. "CSV에서 읽은 레코드가 분석으로, 분석 결과가 차트·요약으로 흘러간다"는 뜻입니다.
(B) 의존 관계 도식 — "누가 누구를 아는가"
코드가 서로를 import 하는 방향입니다. 화살표는 3교시 규약대로 "왼쪽이 오른쪽을 안다(의존한다)"는 뜻입니다.
app.py ──▶ AnalyzeSalesUseCase ──▶ SalesRepository(포트) ◀── CsvSalesRepository
│ ▲
└──▶ GenerateReportUseCase ──▶ ChartRenderer(포트) ◀── MatplotlibChartRenderer
└─▶ InsightGateway(포트) ◀── OpenAiInsightGateway
핵심: 유스케이스는 '포트(추상)'를 향해 의존하고(──▶),
구체 어댑터들은 그 포트를 '구현'한다(◀──, 즉 어댑터가 포트에 의존).
그래서 유스케이스는 구체 어댑터의 이름을 모른다.
🔍 관찰 포인트 — 데이터는 어댑터→유스케이스로 흐르지만, 의존(코드 import)은 어댑터→포트(안쪽)로 향합니다. (A)에서 데이터는 CsvSalesRepository → AnalyzeSalesUseCase로 흐릅니다. 하지만 (B)에서 의존 화살표는 CsvSalesRepository → SalesRepository(포트)로, 즉 바깥(어댑터)이 안쪽(포트)을 향합니다. "데이터 흐름 방향"과 "의존 방향"이 다를 수 있다는 것이 클린 아키텍처의 묘미이자, 의존성 역전(DIP)이 하는 일입니다.
💻 실습 1 — 도메인 엔티티 뼈대 작성
⚠️ 지금은 구조만 잡습니다. 검증 규칙의 정식 구현과 테스트는 5교시 TDD에서 합니다. 여기서는 "어떤 데이터가 존재하는가"를 코드로 못 박는 것이 목표입니다.
VS Code에서 src/sales_agent/domain/errors.py를 만듭니다.
class DomainError(Exception):
"""도메인 계층에서 발생하는 모든 규칙 위반의 '부모(최상위)' 예외.
이 클래스를 따로 두는 이유:
- 바깥 계층(어댑터·화면)에서 `except DomainError:` 한 줄로
"도메인에서 난 모든 규칙 위반"을 한꺼번에 잡을 수 있다.
- 파이썬 기본 예외(ValueError 등)와 구분되어, '우리 도메인이 의도적으로
던진 오류'인지 '예상 못 한 버그'인지 분별할 수 있다.
"""
class InvalidSalesRecordError(DomainError):
"""매출 레코드(SalesRecord)가 도메인 규칙을 어겼을 때 발생하는 예외.
예) 수량이 0 이하, 단가가 0 이하, 상품명이 비어 있음.
DomainError를 상속하므로, 위에서 설명한 일괄 처리 대상에 포함된다.
"""
다음으로 src/sales_agent/domain/models.py:
# from __future__ import annotations:
# 타입 힌트를 '문자열처럼' 지연 평가하게 해, 위/아래 정의 순서에 덜 민감해지고
# list[str] 같은 최신 표기를 더 폭넓은 버전에서 쓸 수 있게 해 준다.
from __future__ import annotations
# dataclass: 반복되는 __init__/__eq__/__repr__를 자동 생성해 주는 데코레이터.
# field: 기본값으로 '매번 새 객체'(예: 빈 dict)를 만들어야 할 때 사용한다.
from dataclasses import dataclass, field
from datetime import date # 날짜 타입(연-월-일). datetime이 아니라 date를 쓴다.
# 도메인 내부의 다른 모듈(errors)만 import 한다. 외부 라이브러리는 절대 import 하지 않는다.
from sales_agent.domain.errors import InvalidSalesRecordError
@dataclass(frozen=True) # frozen=True → 생성 후 필드 수정 불가(불변 값 객체)
class SalesRecord:
"""매출 한 건(거래 1행)을 표현하는 불변 값 객체.
'값 객체(Value Object)'란 식별자(id) 없이 '값 그 자체'로 동등성을 판단하는 객체다.
같은 날짜·상품·지역·수량·단가면 같은 레코드로 취급된다(== 비교가 True).
필드:
date: 거래 일자
product: 상품명(빈 문자열 불가)
region: 지역명
quantity: 판매 수량(0보다 커야 함)
unit_price: 단가(0보다 커야 함)
"""
date: date # 거래 일자
product: str # 상품명
region: str # 지역명
quantity: int # 판매 수량 (> 0)
unit_price: float # 단가 (> 0)
def __post_init__(self) -> None:
"""dataclass가 모든 필드를 채운 '직후' 자동 호출되는 검증 훅(hook).
여기서 도메인 규칙을 어긴 값이면 예외를 던져, '잘못된 레코드 객체 자체가
애초에 만들어지지 못하게' 막는다. 즉 SalesRecord가 존재한다 = 이미 유효하다.
(정식 검증 규칙과 테스트는 5교시 TDD에서 채운다.)
"""
if self.quantity <= 0: # 수량은 양수여야 한다
raise InvalidSalesRecordError("quantity must be > 0")
if self.unit_price <= 0: # 단가는 양수여야 한다
raise InvalidSalesRecordError("unit_price must be > 0")
if not self.product.strip(): # 공백만 있는 상품명도 '비어 있음'으로 본다
raise InvalidSalesRecordError("product must not be empty")
@property
def revenue(self) -> float:
"""매출액(= 수량 × 단가). 저장 필드가 아니라 '필요할 때 계산'하는 값.
별도 컬럼으로 저장하지 않는 이유: 수량/단가가 바뀌면 매출액도 따로 고쳐야 하고
깜빡하면 데이터가 어긋난다. 원본 두 값에서 매번 계산하면 항상 일관된다
(2교시의 '단일 진실 원천' 원칙).
"""
return self.quantity * self.unit_price
@dataclass(frozen=True)
class AnalysisResult:
"""분석 산출물을 담는 불변 객체. 7교시의 분석 유스케이스가 값을 채운다.
필드:
total_revenue: 전체 매출 합계
total_quantity: 전체 판매 수량 합계
period_start / period_end: 데이터의 가장 이른/늦은 날짜
by_product / by_region / by_month: 각 기준별 매출 합계 사전(dict)
"""
total_revenue: float
total_quantity: int
period_start: date
period_end: date
# field(default_factory=dict): 인스턴스마다 '새로운 빈 dict'를 기본값으로 준다.
# (그냥 = {} 로 쓰면 모든 인스턴스가 같은 dict를 공유하는 고전적 버그가 생긴다.)
by_product: dict[str, float] = field(default_factory=dict) # 상품명 → 매출 합계
by_region: dict[str, float] = field(default_factory=dict) # 지역명 → 매출 합계
by_month: dict[str, float] = field(default_factory=dict) # "YYYY-MM" → 매출 합계
@property
def top_products(self) -> list[tuple[str, float]]:
"""상품별 매출을 '매출 내림차순'으로 정렬한 (상품명, 매출) 목록을 돌려준다.
sorted(...)의 key=lambda kv: kv[1] → 각 (상품명, 매출) 쌍에서 '매출(index 1)'을
정렬 기준으로 삼는다. reverse=True → 큰 값이 앞으로(내림차순).
"""
return sorted(self.by_product.items(), key=lambda kv: kv[1], reverse=True)
@dataclass(frozen=True)
class Report:
"""사용자에게 최종 제공되는 리포트. 9교시의 리포트 유스케이스가 조립한다.
필드:
title: 리포트 제목
summary: LLM이 생성한 자연어 인사이트
result: 위 분석 결과(숫자) 묶음
chart_paths: 생성된 차트 이미지 파일 경로 목록
"""
title: str
summary: str # LLM이 생성한 인사이트(자연어 문장)
result: AnalysisResult # 숫자 분석 결과(차트·표시에 재사용)
chart_paths: list[str] = field(default_factory=list) # 차트 png 경로들
🔍 관찰 포인트 — 코드의 세 가지 설계 의도를 읽으세요.
- frozen=True(불변) — 한 번 만든 값은 바꿀 수 없습니다. 데이터가 처리 중간에 몰래 변질되는 일이 없어 디버깅이 쉽고, 안전하게 여기저기 넘길 수 있습니다.
- revenue, top_products는 저장 필드가 아니라 @property(계산) — 원본(수량·단가, by_product)에서 그때그때 계산합니다. 2교시에서 본 "단일 진실 원천" 원칙입니다.
- 이 파일 어디에도 pandas, openai, streamlit이 없습니다. 표준 라이브러리(dataclasses, datetime)만 씁니다 — 3교시의 도메인 규칙을 지킨 것입니다.
💻 실습 2 — 유스케이스 포트(약속) 뼈대
src/sales_agent/usecases/ports.py를 만듭니다. 3교시에서 배운 "안쪽이 정의하는 약속"입니다.
from __future__ import annotations
# Protocol: '이런 메서드를 가진 것이면 무엇이든 이 타입으로 인정'하는 구조적 타이핑 도구.
# 어댑터가 이 클래스를 명시적으로 상속하지 않아도, 같은 모양의 메서드만 있으면 만족한다.
from typing import Protocol
from sales_agent.domain.models import AnalysisResult, SalesRecord
class SalesRepository(Protocol):
"""매출 레코드를 '어딘가에서' 적재한다는 약속(포트).
'어딘가'가 CSV인지 DB인지 API인지는 이 약속이 신경 쓰지 않는다.
실제 구현(예: CsvSalesRepository)은 바깥의 어댑터 계층이 담당한다.
"""
# `...`(Ellipsis)는 '본문 없음'을 뜻한다. 약속(시그니처)만 정의하고 구현은 비운다.
def load(self) -> list[SalesRecord]:
"""매출 레코드 목록을 반환한다."""
...
class ChartRenderer(Protocol):
"""분석 결과로 차트 이미지를 만든다는 약속(포트)."""
def render(self, result: AnalysisResult, out_dir: str) -> list[str]:
"""분석 결과를 out_dir에 차트로 그리고, 생성된 이미지 경로 목록을 반환한다."""
...
class InsightGateway(Protocol):
"""분석 결과를 자연어 인사이트로 요약한다는 약속(포트, LLM 담당)."""
def summarize(self, result: AnalysisResult) -> str:
"""분석 결과를 받아 한국어 요약 문장을 반환한다."""
...
🔍 관찰 포인트 — 포트가 "유스케이스 계층"에 있는 이유. 포트(약속)는 어댑터 폴더가 아니라 usecases/ 폴더에 둡니다. "약속을 정의하는 주체는 그 기능이 필요한 안쪽(유스케이스)"이기 때문입니다. 바깥(어댑터)은 이 약속을 가져다 구현할 뿐입니다. 이 배치 자체가 의존성 역전을 코드 위치로 표현한 것입니다.
Protocol은 "이런 메서드를 가진 것이면 무엇이든 이 타입으로 인정한다"는 파이썬의 구조적 타이핑 도구입니다. 어댑터가 SalesRepository를 명시적으로 상속하지 않아도, load() 메서드만 가지면 자동으로 약속을 만족합니다(덕 타이핑).
✏️ 미니 실습 — 도메인이 외부와 독립인지 즉시 확인
PowerShell에서 도메인 모듈만 단독으로 불러와 봅니다.
uv run python -c "import sales_agent.domain.models; print('domain import OK')"
domain import OK가 출력되면, 도메인이 외부 라이브러리 없이 단독으로 로딩된다는 뜻입니다. 만약 도메인이 실수로 pandas를 import 했다면, pandas 관련 오류가 났을 것입니다.
✅ 직접 점검: domain/ 폴더의 파일들을 열어 import pandas·import openai·import streamlit·import matplotlib가 있는지 눈으로 확인하세요. 표준 라이브러리(dataclasses, datetime)와 도메인 내부 모듈(errors) 외에는 아무것도 import 하지 않아야 합니다. (11교시에서 이 점검을 자동 테스트로 만듭니다.)
🔒 진행 게이트
- 제출물(기록): domain import OK 출력.
- 이 결과가 있어야 다음 교시로 넘어갑니다.
- 정답·해설(별도 파일): solutions/미니실습정답_04.md
✅ 체크포인트
- [ ] "숫자는 코드, 해석은 LLM" 역할 분담을 이유와 함께 설명할 수 있다
- [ ] 데이터 흐름 도식(A)과 의존 관계 도식(B)의 차이를 말할 수 있다
- [ ] domain/models.py에 세 엔티티 뼈대가 있고 외부 import가 없다
- [ ] usecases/ports.py에 세 포트가 정의돼 있다
- [ ] domain import OK가 출력된다
🛠️ 트러블슈팅
| 증상 | 해결 |
| ModuleNotFoundError: No module named 'sales_agent' | 패키지가 설치되지 않음. pythonpath는 pytest에만 적용됩니다. → 1교시처럼 pyproject.toml에 [build-system]을 넣고 uv sync 실행. 급할 땐 $env:PYTHONPATH="src" 후 재실행 |
| frozen 객체 수정 시 오류 | 의도된 동작(불변). 값을 바꾸려면 새 객체를 만들어 교체 |
| Protocol에 빨간 줄 | 파이썬 3.8+ 필요. 우리는 3.12라 정상. import 경로 확인 |
| import OK가 아니라 pandas 오류 | 도메인에 실수로 외부 import가 들어감. 제거 |
🔑 핵심 정리
- 이 에이전트는 "계산은 코드, 해석은 LLM" 으로 역할이 나뉜 자동 처리 흐름이다.
- 데이터 흐름(값의 이동) 과 의존 관계(코드의 import 방향) 는 다른 도식이며, 후자가 안쪽을 향하는 것이 클린 아키텍처다.
- 도메인 엔티티와 포트의 뼈대를 외부 의존 없이 세웠다.
다음 교시: 이 뼈대를 대상으로 첫 번째 TDD 사이클(Red→Green→Refactor) 을 직접 손으로 돌려, SalesRecord를 완성합니다.
'AI System > 클린 아키텍처와 바이브 코딩을 활용한 AI Agent 시스템의 설계와 구' 카테고리의 다른 글
| d01 - 6. 데이터 처리 로직 구현 (0) | 2026.06.22 |
|---|---|
| d01 - 5. 첫번째 TDD 사이클 (0) | 2026.06.22 |
| d01 - 3. 클린 아키텍처 핵심 이해 (0) | 2026.06.22 |
| d01 - 2. 문제정의와 구현전략 (0) | 2026.06.22 |
| d01 - 1. 개발환경 구축 (0) | 2026.06.22 |