리팩터링
https://product.kyobobook.co.kr/detail/S000001810241
리팩터링 | 마틴 파울러 - 교보문고
리팩터링 | 개발자가 선택한 프로그램 가치를 높이는 최고의 코드 관리 기술 마틴 파울러의 『리팩터링』이 새롭게 돌아왔다.지난 20년간 전 세계 프로그래머에게 리팩터링의 교본이었던 이 책
product.kyobobook.co.kr
마틴 파울러의 리팩터링 2판을 읽어보았다. 책 1장은 리팩터링 예시를 설명하고 있다. 2장은 리팩터링이 무엇인지 왜 해야 하는지를 설명하고 있다. 3장은 리팩터링이 필요한 코드 스멜 부분을 설명하고 있으며 4장은 리팩터링에 필요한 테스트를 설명하고 있다. 나머지 부분은 리팩터링 기법을 설명한 카탈로그이다. 책의 저자도 카탈로그는 필요할 때마다 찾아보는 것을 권장하기 때문에 따로 정리하지 않았다. 1장부터 3장까지 내용을 정리해 보았다.
리팩터링 정의
리팩터링 정의는 다음과 같다.
리팩터링 : 소프트웨어 겉보기 동작은 그대로 유지한 채, 코드를 이해하고 수정하기 쉽도록 내부 구조를 변경하는 기법
‘겉보기 동작’ 뜻이 애매해 보이는데 간단히 말하자면 리팩터링 전과 후의 코드가 똑같이 동작해야 한다는 뜻이다. 리팩터링의 목적은 코드의 기능이 똑같이 작동하지만 코드를 이해하고 수정하기 쉽게 만드는 것이다. 리팩터링은 프로그램의 성능이 개선되는 것과는 상관없다.
리팩터링 하는 이유
1. 소프트웨어 설계가 좋아진다.
- 리팩터링 하지 않으면 내부 아키텍처가 무너지기 쉽다.
- 규칙적인 리팩터링은 코드 구조 지탱한다.
- 코드 중복 제거와 같은 리팩터링은 설계 개선 작업에 중요한 역할 한다.
2. 소프트웨어 이해가 쉬워진다.
- 리팩터링은 코드가 잘 읽히게 도와준다
- 코드의 목적, 의도를 명확하게 전달하도록 개선할 수 있다.
3. 버그를 쉽게 찾을 수 있다.
- 코드 이해가 쉽다 → 버그를 찾기 쉽다는 뜻이다.
4. 프로그래밍 속도 높일 수 있다.
- 내부 설계가 잘 된 소프트웨어는 새 기능과 버그를 쉽게 고칠 수 있다.
- 설계가 좋을수록 빠른 기능이 누적될수록 빠른 개발이 가능하다.
- 리팩터링의 목적은 개발 기간을 단축하는 것이다. 기능 추가 시간 줄이고 버그 수정 시간 줄여준다.
리팩터링 시기
기능을 쉽게 추가하도록 하는 리팩터링
코드에 기능을 새로 추가하기 전에 리팩터링을 권장한다. 기능 추가 전 코드의 구조를 바꾸면 다른 작업을 하기 쉬워질 만한 부분을 찾는다.
코드를 이해하기 쉽게 만들기
코드의 의도를 명확하게 드러나도록 리팩터링 하는 것이 좋다. 조건부 로직 이상한지 살펴보고, 함수 이름 잘 지었는지 등등을 살펴보는 습관을 기르자.
쓰레기 줍기 리팩터링
수정이 간단한 코드는 바로 리팩터링 하고, 오래 걸리는 코드는 메모만 남긴 후 하던 일을 끝내고 처리하자. 이를 쓰레기 줍기 리팩터링이라고 부른다.
수시로 하는 리팩터링
리팩터링을 하려고 따로 일정을 잡지 않고 이뤄지는 리팩터링을 수시로 하는 리팩터링이라고 부른다. 기능 추가 시 새로운 코드를 작성해 넣는다고 생각하지만, 뛰어난 개발자는 새 기능을 추가하기 쉽도록 코드를 ‘수정’하는 것이 기능을 빠르게 추가하는 방법이라고 생각한다. 즉 새 기능이 필요할 때마다 소프트웨어는 이를 반영하기 위해 수정된다.
오래 걸리는 리팩터링
오래 걸리는 대규모 리팩터링(ex 라이브러리 교체 작업, 컴포넌트 빼내는 작업 등등)을 팀 전체가 하는 것을 추천하지 않는다. 원하는 방향으로 조금씩 개선하는 방법을 추천한다. 예를 들어 라이브러리 교체 시 기존 것과 새것 모두 포용하는 인터페이스부터 마련한다. 기존 코드가 인터페이스를 호출하도록 만들면, 라이브러리 교체가 쉬워진다.
리팩터링 하지 말아야 할 때
코드가 지저분해도 굳이 수정할 필요 없다면 리팩터링 하지 않는다. 내부 동작을 이해해야 할 시점에 리팩터링 하는 것을 권장한다.
리팩터링 개발 프로세스
1. 자가 테스트 코드
리팩토링은 동작이 깨지지 않아야 한다. 이를 위해 오류를 빨리 잡아야 하고, 테스트가 필요하다. 리팩터링을 위해 자가 테스트 코드를 마련해야 한다. 테스트 코드는 리팩터링을 돕고 새 기능 추가도 안전하게 진행하도록 도와준다. 견고한 테스트는 리팩터링 과정에서 생길 버그를 줄여준다.
2. 지속적 통합
개발 시 기능마다 브랜치를 따로 만드는 경우가 있다. 독립 브랜치로 작업하는 기간이 길어질수록 작업 결과를 마스터 브랜치로 통합이 어려워진다. 브랜치의 통합 주기를 짧게 관리해야 한다. 자주 마스터 브랜치와 통합하면 다른 브랜치들과의 차이가 크게 벌어지는 브랜치가 없어져서 머지의 복잡도를 낮출 수 있다.
리팩터링시에 각 팀원이 다른 사람의 작업을 방해하지 않아야 한다. 리팩터링 할 때 지속적 통합을 권장하는 이유이다. 지속적 통합을 통해 리팩터링 한 결과가 다른 팀원의 작업에 문제를 일으키면 즉시 알아낼 수 있다.
코드스멜
어떤 코드를 리팩터링 해야 할지 알아야 한다. 책의 저자는 리팩터링이 필요한 코드들에 일정한 패턴이 있다고 설명하고 있으며 이를 코드 스멜이라고 부른다. 코드 스멜은 리팩터링 하면 해결할 수 있다고 말한다. 책의 저자가 설명한 코드 스멜을 요약해 보았다.
1. 기이한 이름
변수, 함수, 클래스명만 잘 지어도 나중에 문맥을 파악하느라 헤매는 시간을 절약할 수 있다. 이름이 애매하다면 설계에 문제가 숨어 있을 가능성이 높다. 이름을 장 정리해 코드를 잘 파악하도록 하자.
2. 중복 코드
똑같은 코드 구조가 여러 곳에서 반복된다면 하나로 통합하자.
public class DuplicateCodeExample {
// 주문 생성 시 공통으로 사용하는 로깅 코드
public void createOrder(String orderId, double amount) {
// 중복된 로깅 코드
System.out.println("START: 주문 생성");
System.out.println("Order ID: " + orderId);
System.out.println("Amount: " + amount);
System.out.println("END: 주문 생성");
// 주문 생성 로직 ...
}
// 주문 취소 시도 동일한 로깅 코드가 중복됨
public void cancelOrder(String orderId) {
// 중복된 로깅 코드
System.out.println("START: 주문 취소");
System.out.println("Order ID: " + orderId);
System.out.println("END: 주문 취소");
// 주문 취소 로직 ...
}
// 위의 로깅 코드를 별도의 메서드로 추출하여 중복 제거가 필요함
public void log(String action, String orderId, Double amount) {
System.out.println("START: " + action);
System.out.println("Order ID: " + orderId);
if(amount != null) {
System.out.println("Amount: " + amount);
}
System.out.println("END: " + action);
}
}
위 예시처럼 로깅이 중복된다면 별도의 메서드로 추출하자.
3. 긴 함수
좋은 코드를 보면 연산하는 부분이 없어 보인다. 코드가 끝없이 위임하는 방식으로 작성되어 있기 때문이다. 짧은 함수를 사용하면 코드를 이해하고, 공유하고, 선택하기 쉬워진다.
함수를 짧게 만들기 위해 함수 이름을 잘 지어야 하고, 함수 이름을 잘 짓적극적으로 함수를 쪼개야 한다. 함수 이름은 동작 방식이 아닌 ‘의도’가 드러나게 짓는다.(how가 아닌 what으로 짓는다.) 함수 이름에 코드 목적을 드러내야 한다.
public class Order {
private List<Item> items;
public boolean isEligibleForFreeShipping() {
int total = 0;
for (Item item : items) {
total += item.getPrice();
}
return total >= 50000;
}
}
위 코드의 함수를 나누면 다음과 같아진다.
public class Order {
private List<Item> items;
public boolean isEligibleForFreeShipping() {
return totalAmount() >= freeShippingThreshold();
}
private int totalAmount() {
return items.stream()
.mapToInt(Item::getPrice)
.sum();
}
private int freeShippingThreshold() {
return 50000;
}
}
4. 긴 매개변수 목록
매개변수 목록이 길어지면 이해하기 어려울 때가 많다. 매개변수를 객체로 만들거나 여러 함수를 클래스로 묶자.
// 매개변수가 많다.
public void sendEmail(String to, String subject, String body, String from, String replyTo)
//다음과 같이 줄이자.
public void send(EmailMessage message)
5. 전역 데이터
전역 데이터는 코드베이스 어디에서든 건드릴 수 있고 값을 누가 바꿨는지 찾아낼 메커니즘이 없다. 대표적인 전역 데이터 형태는 전역 변수, 클래스 변수, 싱글톤이 있다.
public class AppConfig {
public static String environment = "dev";
}
...
if (AppConfig.environment.equals("prod")) {
// 중요한 로직 실행
}
...
AppConfig.environment = "prod"; // 갑자기 환경이 바뀌면 문제가 발생할 수 있다.
6. 가변 데이터
데이터를 변경하면 예상하지 못한 버그로 이어지는 경우가 종종 있다. 코드 다른 곳에서 다른 값을 사용한다는 생각을 하지 못한 채 수정하면 프로그램이 오작동한다. 함수형 언어를 쓴다면 데이터의 불변성을 보장하지만 대부분 프로그램 언어는 변수 값을 바꾸는 것을 지원한다. 앞에서 봤던 전역 데이터와 비슷한 경우이다.
7. 뒤엉킨 변경
뒤엉킨 변경이란 단일 책임 원칙이 지켜지지 않을 때 발생한다. 하나의 모듈이 서로 다른 이유로 인해 변경되는 일이 많을 때 발생한다.
예를 들어 데이터베이스가 추가될 때 함수 세 개를 바꿔야 하고, 금융 상품이 추가될 때마다 다른 함수 네 개를 바꿔야 하는 모듈이 발생했다면 뒤엉킨 변경이 발생했다는 뜻이다.
8. 산탄총 수술
코드를 변경할 때마다 자잘하게 수정해야 하는 클래스가 많을 때 산탄총 수술이 발생한다. 변경할 부분이 코드 전반에 퍼져있는 경우이다.
아래 예시를 보면 고객 관련 기능이 여러 곳에 흩어져 있다. 고객 코드를 변경해야 할 때 여러 클래스를 고쳐야 한다.
public class Customer {
private String name;
private String address;
private String phoneNumber;
public void printName() {
System.out.println("Customer Name: " + name);
}
public void printAddress() {
System.out.println("Customer Address: " + address);
}
public void printPhoneNumber() {
System.out.println("Customer Phone: " + phoneNumber);
}
}
public class Invoice {
private Customer customer;
public void printInvoice() {
System.out.println("INVOICE");
customer.printName();
customer.printAddress(); // 이곳도 수정 대상
// ...
}
}
public class ShippingLabel {
private Customer customer;
public void printLabel() {
System.out.println("SHIPPING LABEL");
customer.printName(); // 이곳도 수정 대상
customer.printAddress(); // 이곳도 수정 대상
// ...
}
}
다음과 같이 고객 관련 기능을 하나로 묶어야 한다.
public class Customer {
private String name;
private String address;
private String phoneNumber;
public String getName() {
return name;
}
public String getAddress() {
return address;
}
public String getPhoneNumber() {
return phoneNumber;
}
public String getContactInfo() {
return "Customer Name: " + name + "\\nCustomer Phone: " + phoneNumber;
}
public String getFullAddressLabel() {
return "Customer Address: " + address;
}
}
Customer 클래스가 변경되어도 Invoice와 ShippingLabel 클래스는 변경하지 않아도 된다.
public class Invoice {
private Customer customer;
public void printInvoice() {
System.out.println("INVOICE");
System.out.println(customer.getContactInfo());
System.out.println(customer.getFullAddressLabel());
}
}
public class ShippingLabel {
private Customer customer;
public void printLabel() {
System.out.println("SHIPPING LABEL");
System.out.println(customer.getContactInfo());
System.out.println(customer.getFullAddressLabel());
}
}
9. 기능 편애
같은 모듈에 있는 상호작용은 늘리고, 다른 모듈과의 상호작용은 최소로 줄여야 한다. 기능 편애는 어떤 함수가 자기가 속한 모듈의 함수나 데이터보다 다른 모듈의 함수나 데이터와 상호작용할 일이 많을 때 발생한다.
아래 예시를 보자. generateReport()는 CustomerReport 클래스에 있지만, 실제로는 Customer 객체의 필드를 거의 다 사용한다. 이 메서드는 사실상 Customer에 더 관심이 많다.
public class Customer {
private String name;
private String address;
private int loyaltyPoints;
public String getName() {
return name;
}
public String getAddress() {
return address;
}
public int getLoyaltyPoints() {
return loyaltyPoints;
}
}
public class CustomerReport {
public String generateReport(Customer customer) {
StringBuilder report = new StringBuilder();
report.append("Name: ").append(customer.getName()).append("\\n");
report.append("Address: ").append(customer.getAddress()).append("\\n");
report.append("Loyalty Points: ").append(customer.getLoyaltyPoints()).append("\\n");
return report.toString();
}
}
10. 데이터 뭉치
데이터 항목 서너 개가 항상 함께 뭉쳐 다닐 때 데이터 뭉치라고 부른다. 서로 항상 같이 다니는 데이터들이 여러 클래스나 메서드에 반복해서 나타날 때 발생한다. 몰려다니는 데이터는 따로 클래스를 추출하는 형태로 묶는 방법을 권장한다.
다음 예제를 보면 customerName, customerAddress, customerPhone이 항상 같이 호출된다.
public class Invoice {
public void printInvoice(String customerName, String customerAddress, String customerPhone) {
System.out.println("Invoice for: " + customerName);
System.out.println("Address: " + customerAddress);
System.out.println("Phone: " + customerPhone);
}
}
public class ShippingLabel {
public void printLabel(String customerName, String customerAddress, String customerPhone) {
System.out.println("Ship to: " + customerName);
System.out.println(customerAddress);
System.out.println("Contact: " + customerPhone);
}
}
다음과 같이 하나의 클래스로 묶어야 한다.
public class CustomerInfo {
private String name;
private String address;
private String phone;
public CustomerInfo(String name, String address, String phone) {
this.name = name;
this.address = address;
this.phone = phone;
}
public String getName() { return name; }
public String getAddress() { return address; }
public String getPhone() { return phone; }
}
11. 기본형 집착
기본 자료형(int, String, boolean 등)에 너무 의존하거나, 개념적으로 하나의 타입이 될 수 있는 값들을 그냥 기본형으로만 다루는 방법을 의미한다. 여러 String이나 int로 묶이는 값을 클래스로 표현하지 않고 계속 기본형으로만 다루는 코드 스멜이다.
다음 코드를 보자.
public class User {
private String name;
private String email;
private String phone;
public User(String name, String email, String phone) {
this.name = name;
this.email = email;
this.phone = phone;
}
public boolean hasValidEmail() {
return email.contains("@");
}
public boolean hasValidPhone() {
return phone.length() == 10;
}
}
email, phone을 기본형(String)으로 만들었다. 따라서 검증 로직이 User 클래스 안에 있게 된다.
12. 반복되는 switch문
무조건 switch문이나 if문이 있다고 다형성을 변경하는 것을 추천하지 않는다. switch문과 if문이 반복해서 나타날 때 리팩터링을 고민해 보자. 중복된 switch문은 조건절을 하나 추가할 때마다 다른 switch문들도 모두 찾아서 함께 수정해야 한다. 이럴 때 객체지향의 다형성은 반복된 switch문을 개선할 수 있게 해 준다.
13. 반복문
반복문을 사용할 때 map, filter와 같은 컬랙션을 사용해 보자. 로직을 스트림으로 처리 시 객체가 어떻게 처리되는지 이해하기 쉬워진다.
14. 성의 없는 요소
프로그래밍 언어가 제공하는 함수, 클래스, 인터페이스를 의미 없이 사용하는 코드를 성의 없는 요소라고 부른다. 클래스 안에 메서드가 1개만 있거나, 본문 코드를 그대로 쓴 것과 다를 바 없는 함수도 있다. 이런 의미 없는 프로그램 요소는 제거하자.
15. 추측성 일반화
언젠간 쓸지 몰라서 만들어 놓은 코드를 추측성 일반화라고 부른다. 확장성을 위한 파라미터지만 항상 null이라던가 사용되지 않는 추상 클래스나 인터페이스를 의미한다. 성의 없는 요소와 마찬가지로 제거하자.
16. 임시 필드
특정 상황에서만 값이 설정되고 그 외 상황에 null로 설정되는 필드를 임시 필드라고 부른다.
17. 메시지 체인
한 객체를 통해 다른 객체를 얻은 뒤 방금 얻은 객체에 다른 객체를 요청하는 식으로, 다른 객체를 요청하는 작업이 연쇄적으로 이어지는 코드를 말한다. getter가 꼬리를 물고 이어지거나 임시 변수들이 줄줄이 나열되는 코드를 의미한다. 다음이 대표적인 예시이다.
managerName = person.getDepartment().getManager().getName();
18. 중개자
어떤 클래스가 다른 객체의 메서드나 데이터를 대신 호출해 주는 역할만 할 때 그 클래스는 중개자 역할을 한다.
public class Person {
private Department department;
public String getManager() {
return department.getManager();
}
}
위 예제에서 Person은 Department의 중개자 역할을 한다. 보통 사용 시 문제가 되지 않지만 지나치면 문제가 된다. 클래스가 제공하는 메서드 중 절반이 다른 클래스에 구현을 위임하면 문제가 된다.
19. 내부자 거래
두 모듈이 서로의 내부 구현에 지나치게 의존하는 경우를 내부자 거래라고 부른다. 이런 경우 모듈 사이에 결합도가 높아진다. 결합도를 낮추고 투명하게 처리해야 한다.
보통 상속 구조에서 부모 자식 사이에 내부자 거래가 발생하는 경우가 있다. 다음은 예제 코드이다.
public class Employee {
protected String name;
protected double baseSalary;
public Employee(String name, double baseSalary) {
this.name = name;
this.baseSalary = baseSalary;
}
public double calculateSalary() {
return baseSalary;
}
}
public class Salesman extends Employee {
private double commission;
public Salesman(String name, double baseSalary, double commission) {
super(name, baseSalary);
this.commission = commission;
}
@Override
public double calculateSalary() {
// 내부자 거래: 자식이 부모 필드에 직접 의존
return baseSalary + commission;
}
}
Salesman이 baseSalary를 직접 참조하고 있다.
20. 거대한 클래스
한 클래스가 많은 일을 하다 보면 필드 수가 늘어난다. 클래스에 필드가 많으면 중복 코드가 생기기 쉽다. 이런 경우 클래스를 추출하여 일부 필드를 따로 묶는 것을 권장한다.
21. 서로 다른 인터페이스의 대안 클래스
클래스 간의 인터페이스와 함수가 중복될 때 발생하는 코드 스멜이다. 공통되는 함수나 인터페이스를 슈퍼 클래스로 추출하는 방법을 권장한다.
22. 데이터 클래스
데이터 클래스란 데이터 필드와 getter/setter 메서드로만 구성된 클래스를 뜻한다. 변경하면 안 되는 필드는 setter를 제거한다. 다른 클래스에서 데이터 클래스의 getter/setter를 사용하는 메서드를 찾아서 데이터 클래스로 옮길 수 있는지 고민하는 게 좋다.
23. 상속 포기
객체지향 설계에서 상속을 잘못 사용했을 때 자주 나타나는 문제이다. 서브 클래스가 부모의 동작은 필요로 하지만 인터페이스는 따르지 않고 싶은 경우가 대표적인 예이다. 이럴 때 상속을 포기하고 위임(composition)을 사용하는 방법을 권장한다.
리팩터링 예시
리팩터링 책 1장에 리팩터링 예시가 나와있다. 코드가 js로 되어 있어 이 부분을 java로 다시 구현해 보았다. 리팩터링 예시에 나온 요구사항은 다음과 같다.
요구사항
- 연극 공연 데이터를 바탕으로 고객에게 청구서를 생성하는 프로그램이다.
- 특정 고객의 청구 내역을 출력하는 statement() 메서드를 제공한다.
- 연극 유형별 가격 계산을 해야 한다.
- 비극("tragedy")
- 기본요금: 40,000원
- 관객이 30명 초과 시, 초과 인원당 1,000원 추가
- 희극("comedy")
- 기본요금: 30,000원
- 관객이 20명 초과 시, 10,000원 추가 + 초과 인원당 500원 추가
- 추가로, 모든 관객 수에 대해 1인당 300원 추가
- 비극("tragedy")
- 관객이 30명을 초과하면 초과 인원만큼 포인트 추가.
- 희극("comedy")의 경우, 추가 보너스 포인트로 (관객 수 / 5)의 내림값을 추가.
- 결과 출력 포맷
- 연극 제목과 해당 공연의 총비용 (100으로 나누어 원화 변환)
- 공연별 관객 수
- 총비용 합계
- 적립 포인트 합계
코드 구현
위 요구사항을 코드로 구현해 보았다. Play와 Invoice를 담을 객체는 다음과 같이 설정했다.
@Getter
@Setter
public class Play {
public Play(String name, String type) {
this.name = name;
this.type = type;
}
private String name;
private String type;
}
@Getter
@Setter
public class Invoice {
private String customer;
private List<Performance> performances;
public Invoice(String customer, List<Performance> performances) {
this.customer = customer;
this.performances = performances;
}
}
청구서를 출력하는 statement 함수는 다음과 같이 작성했다.
public class Main {
public static void main(String[] args) {
Main main = new Main();
String result = main.statement(main.getInvoice(), main.getPlays());
System.out.println(result);
}
public String statement(Invoice invoice, Map<String, Play> plays) {
double totalAmount = 0.d;
double volumeCredits = 0.d;
NumberFormat numberFormat = NumberFormat.getInstance(Locale.US);
StringBuilder result = new StringBuilder("청구 내역(고객명 : " + invoice.getCustomer() + ") \\n");
for(Performance performance : invoice.getPerformances()) {
Play play = plays.get(performance.getPlayId());
double thisAmount = 0.d;
switch (play.getType()) {
case "tragedy":
thisAmount = 40000.d;
if(performance.getAudience() > 30) {
thisAmount += 1000 * (performance.getAudience() - 30);
}
break;
case "comedy":
thisAmount = 30000.d;
if(performance.getAudience() > 20) {
thisAmount += 10000 + 500 * (performance.getAudience() - 20);
}
thisAmount += 300 * performance.getAudience();
break;
default:
throw new RuntimeException("알 수 없는 장르:" + play.getType());
}
volumeCredits += Math.max(performance.getAudience() - 30, 0);
if(play.getType().equals("comedy")) {
volumeCredits += Math.floor((double) performance.getAudience() / 5);
}
result.append(play.getName()).append(": ").append(numberFormat.format(thisAmount / 100)).append(" ").append(performance.getAudience()).append("석 \\n");
totalAmount += thisAmount;
}
result.append("총액:").append(numberFormat.format(totalAmount / 100)).append('\\n');
result.append("적립 포인트:").append(volumeCredits).append("점 \\n");
return result.toString();
}
private Invoice getInvoice() {
List<Performance> performances = Arrays.asList(
new Performance("hamlet", 55),
new Performance("as-like", 35),
new Performance("othello", 40)
);
return new Invoice("BigCo", performances);
}
private Map<String, Play> getPlays() {
Map<String, Play> plays = new HashMap<>();
plays.put("hamlet", new Play("Hamlet", "tragedy"));
plays.put("as-like", new Play("As You Like It", "comedy"));
plays.put("othello", new Play("Othello", "tragedy"));
return plays;
}
}
statement() 함수에 모든 로직이 다 들어가 있다. 요구사항이 추가되거나 변경되면 statement() 함수를 수정해야 한다. statement() 함수의 복잡도가 크기 때문에 복잡한 작업이 될 것이다. 위 함수를 리팩터링 해보겠다.
리팩터링 - statement() 함수 쪼개기
동작에 따라 함수를 추출해 보겠다. 동작에 따라 함수를 추출한다는 게 무슨 뜻일까? statement() 매서드 안에 switch 문을 보면 공연에 대한 요금을 계산하고 있다. 즉 switch문은 공연에 대한 요금을 계산하는 동작을 하고 있다. 이를 amountFor(String aPerformance)라는 함수로 추출할 것이다.
//함수 추출
private double amountFor(Performance aPerformance) {
double thisAmount = 0.d;
switch (playFor(aPerformance).getType()) {
case "tragedy":
thisAmount = 40000.d;
if(aPerformance.getAudience() > 30) {
thisAmount += 1000 * (aPerformance.getAudience() - 30);
}
break;
case "comedy":
thisAmount = 30000.d;
if(aPerformance.getAudience() > 20) {
thisAmount += 10000 + 500 * (aPerformance.getAudience() - 20);
}
thisAmount += 300 * aPerformance.getAudience();
break;
default:
throw new RuntimeException("알 수 없는 장르:" + playFor(aPerformance).getType());
}
return thisAmount;
}
위와 같이 코드의 기능에 따라 함수를 추출할 것이다. statement() 메서드 안에 기능은 다음과 같다. 달러에 따른 가격 계산, 가격 총합 계산, 적립액 계산이다. 추출한 후 코드는 다음과 같아진다.
public class Main {
public String statement(Invoice invoice, Map<String, Play> plays) {
double totalAmount = 0.d;
double volumeCredits = 0.d;
StringBuilder result = new StringBuilder("청구 내역(고객명 : " + invoice.getCustomer() + ") \\n");
for(Performance performance : invoice.getPerformances()) {
volumeCredits += volumeCreditsFor(performance);
//함수 인라인
result.append(playFor(performance).getName()).append(": ").append(usd(amountFor(performance))).append(" ").append(performance.getAudience()).append("석 \\n");
totalAmount += amountFor(performance);
}
result.append("총액:").append(usd(totalAmount)).append('\\n');
result.append("적립 포인트:").append(volumeCredits).append("점 \\n");
return result.toString();
}
//함수 추출. 달러 가격 계산
private String usd(double aNumber) {
NumberFormat numberFormat = NumberFormat.getInstance(Locale.US);
return numberFormat.format(aNumber/100);
}
//함수 추출. 적립액 계산
private double volumeCreditsFor(Performance performance) {
double result = 0.d;
result += Math.max(performance.getAudience() - 30, 0);
if(playFor(performance).getType().equals("comedy")) {
result += Math.floor((double) performance.getAudience() / 5);
}
return result;
}
//함수 추출. performance에서 play 객체 추출
private Play playFor(Performance performance) {
return plays.get(performance.getPlayId());
}
//함수 추출. 총합 계산
private double amountFor(Performance aPerformance) {
double thisAmount = 0.d;
switch (playFor(aPerformance).getType()) {
case "tragedy":
thisAmount = 40000.d;
if(aPerformance.getAudience() > 30) {
thisAmount += 1000 * (aPerformance.getAudience() - 30);
}
break;
case "comedy":
thisAmount = 30000.d;
if(aPerformance.getAudience() > 20) {
thisAmount += 10000 + 500 * (aPerformance.getAudience() - 20);
}
thisAmount += 300 * aPerformance.getAudience();
break;
default:
throw new RuntimeException("알 수 없는 장르:" + playFor(aPerformance).getType());
}
return thisAmount;
}
}
앞에서 말한 기능에 따라 usd, volumeCreditsFor, amountFor 함수가 생성되었다. 앞에서 말한 기능을 제외하고 playFor이라는 함수가 추가로 만들어졌다. playFor 함수를 보면 변수 대신에 사용된 것을 알 수 있다.
// Play play = plays.get(performance.getPlayId());
// result.append(play.getName()) ...
result.append(playFor(performance).getName()) ...
이를 변수 인라인하기라고 부른다.
다음은 반복문 쪼개기를 알아보겠다. 하나의 for문 안에 volumeCredits와 totalAmount를 구하는 로직이 있다. 각자 다른 기능을 하고 있기 때문에 리팩터링시 쪼개주는 것이 좋다. totalAmound와 totalVolumeCredits라는 함수로 나눠보겠다. 코드는 다음과 같다.
public class Main {
private Map<String, Play> plays;
private Invoice invoice;
public String statement(Invoice invoice, Map<String, Play> plays) {
double totalAmount = 0.d;
StringBuilder result = new StringBuilder("청구 내역(고객명 : " + invoice.getCustomer() + ") \\n");
for(Performance performance : invoice.getPerformances()) {
//함수 인라인
result.append(playFor(performance).getName()).append(": ").append(usd(amountFor(performance))).append(" ").append(performance.getAudience()).append("석 \\n");
}
result.append("총액:").append(usd(totalAmount())).append('\\n');
result.append("적립 포인트:").append(totalVolumeCredits()).append("점 \\n");
return result.toString();
}
private double totalAmount() {
double totalAmount = 0;
for(Performance performance : invoice.getPerformances()) {
totalAmount += amountFor(performance);
}
return totalAmount;
}
private double totalVolumeCredits() {
double volumeCredits = 0.d;
for(Performance performance : invoice.getPerformances()) {
volumeCredits += volumeCreditsFor(performance);
}
return volumeCredits;
}
...
한 번만 돌아도 되는 for문이 3번이나 돌게 된다. 성능이 떨어져 보일 수 있다. 책에서는 리팩터링 후 성능 개선 작업을 하라고 추천한다. 성능이 먼저인지 코드 가독성이 먼저인지는 개인적으로 고민해 볼 문제인 것 같다. 아무튼 리팩터링 작업 후 statement() 함수가 줄어든 것을 확인할 수 있다.
리팩터링 - 다형성 활용하기
조건부 로직을 다형성으로 바꿔보겠다. 코드를 보면 amountFor()와 volumeCreditsFor() 메서드가 공연료와 적립 포인트를 계산한다. 이 두 함수를 전용 클래스로 따로 옮겨보겠다. 공연 관련 데이터를 계산하는 역할을 하는 PerformanceCalculator라는 함수를 생성하겠다. 코드는 다음과 같다.
public class PerformanceCalculator {
public PerformanceCalculator(Performance performance, Play play) {
this.performance = performance;
this.play = play;
}
private Performance performance;
private Play play;
public double amount(Performance performance) {
double thisAmount = 0.d;
switch (play.getType()) {
case "tragedy":
thisAmount = 40000.d;
if(performance.getAudience() > 30) {
thisAmount += 1000 * (performance.getAudience() - 30);
}
break;
case "comedy":
thisAmount = 30000.d;
if(performance.getAudience() > 20) {
thisAmount += 10000 + 500 * (performance.getAudience() - 20);
}
thisAmount += 300 * performance.getAudience();
break;
default:
throw new RuntimeException("알 수 없는 장르:" + play.getType());
}
return thisAmount;
}
public double volumeCreditsFor(Performance performance) {
double result = 0.d;
result += Math.max(performance.getAudience() - 30, 0);
if(play.getType().equals("comedy")) {
result += Math.floor((double) performance.getAudience() / 5);
}
return result;
}
}
statement 함수는 다음과 같이 변경된다.
public String statement(Invoice invoice, Map<String, Play> plays) {
double totalAmount = 0.d;
StringBuilder result = new StringBuilder("청구 내역(고객명 : " + invoice.getCustomer() + ") \\n");
for(Performance performance : invoice.getPerformances()) {
performanceCalculator = new PerformanceCalculator(performance, playFor(performance));
//함수 인라인
result.append(playFor(performance).getName()).append(": ").append(usd(performanceCalculator.amount(performance))).append(" ").append(performance.getAudience()).append("석 \\n");
}
result.append("총액:").append(usd(totalAmount())).append('\\n');
result.append("적립 포인트:").append(totalVolumeCredits()).append("점 \\n");
return result.toString();
}
private double totalAmount() {
double totalAmount = 0;
for(Performance performance : invoice.getPerformances()) {
totalAmount += performanceCalculator.amount(performance);
//함수 인라인
}
return totalAmount;
}
private double totalVolumeCredits() {
double volumeCredits = 0.d;
for(Performance performance : invoice.getPerformances()) {
volumeCredits += performanceCalculator.volumeCreditsFor(performance);
//함수 인라인
}
return volumeCredits;
}
//함수 추출
private String usd(double aNumber) {
NumberFormat numberFormat = NumberFormat.getInstance(Locale.US);
return numberFormat.format(aNumber/100);
}
//함수 추출
private Play playFor(Performance performance) {
return plays.get(performance.getPlayId());
}
PerformanceCalculator를 보면 play의 타입에 따라 amount메서드와 volumeCreditsFor가 달라질 수 있다. 이를 피하기 위해 다형성을 활용하겠다. PerformanceCalculator를 상속받는 서브클래스를 준비하고 팩토리 함수를 활용해 서브 클래스를 생성하도록 하겠다.
PerformanceCalculator를 인터페이스로 만든다.
public interface PerformanceCalculator {
double amount(Performance performance);
double volumeCreditsFor(Performance performance);
}
PerformanceCalculator를 상속받은 TragedyCalculator와 ComedyCalculator를 만든다.
public class ComedyCalculator implements PerformanceCalculator {
public ComedyCalculator(Performance performance, Play play) {
this.performance = performance;
this.play = play;
}
private Performance performance;
private Play play;
@Override
public double amount(Performance performance) {
double thisAmount = 30000.d;
if(performance.getAudience() > 20) {
thisAmount += 10000 + 500 * (performance.getAudience() - 20);
}
thisAmount += 300 * performance.getAudience();
return thisAmount;
}
@Override
public double volumeCreditsFor(Performance performance) {
double result = Math.max(performance.getAudience() - 30, 0);
result += Math.floor((double) performance.getAudience() / 5);
return result;
}
}
public class TragedyCalculator implements PerformanceCalculator {
public TragedyCalculator(Performance performance, Play play) {
this.performance = performance;
this.play = play;
}
private Performance performance;
private Play play;
public double amount(Performance performance) {
double thisAmount = 40000.d;
if(performance.getAudience() > 30) {
thisAmount += 1000 * (performance.getAudience() - 30);
}
return thisAmount;
}
public double volumeCreditsFor(Performance performance) {
return Math.max(performance.getAudience() - 30, 0);
}
}
이전 amount에서 switch문에 따라 해야 하는 일이 달라졌다. 새로운 장르가 추가되면 amount를 추가해야 했다. 하지만 다형성을 활용해서 그럴 필요가 없어졌다. PerformanceCalculator를 상속받은 새로운 클래스를 만들어주면 된다.
PerformanceCalculator의 서브클래스를 생성하는 생성 팩토리 메서드는 다음과 같다.
private PerformanceCalculator createPerformanceCalculator(Performance performance, Play play) {
switch (play.getType()) {
case "tragedy":
return new TragedyCalculator(performance, play);
case "comedy":
return new ComedyCalculator(performance, play);
default:
throw new RuntimeException("알 수 없는 장르:" + play.getType());
}
}
play 타입에 따라 PerformanceCalculator를 상속받은 서브클래스의 인스턴스를 만들어 준다. Main 클래스는 다음과 같다.
public class Main {
public static void main(String[] args) {
Main main = new Main();
main.plays = Main.getPlays();
main.invoice = Main.getInvoice();
String result = main.statement(Main.getInvoice(), Main.getPlays());
System.out.println(result);
}
private Map<String, Play> plays;
private Invoice invoice;
private PerformanceCalculator performanceCalculator;
public String statement(Invoice invoice, Map<String, Play> plays) {
double totalAmount = 0.d;
StringBuilder result = new StringBuilder("청구 내역(고객명 : " + invoice.getCustomer() + ") \\n");
for(Performance performance : invoice.getPerformances()) {
performanceCalculator = createPerformanceCalculator(performance, playFor(performance));
//함수 인라인
result.append(playFor(performance).getName()).append(": ").append(usd(performanceCalculator.amount(performance))).append(" ").append(performance.getAudience()).append("석 \\n");
}
result.append("총액:").append(usd(totalAmount())).append('\\n');
result.append("적립 포인트:").append(totalVolumeCredits()).append("점 \\n");
return result.toString();
}
private double totalAmount() {
double totalAmount = 0;
for(Performance performance : invoice.getPerformances()) {
totalAmount += performanceCalculator.amount(performance);
//함수 인라인
}
return totalAmount;
}
private double totalVolumeCredits() {
double volumeCredits = 0.d;
for(Performance performance : invoice.getPerformances()) {
volumeCredits += performanceCalculator.volumeCreditsFor(performance);
//함수 인라인
}
return volumeCredits;
}
//함수 추출
private String usd(double aNumber) {
NumberFormat numberFormat = NumberFormat.getInstance(Locale.US);
return numberFormat.format(aNumber/100);
}
//함수 추출
private Play playFor(Performance performance) {
return plays.get(performance.getPlayId());
}
private PerformanceCalculator createPerformanceCalculator(Performance performance, Play play) {
switch (play.getType()) {
case "tragedy":
return new TragedyCalculator(performance, play);
case "comedy":
return new ComedyCalculator(performance, play);
default:
throw new RuntimeException("알 수 없는 장르:" + play.getType());
}
}
private static Invoice getInvoice() {
List<Performance> performances = Arrays.asList(
new Performance("hamlet", 55),
new Performance("as-like", 35),
new Performance("othello", 40)
);
return new Invoice("BigCo", performances);
}
private static Map<String, Play> getPlays() {
Map<String, Play> plays = new HashMap<>();
plays.put("hamlet", new Play("Hamlet", "tragedy"));
plays.put("as-like", new Play("As You Like It", "comedy"));
plays.put("othello", new Play("Othello", "tragedy"));
return plays;
}
}
statement()를 Main 메서드에 만들어줬는데 InvoicePrinter라는 클래스를 생성 후 statement()를 옮기겠다. 코드는 다음과 같다.
Main 클래스
public class Main {
public static void main(String[] args) {
String result = new InvoicePrinter(plays(), invoice())
.statement();
System.out.println(result);
}
private static Invoice invoice() {
List<Performance> performances = Arrays.asList(
new Performance("hamlet", 55),
new Performance("as-like", 35),
new Performance("othello", 40)
);
return new Invoice("BigCo", performances);
}
private static Map<String, Play> plays() {
Map<String, Play> plays = new HashMap<>();
plays.put("hamlet", new Play("Hamlet", "tragedy"));
plays.put("as-like", new Play("As You Like It", "comedy"));
plays.put("othello", new Play("Othello", "tragedy"));
return plays;
}
}
InvoicePrinter 클래스
public class InvoicePrinter {
private Map<String, Play> plays;
private Invoice invoice;
private PerformanceCalculator performanceCalculator;
public InvoicePrinter(Map<String, Play> plays, Invoice invoice) {
this.plays = plays;
this.invoice = invoice;
}
public String statement() {
StringBuilder result = new StringBuilder("청구 내역(고객명 : " + invoice.getCustomer() + ") \n");
for(Performance performance : invoice.getPerformances()) {
performanceCalculator = createPerformanceCalculator(performance, playFor(performance));
//함수 인라인
result.append(playFor(performance).getName()).append(": ").append(usd(performanceCalculator.amount(performance))).append(" ").append(performance.getAudience()).append("석 \n");
}
result.append("총액:").append(usd(totalAmount())).append('\n');
result.append("적립 포인트:").append(totalVolumeCredits()).append("점 \n");
return result.toString();
}
private double totalAmount() {
double totalAmount = 0;
for(Performance performance : invoice.getPerformances()) {
totalAmount += performanceCalculator.amount(performance);
}
return totalAmount;
}
private double totalVolumeCredits() {
double volumeCredits = 0.d;
for(Performance performance : invoice.getPerformances()) {
volumeCredits += performanceCalculator.volumeCreditsFor(performance);
}
return volumeCredits;
}
//함수 추출
private String usd(double aNumber) {
NumberFormat numberFormat = NumberFormat.getInstance(Locale.US);
return numberFormat.format(aNumber/100);
}
//함수 추출
private Play playFor(Performance performance) {
return plays.get(performance.getPlayId());
}
private PerformanceCalculator createPerformanceCalculator(Performance performance, Play play) {
switch (play.getType()) {
case "tragedy":
return new TragedyCalculator(performance, play);
case "comedy":
return new ComedyCalculator(performance, play);
default:
throw new RuntimeException("알 수 없는 장르:" + play.getType());
}
}
}