[Refactoring] 냄새 18. 중재자
08 Jan 2023해당 포스트는 inflearn의 백기선님의 강의인 리팩토링 을 듣고 정리한 글입니다.
냄새 18. 중재자
- 냄새 17. 메시지 체인과 반대되는 리팩토링 방법이다.
- 캡슐화를 통해 내부의 구체적인 정보를 최대한 감출 수 있다.
- 그러나, 어떤 클래스의 메소드가 대부분 다른 클래스로 메소드 호출을 위임하고 있다면 중재자를 제거하고 클라이언트가 해당 클래스를 직접 사용하도록 코드를 개선할 수 있다.
- 관련 리팩토링
- “중재자 제거하기 (Remove Middle Man)” 리팩토링을 사용해 클라이언트가 필요한 클래스를 직접 사용하도록 개선할 수 있다.
- “함수 인라인(Inline Function)”을 사용해서 메소드 호출한 쪽으로 코드를 보내서 중재자를 없앨 수 있다.
- “슈퍼클래스를 위임으로 바꾸기 (Replace Superclass with Delegate)”
- “서브 클래스를 위임으로 바꾸기 (Replace Subclass with Delegate)”
리팩토링 38. 중재자 제거하기
- “위임 숨기기”의 반대에 해당하는 리팩토링
- 필요한 캡슐화의 정도는 시간에 따라 그리고 상황에 따라 바뀔 수 있다.
- 캡슐화의 정도를 “중재자 제거하기”와 “위임 숨기기” 리팩토링을 통해 조절할 수 있다.
- 위임하고 있는 객체를 클라이언트가 사용할 수 이도록 getter를 제공하고, 클라이언트는 메시지 체인을 사용하도록 코드를 고친 뒤에 캡슐화에 사용했던 메소드를 제거한다.
- Law of Demeter를 지나치게 따르기 보다는 상황에 맞게 활용하도록 하자.
- 디미터의 법칙, “가장 가까운 객체만 사용한다.”
예제 코드는 저번 메시지 체인과 동일한 코드를 사용한다.
- Department
public class Department {
private Person manager;
public Department(Person manager) {
this.manager = manager;
}
public Person getManager() {
return manager;
}
}
- Person
public class Person {
private Department department;
private String name;
public Person(String name, Department department) {
this.name = name;
this.department = department;
}
public Person getManager() {
return this.department.getManager();
}
}
테스트 코드에서 getManager
메소드를 통해 메시지 체이닝을 적용했다면, 지나치게 캡슐화가 된것같아 풀어줘야 할 수도있다.
Person
클래스의 getManager()
의 구현을 살펴본다면 Department
클래스를 통해 Manager의 정보를 가져온다.
@Test
void getManager_refactor() {
Person nick = new Person("nick", null);
Person keesun = new Person("keesun", new Department(nick));
assertEquals(nick, keesun.getManager());
}
중간 다리의 Department
클래스의 getter를 만들어, Department
정보를 가져오고 Manager
의 정보를 빼오면 될것이다.
이후 사용하지 않는 getManager()
는 삭제해준다.
public class Person {
private Department department;
...
public Department getDepartment() {
return this.department;
}
}
@Test
void getManager_refactor() {
Person nick = new Person("nick", null);
Person keesun = new Person("keesun", new Department(nick));
assertEquals(nick, keesun.getDepartment().getManager());
}
이후 테스트의 정상작동을 확인 해준다.
리팩토링 39. 슈퍼클래스를 위임으로 바꾸기
- 객체지향에서 “상속”은 기존의 기능을 재사용하는 쉬우면서 강력한 방법이지만 때로는 적절하지 않은 경우도 있다.
- 서브클래스는 슈퍼클래스의 모든 기능을 지원해야 한다.
- Stack이라는 자료구조를 만들 때 List를 상속 받는것이 좋을까?
- 서브클래스는 슈퍼클래스 자리를 대체하더라도 잘 동작해야 한다.
- 리스코프 치환 원칙
- 서브클래스는 슈퍼클래의 변경에 취약하다.
- 그렇다면 상속을 사용하지 않는 것이 좋은가?
- 상속은 적절한 경우에 사용한다면 매우 쉽고 효율적인 방법이다.
- 따라서, 우선 상속을 적용한 이후에, 적절치 않다고 판단이 된다면 그때에 이 리팩토링을 적용하자.
예제 코드 Scroll
클래스는 CategoryItem
의 클래스를 상속 받고 있다.
CategoryItem
는 카테고리의 정보를 담고 있다.
Scroll
은 Category의 정보를 언제 마지막으로 청소했는지에 대한 날짜 정보를 담고있다.
- CategoryItem
public class Scroll extends CategoryItem {
private LocalDate dateLastCleaned;
public Scroll(Integer id, String title, List<String> tags, LocalDate dateLastCleaned) {
super(id, title, tags);
this.dateLastCleaned = dateLastCleaned;
}
public long daysSinceLastCleaning(LocalDate targetDate) {
return this.dateLastCleaned.until(targetDate, ChronoUnit.DAYS);
}
}
- CategoryItem
public class CategoryItem {
private Integer id;
private String title;
private List<String> tags;
public CategoryItem(Integer id, String title, List<String> tags) {
this.id = id;
this.title = title;
this.tags = tags;
}
public Integer getId() {
return id;
}
public String getTitle() {
return title;
}
public boolean hasTag(String tag) {
return this.tags.contains(tag);
}
}
만약 Scroll
클래스가 따로 Category로 분류가 되지 않기 때문에 기존의 상속구조를 변경해 주려한다.
해당 구조를 상속에서 위임구조로 변경해보자. 변경 방법은 간단하다.
상위 클래스 CategoryItem
을 변수로 선언하고, super로 전달 받던 데이터를 선언한 변수의 생성자를 통해 인스턴스화 한다.
public class Scroll extends CategoryItem {
...
private CategoryItem categoryItem;
...
public Scroll(Integer id, String title, List<String> tags, LocalDate dateLastCleaned) {
super(id, title, tags);
this.dateLastCleaned = dateLastCleaned;
this.categoryItem = new CategoryItem(id, title, tags);
}
}
이후 상속관계를 끊어주면 된다.
public class Scroll{
private LocalDate dateLastCleaned;
private CategoryItem categoryItem;
public Scroll(Integer id, String title, List<String> tags, LocalDate dateLastCleaned) {
this.dateLastCleaned = dateLastCleaned;
this.categoryItem = new CategoryItem(id, title, tags);
}
public long daysSinceLastCleaning(LocalDate targetDate) {
return this.dateLastCleaned.until(targetDate, ChronoUnit.DAYS);
}
}
이후 테스트를 돌려보면 정상적으로 돌아가는 것을 확인할 수있다.
리팩토링 40. 서브클래스를 위임으로 바꾸기
- 어떤 객체의 행동이 카테고리에 따라 바뀐다면, 보통 상속을 사용해서 일반적인 로직은 슈퍼클래스에 두고 특이한 케이스에 해당하는 로직을 서브클래스를 사용해 표현한다.
- 하지만, 대부분의 프로그래밍 언어에서 상속은 오직 한번만 사용할 수 있다.
- 만약에 어떤 객체를 두가지 이상의 카테고리로 구분해야 한다면?
- 위임을 사용하면 얼마든지 여러가지 이유로 여러 다른 객체로 위임을 할 수 있다.
- 슈퍼클래스가 바뀌면 모든 서브클래스에 영향을 줄 수 있다. 따라서 슈퍼클래스를 변경할 때 서브클래스까지 신경써야 한다.
- 만약에 서브클래스가 전혀 다른 모듈에 있다면?
- 위임을 사용한다면 중간에 인터페이스를 만들어 의존성을 줄일 수 있다.
- “상속 대신 위임을 선호하라.”는 결코 “상속은 나쁘다.” 라는 말이 아니다.
- 처음엔 상속을 적용하고 언제든지 이런 리팩토링을 사용해 위임으로 전환할 수 있다.
예제코드는 Booking
과 PremiumBooking
을 중심으로 돌아간다.
Booking
코드는 쇼를 예약할 때, 일반 예약에 대한 정보를 가져온다.
PremiumBooking
코드는 Booking
을 상속받고 있으며, 프리미엄 예약에 대한 정보를 가져오고 있다.
두 클래스는 TalkShow의 유무와, 해당 예약일이 PeakDay인지를 주로 판별한다.
- Booking
public class Booking {
protected Show show;
protected LocalDateTime time;
public Booking(Show show, LocalDateTime time) {
this.show = show;
this.time = time;
}
public boolean hasTalkback() {
return this.show.hasOwnProperty("talkback") && !this.isPeakDay();
}
protected boolean isPeakDay() {
DayOfWeek dayOfWeek = this.time.getDayOfWeek();
return dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY;
}
public double basePrice() {
double result = this.show.getPrice();
if (this.isPeakDay()) result += Math.round(result * 0.15);
return result;
}
}
- PremiumBooking
public class PremiumBooking extends Booking {
private PremiumExtra extra;
public PremiumBooking(Show show, LocalDateTime time, PremiumExtra extra) {
super(show, time);
this.extra = extra;
}
@Override
public boolean hasTalkback() {
return this.show.hasOwnProperty("talkback");
}
@Override
public double basePrice() {
return Math.round(super.basePrice() + this.extra.getPremiumFee());
}
public boolean hasDinner() {
return this.extra.hasOwnProperty("dinner") && !this.isPeakDay();
}
}
먼저 테스트코드를 실행하면 정상적으로 실행하는 것을 확인 할 수 있다.
원래의 구조라면, Booking
을 상속받은 PremiumBooking
은 Booking
의 정보를 이어 받아 프리미엄일 경우의 로직이 추가적으로 구현되므로, 상속관계를 올바르게 사용했다고 볼 수 있다.
하지만 테스트 코드에서 볼 수 있듯이 PremiumBooking
을 사용하기 위해서는 new PremiumBooking()
를 통해 직접 인스턴스화를 진행해야한다.
@Test
void talkback() {
Show lionKing = new Show(List.of(), 120);
Show aladin = new Show(List.of("talkback"), 120);
LocalDateTime weekday = LocalDateTime.of(2022, 1, 20, 19, 0);
LocalDateTime weekend = LocalDateTime.of(2022, 1, 15, 19, 0);
assertFalse(new Booking(lionKing, weekday).hasTalkback());
assertTrue(new Booking(aladin, weekday).hasTalkback());
assertFalse(new Booking(aladin, weekend).hasTalkback());
PremiumExtra premiumExtra = new PremiumExtra(List.of(), 50);
assertTrue(new PremiumBooking(aladin, weekend, premiumExtra).hasTalkback());
assertFalse(new PremiumBooking(lionKing, weekend, premiumExtra).hasTalkback());
}
이러한 경우에서 만약에 Booking
에서 PremiumBooking
으로 업그레이드 하는 기능이 추가되어야 한다면?
Booking
을 인스턴스화 한 상태에서 PremiumBooking
의 로직을 사용할 수 있어야 할것이다.
이 경우에 상속받은 하위클래스를 없애고 위임으로 변경해 줄 수 있다.
Delegate 패턴을 사용해서 위임을 진행해보도록 하자.
PremiumBooking
일 경우에만 필요한 정보들을 가지고 있는 BookingDelegate
클래스를 만들어 준다.
public class PremiumDelegate {
private Booking host;
private PremiumExtra extra;
public PremiumDelegate(Booking host, PremiumExtra extra) {
this.host = host;
this.extra = extra;
}
}
이후 Booking
클래스에 팩토리 메서드 패턴을 이용해 Booking
와 PremiumBooking
을 각각 생성할 수 있는 메소드들을 만들어 준다.
팩토리 메서드 패턴을 사용하는 이유는 간단하다.
생성자를 만들경우에는 무조건 함수명이 Booking
으로 고정되지만, 팩토리 메서드 패턴을 사용할 경우 아래와 같이 메소드명을 상황에 맞게 변경하여, 다양하게 표현 할 수 있기 때문이다.
팩토리 메소드를 만든 이후 PremiumBooking
클래스는 PremiumDelegate
또한 인스턴스를 만들어 프리미엄 정보를 넣어준다.
public class Booking {
...
public static Booking createBooking(Show show, LocalDateTime time) {
return new Booking(show, time);
}
public static Booking createPremiumBooking(Show show, LocalDateTime time, PremiumExtra extra) {
PremiumBooking premiumBooking = new PremiumBooking(show, time, extra);
premiumBooking.premiumDelegate = new PremiumDelegate(premiumBooking, extra);
return premiumBooking;
}
...
}
아래의 코드는 중요하다. 추후에 모든 프리미엄 정보를 PremiumDelegate
로 위임하고 PremiumBooking
을 삭제할 것이다.
premiumBooking.premiumDelegate = new PremiumDelegate(premiumBooking, extra);
계속 진행해보도록 하자.
테스트 코드를 팩토리 메소드에서 받는걸로 변경해주도록 하자.
@Test
void talkback_factor() {
Show noTalkbackShow = new Show(List.of(), 120);
Show talkbackShow = new Show(List.of("talkback"), 120);
LocalDateTime weekday = LocalDateTime.of(2022, 1, 20, 19, 0);
LocalDateTime weekend = LocalDateTime.of(2022, 1, 15, 19, 0);
assertFalse(Booking.createBooking(noTalkbackShow, weekday).hasTalkback());
assertTrue(Booking.createBooking(talkbackShow, weekday).hasTalkback());
assertFalse(Booking.createBooking(talkbackShow, weekend).hasTalkback());
PremiumExtra premiumExtra = new PremiumExtra(List.of(), 50);
assertTrue(Booking.createPremiumBooking(talkbackShow, weekend, premiumExtra).hasTalkback());
assertFalse(Booking.createPremiumBooking(noTalkbackShow, weekend, premiumExtra).hasTalkback());
}
테스트의 정상작동을 확인할 수 있다.
이제 PremiumBooking
의 로직을 PremiumDelegate
로 옮겨 주도록하자.
어떤식으로 진행되는지는 다음을 보면 알 수 있다.
PremiumBooking
클래스에 있던 hasTalkback()
메소드를 PremiumDelegate
로 옮겨준다.
이후 PremiumBooking
에서는 PremiumDelegate
를 통해서 talkback
의 유무를 호출해 주도록 하자.
동일한 로직이지만, 실질적인 로직은 PremiumDelegate
으로 옮겨갔다고 보면된다. PremiumBooking
는 단순한 중재자 역할로 변경된다.
public class PremiumDelegate {
...
public boolean hasTalkback() {
return this.host.show.hasOwnProperty("talkback");
}
}
public class PremiumBooking extends Booking {
...
@Override
public boolean hasTalkback() {
return this.premiumDelegate.hasTalkback();
}
}
여기서 더 나아가 PremiumBooking
의 로직을 정리해 줄 수도 있다.
Booking
클래스에서 가지고있는 필드 PremiumDelegate
는 Booking.createPremiumBooking
를 호출해야만 생성될 수있는 필드이다.
만약 Booking
클래스에서 PremiumDelegate
가 없다면 해당 Booking은 프리미엄이 아닌 일반 Booking으로 볼 수 있다.
이점을 참고하여 hasTalkback()
메소드를 아래와 같이 수정 할 수 있다.
public class Booking {
...
protected PremiumDelegate premiumDelegate;
public static Booking createBooking(Show show, LocalDateTime time) {
return new Booking(show, time);
}
public static Booking createPremiumBooking(Show show, LocalDateTime time, PremiumExtra extra) {
PremiumBooking premiumBooking = new PremiumBooking(show, time, extra);
premiumBooking.premiumDelegate = new PremiumDelegate(premiumBooking, extra);
return premiumBooking;
}
public boolean hasTalkback() {
return (premiumDelegate != null) ? this.premiumDelegate.hasTalkback() :
this.show.hasOwnProperty("talkback") && !this.isPeakDay();
}
...
}
이후에는 PremiumBooking
에 존재하는 hasTalkback()
를 삭제 할 수 있을 것이다.
이렇게 나머지 로직도 수정해 주도록 하자.
Booking
클래스의 bestPrice()
메소드를 변경해 주려고 한다.
hasTalkback()
메소드와 같이 premiumDelegate
의 객체가 null인지 판별해 금액을 계산해준다.
PremiumDelegate
에는 금액을 계산하는 로직이 없으므로 extendBasePrice
메소드를 새로 만들고, PremiumBooking
의 basePrice
메소드를 옮겨준다.
public class Booking {
...
public double basePrice() {
double result = this.show.getPrice();
if (this.isPeakDay()) result += Math.round(result * 0.15);
return (this.premiumDelegate != null) ? this.premiumDelegate.extendBasePrice(result) : result;
}
}
public class PremiumDelegate {
...
public double extendBasePrice(double result) {
return Math.round(result + this.extra.getPremiumFee());
}
}
이후 옮겨준 basePrice
메소드를 삭제해 주도록하자. 삭제하지 않으면, Override된 basePrice
가 한번더 실행하여 다른 결과가 나오게 된다.
삭제하고 테스트 코드를 실행하여, 기존과 동일하게 동작하는지 확인해보자.
마지막으로 옮겨준 기능은 hasDinner로, PremiumBooking
에만 있는 기능이다.
PremiumBooking
의 hasDinner()
메소드를 PremiumDelegate
로 옮겨준다.
Booking
클래스에도 hasDinner()
메소드를 만들고 premiumDelegate
의 유무에 따라, premiumDelegate
의 hasDinner의 정보를 반환하거나, 기본적으로 false를 반환하도록 하자.
public class PremiumDelegate {
private Booking host;
private PremiumExtra extra;
...
public boolean hasDinner() {
return this.extra.hasOwnProperty("dinner") && !this.host.isPeakDay();
}
}
public class Booking {
protected PremiumDelegate premiumDelegate;
...
public boolean hasDinner() {
return (this.premiumDelegate != null) ? this.premiumDelegate.hasDinner() : false;
}
}
최종적으로 PremiumBooking
에서 사용하는 로직이 없기 때문에, 해당 객체를 인스턴스화 하는 부분을 삭제해 준다.
그렇다면 더이상 사용되지 않는 PremiumBooking
클래스 또한 삭제해 줄 수 있다.
public class Booking {
protected Show show;
protected LocalDateTime time;
protected PremiumDelegate premiumDelegate;
public Booking(Show show, LocalDateTime time) {
this.show = show;
this.time = time;
}
public static Booking createBooking(Show show, LocalDateTime time) {
return new Booking(show, time);
}
public static Booking createPremiumBooking(Show show, LocalDateTime time, PremiumExtra extra) {
Booking booking = createBooking(show, time);
booking.premiumDelegate = new PremiumDelegate(booking, extra);
return booking;
}
...
}
마지막으로 테스트 코드를 모두 돌려보고 정상적으로 돌아가는지 확인해보자.