TDD로 Go 도메인 모델링하기 — 테스트가 이끄는 애그리거트
2025-07-06
1편에서 이벤트 스토밍으로 도메인을 탐색하고 Order 애그리거트의 불변식을 그렸습니다. 오늘은 그 설계를 Go 코드로 옮기는데, TDD(테스트 주도 개발) 로 갑니다. 즉 구현을 먼저 짜고 테스트하는 게 아니라, 실패하는 테스트를 먼저 쓰고 → 그걸 통과시키는 최소 코드를 짜고 → 다듬는 순서로요.
DDD와 TDD는 궁합이 좋습니다. 도메인의 불변식("주문에는 항목이 하나 이상", "총액은 소계의 합")은 그 자체로 실행 가능한 명세 — 즉 테스트 — 로 옮기기 딱 좋기 때문입니다. 규칙을 문장으로 적는 대신 테스트로 적으면, 그 규칙이 영원히 검증됩니다.
💻 이 편의 코드는 github.com/kahnco/go-ddd-shop 의
part-2태그에 있습니다.git checkout part-2 && go test ./...로 직접 돌려보세요.
TDD 한 사이클 — Red · Green · Refactor
TDD의 리듬은 세 박자입니다.
- Red — 아직 없는 기능에 대한 실패하는 테스트 를 먼저 씁니다. 컴파일이 안 되거나 테스트가 빨갛게 실패합니다. 이 단계에서 "무엇을 만들 건지"를 테스트로 못 박습니다.
- Green — 그 테스트를 통과시키는 최소한의 코드 를 짭니다. 우아함은 나중, 지금은 초록불이 목표입니다.
- Refactor — 테스트가 통과하는 걸 안전망 삼아, 코드를 다듬습니다. 테스트가 있으니 겁 없이 정리할 수 있습니다.
이 사이클을 작게 반복하며 Order 애그리거트를 쌓겠습니다. 프로젝트 구조는 도메인을 가장 안쪽에 두는 계층 구조입니다.
internal/ordering/domain/
├── value_objects.go # Money, Quantity, ID, 상태
├── order.go # Order 애그리거트 + OrderLine
├── events.go # 도메인 이벤트
├── repository.go # 리포지토리 포트(인터페이스)
└── errors.go # 도메인 에러
도메인 계층은 DB도 HTTP도 모릅니다. 그래서 아무것도 안 띄우고 테스트만으로 규칙을 검증할 수 있는데, 이게 TDD를 특히 빠르고 즐겁게 만듭니다.
사이클 1 — "빈 주문은 거부된다"
첫 규칙: 항목이 없는 주문은 만들어질 수 없다. 테스트부터 씁니다(Red).
func TestPlaceOrder_빈_항목이면_에러(t *testing.T) {
_, err := PlaceOrder("order-1", "cust-1", nil)
if !errors.Is(err, ErrEmptyOrder) {
t.Fatalf("빈 주문은 ErrEmptyOrder 여야 하는데: %v", err)
}
}
이 시점엔 PlaceOrder도 ErrEmptyOrder도 없으니 컴파일조차 안 됩니다. 그게 정상입니다 — 빨간불에서 시작합니다. 이제 이걸 통과시킬 최소 코드를 짭니다(Green).
var ErrEmptyOrder = errors.New("주문에는 최소 하나의 항목이 필요합니다")
func PlaceOrder(id OrderID, customerID CustomerID, lines []OrderLine) (*Order, error) {
if len(lines) == 0 {
return nil, ErrEmptyOrder
}
return &Order{id: id, customerID: customerID, lines: lines, status: StatusPlaced}, nil
}
테스트가 초록불이 됩니다. 주목할 점은, "빈 주문 거부"라는 불변식이 테스트로 먼저 선언되고 그다음 코드가 그걸 만족시켰다는 겁니다. 규칙이 코드보다 앞섭니다.
사이클 2 — "총액은 소계의 합"
다음 규칙: 주문 총액은 항목 소계의 합이다. 역시 테스트 먼저(Red).
func TestPlaceOrder_총액은_소계의_합(t *testing.T) {
// 상품 A 2개(1,000원) + 상품 B 1개(3,000원) = 5,000원
lines := []OrderLine{
NewOrderLine("prod-A", mustQty(t, 2), mustMoney(t, 1000)),
NewOrderLine("prod-B", mustQty(t, 1), mustMoney(t, 3000)),
}
o, _ := PlaceOrder("order-1", "cust-1", lines)
if got := o.Total().Amount(); got != 5000 {
t.Fatalf("총액 = 5000 이어야 하는데 %d", got)
}
}
흥미로운 지점입니다. 이 테스트를 쓰다 보니 금액(Money)과 수량(Quantity)이라는 값 객체가 필요 해졌습니다. TDD의 부수 효과 중 하나가 이겁니다 — 테스트가 "이런 타입이 있으면 좋겠다"를 자연스럽게 끌어냅니다. 그래서 값 객체를 만듭니다(Green). 값 객체는 생성 시점에 유효성을 강제 하고 불변 입니다.
type Money struct{ amount int64 } // 원. 음수 불가
func NewMoney(amount int64) (Money, error) {
if amount < 0 {
return Money{}, ErrNegativeMoney
}
return Money{amount: amount}, nil
}
func (m Money) Add(o Money) Money { return Money{amount: m.amount + o.amount} }
func (m Money) Times(q Quantity) Money { return Money{amount: m.amount * int64(q.value)} }
그리고 총액은 저장하지 않고 항상 계산 합니다. 그래야 "총액 = 소계 합" 불변식이 절대 깨지지 않으니까요.
func (o *Order) Total() Money {
total := Money{}
for _, l := range o.lines {
total = total.Add(l.Subtotal())
}
return total
}
이왕 값 객체를 만든 김에, "음수 금액", "0개 수량"도 테스트로 못 박습니다(Red → Green). 잘못된 값이 애초에 만들어지지 못하게 하는 거죠.
func TestMoney_음수는_에러(t *testing.T) {
if _, err := NewMoney(-1); !errors.Is(err, ErrNegativeMoney) {
t.Fatalf("음수 금액은 ErrNegativeMoney 여야 하는데: %v", err)
}
}
사이클 3 — "정해진 순서로만 전이된다"
주문 상태는 생성됨 → 결제완료 → 확정 → 배송중 으로만 가고, 취소는 결제 전까지만 된다. 잘못된 전이를 거부하는 테스트 를 먼저 씁니다(Red).
func TestOrder_허용되지_않은_전이는_거부(t *testing.T) {
o, _ := PlaceOrder("order-1", "cust-1", sampleLines(t))
// 생성됨 → (결제 없이) 확정 : 거부돼야 한다
if err := o.Confirm(); !errors.Is(err, ErrInvalidStatusTransition) {
t.Fatalf("PLACED→CONFIRMED 는 거부돼야 하는데: %v", err)
}
if o.Status() != StatusPlaced {
t.Fatalf("거부됐으면 상태는 PLACED 그대로여야 하는데 %s", o.Status())
}
}
이 테스트가 "허용된 전이만 통과, 나머지는 거부하고 상태 유지"를 못 박습니다. 통과시키는 구현은, 허용된 전이를 한 곳에 모은 맵 입니다(Green).
var allowedTransitions = map[OrderStatus][]OrderStatus{
StatusPlaced: {StatusPaid, StatusCancelled},
StatusPaid: {StatusConfirmed, StatusCancelled},
StatusConfirmed: {StatusShipped},
}
func (o *Order) transition(to OrderStatus, event DomainEvent) error {
for _, allowed := range allowedTransitions[o.status] {
if allowed == to {
o.status = to
o.record(event)
return nil
}
}
return fmt.Errorf("%w: %s → %s", ErrInvalidStatusTransition, o.status, to)
}
func (o *Order) MarkPaid() error { return o.transition(StatusPaid, OrderPaid{OrderID: o.id}) }
func (o *Order) Confirm() error { return o.transition(StatusConfirmed, OrderConfirmed{OrderID: o.id}) }
정상 경로(생성→결제→확정→배송)와 "확정된 주문은 취소 불가" 같은 규칙도 각각 테스트로 덮습니다. 규칙 하나 = 테스트 하나 = 명세 하나입니다.
사이클 4 — "일어난 일을 이벤트로 기록한다"
마지막으로, 주문이 생성되면 도메인 이벤트가 기록돼야 합니다(4편에서 이 이벤트로 재고·결제를 잇습니다). 테스트 먼저(Red).
func TestPlaceOrder_OrderPlaced_이벤트를_기록(t *testing.T) {
o, _ := PlaceOrder("order-1", "cust-1", sampleLines(t))
events := o.PullEvents()
if len(events) != 1 {
t.Fatalf("이벤트 1개 여야 하는데 %d개", len(events))
}
if _, ok := events[0].(OrderPlaced); !ok {
t.Fatalf("첫 이벤트는 OrderPlaced 여야 함: %T", events[0])
}
}
이걸 통과시키려고 PlaceOrder에 이벤트 기록을 더하고, 이벤트를 꺼내는 PullEvents를 만듭니다(Green). 도메인은 이벤트를 기록만 하고, 발행(브로커로 보내기)은 나중에 인프라가 합니다.
func (o *Order) record(e DomainEvent) { o.events = append(o.events, e) }
func (o *Order) PullEvents() []DomainEvent {
e := o.events
o.events = nil
return e
}
Refactor — 테스트라는 안전망 위에서
테스트가 초록불인 지금이 다듬을 때입니다. 예를 들어, 항목 목록을 그대로 돌려주면 바깥에서 몰래 바꿔 불변식을 깰 수 있으니, 복사본을 반환하도록 캡슐화를 강화합니다.
func (o *Order) Lines() []OrderLine {
out := make([]OrderLine, len(o.lines))
copy(out, o.lines)
return out
}
이렇게 구조를 바꿔도 테스트를 다시 돌려 초록불이면 안전 합니다. 리팩터링이 무섭지 않은 이유가 이겁니다 — 규칙이 테스트로 고정돼 있으니, 내부를 바꿔도 규칙이 깨지면 즉시 빨간불로 알려줍니다.
리포지토리도 이 단계에서 포트(인터페이스) 로만 둡니다. 도메인이 DB를 모르게 하기 위해서죠(의존성 역전). 구현은 다음 편들에서 인프라 계층에.
type OrderRepository interface {
Save(ctx context.Context, order *Order) error
FindByID(ctx context.Context, id OrderID) (*Order, error)
}
전부 초록불
사이클을 다 돌고 나면, 도메인의 모든 규칙이 테스트로 덮여 있습니다. DB를 안 띄우니 순식간에 통과합니다.
$ go test ./...
ok github.com/kahnco/go-ddd-shop/internal/ordering/domain 0.35s
빈 주문 거부, 총액 계산, 값 객체 검증, 상태 전이 규칙, 이벤트 기록 — 이 모두가 처음에 테스트로 선언되고 나중에 구현이 따라온 것들입니다.
정리 — TDD가 준 것
- Red → Green → Refactor 사이클로, 도메인 규칙을 하나씩 테스트로 못 박고 구현이 따라오게 했습니다.
- 테스트가 "이런 타입이 필요하다"를 끌어내(값 객체), 설계를 자연스럽게 이끌었습니다.
- 규칙 하나 = 테스트 하나라, 도메인의 불변식이 실행 가능한 명세 로 영원히 검증됩니다.
- 테스트라는 안전망 덕에 겁 없이 리팩터링 할 수 있었습니다.
- 도메인이 DB·프레임워크를 모르니(헥사고날), 테스트가 빠르고 순수 합니다.
TDD와 DDD가 만나면, 도메인 규칙이 문서가 아니라 깨지면 즉시 알려주는 살아있는 검증 이 됩니다. 1편의 설계가 이제 테스트로 고정된 Go 코드가 됐으니, 다음 편에서는 이 도메인 바깥에 애플리케이션 계층(유스케이스)과 실제 저장소·API 를 얹어 진짜 도는 서비스를 만듭니다. 물론 거기서도 테스트를 먼저 씁니다.
이번 편 전체 코드는 리포의
part-2태그에 있습니다.