DEV ℧ Developer Diary

[Refactoring] 냄새 18. 중재자

해당 포스트는 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());
}

이후 테스트의 정상작동을 확인 해준다.

리팩토링1

리팩토링 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);
    }
}

이후 테스트를 돌려보면 정상적으로 돌아가는 것을 확인할 수있다.

리팩토링2

리팩토링 40. 서브클래스를 위임으로 바꾸기

  • 어떤 객체의 행동이 카테고리에 따라 바뀐다면, 보통 상속을 사용해서 일반적인 로직은 슈퍼클래스에 두고 특이한 케이스에 해당하는 로직을 서브클래스를 사용해 표현한다.
  • 하지만, 대부분의 프로그래밍 언어에서 상속은 오직 한번만 사용할 수 있다.
    • 만약에 어떤 객체를 두가지 이상의 카테고리로 구분해야 한다면?
    • 위임을 사용하면 얼마든지 여러가지 이유로 여러 다른 객체로 위임을 할 수 있다.
  • 슈퍼클래스가 바뀌면 모든 서브클래스에 영향을 줄 수 있다. 따라서 슈퍼클래스를 변경할 때 서브클래스까지 신경써야 한다.
    • 만약에 서브클래스가 전혀 다른 모듈에 있다면?
    • 위임을 사용한다면 중간에 인터페이스를 만들어 의존성을 줄일 수 있다.
  • “상속 대신 위임을 선호하라.”는 결코 “상속은 나쁘다.” 라는 말이 아니다.
    • 처음엔 상속을 적용하고 언제든지 이런 리팩토링을 사용해 위임으로 전환할 수 있다.

예제코드는 BookingPremiumBooking을 중심으로 돌아간다.

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();
    }
}

먼저 테스트코드를 실행하면 정상적으로 실행하는 것을 확인 할 수 있다.

리팩토링3

원래의 구조라면, Booking을 상속받은 PremiumBookingBooking의 정보를 이어 받아 프리미엄일 경우의 로직이 추가적으로 구현되므로, 상속관계를 올바르게 사용했다고 볼 수 있다.

하지만 테스트 코드에서 볼 수 있듯이 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 클래스에 팩토리 메서드 패턴을 이용해 BookingPremiumBooking을 각각 생성할 수 있는 메소드들을 만들어 준다.

팩토리 메서드 패턴을 사용하는 이유는 간단하다.

생성자를 만들경우에는 무조건 함수명이 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());
}

테스트의 정상작동을 확인할 수 있다.

리팩토링4

이제 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 클래스에서 가지고있는 필드 PremiumDelegateBooking.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 메소드를 새로 만들고, PremiumBookingbasePrice 메소드를 옮겨준다.

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가 한번더 실행하여 다른 결과가 나오게 된다.

리팩토링5

삭제하고 테스트 코드를 실행하여, 기존과 동일하게 동작하는지 확인해보자.

리팩토링6

마지막으로 옮겨준 기능은 hasDinner로, PremiumBooking에만 있는 기능이다.

PremiumBookinghasDinner() 메소드를 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;
    }
    ...
}

마지막으로 테스트 코드를 모두 돌려보고 정상적으로 돌아가는지 확인해보자.

리팩토링7