본문 바로가기

# 01/리팩토링

[리팩토링] CHAPTER 01 - 맛보기 예제

반응형


Movie 클래스 - 간단한 비디오 데이터 클래스

public class Movie {

    public static final int CHILDRENS = 2;
    public static final int REGULAR = 0;
    public static final int NEW_RELEASE = 1;
    private String _title;
    private int _priceCode;

    public Movie (String title, int priceCode) {
        _title = title;
        _priceCode = priceCode;
    }

    public int getPriceCode() {
        return _priceCode;
    }

    public void setPriceCode(int arg) {
        _priceCode = arg;
    }

    public String getTitle() {
        return _title;
    }
}


Rental 클래스 - 대여 정보 클래스

public class Rental {

    private Movie _movie;
    private int _daysRented;

    public Rental(Movie movie, int daysRented) {
        _movie = movie;
        _daysRented = daysRented;
    }

    public int getDaysRented() {
        return _daysRented;
    }

    public Movie getMovie() {
        return _movie;
    }
}


Customer 클래스 - 고객 클래스

public class Customer {
    private String _name;
    private Vector _rentals = new Vector();

    public Customer(String name) {
        _name = name;
    }

    public void addRental(Rental arg) {
        _rentals.addElement(arg);
    }

    public String getName() {
        return _name;
    }

    public String statement() {

        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = getName() + " 고객님의 대여 기록\\n";

        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = (Rental) rentals.nextElement();

            // 비디오 종류별 대여료 계산
            switch (each.getMovie().getPriceCode()) {
                case Movie.REGULAR:
                    thisAmount += 2;
                    if (each.getDaysRented() > 2)
                        thisAmount += (each.getDaysRented() - 2) * 1.5;
                    break;
                case Movie.NEW_RELEASE:
                    thisAmount += each.getDaysRented() * 3;
                    break;
                case Movie.CHILDRENS:
                    thisAmount += 1.5;
                    if (each.getDaysRented() > 3)
                        thisAmount += (each.getDaysRented() - 3) * 1.5;
                    break;
            }

            // 적립 포인트를 1 포인트 증가
            frequentRenterPoints++;

            // 최신물을 이틀 이상 대여하면 보너스 포인트 지급
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                    each.getDaysRented() > 1) frequentRenterPoints++;

            // 이번에 대여하는 비디오 정보와 대여료를 출력
            result += "\\t" + each.getMovie().getTitle() + "\\t" +
                    String.valueOf(thisAmount) + "\\n";

            // 현재까지 누적된 총 대여료
            totalAmount += thisAmount;
        }

        // 푸터 행 추가
        result += "누적 대여료 : " + String.valueOf(totalAmount) + "\\n";
        result += "적립 포인트 : " + String.valueOf(frequentRenterPoints);
        return result;
    }

}



▶ 첫 번째 리팩토링 - 너무 긴 statement 메서드!!!!


긴 메서드를 분해해서 각 부분을 알맞은 클래스로 옮기는 것.

→ 이것은 중복 코드를 줄이고 HTML로 내역을 출력하는 statement 메서드를 좀 더 간편하게 작 성하기 위해서다.



statement 메서드 - 변경 전

public String statement() {

        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = getName() + " 고객님의 대여 기록\\n";

        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = (Rental) rentals.nextElement();

            **// 비디오 종류별 대여료 계산
            switch (each.getMovie().getPriceCode()) {
                case Movie.REGULAR:
                    thisAmount += 2;
                    if (each.getDaysRented() > 2)
                        thisAmount += (each.getDaysRented() - 2) * 1.5;
                    break;
                case Movie.NEW_RELEASE:
                    thisAmount += each.getDaysRented() * 3;
                    break;
                case Movie.**CHILDRENS**:
                    thisAmount += 1.5;
                    if (each.getDaysRented() > 3)
                        thisAmount += (each.getDaysRented() - 3) * 1.5;
                    break;
            }**

            // 적립 포인트를 1 포인트 증가
            frequentRenterPoints++;

            // 최신물을 이틀 이상 대여하면 보너스 포인트 지급
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                    each.getDaysRented() > 1) frequentRenterPoints++;

            // 이번에 대여하는 비디오 정보와 대여료를 출력
            result += "\\t" + each.getMovie().getTitle() + "\\t" +
                    String.valueOf(thisAmount) + "\\n";

            // 현재까지 누적된 총 대여료
            totalAmount += thisAmount;
        }

        // 푸터 행 추가
        result += "누적 대여료 : " + String.valueOf(totalAmount) + "\\n";
        result += "적립 포인트 : " + String.valueOf(frequentRenterPoints);
        return result;
    }


여기서 확실히 분리할 부분은 switch 문이다.

먼저 메서드안에서만 효력이 있는 모든 지역변수와 매개변수에 해당하는 부분을 살펴봐야 한다.

statement 메서드 안에서 효력이 있는 변수는 each 변수와 thisAmount 변수다.

→ 둘 중에서 each 변수는 코드로 인해 변경되지 않고 thisAmount 는 변경된다.

변경되지 않는 변수는 매개변수로 전달할 수 있다.

변경되는 변수는 더 주의해야 한다.

변경되는 변수가 하나뿐이라면 그변수를 반환할 수 있다.

그 임시변수는 루프를 한 번 돌때마다 계속 0으로 초기화되며, switch 문에 진입할 떄까지 변경되지 않는다.

따라서 그 결과를 그냥 대입하면 된다.



statement 메서드 - 변경 후

 public String statement() {

        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = getName() + " 고객님의 대여 기록\\n";

        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = (Rental) rentals.nextElement();

            **// 비디오 종류별 대여료 계산
            thisAmount = amountFor(each);**

            // 적립 포인트를 1 포인트 증가
            frequentRenterPoints++;

            // 최신물을 이틀 이상 대여하면 보너스 포인트 지급
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                    each.getDaysRented() > 1) frequentRenterPoints++;

            // 이번에 대여하는 비디오 정보와 대여료를 출력
            result += "\\t" + each.getMovie().getTitle() + "\\t" +
                    String.valueOf(thisAmount) + "\\n";

            // 현재까지 누적된 총 대여료
            totalAmount += thisAmount;
        }

        // 푸터 행 추가
        result += "누적 대여료 : " + String.valueOf(totalAmount) + "\\n";
        result += "적립 포인트 : " + String.valueOf(frequentRenterPoints);
        return result;
    }


**// 비디오 종류별 대여료 계산 기능을 빼내어 별도의 함수로 작성
    private double amountFor(Rental each) {
        
        double thisAmount = 0;
        
        switch (each.getMovie().getPriceCode()) {
            case Movie.REGULAR:
                thisAmount += 2;
                if (each.getDaysRented() > 2)
                    thisAmount += (each.getDaysRented() - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                thisAmount += each.getDaysRented() * 3;
                break;
            case Movie.**CHILDRENS**:
                thisAmount += 1.5;
                if (each.getDaysRented() > 3)
                    thisAmount += (each.getDaysRented() - 3) * 1.5;
                break;
        }
        return thisAmount;
    }**



▶ 두 번째 리팩토링 - amountFor 메서드 안의 변수명 수정!!!!


amountFor 메서드 - 변경 전

private double amountFor(Rental each) {

        double thisAmount = 0;

        switch (each.getMovie().getPriceCode()) {
            case Movie.REGULAR:
                thisAmount += 2;
                if (each.getDaysRented() > 2)
                    thisAmount += (each.getDaysRented() - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                thisAmount += each.getDaysRented() * 3;
                break;
            case Movie.CHILDRENS:
                thisAmount += 1.5;
                if (each.getDaysRented() > 3)
                    thisAmount += (each.getDaysRented() - 3) * 1.5;
                break;
        }

        return thisAmount;
    }


amountFor 메서드 - 변경 후

private double amountFor(Rental **aRental**) {

        double **result** = 0;

        switch (**aRental**.getMovie().getPriceCode()) {
            case Movie.REGULAR:
                **result** += 2;
                if (**aRental**.getDaysRented() > 2)
                    **result** += (**aRental**.getDaysRented() - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                **result** += **aRental**.getDaysRented() * 3;
                break;
            case Movie.CHILDRENS:
                **result** += 1.5;
                if (**aRental**.getDaysRented() > 3)
                    **result** += (**aRental**.getDaysRented() - 3) * 1.5;
                break;
        }

        return **result**;
    }


번거로운데 굳이 변수명을 변경할 필요성이 있나? 당연히 있다.

.

→ 좋은 코드는 그것이 무슨 기능을 하는지 분명히 드러나야 하는데, 코드의 기능을 분명히 드러내는 열쇠가 바로 직관적인 변수명이다.



컴퓨터가 인식 가능한 코드는 바보라도 작성할 수 있지만, 인간이 이해할 수 있는 코드는 실력 있는 프로그래머만 작성할 수 있다.



▶ 세 번째 리팩토링 - 대여료 계산 메서드 옮기기!!!!


amountFor 메서드 - 변경 전

public class Customer {

	...
    
    **// 비디오 종류별 대여료 계산 기능을 빼내어 별도의 함수로 작성
    private double amountFor(Rental aRental) {

        double result = 0;

        switch (aRental.getMovie().getPriceCode()) {
            case Movie.REGULAR:
                result += 2;
                if (aRental.getDaysRented() > 2)
                    result += (aRental.getDaysRented() - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                result += aRental.getDaysRented() * 3;
                break;
            case Movie.**CHILDRENS**:
                result += 1.5;
                if (aRental.getDaysRented() > 3)
                    result += (aRental.getDaysRented() - 3) * 1.5;
                break;
        }

        return result;
    }**

}


amountFor 메서드를 보면 Rental 클래스의 정보를 이용하고 정작 자신이 속한 Customer 클래스의 정보를 이용하지 않는다.

→ 메서드는 대체로 자신이 사용하는 데이터와 같은 객체에 들어 있어야 한다.

그래서 당연히 이 메서드는 Rental 클래스로 옮겨야 한다.

먼저 amountFor 메서드 코드를 Rental 클래스로 복사하고 그 클래스 환경에 맞게 다음과 같이 수정한 후 컴파일 하자.

→ 매개변수를 삭제하고 메서드명도 바꿔 준다.



getCharge 메서드 - 변경 후

public class Rental {
	
	...

    **// 대여료 계산
    double getCharge() {

        double result = 0;

        switch (getMovie().getPriceCode()) {
            case Movie.REGULAR:
                result += 2;
                if (getDaysRented() > 2)
                    result += (getDaysRented() - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                result += getDaysRented() * 3;
                break;
            case Movie.**CHILDRENS**:
                result += 1.5;
                if (getDaysRented() > 3)
                    result += (getDaysRented() - 3) * 1.5;
                break;
        }

        return result;
    }**

}


Customer 클래스 - 변경 후

public class Customer {
   
	...

    public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = getName() + " 고객님의 대여 기록\\n";
        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = (Rental) rentals.nextElement();

            **// 비디오 종류별 대여료 계산 함수 호출
            thisAmount = each.getCharge();**

            // 적립 포인트를 1 포인트 증가
            frequentRenterPoints++;
            // 최신물을 이틀 이상 대여하면 보너스 포인트 지급
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                    each.getDaysRented() > 1) frequentRenterPoints++;

            // 이번에 대여하는 비디오 정보와 대여료를 출력
            result += "\\t" + each.getMovie().getTitle() + "\\t" +
                    String.valueOf(thisAmount) + "\\n";
            // 현재까지 누적된 총 대여료
            totalAmount += thisAmount;
        }
        // 푸터 행 추가
        result += "누적 대여료 : " + String.valueOf(totalAmount) + "\\n";
        result += "적립 포인트 : " + String.valueOf(frequentRenterPoints);
        return result;
    }


}



▶ 네 번째 리팩토링 - 불필요한 임시변수 삭제!!!!


statement 메서드 - 변경 전

public class Customer {
    
	...

    public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = getName() + " 고객님의 대여 기록\\n";
        
				while (rentals.hasMoreElements()) {
            **double thisAmount = 0;**
            Rental each = (Rental) rentals.nextElement();

            // 비디오 종류별 대여료 계산 함수 호출
            **thisAmount** = each.getCharge();

            // 적립 포인트를 1 포인트 증가
            frequentRenterPoints++;
            // 최신물을 이틀 이상 대여하면 보너스 포인트 지급
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                    each.getDaysRented() > 1) frequentRenterPoints++;

            // 이번에 대여하는 비디오 정보와 대여료를 출력
            result += "\\t" + each.getMovie().getTitle() + "\\t" +
                    String.valueOf(**thisAmount**) + "\\n";
            // 현재까지 누적된 총 대여료
            totalAmount += **thisAmount**;
        }
        // 푸터 행 추가
        result += "누적 대여료 : " + String.valueOf(totalAmount) + "\\n";
        result += "적립 포인트 : " + String.valueOf(frequentRenterPoints);
        return result;
    }


}


thisAmount 의 불필요한 중복 - thisAmount 는 each.charge 메서드의 결과를 저장하는 데만 사용됨

→ 임시변수를 메서드 호출로 전환하여 thisAmount 변수를 삭제해야 한다.



statement 메서드 - 변경 후

public class Customer {
    
	...

    public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = getName() + " 고객님의 대여 기록\\n";
        
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();

            // 적립 포인트를 1 포인트 증가
            frequentRenterPoints++;
            // 최신물을 이틀 이상 대여하면 보너스 포인트 지급
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                    each.getDaysRented() > 1) frequentRenterPoints++;

            // 이번에 대여하는 비디오 정보와 대여료를 출력
            result += "\\t" + each.getMovie().getTitle() + "\\t" +
                    String.valueOf(**each.getCharge()**) + "\\n";
            // 현재까지 누적된 총 대여료
            totalAmount += **each.getCharge()**;
        }
        // 푸터 행 추가
        result += "누적 대여료 : " + String.valueOf(totalAmount) + "\\n";
        result += "적립 포인트 : " + String.valueOf(frequentRenterPoints);
        return result;
    }

}


이런 임시변수는 최대한 없애는 것이 좋다.


→ 임시변수가 많으면 불필요하게 많은 매개변수를 전달하게 되는 문제가 흔히 생긴다.

심지어 임시변수의 용도를 차츰 잊기 십상이다.

임시변수는 특히 긴 메서드 안에서 알게 모르게 늘어나는데, 당연히 성능이 떨어진다.



▶ 다섯 번째 리팩토링 - 적립 포인트 계산을 메서드로 빼기!!!!


statement 메서드 - 변경 전

public class Customer {
    
	...

    public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = getName() + " 고객님의 대여 기록\\n";

        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();

            **// 적립 포인트를 1 포인트 증가
            frequentRenterPoints++;
            // 최신물을 이틀 이상 대여하면 보너스 포인트 지급
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                    each.getDaysRented() > 1) frequentRenterPoints++;**

            // 이번에 대여하는 비디오 정보와 대여료를 출력
            result += "\\t" + each.getMovie().getTitle() + "\\t" +
                    String.valueOf(each.getCharge()) + "\\n";
            // 현재까지 누적된 총 대여료
            totalAmount += each.getCharge();
        }
        // 푸터 행 추가
        result += "누적 대여료 : " + String.valueOf(totalAmount) + "\\n";
        result += "적립 포인트 : " + String.valueOf(frequentRenterPoints);
        return result;
    }


}


대여 비디오의 종류에 따라 적립 포인트 계산법도 달라진다.

이 부분의 처리 코드도 Rental 클래스에 넣는 것이 합리적으로 생각된다.



statement 메서드 - 변경 후

public class Customer {
    
	...

    public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = getName() + " 고객님의 대여 기록\\n";

        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();

            **// 경우에 따른 적립 포인트 지급 함수를 호출
            frequentRenterPoints += each.getFrequentRenterPoints();**

            // 이번에 대여하는 비디오 정보와 대여료를 출력
            result += "\\t" + each.getMovie().getTitle() + "\\t" +
                    String.valueOf(each.getCharge()) + "\\n";
            // 현재까지 누적된 총 대여료
            totalAmount += each.getCharge();
        }
        // 푸터 행 추가
        result += "누적 대여료 : " + String.valueOf(totalAmount) + "\\n";
        result += "적립 포인트 : " + String.valueOf(frequentRenterPoints);
        return result;
    }


}


Rental 클래스 - 변경 후

public class Rental {

   ...

    **// 최신물을 이틀 이상 대여하면 2포인트 지급하고 그 외엔 1포인트 지급하는 코드를 빼내
    // getFrequentRenterPoints 메서드로 만들고 이 Rental 클래스로 옮겼다.
    int getFrequentRenterPoints() {
        if ((getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
            getDaysRented() > 1)
            return 2;
        else
            return 1;
    }**

}



▶ 여섯 번째 리팩토링 - 임시변수 없애기!!!!


앞에서도 말했듯이 임시변수로 인해 문제가 생길 수 있다.

→ 임시변수는 자체 루틴 안에서만 효력이 있다 보니, 점점 더 많은 임시변수를 사용하게 되어 코드가 복잡해지기 쉽다.


statement 메서드 - 변경 전

public class Customer {

	...

    public String statement() {
        **double totalAmount = 0;**
        **int frequentRenterPoints = 0;**
        Enumeration rentals = _rentals.elements();
        String result = getName() + " 고객님의 대여 기록\\n";

        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();

            // 경우에 따른 적립 포인트 지급 함수를 호출
            frequentRenterPoints += each.getFrequentRenterPoints();

            // 이번에 대여하는 비디오 정보와 대여료를 출력
            result += "\\t" + each.getMovie().getTitle() + "\\t" +
                    String.valueOf(each.getCharge()) + "\\n";
            // 현재까지 누적된 총 대여료
            totalAmount += each.getCharge();
        }
        // 푸터 행 추가
        result += "누적 대여료 : " + String.valueOf(**totalAmount**) + "\\n";
        result += "적립 포인트 : " + String.valueOf(**frequentRenterPoints**);
        return result;
    }


}


현재 임시변수는 두 개 있으며, 두 변수는 해당 고객에 첨가된 대여료를 이용해 총 대여료를 계산할 때 사용된다. 총 대여료는 아스키 코드 내역과 HTML 내역 두 곳에 필요하다.

임시변수를 메서드 호출로 전환하여 totalAmount 변수와 frequentRenterPoints 변수를 질의 메서드로 고치는 것을 선호한다.

→ 질의 메서드(쿼리 메서드) : 필요한 값을 반환하고자 호출되는 메서드

질의 메서드는 클래스 안의 모든 메서드에서 접근 가능하므로, 메서드를 복잡하게 만드는 이런 임시변수를 사용하지 않아도 되니 설계가 훨씬 깔끔해진다.



statement 메서드 - 변경 후

public class Customer {
   
	...

    public String statement() {
        
        Enumeration rentals = _rentals.elements();
        String result = getName() + " 고객님의 대여 기록\\n";

        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();

            // 이번에 대여하는 비디오 정보와 대여료를 출력
            result += "\\t" + each.getMovie().getTitle() + "\\t" +
                    String.valueOf(each.getCharge()) + "\\n";
          
        }
        // 푸터 행 추가
        result += "누적 대여료 : " + String.valueOf(**getTotalCharge()**) + "\\n";
        result += "적립 포인트 : " + String.valueOf(**getTotalFrequentRenterPoints()**);
        return result;
    }
    
    **private double getTotalCharge() {
        double result = 0;
        Enumeration rentals = _rentals.elements();
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();
            result += each.getCharge();
        }
        return result;
    }**


    **private int getTotalFrequentRenterPoints() {
        int result = 0;
        Enumeration rentals = _rentals.elements();
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();
            result += each.getFrequentRenterPoints();
        }
        return result;
    }**

}


잠시 멈추고 방금 전에 실시한 리팩토링 단계에 대해 좀 생각해보자.

대개 리팩토링 기법을 실시하면 코드 양이 줄게 마련인데 방금의 리팩토링은 오히려 코드가 늘었다.

그리고 또 한가지 문제점은 성능이다.

수정 전 코드는 while 문 루프를 1회만 실행했는데 수행 후 코드는 3회나 실행한다. 오랜 시간이 걸리는 while 문으로 인해 성능이 저하된다.

이러한 이유로 이 리팩토링을 하지 않으려 하지만, 항상 다양한 경우의 수를 생각하자.

프로파일하기 전까지 난 루프의 계산 시간이 얼마나 걸리는지 몰랐고, 루프가 설마 그렇게 전체 시스템 성능을 떨어뜨릴 정도로 자주 호출되는지 전혀 상상조차 못했다.

while 문 리팩토링에 지레 겁먹을 필요는 없다.

while 문은 최적화 단계에서 걱정해도 늦지 않다. 최적화 단계가 성능 해결의 적기이며 효과적인 최적화를 위한 더 많은 선택의 여지가 있다.

이 메서드 호출들은 이제 Customer 클래스 안 어디서나 사용할 수 있다.

이제 시스템의 다른 부분에 이 정보가 필요하다면 이 메서드 호출들을 클래스의 public 인터페이스에 간단히 추가하면 된다.

이런 질의 메서드 호출 방식을 사용하지 않으면, 대여료 정보를 알아내고 루프안에서 계산하는 코드를 여러 다른 메서드에넣어야 할 것이다.

복잡한 시스템에서는 그렇게 하면 작성할 코드도 많아지고 그에 따라 유지보수도 힘들어진다.



htmlStatement 메서드 추가

public String htmlStatement() {
        Enumeration rentals = _rentals.elements();
        String result = "<H1><EM>" + getName() + " 고객님의 대여 기록</EM></H1><P>\\n";
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();
            // 모든 대여 비디오 정보와 대여료를 출력
            result += each.getMovie().getTitle()+ ": " +
                    String.valueOf(each.getCharge()) + "<BR>\\n";
        }
        // 푸터 행 추가
        result += "<P>누적 대여료 : <EM>" + String.valueOf(getTotalCharge()) 
								+ "</EM><P>\\n";

        result += "적립 포인트 : <EM>" +
                String.valueOf(getTotalFrequentRenterPoints()) + "</EM><P>";
        return result;
    }


계산 부분을 빼내서 htmlStatement 메서드로 작성하면 처음의 statement 메서드에 들어 있던 계산 코드를 전부 재사용할 수 있다.

복사해서 붙인 중복 코드가 없으니 계산식 자체를 수정해야 할 때도 한 군데만 수정하면 된다.

이제 어떠한 형식의 내역이든 아주 신속하고 간편히 준비할 수 있다.

이 리팩토링 단계는 오래걸리지 않는다.

코드의 기능을 파악하는 데 많은 시간이 걸렸는데, 어차피 거쳐야 할 과정이었다.

그런데 이제 사용자들의 또 다른 요구가 있다.

대여점의 비디오 분류를 바꾸려고 준비 중이다.

분류를 어떻게 변경할지는 아직 결정하지 않았지만, 분명한 건 기존과 전혀 다른 방식으로 분류하리란 것이다.

수정하는 각 비디오 분류마다 대여료와 적립 포인트의 적립 비율도 결정해야 한다.

지금 이런 식의 수정을 하기엔 무리다.

우선, 대여료 메서드와 적립 포인트 메서드부터 마무리 짓고 조건문 코드를 수정해서 비디오 분류를 변경해야 한다.



▶ 일곱 번째 리팩토링 - 가격 책정 부분의 조건문을 재정의로 교체!!!!


getCharge 메서드 - 변경 전

public class Rental {

   ...

    // 대여료 계산
    double getCharge() {

        double result = 0;

        switch (getMovie().getPriceCode()) {
            case Movie.REGULAR:
                result += 2;
                if (getDaysRented() > 2)
                    result += (getDaysRented() - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                result += getDaysRented() * 3;
                break;
            case Movie.CHILDRENS:
                result += 1.5;
                if (getDaysRented() > 3)
                    result += (getDaysRented() - 3) * 1.5;
                break;
        }

        return result;
    }

   ...
}


제일 먼저 고칠 부분은 switch 문이다. 타 객체의 속성을 switch 문의 인자로 하는 것은 나쁜 방법이다.

switch 문의 인자로는 타 객체 데이터를 사용하지 말고 자신의 데이터를 사용해야 한다.

앞의 코드에 있는 getCharge 메서드를 Movie 클래스로 옮기자.



getCharge 메서드 - 변경 후

public class Movie {

   ...

    // 대여료 계산
    double getCharge(int daysRented) {

        double result = 0;

        switch (getPriceCode()) {
            case Movie.REGULAR:
                result += 2;
                if (daysRented > 2)
                    result += (daysRented - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                result += daysRented * 3;
                break;
            case Movie.CHILDRENS:
                result += 1.5;
                if (daysRented > 3)
                    result += (daysRented - 3) * 1.5;
                break;
        }

        return result;
    }
    
    ...
}


Rental 클래스 - 변경 후

public class Rental {

    ...
    
    double getCharge() {
        return _movie.getCharge(_daysRented);
    }
    
   ...
}


수정 코드가 제대로 돌아가게 하려고 대여 기간을 전달 했다. 대여 기간은 Rental 클래스에 있는 데이터다.

getCharge 메서드는 결국 두 개의 데이터인 대여 기간과 비디오 종류를 사용한다.

대여 기간을 Rental 클래스에 전달하지 않고 Movie 클래스에 전달했는데 왜 그랬을까?

그 이유는 사용자가 요청한 변경은 단지 새로운 비디오 종류를 추가해 달라는 것이었기 때문이다.

대체로 비디오 종류에 대한 정보는 나중에 변경할 가능성이 높다.

비디오 종류를 변경해도 그로 인해 미치는 영향을 최소화하고자 대여료 계산을 Movie 클래스 안에 넣은 것이다.

적립 포인트 계산 메서드도 마찬가지로 옮기자.

이렇게 하면 비디오 종류마다 달라지는 대여료와 적립 포인트 계산이 다음과 같이 비디오 분류가 든 클래스 자체에서 처리된다.



getFrequentRenterPoints - 변경 전

public class Rental {

   ...

    // 최신물을 이틀 이상 대여하면 2포인트 지급하고 그 외엔 1포인트 지급하는 코드를 빼내
    // getFrequentRenterPoints 메서드로 만들고 이 Rental 클래스로 옮겼다.
    int getFrequentRenterPoints() {
        if ((getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
            getDaysRented() > 1)
            return 2;
        else
            return 1;
    }

}


getFrequentRenterPoints - 변경 후

public class Movie {

    ...
    
    int getFrequentRenterPoints(int daysRented) {
        if((getPriceCode() == Movie.NEW_RELEASE) && daysRented > 1)
            return 2;
        else 
            return 1;
    }

	...
}


Rental 클래스 - 변경 후

public class Rental {

   ...

    // 최신물을 이틀 이상 대여하면 2포인트 지급하고 그 외엔 1포인트 지급하는 코드를 빼내
    // getFrequentRenterPoints 메서드로 만들고 이 Rental 클래스로 옮겼다.
    int getFrequentRenterPoints() {
       return _movie.getFrequentRenterPoints(_daysRented);
    }

}



▶ 마지막 여덟 번째 리팩토링 - 상속 구조 만들기!!!!


Movie 클래스는 비디오 종류에 따라 같은 메서드 호출에도 각기 다른 값을 반환한다.

그런데 이건 하위클래스가 처리할 일이다.

따라서 Movie 클래스를 상속받는 3개의 하위 클래스를 작성하고, 비디오 종류별 대여료 계산을 각 하위클래스에 넣어야 한다.


Movie

  • RegularMovie
  • ChildrensMovie
  • NewReleaseMovie


이렇게 하위클래스를 작성해 상속 구조를 만들면 switch 문을 재정의로 바꿀 수 있다. 다만 의도대로 작동하지 않는 것이 한가지 흠이다.

수명주기 동안 비디오는 언제든 분류가 바뀔 수 있지만 객체는 수정이 불가능하므로 불일치가 발생한다.

하지만 <4인방의 상태패턴>을 적용 switch 문을 삭제하면 된다.

인다이렉션 : 값 자체가 아니라 이름, 참조, 컨테이너 등을 사용해서 대상을 참조하는 기능

인다이렉션 기능을 추가하면 Price 클래스 안의 코드를 하위클래스로 만들어서 언제든 대여료를 변경할 수 있다.

이미 4인방 패턴을 알고 있다면 '이게 대체 상태인가 전략인가?'하는 의문이 들 것이다. Price 클래스가 나타내는 것이 대여료 계산 알고리즘인가(그렇다면 이 클래스명을 Pricer나 PricingStrategy라고 고치는 게 낫겠다), 아니면 비디오의 상태인가(스타트렉 X는 최신물이다)? 이 단계에서 패턴과 클래스명을 뭐로 정하느냐에 따라 구조에 대한 구상 방식이 달라진다. 현재는 Price 클래스의 코드는 비디오의 상태라고 생각한다. 만약 나중에 전략(알고리즘, 기능) 이 의도에 더 알맞다고 판단되면 클래스명을 변경하는 리팩토링 기법을 적용해 그렇게 만들면 된다.

상태 패턴을 적용하려면 세 가지 리팩토링 기법을 사용해야 한다. 우선 분류 부호를 상태/전략 패턴으로 전환 기법을 실시해서 분류 부호의 기능을 상태 패턴 안으로 옮겨야 한다. 그 다음에 메서드 이동 기법을 실시하여 switch 문을 Price 클래스 안으로 옮겨야 한다. 끝으로 조건문을 재정의로 전환 기법을 실시해서 switch 문을 없애야 한다.

먼저 분류 부호를 상태/전략 패턴으로 전환 기법을 실시하자.

이 기법의 첫 단계는 분류 부호에 필드 자체 캡슐화 기법을 적용해서 반드시 읽기/쓰기 메서드를 거쳐서만 분류 부호를 사용할 수 있게 해야 한다. 코드의 대부분은 다른 클래스에서 옮겨온 것이라 대부분의 메서드엔 이미 읽기 메서드가 들어 있다. 하지만 생성자 함수는 다음과 같이 priceCode에 직접 접근한다.



Movie 클래스 - 변경 전

public class Movie {

    ...

    public Movie (String title, int priceCode) {
        _title = title;
        _priceCode = priceCode;
    }

    ...
}


앞의 코드에 있는 직접 접근 명령문 대신 다음과 같이 쓰기 메서드를 사용하면 된다.



Movie 클래스 - 변경 후

public class Movie {

    ...

    public Movie (String title, int priceCode) {
        _title = title;
        setPriceCode(priceCode);
    }

    ...
}


이제 Price 클래스를 상속 확장하는 클래스 3개를 추가로 작성하자. 다음과 같이 각 하위클래스에 구체적인 메서드를 작성하고 Price 클래스에는 추상 메서드를 넣어 종류 판단 기능을 제공하자.



Price 클래스 - 변경 후

abstract class Price {
    abstract int getPriceCode();
}

class ChilderensPrice extends Price {
    int getPriceCode() {
        return Movie.CHILDRENS;
    }
}

class NewReleasePrice extends Price {
    int getPriceCode() {
        return Movie.NEW_RELEASE;
    }
}

class RegularPrice extends Price {
    int getPriceCode() {
        return Movie.REGULAR;
    }
}


이제 priceCode가 새 클래스를 사용할 수 있게 Movie 클래스의 읽기/쓰기 메서드를 수정하자.



Movie 클래스 - 변경 전

public class Movie {

    private int _priceCode;

    public int getPriceCode() {
        return _priceCode;
    }

    public void setPriceCode(int arg) {
        _priceCode = arg;
    }

}


Movie 클래스 - 변경 후

public class Movie {

    private Price _price;

    public int getPriceCode() {
        return _price.getPriceCode();
    }

    public void setPriceCode(int arg) {
        switch (arg) {
            case REGULAR :
                _price = new RegularPrice();
                break;
            case CHILDRENS :
                _price = new ChilderensPrice();
                break;
            case NEW_RELEASE :
                _price = new NewReleasePrice();
                break;
            default:
                throw new IllegalArgumentException("가격 코드가 잘못됐습니다.");
        }
    }

}



이제 메서드 이동 기법을 실시해서 getCharge 메서드를 옮기자.


Movie 클래스 - 변경 전

public class Movie {

    // 대여료 계산
    double getCharge(int daysRented) {

        double result = 0;

        switch (getPriceCode()) {
            case Movie.REGULAR:
                result += 2;
                if (daysRented > 2)
                    result += (daysRented - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                result += daysRented * 3;
                break;
            case Movie.CHILDRENS:
                result += 1.5;
                if (daysRented > 3)
                    result += (daysRented - 3) * 1.5;
                break;
        }

        return result;
    }
}


Movie 클래스 - 변경 후

public class Movie {

    // 대여료 계산
    double getCharge(int daysRented) {
        return _price.getCharge(daysRented);
    }
}


Price 클래스 - 변경 후

abstract class Price {
    
    double getCharge(int daysRented) {
        double result = 0;
        switch (getPriceCode()) {
            case Movie.REGULAR:
                result += 2;
                if (daysRented > 2)
                    result += (daysRented - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                result += daysRented * 3;
                break;
            case Movie.CHILDRENS:
                result += 1.5;
                if (daysRented > 3)
                    result += (daysRented - 3) * 1.5;
                break;
        }
        return result;
    }
}


조건문을 재정의로 전환 기법을 실시하자.



Price 클래스 - 변경 전

abstract class Price {
    
    double getCharge(int daysRented) {
        double result = 0;
        switch (getPriceCode()) {
            case Movie.REGULAR:
                result += 2;
                if (daysRented > 2)
                    result += (daysRented - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                result += daysRented * 3;
                break;
            case Movie.CHILDRENS:
                result += 1.5;
                if (daysRented > 3)
                    result += (daysRented - 3) * 1.5;
                break;
        }
        return result;
    }
}


switch 문에 든 case 문 코드를 가져다가 한 번에 하나씩 재정의 메서드로 작성하면 된다.



ChilderensPrice 클래스 - 변경 후

class ChilderensPrice extends Price {
    double getCharge(int daysRented) {
        double result = 1.5;
        if (daysRented > 3)
            result += (daysRented - 3) * 1.5;
        return result;
    }
}


NewReleasePrice 클래스 - 변경 후

class NewReleasePrice extends Price {
   
    double getCharge(int daysRented) {
        return daysRented * 3;
    }
}


RegularPrice 클래스 - 변경 후

class RegularPrice extends Price {

    double getCharge(int daysRented) {
        double result = 2;
        if (daysRented > 2)
            result += (daysRented - 2) * 1.5;
        return result;
    }
}



모든 case 문을 재정의 메서드로 만들었으면 다음과 같이 Price.getCharge 추상 메서드를 선언하자.



Price 클래스 - 변경 후

abstract class Price {
    
    abstract double getCharge(int daysRented);
}


이제 getFrequentRenterPoints 메서드에도 지금까지 한 것과 같은 과정을 실시하자.



Movie 클래스 - 변경 전

public class Movie {

    int getFrequentRenterPoints(int daysRented) {
        if((getPriceCode() == Movie.NEW_RELEASE) && daysRented > 1)
            return 2;
        else
            return 1;
    }
}


우선 getFrequentRenterPoints 메서드를 Price 클래스로 다음과 같이 옮기자.



Movie 클래스 - 변경 후

public class Movie {

    int getFrequentRenterPoints(int daysRented) {
        return _price.getFrequentRenterPoints(daysRented);
    }
}


Price 클래스 - 변경 후

abstract class Price {

    int getFrequentRenterPoints(int daysRented) {
        if((getPriceCode() == Movie.NEW_RELEASE) && daysRented > 1)
            return 2;
        else
            return 1;
    }
}


단, 이때 상위클래스 메서드를 추상 메서드로 만들지 말고 그 대신 NewReleasePrice 클래스에 재정의 메서드를 작성하자.



Price 클래스 - 변경 후

abstract class Price {
  
    int getFrequentRenterPoints(int daysRented) {
            return 1;
    }
}


NewReleasePrice 클래스 - 변경 후

class NewReleasePrice extends Price {
   
    int getFrequentRenterPoints (int daysRented) {
        return (daysRented > 1) ? 2 : 1;
    }
}


상태 패턴을 적용하는 작업은 이렇듯 상당히 복잡한데, 과연 이렇게까지 해서 적용할 가치가 있을까?

상태 패턴을 적용하면 대여료 계산 방식을 변경하거나, 새 대여료를 추가하거나, 부수적인 대여료 관련 동작을 추가할 떄 아주 쉽게 수정할 수 있다.

프로그램의 다른 부분은 상태 패턴의 영향을 받지 않는다. 지금까지 다룬 예제처럼 기능이 별로 없을땐 이 장점이 미미해 보일 수 있지만, 십여 개가 넘는 대여료 관련 메서드로 구성된 더 복잡한 시스템이라면 무시 할 수 없는 차이가 보인다.

리팩토링의 각 단계에서 실시한 수정이 미미하다 보니 이렇게 조금씩 수정하는 것을 답답하게생각할 수 있지만, 디버거를 실행할 일도 없었으므로 실제 작업 과정은 상당히 빠른 편이었다.

코드를 수정한 시간보다 내가 이 설명을 작성한 시간이 더 걸렸을 정도다.

이것으로 큼직한 두 번째 리팩토링을 마쳤다. 이제 비디오 분류를 변경하거나 대여료와 적립 포인트 시스템을 수정하는 작업쯤은 훨씬 쉬울 것이다.


고찰

이 예제를 실습하면서 배운 가장 중요한 교훈은 '간단한 수정 → 테스트'를 리듬처럼 반복해야 한다는 것이다. 이 리듬을 지킬 때만이 리팩토링을 빠르고 안정적으로 완료할 수 있다.




반응형