Logic in Code,
Freedom in Travel.

인생 뭐 있나 사람 사는거 다 똑같지

도서

[IT/방법론] - 테스트 주도 개발 시작하기 Java

귀찮은 개발자 2024. 2. 16. 02:28 계산 중...
목차 (Table of Contents)

책을 읽게 된 계기

레거시 코드 수정할 때마다 불안했다. "이 코드를 고치면 다른 게 깨지는 건 아닐까?" 테스트 코드가 없으니 확신할 수 없었고, 매번 수동 테스트로 전체 기능을 검증했다. 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