책을 읽게 된 계기
레거시 코드 수정할 때마다 불안했다. "이 코드를 고치면 다른 게 깨지는 건 아닐까?" 테스트 코드가 없으니 확신할 수 없었고, 매번 수동 테스트로 전체 기능을 검증했다. TDD가 답일 수 있다는 얘기를 듣고 책을 집어 들었다.
책의 구성
Part 1: TDD 시작 (1-3장)
- Red-Green-Refactor 사이클
- 테스트 코드 작성법
- 간단한 예제로 감 잡기
Part 2: TDD 기초 (4-7장)
- JUnit 5 사용법
- 단언문(Assertion) 활용
- 테스트 라이프사이클
- 대역(Test Double) 개념
Part 3: 실전 적용 (8-11장)
- 외부 의존성 처리
- 테스트 가능한 설계
- 통합 테스트
- 레거시 코드에 테스트 추가
실전 적용 - 주문 시스템
책을 읽으며 실제 프로젝트에 적용했다.
Before TDD
@Service
public class OrderService {
@Autowired
private ProductRepository productRepository;
@Autowired
private OrderRepository orderRepository;
public Order createOrder(Long productId, int quantity) {
Product product = productRepository.findById(productId).get();
product.setStock(product.getStock() - quantity);
productRepository.save(product);
Order order = new Order(product, quantity);
return orderRepository.save(order);
}
}
문제점
- 테스트 작성 어려움 (실제 DB 필요)
- 재고 부족 상황 처리 없음
- 트랜잭션 롤백 시 재고 불일치 가능
After TDD
// 1. Red - 실패하는 테스트
@Test
void 주문_생성시_재고가_부족하면_예외발생() {
// given
Product product = new Product("상품A", 5);
// when & then
assertThatThrownBy(() -> orderService.createOrder(product.getId(), 10))
.isInstanceOf(OutOfStockException.class)
.hasMessage("재고가 부족합니다");
}
// 2. Green - 최소 구현
@Service
public class OrderService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
public OrderService(ProductRepository productRepository,
OrderRepository orderRepository) {
this.productRepository = productRepository;
this.orderRepository = orderRepository;
}
@Transactional
public Order createOrder(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException());
if (product.getStock() < quantity) {
throw new OutOfStockException("재고가 부족합니다");
}
product.decreaseStock(quantity);
Order order = new Order(product, quantity);
return orderRepository.save(order);
}
}
// 3. Refactor - 개선
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private int stock;
public void decreaseStock(int quantity) {
validateStock(quantity);
this.stock -= quantity;
}
private void validateStock(int quantity) {
if (this.stock < quantity) {
throw new OutOfStockException("재고가 부족합니다");
}
}
}
테스트 코드
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private ProductRepository productRepository;
@Mock
private OrderRepository orderRepository;
@InjectMocks
private OrderService orderService;
@Test
void 주문_생성시_재고가_차감된다() {
// given
Product product = new Product("상품A", 10);
given(productRepository.findById(1L)).willReturn(Optional.of(product));
// when
orderService.createOrder(1L, 3);
// then
assertThat(product.getStock()).isEqualTo(7);
verify(orderRepository).save(any(Order.class));
}
@Test
void 주문_생성시_재고가_부족하면_예외발생() {
// given
Product product = new Product("상품A", 5);
given(productRepository.findById(1L)).willReturn(Optional.of(product));
// when & then
assertThatThrownBy(() -> orderService.createOrder(1L, 10))
.isInstanceOf(OutOfStockException.class);
assertThat(product.getStock()).isEqualTo(5); // 재고 변경 안됨
verify(orderRepository, never()).save(any());
}
@Test
void 존재하지_않는_상품_주문시_예외발생() {
// given
given(productRepository.findById(999L)).willReturn(Optional.empty());
// when & then
assertThatThrownBy(() -> orderService.createOrder(999L, 1))
.isInstanceOf(ProductNotFoundException.class);
}
}
책에서 배운 핵심 원칙
1. 테스트하기 쉬운 코드 = 좋은 설계
// Bad: 테스트 어려움
public class PaymentService {
public void processPayment(Order order) {
PaymentGateway gateway = new PaymentGateway(); // 하드코딩
gateway.charge(order.getAmount());
}
}
// Good: 의존성 주입
public class PaymentService {
private final PaymentGateway gateway;
public PaymentService(PaymentGateway gateway) {
this.gateway = gateway;
}
public void processPayment(Order order) {
gateway.charge(order.getAmount());
}
}
// Test
@Test
void 결제_처리시_게이트웨이_호출() {
PaymentGateway mockGateway = mock(PaymentGateway.class);
PaymentService service = new PaymentService(mockGateway);
service.processPayment(new Order(10000));
verify(mockGateway).charge(10000);
}
2. 테스트 범위의 균형
- 단위 테스트 70%: 빠르고 많이
- 통합 테스트 20%: 주요 플로우
- E2E 테스트 10%: 핵심 시나리오만
3. Mock의 적절한 사용
// Over-mocking (나쁜 예)
@Test
void 사용자_생성() {
User user = mock(User.class);
when(user.getName()).thenReturn("홍길동");
when(user.getEmail()).thenReturn("hong@test.com");
// 단순 객체를 mock으로 만들 필요 없음
}
// 적절한 사용
@Test
void 외부_API_호출() {
ExternalApiClient mockClient = mock(ExternalApiClient.class);
when(mockClient.fetchData()).thenReturn(testData);
// 외부 의존성만 mock
}
적용 과정의 어려움
1주차 : 테스트 작성이 더 오래 걸림
- 구현: 30분
- 테스트: 1시간
- "이래서 언제 개발하나" 싶었음
2주차 : 속도 비슷해짐
- 테스트 패턴 익숙해짐
- 재사용 가능한 픽스처 구축
1개월 후 : 전체 개발 시간 단축
- 디버깅 시간 대폭 감소
- 리팩토링 자신감
- 버그 발견이 개발 단계로 이동
측정 가능한 효과
정량적
- 코드 커버리지: 30% → 75%
- 프로덕션 버그: 월 8건 → 2건
- 핫픽스 배포: 월 5회 → 1회
- 리팩토링 시간: 평균 4시간 → 1시간
정성적
- 레거시 코드 수정 두려움 감소
- 코드 리뷰에서 버그 논의 감소
- 새 기능 추가 속도 향상
책의 한계
1. 레거시 코드 다루기는 표면적
- 10장에서 다루지만 깊이 부족
- 실무 레거시는 훨씬 복잡
- "Working Effectively with Legacy Code" 병행 추천
2. Spring 통합 테스트 설명 부족
- @SpringBootTest, @DataJpaTest 등은 직접 학습 필요
- 테스트 속도 개선 방법 미흡
3. 고급 Mock 기법 부족
- Mockito ArgumentCaptor
- @SpyBean vs @MockBean
- 실무에서 필요한 내용은 공식 문서 참조
결론
이 책은 TDD의 "왜"와 "어떻게"를 모두 다룬다. 완벽하지 않지만, 시작점으로는 최고다.
핵심 메시지 : 테스트는 비용이 아니라 투자다. 초기 시간이 들지만, 장기적으론 개발 속도를 높인다.
실무에 적용하려면 책만으로 부족하다. 직접 부딪히며 배워야 한다. 하지만 이 책이 그 출발점이 되어줄 것이다.
TDD 늦었다고 생각할 때가 가장 빠르다.
'도서' 카테고리의 다른 글
| [IT/CS] - Clean Code (클린 코드) (0) | 2024.05.07 |
|---|---|
| [IT/CS] - 면접을 위한 CS 전공지식 노트 후기 (0) | 2024.02.16 |