IT Knowledge/Development/images/테스트-주도-개발tdd-diagram.svg

📌 핵심 개념

TDD (Test-Driven Development)는 테스트를 먼저 작성하고 그 테스트를 통과하는 코드를 작성하는 개발 방법론입니다.

Red → Green → Refactor 사이클:

  1. 🔴 Red: 실패하는 테스트 작성
  2. 🟢 Green: 테스트를 통과하는 최소한의 코드 작성
  3. 🔵 Refactor: 코드 개선 (테스트는 계속 통과)

AI 요약 보고서

  • 개념 및 사이클: TDD(테스트 주도 개발)는 테스트를 먼저 작성하고 이를 통과하는 최소한의 코드를 구현하는 방식으로, Red(실패 테스트) → Green(통과) → Refactor(개선) 순환을 따른다.
  • 실제 사례:
    • 계산기 개발: 일반 개발은 구현 후 테스트 작성이 흔한 반면, TDD는 테스트 먼저 작성, 최소 코드 구현, 리팩토링을 통해 엣지 케이스 사전 발견과 안전성 확보, 문서화 효과를 가져온다.
    • 결제 시스템: 쿠폰 할인 기능 개발에서 TDD는 다양한 할인 유형과 유효성 검증을 테스트 후 구현, 전략 패턴 적용으로 확장성을 높였다.
    • 백엔드 사용자 등록: 목(mock) 객체 활용, 유효성 검증, 비밀번호 암호화 등 복잡한 로직을 테스트 주도 방식으로 안정적 개발 가능.
  • 실전 패턴:
    • AAA(Arrange-Act-Assert) 구조로 명확한 테스트 설계.
    • Test Double(Stub, Mock, Spy) 활용으로 의존성 제어.
    • 파라미터화된 테스트로 다양한 입력 검증.
  • 효과 및 통계:
    • IBM, Microsoft, Spotify 등 기업에서 버그 밀도 감소, 개발 시간 단축, 배포 안정성 향상 등 실질적 성과 보고.
    • 코드 커버리지 목표는 전체적으로 80% 이상, 핵심 영역은 95% 이상 달성 권장.
  • 실무 팁:
    • 작은 단계로 개발, 독립적이고 반복 가능하며 자가 검증하는 테스트 작성.
    • FIRST 원칙(빠름, 독립적, 반복 가능, 자가 검증, 적시)을 준수.
    • 의존성 주입으로 테스트 용이한 구조 설계.
  • 도입 로드맵:
    • 신규 기능부터 TDD 적용(1개월).
    • 버그 수정 시 테스트 추가(2-3개월).
    • 레거시 코드에 특성화 테스트 도입 지속.
  • 참고 자료: Kent Beck의 『Test-Driven Development』, Jest 공식 문서, Martin Fowler의 테스트 피라미드 등.

🎯 실제 사례로 이해하기

사례 1: 계산기 만들기 - TDD vs 일반 개발

❌ 일반적인 개발 방식

// calculator.js - 바로 구현부터 시작
class Calculator {
  add(a, b) {
    return a + b;
  }
 
  subtract(a, b) {
    return a - b;  // 버그: 음수 처리 안 됨
  }
 
  divide(a, b) {
    return a / b;  // 버그: 0으로 나누기 예외 처리 안 됨
  }
}
 
// 나중에 테스트 작성 (귀찮아서 건너뛰기 쉬움)

✅ TDD 방식

// calculator.test.js - 1단계: RED (실패하는 테스트 작성)
describe('Calculator', () => {
  let calc;
 
  beforeEach(() => {
    calc = new Calculator();
  });
 
  describe('덧셈', () => {
    it('두 양수를 더한다', () => {
      expect(calc.add(2, 3)).toBe(5);
    });
 
    it('음수를 더한다', () => {
      expect(calc.add(-1, -2)).toBe(-3);
    });
 
    it('소수점을 더한다', () => {
      expect(calc.add(0.1, 0.2)).toBeCloseTo(0.3);  // 부동소수점 이슈 고려
    });
  });
 
  describe('나눗셈', () => {
    it('정상적으로 나눈다', () => {
      expect(calc.divide(10, 2)).toBe(5);
    });
 
    it('0으로 나누면 에러를 던진다', () => {
      expect(() => calc.divide(10, 0)).toThrow('0으로 나눌 수 없습니다');
    });
  });
});
 
// 2단계: GREEN (테스트 통과하는 최소 코드)
class Calculator {
  add(a, b) {
    return a + b;
  }
 
  divide(a, b) {
    if (b === 0) {
      throw new Error('0으로 나눌 수 없습니다');
    }
    return a / b;
  }
}
 
// 3단계: REFACTOR (코드 개선)
class Calculator {
  add(...numbers) {
    return numbers.reduce((sum, num) => sum + num, 0);
  }
 
  divide(dividend, divisor) {
    this.#validateDivisor(divisor);
    return dividend / divisor;
  }
 
  #validateDivisor(divisor) {
    if (divisor === 0) {
      throw new Error('0으로 나눌 수 없습니다');
    }
  }
}

결과:

  • 엣지 케이스 미리 발견
  • 리팩토링 시 안전성 보장
  • 문서화 효과

사례 2: 실무 - 결제 시스템 개발

요구사항: 쿠폰 할인 적용 기능

1단계: RED - 테스트 먼저 작성

// payment.test.js
describe('PaymentService', () => {
  let paymentService;
 
  beforeEach(() => {
    paymentService = new PaymentService();
  });
 
  describe('쿠폰 할인 적용', () => {
    it('정률 쿠폰: 10% 할인', () => {
      const order = { totalAmount: 10000 };
      const coupon = { type: 'PERCENTAGE', value: 10 };
 
      const result = paymentService.applyCoupon(order, coupon);
 
      expect(result.discountAmount).toBe(1000);
      expect(result.finalAmount).toBe(9000);
    });
 
    it('정액 쿠폰: 5000원 할인', () => {
      const order = { totalAmount: 10000 };
      const coupon = { type: 'FIXED', value: 5000 };
 
      const result = paymentService.applyCoupon(order, coupon);
 
      expect(result.discountAmount).toBe(5000);
      expect(result.finalAmount).toBe(5000);
    });
 
    it('쿠폰 금액이 주문금액보다 크면 0원', () => {
      const order = { totalAmount: 3000 };
      const coupon = { type: 'FIXED', value: 5000 };
 
      const result = paymentService.applyCoupon(order, coupon);
 
      expect(result.finalAmount).toBe(0);
      expect(result.discountAmount).toBe(3000);  // 최대 할인액은 주문금액
    });
 
    it('만료된 쿠폰은 에러', () => {
      const order = { totalAmount: 10000 };
      const coupon = {
        type: 'FIXED',
        value: 5000,
        expiryDate: new Date('2020-01-01')
      };
 
      expect(() => paymentService.applyCoupon(order, coupon))
        .toThrow('만료된 쿠폰입니다');
    });
 
    it('최소 주문금액 미달 시 에러', () => {
      const order = { totalAmount: 8000 };
      const coupon = {
        type: 'FIXED',
        value: 5000,
        minOrderAmount: 10000
      };
 
      expect(() => paymentService.applyCoupon(order, coupon))
        .toThrow('최소 주문금액 10000원 이상이어야 합니다');
    });
  });
});

2단계: GREEN - 테스트 통과 코드

// payment.js
class PaymentService {
  applyCoupon(order, coupon) {
    // 쿠폰 유효성 검증
    this.#validateCoupon(coupon, order);
 
    // 할인액 계산
    let discountAmount = this.#calculateDiscount(order.totalAmount, coupon);
 
    // 할인액이 주문금액보다 클 수 없음
    discountAmount = Math.min(discountAmount, order.totalAmount);
 
    return {
      discountAmount,
      finalAmount: order.totalAmount - discountAmount
    };
  }
 
  #validateCoupon(coupon, order) {
    // 만료 체크
    if (coupon.expiryDate && new Date() > new Date(coupon.expiryDate)) {
      throw new Error('만료된 쿠폰입니다');
    }
 
    // 최소 주문금액 체크
    if (coupon.minOrderAmount && order.totalAmount < coupon.minOrderAmount) {
      throw new Error(`최소 주문금액 ${coupon.minOrderAmount}원 이상이어야 합니다`);
    }
  }
 
  #calculateDiscount(totalAmount, coupon) {
    switch (coupon.type) {
      case 'PERCENTAGE':
        return totalAmount * (coupon.value / 100);
      case 'FIXED':
        return coupon.value;
      default:
        throw new Error('알 수 없는 쿠폰 타입');
    }
  }
}

3단계: REFACTOR - 전략 패턴 적용

// 할인 전략 분리
class DiscountStrategy {
  calculate(totalAmount) {
    throw new Error('구현 필요');
  }
}
 
class PercentageDiscount extends DiscountStrategy {
  constructor(percentage) {
    super();
    this.percentage = percentage;
  }
 
  calculate(totalAmount) {
    return totalAmount * (this.percentage / 100);
  }
}
 
class FixedDiscount extends DiscountStrategy {
  constructor(amount) {
    super();
    this.amount = amount;
  }
 
  calculate(totalAmount) {
    return this.amount;
  }
}
 
// 리팩토링된 PaymentService
class PaymentService {
  #strategyMap = {
    'PERCENTAGE': (value) => new PercentageDiscount(value),
    'FIXED': (value) => new FixedDiscount(value)
  };
 
  applyCoupon(order, coupon) {
    this.#validateCoupon(coupon, order);
 
    const strategy = this.#strategyMap[coupon.type](coupon.value);
    let discountAmount = strategy.calculate(order.totalAmount);
    discountAmount = Math.min(discountAmount, order.totalAmount);
 
    return {
      discountAmount,
      finalAmount: order.totalAmount - discountAmount
    };
  }
 
  // ... 나머지 코드는 동일
}
 
// 테스트는 그대로 통과! (리팩토링 안전성 보장)

사례 3: 백엔드 API - 사용자 등록

요구사항: 이메일 중복 체크 + 비밀번호 암호화

// user.service.test.js
describe('UserService', () => {
  let userService;
  let mockUserRepository;
  let mockPasswordHasher;
 
  beforeEach(() => {
    // 목(Mock) 객체 생성
    mockUserRepository = {
      findByEmail: jest.fn(),
      save: jest.fn()
    };
    mockPasswordHasher = {
      hash: jest.fn()
    };
 
    userService = new UserService(mockUserRepository, mockPasswordHasher);
  });
 
  describe('회원가입', () => {
    it('성공: 새로운 사용자 등록', async () => {
      // Given: 중복 이메일 없음
      mockUserRepository.findByEmail.mockResolvedValue(null);
      mockPasswordHasher.hash.mockResolvedValue('hashed_password_123');
 
      const userData = {
        email: 'test@example.com',
        password: 'password123',
        name: '홍길동'
      };
 
      // When: 회원가입 실행
      const result = await userService.register(userData);
 
      // Then: 검증
      expect(mockUserRepository.findByEmail).toHaveBeenCalledWith('test@example.com');
      expect(mockPasswordHasher.hash).toHaveBeenCalledWith('password123');
      expect(mockUserRepository.save).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'hashed_password_123',
        name: '홍길동'
      });
      expect(result.id).toBeDefined();
    });
 
    it('실패: 이메일 중복', async () => {
      // Given: 이미 존재하는 이메일
      mockUserRepository.findByEmail.mockResolvedValue({
        id: 1,
        email: 'test@example.com'
      });
 
      const userData = {
        email: 'test@example.com',
        password: 'password123',
        name: '홍길동'
      };
 
      // When & Then
      await expect(userService.register(userData))
        .rejects
        .toThrow('이미 사용 중인 이메일입니다');
 
      expect(mockUserRepository.save).not.toHaveBeenCalled();
    });
 
    it('실패: 비밀번호 너무 짧음', async () => {
      const userData = {
        email: 'test@example.com',
        password: '123',  // 3자리
        name: '홍길동'
      };
 
      await expect(userService.register(userData))
        .rejects
        .toThrow('비밀번호는 8자 이상이어야 합니다');
    });
 
    it('실패: 이메일 형식 오류', async () => {
      const userData = {
        email: 'invalid-email',
        password: 'password123',
        name: '홍길동'
      };
 
      await expect(userService.register(userData))
        .rejects
        .toThrow('올바른 이메일 형식이 아닙니다');
    });
  });
});
 
// user.service.js - 구현
class UserService {
  constructor(userRepository, passwordHasher) {
    this.userRepository = userRepository;
    this.passwordHasher = passwordHasher;
  }
 
  async register(userData) {
    // 1. 유효성 검증
    this.#validateEmail(userData.email);
    this.#validatePassword(userData.password);
 
    // 2. 중복 체크
    const existingUser = await this.userRepository.findByEmail(userData.email);
    if (existingUser) {
      throw new Error('이미 사용 중인 이메일입니다');
    }
 
    // 3. 비밀번호 해싱
    const hashedPassword = await this.passwordHasher.hash(userData.password);
 
    // 4. 저장
    const newUser = {
      ...userData,
      password: hashedPassword
    };
 
    return await this.userRepository.save(newUser);
  }
 
  #validateEmail(email) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      throw new Error('올바른 이메일 형식이 아닙니다');
    }
  }
 
  #validatePassword(password) {
    if (password.length < 8) {
      throw new Error('비밀번호는 8자 이상이어야 합니다');
    }
  }
}

🔧 TDD 실전 패턴

1. AAA 패턴

it('주문 생성 시 재고 감소', async () => {
  // Arrange (준비): 테스트 환경 설정
  const product = { id: 1, stock: 10 };
  const orderQuantity = 3;
 
  // Act (실행): 테스트 대상 실행
  await orderService.createOrder(product.id, orderQuantity);
 
  // Assert (검증): 결과 확인
  const updatedProduct = await productRepository.findById(product.id);
  expect(updatedProduct.stock).toBe(7);
});

2. 테스트 더블 (Test Double)

// Stub: 미리 정의된 답변 반환
const stubWeatherAPI = {
  getTemperature: () => 25  // 항상 25도 반환
};
 
// Mock: 호출 여부 및 인자 검증
const mockEmailService = {
  sendEmail: jest.fn()
};
await userService.register(userData);
expect(mockEmailService.sendEmail).toHaveBeenCalledWith(
  'test@example.com',
  '가입을 환영합니다'
);
 
// Spy: 실제 구현 + 호출 추적
const spy = jest.spyOn(logger, 'error');
await someFunction();
expect(spy).toHaveBeenCalledTimes(1);

3. 파라미터화된 테스트

describe('할인 계산', () => {
  it.each([
    [10000, 10, 1000],   // [원가, 할인율, 기대값]
    [20000, 20, 4000],
    [5000, 50, 2500],
    [1000, 100, 1000]
  ])('원가 %i원에 %i% 할인하면 %i원', (price, rate, expected) => {
    expect(calculateDiscount(price, rate)).toBe(expected);
  });
});

📊 TDD의 효과

실제 기업 사례

기업도입 전도입 후결과
IBM버그 밀도: 4.5/KLOC버그 밀도: 0.5/KLOC10배 개선
Microsoft개발 시간 +15%버그 감소 40-90%ROI 200-300%
Spotify배포 후 핫픽스 20%배포 후 핫픽스 3%안정성 향상

코드 커버리지 목표

// jest.config.js
module.exports = {
  coverageThreshold: {
    global: {
      branches: 80,      // 분기 80%
      functions: 80,     // 함수 80%
      lines: 80,         // 라인 80%
      statements: 80     // 구문 80%
    },
    // 핵심 비즈니스 로직은 더 높게
    './src/services/payment/': {
      branches: 95,
      functions: 95,
      lines: 95,
      statements: 95
    }
  }
};

💡 TDD 실무 팁

1. 작은 단계로 시작

// ❌ 한 번에 복잡한 테스트
it('주문 처리 전체 플로우', async () => {
  // 100줄의 테스트...
});
 
// ✅ 작게 쪼개기
describe('주문 처리', () => {
  it('재고 확인', () => { /* ... */ });
  it('재고 차감', () => { /* ... */ });
  it('결제 처리', () => { /* ... */ });
  it('주문 상태 업데이트', () => { /* ... */ });
  it('알림 발송', () => { /* ... */ });
});

2. FIRST 원칙

  • Fast: 빠른 테스트 (수천 개가 몇 초 안에)
  • Independent: 독립적 (순서 무관)
  • Repeatable: 반복 가능 (언제든 같은 결과)
  • Self-validating: 자가 검증 (통과/실패만)
  • Timely: 적시에 (구현 전 작성)
// ❌ Independent 위반
it('사용자 생성', () => {
  user = createUser();  // 전역 변수 사용
});
 
it('사용자 수정', () => {
  updateUser(user);  // 이전 테스트에 의존
});
 
// ✅ 각 테스트 독립적
describe('사용자 관리', () => {
  let user;
 
  beforeEach(() => {
    user = createUser();  // 매번 새로 생성
  });
 
  it('사용자 생성', () => { /* ... */ });
  it('사용자 수정', () => { /* ... */ });
});

3. 테스트하기 쉬운 코드 작성

// ❌ 테스트하기 어려운 코드
class OrderService {
  createOrder(productId, quantity) {
    const product = database.query(`SELECT * FROM products WHERE id = ${productId}`);
    const user = getCurrentUser();  // 전역 함수
    const now = new Date();  // 테스트마다 다른 값
 
    // ...
  }
}
 
// ✅ 테스트하기 쉬운 코드 (의존성 주입)
class OrderService {
  constructor(productRepository, userContext, clock) {
    this.productRepository = productRepository;
    this.userContext = userContext;
    this.clock = clock;
  }
 
  createOrder(productId, quantity) {
    const product = this.productRepository.findById(productId);
    const user = this.userContext.getCurrentUser();
    const now = this.clock.now();
 
    // ...
  }
}
 
// 테스트에서 쉽게 Mock 가능
const mockClock = { now: () => new Date('2024-01-01') };
const service = new OrderService(mockRepo, mockUser, mockClock);

🚀 TDD 도입 로드맵

Phase 1: 새 기능부터 TDD 적용 (1개월)

// 신규 기능: 위시리스트
describe('WishlistService', () => {
  it('상품 추가', () => { /* ... */ });
  it('상품 제거', () => { /* ... */ });
  // TDD로 개발
});

Phase 2: 버그 수정 시 테스트 추가 (2-3개월)

// 버그 리포트: "같은 상품 중복 추가됨"
it('중복 상품 추가 방지', () => {
  wishlist.add(product1);
  wishlist.add(product1);
 
  expect(wishlist.items).toHaveLength(1);
});
 
// 테스트 작성 후 버그 수정

Phase 3: 레거시 코드 테스트 추가 (지속적)

// 기존 코드에 특성화 테스트 (Characterization Test)
describe('레거시 calculateTax', () => {
  it('현재 동작 기록', () => {
    // 현재 동작을 테스트로 기록
    expect(calculateTax(10000)).toBe(1000);
  });
 
  // 리팩토링 후에도 테스트 통과 확인
});

🔗 참고 자료