이 글은 Claude Opus 4.6 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.


도입#

레거시 코드를 변경하려면 먼저 테스트를 작성해야 합니다. 하지만 테스트를 작성하려면 코드의 의존성을 깨야 합니다. 의존성을 깨려면 코드를 변경해야 하는데, 변경의 안전성을 보장해줄 테스트가 아직 없습니다.

이것이 Feathers가 말하는 “레거시 코드의 딜레마"입니다.

In legacy code, the weights work against us. We have to pull out a piece to work with because we can’t easily test it in place. — Michael Feathers, Working Effectively with Legacy Code (2004)

이 딜레마를 해결하는 첫 번째 단계는 코드를 테스트 하네스(test harness) 에 넣는 것입니다. 테스트 하네스란 테스트 코드에서 대상 코드를 인스턴스화하고 실행할 수 있는 상태를 의미합니다. 이 파트에서는 원서 9~13장의 핵심 기법들을 JDK 25 코드로 다룹니다.

클래스를 테스트 하네스에 넣기#

클래스를 테스트에서 인스턴스화하려고 할 때 가장 흔히 부딪히는 장벽은 생성자의 의존성입니다. 생성자가 데이터베이스 커넥션을 열거나, 외부 서비스에 연결하거나, 파일 시스템에 접근하면 테스트 환경에서 해당 클래스를 생성할 수 없습니다.

Feathers는 이런 장벽을 유형별로 분류하고 각각에 대한 해법을 제시합니다.

Case 1: The Hidden Dependency (숨겨진 의존성)#

생성자 내부에서 직접 의존 객체를 생성하는 경우입니다. 클래스의 공개 인터페이스만 봐서는 어떤 의존성이 있는지 알 수 없습니다.

Before:

public class ReportGenerator {
    private final DatabaseConnection connection;
    private final PdfRenderer renderer;

    public ReportGenerator() {
        // 생성자에서 직접 의존성 생성 — 테스트 불가능
        this.connection = new DatabaseConnection("jdbc:mysql://prod:3306/reports");
        this.renderer = new PdfRenderer("/usr/local/fonts");
    }

    public byte[] generate(String reportId) {
        var data = connection.query("SELECT * FROM reports WHERE id = ?", reportId);
        return renderer.render(data);
    }
}

테스트에서 new ReportGenerator()를 호출하면 프로덕션 데이터베이스에 연결을 시도합니다. 테스트가 불가능합니다.

After (JDK 25 – Parameterize Constructor):

public class ReportGenerator {
    private final DatabaseConnection connection;
    private final PdfRenderer renderer;

    // 테스트용 생성자: 의존성 주입
    public ReportGenerator(DatabaseConnection connection, PdfRenderer renderer) {
        this.connection = connection;
        this.renderer = renderer;
    }

    // 기존 호환성을 위한 생성자 (production 용)
    public ReportGenerator() {
        this(
            new DatabaseConnection("jdbc:mysql://prod:3306/reports"),
            new PdfRenderer("/usr/local/fonts")
        );
    }

    public byte[] generate(String reportId) {
        var data = connection.query("SELECT * FROM reports WHERE id = ?", reportId);
        return renderer.render(data);
    }
}

핵심은 의존성을 매개변수로 받는 생성자를 추가하고, 기존 기본 생성자는 이 새 생성자에 위임하도록 변경하는 것입니다. 기존 호출 코드는 전혀 수정할 필요가 없습니다.

// 테스트
@ExtendWith(MockitoExtension.class)
class ReportGeneratorTest {
    @Mock DatabaseConnection connection;
    @Mock PdfRenderer renderer;

    @Test
    @DisplayName("리포트 데이터를 조회하여 PDF를 생성한다")
    void generatesPdfFromReportData() {
        var generator = new ReportGenerator(connection, renderer);
        var testData = new ReportData(Map.of("title", "Q4 Report"));
        when(connection.query(anyString(), eq("RPT-001"))).thenReturn(testData);
        when(renderer.render(testData)).thenReturn(new byte[]{0x25, 0x50, 0x44, 0x46}); // %PDF

        byte[] result = generator.generate("RPT-001");

        assertThat(result).isNotEmpty();
        verify(renderer).render(testData);
    }
}

Feathers는 이 기법을 Parameterize Constructor라고 부릅니다. 가장 단순하면서도 효과적인 의존성 깨기 기법 중 하나입니다.

Case 2: The Irritating Parameter (짜증나는 매개변수)#

메서드의 매개변수가 생성하기 어려운 객체인 경우입니다. HttpServletRequest처럼 인터페이스 뒤에 복잡한 컨테이너 구현이 숨어 있는 객체가 대표적입니다.

Before:

public class CreditValidator {
    public boolean isValid(HttpServletRequest request) {
        String creditNumber = request.getParameter("creditNo");
        String expDate = request.getParameter("expDate");
        // 복잡한 유효성 검증 로직...
        return creditNumber != null && creditNumber.length() == 16;
    }
}

테스트하려면 HttpServletRequest를 mock해야 합니다. mock 자체는 어렵지 않지만, 테스트가 HTTP라는 전달 메커니즘에 불필요하게 결합됩니다.

After (JDK 25 – Extract Interface / record 도입):

// 필요한 데이터만 추출한 record
public record CreditRequest(String creditNumber, String expirationDate) {
    // HttpServletRequest에서 변환하는 팩토리 메서드
    public static CreditRequest from(HttpServletRequest request) {
        return new CreditRequest(
            request.getParameter("creditNo"),
            request.getParameter("expDate")
        );
    }
}

public class CreditValidator {
    // 인터페이스가 아닌 record를 받으므로 테스트에서 쉽게 생성 가능
    public boolean isValid(CreditRequest request) {
        return request.creditNumber() != null
            && request.creditNumber().length() == 16;
    }
}
@Test
void validCreditNumber_returnsTrue() {
    var request = new CreditRequest("1234567890123456", "12/28");
    assertThat(new CreditValidator().isValid(request)).isTrue();
}

@Test
void nullCreditNumber_returnsFalse() {
    var request = new CreditRequest(null, "12/28");
    assertThat(new CreditValidator().isValid(request)).isFalse();
}

record로 HTTP 요청의 필요한 부분만 추출하면, HttpServletRequest를 mock할 필요가 없습니다. 테스트에서 new CreditRequest(...)로 원하는 값을 즉시 생성할 수 있습니다.

이것이 Feathers가 말하는 Adapt Parameter 기법의 현대적 적용입니다. 원서에서는 인터페이스를 추출하여 래퍼를 만드는 방식을 사용했지만, JDK 16에서 도입된 record (JEP 395)를 사용하면 보일러플레이트 없이 같은 효과를 얻을 수 있습니다.

Case 3: The Irritating Global Dependency (전역 의존성)#

싱글톤이나 정적 메서드를 통한 전역 의존성은 레거시 코드에서 가장 흔한 테스트 장벽입니다.

Before:

public class PricingEngine {
    public BigDecimal calculatePrice(String productId, int quantity) {
        // 싱글톤에 대한 전역 의존성
        var config = AppConfig.getInstance();
        var taxRate = config.getTaxRate();
        var discount = DiscountRegistry.getInstance().getDiscount(productId);

        var basePrice = catalog.getPrice(productId);
        return basePrice
            .multiply(BigDecimal.valueOf(quantity))
            .multiply(BigDecimal.ONE.subtract(discount))
            .multiply(BigDecimal.ONE.add(taxRate));
    }
}

AppConfig.getInstance()DiscountRegistry.getInstance()가 프로덕션 설정 파일이나 데이터베이스에 접근합니다. 테스트 환경에서 이 싱글톤들의 상태를 제어할 수 없으므로 테스트가 불가능합니다.

After (JDK 25 – 의존성을 명시적으로 전달):

public record PricingContext(BigDecimal taxRate, DiscountPolicy discountPolicy) {}

public interface DiscountPolicy {
    BigDecimal getDiscount(String productId);
}

public class PricingEngine {
    private final PricingContext context;
    private final ProductCatalog catalog;

    public PricingEngine(PricingContext context, ProductCatalog catalog) {
        this.context = context;
        this.catalog = catalog;
    }

    public BigDecimal calculatePrice(String productId, int quantity) {
        var discount = context.discountPolicy().getDiscount(productId);
        var basePrice = catalog.getPrice(productId);
        return basePrice
            .multiply(BigDecimal.valueOf(quantity))
            .multiply(BigDecimal.ONE.subtract(discount))
            .multiply(BigDecimal.ONE.add(context.taxRate()));
    }
}
@Test
void appliesTaxAndDiscount() {
    var context = new PricingContext(
        new BigDecimal("0.10"),
        productId -> new BigDecimal("0.20")  // 20% 할인
    );
    var catalog = mock(ProductCatalog.class);
    when(catalog.getPrice("ITEM-1")).thenReturn(new BigDecimal("10000"));

    var engine = new PricingEngine(context, catalog);
    var price = engine.calculatePrice("ITEM-1", 2);

    // 10000 * 2 * (1 - 0.20) * (1 + 0.10) = 17600
    assertThat(price).isEqualByComparingTo("17600");
}

전역 의존성을 생성자 매개변수로 전환하면 두 가지 이점이 있습니다. 첫째, 테스트에서 의존성을 완전히 제어할 수 있습니다. 둘째, 클래스의 의존 관계가 시그니처에 명시적으로 드러나므로 코드를 읽는 사람이 의존성을 즉시 파악할 수 있습니다.

DiscountPolicy를 함수형 인터페이스로 정의했기 때문에 테스트에서 람다로 간단히 구현할 수 있습니다. PricingContext를 record로 정의하여 관련된 설정 값들을 하나의 불변 객체로 묶었습니다.

메서드를 테스트 하네스에 넣기#

클래스를 인스턴스화하는 문제를 해결했다면, 다음은 개별 메서드를 테스트 가능한 상태로 만드는 것입니다.

숨겨진 메서드 (Hidden Method)#

private 메서드를 직접 테스트하고 싶은 충동은 흔히 발생합니다. 복잡한 계산 로직이 private 메서드 안에 숨어 있고, public 메서드를 통해 간접적으로 테스트하기에는 경로가 너무 복잡한 경우입니다.

Feathers의 조언은 명확합니다.

If we need to test a private method, we should make it public. If making it public bothers us, in most cases, it means that our class is doing too much and we ought to fix it. — Michael Feathers, Working Effectively with Legacy Code (2004)

private 메서드를 직접 테스트하고 싶다면, 그것은 별도의 클래스로 추출해야 한다는 신호입니다.

Before:

public class OrderProcessor {
    public void process(Order order) {
        validate(order);
        var total = calculateTotal(order);  // private, 복잡한 로직
        paymentService.charge(order.customerId(), total);
    }

    private BigDecimal calculateTotal(Order order) {
        // 수십 줄의 복잡한 계산 로직
        // 세금, 할인, 배송비, 멤버십 등
    }
}

After (JDK 25 – Extract Class):

// 복잡한 계산 로직을 별도 클래스로 추출
public class OrderTotalCalculator {
    private final TaxPolicy taxPolicy;
    private final ShippingPolicy shippingPolicy;

    public OrderTotalCalculator(TaxPolicy taxPolicy, ShippingPolicy shippingPolicy) {
        this.taxPolicy = taxPolicy;
        this.shippingPolicy = shippingPolicy;
    }

    public BigDecimal calculate(Order order) {
        var subtotal = order.items().stream()
            .map(item -> item.price().multiply(BigDecimal.valueOf(item.quantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);

        var tax = taxPolicy.calculate(subtotal);
        var shipping = shippingPolicy.calculate(order);

        return subtotal.add(tax).add(shipping);
    }
}
@Test
void calculatesOrderTotal() {
    var calculator = new OrderTotalCalculator(
        subtotal -> subtotal.multiply(new BigDecimal("0.10")),  // 10% tax
        order -> new BigDecimal("3000")  // flat shipping
    );

    var order = new Order(List.of(
        new OrderItem("A", new BigDecimal("10000"), 2),
        new OrderItem("B", new BigDecimal("5000"), 1)
    ));

    // (10000*2 + 5000*1) + 10% tax + 3000 shipping = 25000 + 2500 + 3000 = 30500
    assertThat(calculator.calculate(order)).isEqualByComparingTo("30500");
}

OrderProcessor의 private 메서드였던 계산 로직이 OrderTotalCalculator라는 독립 클래스의 public 메서드가 되었습니다. 이제 이 로직은 직접 테스트할 수 있고, OrderProcessor는 조율(orchestration)만 담당하는 얇은 클래스가 됩니다. 이것이 단일 책임 원칙(SRP)의 실천적 적용입니다.

Effect Analysis (영향 분석)#

코드를 변경했을 때 그 영향이 어디까지 퍼지는지를 분석하는 기법입니다. Feathers는 이를 Effect Sketch(영향 스케치) 라는 시각적 도구로 설명합니다.

영향 분석의 절차는 다음과 같습니다.

  1. 변경하려는 메서드에서 시작합니다.
  2. 해당 메서드가 반환하는 값, 수정하는 변수를 추적합니다.
  3. 영향받는 변수를 사용하는 다른 메서드들을 추적합니다.
  4. 이 과정을 영향이 시스템 경계에 도달할 때까지 반복합니다.
  5. 영향 경로상의 핀치 포인트(pinch point) 를 찾습니다.

다음은 calculateTotal 메서드 변경의 영향을 스케치한 것입니다.

graph TD
    A[calculateTotal] -->|affects| B[order.total]
    B -->|affects| C[invoice.amount]
    B -->|affects| D[receipt.printedTotal]
    A -->|calls| E[taxPolicy.calculate]
    E -->|affects| F[taxAmount]
    F -->|affects| B
    style A fill:#90EE90,color:#000000
    style B fill:#FFD700,color:#000000
    style C fill:#87CEEB,color:#000000
    style D fill:#87CEEB,color:#000000
    style E fill:#90EE90,color:#000000
    style F fill:#FFD700,color:#000000

이 스케치에서 order.total핀치 포인트입니다. calculateTotaltaxPolicy.calculate의 영향이 모두 이 지점을 통과합니다. 핀치 포인트에서 테스트를 작성하면 적은 수의 테스트로 넓은 범위를 검증할 수 있습니다.

핀치 포인트는 테스트 작성의 효율적 지점이기도 하지만, 향후 코드 분리의 자연스러운 경계이기도 합니다. 핀치 포인트를 중심으로 모듈을 분리하면 각 모듈의 응집도가 높고 결합도가 낮은 구조를 얻을 수 있습니다.

Characterization Test (특성 테스트)#

Characterization Test는 기존 코드의 현재 동작을 그대로 기록하는 테스트입니다. 코드가 “무엇을 해야 하는가"가 아니라 “지금 실제로 무엇을 하는가"를 포착합니다.

일반적인 단위 테스트가 “이 코드는 이렇게 동작해야 한다"라는 명세를 검증한다면, Characterization Test는 “이 코드는 현재 이렇게 동작한다"라는 사실을 기록합니다. 코드에 버그가 있더라도 그 버그를 포함한 현재 동작을 기록하는 것이 목적입니다.

작성 절차#

  1. 코드의 한 부분을 테스트 하네스에 넣습니다.
  2. 의도적으로 실패하는 assertion을 작성합니다.
  3. 실패 메시지에서 실제 값을 확인합니다.
  4. 실제 값을 expected 값으로 변경하여 테스트를 통과시킵니다.
  5. 이 과정을 반복하여 코드의 동작을 문서화합니다.

예제#

// 레거시 코드
public class StringFormatter {
    public static String format(String template, Map<String, String> values) {
        String result = template;
        for (Map.Entry<String, String> entry : values.entrySet()) {
            result = result.replace("${" + entry.getKey() + "}", entry.getValue());
        }
        return result;
    }
}

이 코드의 동작을 모르는 상태에서 Characterization Test를 작성합니다.

class StringFormatterTest {
    @Test
    @DisplayName("템플릿의 플레이스홀더를 값으로 치환한다")
    void replacesPlaceholders() {
        var result = StringFormatter.format(
            "Hello, ${name}! You have ${count} messages.",
            Map.of("name", "Kim", "count", "5")
        );
        // 처음에는 의도적으로 틀린 값을 넣어 실제 동작을 확인
        // assertEquals("fred", result);
        // → 실패 메시지: expected:<fred> but was:<Hello, Kim! You have 5 messages.>

        // 실제 동작을 기록
        assertThat(result).isEqualTo("Hello, Kim! You have 5 messages.");
    }

    @Test
    @DisplayName("존재하지 않는 플레이스홀더는 그대로 남는다")
    void preservesUnknownPlaceholders() {
        var result = StringFormatter.format(
            "${greeting}, ${name}!",
            Map.of("name", "Lee")
        );
        assertThat(result).isEqualTo("${greeting}, Lee!");
    }

    @Test
    @DisplayName("빈 맵이면 템플릿을 그대로 반환한다")
    void returnsTemplateWhenNoValues() {
        var result = StringFormatter.format("Hello, ${name}!", Map.of());
        assertThat(result).isEqualTo("Hello, ${name}!");
    }

    @Test
    @DisplayName("null 값이 포함되면 어떻게 되는지 특성을 기록한다")
    void handlesNullValues() {
        // HashMap은 null 값을 허용합니다
        var values = new HashMap<String, String>();
        values.put("name", null);

        var result = StringFormatter.format("Hello, ${name}!", values);
        assertThat(result).isEqualTo("Hello, null!");
    }
}

마지막 테스트 handlesNullValues에 주목하십시오. null 값이 전달되면 String.replace"null" 문자열로 치환합니다. 이것이 의도된 동작인지는 알 수 없지만, 현재 코드가 이렇게 동작한다는 사실을 기록했습니다.

Characterization Test의 핵심#

  • 코드를 “올바르게” 만드는 것이 목적이 아닙니다.
  • 현재 동작을 안전망(safety net) 으로 기록합니다.
  • 이후 리팩터링 시 기존 동작이 변경되면 즉시 감지할 수 있습니다.
  • 버그를 발견하더라도 Characterization Test에서는 현재 동작을 그대로 기록합니다. 버그 수정은 별도의 작업으로 진행합니다.

Characterization Test는 레거시 코드를 다루는 데 있어 가장 실용적인 도구입니다. 코드의 명세가 없을 때, 코드 자체를 명세로 삼는 것입니다.

마무리#

이 파트에서는 테스트가 전혀 없는 코드를 테스트 하네스에 넣는 기법들을 살펴봤습니다. Parameterize Constructor, Adapt Parameter, Extract Class는 모두 “의존성을 깨서 코드를 테스트 가능하게 만드는” 같은 원칙의 변형입니다. Characterization Test는 코드의 현재 동작을 안전망으로 기록하는 실용적 기법이고, Effect Analysis는 변경의 영향 범위를 시각화하여 테스트를 작성할 최적의 지점을 찾는 전략입니다.

Part 4에서는 비대한 클래스, 몬스터 메서드, 구조 없는 애플리케이션 등 대규모 코드 문제를 다루는 전략을 살펴봅니다.

References#