리팩토링으로 기존 코드의 구조를 개선하는 방법
리팩토링의 목적과 코드 냄새, 메서드·클래스·일반화 기법을 정리하고 테스트 및 설계 패턴과의 관계를 살펴본다.
2026-08-14 · 최초 발행 2025-05-23
동작을 바꾸지 않고 구조를 바꾸는 일
리팩토링은 외부에서 관찰되는 동작을 유지하면서 코드 내부의 구조를 개선하는 과정이다. 가독성과 유지보수성을 높이고 버그 발생 가능성을 낮추는 데 목적이 있으며, 기술 부채를 관리하는 방법이기도 하다.
이 개념은 마틴 파울러(Martin Fowler)의 저서 *"Refactoring: Improving the Design of Existing Code"*를 통해 체계화됐다. 한 번의 대규모 정비보다 지속적으로 작은 개선을 반복하는 방식이 소프트웨어 품질 관리에 연결된다.
코드 냄새가 알려주는 구조적 문제
코드 냄새(Code Smell)는 더 깊은 설계 문제를 암시하는 표면적 징후다. 냄새 자체가 곧 오류라는 뜻은 아니지만, 코드를 변경하기 전에 구조를 살펴봐야 할 지점을 알려준다.
대표적인 신호는 다음과 같다.
- 중복 코드(Duplicated Code): 동일한 코드 구조가 여러 위치에 존재한다.
- 긴 메서드(Long Method): 하나의 메서드가 너무 많은 기능을 맡는다.
- 거대한 클래스(Large Class): 클래스에 책임이 과도하게 몰려 있다.
- 과도한 매개변수(Long Parameter List): 메서드가 너무 많은 매개변수를 받는다.
- 데이터 뭉치(Data Clumps): 특정 데이터 항목이 항상 함께 등장한다.
- 기능 탐닉(Feature Envy): 메서드가 자신의 클래스보다 다른 클래스의 데이터를 지나치게 사용한다.
- 임시 필드(Temporary Field): 특정 상황에서만 쓰이는 인스턴스 변수가 있다.
- 거부된 유산(Refused Bequest): 하위 클래스가 상위 클래스에서 물려받은 기능을 사용하지 않는다.
메서드와 책임의 경계를 다시 잡기
메서드 수준에서는 코드 일부를 별도 메서드로 분리하는 Extract Method가 자주 쓰인다. 계산과 출력처럼 서로 다른 일을 한 메서드에 섞어 두지 않고, 각 역할을 드러내는 이름으로 나누는 방식이다.
// 리팩토링 전
void printOwing() {
printBanner();
// 미지불금 계산
double outstanding = 0.0;
for (Order order : orders) {
outstanding += order.getAmount();
}
// 상세 정보 출력
System.out.println("고객명: " + name);
System.out.println("미지불금: " + outstanding);
}
// 리팩토링 후
void printOwing() {
printBanner();
double outstanding = calculateOutstanding();
printDetails(outstanding);
}
double calculateOutstanding() {
double result = 0.0;
for (Order order : orders) {
result += order.getAmount();
}
return result;
}
void printDetails(double outstanding) {
System.out.println("고객명: " + name);
System.out.println("미지불금: " + outstanding);
}
반대로 메서드 본문이 메서드명만큼 명확하다면 Inline Method로 호출을 본문으로 대체할 수 있다. Replace Temp with Query는 임시 변수를 쿼리 메서드로 바꾸고, Introduce Parameter Object는 함께 전달되는 여러 매개변수를 하나의 객체로 묶는다.
클래스 경계를 조정할 때는 Move Method로 메서드를 더 적절한 클래스로 옮기고, Extract Class로 과도한 책임을 분리한다. Hide Delegate는 위임 관계를 감추며, Remove Middle Man은 불필요하게 늘어난 위임을 없앤다.
상속 구조도 리팩토링 대상이다. 하위 클래스에 중복된 필드나 메서드는 Pull Up Field/Method로 상위 클래스로 올리고, 특정 하위 클래스에만 필요한 요소는 Push Down Field/Method로 내린다. 공통 동작은 Extract Interface/Superclass로 추출할 수 있으며, 상속 관계가 맞지 않을 때는 Replace Inheritance with Delegation을 적용한다.
작은 변경과 검증을 반복하는 흐름
리팩토링은 코드 냄새를 찾고, 적절한 기법을 선택한 뒤, 테스트로 변경을 확인하는 순환 과정으로 진행된다. 테스트가 실패하면 변경을 되돌리고 다시 검토한다.
영화 대여 시스템에서 책임을 옮긴 예
아래 초기 구현에서는 Customer.statement()가 대여 요금 계산, 적립 포인트 계산, 출력 문자열 생성까지 모두 담당한다.
class Customer {
private String name;
private List<Rental> rentals = new ArrayList<>();
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
String result = "대여 기록 (" + name + ")\n";
for (Rental rental : rentals) {
double thisAmount = 0;
// 영화 유형에 따른 요금 계산
switch (rental.getMovie().getPriceCode()) {
case Movie.REGULAR:
thisAmount += 2;
if (rental.getDaysRented() > 2)
thisAmount += (rental.getDaysRented() - 2) * 1.5;
break;
case Movie.NEW_RELEASE:
thisAmount += rental.getDaysRented() * 3;
break;
case Movie.CHILDRENS:
thisAmount += 1.5;
if (rental.getDaysRented() > 3)
thisAmount += (rental.getDaysRented() - 3) * 1.5;
break;
}
// 적립 포인트 계산
frequentRenterPoints++;
if (rental.getMovie().getPriceCode() == Movie.NEW_RELEASE
&& rental.getDaysRented() > 1)
frequentRenterPoints++;
result += "\t" + rental.getMovie().getTitle() + "\t"
+ String.valueOf(thisAmount) + "\n";
totalAmount += thisAmount;
}
result += "총액: " + String.valueOf(totalAmount) + "\n";
result += "적립 포인트: " + String.valueOf(frequentRenterPoints);
return result;
}
}
리팩토링 뒤에는 Customer가 대여 목록을 순회하고 합계를 모으는 역할에 집중한다. 요금과 포인트 계산은 Rental과 Movie 쪽으로 이동하며, 영화 유형에 따른 차이는 하위 클래스의 다형성으로 표현된다.
class Customer {
private String name;
private List<Rental> rentals = new ArrayList<>();
public String statement() {
String result = "대여 기록 (" + name + ")\n";
for (Rental rental : rentals) {
result += "\t" + rental.getMovie().getTitle() + "\t"
+ String.valueOf(rental.getCharge()) + "\n";
}
result += "총액: " + String.valueOf(getTotalCharge()) + "\n";
result += "적립 포인트: " + String.valueOf(getTotalFrequentRenterPoints());
return result;
}
private double getTotalCharge() {
double result = 0;
for (Rental rental : rentals) {
result += rental.getCharge();
}
return result;
}
private int getTotalFrequentRenterPoints() {
int result = 0;
for (Rental rental : rentals) {
result += rental.getFrequentRenterPoints();
}
return result;
}
}
class Rental {
private Movie movie;
private int daysRented;
public double getCharge() {
return movie.getCharge(daysRented);
}
public int getFrequentRenterPoints() {
return movie.getFrequentRenterPoints(daysRented);
}
}
abstract class Movie {
public static final int REGULAR = 0;
public static final int NEW_RELEASE = 1;
public static final int CHILDRENS = 2;
private String title;
public abstract double getCharge(int daysRented);
public int getFrequentRenterPoints(int daysRented) {
return 1;
}
}
class RegularMovie extends Movie {
@Override
public double getCharge(int daysRented) {
double result = 2;
if (daysRented > 2)
result += (daysRented - 2) * 1.5;
return result;
}
}
class NewReleaseMovie extends Movie {
@Override
public double getCharge(int daysRented) {
return daysRented * 3;
}
@Override
public int getFrequentRenterPoints(int daysRented) {
return (daysRented > 1) ? 2 : 1;
}
}
class ChildrensMovie extends Movie {
@Override
public double getCharge(int daysRented) {
double result = 1.5;
if (daysRented > 3)
result += (daysRented - 3) * 1.5;
return result;
}
}
설계 패턴으로 이어지는 구조 개선
리팩토링은 설계 패턴을 적용할 수 있는 구조로 코드를 바꾸는 과정과 맞닿아 있다. 조건문을 다형성으로 전환할 때는 Strategy 패턴이 연결될 수 있고, 중복을 공통 상위 클래스로 끌어올릴 때는 Template Method 패턴을 활용한다.
객체 생성 로직을 캡슐화하려면 Factory Method 패턴을 도입할 수 있다. 객체와 객체 집합을 같은 방식으로 다뤄야 한다면 Composite 패턴이 대상이 된다.
레거시와 장바구니 시스템에서의 적용
20년 된 COBOL 기반 뱅킹 시스템을 Java로 마이그레이션한 사례에서는 자동화 도구로 직접 코드를 변환한 뒤 점진적 리팩토링을 수행했다. 긴 메서드를 분리하고, 전역 변수를 클래스 필드로 옮겼다. 이어 조건문을 다형성으로 대체해 전략 패턴을 도입하고, 데이터 액세스 계층에는 Repository 패턴을 적용했다. 결과로 유지보수성은 230% 향상되고 버그 발생률은 80% 감소했다.
전자상거래 장바구니 시스템을 모놀리식 구조에서 마이크로서비스로 옮기는 과정에서는 책임에 따라 클래스를 분리하고, 결합도를 낮추기 위해 인터페이스를 도입했다. 도메인 객체와 서비스를 분리하는 Domain-Driven Design도 적용됐다. 배포 주기는 2주에서 1일로 단축됐고, 시스템 확장성은 크게 향상됐다.
TDD가 제공하는 안전망
TDD와 리팩토링은 서로 보완한다. 실패하는 테스트를 먼저 작성하고, 테스트를 통과하는 최소한의 코드를 만든 다음, 코드 구조를 정리한다. 테스트 커버리지는 이 변경을 안전하게 수행하기 위한 기반이며, 리팩토링은 TDD의 리팩토링 단계에서 핵심 활동이 된다.
변경 범위를 통제해야 하는 이유
광범위한 변경은 예상하지 못한 버그를 만들 수 있고, 테스트가 부족하면 리팩토링의 안전성은 낮아진다. 비즈니스 가치를 증명하기 어렵다는 점도 제약이 된다.
따라서 변경은 작은 단위로 적용하고, 자동화된 테스트를 먼저 마련할 필요가 있다. 기술 부채의 비용을 경영진에게 명확히 전달하며, 리팩토링을 예외적인 정비 작업이 아니라 개발 프로세스의 일상적인 부분으로 다루는 접근이 필요하다.
리팩토링은 유지보수 비용을 낮추고 코드 품질을 높이는 활동이다. 코드 냄새를 읽고 적절한 기법을 선택하는 능력, 자동화된 테스트, 설계 패턴의 활용이 함께 갖춰질 때 지속적인 작은 개선이 대규모 재작성보다 효과적으로 작동한다.