IT Knowledge/Development/images/테스트-주도-개발tdd-diagram.svg
📌 핵심 개념
TDD (Test-Driven Development)는 테스트를 먼저 작성하고 그 테스트를 통과하는 코드를 작성하는 개발 방법론입니다.
Red → Green → Refactor 사이클:
- 🔴 Red: 실패하는 테스트 작성
- 🟢 Green: 테스트를 통과하는 최소한의 코드 작성
- 🔵 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/KLOC | 10배 개선 |
| 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);
});
// 리팩토링 후에도 테스트 통과 확인
});