WELC ver. 2026 Part 4: 대규모 코드 문제 다루기
이 글은 Claude Opus 4.6 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
도입#
레거시 시스템에서 가장 자주 마주치는 문제는 규모의 문제입니다. 한 클래스가 수천 줄, 한 메서드가 수백 줄인 코드는 현업에서 흔히 발견됩니다. 이런 코드는 읽기 어렵고, 테스트하기 어렵고, 변경하면 예상치 못한 곳에서 장애가 발생합니다.
이번 글에서는 Michael Feathers의 Working Effectively with Legacy Code 14~22장을 바탕으로, 대규모 코드 문제를 다루는 전략을 JDK 25 기준의 modern Java 코드로 살펴봅니다. SOLID 원칙 중 SRP, ISP, OCP의 실전 적용이 핵심입니다.
라이브러리 의존성 문제#
외부 라이브러리에 직접 의존하는 코드#
레거시 코드에서 흔히 볼 수 있는 패턴은 비즈니스 로직이 특정 라이브러리의 API에 직접 의존하는 것입니다. 라이브러리를 교체하거나 업그레이드할 때 비즈니스 로직까지 수정해야 하고, 라이브러리가 테스트하기 어려운 구조라면 비즈니스 로직의 테스트도 어려워집니다.
Before:
public class OrderExporter {
public void export(List<Order> orders) {
// Apache POI에 직접 의존
XSSFWorkbook workbook = new XSSFWorkbook();
XSSFSheet sheet = workbook.createSheet("Orders");
for (int i = 0; i < orders.size(); i++) {
XSSFRow row = sheet.createRow(i);
row.createCell(0).setCellValue(orders.get(i).getId());
row.createCell(1).setCellValue(orders.get(i).getTotal().doubleValue());
}
try (FileOutputStream fos = new FileOutputStream("/exports/orders.xlsx")) {
workbook.write(fos);
}
}
}
OrderExporter의 비즈니스 로직(주문 데이터를 스프레드시트로 내보내기)이 Apache POI의 XSSFWorkbook, XSSFSheet, XSSFRow 등에 직접 의존하고 있습니다. 이 클래스를 테스트하려면 실제로 파일을 생성해야 합니다.
After (JDK 25 – Adapter 패턴으로 라이브러리 의존성 격리):
// 추상화 계층
public interface SpreadsheetWriter {
void createSheet(String name);
void writeRow(int rowIndex, List<String> values);
void save(Path outputPath) throws IOException;
}
// Apache POI 구현 (이 클래스만 라이브러리에 의존)
public class PoiSpreadsheetWriter implements SpreadsheetWriter {
private final XSSFWorkbook workbook = new XSSFWorkbook();
private XSSFSheet currentSheet;
@Override
public void createSheet(String name) {
this.currentSheet = workbook.createSheet(name);
}
@Override
public void writeRow(int rowIndex, List<String> values) {
var row = currentSheet.createRow(rowIndex);
for (int i = 0; i < values.size(); i++) {
row.createCell(i).setCellValue(values.get(i));
}
}
@Override
public void save(Path outputPath) throws IOException {
try (var fos = new FileOutputStream(outputPath.toFile())) {
workbook.write(fos);
}
}
}
// 비즈니스 로직은 추상화에만 의존
public class OrderExporter {
private final SpreadsheetWriter writer;
public OrderExporter(SpreadsheetWriter writer) {
this.writer = writer;
}
public void export(List<Order> orders, Path outputPath) throws IOException {
writer.createSheet("Orders");
for (int i = 0; i < orders.size(); i++) {
writer.writeRow(i, List.of(
orders.get(i).id(),
orders.get(i).total().toPlainString()
));
}
writer.save(outputPath);
}
}
이제 테스트에서 SpreadsheetWriter를 mock으로 대체할 수 있습니다.
@Test
void exportsOrdersToSpreadsheet() throws IOException {
var writer = mock(SpreadsheetWriter.class);
var exporter = new OrderExporter(writer);
var orders = List.of(
new Order("ORD-1", new BigDecimal("15000")),
new Order("ORD-2", new BigDecimal("23000"))
);
exporter.export(orders, Path.of("/tmp/test.xlsx"));
verify(writer).createSheet("Orders");
verify(writer).writeRow(0, List.of("ORD-1", "15000"));
verify(writer).writeRow(1, List.of("ORD-2", "23000"));
}
Feathers의 조언은 명확합니다.
라이브러리를 직접 사용하지 말고, 라이브러리를 감싸는 얇은 추상화 계층을 만드세요. 테스트할 수 없는 코드에 대한 의존성을 관리 가능한 인터페이스 뒤로 숨기세요.
핵심은 라이브러리의 API가 아무리 잘 설계되어 있어도, 비즈니스 로직이 특정 라이브러리에 직접 의존하면 테스트와 교체가 어려워진다는 것입니다. Adapter 패턴으로 얇은 추상화 계층을 만들면, 비즈니스 로직은 자체 인터페이스에만 의존하게 됩니다.
비대한 클래스 다루기 – SRP와 ISP#
Single Responsibility Principle (SRP)#
Robert C. Martin이 정의한 SRP의 핵심은 다음과 같습니다.
클래스는 변경의 이유가 하나여야 합니다.
Feathers는 비대한 클래스에서 책임을 발견하는 5가지 휴리스틱을 제시합니다.
- 메서드 그룹화 – 관련된 메서드들을 묶어봅니다. 한 클래스 안에서 서로 다른 그룹이 보이면, 각 그룹이 별도 클래스의 후보입니다.
- 숨겨진 메서드 찾기 – private 메서드가 많다면 별도 클래스가 숨어있을 수 있습니다. private 메서드를 새 클래스의 public 메서드로 승격시킬 수 있는지 검토합니다.
- 변경 가능한 결정 찾기 – 향후 변경될 수 있는 결정(데이터 형식, 알고리즘, 외부 서비스 등)을 캡슐화합니다.
- 내부 관계 찾기 – 인스턴스 변수 간의 사용 관계를 분석합니다. 특정 메서드 그룹이 특정 인스턴스 변수만 사용한다면, 해당 그룹과 변수를 별도 클래스로 추출할 수 있습니다.
- 주요 책임 찾기 – 클래스의 이름이 주요 책임을 나타내야 합니다. 클래스 이름으로 설명할 수 없는 메서드가 있다면, 그 메서드는 다른 클래스에 속해야 합니다.
Before (비대한 클래스):
public class UserService {
private final DataSource dataSource;
private final EmailSender emailSender;
private final PasswordEncoder encoder;
private final AuditLogger auditLogger;
// 사용자 CRUD
public User findById(long id) { /* SQL 직접 실행... */ }
public void save(User user) { /* SQL 직접 실행... */ }
public void delete(long id) { /* SQL 직접 실행... */ }
// 인증
public boolean authenticate(String username, String password) { /* ... */ }
public void resetPassword(String email) { /* ... */ }
public String generateToken(User user) { /* JWT 생성... */ }
// 알림
public void sendWelcomeEmail(User user) { /* ... */ }
public void sendPasswordResetEmail(String email, String token) { /* ... */ }
// 감사(audit)
public void logLogin(User user) { /* ... */ }
public void logAction(User user, String action) { /* ... */ }
}
이 클래스에는 최소 4가지 변경의 이유가 있습니다. 데이터 접근 방식 변경, 인증 정책 변경, 알림 방식 변경, 감사 정책 변경입니다. Feathers의 휴리스틱을 적용하면, 메서드 그룹이 명확하게 4개로 나뉘고, 각 그룹이 사용하는 인스턴스 변수도 다릅니다.
After (JDK 25 – SRP 적용, 책임별 분리):
// 1. 사용자 저장소 -- 데이터 접근 책임
public class UserRepository {
private final DataSource dataSource;
public UserRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
public Optional<User> findById(long id) {
// JDBC or JPA
}
public Optional<User> findByUsername(String username) {
// JDBC or JPA
}
public void save(User user) { /* ... */ }
public void delete(long id) { /* ... */ }
}
// 2. 인증 서비스 -- 인증 책임
public class AuthenticationService {
private final UserRepository userRepository;
private final PasswordEncoder encoder;
private final TokenGenerator tokenGenerator;
public AuthenticationService(UserRepository userRepository,
PasswordEncoder encoder,
TokenGenerator tokenGenerator) {
this.userRepository = userRepository;
this.encoder = encoder;
this.tokenGenerator = tokenGenerator;
}
public Optional<AuthToken> authenticate(String username, String password) {
return userRepository.findByUsername(username)
.filter(user -> encoder.matches(password, user.passwordHash()))
.map(tokenGenerator::generate);
}
}
// 3. 알림 서비스 -- 알림 책임
public class UserNotificationService {
private final EmailSender emailSender;
private final TemplateEngine templateEngine;
public UserNotificationService(EmailSender emailSender,
TemplateEngine templateEngine) {
this.emailSender = emailSender;
this.templateEngine = templateEngine;
}
public void sendWelcome(User user) {
var body = templateEngine.render("welcome", Map.of("name", user.name()));
emailSender.send(user.email(), "Welcome!", body);
}
}
// record로 값 객체 정의
public record AuthToken(String token, Instant expiresAt) {}
각 클래스는 하나의 변경 이유만 가집니다. UserRepository는 데이터 접근 방식이 변경될 때만, AuthenticationService는 인증 정책이 변경될 때만 수정하면 됩니다. 테스트도 각 책임 단위로 독립적으로 작성할 수 있습니다.
Interface Segregation Principle (ISP)#
ISP의 핵심은 다음과 같습니다.
클라이언트는 사용하지 않는 메서드에 의존하지 않아야 합니다.
비대한 인터페이스는 구현체에 불필요한 메서드 구현을 강제하고, 클라이언트에 불필요한 의존성을 만듭니다.
Before:
public interface ScheduledJob {
void run();
boolean isRunning();
void pause();
void resume();
void cancel();
String getSchedule();
void setSchedule(String cron);
List<JobExecution> getHistory();
JobMetrics getMetrics();
}
일회성 정리 작업을 구현하려면 pause(), resume(), getSchedule(), setSchedule() 등 사용하지 않는 메서드도 모두 구현해야 합니다. 대부분 throw new UnsupportedOperationException()으로 채워지거나 빈 메서드로 남습니다.
After (JDK 25 – ISP 적용):
public interface Executable {
void run();
boolean isRunning();
}
public interface Pausable {
void pause();
void resume();
}
public interface Cancellable {
void cancel();
}
public interface Schedulable {
String getSchedule();
void setSchedule(String cron);
}
public interface Observable {
List<JobExecution> getHistory();
JobMetrics getMetrics();
}
// 필요한 인터페이스만 조합
public class DataSyncJob implements Executable, Schedulable, Observable {
// Executable, Schedulable, Observable의 메서드만 구현
}
// 일회성 작업은 실행과 취소만
public class OneTimeCleanupJob implements Executable, Cancellable {
// Executable, Cancellable의 메서드만 구현
}
각 클라이언트는 자신이 실제로 사용하는 인터페이스에만 의존합니다. 스케줄러는 Schedulable에만 의존하고, 모니터링 시스템은 Observable에만 의존합니다. 인터페이스가 작아지면 구현과 테스트 모두 간결해집니다.
몬스터 메서드 다루기#
Feathers는 비대한 메서드를 두 가지로 분류합니다.
- Bulleted Method: 들여쓰기가 거의 없는 긴 순차 코드입니다. 각 단계가 순서대로 나열되어 있고, 단계 간 의존성이 낮습니다.
- Snarled Method: 복잡한 조건 분기와 중첩 루프로 이루어진 코드입니다. 단계 간 의존성이 높고, 로컬 변수가 여러 단계에 걸쳐 사용됩니다.
두 경우 모두 기본 전략은 Extract Method입니다. 코드 블록에 의미 있는 이름을 부여하여 코드의 의도를 드러내는 것이 핵심입니다.
Before (몬스터 메서드):
public class InvoiceProcessor {
public Invoice process(Order order) {
// 1. 유효성 검증 (30줄)
if (order == null) throw new IllegalArgumentException("Order is null");
if (order.getItems() == null || order.getItems().isEmpty())
throw new IllegalArgumentException("No items");
for (OrderItem item : order.getItems()) {
if (item.getPrice() == null || item.getPrice().compareTo(BigDecimal.ZERO) < 0)
throw new IllegalArgumentException("Invalid price");
}
// 2. 소계 계산 (20줄)
BigDecimal subtotal = BigDecimal.ZERO;
for (OrderItem item : order.getItems()) {
subtotal = subtotal.add(item.getPrice().multiply(
BigDecimal.valueOf(item.getQuantity())));
}
// 3. 할인 적용 (30줄)
BigDecimal discount = BigDecimal.ZERO;
if (subtotal.compareTo(new BigDecimal("100000")) > 0) {
discount = subtotal.multiply(new BigDecimal("0.10"));
} else if (subtotal.compareTo(new BigDecimal("50000")) > 0) {
discount = subtotal.multiply(new BigDecimal("0.05"));
}
// 4. 세금 계산 (15줄)
BigDecimal afterDiscount = subtotal.subtract(discount);
BigDecimal tax = afterDiscount.multiply(new BigDecimal("0.10"));
// 5. 송장 생성 (20줄)
Invoice invoice = new Invoice();
invoice.setOrderId(order.getId());
invoice.setSubtotal(subtotal);
invoice.setDiscount(discount);
invoice.setTax(tax);
invoice.setTotal(afterDiscount.add(tax));
invoice.setCreatedAt(new Date());
return invoice;
}
}
이 메서드는 Bulleted Method에 해당합니다. 유효성 검증, 소계 계산, 할인 적용, 세금 계산, 송장 생성이라는 5개 단계가 순차적으로 나열되어 있습니다. 각 단계를 Extract Method로 추출하고, 할인/세금 계산처럼 정책이 변경될 수 있는 부분은 별도 객체로 분리합니다.
After (JDK 25 – Extract Method + record + sealed interface):
public class InvoiceProcessor {
private final DiscountPolicy discountPolicy;
private final TaxPolicy taxPolicy;
public InvoiceProcessor(DiscountPolicy discountPolicy, TaxPolicy taxPolicy) {
this.discountPolicy = discountPolicy;
this.taxPolicy = taxPolicy;
}
public Invoice process(Order order) {
validateOrder(order);
var subtotal = calculateSubtotal(order);
var discount = discountPolicy.calculate(subtotal);
var tax = taxPolicy.calculate(subtotal.subtract(discount));
return createInvoice(order, subtotal, discount, tax);
}
private void validateOrder(Order order) {
if (order == null) throw new IllegalArgumentException("Order is null");
if (order.items().isEmpty())
throw new IllegalArgumentException("No items");
order.items().forEach(item -> {
if (item.price() == null || item.price().signum() < 0)
throw new IllegalArgumentException(
"Invalid price for: " + item.name());
});
}
private BigDecimal calculateSubtotal(Order order) {
return order.items().stream()
.map(item -> item.price().multiply(BigDecimal.valueOf(item.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
private Invoice createInvoice(Order order, BigDecimal subtotal,
BigDecimal discount, BigDecimal tax) {
var afterDiscount = subtotal.subtract(discount);
return new Invoice(
order.id(),
subtotal,
discount,
tax,
afterDiscount.add(tax),
Instant.now()
);
}
}
process() 메서드가 5줄로 줄었고, 각 줄이 하나의 단계를 나타냅니다. 코드를 읽는 것만으로 “검증 -> 소계 계산 -> 할인 계산 -> 세금 계산 -> 송장 생성"이라는 흐름이 드러납니다.
Invoice는 record로 재정의합니다.
public record Invoice(
String orderId,
BigDecimal subtotal,
BigDecimal discount,
BigDecimal tax,
BigDecimal total,
Instant createdAt
) {}
할인 정책은 sealed interface와 record로 정의하여, 가능한 할인 유형을 타입 시스템으로 제한합니다.
public sealed interface DiscountPolicy
permits TieredDiscount, FlatDiscount, NoDiscount {
BigDecimal calculate(BigDecimal subtotal);
}
public record TieredDiscount(List<DiscountTier> tiers) implements DiscountPolicy {
public record DiscountTier(BigDecimal threshold, BigDecimal rate) {}
@Override
public BigDecimal calculate(BigDecimal subtotal) {
return tiers.stream()
.filter(tier -> subtotal.compareTo(tier.threshold()) > 0)
.map(tier -> subtotal.multiply(tier.rate()))
.max(BigDecimal::compareTo)
.orElse(BigDecimal.ZERO);
}
}
public record FlatDiscount(BigDecimal rate) implements DiscountPolicy {
@Override
public BigDecimal calculate(BigDecimal subtotal) {
return subtotal.multiply(rate);
}
}
public record NoDiscount() implements DiscountPolicy {
@Override
public BigDecimal calculate(BigDecimal subtotal) {
return BigDecimal.ZERO;
}
}
sealed interface는 컴파일 타임에 가능한 모든 구현체를 열거합니다. switch 표현식에서 pattern matching과 함께 사용하면, 새 할인 유형 추가 시 컴파일러가 처리되지 않은 케이스를 알려줍니다.
중복 코드 문제 – Open/Closed Principle#
Robert C. Martin이 정리한 Open/Closed Principle(OCP)의 핵심은 다음과 같습니다.
소프트웨어 엔티티는 확장에는 열려 있고, 수정에는 닫혀 있어야 합니다.
레거시 코드에서 중복이 발생하는 전형적인 패턴은, 유사한 구조의 코드가 약간의 차이만 두고 여러 곳에 복사-붙여넣기 되는 것입니다. 공통 부분과 가변 부분을 분리하지 않은 결과입니다.
Before (중복 코드):
public class AddEmployeeCommand {
public void execute() {
// 헤더 작성
output.write("CMD:ADD_EMPLOYEE\n");
output.write("VERSION:1\n");
output.write("TIMESTAMP:" + System.currentTimeMillis() + "\n");
// 페이로드
output.write("NAME:" + name + "\n");
output.write("DEPARTMENT:" + department + "\n");
}
}
public class RemoveEmployeeCommand {
public void execute() {
// 헤더 작성 (중복!)
output.write("CMD:REMOVE_EMPLOYEE\n");
output.write("VERSION:1\n");
output.write("TIMESTAMP:" + System.currentTimeMillis() + "\n");
// 페이로드
output.write("EMPLOYEE_ID:" + employeeId + "\n");
}
}
헤더 작성 로직이 중복되어 있습니다. 명령 타입이 추가될 때마다 같은 헤더 코드가 복사됩니다. 헤더 형식이 변경되면 모든 Command 클래스를 수정해야 합니다.
After (JDK 25 – sealed interface + record로 OCP 적용):
public sealed interface Command
permits AddEmployeeCommand, RemoveEmployeeCommand,
TransferEmployeeCommand {
String commandType();
Map<String, String> payload();
}
public record AddEmployeeCommand(String name, String department)
implements Command {
@Override
public String commandType() { return "ADD_EMPLOYEE"; }
@Override
public Map<String, String> payload() {
return Map.of("NAME", name, "DEPARTMENT", department);
}
}
public record RemoveEmployeeCommand(String employeeId) implements Command {
@Override
public String commandType() { return "REMOVE_EMPLOYEE"; }
@Override
public Map<String, String> payload() {
return Map.of("EMPLOYEE_ID", employeeId);
}
}
// 직렬화 로직은 한 곳에만 존재
public class CommandSerializer {
public String serialize(Command command) {
var sb = new StringBuilder();
sb.append("CMD:%s\n".formatted(command.commandType()));
sb.append("VERSION:1\n");
sb.append("TIMESTAMP:%d\n".formatted(System.currentTimeMillis()));
command.payload().forEach((k, v) ->
sb.append("%s:%s\n".formatted(k, v)));
return sb.toString();
}
}
이 구조의 핵심을 정리하면 다음과 같습니다.
sealed interface+record로 명령 타입을 열거합니다. 각 명령은 자신의 타입 이름과 페이로드만 정의합니다.- 직렬화 로직의 공통 부분(헤더 형식)은
CommandSerializer에 한 번만 작성합니다. - 새 명령 타입 추가 시
Command의permits에 추가하고 record만 만들면 됩니다. 기존 코드를 수정할 필요가 없습니다.
이것이 OCP가 말하는 “확장에 열려 있고, 수정에 닫혀 있는” 구조입니다.
코드 이해하기 – Scratch Refactoring#
Feathers가 제시한 기법 중 가장 직관적이면서도 간과되기 쉬운 것이 Scratch Refactoring입니다. 코드를 이해하기 위해 과감하게 리팩터링하되, 그 결과를 버리는 기법입니다.
절차는 다음과 같습니다.
- 버전 관리에서 현재 상태를 체크포인트합니다.
- 테스트 없이 과감하게 이름 변경, 메서드 추출, 클래스 분리를 수행합니다. 컴파일이 깨져도 상관없습니다.
- 리팩터링 과정에서 코드의 구조와 의존 관계를 파악합니다.
- 모든 변경을 되돌립니다 (
git checkout .). - 이해한 내용을 바탕으로 테스트를 먼저 작성하며, 이번에는 안전하게 리팩터링합니다.
이 기법은 “도구"라기보다 “마인드셋"에 가깝습니다. 리팩터링은 코드를 이해하는 데 최고의 도구이지만, 테스트 없이 리팩터링한 결과를 프로덕션에 반영하는 것은 위험합니다. 결과를 버리되 이해를 남기는 것이 핵심입니다.
수천 줄짜리 레거시 클래스를 처음 마주했을 때, 읽기만 해서는 구조를 파악하기 어렵습니다. 직접 이름을 바꾸고, 메서드를 추출하고, 클래스를 분리해보면 “이 변수가 저 메서드에서 쓰이는구나”, “이 부분은 사실 별도 책임이구나"라는 이해가 자연스럽게 따라옵니다. 다만, 그 결과물은 과감하게 버려야 합니다.
마무리#
Part 5에서는 이 시리즈의 마지막으로, 의존성을 깨는 25가지 기법 중 modern Java에서 특히 유용한 핵심 기법들을 카탈로그 형태로 정리합니다.
References#
- Feathers, M. (2004). Working Effectively with Legacy Code. Prentice Hall. ISBN 978-0131177055.
- Martin, R.C. (2003). Agile Software Development: Principles, Patterns, and Practices. Prentice Hall. (SRP, OCP, ISP)
- 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
- Gamma, E. et al. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. (Adapter, Decorator, Template Method)
- Fowler, M. (2018). Refactoring: Improving the Design of Existing Code (2nd ed.). Addison-Wesley.
- Understand Legacy Code. “Key Points of Working Effectively with Legacy Code”. https://understandlegacycode.com/blog/key-points-of-working-effectively-with-legacy-code/