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


도입#

원서 Chapter 25는 25가지 의존성 깨기(Dependency-Breaking) 기법을 카탈로그 형태로 제시합니다. 각 기법은 “테스트 하네스에 코드를 넣기 위해 의존성을 어떻게 끊을 것인가"라는 질문에 대한 구체적인 답입니다.

다만 일부 기법은 C/C++ 전용이거나, 현대 Java에서는 불필요합니다. Link Substitution, Text Redefinition, Replace Function with Function Pointer 같은 기법이 대표적입니다. 이 파트에서는 2026년 현재 Java 개발에서 특히 유용한 핵심 기법들을 선별하여 정리합니다.

각 기법은 문제 상황 -> 기법 설명 -> Before/After 코드 -> 적용 단계 순으로 구성됩니다.

Parameterize Constructor (생성자 매개변수화)#

문제#

클래스가 생성자에서 직접 의존 객체를 생성합니다. 테스트에서 해당 의존 객체를 대체할 방법이 없습니다.

Before#

public class NotificationService {
    private final SmtpClient smtpClient;

    public NotificationService() {
        this.smtpClient = new SmtpClient("smtp.company.com", 587);
    }

    public void notify(String to, String message) {
        smtpClient.send(to, "Notification", message);
    }
}

After#

public class NotificationService {
    private final SmtpClient smtpClient;

    public NotificationService(SmtpClient smtpClient) {
        this.smtpClient = smtpClient;
    }

    public NotificationService() {
        this(new SmtpClient("smtp.company.com", 587));
    }

    public void notify(String to, String message) {
        smtpClient.send(to, "Notification", message);
    }
}

적용 단계#

  1. 내부에서 생성하는 의존 객체를 매개변수로 받는 새 생성자를 추가합니다.
  2. 기존 생성자는 새 생성자를 호출하도록 수정합니다.
  3. 테스트에서 새 생성자로 mock/fake 객체를 주입합니다.

기존 호출부는 인자 없는 생성자를 그대로 사용하므로 하위 호환성이 유지됩니다. 가장 단순하면서도 효과적인 기법 중 하나입니다.

Parameterize Method (메서드 매개변수화)#

문제#

메서드 내부에서 직접 객체를 생성하여 사용합니다. 생성자가 아닌 메서드 수준에서 의존성이 숨겨져 있습니다.

Before#

public class RateCalculator {
    public BigDecimal calculate(String productId) {
        var config = new ProductConfig();  // 직접 생성
        var baseRate = config.getRate(productId);
        return baseRate.multiply(getMultiplier());
    }
}

After#

public class RateCalculator {
    public BigDecimal calculate(String productId) {
        return calculate(productId, new ProductConfig());
    }

    // 매개변수화된 버전
    BigDecimal calculate(String productId, ProductConfig config) {
        var baseRate = config.getRate(productId);
        return baseRate.multiply(getMultiplier());
    }
}

적용 단계#

  1. 기존 메서드를 복사하여 의존 객체를 매개변수로 받는 오버로드 메서드를 만듭니다.
  2. 기존 메서드는 새 메서드에 기본값을 전달하도록 수정합니다.
  3. 테스트에서 매개변수화된 메서드를 직접 호출합니다.

매개변수화된 메서드의 접근 제어자는 패키지 프라이빗으로 두는 것이 일반적입니다. 테스트 클래스를 같은 패키지에 배치하면 접근할 수 있습니다.

Extract Interface (인터페이스 추출)#

문제#

구체 클래스에 직접 의존하여 테스트에서 대체할 수 없습니다. 이것은 가장 많이 사용되는 의존성 깨기 기법 중 하나입니다.

Before#

public class PayrollCalculator {
    private final TaxAuthority taxAuthority;  // 구체 클래스

    public PayrollCalculator() {
        this.taxAuthority = new TaxAuthority();
    }

    public PaySlip calculate(Employee employee) {
        var gross = employee.salary();
        var tax = taxAuthority.calculateTax(gross, employee.region());
        return new PaySlip(employee.id(), gross, tax, gross.subtract(tax));
    }
}

public class TaxAuthority {
    public BigDecimal calculateTax(BigDecimal income, String region) {
        // 외부 세금 서비스 API 호출
        var response = httpClient.get("https://tax.api/rate/" + region);
        // 복잡한 계산...
        return income.multiply(parseRate(response));
    }
}

After (JDK 25)#

// 추출된 인터페이스
public interface TaxCalculator {
    BigDecimal calculateTax(BigDecimal income, String region);
}

// 기존 클래스가 인터페이스를 구현
public class TaxAuthority implements TaxCalculator {
    @Override
    public BigDecimal calculateTax(BigDecimal income, String region) {
        var response = httpClient.get("https://tax.api/rate/" + region);
        return income.multiply(parseRate(response));
    }
}

// 이제 인터페이스에 의존
public class PayrollCalculator {
    private final TaxCalculator taxCalculator;

    public PayrollCalculator(TaxCalculator taxCalculator) {
        this.taxCalculator = taxCalculator;
    }

    public PaySlip calculate(Employee employee) {
        var gross = employee.salary();
        var tax = taxCalculator.calculateTax(gross, employee.region());
        return new PaySlip(employee.id(), gross, tax, gross.subtract(tax));
    }
}

// record로 PaySlip 정의
public record PaySlip(String employeeId, BigDecimal gross, BigDecimal tax, BigDecimal net) {}

테스트#

@Test
void calculatesNetPay() {
    // 람다로 간단하게 fake 구현
    TaxCalculator fakeTax = (income, region) -> income.multiply(new BigDecimal("0.20"));

    var calculator = new PayrollCalculator(fakeTax);
    var employee = new Employee("EMP-001", new BigDecimal("5000000"), "KR");

    var paySlip = calculator.calculate(employee);

    assertThat(paySlip.net()).isEqualByComparingTo("4000000");
}

Feathers의 인터페이스 네이밍 조언을 참고할 만합니다. 기존 클래스 이름을 인터페이스로 사용하고, 구현 클래스에 구체적인 이름을 붙이는 방법입니다. 예를 들어 TaxCalculator(인터페이스) + HttpTaxAuthority(구현)와 같은 구성입니다. 인터페이스가 더 자주 참조되므로 더 간결한 이름을 갖는 것이 합리적입니다.

Adapt Parameter (매개변수 적응)#

문제#

메서드의 매개변수가 생성하기 어렵거나 테스트 환경에서 사용할 수 없는 객체입니다. HttpSession, HttpServletRequest 같은 프레임워크 객체가 대표적입니다.

Before#

public class ReportService {
    public void generateReport(HttpSession session) {
        String userId = (String) session.getAttribute("userId");
        String role = (String) session.getAttribute("role");
        // 리포트 생성 로직...
    }
}

After (JDK 25 – record로 필요한 데이터만 추출)#

public record ReportContext(String userId, String role) {
    public static ReportContext from(HttpSession session) {
        return new ReportContext(
            (String) session.getAttribute("userId"),
            (String) session.getAttribute("role")
        );
    }
}

public class ReportService {
    public void generateReport(ReportContext context) {
        // context.userId(), context.role() 사용
    }
}

적용 단계#

  1. 메서드가 실제로 매개변수에서 사용하는 데이터를 파악합니다.
  2. 해당 데이터만 담는 record를 정의합니다.
  3. 원본 객체에서 record를 생성하는 팩토리 메서드를 추가합니다.
  4. 메서드 시그니처를 record 타입으로 변경합니다.

JDK 25의 record는 이 기법에 최적화된 도구입니다. 불변이고, equals/hashCode/toString이 자동으로 생성되며, 테스트에서 간단하게 인스턴스를 만들 수 있습니다.

Subclass and Override Method (서브클래스화 및 메서드 오버라이드)#

문제#

클래스의 특정 메서드가 외부 자원에 의존하여 테스트할 수 없습니다. 전체 구조를 변경하기 전에 빠르게 테스트를 추가해야 합니다.

Before#

public class InventoryTracker {
    public void recordSale(String productId, int quantity) {
        // 비즈니스 로직
        var remaining = getStock(productId) - quantity;
        if (remaining < getReorderThreshold(productId)) {
            sendReorderAlert(productId, remaining);  // 이메일 발송
        }
        updateStock(productId, remaining);
    }

    protected void sendReorderAlert(String productId, int remaining) {
        emailService.send("warehouse@company.com",
            "Reorder needed: " + productId + " (remaining: " + remaining + ")");
    }

    // ... getStock, updateStock 등
}

After (테스트에서 오버라이드)#

// 테스트 전용 서브클래스
class TestableInventoryTracker extends InventoryTracker {
    private final List<String> reorderAlerts = new ArrayList<>();

    @Override
    protected void sendReorderAlert(String productId, int remaining) {
        reorderAlerts.add(productId + ":" + remaining);
    }

    public List<String> getReorderAlerts() {
        return Collections.unmodifiableList(reorderAlerts);
    }
}

@Test
void sendsReorderAlertWhenStockIsLow() {
    var tracker = new TestableInventoryTracker(/* ... */);
    tracker.recordSale("ITEM-42", 95);
    assertThat(tracker.getReorderAlerts()).containsExactly("ITEM-42:5");
}

이 기법은 “최종 목표"가 아닌 “중간 단계"입니다. 레거시 코드에 테스트를 추가하는 가장 빠른 방법 중 하나이지만, 궁극적으로는 Extract Interface나 Parameterize Constructor로 더 깔끔한 설계로 이동해야 합니다.

Extract and Override Call (호출 추출 및 오버라이드)#

문제#

메서드 중간에 테스트하기 어려운 호출이 있습니다. 외부 API 호출, 파일 I/O, 데이터베이스 접근 등이 메서드 로직과 뒤섞여 있습니다.

Before#

public class PriceUpdater {
    public void updatePrices(List<Product> products) {
        for (var product : products) {
            var newPrice = fetchCurrentPrice(product.sku());  // 외부 API
            product.setPrice(newPrice);
            save(product);
        }
    }

    private BigDecimal fetchCurrentPrice(String sku) {
        // HTTP 호출로 시세 조회
        return httpClient.get("https://pricing.api/price/" + sku)
            .getBody(BigDecimal.class);
    }
}

After (1단계: 오버라이드 가능하게)#

public class PriceUpdater {
    public void updatePrices(List<Product> products) {
        for (var product : products) {
            var newPrice = fetchCurrentPrice(product.sku());
            product.setPrice(newPrice);
            save(product);
        }
    }

    // protected로 변경하여 오버라이드 가능하게
    protected BigDecimal fetchCurrentPrice(String sku) {
        return httpClient.get("https://pricing.api/price/" + sku)
            .getBody(BigDecimal.class);
    }
}

// 테스트
class TestablePriceUpdater extends PriceUpdater {
    private final Map<String, BigDecimal> prices;

    TestablePriceUpdater(Map<String, BigDecimal> prices) {
        this.prices = prices;
    }

    @Override
    protected BigDecimal fetchCurrentPrice(String sku) {
        return prices.getOrDefault(sku, BigDecimal.ZERO);
    }
}

After (2단계: 인터페이스로 추출)#

테스트가 확보되면 의존성을 인터페이스로 추출하여 더 나은 설계로 이동합니다.

// 의존성을 인터페이스로 추출
@FunctionalInterface
public interface PriceFetcher {
    BigDecimal fetch(String sku);
}

public class PriceUpdater {
    private final PriceFetcher priceFetcher;
    private final ProductRepository repository;

    public PriceUpdater(PriceFetcher priceFetcher, ProductRepository repository) {
        this.priceFetcher = priceFetcher;
        this.repository = repository;
    }

    public void updatePrices(List<Product> products) {
        for (var product : products) {
            var newPrice = priceFetcher.fetch(product.sku());
            repository.save(product.withPrice(newPrice));
        }
    }
}

테스트#

@Test
void updatesPricesFromExternalSource() {
    PriceFetcher fakeFetcher = sku -> switch (sku) {
        case "SKU-001" -> new BigDecimal("15000");
        case "SKU-002" -> new BigDecimal("28000");
        default -> BigDecimal.ZERO;
    };

    var repository = mock(ProductRepository.class);
    var updater = new PriceUpdater(fakeFetcher, repository);

    updater.updatePrices(List.of(
        new Product("SKU-001", BigDecimal.ZERO),
        new Product("SKU-002", BigDecimal.ZERO)
    ));

    verify(repository).save(argThat(p -> p.sku().equals("SKU-001")
        && p.price().equals(new BigDecimal("15000"))));
}

@FunctionalInterface로 선언하면 람다나 메서드 레퍼런스로 간결하게 구현할 수 있습니다. 단일 메서드 인터페이스에 적합한 접근입니다.

Encapsulate Global References (전역 참조 캡슐화)#

문제#

코드가 전역 변수나 싱글톤에 직접 접근합니다. GlobalConfig.getInstance() 같은 호출이 코드 곳곳에 퍼져 있어 테스트에서 제어할 수 없습니다.

Before#

public class OrderValidator {
    public boolean validate(Order order) {
        // 전역 싱글톤 접근
        var config = GlobalConfig.getInstance();
        var maxAmount = config.getMaxOrderAmount();
        return order.total().compareTo(maxAmount) <= 0;
    }
}

After – 방법 1: 생성자 주입 (권장)#

public class OrderValidator {
    private final ValidationConfig config;

    public OrderValidator(ValidationConfig config) {
        this.config = config;
    }

    public boolean validate(Order order) {
        return order.total().compareTo(config.maxOrderAmount()) <= 0;
    }
}

public record ValidationConfig(BigDecimal maxOrderAmount) {
    // 기존 글로벌 설정에서 생성하는 팩토리
    public static ValidationConfig fromGlobal() {
        var global = GlobalConfig.getInstance();
        return new ValidationConfig(global.getMaxOrderAmount());
    }
}

After – 방법 2: JDK 25 Scoped Values (컨텍스트 전파가 필요한 경우)#

// Scoped Values (JEP 506) -- JDK 25에서 final
public class RequestContext {
    public static final ScopedValue<ValidationConfig> CONFIG = ScopedValue.newInstance();
}

// 요청 처리 시작점에서
ScopedValue.runWhere(RequestContext.CONFIG, validationConfig, () -> {
    orderValidator.validate(order);
});

// OrderValidator에서
public boolean validate(Order order) {
    var config = RequestContext.CONFIG.get();
    return order.total().compareTo(config.maxOrderAmount()) <= 0;
}

JDK 25에서 Scoped Values(JEP 506)가 final로 확정되었습니다. ThreadLocal의 대안으로 설계되었으며, 불변이고 스코프가 명확하여 테스트에서 제어하기 쉽습니다. 다만 대부분의 경우 생성자 주입이 더 단순하고 명시적입니다. Scoped Values는 깊은 호출 스택을 통해 컨텍스트를 전파해야 하는 경우에 적합합니다.

Break Out Method Object (메서드 객체 추출)#

문제#

메서드가 너무 크고 복잡하여 리팩터링 자체가 어렵습니다. 수많은 지역 변수, 중첩 루프, 조건 분기가 뒤얽혀 있어 메서드 추출조차 쉽지 않습니다.

Before#

public class ReportEngine {
    public String generateReport(List<Transaction> transactions,
                                  DateRange range, String format) {
        // 200줄의 복잡한 메서드
        // 수많은 지역 변수, 중첩 루프, 조건 분기...
    }
}

After (메서드를 클래스로 추출)#

// 메서드의 매개변수와 지역변수가 클래스의 필드가 됨
public class ReportGeneration {
    private final List<Transaction> transactions;
    private final DateRange range;
    private final String format;

    // 리팩터링 과정에서 추출된 중간 값들
    private List<Transaction> filtered;
    private Map<String, BigDecimal> aggregated;

    public ReportGeneration(List<Transaction> transactions,
                             DateRange range, String format) {
        this.transactions = transactions;
        this.range = range;
        this.format = format;
    }

    public String execute() {
        filterByDateRange();
        aggregateByCategory();
        return formatOutput();
    }

    private void filterByDateRange() {
        this.filtered = transactions.stream()
            .filter(t -> range.contains(t.date()))
            .toList();
    }

    private void aggregateByCategory() {
        this.aggregated = filtered.stream()
            .collect(Collectors.groupingBy(
                Transaction::category,
                Collectors.reducing(BigDecimal.ZERO,
                    Transaction::amount, BigDecimal::add)
            ));
    }

    private String formatOutput() {
        return switch (format) {
            case "csv" -> formatCsv();
            case "json" -> formatJson();
            default -> formatText();
        };
    }

    // formatCsv, formatJson, formatText ...
}

// 원래 클래스에서는 위임
public class ReportEngine {
    public String generateReport(List<Transaction> transactions,
                                  DateRange range, String format) {
        return new ReportGeneration(transactions, range, format).execute();
    }
}

적용 단계#

  1. 거대한 메서드를 위한 새 클래스를 만듭니다.
  2. 메서드의 매개변수를 생성자 매개변수로 옮깁니다.
  3. 메서드의 지역변수를 클래스의 필드로 옮깁니다.
  4. 메서드 본문을 execute()로 복사합니다.
  5. 원래 메서드는 새 클래스에 위임합니다.
  6. 이제 클래스 내에서 작은 private 메서드로 추출이 가능합니다.

이 기법의 핵심은 “거대한 메서드를 클래스로 승격시키면 메서드 추출이 쉬워진다"는 것입니다. 지역변수가 필드가 되므로 메서드 간에 매개변수를 전달할 필요가 없어집니다.

Introduce Instance Delegator (인스턴스 위임자 도입)#

문제#

static 메서드에 의존하는 코드를 테스트할 수 없습니다. Java에서 static 메서드는 일반적인 방법으로 mock할 수 없습니다.

Before#

public class BankingService {
    public static boolean transferFunds(String from, String to, BigDecimal amount) {
        // 정적 메서드 -- mock 불가능
        var fromBalance = AccountDatabase.getBalance(from);
        if (fromBalance.compareTo(amount) < 0) return false;
        AccountDatabase.debit(from, amount);
        AccountDatabase.credit(to, amount);
        return true;
    }
}

// 호출하는 쪽
public class PaymentProcessor {
    public void process(Payment payment) {
        boolean success = BankingService.transferFunds(
            payment.fromAccount(), payment.toAccount(), payment.amount());
        // ...
    }
}

After (1단계: 인스턴스 위임자 추가)#

public class BankingService {
    // 기존 static 메서드는 유지 (하위 호환)
    public static boolean transferFunds(String from, String to, BigDecimal amount) {
        return new BankingService().executeTransfer(from, to, amount);
    }

    // 인스턴스 메서드 추가 -- 테스트에서 오버라이드 또는 mock 가능
    public boolean executeTransfer(String from, String to, BigDecimal amount) {
        var fromBalance = AccountDatabase.getBalance(from);
        if (fromBalance.compareTo(amount) < 0) return false;
        AccountDatabase.debit(from, amount);
        AccountDatabase.credit(to, amount);
        return true;
    }
}

After (2단계: 인터페이스로 추출)#

@FunctionalInterface
public interface FundTransferService {
    boolean transfer(String from, String to, BigDecimal amount);
}

public class PaymentProcessor {
    private final FundTransferService transferService;

    public PaymentProcessor(FundTransferService transferService) {
        this.transferService = transferService;
    }

    public void process(Payment payment) {
        boolean success = transferService.transfer(
            payment.fromAccount(), payment.toAccount(), payment.amount());
        // ...
    }
}

1단계는 기존 코드의 호환성을 유지하면서 테스트 가능한 진입점을 만드는 것이고, 2단계는 호출하는 쪽을 인터페이스에 의존하도록 전환하는 것입니다.

기법 선택 가이드#

문제 상황 권장 기법 난이도
생성자에서 의존성 직접 생성 Parameterize Constructor 낮음
메서드 내부에서 객체 직접 생성 Parameterize Method 낮음
구체 클래스에 직접 의존 Extract Interface 중간
매개변수가 생성 어려운 객체 Adapt Parameter (record 활용) 낮음
전역 변수/싱글톤 의존 Encapsulate Global References 중간
특정 메서드가 외부 자원 접근 Extract and Override Call -> Extract Interface 중간
static 메서드 의존 Introduce Instance Delegator 중간
메서드가 너무 크고 복잡 Break Out Method Object 높음
테스트에서 특정 동작만 대체 필요 Subclass and Override Method 낮음

난이도가 낮은 기법부터 시도하는 것을 권장합니다. Parameterize Constructor와 Parameterize Method는 변경 범위가 작고 위험이 낮습니다. Extract Interface는 변경 범위가 더 넓지만 효과도 큽니다. Break Out Method Object는 변경 범위가 가장 넓으므로 다른 기법이 적용되지 않을 때 사용합니다.

또한 여러 기법은 조합하여 사용하는 경우가 많습니다. Extract and Override Call로 테스트를 확보한 뒤, Extract Interface로 설계를 개선하는 것이 전형적인 흐름입니다.

시리즈 마무리#

이 시리즈에서는 2004년 출판된 Working Effectively with Legacy Code의 핵심 기법들을 JDK 25 기준의 modern Java로 재현했습니다.

원서의 기법들은 20년이 지난 지금도 유효합니다. Records, Sealed Classes, Pattern Matching, Scoped Values 등 modern Java의 기능들은 이 기법들을 더 간결하고 안전하게 적용할 수 있게 해줍니다. record는 Adapt Parameter에서 값 객체를 간결하게 정의하는 데 적합하고, @FunctionalInterface는 Extract Interface에서 람다로 테스트 더블을 만드는 것을 가능하게 합니다. Sealed Classes는 도메인 모델의 변형을 제한적으로 정의할 때 유용하고, Pattern Matching for switch는 조건 분기를 더 읽기 쉽게 만듭니다.

Feathers의 핵심 메시지는 이것입니다. 레거시 코드를 다루는 것은 기술적 문제이기도 하지만, 무엇보다 테스트에 대한 태도의 문제입니다. “테스트 없는 코드는 레거시 코드다"라는 그의 정의는 단순하지만, 그만큼 본질적입니다.

References#