Chapter 01. 맛보기 예제
전체 코드는 https://github.com/azurealstn/refactoring 에서 확인할 수 있습니다.
프로그램 목적: 비디오 대여점에서 고객의 대여료 내역을 계산하고 출력하는 간단한 프로그램
- 고객이 대여한 비디오와 대여 기간을 표시한 후, 비디오 종류와 대여 기간을 토대로 대여료를 계산한다.
- 비디오 종류는 일반물, 아동물, 최신물 세 종류이며, 대여료 계산과 더불어 내역을 바탕으로 적립 포인트도 계산되는데, 이 포인트는 비디오가 최신물인지 아닌지에 따라 달라진다.
클래스 다이어그램
- Movie 클래스: 비디오 종류를 나타내는 priceCode
- Rental 클래스: 대여 기간을 나타내는 daysRented
- Customer 클래스: 고객을 나타내며, 고객의 대여료 내역을 계산하고 출력하기 위한 statement 메서드
Customer 클래스
package chapter_1;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
public class Customer {
private String name;
private List<Rental> rentals = new ArrayList<>();
public Customer(String name) {
this.name = name;
}
public void addRental(Rental rental) {
rentals.add(rental);
}
public String getName() {
return name;
}
public List<Rental> getRentals() {
return rentals;
}
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
String result = getName() + " 고객님의 대여 기록\n";
for (int i = 0; i < rentals.size(); i++) {
double thisAmount = 0;
Rental rental = rentals.get(i);
// 비디오 종류별 대여료 계산
switch (rental.getMovie().getPriceCode()) {
case Movie.REGULAR -> {
thisAmount += 2;
if (rental.getDaysRented() > 2) {
thisAmount += (rental.getDaysRented() - 2) * 1.5;
}
}
case Movie.NEW_RELEASE -> thisAmount += (rental.getDaysRented() - 3) * 1.5;
case Movie.CHILDRENS -> {
thisAmount += 1.5;
if (rental.getDaysRented() > 3) {
thisAmount += (rental.getDaysRented() - 3) * 1.5;
}
}
}
// 적립 포인트를 1 포인트 증가
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;
}
}
문제점: statement 메서드에 너무 많은 기능이 있어 나중에 수정할 때 실수할 확률이 높아진다.
Tip. 프로그램에 기능을 추가해야 하는데 코드구조가 조잡해서 그 기능을 추가하기 힘들다면, 우선 리팩토링을 실시해서 기능을 추가하기 쉽게 만든 후 기능을 추가하자.
리팩토링 첫 단계
리팩토링할 코드 부분에 대한 신뢰도 높은 각종 테스트를 작성한다.
Tip. 리팩토링하기 전에 반드시 신뢰도 높은 테스트가 준비됐는지 확인하자. 이 테스트들은 반드시 자체검사가 되게 작성한다.
statement 메서드 분해와 기능 재분배
리팩토링 후
public class Customer {
...
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
String result = getName() + " 고객님의 대여 기록\n";
for (int i = 0; i < rentals.size(); i++) {
double thisAmount = 0;
Rental rental = rentals.get(i);
// 비디오 종류별 대여료 계산
thisAmount = amountFor(rental);
// 적립 포인트를 1 포인트 증가
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;
}
// 비디오 종류별 대여료 계산 기능을 빼내어 별도의 메서드로 작성
private double amountFor(Rental rental) {
double thisAmount = 0;
switch (rental.getMovie().getPriceCode()) {
case Movie.REGULAR -> {
thisAmount += 2;
if (rental.getDaysRented() > 2) {
thisAmount += (rental.getDaysRented() - 2) * 1.5;
}
}
case Movie.NEW_RELEASE -> thisAmount += (rental.getDaysRented() - 3) * 1.5;
case Movie.CHILDRENS -> {
thisAmount += 1.5;
if (rental.getDaysRented() > 3) {
thisAmount += (rental.getDaysRented() - 3) * 1.5;
}
}
}
return thisAmount;
}
}
// 비디오 종류별 대여료 계산
thisAmount = amountFor(rental);
위 부분이 리팩토링 되었다. 한 메서드에 복잡한 코드를 메서드로 분리한 것이다.
❗여기서 주의할 점이 있는데, 변경되는 변수는 반드시 Return 시켜주어야 한다. 그렇지 않으면 변경된 변수가 반영되지 않는다.
이 말이 무슨 말인지 이해가 되지 않는다면 Call by Value와 Call by Referece를 참고하길 바란다.
대여료 계산 메서드 옮기기
amountFor 메서드를 보면 Rental 클래스의 정보를 사용하고, 정작 Customer 클래스의 정보는 사용하지 않고 있다. 그래서 Rental 클래스로 메서드를 옮겨보자.
Rental 클래스
public class Rental {
private Movie movie;
private int daysRented;
...
public double getCharge() {
double thisAmount = 0;
switch (movie.getPriceCode()) {
case Movie.REGULAR -> {
thisAmount += 2;
if (daysRented > 2) {
thisAmount += (daysRented - 2) * 1.5;
}
}
case Movie.NEW_RELEASE -> thisAmount += (daysRented - 3) * 1.5;
case Movie.CHILDRENS -> {
thisAmount += 1.5;
if (daysRented > 3) {
thisAmount += (daysRented - 3) * 1.5;
}
}
}
return thisAmount;
}
}
- Rental 클래스의 getCharge 메서드로 옮겼고, 매개변수도 사라졌다.
- Customer 클래스의 amountFor 메서드 부분도 수정해보자.
public class Customer {
...
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
String result = getName() + " 고객님의 대여 기록\n";
for (int i = 0; i < rentals.size(); i++) {
double thisAmount = 0;
Rental rental = rentals.get(i);
// 비디오 종류별 대여료 계산
thisAmount = rental.getCharge();
...
}
// 푸터 행 추가
result += "누적 대여료: " + String.valueOf(totalAmount) + "\n";
result += "적립 포인트: " + String.valueOf(frequentRenterPoints);
return result;
}
// 비디오 종류별 대여료 계산 기능을 빼내어 별도의 메서드로 작성
private double amountFor(Rental rental) {
...
}
}
thisAmount = rental.getCharge();
로 수정하였다.- 중요한 것은 이렇게 리팩토링 할 때마다 반드시 테스트를 통해 원래 동작이 제대로 수행되는지 확인해야 한다.
- 그래서 테스트코드가 중요한 것이다.
적립 포인트 계산을 메서드로 빼기
이번에는 적립 포인트 계산 메서드를 분리하고 옮겨보자.
Rental 클래스
public class Rental {
...
// 최신물을 이틀 이상 대여하면 2포인트 지급하고 그 외엔 1포인트 지급하는 코드를
// 빼내 getFrequentRenterPoints 메서드로 만들고 이 Rental 클래스로 옮겼다.
public int getFrequentRenterPoints() {
if ((movie.getPriceCode() == Movie.NEW_RELEASE) && daysRented > 1) {
return 2;
} else {
return 1;
}
}
}
Customer 클래스
public String statement() {
...
double totalAmount = 0;
int frequentRenterPoints = 0;
String result = getName() + " 고객님의 대여 기록\n";
for (int i = 0; i < rentals.size(); i++) {
double thisAmount = 0;
Rental rental = rentals.get(i);
// 비디오 종류별 대여료 계산
thisAmount = rental.getCharge();
frequentRenterPoints += rental.getFrequentRenterPoints();
...
}
...
}
frequentRenterPoints += rental.getFrequentRenterPoints();
- Rental 클래스를 통해 getFrequentRenterPoints 메서드를 호출한다.
- 이러면 역시 매개변수를 전달할 필요가 없어진다.
임시변수 제거
statement 메서드에서 임시변수인 totalAmount
와 frequentRenterPoints
를 제거해보자.
Customer 클래스
public String statement() {
String result = getName() + " 고객님의 대여 기록\n";
for (int i = 0; i < rentals.size(); i++) {
double thisAmount = 0;
Rental rental = rentals.get(i);
// 비디오 종류별 대여료 계산
thisAmount = rental.getCharge();
// 이번에 대여하는 비디오 정보와 대여료를 출력
result += "\t" + rental.getMovie().getTitle() + "\t" +
String.valueOf(thisAmount) + "\n";
}
// 푸터 행 추가
result += "누적 대여료: " + String.valueOf(getTotalCharge()) + "\n";
result += "적립 포인트: " + String.valueOf(getTotalFrequentRenterPoints());
return result;
}
private double getTotalCharge() {
double totalAmount = 0;
for (int i = 0; i < rentals.size(); i++) {
Rental rental = rentals.get(i);
totalAmount += rental.getCharge();
}
return totalAmount;
}
private int getTotalFrequentRenterPoints() {
int frequentRenterPoints = 0;
for (int i = 0; i < rentals.size(); i++) {
Rental rental = rentals.get(i);
frequentRenterPoints += rental.getFrequentRenterPoints();
}
return frequentRenterPoints;
}
// 푸터 행 추가
result += "누적 대여료: " + String.valueOf(getTotalCharge()) + "\n";
result += "적립 포인트: " + String.valueOf(getTotalFrequentRenterPoints());
return result;
- getTotalCharge 메서드와 getTotalFrequentRenterPoints 메서드를 생성하여 임시변수를 제거하였다.
임시변수 사용의 문제점
- 임시변수가 많으면 불필요하게 많은 매개변수를 전달하게 되는 문제가 흔히 생긴다.
- 임시변수의 용도를 잊기 쉽다.
- 임시변수를 많이 사용하면 성능이 떨어진다.
가격 책정 부분의 조건문을 재정의로 교체
Rental 클래스
public double getCharge() {
double thisAmount = 0;
switch (movie.getPriceCode()) {
case Movie.REGULAR -> {
thisAmount += 2;
if (daysRented > 2) {
thisAmount += (daysRented - 2) * 1.5;
}
}
case Movie.NEW_RELEASE -> thisAmount += (daysRented - 3) * 1.5;
case Movie.CHILDRENS -> {
thisAmount += 1.5;
if (daysRented > 3) {
thisAmount += (daysRented - 3) * 1.5;
}
}
}
return thisAmount;
}
- Rental 클래스의 swtich 문의 인자로 타 객체를 사용하는 것은 좋지 않은 방법이다. (타 객체: Movie)
- 따라서 getCharge 메서드를 Movie 클래스로 옮기자.
Movie 클래스
public double getCharge(int daysRented) {
double thisAmount = 0;
switch (priceCode) {
case Movie.REGULAR -> {
thisAmount += 2;
if (daysRented > 2) {
thisAmount += (daysRented - 2) * 1.5;
}
}
case Movie.NEW_RELEASE -> thisAmount += (daysRented - 3) * 1.5;
case Movie.CHILDRENS -> {
thisAmount += 1.5;
if (daysRented > 3) {
thisAmount += (daysRented - 3) * 1.5;
}
}
}
return thisAmount;
}
- 매개변수로 대여 기간을 전달했다.
- 이제 Rental 클래스의 getCharge 메서드를 아래와 같이 수정하자.
public double getCharge() {
return movie.getCharge(daysRented);
}
- 적립 포인트 계산 메서드 역시 Movie 클래스로 옮기자.
Rental 클래스
public int getFrequentRenterPoints() {
return movie.getFrequentRenterPoints(daysRented);
}
Movie 클래스
public int getFrequentRenterPoints(int daysRented) {
if ((priceCode == Movie.NEW_RELEASE) && daysRented > 1) {
return 2;
} else {
return 1;
}
}
지금까지 리팩토링 작업한 후 클래스 다이어그램은 아래와 같다.
마지막 단계, 상속 구조 만들기
Movie 클래스를 상속받는 3개의 하위클래스를 작성하고, 비디오 종류별 대여료 계산을 각 하위클래스에 넣어야 한다.
- 이렇게 하면 switch문을 재정의로 바꿀 수 있다.
- 하지만 비디오는 언제든 분류가 바뀔 수 있지만 객체는 수정이 불가능하므로 불일치가 발생한다.
- 그래서 상태 패턴을 적용하면 해결할 수 있다.
- 인다이렉션 기능을 추가하면 Price 클래스 안의 코드를 하위클래스로 만들어서 언제든 대여료를 변경할 수 있다.
인다이렉션: 값 자체가 아니라 이름, 참조, 컨테이너 등을 사용해서 대상을 참조하는 기능
1. 분류 부호를 상태/전략 패턴으로 전환 기법
분류 부호에 필드 자체 캡슐화를 적용해서 반드시 getter/setter를 통해서만 분류 부호를 사용할 수 있게 해야 한다.
class Movie {
public Movie(String title, int priceCode) {
this.title = title;
setPriceCode(priceCode);
}
}
public abstract class Price {
abstract int getPriceCode();
}
public class NewReleasePrice extends Price {
@Override
int getPriceCode() {
return Movie.NEW_RELEASE;
}
}
public class RegularPrice extends Price{
@Override
int getPriceCode() {
return Movie.REGULAR;
}
}
public class ChildrensPrice extends Price{
@Override
int getPriceCode() {
return Movie.CHILDRENS;
}
}
- Price 클래스와 하위클래스들을 모두 생성했다면, priceCode가 Price 클래스를 사용할 수 있게 Movie 클래스에 priceCode의 getter/setter를 생성한다.
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;
private Price price;
public Movie(String title, int priceCode) {
this.title = title;
setPriceCode(priceCode);
}
public int getPriceCode() {
return priceCode;
}
public void setPriceCode(int priceCode) {
switch (priceCode) {
case REGULAR -> price = new RegularPrice();
case CHILDRENS -> price = new ChildrensPrice();
case NEW_RELEASE -> price = new NewReleasePrice();
default -> throw new IllegalArgumentException("가격 코드가 잘못되었습니다.");
}
}
}
2. 메서드 이동 기법
public class Movie {
...
public double getCharge(int daysRented) {
return price.getCharge(daysRented);
}
}
public abstract class Price {
abstract int getPriceCode();
abstract double getCharge(int daysRented);
}
3. 조건문 재정의 전환 기법
getCharge를 재정의한다.
public class RegularPrice extends Price {
@Override
int getPriceCode() {
return Movie.REGULAR;
}
@Override
double getCharge(int daysRented) {
double thisAmount = 2;
if (daysRented > 2) {
thisAmount += (daysRented - 2) * 1.5;
}
return thisAmount;
}
}
public class ChildrensPrice extends Price {
@Override
int getPriceCode() {
return Movie.CHILDRENS;
}
@Override
double getCharge(int daysRented) {
double thisAmount = 1.5;
if (daysRented > 3) {
thisAmount += (daysRented - 3) * 1.5;
}
return thisAmount;
}
}
public class NewReleasePrice extends Price {
@Override
int getPriceCode() {
return Movie.NEW_RELEASE;
}
@Override
double getCharge(int daysRented) {
return daysRented * 3;
}
}
- getFrequentRenterPoints 메서드도 위와 같은 상태패턴으로 똑같이 적용해주자. (여기선 패스하겠다!)
정리
상태패턴을 사용하여 대여료 계산 방식을 변경하거나, 새 대여료를 추가하거나, 부수적인 대여료 관련 동작을 추가할 때 아주 쉽게 수정할 수 있다.
개인적인 생각
위 예제를 리팩토링 해보면서 느낀 점이 있다.
한 메서드에 여러 기능이 있는 경우에는 반드시 리팩토링을 해주어야 한다.
하지만 상태패턴의 경우 과연 프로젝트에 필요한 리팩토링인지를 꼭 잘 생각해서 적용할지 안할지 고민할 필요가 있을 것 같다.
너무 과한 리팩토링은 오히려 협업하는 사람들이 보기 어려울 수 있다. 리팩토링은 나만 편하려고 하는 것이 아닌 다른 개발자들도 보기가 당연히 편해야 한다. 이러한 점을 고려할 때 트레이드오프를 생각하여 리팩토링을 적용해보면 좋을 것 같다.
또한 리팩토링 후에는 반드시 테스트를 수행해야 한다.
References
'공부 기록' 카테고리의 다른 글
[리팩토링 1판] Chapter 03. 코드의 구린내 (1) | 2024.03.29 |
---|---|
[리팩토링 1판] Chaptor 02. 리팩토링 개론 (1) | 2024.03.27 |
[CSS] display와 position에 대해 알아보자. (0) | 2022.06.19 |
[CSS] 박스모델 이해하기 (추가로. background) (0) | 2022.06.19 |
[CSS] Cascading 이해하기! 추가로. ::before, ::after (0) | 2022.06.18 |
댓글