13교시 · 바이브 코딩으로 빠른 확장
2일차 15:00–16:00 · 이론/실습 선행: 12교시(Copilot 규칙·프롬프트 세팅)
🎯 학습 목표
- 완성된 프로젝트에 새 기능을 바이브 코딩 + TDD로 빠르게 추가한다.
- 클린 아키텍처가 확장을 얼마나 안전하게 만드는지(기존 코드를 안 건드림) 직접 확인한다.
- "포트만 맞추면 부품을 갈아 끼울 수 있다"는 원리를 새 데이터 출처 교체로 체감한다.
📖 개념 — 확장이 두렵지 않은 이유
보통 "기능 추가"는 두렵습니다. 한 곳을 고치면 다른 곳이 깨질지 모르기 때문입니다. 하지만 우리 프로젝트는 두 가지 안전장치가 있습니다.
- 계층 분리 — 새 기능은 보통 한 계층에만 영향을 줍니다(예: 새 지표는 도메인/유스케이스, 새 출처는 어댑터).
- 테스트 — 기존 기능이 깨지면 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로 가속·확장)을 회고하고, 이 방식을 다른 도메인·현업·교육으로 옮기는 전략을 정리합니다.
'AI System > 클린 아키텍처와 바이브 코딩을 활용한 AI Agent 시스템의 설계와 구' 카테고리의 다른 글
| d02 - 14. 과정 리뷰와 확정 전략 (0) | 2026.06.23 |
|---|---|
| d02 - 12. 바이브 코딩 도입과 빠른 재현 (0) | 2026.06.23 |
| d02 - 11. 구조안정화 (0) | 2026.06.23 |
| d02 - 10. 웹 인터페이스 연결 (0) | 2026.06.23 |
| d02 - 9. 결과생성 파이프라인 (0) | 2026.06.23 |