WELC ver. 2026 Part 5: 의존성 깨기 기법 카탈로그
이 글은 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);
}
}
적용 단계#
- 내부에서 생성하는 의존 객체를 매개변수로 받는 새 생성자를 추가합니다.
- 기존 생성자는 새 생성자를 호출하도록 수정합니다.
- 테스트에서 새 생성자로 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());
}
}
적용 단계#
- 기존 메서드를 복사하여 의존 객체를 매개변수로 받는 오버로드 메서드를 만듭니다.
- 기존 메서드는 새 메서드에 기본값을 전달하도록 수정합니다.
- 테스트에서 매개변수화된 메서드를 직접 호출합니다.
매개변수화된 메서드의 접근 제어자는 패키지 프라이빗으로 두는 것이 일반적입니다. 테스트 클래스를 같은 패키지에 배치하면 접근할 수 있습니다.
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() 사용
}
}
적용 단계#
- 메서드가 실제로 매개변수에서 사용하는 데이터를 파악합니다.
- 해당 데이터만 담는 record를 정의합니다.
- 원본 객체에서 record를 생성하는 팩토리 메서드를 추가합니다.
- 메서드 시그니처를 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();
}
}
적용 단계#
- 거대한 메서드를 위한 새 클래스를 만듭니다.
- 메서드의 매개변수를 생성자 매개변수로 옮깁니다.
- 메서드의 지역변수를 클래스의 필드로 옮깁니다.
- 메서드 본문을
execute()로 복사합니다. - 원래 메서드는 새 클래스에 위임합니다.
- 이제 클래스 내에서 작은 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#
- Feathers, M. (2004). Working Effectively with Legacy Code. Prentice Hall. ISBN 978-0131177055.
- OpenJDK. “JDK 25”. https://openjdk.org/projects/jdk/25/
- JEP 506: Scoped Values. https://openjdk.org/jeps/506
- JEP 409: Sealed Classes. https://openjdk.org/jeps/409
- JEP 395: Records. https://openjdk.org/jeps/395
- JEP 441: Pattern Matching for switch. https://openjdk.org/jeps/441
- JEP 513: Flexible Constructor Bodies. https://openjdk.org/jeps/513
- JUnit 5 User Guide. https://junit.org/junit5/docs/current/user-guide/
- Mockito Framework. https://site.mockito.org/
- AssertJ. https://assertj.github.io/doc/
- Oracle. “Java SE 25 Documentation”. https://docs.oracle.com/en/java/javase/25/
- Understand Legacy Code. “Key Points of Working Effectively with Legacy Code”. https://understandlegacycode.com/blog/key-points-of-working-effectively-with-legacy-code/
- Fowler, M. (2018). Refactoring: Improving the Design of Existing Code (2nd ed.). Addison-Wesley.