본문 바로가기
AI System/클린 아키텍처와 바이브 코딩을 활용한 AI Agent 시스템의 설계와 구

d02 - 9. 결과생성 파이프라인

by Toddler_AD 2026. 6. 23.

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"![chart]({p})" 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가 실제로 생성되고, 그 안에 계산된 총매출이 들어갔는지 확인합니다.

  1. FakeChart(더미 png 반환), FakeInsight(고정 문장 반환)를 만든다.
  2. 최소 AnalysisResult 하나로 GenerateReportUseCase(...).execute(result, "out")를 호출.
  3. 반환된 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 화면에 연결해, 사용자가 업로드→리포트까지 클릭으로 경험하게 만듭니다.