Skip to content

AI 시대에 TDD가 더 중요해진 이유 - Mock과 레이어드 아키텍처 관점에서

AI 시대에 TDD가 더 중요해진 이유 - Mock과 레이어드 아키텍처 관점에서

TL;DR

  • 증상: AI가 생성한 코드가 컴파일은 되는데 로직이 틀린 경우가 많다
  • 원인: AI는 비즈니스 로직의 맥락을 모르고, 사람이 일일이 검증하기 힘들다
  • 해결: TDD로 테스트를 먼저 작성하면 AI 코드를 빠르게 검증할 수 있다
  • 효과: 코드 품질 향상, 리팩토링 안정성 확보, AI와의 협업 효율 상승
  • 한계: 초기 학습 곡선, 테스트 작성 시간 투자 필요

왜 갑자기 TDD 글을 쓰게 됐을까요?

솔직히 말하면, Cursor AI로 바이브 코딩을 하면서 “이거 왜 이렇게 불안하지?”라는 생각이 들었습니다. AI가 코드를 뚝딱 만들어주는데, 막상 돌려보면 미묘하게조금씩 틀린 경우가 많더라고요.

컴파일은 잘 되는데 로직이 틀린 코드. 이게 가장 무섭습니다.

그래서 TDD를 다시 들여다보게 됐습니다. 예전엔 “시간 없는데 테스트까지 언제 쓰냐”고 생각했는데, AI 시대엔 오히려 TDD가 더 필요하다는 걸 깨달았어요.


목차

  1. TDD가 뭔가요? (5분 요약)
  2. Mock은 뭐고 왜 필요한가요?
  3. 레이어드 아키텍처에서 TDD 적용하기
  4. DB 테스트가 어려운 이유
  5. AI 바이브 코딩 시대에 TDD가 더 중요한 이유
  6. 시스템 점검 체크리스트

TDD가 뭔가요? (5분 요약)

TDD는 Test-Driven Development의 약자입니다. 한국어로 하면 “테스트 주도 개발”이에요.

근데 이게 뭔지 설명하기 전에, 먼저 우리가 보통 어떻게 개발하는지 생각해봅시다.

일반적인 개발 순서:

1. 코드 작성
2. 실행해서 확인
3. 버그 발견
4. 수정
5. 다시 실행
6. (반복...)

저도 이렇게 했습니다. 솔직히 대부분이 이렇게 하죠.

TDD 개발 순서:

1. 테스트 먼저 작성 (이 시점엔 당연히 실패)
2. 테스트가 통과할 만큼만 코드 작성
3. 리팩토링
4. (반복...)

뭐가 다른지 느껴지시나요?

핵심은 “테스트를 먼저 쓴다”입니다.

Red-Green-Refactor 사이클

TDD는 세 단계를 반복합니다:

  1. Red: 실패하는 테스트를 먼저 작성한다
  2. Green: 테스트가 통과할 만큼만 코드를 작성한다
  3. 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이 필요한가?

  1. 외부 의존성 제거: 결제 API, 외부 서비스 등을 실제로 호출하지 않아도 됨
  2. 테스트 속도 향상: 네트워크 호출, DB 연결 없이 빠르게 테스트
  3. 시나리오 통제: “결제 실패”, “재고 부족” 같은 상황을 쉽게 재현
  4. 격리된 테스트: 다른 컴포넌트의 영향 없이 순수하게 로직만 테스트

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     │
└─────────────────┘

각 레이어별 테스트 전략

1. Controller 레이어

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private OrderService orderService;
    
    @Test
    void 주문_생성_성공시_201_응답() throws Exception {
        // given
        OrderRequest request = new OrderRequest("product-1", 1, 10000);
        Order order = Order.create(request);
        when(orderService.placeOrder(any())).thenReturn(order);
        
        // when & then
        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.orderId").exists());
    }
}

Controller는 @WebMvcTest로 테스트합니다. Service는 @MockBean으로 Mock 처리하고, HTTP 요청/응답만 검증해요.

2. Service 레이어

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;
    
    @InjectMocks
    private OrderService orderService;
    
    @Test
    void 주문_생성시_주문번호_생성() {
        // given
        OrderRequest request = new OrderRequest("product-1", 1, 10000);
        when(orderRepository.save(any())).thenAnswer(invocation -> {
            Order order = invocation.getArgument(0);
            return order;
        });
        
        // when
        Order order = orderService.createOrder(request);
        
        // then
        assertThat(order.getOrderNumber()).isNotBlank();
    }
}

Service는 순수한 단위 테스트입니다. Repository를 Mock으로 처리해서 DB 없이 비즈니스 로직만 테스트해요.

3. Repository 레이어 (여기가 문제)

Repository 테스트는 좀 복잡합니다. 이건 다음 섹션에서 자세히 다룰게요.

레이어별 테스트 범위

레이어테스트 방식Mock 대상검증 범위
Controller@WebMvcTestServiceHTTP 요청/응답
Service단위 테스트Repository비즈니스 로직
Repository통합 테스트없음 (실제 DB)SQL/JPA 동작

DB 테스트가 어려운 이유

솔직히 말하면, 저도 Repository 테스트는 잘 안 썼습니다.

왜냐면 귀찮거든요. 근데 왜 귀찮은지 생각해보면 이런 이유들이 있습니다.

1. 테스트 환경 세팅이 복잡하다

Repository를 테스트하려면 실제 DB가 필요합니다. 근데 테스트할 때마다 로컬 MySQL 띄우기 싫죠.

해결책: H2 인메모리 DB 또는 Testcontainers

// application-test.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop

근데 H2와 MySQL은 SQL 문법이 조금 달라서, 가끔 로컬에선 통과하는데 운영에서 터지는 경우가 있습니다.

그래서 요즘은 Testcontainers로 실제 MySQL 컨테이너를 띄워서 테스트하는 경우가 많아요.

@Testcontainers
@DataJpaTest
class OrderRepositoryTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
    
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }
}

2. 테스트 데이터 관리가 귀찮다

테스트마다 데이터를 넣었다 지웠다 해야 합니다.

@BeforeEach
void setUp() {
    // 테스트 데이터 세팅
    orderRepository.save(Order.create(...));
}

@AfterEach
void tearDown() {
    // 데이터 정리
    orderRepository.deleteAll();
}

이게 테스트가 많아지면 엄청 복잡해져요. 그리고 테스트 간에 데이터가 섞이면 “어? 내 로컬에선 되는데?”라는 상황이 생깁니다.

3. 테스트 속도가 느리다

DB 연결, 트랜잭션 처리, 데이터 삽입/삭제… 이런 작업들이 시간을 잡아먹습니다.

Mock 기반 단위 테스트는 수백 개가 1초 안에 끝나는데, DB 통합 테스트는 20개만 해도 10초 넘게 걸리기도 해요.

4. 그래서 어떻게 해야 하나요?

실용적인 접근:

  1. Repository 테스트는 최소화: JPA의 기본 메서드(save, findById 등)는 테스트 안 해도 됨
  2. 커스텀 쿼리만 테스트: @Query로 작성한 복잡한 쿼리만 테스트
  3. Service 레이어에서 Mock 처리: Repository를 Mock으로 대체해서 비즈니스 로직 테스트
  4. 통합 테스트는 중요한 시나리오만: 전체 플로우가 필요한 경우에만 사용
// 이런 건 굳이 테스트 안 해도 됨 (JPA가 알아서 처리)
Order save(Order order);
Optional<Order> findById(Long id);

// 이런 건 테스트하는 게 좋음 (비즈니스 로직이 있음)
@Query("SELECT o FROM Order o WHERE o.userId = :userId AND o.status = :status")
List<Order> findByUserIdAndStatus(@Param("userId") Long userId, @Param("status") OrderStatus status);

AI 바이브 코딩 시대에 TDD가 더 중요한 이유

드디어 본론입니다.

저도 요즘 Cursor AI로 많이 개발합니다. “이런 기능 만들어줘”라고 하면 뚝딱 코드가 나오거든요.

근데 문제가 있습니다.

AI가 만든 코드의 함정

AI는 문법적으로 올바른 코드를 잘 만듭니다. 컴파일 에러? 거의 없어요.

하지만 비즈니스 로직이 맞는지는 다른 문제입니다.

예를 들어, 이런 요청을 했다고 해봅시다:

“할인 쿠폰 적용 로직 만들어줘. 10% 할인 쿠폰이야.”

AI가 이렇게 만들었어요:

public int applyDiscount(int price, Coupon coupon) {
    if (coupon.getType() == CouponType.PERCENTAGE) {
        return price - (price * coupon.getDiscountRate() / 100);
    }
    return price;
}

언뜻 보면 맞는 것 같죠? 근데 저희 서비스에선:

  • 최소 주문 금액이 있음 (1만원 이상만 쿠폰 적용)
  • 최대 할인 금액이 있음 (최대 5천원)
  • 이미 사용한 쿠폰은 적용 불가

AI는 이런 비즈니스 규칙을 모릅니다.

TDD가 AI의 가드레일이 되는 이유

TDD로 테스트를 먼저 작성하면:

@Test
void 최소_주문_금액_미만이면_쿠폰_적용_불가() {
    Order order = Order.of(8000);
    Coupon coupon = Coupon.percentage(10);
    
    assertThatThrownBy(() -> order.applyCoupon(coupon))
        .isInstanceOf(MinimumOrderAmountException.class);
}

@Test
void 할인_금액은_최대_5천원() {
    Order order = Order.of(100000);
    Coupon coupon = Coupon.percentage(10);  // 1만원 할인 예상
    
    // 근데 최대 5천원까지만
    int discountedPrice = order.applyCoupon(coupon);
    assertThat(discountedPrice).isEqualTo(95000);  // 5천원만 할인
}

@Test
void 이미_사용한_쿠폰은_적용_불가() {
    Coupon usedCoupon = Coupon.percentage(10);
    usedCoupon.markAsUsed();
    
    assertThatThrownBy(() -> order.applyCoupon(usedCoupon))
        .isInstanceOf(CouponAlreadyUsedException.class);
}

이 테스트들이 AI가 모르는 비즈니스 규칙을 명세합니다.

AI에게 “이 테스트 통과하도록 코드 수정해줘”라고 하면, 비즈니스 규칙에 맞는 코드가 나옵니다.

AI + TDD 워크플로우

제가 실제로 쓰는 방식입니다:

1. 요구사항을 테스트로 먼저 작성
2. AI에게 "이 테스트 통과하도록 코드 만들어줘" 요청
3. 테스트 실행해서 통과 확인
4. 리팩토링 (AI에게 요청하거나 직접)

이렇게 하면:

  • AI 코드 검증이 자동화됨: 테스트만 돌리면 됨
  • 비즈니스 로직 누락 방지: 테스트에 명시되어 있으니까
  • 리팩토링 안전망: AI가 코드 바꿔도 테스트가 지켜줌

Non-deterministic한 AI 출력에 대한 가드레일

AI는 같은 질문에도 다른 답을 줍니다. 오늘 만든 코드와 내일 만든 코드가 다를 수 있어요.

TDD는 이런 비결정적인(non-deterministic) 출력에 대한 가드레일입니다.

// 이 테스트는 AI 코드가 뭐가 됐든 검증해줌
@Test
void 주문_상태는_PENDING에서_시작() {
    Order order = Order.create(request);
    assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
}

AI가 코드를 어떻게 짜든, 이 테스트를 통과해야 합니다. 통과 못 하면 잘못된 거고요.


시스템 점검 체크리스트

TDD 도입을 고민 중이라면, 이것만 체크해보세요:

1. 현재 테스트 커버리지 확인

./gradlew test jacocoTestReport
  • 0%에 가까우면? → TDD 도입 효과가 클 거예요
  • 이미 70% 이상이면? → 기존 테스트 방식 유지하면서 점진적으로

2. 가장 버그가 많은 영역 파악

  • 어떤 서비스에서 버그가 많이 나오나요?
  • 그 서비스에 테스트가 있나요?
  • 없다면 거기부터 시작하세요

3. 외부 의존성 정리

  • 결제 API, 외부 서비스 등 Mock이 필요한 부분 파악
  • Mockito 의존성 추가했는지 확인

4. CI/CD에 테스트 연동

# GitHub Actions 예시
- name: Run Tests
  run: ./gradlew test
  • PR마다 테스트 자동 실행되도록 설정
  • 테스트 실패하면 머지 못 하게 막기

5. 팀 규칙 정하기

  • 새로운 기능은 테스트와 함께 작성
  • 버그 수정 시 재현 테스트 먼저 작성
  • 코드 리뷰에서 테스트 커버리지 확인

마무리

TDD는 “테스트를 먼저 쓴다”라는 단순한 규칙인데, 이게 AI 시대에 더 빛을 발합니다.

AI가 코드를 만들어주는 건 좋은데, 그 코드가 맞는지 검증하는 건 결국 사람 몫이거든요. 근데 테스트가 있으면 이 검증을 자동화할 수 있습니다.

저도 아직 TDD를 완벽하게 하진 못합니다. 급한 일이 있으면 테스트 없이 코드부터 짜기도 해요. 근데 그럴 때마다 나중에 후회합니다.

“아, 테스트 있었으면 이거 바로 잡았을 텐데…”

TDD는 완벽하게 하려고 하면 오히려 힘들어요. 처음엔 중요한 비즈니스 로직만 테스트 달아보세요. 그것만 해도 많이 달라집니다.

읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site