WELC ver. 2026 Part 2: 안전한 코드 변경 패턴 — Sprout와 Wrap
이 글은 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 적용 단계#
- 변경이 필요한 코드의 위치를 식별합니다.
- 새 기능을 독립된 메서드로 작성합니다.
- 기존 메서드에서 새 메서드를 호출합니다.
- 새 메서드에 대한 테스트를 작성합니다.
장점: 새 코드와 기존 코드의 경계가 명확합니다. 새 코드는 테스트 가능합니다.
단점: 기존 메서드는 여전히 테스트되지 않은 상태입니다. 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 적용 단계#
- 기존 메서드의 이름을 변경합니다 (
pay->dispatchPay). - 원래 이름으로 새 메서드를 만듭니다.
- 새 메서드에서 이름이 변경된 기존 메서드와 새 기능을 호출합니다.
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 절차는 다음과 같습니다.
- 실패하는 테스트 작성
- 컴파일되게 만들기
- 테스트 통과시키기
- 중복 제거
- 반복
이 절차에서 특히 “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#
- Feathers, M. (2004). Working Effectively with Legacy Code. Prentice Hall. ISBN 978-0131177055.
- OpenJDK. “JDK 25”. https://openjdk.org/projects/jdk/25/
- JEP 409: Sealed Classes. https://openjdk.org/jeps/409
- JEP 395: Records. https://openjdk.org/jeps/395
- JEP 394: Pattern Matching for instanceof. https://openjdk.org/jeps/394
- JUnit 5 User Guide. https://junit.org/junit5/docs/current/user-guide/
- Gamma, E. et al. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. (Decorator 패턴)
- Understand Legacy Code. “Key Points of Working Effectively with Legacy Code”. https://understandlegacycode.com/blog/key-points-of-working-effectively-with-legacy-code/