9교시 · 결과 생성 파이프라인
2일차 10:00–11:00 · 이론/실습 선행: 7교시(분석), 8교시(차트), 4교시(InsightGateway 포트)
🎯 학습 목표
- 분석 결과를 LLM이 자연어로 해석하게 하는 어댑터를 구현한다.
- "숫자는 코드, 해석은 LLM"의 경계를 코드로 구현한다.
- 차트·요약·지표를 하나의 리포트로 조립하는 유스케이스를 만든다.
- API 키를 .env로 안전하게 다룬다.
📖 개념 1 — LLM에게 "계산"이 아니라 "해석"을 맡긴다
4교시에서 정한 대원칙을 이제 구현합니다.
숫자(총매출·상위 상품 등)는 코드가 계산하고, 그 확정된 숫자를 LLM에게 건네 "의미를 글로 풀어 달라"고 한다.
왜 이렇게 나눌까요?
| 만약 LLM에게 계산까지 시키면 | 우리 방식(코드가 계산) |
| 원본 CSV를 통째로 LLM에 보냄 | 계산된 요약 숫자만 보냄 |
| LLM이 산수를 틀릴 수 있음 | 숫자는 코드가 보장 → 항상 정확 |
| 토큰 비용↑(데이터가 큼) | 토큰 비용↓(요약은 작음) |
| 민감 원본이 외부로 나감 | 집계값만 나가 유출 위험↓ |
🔍 관찰 포인트 — LLM에 보내는 것은 "원본"이 아니라 "분석 결과"입니다. 프롬프트에는 매출 1000행이 아니라, "총매출 6,739,000원, 상위 상품: 노트북···" 같은 이미 계산된 요약만 들어갑니다. LLM은 이 정확한 숫자를 바탕으로 문장만 만듭니다. 즉, 틀릴 수 없는 부분(숫자)과 잘하는 부분(문장)을 각자에게 맡기는 설계입니다.
📖 개념 2 — "주입 가능한 클라이언트"로 테스트를 가능하게
LLM 호출은 네트워크·과금이 따릅니다. 테스트가 실제 OpenAI를 호출하면 느리고, 돈이 들고, 인터넷이 없으면 실패합니다. 그래서 어댑터를 만들 때 OpenAI 클라이언트를 생성자 인자로 주입받게 만듭니다(의존성 주입).
이렇게 하면:
- 실제 실행 시 → 진짜 OpenAI 클라이언트를 주입.
- 테스트 시 → 가짜(Fake) 클라이언트를 주입(정해진 답을 즉시 반환).
같은 어댑터가 환경에 따라 다른 클라이언트와 결합됩니다. 8교시까지 반복한 그 패턴(포트/주입)을 LLM에도 그대로 적용하는 것입니다.
🔴 Red — LLM 어댑터 테스트 (Fake 클라이언트)
tests/test_llm_gateway.py:
from datetime import date
from sales_agent.domain.models import AnalysisResult
from sales_agent.adapters.llm_gateway import OpenAiInsightGateway
class FakeChatCompletions:
"""OpenAI 클라이언트의 chat.completions.create(...)를 흉내 내는 가짜.
실제 네트워크 호출 대신, 들어온 프롬프트를 기록하고 고정된 응답을 돌려준다.
덕분에 테스트가 빠르고, 과금되지 않으며, 인터넷 없이도 동작한다.
"""
def __init__(self):
self.last_prompt = None # 마지막으로 전달된 사용자 프롬프트를 보관(검사용)
def create(self, model, messages, **kwargs):
# messages[-1]은 마지막 메시지(=user 프롬프트). 그 content를 저장해 둔다.
self.last_prompt = messages[-1]["content"]
# 아래 중첩 클래스들은 실제 OpenAI 응답 구조(resp.choices[0].message.content)를
# 똑같이 흉내 내기 위한 최소 껍데기다.
class _Msg:
content = "노트북이 전체 매출을 견인했습니다. 1월 매출이 가장 높았습니다."
class _Choice:
message = _Msg()
class _Resp:
choices = [_Choice()]
return _Resp()
class FakeClient:
"""OpenAI 클라이언트 모양(client.chat.completions.create)을 갖춘 가짜."""
def __init__(self):
# type("chat", (), {...})(): 'completions' 속성을 가진 익명 객체를 즉석 생성.
# 결과적으로 client.chat.completions.create(...) 경로를 그대로 흉내 낸다.
self.chat = type("chat", (), {"completions": FakeChatCompletions()})()
def make_result() -> AnalysisResult:
"""LLM 요약 입력으로 쓸 고정 분석 결과."""
return AnalysisResult(
total_revenue=7_200_000,
total_quantity=17,
period_start=date(2024, 1, 5),
period_end=date(2024, 2, 11),
by_product={"노트북": 7_000_000, "마우스": 200_000},
by_region={"서울": 7_000_000, "부산": 200_000},
by_month={"2024-01": 5_200_000, "2024-02": 2_000_000},
)
def test_summarize_returns_text():
"""요약은 비어 있지 않은 문자열을 반환한다."""
client = FakeClient() # 가짜 클라이언트 주입
gateway = OpenAiInsightGateway(client=client, model="gpt-4.1-mini")
summary = gateway.summarize(make_result())
assert isinstance(summary, str) # 반환 타입이 문자열인가
assert len(summary) > 0 # 빈 문자열이 아닌가
def test_prompt_contains_computed_numbers():
"""프롬프트에는 '코드가 계산한 숫자'가 담긴다(원본 CSV가 아니라)."""
client = FakeClient()
gateway = OpenAiInsightGateway(client=client, model="gpt-4.1-mini")
gateway.summarize(make_result())
# 가짜 클라이언트가 기록해 둔 '실제로 보낸 프롬프트'를 꺼내 검사한다.
sent = client.chat.completions.last_prompt
assert "7,200,000" in sent or "7200000" in sent # 총매출(계산값)이 프롬프트에 포함됐는가
assert "노트북" in sent # 상위 상품(계산값)이 포함됐는가
🔍 관찰 포인트 — 두 번째 테스트가 "설계 원칙"을 지킵니다. test_prompt_contains_computed_numbers는 단순한 동작 확인을 넘어, "LLM에 보내는 프롬프트에 계산된 숫자가 들어가는가" 를 검증합니다. 즉 "숫자는 코드가 계산해 넘긴다"는 4교시의 원칙이 코드에서 실제로 지켜지는지를 테스트로 못 박은 것입니다. 설계 의도를 테스트로 고정하면, 나중에 누가 실수로 원본을 통째로 보내도록 바꾸면 테스트가 깨져 알려 줍니다.
uv run pytest tests/test_llm_gateway.py -v # 🔴 실패 확인
🟢 Green — LLM 어댑터 구현
src/sales_agent/adapters/llm_gateway.py:
from __future__ import annotations
from sales_agent.domain.models import AnalysisResult
class OpenAiInsightGateway:
"""분석 결과를 LLM으로 요약하는 어댑터.
usecases.ports.InsightGateway 프로토콜(summarize 메서드)을 구현한다.
OpenAI 클라이언트를 '주입'받으므로, 테스트에서는 Fake 클라이언트로 갈아 끼워
네트워크·과금 없이 검증할 수 있다.
"""
def __init__(self, client, model: str = "gpt-4.1-mini") -> None:
"""client: chat.completions.create(...)를 가진 OpenAI 클라이언트(또는 Fake).
model: 사용할 모델 이름. 기본값을 '이 한 곳'에만 두어 교체 지점을 단일화한다.
"""
self._client = client
self._model = model
def summarize(self, result: AnalysisResult) -> str:
"""분석 결과(숫자)를 받아 LLM이 만든 한국어 요약 문장을 반환한다."""
prompt = self._build_prompt(result) # 계산된 숫자로 프롬프트 구성
response = self._client.chat.completions.create(
model=self._model,
messages=[
# system: LLM의 역할·제약을 규정. "주어진 숫자만 근거로" → 환각 억제.
{"role": "system", "content": "너는 매출 데이터를 해석하는 분석가다. "
"주어진 숫자만 근거로, 한국어로 3~4문장 요약하라."},
# user: 실제 분석 숫자가 담긴 프롬프트.
{"role": "user", "content": prompt},
],
)
# 응답 구조: response.choices[0].message.content. 앞뒤 공백은 제거한다.
return response.choices[0].message.content.strip()
@staticmethod
def _build_prompt(result: AnalysisResult) -> str:
"""'코드가 계산한 숫자'만으로 프롬프트를 만든다(원본 CSV는 절대 보내지 않는다).
f"{값:,.0f}" 포맷: 천 단위 콤마(,) + 소수점 0자리 정수 표기.
예) 7200000 → "7,200,000"
"""
# 상위 3개 상품을 "상품 금액원, ..." 형태 문자열로 연결.
top = ", ".join(f"{name} {value:,.0f}원" for name, value in result.top_products[:3])
# 월별 매출을 시간순으로 정렬해 "2024-01 금액원, ..." 형태로 연결.
months = ", ".join(f"{m} {v:,.0f}원" for m, v in sorted(result.by_month.items()))
return (
f"분석 기간: {result.period_start} ~ {result.period_end}\n"
f"총매출: {result.total_revenue:,.0f}원\n"
f"총수량: {result.total_quantity}개\n"
f"상위 상품: {top}\n"
f"월별 매출: {months}\n\n"
f"위 숫자를 근거로 핵심 인사이트를 요약해 줘."
)
🔍 관찰 포인트 — 모델 이름은 한 곳에만 있습니다. 모델 문자열 "gpt-4.1-mini"는 생성자 기본값 한 곳에만 있습니다. 나중에 다른 모델로 바꾸려면 이 한 줄(또는 주입 시 인자)만 고치면 됩니다. 기관 정책이나 비용에 따라 모델을 교체해도, 분석·리포트 로직은 전혀 건드리지 않습니다.
uv run pytest tests/test_llm_gateway.py -v # 🟢 통과 확인 (네트워크 호출 없음)
🟢 Green — 리포트 조립 유스케이스
이제 분석 결과 + 차트 + 요약을 하나의 리포트로 묶습니다. tests/test_generate_report.py:
from datetime import date
from pathlib import Path
from sales_agent.domain.models import AnalysisResult, Report
from sales_agent.usecases.generate_report import GenerateReportUseCase
class FakeChart:
"""ChartRenderer 포트를 만족하는 가짜. matplotlib 없이 더미 파일만 만든다."""
def render(self, result, out_dir):
p = Path(out_dir) / "chart.png"
p.parent.mkdir(parents=True, exist_ok=True) # 폴더 보장
p.write_bytes(b"img") # 내용이 아무거나 들어간 더미 png
return [str(p)] # 경로 목록 반환(실제 어댑터와 같은 모양)
class FakeInsight:
"""InsightGateway 포트를 만족하는 가짜. LLM 없이 고정 문장을 돌려준다."""
def summarize(self, result):
return "테스트 요약 인사이트"
def make_result() -> AnalysisResult:
"""리포트 조립에 넣을 최소한의 분석 결과."""
return AnalysisResult(
total_revenue=7_200_000, total_quantity=17,
period_start=date(2024, 1, 5), period_end=date(2024, 2, 11),
by_product={"노트북": 7_000_000}, by_region={"서울": 7_000_000},
by_month={"2024-01": 7_200_000},
)
def test_generate_report_assembles_all_parts(tmp_path):
"""리포트는 요약·차트·지표를 모두 담아 조립되고, 마크다운 파일로 저장된다."""
# 두 가짜 어댑터를 주입 → 네트워크·matplotlib 없이 '조립 로직'만 검증
usecase = GenerateReportUseCase(FakeChart(), FakeInsight())
report, md_path = usecase.execute(make_result(), str(tmp_path))
assert isinstance(report, Report) # 결과가 Report 객체인가
assert report.summary == "테스트 요약 인사이트" # 요약이 들어갔는가
assert len(report.chart_paths) == 1 # 차트 경로가 담겼는가
assert Path(md_path).exists() # 마크다운 리포트 파일이 생성됐는가
assert "총매출" in Path(md_path).read_text(encoding="utf-8") # 내용에 지표가 들어갔는가
src/sales_agent/usecases/generate_report.py:
from __future__ import annotations
from pathlib import Path
from sales_agent.domain.models import AnalysisResult, Report
from sales_agent.usecases.ports import ChartRenderer, InsightGateway # 추상(포트)만 import
class GenerateReportUseCase:
"""분석 결과를 차트·AI 요약과 함께 하나의 리포트로 조립하는 유스케이스.
스스로 차트를 그리거나 LLM을 부르지 않는다. 두 포트에게 '시키고' 결과만 조립한다.
"""
def __init__(self, chart_renderer: ChartRenderer, insight_gateway: InsightGateway) -> None:
# 두 포트(추상)에만 의존 → 진짜 어댑터든 Fake든 주입 가능.
self._charts = chart_renderer
self._insight = insight_gateway
def execute(self, result: AnalysisResult, out_dir: str) -> tuple[Report, str]:
"""차트 생성 → 요약 생성 → 리포트 객체 조립 → 마크다운 저장 순으로 진행한다.
반환: (조립된 Report 객체, 저장된 마크다운 파일 경로) 튜플.
"""
chart_paths = self._charts.render(result, out_dir) # 포트①: 차트 그리기 위임
summary = self._insight.summarize(result) # 포트②: AI 요약 위임
report = Report( # 결과들을 도메인 객체로 조립
title="매출 인사이트 리포트",
summary=summary,
result=result,
chart_paths=chart_paths,
)
md_path = self._write_markdown(report, out_dir) # 사람이 읽을 .md로 저장
return report, md_path
@staticmethod
def _write_markdown(report: Report, out_dir: str) -> str:
"""Report 객체를 마크다운 텍스트로 직렬화해 report.md로 저장한다."""
r = report.result
# 리포트 본문을 줄 단위 리스트로 구성한 뒤 마지막에 합친다(읽기 쉬움).
lines = [
f"# {report.title}",
"",
f"- 기간: {r.period_start} ~ {r.period_end}",
f"- 총매출: {r.total_revenue:,.0f}원",
f"- 총수량: {r.total_quantity}개",
"",
"## AI 인사이트",
report.summary,
"",
"## 차트",
# 차트 경로마다 마크다운 이미지 문법 을 한 줄씩 생성(*: 리스트 펼치기)
*[f"" for p in report.chart_paths],
]
path = Path(out_dir) / "report.md"
path.parent.mkdir(parents=True, exist_ok=True) # 출력 폴더 보장
path.write_text("\n".join(lines), encoding="utf-8") # 줄들을 개행으로 이어 UTF-8 저장
return str(path)
🔍 관찰 포인트 — 유스케이스가 "조율"만 합니다. GenerateReportUseCase는 차트를 직접 그리지도, LLM을 직접 부르지도 않습니다. 두 포트(ChartRenderer, InsightGateway)에게 시키고, 결과를 조립할 뿐입니다. 그래서 테스트에서 FakeChart·FakeInsight를 끼우면 네트워크·matplotlib 없이 조립 로직만 검증됩니다.
uv run pytest tests/test_generate_report.py -v # 🟢 통과 확인
💻 실습 — 실제 OpenAI 연결 (인프라 계층)
이제 "진짜 클라이언트"를 만드는 인프라 코드를 작성합니다. src/sales_agent/infra/openai_client.py:
from __future__ import annotations
import os # 환경 변수 접근
from dotenv import load_dotenv # .env 파일을 읽어 환경 변수로 올려 주는 라이브러리
from openai import OpenAI # 공식 OpenAI 클라이언트
def make_openai_client() -> OpenAI:
""".env에서 API 키를 읽어 OpenAI 클라이언트를 생성하는 '팩토리' 함수.
비밀값(API 키)을 다루는 코드는 가장 바깥(인프라 계층) 이 한 곳에만 둔다.
도메인·유스케이스·어댑터 어디에도 키가 등장하지 않아 유출 위험이 최소화된다.
"""
load_dotenv() # 프로젝트의 .env를 찾아 환경 변수로 로드
api_key = os.environ.get("OPENAI_API_KEY") # 환경 변수에서 키를 읽음(없으면 None)
if not api_key: # 키가 비어 있으면 명확한 안내와 함께 중단
raise RuntimeError("OPENAI_API_KEY가 설정되지 않았습니다. .env를 확인하세요.")
return OpenAI(api_key=api_key) # 키를 넣어 실제 클라이언트 생성·반환
.env 만들기 (절대 git에 올리지 않습니다)
VS Code에서 프로젝트 루트에 .env 파일을 만들고 본인 키를 적습니다.
OPENAI_API_KEY=sk-여기에-본인-키
⚠️ .env는 1교시에서 이미 .gitignore에 넣었습니다. 커밋되지 않는지 반드시 확인하세요. PowerShell로 git 추적 여부를 점검할 수 있습니다.
git status # .env가 'Untracked'/목록에 없으면 정상(무시되고 있음)
🔍 관찰 포인트 — 키를 읽는 곳은 인프라(4계층) 한 곳뿐입니다. os.environ이나 .env를 다루는 코드는 infra/openai_client.py에만 있습니다. 도메인·유스케이스·어댑터 어디에도 키가 등장하지 않습니다. 비밀값을 가장 바깥 한 곳에 가둬, 유출 위험과 관리 지점을 최소화한 것입니다.
🔵 통합 확인 — 전체 파이프라인 한 번 돌리기
지금까지의 부품을 손으로 조립해, CSV부터 리포트까지 한 흐름을 실제로 돌려 봅니다. (실제 OpenAI를 호출하므로 소량 과금이 있을 수 있습니다.)
@'
from sales_agent.adapters.csv_repository import CsvSalesRepository
from sales_agent.usecases.analyze_sales import AnalyzeSalesUseCase
from sales_agent.adapters.chart_renderer import MatplotlibChartRenderer
from sales_agent.adapters.llm_gateway import OpenAiInsightGateway
from sales_agent.usecases.generate_report import GenerateReportUseCase
from sales_agent.infra.openai_client import make_openai_client
# 1) 적재 → 2) 분석
result = AnalyzeSalesUseCase(CsvSalesRepository("sample_data/sales_sample.csv")).execute()
# 3) 차트 어댑터 + LLM 어댑터(진짜 클라이언트 주입)
charts = MatplotlibChartRenderer()
insight = OpenAiInsightGateway(client=make_openai_client(), model="gpt-4.1-mini")
# 4) 리포트 조립
report, md_path = GenerateReportUseCase(charts, insight).execute(result, "outputs/report")
print("리포트 생성:", md_path)
print("AI 요약:", report.summary)
'@ | uv run python -
outputs/report/report.md가 생성되고, AI 요약 문장이 출력되면 파이프라인 완성입니다.
🔍 관찰 포인트 — 이 스크립트가 곧 10교시 app.py가 할 일입니다. 여기서 부품들을 손으로 연결한 코드가, 다음 교시에서 Streamlit 화면 뒤에 그대로 들어갑니다. 즉, 화면은 "이 조립 과정을 사용자 대신 실행해 주는 껍데기"일 뿐입니다.
uv run pytest -v # 전체 회귀 확인 (테스트는 여전히 네트워크 없이 통과)
✏️ 미니 실습 — 리포트 유스케이스 단독 검증
진짜 matplotlib·OpenAI 없이, Fake 차트·Fake LLM만으로 GenerateReportUseCase를 호출해 보세요. report.md가 실제로 생성되고, 그 안에 계산된 총매출이 들어갔는지 확인합니다.
- FakeChart(더미 png 반환), FakeInsight(고정 문장 반환)를 만든다.
- 최소 AnalysisResult 하나로 GenerateReportUseCase(...).execute(result, "out")를 호출.
- 반환된 report.md를 열어 총매출 줄을 찾아 기록.
🔍 관찰 포인트: 리포트 유스케이스는 차트·LLM 포트만 알기에, 진짜 외부 도구 없이 조립 로직을 검증할 수 있습니다. 9교시 통합 테스트가 바로 이 구조 덕분에 가능합니다.
🔒 진행 게이트
- 제출물(기록): 생성된 report.md 경로 + 그 안의 총매출 줄(예: - 총매출: 7,200,000원).
- 이 결과가 있어야 다음 교시로 넘어갑니다.
- 정답·해설(별도 파일): solutions/미니실습정답_09.md
✅ 체크포인트
- [ ] LLM 어댑터가 Fake 클라이언트로 테스트된다(네트워크 0회)
- [ ] 프롬프트에 "계산된 숫자"가 들어가는 것을 테스트로 확인했다
- [ ] 리포트 유스케이스가 요약·차트·지표를 조립한다
- [ ] .env로 키를 읽고, git에 추적되지 않는다
- [ ] 실제 파이프라인이 report.md를 생성한다
🛠️ 트러블슈팅
| 증상 | 원인 | 해결 |
| OPENAI_API_KEY가 설정되지 않았습니다 | .env 미생성/오타 | 루트에 .env, 키 이름 정확히 |
| AuthenticationError | 키 오류/만료 | OpenAI 대시보드에서 키·크레딧 확인 |
| .env가 git에 잡힘 | gitignore 누락 | .gitignore에 .env, 추적 중이면 git rm --cached .env |
| 요약이 숫자를 지어냄 | 원본을 보냄 | 프롬프트에 "주어진 숫자만 근거로" 명시 확인 |
| here-string 오류 | '@ 들여쓰기 | '@는 줄 맨 앞에 |
🔑 핵심 정리
- 숫자는 코드, 해석은 LLM — 계산된 요약만 프롬프트로 보내 정확성·비용·보안을 모두 잡았다.
- 클라이언트를 주입해 LLM 어댑터를 네트워크 없이 테스트했다.
- 리포트 유스케이스는 포트만 알고 조율하며, 비밀값은 인프라 한 곳에 가뒀다.
다음 교시: 이 파이프라인을 Streamlit 화면에 연결해, 사용자가 업로드→리포트까지 클릭으로 경험하게 만듭니다.
'AI System > 클린 아키텍처와 바이브 코딩을 활용한 AI Agent 시스템의 설계와 구' 카테고리의 다른 글
| d02 - 11. 구조안정화 (0) | 2026.06.23 |
|---|---|
| d02 - 10. 웹 인터페이스 연결 (0) | 2026.06.23 |
| d02 - 8. 시각화와 출력 설계 (1) | 2026.06.23 |
| d01 - 클린 아키텍처 개념과 이점 (0) | 2026.06.22 |
| d01 - 7. 분석 로직 구현 (0) | 2026.06.22 |