“테스트는 나중에 쓰면 되잖아요?” 맞다. 그런데 나중에 쓰는 테스트와 먼저 쓰는 테스트는 근본적으로 다르다. 코드를 먼저 짜면 테스트는 그 코드의 동작을 확인하는 도구가 된다. 테스트를 먼저 쓰면 테스트가 설계 도구가 된다. TDD는 테스트 방법론이 아니라 설계 방법론이다.

TDD란 무엇인가

비유: 집을 지을 때 설계도(테스트)를 먼저 그리고 집(코드)을 짓는 것이다. 설계도 없이 벽돌부터 쌓으면 나중에 “화장실이 거실 한가운데”인 상황이 생긴다. 설계도가 먼저 있으면 짓기 전에 문제를 발견한다.

graph LR
    Red["Red"] --> Green["Green"]
    Green --> Refactor["Refactor"]
    Refactor --> Red

TDD의 세 가지 법칙 (Robert C. Martin):

  1. 실패하는 단위 테스트를 먼저 작성하기 전에는 프로덕션 코드를 작성하지 않는다.
  2. 컴파일이 되면서 실행이 실패하는 정도로만 테스트를 작성한다.
  3. 현재 실패하는 테스트를 통과하는 정도로만 프로덕션 코드를 작성한다.

만약 이 규칙 없이 코드를 먼저 짜면? 테스트를 나중에 추가할 때 이미 구현된 코드에 맞게 테스트를 짜게 된다. “잘 통과하는 테스트”를 만드는 데 집중하고, “올바른 동작을 검증하는 테스트”가 아니게 된다.


TDD 실전: 계산기 → 주문 서비스

Step 1: 실패하는 테스트 먼저 (Red)

Calculator 클래스가 아직 존재하지 않는다. 테스트가 컴파일조차 안 된다. 그게 정상이다.

class CalculatorTest {

    @Test
    void 두_수를_더할_수_있다() {
        // given
        Calculator calculator = new Calculator();  // 아직 없는 클래스

        // when
        int result = calculator.add(3, 4);

        // then
        assertThat(result).isEqualTo(7);
    }

    @Test
    void 0으로_나누면_예외가_발생한다() {
        Calculator calculator = new Calculator();

        assertThatThrownBy(() -> calculator.divide(10, 0))
            .isInstanceOf(ArithmeticException.class)
            .hasMessage("0으로 나눌 수 없습니다");
    }
}
// 컴파일 에러! → RED 단계

Step 2: 통과하는 최소 코드 (Green)

“최소한”이 중요하다. 테스트를 통과하는 가장 단순한 코드를 쓴다:

public class Calculator {

    public int add(int a, int b) {
        return a + b;  // 가장 단순한 구현
    }

    public int divide(int a, int b) {
        if (b == 0) {
            throw new ArithmeticException("0으로 나눌 수 없습니다");
        }
        return a / b;
    }
}
// 테스트 통과! → GREEN 단계

Step 3: 리팩토링 (Refactor)

테스트가 있으므로 마음 놓고 내부를 바꿀 수 있다. 테스트가 깨지면 실수한 것이다:

public class Calculator {
    // 상수 추출, 검증 메서드 분리 등 — 동작은 그대로
    private void validateNotZero(int divisor) {
        if (divisor == 0) {
            throw new ArithmeticException("0으로 나눌 수 없습니다");
        }
    }

    public int divide(int a, int b) {
        validateNotZero(b);
        return a / b;
    }
}
// 테스트 여전히 통과 → REFACTOR 완료

실무 예제: 주문 서비스 TDD

단순 계산기가 아닌 실제 비즈니스 로직으로 TDD를 적용한다.

요구사항: “재고가 있으면 주문하고, 없으면 예외를 던진다”

Red — 테스트 먼저:

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private ProductRepository productRepository;

    @Mock
    private OrderRepository orderRepository;

    @InjectMocks
    private OrderService orderService;

    @Test
    void 재고가_있으면_주문이_생성된다() {
        // given
        Product product = Product.builder()
            .id(1L).name("노트북").price(1_000_000).stock(5).build();

        given(productRepository.findById(1L)).willReturn(Optional.of(product));
        given(orderRepository.save(any())).willAnswer(inv -> inv.getArgument(0));

        // when
        Order order = orderService.createOrder(1L, 2);  // 아직 없는 메서드

        // then
        assertThat(order.getQuantity()).isEqualTo(2);
        assertThat(order.getTotalPrice()).isEqualTo(2_000_000);
    }

    @Test
    void 재고가_부족하면_예외가_발생한다() {
        // given
        Product product = Product.builder()
            .id(1L).name("노트북").stock(1).build();  // 재고 1개

        given(productRepository.findById(1L)).willReturn(Optional.of(product));

        // when & then
        assertThatThrownBy(() -> orderService.createOrder(1L, 5))  // 5개 주문
            .isInstanceOf(InsufficientStockException.class)
            .hasMessageContaining("재고 부족");
    }
}

Green — 최소 구현:

@Service
@RequiredArgsConstructor
public class OrderService {

    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;

    public Order createOrder(Long productId, int quantity) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));

        // 비즈니스 규칙: 재고 확인
        if (product.getStock() < quantity) {
            throw new InsufficientStockException(
                "재고 부족: 요청 " + quantity + ", 현재 " + product.getStock()
            );
        }

        Order order = Order.builder()
            .productId(productId)
            .quantity(quantity)
            .totalPrice(product.getPrice() * quantity)
            .build();

        return orderRepository.save(order);
    }
}

테스트를 먼저 써야 이 코드의 인터페이스(메서드 시그니처, 예외 타입, 반환값)가 자연스럽게 결정된다. 코드를 먼저 쓰면 “일단 구현하고 나중에 맞춰보자”가 된다.


테스트 피라미드 — 어떤 테스트를 얼마나 써야 하는가

graph LR
    E2E["E2E 테스트"]
    Integration["통합 테스트"]
    Unit["단위 테스트"]
    style E2E fill:#f88,stroke:#c00,color:#000
    style Integration fill:#ff8,stroke:#880,color:#000
    style Unit fill:#8f8,stroke:#080,color:#000

단위 테스트를 70%로 가져가는 이유: E2E 테스트 1개가 실패하면 어디서 깨졌는지 알기 어렵다. 단위 테스트가 깨지면 정확히 어떤 클래스의 어떤 조건에서 문제가 생겼는지 즉시 알 수 있다.


Mockito — 의존성 격리

@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {

    @Mock
    private PaymentGateway paymentGateway;   // 외부 결제 API

    @Mock
    private OrderRepository orderRepository;

    @InjectMocks
    private PaymentService paymentService;

    @Test
    void 결제_성공_시_주문_상태가_변경된다() {
        // given — 외부 API가 성공 응답을 반환한다고 가정
        Order order = Order.builder().id(1L).totalPrice(50_000).build();
        given(orderRepository.findById(1L)).willReturn(Optional.of(order));
        given(paymentGateway.pay(any())).willReturn(PaymentResult.success("TX-001"));

        // when
        paymentService.processPayment(1L);

        // then
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
        // 외부 API가 정확히 1번 호출됐는지 검증
        then(paymentGateway).should(times(1)).pay(any());
    }

    @Test
    void 결제_실패_시_예외가_발생한다() {
        // given — 외부 API가 실패를 반환
        given(orderRepository.findById(1L)).willReturn(
            Optional.of(Order.builder().id(1L).build())
        );
        given(paymentGateway.pay(any())).willReturn(PaymentResult.fail("잔액 부족"));

        // when & then
        assertThatThrownBy(() -> paymentService.processPayment(1L))
            .isInstanceOf(PaymentFailedException.class);
    }
}

Mock을 쓰는 이유: 단위 테스트에서 실제 결제 API를 호출하면 돈이 나간다. DB가 없어도 테스트가 실행되어야 빠르게 피드백을 받을 수 있다.


Testcontainers — 실제 DB로 통합 테스트

Mock으로는 실제 DB 동작(인덱스, 제약조건, 트랜잭션)을 검증할 수 없다. Testcontainers는 테스트 시 실제 Docker 컨테이너를 띄운다:

@SpringBootTest
@Testcontainers
class OrderRepositoryTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        // 테스트 컨테이너의 동적 포트를 Spring 설정에 주입
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void 주문을_저장하고_조회할_수_있다() {
        // given
        Order order = Order.builder()
            .userId(1L).productId(1L).quantity(2).totalPrice(100_000).build();

        // when
        Order saved = orderRepository.save(order);

        // then
        assertThat(saved.getId()).isNotNull();
        assertThat(orderRepository.findById(saved.getId())).isPresent();
    }
}

Testcontainers 없이 H2 메모리 DB로 테스트하면? MySQL 특화 쿼리나 제약조건이 H2에서는 다르게 동작해서 프로덕션에서만 에러가 난다.


BDD — 비즈니스 언어로 테스트 쓰기

// 기존 TDD
@Test
void orderService_createOrder_withInsufficientStock_throwsException() { ... }

// BDD — Given/When/Then + 비즈니스 언어
@Test
void 재고가_부족할_때_주문하면_예외가_발생한다() {
    // Given: 재고가 1개인 상품이 있다
    given(productRepository.findById(1L))
        .willReturn(Optional.of(product.withStock(1)));

    // When: 재고보다 많은 수량을 주문하면
    ThrowableAssert.ThrowingCallable action = () -> orderService.createOrder(1L, 5);

    // Then: 재고 부족 예외가 발생한다
    assertThatThrownBy(action)
        .isInstanceOf(InsufficientStockException.class);
}

BDD 스타일의 장점: 테스트 이름과 Given/When/Then 구조가 비즈니스 요구사항을 그대로 표현한다. 기획자나 PM이 읽어도 의미를 이해할 수 있다.


TDD가 어려운 이유와 해결책

어려움 해결책
“뭘 테스트해야 할지 모르겠다” 요구사항을 시나리오로 쪼개라. “정상 케이스”, “예외 케이스”, “경계값”
“테스트가 너무 느리다” 단위 테스트에서 DB/외부 API는 Mock으로 격리
“레거시 코드에 테스트를 못 붙인다” 변경이 필요한 부분만 작은 메서드로 추출 후 테스트 추가
“테스트가 구현 세부사항에 종속된다” 내부 구현이 아닌 공개 인터페이스 기준으로 테스트

TDD 실천 흐름

graph LR
    REQ["요구사항"] --> RED["Red: 실패 테스트 작성"]
    RED --> GREEN["Green: 최소 코드 구현"]
    GREEN --> REF["Refactor: 코드 개선"]
    REF -->|"다음 요구사항"| RED
    REF -->|"완료"| DONE["배포 가능 코드"]

정리

항목 핵심
TDD 사이클 Red → Green → Refactor 반복
가장 큰 혜택 설계 개선 — 테스트하기 어려운 코드는 설계가 나쁜 코드
단위 테스트 Mock으로 의존성 격리, 빠르고 독립적으로
통합 테스트 Testcontainers로 실제 환경에 가깝게
테스트 피라미드 단위 70% + 통합 20% + E2E 10%

왜 TDD인가? (vs 테스트 후 작성 vs 테스트 없음)

방식 테스트 작성 시점 설계 영향 커버리지 실무 현실
TDD 코드 작성 전 설계 개선 강제 높음 도입 초기 속도 느림
테스트 후 작성 구현 후 기존 설계 그대로 중간 의지 없으면 건너뜀
테스트 없음 없음 영향 없음 0% 레거시 시스템에 흔함
TDD의 핵심 가치는 "커버리지"가 아닌 "설계":

테스트하기 어려운 코드 = 설계가 나쁜 코드
  - 의존성이 너무 많은 클래스 → 생성자에 파라미터 10개
  - 정적 메서드 남용 → Mock 불가
  - 전역 상태 → 테스트 순서 의존성

TDD를 하면:
  → "이 코드를 테스트하려면 어떻게 해야 하지?" 고민
  → 자연스럽게 단일 책임, 의존성 주입으로 설계 개선
  → 테스트 가능한 코드 = 변경하기 쉬운 코드

실무에서 자주 하는 실수

실수 1: 구현 세부사항을 테스트 (깨지기 쉬운 테스트)

// 나쁜 예 — private 메서드, 내부 호출 순서 검증
@Test
void 내부_메서드_호출_검증() {
    orderService.createOrder(request);
    // 내부 구현 변경 시 테스트 깨짐
    verify(orderRepository, times(1)).save(any());
    verify(inventoryRepository, times(1)).decreaseStock(any());
    verify(notificationRepository, times(1)).save(any());  // 순서 강제
}

// 좋은 예 — 공개 인터페이스, 비즈니스 결과 검증
@Test
void 주문_생성_시_주문이_저장된다() {
    OrderResult result = orderService.createOrder(request);
    assertThat(result.getOrderId()).isNotNull();
    assertThat(result.getStatus()).isEqualTo(OrderStatus.PENDING);
    // 어떻게 저장했는지(구현)가 아닌 결과를 검증
}

실수 2: 하나의 테스트에 여러 개 검증

// 나쁜 예 — 하나의 테스트에 여러 assert
@Test
void 주문_생성_전체_검증() {
    OrderResult result = orderService.createOrder(request);
    assertThat(result.getOrderId()).isNotNull();        // 1번 실패 시
    assertThat(result.getAmount()).isEqualTo(10000);   // 이하 실행 안 됨
    assertThat(result.getStatus()).isEqualTo(PENDING);
    verify(eventPublisher).publish(any());
}

// 좋은 예 — 단일 책임 테스트
@Test void 주문_생성_시_ID가_발급된다() { ... }
@Test void 주문_생성_시_금액이_정확하다() { ... }
@Test void 주문_생성_시_이벤트가_발행된다() { ... }

실수 3: 테스트에서 실제 시간 의존 (flaky test)

// 나쁜 예 — 현재 시간에 의존
@Test
void 주문_생성_시_생성시각이_기록된다() {
    Order order = orderService.createOrder(request);
    assertThat(order.getCreatedAt()).isEqualTo(LocalDateTime.now()); // 가끔 실패
}

// 좋은 예 — Clock 주입으로 시간 고정
@Test
void 주문_생성_시_생성시각이_기록된다() {
    Clock fixedClock = Clock.fixed(Instant.parse("2026-05-01T10:00:00Z"), ZoneOffset.UTC);
    OrderService service = new OrderService(orderRepository, fixedClock);
    Order order = service.createOrder(request);
    assertThat(order.getCreatedAt()).isEqualTo(LocalDateTime.of(2026, 5, 1, 10, 0, 0));
}

실수 4: Mock을 과도하게 사용 (Mock Hell)

// 나쁜 예 — 의존성 10개를 전부 Mock
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock UserRepository userRepo;
    @Mock ProductRepository productRepo;
    @Mock InventoryService inventoryService;
    @Mock PaymentService paymentService;
    @Mock NotificationService notificationService;
    @Mock CouponService couponService;
    @Mock ShippingService shippingService;
    // Mock 설정 코드가 테스트 코드보다 많아짐
    // → 서비스가 너무 많은 것을 알고 있다는 설계 냄새

// 해결: 서비스 분리 (Facade 패턴) 또는 의존성 줄이기

실수 5: 테스트 데이터를 각 테스트에서 중복 정의

// 나쁜 예 — 매 테스트마다 같은 객체 생성
@Test void test1() { User user = new User(1L, "김철수", "test@email.com", ...); }
@Test void test2() { User user = new User(1L, "김철수", "test@email.com", ...); }

// 좋은 예 — Test Fixture / Object Mother 패턴
class UserFixture {
    public static User defaultUser() {
        return User.builder().id(1L).name("김철수").email("test@email.com").build();
    }
    public static User adminUser() {
        return defaultUser().toBuilder().role(Role.ADMIN).build();
    }
}
@Test void test1() { User user = UserFixture.defaultUser(); }

면접 포인트

Q. TDD의 Red-Green-Refactor 사이클을 설명하세요.

Red: 실패하는 테스트를 먼저 작성
  → 아직 구현이 없으므로 컴파일 에러 또는 테스트 실패
  → 이 단계의 목적: "무엇을 구현해야 하는가" 명확히 정의

Green: 테스트를 통과하는 최소한의 코드 작성
  → 코드 품질보다 테스트 통과가 목적
  → 가장 단순한 방법으로 통과시킴 (심지어 하드코딩도 OK)

Refactor: 코드 품질 개선
  → 테스트가 통과하는 상태를 유지하면서
  → 중복 제거, 네이밍 개선, 구조 개선
  → 테스트가 안전망 역할

Q. 단위 테스트와 통합 테스트의 차이는?

단위 테스트:
  범위: 하나의 클래스/메서드
  의존성: Mock으로 격리
  속도: 매우 빠름 (ms 단위)
  목적: 비즈니스 로직 검증

통합 테스트:
  범위: 여러 컴포넌트 또는 외부 시스템 포함
  의존성: 실제 DB, 실제 Redis (Testcontainers)
  속도: 느림 (초 단위)
  목적: 컴포넌트 간 협력, 실제 환경 검증

테스트 피라미드: 단위 70% + 통합 20% + E2E 10%

Q. 테스트하기 어려운 코드의 특징은?

1. 정적 메서드 의존 (LocalDateTime.now(), Math.random())
   → Mock 불가 → 시간/랜덤을 주입받도록 변경

2. new 키워드로 직접 객체 생성 (new ExternalApiClient())
   → 의존성 주입(DI)으로 변경 → Mock 주입 가능

3. 전역 상태 (static 변수, Singleton)
   → 테스트 간 상태 공유 → 순서 의존성

4. 너무 많은 책임 (God Class)
   → 단일 책임으로 분리

결론: 테스트하기 어려운 코드 = 변경하기 어려운 코드
     TDD는 결국 좋은 설계를 강제하는 방법론

함께 읽으면 좋은 글

카테고리:

업데이트:

댓글

이 글이 도움이 됐다면?

같은 카테고리의 다른 글도 확인해보세요

더 많은 글 보기 →