5교시 · 첫 번째 TDD 사이클
1일차 15:00–16:00 · 이론/실습 선행: 4교시(엔티티·포트 뼈대), 2교시(F1 테스트 목록)
🎯 학습 목표
- SalesRecord(F1)를 대상으로 Red → Green → Refactor를 처음부터 끝까지 한 바퀴 직접 돌린다.
- "테스트가 먼저 실패하는 것"을 직접 보고, 통과시키며 TDD의 안전망을 체감한다.
- 1일차에 익힌 흐름(테스트 작성 → 실행 → 손으로 구현 → 통과)을 몸에 익힌다.
📖 개념 — 왜 SalesRecord가 첫 타자인가
SalesRecord는 외부(파일·LLM·화면)가 전혀 필요 없는 가장 안쪽의 순수 도메인입니다(2·3교시). 그래서 첫 TDD 연습 대상으로 가장 적합합니다.
- 테스트가 가장 단순하고 빠릅니다 (입력 값만 주면 됨).
- 통과/실패가 명확합니다 (계산·검증의 결과가 분명).
- 여기서 익힌 리듬을 이후 모든 기능(6~9교시)에 그대로 적용합니다.
이 한 시간의 목표는 "완벽한 SalesRecord"를 만드는 것이 아니라, TDD의 리듬(Red→Green→Refactor)을 몸에 익히는 것입니다. 리듬만 익히면 나머지는 같은 동작의 반복입니다.
🔴 1단계 Red — 실패하는 테스트 먼저
2교시 docs/test-list.md의 F1 항목을 테스트로 옮깁니다. VS Code에서 tests/test_models.py를 만들고 직접 작성하세요. 정상 케이스 1개 + quantity/unit_price/product 경계 오류 케이스를 포함합니다. 각 부분의 의도를 주석으로 표시했습니다.
from datetime import date
import pytest # 테스트 프레임워크. raises 등 검증 도구를 제공한다.
from sales_agent.domain.errors import InvalidSalesRecordError
from sales_agent.domain.models import SalesRecord
def make(**overrides) -> SalesRecord:
"""기본값으로 SalesRecord를 만들고, 인자로 넘긴 필드만 덮어쓰는 테스트 헬퍼.
**overrides: 호출 시 키워드 인자로 넘긴 값들이 dict로 모인다.
예) make(quantity=0) → overrides == {"quantity": 0}
이 헬퍼가 있으면 각 테스트는 '바꾸고 싶은 값 하나'만 적으면 되어,
"이 테스트가 무엇을 검증하는지"가 한눈에 드러난다.
"""
# 모든 필드가 유효한 '정상 레코드'를 기본값으로 준비한다.
base = dict(
date=date(2024, 1, 5),
product="노트북",
region="서울",
quantity=3,
unit_price=1_200_000.0,
)
base.update(overrides) # 넘어온 값만 기본값 위에 덮어쓴다
return SalesRecord(**base) # dict를 키워드 인자로 풀어 생성(**) → SalesRecord(date=..., ...)
def test_revenue_is_quantity_times_unit_price():
"""정상 값이면 매출액(revenue)은 수량 × 단가로 계산된다."""
record = make(quantity=3, unit_price=1_200_000.0) # 3 × 1,200,000 = 3,600,000
assert record.revenue == 3_600_000.0 # 계산 결과가 기대값과 같은가
def test_quantity_must_be_positive():
"""수량이 0 이하이면 도메인 오류(InvalidSalesRecordError)가 발생한다."""
# pytest.raises(X): with 블록 안에서 X 예외가 나면 통과, 안 나면 실패.
with pytest.raises(InvalidSalesRecordError):
make(quantity=0) # 수량 0 → 생성 단계에서 예외가 나야 정상
def test_unit_price_must_be_positive():
"""단가가 0 이하이면 도메인 오류가 발생한다."""
with pytest.raises(InvalidSalesRecordError):
make(unit_price=-100.0) # 음수 단가 → 예외
def test_product_must_not_be_empty():
"""상품명이 비어 있으면(공백만 있어도) 도메인 오류가 발생한다."""
with pytest.raises(InvalidSalesRecordError):
make(product=" ") # 공백뿐인 상품명도 '비어 있음'으로 취급 → 예외
def test_record_is_immutable():
"""레코드는 불변(frozen)이라 생성 후 필드를 바꿀 수 없다."""
record = make()
# frozen=True인 dataclass의 필드에 값을 대입하면 예외가 난다.
with pytest.raises(Exception):
record.quantity = 99 # type: ignore[misc] # (타입 검사기 경고를 의도적으로 무시)
🔍 관찰 포인트 1 — make() 헬퍼 패턴. 매 테스트마다 5개 필드를 다 적으면, 정작 "이 테스트가 무엇을 검증하는지"가 묻힙니다. make(quantity=0)처럼 "바꾸고 싶은 값만" 넘기면, 테스트의 의도가 한눈에 보입니다. 테스트도 읽기 좋은 코드여야 합니다.
🔍 관찰 포인트 2 — pytest.raises로 "오류가 나야 정상"을 검증. with pytest.raises(InvalidSalesRecordError): 블록은 "이 안의 코드가 해당 예외를 던지면 통과"라는 뜻입니다. 즉 "잘못된 입력에는 제대로 오류를 내는가" 를 테스트합니다. 정상 동작뿐 아니라 오류 동작도 명세라는 점이 중요합니다.
Red 실행
uv run pytest tests/test_models.py -v
💡 4교시에서 models.py 뼈대를 이미 만들었다면, 일부 또는 전부가 통과할 수 있습니다. 그래도 괜찮습니다. TDD의 핵심은 "테스트가 동작을 규정한다"는 것입니다. 만약 뼈대를 비워 두고 시작했다면, 지금은 ImportError나 실패가 나는 게 정상입니다(=Red).
🔍 관찰 포인트 3 — 실패 메시지는 "다음 할 일 목록"입니다. 실패 메시지를 반드시 읽으세요. 예컨대 ImportError: cannot import name 'InvalidSalesRecordError'가 나오면, 그것은 "그 예외 클래스를 만들라"는 구체적 지시입니다. 빨간 글씨는 잔소리가 아니라 명세입니다.
🟢 2단계 Green — 통과시키는 최소 구현
이제 위 테스트를 통과시키는 최소한의 구현을 domain/errors.py와 domain/models.py에 직접 작성합니다. "테스트를 통과시키는 데 필요한 것만" 만들고, 도메인은 외부 라이브러리를 import 하지 않습니다.
결과는 4교시의 뼈대와 거의 같아야 합니다. 핵심만 다시 확인합니다.
src/sales_agent/domain/errors.py:
class DomainError(Exception):
"""도메인 규칙 위반의 최상위(부모) 예외.
바깥 계층에서 `except DomainError:` 한 줄로 도메인이 의도적으로 던진
모든 규칙 위반을 한꺼번에 잡을 수 있게 해 주는 공통 조상이다.
"""
class InvalidSalesRecordError(DomainError):
"""매출 레코드가 도메인 규칙(양수 수량/단가, 비어 있지 않은 상품명)을 어겼을 때 발생."""
src/sales_agent/domain/models.py (SalesRecord 부분):
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from sales_agent.domain.errors import InvalidSalesRecordError
@dataclass(frozen=True) # 자동 생성자/비교 + 불변(생성 후 수정 불가)
class SalesRecord:
"""매출 한 건을 표현하는 불변 값 객체.
생성 시점에 검증을 통과하므로, 'SalesRecord 객체가 존재한다'는 곧
'유효한 매출 한 건이다'를 보장한다.
"""
date: date # 거래 일자
product: str # 상품명
region: str # 지역명
quantity: int # 수량 (> 0)
unit_price: float # 단가 (> 0)
def __post_init__(self) -> None:
"""모든 필드가 채워진 직후 호출되어 도메인 규칙을 검증한다.
오류 메시지에 실제 값(got ...)을 함께 넣어, 어떤 값 때문에 실패했는지
디버깅·로그에서 바로 알 수 있게 한다.
"""
if self.quantity <= 0: # 수량은 양수
raise InvalidSalesRecordError(f"quantity must be > 0, got {self.quantity}")
if self.unit_price <= 0: # 단가는 양수
raise InvalidSalesRecordError(f"unit_price must be > 0, got {self.unit_price}")
if not self.product.strip(): # strip() 후 빈 문자열 → 공백뿐인 상품명도 거부
raise InvalidSalesRecordError("product must not be empty")
@property
def revenue(self) -> float:
"""매출액 = 수량 × 단가 (저장하지 않고 매번 계산)."""
return self.quantity * self.unit_price
🔍 관찰 포인트 — "최소 구현"의 의미. 테스트를 통과시키는 데 필요한 코드만 썼습니다. 예를 들어 "통화 변환", "할인율 적용" 같은 아직 테스트가 요구하지 않은 기능은 만들지 않습니다. 검증되지 않은 코드는 버그가 숨을 자리일 뿐입니다. 필요해지면 그때 테스트를 먼저 쓰고 추가합니다. 이것이 TDD가 코드를 군더더기 없이 유지하는 방식입니다.
Green 실행
uv run pytest tests/test_models.py -v
기대 출력:
test_revenue_is_quantity_times_unit_price PASSED
test_quantity_must_be_positive PASSED
test_unit_price_must_be_positive PASSED
test_product_must_not_be_empty PASSED
test_record_is_immutable PASSED
====================== 5 passed ======================
🔵 3단계 Refactor — 테스트를 지키며 다듬기
테스트가 통과하는 지금이 코드를 안전하게 개선할 수 있는 순간입니다. 테스트라는 안전망이 있으니, 다듬다가 실수해도 즉시 알 수 있습니다. 동작은 그대로 두고 중복을 줄이고 의도가 드러나게 다듬되, tests/test_models.py가 계속 모두 통과해야 합니다.
예시 — 반복되는 "양수 검증"을 작은 메서드로 묶어 읽기 좋게:
def __post_init__(self) -> None:
"""객체 생성 직후 도메인 규칙을 검증한다.
양수 검증이 두 번 반복되던 것을, 아래 공통 메서드 한 곳으로 모았다.
규칙이 한 곳에 모이면, 나중에 메시지 형식을 바꿔도 한 번만 고치면 된다.
"""
self._require_positive("quantity", self.quantity) # 수량 검증 위임
self._require_positive("unit_price", self.unit_price) # 단가 검증 위임
if not self.product.strip():
raise InvalidSalesRecordError("product must not be empty")
@staticmethod # self를 쓰지 않는 순수 보조 함수라 정적 메서드로 둔다
def _require_positive(field_name: str, value: float) -> None:
"""값이 0 이하이면 도메인 오류를 던지는 공통 검증.
field_name: 오류 메시지에 표시할 필드 이름(예: "quantity")
value: 검사할 실제 값
"""
if value <= 0:
raise InvalidSalesRecordError(f"{field_name} must be > 0, got {value}")
Refactor 후 재확인 (필수)
uv run pytest -v
🔍 관찰 포인트 — 리팩터링과 기능 변경은 다릅니다. 리팩터링 전후로 테스트가 모두 그대로 통과해야 합니다. 동작은 똑같고 코드 모양만 좋아진 것이 리팩터링입니다. 만약 테스트가 깨졌다면, 그것은 리팩터링이 아니라 (의도치 않은) 기능 변경입니다. 즉시 되돌리고, 더 작은 단위로 다시 시도하세요.
✏️ 미니 실습 — 사이클을 한 번 더 돌려 보기
docs/test-list.md에 항목을 추가하고 같은 루프를 한 번 더 경험합니다.
- [ ] 같은 값으로 만든 두 레코드는 동등(==)하다
테스트 추가:
def test_records_with_same_values_are_equal():
"""같은 값으로 만든 두 레코드는 동등(==)하다.
@dataclass가 __eq__를 자동 생성하므로, 모든 필드 값이 같으면 두 객체는 같다.
별도 구현 없이 통과할 가능성이 크다(= 이미 보장된 동작을 확인하는 테스트).
"""
assert make() == make()
uv run pytest tests/test_models.py -v
🔍 관찰 포인트: 이 테스트는 추가 구현 없이 바로 통과할 가능성이 큽니다. @dataclass(frozen=True)가 "필드 값이 같으면 동등"을 자동으로 제공하기 때문입니다. "테스트를 먼저 써 봤더니 이미 충족되더라"도 유효한 TDD 경험입니다 — 그 기능이 이미 보장됨을 확인한 것입니다.
🔒 진행 게이트
- 제출물(기록): 동등성 테스트 추가 후 uv run pytest 전체 통과.
- 이 결과가 있어야 다음 교시로 넘어갑니다.
- 정답·해설(별도 파일): solutions/미니실습정답_05.md
✅ 체크포인트
- [ ] Red에서 테스트가 의미 있게 실패(또는 통과)하는 것을 확인했다
- [ ] Green에서 최소 구현으로 5개 테스트를 통과시켰다
- [ ] Refactor 후에도 uv run pytest가 전부 통과한다
- [ ] Red→Green→Refactor 한 사이클을 스스로 설명할 수 있다
🛠️ 트러블슈팅
| 증상 | 원인 | 해결 |
| ImportError: cannot import name 'SalesRecord' | 구현 전(Red 상태) | 정상. Green 단계에서 구현하면 해결 |
| 모든 테스트가 처음부터 통과 | 4교시 뼈대가 이미 완성형 | 테스트를 1개 더 추가해 Red→Green을 체험 |
| Refactor 후 테스트 깨짐 | 동작이 바뀜 | 변경을 되돌리고, 더 작은 단위로 다시 |
| frozen 객체에 값 대입이 오류 안 남 | dataclass 데코레이터 누락 | @dataclass(frozen=True) 확인 |
🔑 핵심 정리
- TDD의 핵심 리듬은 Red(실패 테스트) → Green(최소 구현) → Refactor(안전한 정리) 다.
- 테스트는 "정상 동작"뿐 아니라 "잘못된 입력에는 오류를 내는가" 까지 명세한다.
- 이 리듬을 6~9교시의 모든 기능에 그대로 반복 적용한다 — 손에 익히면 나머지는 같은 동작의 반복이다.
다음 교시: CSV 파일에서 매출 레코드를 안전하게 적재하는 어댑터를 같은 TDD 리듬으로 직접 구현합니다.
'AI System > 클린 아키텍처와 바이브 코딩을 활용한 AI Agent 시스템의 설계와 구' 카테고리의 다른 글
| d01 - 7. 분석 로직 구현 (0) | 2026.06.22 |
|---|---|
| d01 - 6. 데이터 처리 로직 구현 (0) | 2026.06.22 |
| d01 - 4. AI Agent 구조 설계 (0) | 2026.06.22 |
| d01 - 3. 클린 아키텍처 핵심 이해 (0) | 2026.06.22 |
| d01 - 2. 문제정의와 구현전략 (0) | 2026.06.22 |