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

d02 - 13. 바이브 코딩으로 빠른 확장

by Toddler_AD 2026. 6. 23.

13교시 · 바이브 코딩으로 빠른 확장

2일차 15:00–16:00 · 이론/실습 선행: 12교시(Copilot 규칙·프롬프트 세팅)


🎯 학습 목표

  • 완성된 프로젝트에 새 기능을 바이브 코딩 + TDD로 빠르게 추가한다.
  • 클린 아키텍처가 확장을 얼마나 안전하게 만드는지(기존 코드를 안 건드림) 직접 확인한다.
  • "포트만 맞추면 부품을 갈아 끼울 수 있다"는 원리를 새 데이터 출처 교체로 체감한다.

📖 개념 — 확장이 두렵지 않은 이유

보통 "기능 추가"는 두렵습니다. 한 곳을 고치면 다른 곳이 깨질지 모르기 때문입니다. 하지만 우리 프로젝트는 두 가지 안전장치가 있습니다.

  1. 계층 분리 — 새 기능은 보통 한 계층에만 영향을 줍니다(예: 새 지표는 도메인/유스케이스, 새 출처는 어댑터).
  2. 테스트 — 기존 기능이 깨지면 pytest가 즉시 알려 줍니다(회귀 방지).

여기에 12교시에서 만든 규칙·프롬프트가 더해지면, 새 기능을 빠르고 안전하게 붙일 수 있습니다. 이 교시에서 두 가지 확장을 해 봅니다.

확장 닿는 계층 보여 주는 것
A. 새 분석 지표 "최고 매출 월" domain TDD로 안쪽 기능 추가
B. 새 데이터 출처 "JSON 적재" adapter만 포트로 부품 교체(기존 코드 불변)

💻 확장 A — 새 분석 지표 "최고 매출 월"

"매출이 가장 높은 달(peak_month)"을 돌려주는 지표를 추가합니다. 같은 TDD 루프를, 이번엔 바이브 코딩으로 빠르게 돌립니다.

① Red — /generate-test

Copilot Chat에서:

/generate-test
AnalysisResult에 peak_month 속성을 추가하려 한다.
by_month(월→매출 dict)에서 매출이 가장 높은 월("YYYY-MM")을 돌려준다.
by_month가 비어 있으면 None을 반환한다. 검증 테스트를 tests/test_models.py에 추가해 줘.

 

생성될 테스트(직접 확인·수정):

from datetime import date
from sales_agent.domain.models import AnalysisResult


def _result(by_month):
    """최고 매출 월 검증용 최소 AnalysisResult 생성 헬퍼(by_month만 다르게)."""
    return AnalysisResult(
        total_revenue=sum(by_month.values()),
        total_quantity=1,
        period_start=date(2024, 1, 1),
        period_end=date(2024, 12, 31),
        by_month=by_month,
    )


def test_peak_month():
    """매출이 가장 높은 월(YYYY-MM)을 돌려준다."""
    result = _result({"2024-01": 5_200_000, "2024-02": 2_000_000})
    assert result.peak_month == "2024-01"        # 1월이 가장 높다


def test_peak_month_empty():
    """월별 데이터가 없으면(빈 dict) None을 돌려준다(예외 대신 안전값)."""
    assert _result({}).peak_month is None

 

🔍 관찰 포인트: 프롬프트에 경계 케이스("비어 있으면 None")를 명시했습니다. 12교시에서 익혔듯, 모호하게 맡기면 빈 데이터에서 오류가 숨을 수 있습니다. 사람이 "무엇을 검증할지"를 또렷이 지시하는 것이 핵심입니다.

uv run pytest tests/test_models.py -v   # 🔴 peak_month 관련 실패 확인

② Green — /implement-feature

/implement-feature
AnalysisResult에 peak_month 속성을 추가해 위 테스트를 통과시켜 줘.
저장 필드가 아니라 @property로 계산하고, by_month가 비면 None을 반환한다.

 

src/sales_agent/domain/models.py의 AnalysisResult에 추가될 코드:

    @property
    def peak_month(self) -> str | None:
        """매출이 가장 높은 월("YYYY-MM")을 돌려준다. 데이터가 없으면 None.

        max(self.by_month, key=self.by_month.get): by_month의 키(월) 중
        값(매출)이 가장 큰 키를 고른다(argmax). 빈 dict이면 None을 반환한다.
        저장 필드가 아니라 매번 계산하는 파생값이다(단일 진실 원천).
        """
        if not self.by_month:          # 빈 데이터 경계 처리
            return None
        return max(self.by_month, key=self.by_month.get)

 

🔍 관찰 포인트 — 새 지표가 도메인 안쪽에만 닿았습니다. 이 변경은 domain/models.py 한 곳뿐입니다. CSV 어댑터도, 웹 화면도 건드리지 않았습니다. 그리고 @property로 계산하므로 저장·동기화 문제도 없습니다(2교시 단일 진실 원천 원칙). 화면에 보여 주고 싶다면 10교시 app.py에 st.metric("최고 매출 월", result.peak_month or "-") 한 줄만 더하면 됩니다.

uv run pytest -v   # 🟢 통과 + 기존 테스트 회귀 없음 확인

💻 확장 B — 새 데이터 출처 "JSON 적재" (부품 교체)

이번엔 더 강력한 확장입니다. "매출 데이터가 CSV가 아니라 JSON으로 온다면?" 클린 아키텍처에서는 새 어댑터 하나만 추가하면 됩니다 — 분석·리포트·웹 코드는 한 줄도 바뀌지 않습니다.

① Red — /generate-test

/generate-test
JsonSalesRepository를 만들려 한다. SalesRepository 포트를 구현하며,
JSON 배열 파일([{date, product, region, quantity, unit_price}, ...])을 읽어
list[SalesRecord]를 반환한다. 잘못된 항목은 CsvSalesRepository처럼 skipped에 기록한다.
정상 적재 + 잘못된 수량 제외 테스트를 tests/test_json_repository.py에 만들어 줘.
임시 파일은 tmp_path를 쓴다.

 

생성될 테스트(직접 확인):

import json
from pathlib import Path

from sales_agent.adapters.json_repository import JsonSalesRepository


def _write_json(tmp_path: Path, rows: list[dict]) -> str:
    """주어진 항목들을 임시 JSON 파일로 쓰고 경로를 반환한다."""
    p = tmp_path / "sales.json"
    p.write_text(json.dumps(rows, ensure_ascii=False), encoding="utf-8")
    return str(p)


def test_loads_valid_json(tmp_path):
    """정상 JSON 항목은 SalesRecord로 적재된다."""
    path = _write_json(tmp_path, [
        {"date": "2024-01-05", "product": "노트북", "region": "서울",
         "quantity": 3, "unit_price": 1200000},
    ])
    records = JsonSalesRepository(path).load()
    assert len(records) == 1
    assert records[0].revenue == 3_600_000


def test_skips_invalid_quantity(tmp_path):
    """수량이 0 이하인 항목은 제외되고 사유가 기록된다."""
    repo = JsonSalesRepository(_write_json(tmp_path, [
        {"date": "2024-01-05", "product": "노트북", "region": "서울",
         "quantity": 3, "unit_price": 1200000},
        {"date": "2024-01-06", "product": "마우스", "region": "부산",
         "quantity": 0, "unit_price": 25000},     # 수량 0 → 제외
    ]))
    records = repo.load()
    assert len(records) == 1
    assert len(repo.skipped) == 1
uv run pytest tests/test_json_repository.py -v   # 🔴 실패 확인

② Green — /implement-feature

/implement-feature
위 테스트를 통과시키는 JsonSalesRepository를
src/sales_agent/adapters/json_repository.py에 구현해 줘.
SalesRepository 포트를 만족하고, CsvSalesRepository와 같은 skipped 패턴을 쓴다.

 

src/sales_agent/adapters/json_repository.py:

from __future__ import annotations

import json
from datetime import datetime

from sales_agent.adapters.csv_repository import SkippedRow  # 제외 보고 구조 재사용
from sales_agent.domain.errors import InvalidSalesRecordError
from sales_agent.domain.models import SalesRecord


class JsonSalesRepository:
    """JSON 배열 파일에서 매출 레코드를 적재하는 어댑터.

    usecases.ports.SalesRepository 프로토콜을 구현한다(CsvSalesRepository와 형제).
    """

    def __init__(self, path: str) -> None:
        self._path = path
        self.skipped: list[SkippedRow] = []

    def load(self) -> list[SalesRecord]:
        """JSON을 읽어 유효한 SalesRecord 목록을 반환한다."""
        with open(self._path, encoding="utf-8") as f:
            rows = json.load(f)                  # JSON 배열 → 파이썬 리스트(dict들)

        self.skipped = []
        records: list[SalesRecord] = []
        for i, row in enumerate(rows):           # i: 0기반 항목 순번
            try:
                records.append(self._to_record(row))
            except (InvalidSalesRecordError, ValueError, KeyError) as e:
                # 항목 번호는 사람이 보기 쉽게 1부터 센다.
                self.skipped.append(SkippedRow(line=i + 1, reason=str(e)))
        return records

    @staticmethod
    def _to_record(row: dict) -> SalesRecord:
        """JSON 항목(dict) 하나를 검증·변환해 SalesRecord로 만든다."""
        return SalesRecord(
            date=datetime.strptime(str(row["date"]), "%Y-%m-%d").date(),
            product=str(row["product"]).strip(),
            region=str(row["region"]).strip(),
            quantity=int(row["quantity"]),
            unit_price=float(row["unit_price"]),
        )

 

🔍 관찰 포인트 — 기존 코드를 한 줄도 바꾸지 않았습니다. AnalyzeSalesUseCase, GenerateReportUseCase, 차트·LLM 어댑터, 그리고 테스트들… 전부 그대로입니다. 새 파일 하나(json_repository.py)만 추가했을 뿐입니다. 분석 유스케이스는 "CSV냐 JSON이냐"를 모른 채, 오직 SalesRepository 포트만 알기 때문입니다. 이것이 4교시·7교시에서 본 의존성 역전의 실전 보상입니다.

uv run pytest -v   # 🟢 신규 통과 + 기존 전체 회귀 없음

③ 조립만 교체 (사용처)

실제로 JSON 출처를 쓰려면 조립 지점(10교시 app.py 또는 직접 호출)에서 클래스 이름만 바꿔 끼우면 됩니다.

# 기존: CSV에서 적재
# repo = CsvSalesRepository("sample_data/sales_sample.csv")

# 변경: JSON에서 적재 (이 한 줄만 교체, 나머지 파이프라인 동일)
repo = JsonSalesRepository("sample_data/sales_sample.json")

result = AnalyzeSalesUseCase(repo).execute()   # 분석 코드는 그대로 동작

 

🔍 관찰 포인트: "부품을 갈아 끼운다"는 말이 추상적 비유가 아니라 실제 코드 한 줄 교체임을 확인했습니다. DB·API 출처도 같은 방식으로 어댑터만 추가하면 됩니다.


✏️ 미니 실습 — 스스로 확장 하나 더

다음 중 하나를 골라, 같은 바이브 코딩 루프(generate-test → implement-feature → refactor)로 직접 추가해 보세요.

  • 분석 지표: "지역별 매출 비중(%)" 속성(지역 매출 ÷ 총매출 × 100)
  • 차트: by_region 막대 차트(어댑터에 메서드 추가, render가 3개 반환)
  • 화면: app.py에 최고 매출 월 지표(st.metric) 표시

🔍 관찰 포인트: 어떤 것을 고르든, 닿는 계층이 하나로 좁혀지는지 확인하세요. 범위가 좁다는 것은 곧 "안전하게 확장 가능한 설계"라는 증거입니다.

🔒 진행 게이트

  • 제출물(기록): 선택한 확장 1개의 테스트 통과 + 기존 테스트 회귀 없음.
  • 이 결과가 있어야 다음 교시로 넘어갑니다.
  • 정답·해설(별도 파일)solutions/미니실습정답_13.md

✅ 체크포인트

  • [ ] peak_month를 TDD로 추가했고 경계 케이스(빈 by_month → None)를 처리했다
  • [ ] JsonSalesRepository를 추가했고 포트를 만족한다
  • [ ] JSON 어댑터 추가 시 기존 코드·테스트가 그대로 통과했다
  • [ ] 조립 지점에서 한 줄 교체로 출처를 바꿀 수 있음을 확인했다
  • [ ] uv run pytest -v 전체가 통과한다

🛠️ 트러블슈팅

증상 원인 해결
새 기능 추가 후 기존 테스트가 깨짐 안쪽(도메인) 시그니처를 바꿈 새 기능은 추가만 하고 기존 동작은 보존. 깨지면 되돌리기
JSON 적재가 전부 skipped 키 이름/형식 불일치 JSON 키가 date/product/region/quantity/unit_price인지 확인
Copilot이 CsvSalesRepository를 수정하려 함 범위 오해 "기존 파일은 건드리지 말고 새 파일만"이라고 지시
peak_month가 빈 데이터에서 오류 빈 by_month 분기 누락 if not self.by_month: return None 처리

🔑 핵심 정리

  • 클린 아키텍처 덕분에 새 기능은 한 계층에만 닿고, 테스트가 회귀를 막아 확장이 안전하다.
  • 새 데이터 출처는 어댑터 하나 추가 + 조립 한 줄 교체로 끝난다(기존 코드 불변).
  • 바이브 코딩은 이 안전한 구조 위에서 확장 속도를 끌어올린다 — 구조가 먼저, 속도는 그 위에서.

다음 교시: 2일간의 흐름(손으로 마스터 → AI로 가속·확장)을 회고하고, 이 방식을 다른 도메인·현업·교육으로 옮기는 전략을 정리합니다.