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

d01 - 4. AI Agent 구조 설계

by Toddler_AD 2026. 6. 22.

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 경로들

 

🔍 관찰 포인트 — 코드의 세 가지 설계 의도를 읽으세요.

  1. frozen=True(불변) — 한 번 만든 값은 바꿀 수 없습니다. 데이터가 처리 중간에 몰래 변질되는 일이 없어 디버깅이 쉽고, 안전하게 여기저기 넘길 수 있습니다.
  2. revenue, top_products는 저장 필드가 아니라 @property(계산) — 원본(수량·단가, by_product)에서 그때그때 계산합니다. 2교시에서 본 "단일 진실 원천" 원칙입니다.
  3. 이 파일 어디에도 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를 완성합니다.