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


시리즈 소개#

2004년 Michael Feathers가 출판한 Working Effectively with Legacy Code (a.k.a. WELC)는 레거시 코드를 안전하게 변경하는 기법들을 체계적으로 정리한 명저입니다. 출판된 지 20년이 넘었지만, 이 책이 제시하는 원칙과 알고리즘은 여전히 유효합니다. 코드베이스가 노후화되는 속도보다 개발자가 이 책을 읽는 속도가 느린 것이 문제일 뿐입니다.

원서의 예제는 Java 1.4/5, C++, C 시대의 코드입니다. 이 시리즈에서는 JDK 25 (2025년 9월 GA, LTS) 기준의 modern Java로 핵심 기법들을 재현합니다. Sealed class, record, text block, pattern matching 등 최신 언어 기능을 적극 활용하여, 원서의 기법이 오늘날 어떤 형태로 적용되는지 보여드리겠습니다.

시리즈는 총 5개 파트로 구성됩니다.

  • Part 1: 레거시 코드의 정의와 변경의 역학 (원서 1~5장)
  • Part 2: Sprout/Wrap 패턴 — 기존 코드에 기능 추가하기 (원서 6~8장)
  • Part 3: 의존성 깨기 기법 I (원서 9~16장)
  • Part 4: 의존성 깨기 기법 II (원서 17~21장)
  • Part 5: 대규모 변경과 코드 이해 전략 (원서 22~25장)

레거시 코드란 무엇인가#

Feathers의 정의는 간결합니다.

Code without tests is legacy code. — Michael Feathers, Working Effectively with Legacy Code (2004)

테스트가 없는 코드는 레거시 코드입니다. 코드가 얼마나 오래되었는지, 어떤 언어로 작성되었는지, 아키텍처가 얼마나 복잡한지는 부차적인 문제입니다. 핵심은 테스트의 유무입니다.

이 정의가 중요한 이유는 명확합니다. 테스트가 없으면 코드를 변경한 후 그 변경이 기존 동작을 깨뜨리지 않았는지 확인할 방법이 없습니다. 변경이 안전한지 검증할 수단이 없는 코드에서는 모든 수정이 잠재적 장애의 씨앗입니다.

코드 변경의 4가지 이유#

Feathers는 소프트웨어에서 코드를 변경하는 이유를 네 가지로 분류합니다.

  1. 기능 추가 (Adding a feature): 새로운 비즈니스 요구사항을 구현합니다.
  2. 버그 수정 (Fixing a bug): 의도와 다르게 동작하는 코드를 바로잡습니다.
  3. 설계 개선 (Improving the design): 외부 동작은 유지하면서 코드의 내부 구조를 개선합니다. 리팩터링이 여기에 해당합니다.
  4. 자원 사용 최적화 (Optimizing resource usage): 메모리, CPU, 네트워크 등의 자원 효율을 높입니다.

이 네 가지 이유에서 공통적으로 중요한 것은, 변경 후에도 기존 동작이 보존되어야 한다는 점입니다. 기능 추가 시에도 기존 기능이 깨져서는 안 되고, 성능 최적화 시에도 비즈니스 로직이 달라져서는 안 됩니다. 이 “기존 동작 보존"을 보장하는 것이 바로 테스트입니다.

The Legacy Code Change Algorithm#

Feathers는 레거시 코드를 안전하게 변경하기 위한 5단계 알고리즘을 제시합니다.

1. 변경 지점 식별 (Identify change points)#

코드의 어느 부분을 변경해야 하는지 파악합니다. 이 단계에서는 코드를 이해하는 것이 핵심입니다. 기능 스케치(Feature Sketching), 영향 분석(Effect Analysis) 등의 기법이 활용됩니다. Part 5에서 대규모 코드베이스를 이해하는 전략과 함께 다룹니다.

2. 테스트 지점 찾기 (Find test points)#

변경의 정확성을 검증할 수 있는 지점을 찾습니다. 변경 지점과 테스트 지점이 항상 같지는 않습니다. 때로는 변경의 영향이 전파되는 경로를 따라 더 상위 레벨에서 테스트하는 것이 효과적입니다.

3. 의존성 깨기 (Break dependencies)#

레거시 코드에서 테스트를 작성하기 어려운 가장 큰 이유는 의존성입니다. 데이터베이스, 외부 API, 파일 시스템, 싱글턴 등에 대한 직접적인 의존성이 코드를 테스트 하네스(test harness)에 넣는 것을 방해합니다. 이 단계에서 의존성을 끊어내어 코드를 테스트 가능하게 만듭니다. Part 3과 Part 4에서 구체적인 의존성 깨기 기법들을 집중적으로 다룹니다.

4. 테스트 작성 (Write tests)#

의존성을 깬 후, 기존 동작을 포착하는 특성 테스트(Characterization Test) 를 작성합니다. 특성 테스트는 코드가 “어떻게 동작해야 하는지"가 아니라 “현재 어떻게 동작하는지"를 기록합니다. 이 테스트가 변경 과정에서 안전망 역할을 합니다.

5. 변경 및 리팩터링 (Make changes and refactor)#

테스트가 있으니 이제 안전하게 코드를 변경할 수 있습니다. 변경 후 테스트를 실행하여 기존 동작이 보존되었는지 확인합니다. 필요하다면 리팩터링을 통해 설계를 개선합니다.

이 시리즈의 나머지 파트들은 이 알고리즘의 각 단계를 깊이 있게 다룹니다. 특히 3단계(의존성 깨기)에 가장 많은 분량을 할애합니다. 레거시 코드에서 가장 어려운 부분이 바로 의존성을 끊어내는 것이기 때문입니다.

Seam Model — 코드의 접합점#

Feathers가 이 책에서 제시하는 가장 핵심적인 개념 중 하나가 Seam입니다.

A seam is a place where you can alter behavior in your program without editing in that place. — Michael Feathers

Seam(접합점)이란, 해당 위치의 코드를 직접 편집하지 않고도 프로그램의 동작을 변경할 수 있는 지점입니다.

그리고 모든 Seam에는 Enabling Point가 존재합니다. Enabling Point는 Seam에서 어떤 동작을 사용할지 결정하는 지점입니다. 예를 들어, 인터페이스가 Seam이라면 생성자에서 어떤 구현체를 주입하느냐가 Enabling Point입니다.

Seam의 3가지 유형#

Feathers는 Seam을 세 가지 유형으로 분류합니다.

Preprocessing Seam: C/C++의 전처리기(#define, #ifdef)를 이용하여 컴파일 전에 코드의 동작을 변경하는 방식입니다. Java에는 전처리기가 없으므로 해당되지 않습니다.

Link Seam: 같은 이름의 클래스나 함수가 링크 단계에서 어떤 구현체로 연결되는지를 바꾸는 방식입니다. Java에서는 클래스패스 순서를 조작하거나, 테스트 시 같은 패키지에 같은 이름의 클래스를 배치하는 것이 이에 해당합니다. 실무에서 의도적으로 사용하기에는 취약한 방식이므로 권장되지 않습니다.

Object Seam: 객체지향 프로그래밍에서 가장 유용하고 안전한 Seam입니다. 인터페이스, 추상 클래스, 다형성을 활용하여 의존 객체를 교체합니다. 생성자 주입, 메서드 오버라이드 등이 Enabling Point가 됩니다. Java 개발에서 가장 빈번하게 사용하는 Seam 유형입니다.

Object Seam을 Modern Java로 구현#

Object Seam의 적용 전후를 비교해 보겠습니다.

Before — 테스트 불가능한 코드 (Java 1.4 스타일)#

public class PaymentProcessor {
    public void processPayment(double amount) {
        // 직접 외부 API 호출 — 테스트 불가능
        HttpClient client = new HttpClient();
        String response = client.post("https://payment.api/charge",
            "amount=" + amount);
        if (response.contains("ERROR")) {
            throw new RuntimeException("Payment failed");
        }
        // 직접 DB 접근 — 테스트 불가능
        Database db = Database.getInstance();
        db.execute("INSERT INTO payments (amount, status) VALUES ("
            + amount + ", 'SUCCESS')");
    }
}

이 코드에는 Seam이 없습니다. HttpClientDatabase가 메서드 내부에서 직접 생성되거나 싱글턴으로 접근되기 때문에, 테스트 시 이들을 대체할 방법이 없습니다. 테스트를 실행하려면 실제 외부 API와 데이터베이스가 필요합니다.

After — Object Seam 적용 (JDK 25)#

// Seam: 인터페이스로 외부 의존성 추상화
public sealed interface PaymentGateway permits HttpPaymentGateway, TestPaymentGateway {
    PaymentResult charge(BigDecimal amount);
}

public record PaymentResult(boolean success, String transactionId) {}

// Production 구현
public final class HttpPaymentGateway implements PaymentGateway {
    private final HttpClient httpClient = HttpClient.newHttpClient();

    @Override
    public PaymentResult charge(BigDecimal amount) {
        var request = HttpRequest.newBuilder()
            .uri(URI.create("https://payment.api/charge"))
            .POST(HttpRequest.BodyPublishers.ofString(
                """
                {"amount": %s}
                """.formatted(amount)))
            .build();
        // ...
        return new PaymentResult(true, "txn-123");
    }
}

// Test 구현 (Enabling Point: 생성자에서 어떤 구현체를 주입할지 결정)
public final class TestPaymentGateway implements PaymentGateway {
    private boolean shouldSucceed = true;

    public void setShouldSucceed(boolean shouldSucceed) {
        this.shouldSucceed = shouldSucceed;
    }

    @Override
    public PaymentResult charge(BigDecimal amount) {
        return new PaymentResult(shouldSucceed, "test-txn-001");
    }
}

// Seam 적용된 PaymentProcessor
public class PaymentProcessor {
    private final PaymentGateway gateway;
    private final PaymentRepository repository;

    // Enabling Point: 생성자 주입
    public PaymentProcessor(PaymentGateway gateway, PaymentRepository repository) {
        this.gateway = gateway;
        this.repository = repository;
    }

    public void processPayment(BigDecimal amount) {
        var result = gateway.charge(amount);
        if (!result.success()) {
            throw new PaymentException("Payment failed");
        }
        repository.save(new PaymentRecord(amount, result.transactionId()));
    }
}

변경된 점을 정리하면 다음과 같습니다.

  • sealed interface로 Seam을 명시적으로 정의했습니다 (JDK 17+). PaymentGateway의 구현체가 HttpPaymentGatewayTestPaymentGateway로 제한된다는 것을 타입 시스템 레벨에서 선언합니다. 실무에서는 sealed 대신 일반 interface를 사용하는 것이 더 유연할 수 있지만, 여기서는 Seam의 범위를 명시적으로 보여주기 위해 사용했습니다.
  • record로 값 객체를 간결하게 표현했습니다 (JDK 16+). PaymentResult는 불변 데이터 캐리어입니다. equals(), hashCode(), toString()이 자동으로 생성됩니다.
  • Text block으로 JSON 리터럴을 가독성 있게 작성했습니다 (JDK 15+). 문자열 연결 대신 """...""" 블록을 사용합니다.
  • 생성자 주입이 Enabling Point 역할을 합니다. 프로덕션에서는 HttpPaymentGateway를, 테스트에서는 TestPaymentGateway를 주입합니다. PaymentProcessor의 코드를 변경하지 않고도 동작을 바꿀 수 있습니다. 이것이 Seam의 핵심입니다.
  • BigDecimal로 금액을 처리합니다. double로 금액을 다루면 부동소수점 오차가 발생합니다. 금융 계산에서는 반드시 BigDecimal을 사용해야 합니다.

2026년의 테스트 도구#

레거시 코드에 테스트를 추가하려면 테스트 도구에 대한 기본적인 이해가 필요합니다. 2026년 현재 Java 생태계의 표준 테스트 도구를 간략히 소개합니다.

JUnit 5 (Jupiter)#

Java의 사실상 표준 테스트 프레임워크입니다. 주요 어노테이션은 다음과 같습니다.

  • @Test: 테스트 메서드를 표시합니다.
  • @BeforeEach: 각 테스트 전에 실행되는 설정 메서드를 표시합니다.
  • @DisplayName: 테스트의 표시 이름을 지정합니다. 한국어로 작성하면 테스트 결과가 읽기 쉬워집니다.
  • @Nested: 테스트 클래스 안에 내부 클래스를 만들어 테스트를 계층적으로 구성합니다.
  • @ParameterizedTest: 여러 입력값으로 같은 테스트를 반복 실행합니다.

Mockito 5+#

목(mock) 객체를 생성하고 동작을 정의하는 프레임워크입니다. mock(), when(), verify() 등의 API로 의존 객체의 동작을 제어합니다. @ExtendWith(MockitoExtension.class)로 JUnit 5와 통합합니다.

AssertJ#

유창한(fluent) 스타일의 assertion API를 제공합니다. assertThat(actual).isEqualTo(expected) 형태로 검증 코드를 자연어에 가깝게 작성할 수 있습니다. 예외 검증 시 assertThatThrownBy()가 특히 유용합니다.

PaymentProcessor 테스트 예제#

앞에서 Object Seam을 적용한 PaymentProcessor를 테스트하는 코드입니다.

@ExtendWith(MockitoExtension.class)
class PaymentProcessorTest {

    @Mock
    private PaymentRepository repository;

    private TestPaymentGateway gateway;
    private PaymentProcessor processor;

    @BeforeEach
    void setUp() {
        gateway = new TestPaymentGateway();
        processor = new PaymentProcessor(gateway, repository);
    }

    @Test
    @DisplayName("결제 성공 시 결제 기록이 저장된다")
    void savesPaymentRecord_whenPaymentSucceeds() {
        var amount = new BigDecimal("50000");

        processor.processPayment(amount);

        verify(repository).save(argThat(record ->
            record.amount().equals(amount) &&
            record.transactionId().equals("test-txn-001")
        ));
    }

    @Test
    @DisplayName("결제 실패 시 PaymentException이 발생한다")
    void throwsException_whenPaymentFails() {
        gateway.setShouldSucceed(false);

        assertThatThrownBy(() -> processor.processPayment(new BigDecimal("50000")))
            .isInstanceOf(PaymentException.class)
            .hasMessageContaining("Payment failed");
    }
}

PaymentGateway에는 TestPaymentGateway를 직접 생성하여 주입하고, PaymentRepository에는 Mockito의 @Mock을 사용했습니다. 이 차이에 주목할 필요가 있습니다. TestPaymentGateway는 테스트에서 결제 성공/실패 시나리오를 제어하기 위해 명시적으로 만든 테스트 구현체이고, PaymentRepositorysave() 호출 여부만 검증하면 되므로 mock으로 충분합니다. 상황에 따라 적절한 테스트 더블을 선택하는 것이 중요합니다.

마무리#

이 글에서는 Feathers의 레거시 코드 정의, 코드 변경의 4가지 이유, Legacy Code Change Algorithm, 그리고 Seam Model의 핵심 개념을 살펴보았습니다. 특히 Object Seam을 JDK 25의 sealed interface, record, text block 등으로 구현하는 방법을 확인했습니다. Part 2에서는 기존 코드에 기능을 추가할 때 가장 즉시 적용 가능한 Sprout Method, Sprout Class, Wrap Method, Wrap Class 패턴을 다룹니다.

References#