AI 시대에 TDD가 더 중요해진 이유 - Mock과 레이어드 아키텍처 관점에서
TL;DR
- 증상: AI가 생성한 코드가 컴파일은 되는데 로직이 틀린 경우가 많다
- 원인: AI는 비즈니스 로직의 맥락을 모르고, 사람이 일일이 검증하기 힘들다
- 해결: TDD로 테스트를 먼저 작성하면 AI 코드를 빠르게 검증할 수 있다
- 효과: 코드 품질 향상, 리팩토링 안정성 확보, AI와의 협업 효율 상승
- 한계: 초기 학습 곡선, 테스트 작성 시간 투자 필요
왜 갑자기 TDD 글을 쓰게 됐을까요?
솔직히 말하면, Cursor AI로 바이브 코딩을 하면서 “이거 왜 이렇게 불안하지?”라는 생각이 들었습니다. AI가 코드를 뚝딱 만들어주는데, 막상 돌려보면 미묘하게조금씩 틀린 경우가 많더라고요.
컴파일은 잘 되는데 로직이 틀린 코드. 이게 가장 무섭습니다.
그래서 TDD를 다시 들여다보게 됐습니다. 예전엔 “시간 없는데 테스트까지 언제 쓰냐”고 생각했는데, AI 시대엔 오히려 TDD가 더 필요하다는 걸 깨달았어요.
목차
- TDD가 뭔가요? (5분 요약)
- Mock은 뭐고 왜 필요한가요?
- 레이어드 아키텍처에서 TDD 적용하기
- DB 테스트가 어려운 이유
- AI 바이브 코딩 시대에 TDD가 더 중요한 이유
- 시스템 점검 체크리스트
TDD가 뭔가요? (5분 요약)
TDD는 Test-Driven Development의 약자입니다. 한국어로 하면 “테스트 주도 개발”이에요.
근데 이게 뭔지 설명하기 전에, 먼저 우리가 보통 어떻게 개발하는지 생각해봅시다.
일반적인 개발 순서:
1. 코드 작성
2. 실행해서 확인
3. 버그 발견
4. 수정
5. 다시 실행
6. (반복...)저도 이렇게 했습니다. 솔직히 대부분이 이렇게 하죠.
TDD 개발 순서:
1. 테스트 먼저 작성 (이 시점엔 당연히 실패)
2. 테스트가 통과할 만큼만 코드 작성
3. 리팩토링
4. (반복...)뭐가 다른지 느껴지시나요?
핵심은 “테스트를 먼저 쓴다”입니다.
Red-Green-Refactor 사이클
TDD는 세 단계를 반복합니다:
- Red: 실패하는 테스트를 먼저 작성한다
- Green: 테스트가 통과할 만큼만 코드를 작성한다
- Refactor: 코드를 깔끔하게 정리한다
이게 왜 좋냐면요.
테스트를 먼저 쓰려면 “이 코드가 뭘 해야 하는지”를 먼저 정의해야 합니다. 그래서 설계를 더 잘하게 되고, 요구사항을 더 명확하게 이해하게 됩니다.
// Red 단계: 실패하는 테스트 먼저 작성
@Test
void 주문_금액이_10만원_이상이면_배송비_무료() {
// given
Order order = Order.create(100_000);
// when
int shippingFee = order.calculateShippingFee();
// then
assertThat(shippingFee).isEqualTo(0);
}이 테스트를 먼저 쓰면, Order 클래스와 calculateShippingFee() 메서드가 필요하다는 걸 알게 됩니다. 그리고 “10만원 이상이면 배송비 무료”라는 비즈니스 규칙이 명확해지죠.
Mock은 뭐고 왜 필요한가요?
Mock을 설명하기 전에, 먼저 왜 필요한지부터 이야기할게요.
문제 상황
주문 서비스를 테스트하고 싶습니다. 근데 이 서비스는 이런 의존성이 있어요:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway; // 외부 결제 API
private final InventoryService inventoryService; // 재고 서비스
public Order placeOrder(OrderRequest request) {
// 재고 확인
if (!inventoryService.checkStock(request.getProductId())) {
throw new OutOfStockException();
}
// 결제 처리
PaymentResult result = paymentGateway.pay(request.getAmount());
if (!result.isSuccess()) {
throw new PaymentFailedException();
}
// 주문 저장
Order order = Order.create(request);
return orderRepository.save(order);
}
}OrderService를 테스트하려면 실제 결제 API를 호출해야 할까요?
당연히 안 됩니다. 테스트할 때마다 진짜 돈이 빠져나가면 큰일이죠.
Mock이란?
Mock은 “가짜 객체”입니다.
진짜 객체 대신 가짜 객체를 넣어서, 원하는 대로 동작하게 만드는 거예요.
// PaymentGateway Mock 예시
PaymentGateway mockGateway = mock(PaymentGateway.class);
// "pay() 호출하면 성공 결과를 리턴해라"라고 지시
when(mockGateway.pay(anyLong()))
.thenReturn(PaymentResult.success());이렇게 하면 실제 결제 API를 호출하지 않고도, “결제가 성공했을 때” 시나리오를 테스트할 수 있습니다.
왜 Mock이 필요한가?
- 외부 의존성 제거: 결제 API, 외부 서비스 등을 실제로 호출하지 않아도 됨
- 테스트 속도 향상: 네트워크 호출, DB 연결 없이 빠르게 테스트
- 시나리오 통제: “결제 실패”, “재고 부족” 같은 상황을 쉽게 재현
- 격리된 테스트: 다른 컴포넌트의 영향 없이 순수하게 로직만 테스트
Mockito 사용 예시
Java에서 가장 많이 쓰는 Mock 라이브러리는 Mockito입니다.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentGateway paymentGateway;
@Mock
private InventoryService inventoryService;
@InjectMocks
private OrderService orderService;
@Test
void 재고가_없으면_주문_실패() {
// given
OrderRequest request = new OrderRequest("product-1", 1, 10000);
// 재고 없다고 설정
when(inventoryService.checkStock("product-1")).thenReturn(false);
// when & then
assertThatThrownBy(() -> orderService.placeOrder(request))
.isInstanceOf(OutOfStockException.class);
// 결제 API는 호출되지 않아야 함
verify(paymentGateway, never()).pay(anyLong());
}
@Test
void 결제_성공시_주문_저장() {
// given
OrderRequest request = new OrderRequest("product-1", 1, 10000);
Order expectedOrder = Order.create(request);
when(inventoryService.checkStock("product-1")).thenReturn(true);
when(paymentGateway.pay(10000L)).thenReturn(PaymentResult.success());
when(orderRepository.save(any(Order.class))).thenReturn(expectedOrder);
// when
Order result = orderService.placeOrder(request);
// then
assertThat(result).isNotNull();
verify(orderRepository).save(any(Order.class));
}
}Mock을 쓰면 이런 게 가능해집니다:
when(...).thenReturn(...): 이 메서드 호출하면 이걸 리턴해라verify(...): 이 메서드가 호출됐는지 확인해라never(),times(n): 호출 횟수 검증
레이어드 아키텍처에서 TDD 적용하기
대부분의 Spring 프로젝트는 레이어드 아키텍처를 씁니다.
┌─────────────────┐
│ Controller │ ← 요청/응답 처리
├─────────────────┤
│ Service │ ← 비즈니스 로직
├─────────────────┤
│ Repository │ ← 데이터 접근
├─────────────────┤
│ Database │
└─────────────────┘