TDD는 테스트가 아니라 설계다

TDD를 “테스트를 먼저 쓰는 것”으로 이해하면 금방 포기하게 된다. 테스트 파일을 열고 아직 존재하지 않는 클래스를 호출하는 코드를 쓰는 게 왜 좋다는 건지 납득이 안 된다. TDD의 핵심은 테스트 순서가 아니라 설계 피드백 루프다.

전통적 개발의 문제

전통적인 개발 흐름은 이렇다.

요구사항 → 설계 → 구현 → 테스트

테스트는 마지막에 온다. 구현이 끝난 뒤 “잘 동작하는지 확인”하는 용도다. 이 구조에서 발생하는 문제가 있다.

설계 결정의 피드백이 너무 늦게 온다. 클래스 간 결합도가 너무 높았다는 걸, 의존성 주입이 빠졌다는 걸, 인터페이스 설계가 사용하기 어렵다는 걸 구현을 다 끝낸 뒤에야 알게 된다.

// 전통적 방식으로 만든 결과 — 테스트를 나중에 쓰려 하면 막힌다
class OrderService {
  processOrder(orderId: string): void {
    // 직접 DB 연결
    const conn = new DatabaseConnection('postgres://prod-db/orders');
    const order = conn.query(`SELECT * FROM orders WHERE id = '${orderId}'`);
 
    // 직접 외부 서비스 호출
    const paymentResult = PaymentGateway.charge(order.userId, order.amount);
 
    // 직접 이메일 발송
    EmailSender.send(order.email, `주문 ${orderId} 처리 완료`);
 
    conn.execute(`UPDATE orders SET status = 'done' WHERE id = '${orderId}'`);
  }
}

이 코드를 테스트하려면 실제 DB가 필요하고, 실제 결제 게이트웨이가 필요하고, 실제 이메일 서버가 필요하다. 테스트가 불가능한 게 아니라, 설계가 나쁜 것이다.

Testability = Designability

테스트하기 어려운 코드는 설계가 나쁜 코드다. 이 등식이 TDD의 핵심 통찰이다.

테스트하기 어려운 이유를 나열하면 항상 설계 문제로 귀결된다.

  • “DB가 없어서 테스트가 안 된다” → 의존성이 역전되어 있지 않다
  • “다른 서비스가 필요해서 단독으로 실행이 안 된다” → 결합도가 너무 높다
  • “전역 상태를 바꿔서 테스트 순서에 따라 결과가 달라진다” → 부수 효과가 관리되지 않는다
  • “어떤 상황을 재현해야 하는지 모르겠다” → 인터페이스가 명확하지 않다

테스트를 먼저 쓰면 이 문제들을 구현 전에 발견할 수 있다.

// 테스트를 먼저 쓰면 설계가 강제된다
describe('OrderService', () => {
  it('주문을 처리하면 결제가 요청되고 상태가 업데이트된다', async () => {
    // 테스트를 쓰는 시점에 "무엇을 주입해야 하는가"가 명확해진다
    const mockOrderRepo = { findById: jest.fn(), save: jest.fn() };
    const mockPaymentGateway = { charge: jest.fn().mockResolvedValue({ success: true }) };
    const mockEmailSender = { send: jest.fn() };
 
    const service = new OrderService(mockOrderRepo, mockPaymentGateway, mockEmailSender);
 
    await service.processOrder('order-123');
 
    expect(mockPaymentGateway.charge).toHaveBeenCalledWith('order-123');
    expect(mockOrderRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({ status: 'done' })
    );
  });
});

테스트를 먼저 쓰는 순간 OrderService가 무엇에 의존하는지, 어떻게 의존성을 주입받아야 하는지가 드러난다. 이 설계 결정을 구현 전에 하게 되는 것이다.

빠른 피드백 루프의 가치

버그를 나중에 발견할수록 수정 비용이 증가한다. 이 개념은 직관적으로 이해되지만 실제로 얼마나 차이가 나는지는 체감하기 어렵다.

코딩 중 발견:   수정 비용 1x
코드 리뷰 중:   수정 비용 6x
QA 테스트 중:   수정 비용 15x
프로덕션 배포 후: 수정 비용 64x

TDD의 피드백 루프는 초 단위다. 테스트를 실행하면 방금 쓴 코드가 의도대로 동작하는지 즉시 알 수 있다. 설계 결정의 적절함도 마찬가지다. 테스트 작성이 어려워지는 순간 설계에 문제가 있다는 신호를 즉시 받는다.

Living Documentation

테스트는 코드의 명세다. 주석보다 신뢰할 수 있는 명세다.

주석은 코드가 바뀌어도 업데이트되지 않는다. 테스트는 코드가 바뀌면 실패한다. 따라서 통과하는 테스트는 항상 현재 동작을 정확하게 기술한다.

describe('CartDiscountCalculator', () => {
  describe('10만원 이상 구매 시', () => {
    it('10% 할인을 적용한다', () => {
      const cart = new Cart([
        new CartItem('상품A', 60000),
        new CartItem('상품B', 50000),
      ]);
 
      const discounted = calculator.calculate(cart);
 
      expect(discounted.totalPrice).toBe(99000); // 110000 * 0.9
    });
  });
 
  describe('10만원 미만 구매 시', () => {
    it('할인을 적용하지 않는다', () => {
      const cart = new Cart([new CartItem('상품A', 50000)]);
 
      const discounted = calculator.calculate(cart);
 
      expect(discounted.totalPrice).toBe(50000);
    });
  });
});

이 테스트를 읽으면 CartDiscountCalculator가 무엇을 하는지, 어떤 조건에서 어떻게 동작하는지 즉시 알 수 있다. 구현 코드를 볼 필요가 없다.

회귀 방어와 리팩토링 용기

테스트가 없으면 리팩토링을 두려워하게 된다. “건드렸다가 뭔가 깨지면 어쩌지”라는 생각이 코드를 그대로 두게 만든다. 기술 부채가 쌓이는 주요 원인이다.

테스트가 있으면 리팩토링 후 테스트를 돌려보면 된다. 모두 통과하면 동작이 유지된다는 확신이 있다. 이것이 리팩토링 용기다.

// 리팩토링 전 — 중복이 있고 이름이 불명확
function calc(items: { p: number; q: number }[]): number {
  let t = 0;
  for (const i of items) {
    t += i.p * i.q;
  }
  if (t >= 100000) {
    t = t * 0.9;
  }
  return t;
}
 
// 리팩토링 후 — 의도가 명확
function calculateTotalWithDiscount(items: OrderItem[]): number {
  const subtotal = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  return applyBulkDiscount(subtotal);
}
 
function applyBulkDiscount(subtotal: number): number {
  const BULK_THRESHOLD = 100_000;
  const BULK_DISCOUNT_RATE = 0.1;
  return subtotal >= BULK_THRESHOLD
    ? subtotal * (1 - BULK_DISCOUNT_RATE)
    : subtotal;
}

리팩토링 전후 테스트는 동일하다. 테스트가 통과하는 한 리팩토링은 안전하다.

TDD 흐름

TDD의 실제 흐름은 세 단계의 반복이다.

Red   → 실패하는 테스트를 작성한다 (설계를 탐색한다)
Green → 최소한의 코드로 테스트를 통과시킨다
Refactor → 동작을 유지하면서 설계를 개선한다

“테스트를 먼저 쓴다”는 것은 이 루프의 시작점일 뿐이다. 루프를 반복하면서 설계가 점진적으로 드러나고 개선된다.

TDD는 테스트 기법이 아니다. 설계를 주도하는 개발 방법론이다.