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


도입#

Part 1에서 다룬 Legacy Code Change Algorithm의 5단계 중, 이번 글은 “변경 및 리팩터링” 단계에 해당하는 실전 패턴들을 다룹니다. Michael Feathers의 Working Effectively with Legacy Code 6~8장에 등장하는 핵심 기법입니다.

레거시 코드에 기능을 추가해야 하는데 시간이 없을 때, 기존 코드를 전면 리팩터링하는 것은 현실적이지 않습니다. 이때 사용할 수 있는 4가지 패턴이 있습니다.

패턴 핵심 아이디어
Sprout Method 새 기능을 별도 메서드로 추출
Sprout Class 새 기능을 별도 클래스로 분리
Wrap Method 기존 메서드를 감싸서 전후 동작 추가
Wrap Class Decorator 패턴으로 기존 클래스를 감싸기

이 4가지 패턴의 공통점은 기존 코드를 가능한 한 건드리지 않으면서 새 기능을 추가한다는 것입니다.


Sprout Method (새싹 메서드)#

새 기능을 기존 메서드에 끼워넣는 대신, 별도의 새 메서드로 작성하여 호출하는 기법입니다.

Before (레거시 코드 — Java 1.4 스타일)#

public class TransactionGate {
    public void postEntries(List entries) {
        for (Iterator it = entries.iterator(); it.hasNext(); ) {
            Entry entry = (Entry) it.next();
            entry.postDate();
        }
        transactionBundle.getListManager().add(entries);
    }
}

요구사항이 하나 들어옵니다. 중복 항목을 필터링해야 합니다.

나쁜 접근 — 기존 메서드에 직접 수정#

public void postEntries(List entries) {
    List entriesToAdd = new LinkedList();
    for (Iterator it = entries.iterator(); it.hasNext(); ) {
        Entry entry = (Entry) it.next();
        if (!transactionBundle.getListManager().hasEntry(entry)) {
            entry.postDate();
            entriesToAdd.add(entry);
        }
    }
    transactionBundle.getListManager().add(entriesToAdd);
}

이렇게 하면 기존 코드와 새 코드가 뒤섞여서, 어디까지가 원래 동작이고 어디부터가 새 기능인지 구분할 수 없게 됩니다. 테스트도 불가능합니다.

After (JDK 25 — Sprout Method 적용)#

public class TransactionGate {
    public void postEntries(List<Entry> entries) {
        var uniqueEntries = filterDuplicates(entries);
        uniqueEntries.forEach(Entry::postDate);
        transactionBundle.getListManager().add(uniqueEntries);
    }

    // Sprout Method: 새 기능을 독립된 메서드로 추출
    List<Entry> filterDuplicates(List<Entry> entries) {
        var existingManager = transactionBundle.getListManager();
        return entries.stream()
            .filter(entry -> !existingManager.hasEntry(entry))
            .toList();
    }
}

filterDuplicates는 패키지-프라이빗 접근 제어자를 사용합니다. 같은 패키지의 테스트 클래스에서 직접 호출할 수 있도록 하기 위함입니다.

Sprout Method의 테스트 (JUnit 5)#

@Test
@DisplayName("중복 항목이 필터링된다")
void filtersDuplicateEntries() {
    // setup with mock ListManager...
    var result = gate.filterDuplicates(List.of(entry1, entry2, entry3));
    assertThat(result).containsExactly(entry1, entry3);
}

새로 추출한 메서드만 독립적으로 테스트할 수 있습니다. 기존 postEntries의 복잡한 의존성을 세팅할 필요가 없습니다.

Sprout Method 적용 단계#

  1. 변경이 필요한 코드의 위치를 식별합니다.
  2. 새 기능을 독립된 메서드로 작성합니다.
  3. 기존 메서드에서 새 메서드를 호출합니다.
  4. 새 메서드에 대한 테스트를 작성합니다.

장점: 새 코드와 기존 코드의 경계가 명확합니다. 새 코드는 테스트 가능합니다.

단점: 기존 메서드는 여전히 테스트되지 않은 상태입니다. Sprout Method는 응급 처치이지, 근본적인 해결이 아닙니다.


Sprout Class (새싹 클래스)#

새 기능이 기존 클래스와 다른 책임을 가질 때, 아예 새 클래스로 분리하는 기법입니다. Sprout Method의 확장판이라고 볼 수 있습니다.

Before#

public class InventoryManager {
    public void processReceiving(List<Product> products) {
        for (Product product : products) {
            // 복잡한 재고 처리 로직 (수백 줄)
            warehouse.stock(product);
            ledger.record(product.getSku(), product.getQuantity());
        }
    }
}

요구사항: 유통기한이 지난 제품을 필터링해야 합니다.

InventoryManager에 유통기한 검증 로직을 직접 추가하면 이 클래스의 책임이 더 비대해집니다. 이미 수백 줄인 클래스에 또 다른 관심사를 밀어넣는 것은 상황을 악화시킵니다.

After (JDK 25 — Sprout Class 적용)#

// Sprout Class: 새 책임을 별도 클래스로 분리
public class ExpirationValidator {
    private final Clock clock;

    public ExpirationValidator(Clock clock) {
        this.clock = clock;
    }

    public List<Product> removeExpired(List<Product> products) {
        var now = LocalDate.now(clock);
        return products.stream()
            .filter(p -> p.expirationDate().isAfter(now))
            .toList();
    }
}

// 기존 클래스에서는 Sprout Class를 호출
public class InventoryManager {
    private final ExpirationValidator expirationValidator;

    public InventoryManager(ExpirationValidator expirationValidator, /* ... */) {
        this.expirationValidator = expirationValidator;
    }

    public void processReceiving(List<Product> products) {
        var validProducts = expirationValidator.removeExpired(products);
        for (Product product : validProducts) {
            warehouse.stock(product);
            ledger.record(product.getSku(), product.getQuantity());
        }
    }
}

몇 가지 포인트가 있습니다.

  • Clock 주입으로 시간 의존성을 테스트 가능하게 만듭니다. LocalDate.now()를 직접 호출하면 테스트에서 날짜를 제어할 수 없습니다.
  • Product가 record 타입이라면 expirationDate() 접근자를 자연스럽게 사용할 수 있습니다.
  • ExpirationValidator는 완전히 독립적인 단위이므로, 테스트 작성이 간단합니다.

Sprout Class를 사용하는 시점은 다음과 같습니다.

  • 새 기능이 기존 클래스의 책임과 명확히 다를 때
  • 기존 클래스의 생성자가 너무 복잡해서 테스트 인스턴스를 만들기 어려울 때
  • 새 기능 자체가 여러 곳에서 재사용될 가능성이 있을 때

Wrap Method (감싸기 메서드)#

기존 메서드의 실행 전후에 새 동작을 추가하기 위해, 기존 메서드의 이름을 변경하고 원래 이름으로 새 메서드를 만들어 감싸는 기법입니다.

Before#

public class Employee {
    public void pay() {
        Money amount = calculator.calculatePay(this);
        bankService.transfer(account, amount);
    }
}

요구사항: 급여 지급 시 감사(audit) 로그를 남겨야 합니다.

After (JDK 25 — Wrap Method 적용)#

public class Employee {
    public void pay() {
        logPayment();      // 새 동작 (before)
        dispatchPay();     // 원래 동작
    }

    // 원래 pay()의 이름을 변경
    private void dispatchPay() {
        Money amount = calculator.calculatePay(this);
        bankService.transfer(account, amount);
    }

    // Wrap: 새 기능을 별도 메서드로
    private void logPayment() {
        auditLog.record(new PaymentEvent(
            this.employeeId(),
            Instant.now(),
            "SALARY_PAYMENT"
        ));
    }
}

// record로 이벤트 정의
public record PaymentEvent(String employeeId, Instant timestamp, String type) {}

Wrap Method 적용 단계#

  1. 기존 메서드의 이름을 변경합니다 (pay -> dispatchPay).
  2. 원래 이름으로 새 메서드를 만듭니다.
  3. 새 메서드에서 이름이 변경된 기존 메서드와 새 기능을 호출합니다.

Wrap Method의 핵심은 기존 메서드의 시그니처(이름, 파라미터, 반환 타입)를 유지한다는 것입니다. 외부에서 pay()를 호출하는 코드는 전혀 변경할 필요가 없습니다.

장점: 기존 코드를 수정하지 않으면서 전후 동작을 추가할 수 있습니다.

단점: 메서드 이름 변경이 필요하므로, private이 아닌 메서드에 적용할 때는 호출부 영향을 확인해야 합니다.


Wrap Class (감싸기 클래스)#

Decorator 패턴의 응용입니다. 기존 클래스를 수정하지 않고, 새 클래스로 감싸서 동작을 추가합니다.

Before#

public class NotificationSender {
    public void send(String userId, String message) {
        emailService.send(userId, message);
    }
}

요구사항: 알림 전송 시 감사(audit) 로그를 남겨야 합니다.

After (JDK 25 — Wrap Class 적용)#

// 인터페이스 추출
public interface MessageSender {
    void send(String userId, String message);
}

// 기존 클래스가 인터페이스 구현
public class NotificationSender implements MessageSender {
    @Override
    public void send(String userId, String message) {
        emailService.send(userId, message);
    }
}

// Wrap Class: Decorator 패턴으로 감사 로그 추가
public class AuditingMessageSender implements MessageSender {
    private final MessageSender delegate;
    private final AuditLogger auditLogger;

    public AuditingMessageSender(MessageSender delegate, AuditLogger auditLogger) {
        this.delegate = delegate;
        this.auditLogger = auditLogger;
    }

    @Override
    public void send(String userId, String message) {
        auditLogger.log("Sending message to %s".formatted(userId));
        delegate.send(userId, message);
        auditLogger.log("Message sent to %s".formatted(userId));
    }
}

Wrap Class의 핵심은 인터페이스 추출입니다. 기존 클래스에서 인터페이스를 추출하고, Decorator가 같은 인터페이스를 구현하게 합니다. 이렇게 하면 기존 코드를 전혀 수정하지 않고도 동작을 추가할 수 있습니다.

Decorator 패턴의 장점은 체이닝이 가능하다는 것입니다. logging, retry, metrics 등 여러 관심사를 각각의 Decorator로 분리하고, 필요에 따라 조합할 수 있습니다.

// Decorator 체이닝 예시
MessageSender sender = new RetryingMessageSender(
    new AuditingMessageSender(
        new NotificationSender(emailService),
        auditLogger
    ),
    retryPolicy
);

TDD와 레거시 코드#

Feathers는 TDD(Test-Driven Development)를 레거시 코드에 적용하는 방법도 설명합니다. 레거시 코드에서의 TDD 절차는 다음과 같습니다.

  1. 실패하는 테스트 작성
  2. 컴파일되게 만들기
  3. 테스트 통과시키기
  4. 중복 제거
  5. 반복

이 절차에서 특히 “2. 컴파일되게 만들기"가 레거시 코드에서 가장 어려운 단계입니다. 의존성이 복잡하게 얽혀 있어서 테스트 대상 클래스의 인스턴스를 만드는 것 자체가 난관인 경우가 많습니다.

Programming by Difference — Sealed Classes 활용#

Feathers가 소개하는 “Programming by Difference"는 기존 클래스를 상속하여 차이점만 구현하는 기법입니다. 레거시 코드에서 빠르게 변형을 만들어야 할 때 유용하지만, 상속 남용으로 이어질 위험이 있습니다.

JDK 25에서는 Sealed Classes를 사용하면 이 기법을 타입 안전하게 적용할 수 있습니다.

Before (상속 남용):

public class MessageForwarder {
    protected void sendMessage(Message msg) {
        // 복잡한 전송 로직
    }
}

// Programming by Difference: 기존 동작을 상속으로 확장
public class AnonymousMessageForwarder extends MessageForwarder {
    @Override
    protected void sendMessage(Message msg) {
        msg.setFrom("anonymous");
        super.sendMessage(msg);
    }
}

이 접근의 문제점은 상속 계층이 제한 없이 확장될 수 있다는 것입니다. 누구든 MessageForwarder를 상속할 수 있고, sendMessage를 오버라이드할 수 있습니다.

After (JDK 25 — Sealed Classes로 타입 안전하게):

public sealed interface MessageForwardingStrategy
        permits StandardForwarding, AnonymousForwarding, EncryptedForwarding {
    Message prepare(Message original);
}

public record StandardForwarding() implements MessageForwardingStrategy {
    @Override
    public Message prepare(Message original) {
        return original;
    }
}

public record AnonymousForwarding() implements MessageForwardingStrategy {
    @Override
    public Message prepare(Message original) {
        return original.withFrom("anonymous");
    }
}

public record EncryptedForwarding(EncryptionKey key) implements MessageForwardingStrategy {
    @Override
    public Message prepare(Message original) {
        return original.withBody(encrypt(original.body(), key));
    }
}

// 전략을 사용하는 Forwarder
public class MessageForwarder {
    private final MessageForwardingStrategy strategy;
    private final TransportLayer transport;

    public MessageForwarder(MessageForwardingStrategy strategy, TransportLayer transport) {
        this.strategy = strategy;
        this.transport = transport;
    }

    public void forward(Message message) {
        var prepared = strategy.prepare(message);
        transport.send(prepared);
    }
}

이 변환에서 주목할 포인트는 다음과 같습니다.

  • sealed interface로 허용된 변형을 컴파일 타임에 제한합니다. permits 절에 명시된 클래스만 이 인터페이스를 구현할 수 있습니다.
  • record로 불변 전략 객체를 간결하게 표현합니다. boilerplate 코드가 사라집니다.
  • 상속 대신 합성(composition)을 사용합니다. MessageForwarder는 전략 객체를 주입받습니다.
  • switch 패턴 매칭으로 모든 변형을 exhaustive하게 처리할 수 있습니다. 새 변형이 추가되면 컴파일러가 누락된 분기를 알려줍니다.

마무리#

이번 글에서 다룬 4가지 패턴(Sprout Method, Sprout Class, Wrap Method, Wrap Class)은 레거시 코드에 기능을 추가할 때 가장 즉시 적용 가능한 기법들입니다. 기존 코드를 최소한으로 건드리면서 새 코드를 테스트 가능한 형태로 작성하는 것이 핵심입니다.

Part 3에서는 테스트가 전혀 없는 클래스와 메서드를 테스트 하네스에 넣는 구체적인 기법을 다룹니다.


References#