We will find a way, we always have.

-interstellar

OOP

[오브젝트] 5장 책임 할당하기

Redddy 2023. 12. 9. 00:41

이번 장에서는 이전 장과는 달리 데이터에 초점을 맞추는 것이 아닌 객체에 책임을 할당하는데에 초점을 맞추는 방식을 살펴본다.  

 

1. 책임 주도 설계를 향해

 

데이터 중심의 설계에서 책임 중심의 설계로 전환하기 위해서는 다음의 두 가지 원칙을 따라야 한다. 

 

  • 데이터보다 행동을 먼저 결정하라
  • 협력이라는 문맥 안에서 책임을 결정하라

 

책임 주도 설계

책임 주도 설계의 흐름은 아래와 같다.

 

  • 시스템이 사용자에게 제공하는 기능인 시스템 책임을 파악한다.
  • 시스템 책임을 더 작은 책임으로 분할한다.
  • 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당한다.
  • 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾는다.
  • 해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력하게 한다.

 

누군가 프리코스 4주차 미션을 책임 주도 설계로 하였나고 질문한다면, 내가 이해하고 있는 선에서 최대한 노력했다고 답할 것이다. 

 

 

2. 책임 할당을 위한 GRASP 패턴

 

GRASP 패턴은 크레이그 라만(Graig Larman)이 제안한 책임 할당 기법이다. GRASP는 "General Responsibility Assignment Software Pattern(일반적인 책임 할당을 위한 소프트웨어 패턴)"의 약자로 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 집합을 패턴 형식으로 정리한 것이다.

 

책임 할당을 할 때에는 정보 전문가에게 책임을 할당해야하고, 창조자에게 객체 생성 책임을 할당해야 한다. 

 

 

책에서 나온 예시 대신 내가 미션을 진행하면서 작성한 코드로 예시를 들어보자. 

4주차 미션에서 할인 구현을 DiscountPolicy와 GiveawayPolicy 인터페이스를 만들어 각각의 이벤트들은 이 인터페이스를 구현하고, DiscountManager와 GiveawayManager를 만들어 Policy를 관리하고, 이 두 매니저를 가지고 인스턴스 변수로 가지고 있는 Promotion 클래스를 만들어 전체적인 할인을 확인하였다. 

 

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class Promotion {
 
    private final DiscountManager discountManager;
    private final GiveawayManager giveawayManager;
 
    private Promotion(DiscountManager discountManager, GiveawayManager giveawayManager) {
        this.discountManager = discountManager;
        this.giveawayManager = giveawayManager;
    }
 
    public static Promotion of(VisitDate visitDate, OrderHistory orderHistory) {
        DiscountManager discountManager = DiscountManager.of(getDiscountPolicy(), visitDate, orderHistory);
        GiveawayManager giveawayManager = GiveawayManager.of(getGiveawayPolicy(), orderHistory);
        return new Promotion(discountManager, giveawayManager);
    }
 
    private static List<DiscountPolicy> getDiscountPolicy() {
        return List.of(
                new ChristmasDDayDiscountPolicy(),
                new WeekDayDiscountPolicy(),
                new WeekendDiscountPolicy(),
                new SpecialDiscountPolicy()
        );
    }
 
    private static List<GiveawayPolicy> getGiveawayPolicy() {
        return List.of(new MenuGiveawayPolicy());
    }
 
    public Benefit getBenefit() {
        Map<String, Integer> result = new LinkedHashMap<>();
        discountManager.forEach(result::put);
        giveawayManager.forEach(giveawayPolicy -> result.put(giveawayPolicy.getName(), giveawayPolicy.getPrice()));
        return new Benefit(result);
    }
 
    public GiveawayMenu getGiveawayMenu() {
        List<Menu> result = new ArrayList<>();
        giveawayManager.forEach(giveawayPolicy -> result.add(giveawayPolicy.getMenu()));
        return new GiveawayMenu(result);
    }
 
    public Amount getDiscountedAmount(OrderHistory orderHistory) {
        int value = orderHistory.getTotalAmount() - discountManager.getBenefit();
        return new Amount(value);
    }
 
    public Badge getBadge() {
        int amount = discountManager.getBenefit() + giveawayManager.calculateGiveawayBenefit();
        return Badge.from(amount);
    }
}
 
cs

 

지금 다시 보니까 각각 프로모션 구현체를 Promotion에서 생성할 것이 아니라 Manager에게 생성할 책임을 넘겨주는 것이 더 바람직해보인다. 후후

 

 

 

각각 인터페이스는 이렇게 생겼다. 

 

1
2
3
4
5
6
7
8
9
10
public interface GiveawayPolicy {
    boolean hasGiveaway(OrderHistory orderHistory);
 
    String getName();
 
    Menu getMenu();
 
    int getPrice();
}
 
cs

 

 

1
2
3
4
5
6
public interface DiscountPolicy {
    int discount(VisitDate visitDate, OrderHistory orderHistory);
 
    String getName();
}
 
cs

 

 

그리고 할인 정책 구현체를 확인해보면 이렇게 생겼다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SpecialDiscountPolicy implements DiscountPolicy {
 
    private static final String name = "특별 할인";
 
    @Override
    public int discount(VisitDate visitDate, OrderHistory orderHistory) {
        if (visitDate.isHoliday()) {
            return Rule.SPECIAL_DISCOUNT_AMOUNT.getValue();
        }
        return 0;
    }
 
    @Override
    public String getName() {
        return name;
    }
}
cs

 

공휴일인 경우 1000원을 할인하는 책임을 가지고 있는 클래스이다. 방문 날짜의 정보를 가지고 있는 VisitDate에게 방문 날짜가 공휴일인지 물어보는 메시지를 보내고 있고, 만약 공휴일이라면 상수를 가지고 있는 Rule에서 할인 금액을 가져온다. 

 

 

 

내가 작성한 설계 순서는 다음과 같다.

 

  • 요구사항을 파악하여 기능 목록 정리
  • 일단 돌아가는 쓰레기 만들기
  • 책임을 분리하고 해당 책임을 수행할 객체 또는 역할을 찾아 할당
  • 객체가 수행할 수 없는 부분은 다른 객체에게 도움을 요청 

Promotion에서 getBenefit 메서드는 미션 제출날까지도 int 를 반환하는 형태였다. 이를 Benefit 객체를 만들어 책임을 분리시키게 된 계기는 소감문을 작성하는 도중, 책임을 분리하라는 키워드를 보고 다시 한번 리팩토링하며 수정하게 되었다. 

 

Benefit 클래스는 이렇게 생겼다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Benefit {
    private final Map<String, Integer> benefit;
 
    public Benefit(Map<String, Integer> benefit) {
        this.benefit = benefit;
    }
 
    public void forEach(BiConsumer<super String, ? super Integer> action) {
        benefit.forEach(action);
    }
 
    public boolean isEmpty() {
        return benefit.isEmpty();
    }
 
    public int getTotalBenefit() {
        return -benefit.values().stream()
                .mapToInt(Integer::intValue)
                .sum();
    }
 
    @Override
    public String toString() {
        return benefit.toString();
    }
}
 
cs

 

이 클래스가 있기 전에는 Promotion 클래스 안에서 저런 책임을 수행하는 형태였다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Promotion {
    
    // 중략
    
    public void forEach(Consumer<super GiveawayPolicy> action) {
        giveawayManager.forEach(action);
    }
 
    public boolean hasGiveawayMenu() {
        return !giveawayManager.isEmpty();
    }
 
    public boolean hasDiscount() {
        return !discountManager.isEmpty();
    }
 
    public void forEach(BiConsumer<super String, ? super Integer> action) {
        discountManager.forEach(action);
    }
 
    public int getDiscountedAmount(OrderHistory orderHistory) {
        return orderHistory.getTotalAmount() - discountManager.getBenefit();
    }
 
    public int calculateTotalBenefit() {
        return -(discountManager.getBenefit() + giveawayManager.calculateGiveawayBenefit());
    }
}
 
cs

 

 

그리고 Ouputview에서도 Promotion 객체에게 메시지를 던지는 형태였는데, 이보다는 Benefit 객체에게 메시지를 던지는 형태가 좀 더 바람직해보인다.

 

 

작성한 코드가 정상적으로 동작하는지는 테스트 코드를 통해 확인하였다. 

 

3. 책임 주도 설계의 대안

 

책임 주도 설계에 익숙해지기 위해서는 부단한 노력과 시간이 필요하다. 그러나 어느 정도 경험을 쌓은 숙련된 설계자조차도 적절한 책임과 객체를 선택하는 일에 어려움을 느끼고는 한다.

 

아무것도 없는 상태에서 책임과 협력에 관해 고민하기보다는 일단 실행되는 코드를 얻고 난 후 코드 상에 명확하게 드러나는 책임들을 올바른 위치로 이동시키는 것이다. 

 

리팩터링하자!

 

메서드 응집도

긴 메서드는 다양한 측면에서 코드의 유지보수에 부정적인 영향을 미친다. 

 

  • 어떤 일을 수행하는지 한눈에 파악하기 어렵기 때문에 코드를 전체적으로 이해하는 데 너무 많은 시간이 걸린다.
  • 하나의 메서드 안에서 너무 많은 작엄을 처리하기 때문에 변경이 필요할 때 수정해야 할 부분을 찾기 어렵다.
  • 메서드 내부의 일부 로직만 수정하더라도 메서드의 나머지 부분에서 버그가 발생할 확률이 높다.
  • 로직의 일부만 재사용하는 것이 불가능하다.
  • 코드를 재사용하는 유일한 방법은 원하는 코드를 복사해서 붙여넣는 것뿐이므로 코드 중복을 초래하기 쉽다. 

 

 

초기 버전의 ChistmasPromotionController의 run 메서드이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class ChristmasPromotionController {
 
    private final InputView inputView;
    private final OutputView outputView;
 
    public ChristmasPromotionController(InputView inputView, OutputView outputView) {
        this.inputView = inputView;
        this.outputView = outputView;
    }
 
    public void run() {
        outputView.printWelcomeMessage();
        VisitDate date = readDate();
        OrderHistory orderHistory = readOrder();
        outputView.printPreviewEventBenefits(date);
        outputView.printOrderMenu(orderHistory);
        outputView.printTotalOrderAmount(orderHistory);
        //TODO: 이 메서드 수정
        outputView.printGiveawayMenu(getGiveawayMenu(orderHistory));
        List<DiscountPolicy> discountPolicies = getDiscountPolicy();
        DiscountService discountService = DiscountService.of(discountPolicies, date, orderHistory);
        outputView.printBenefitDetails(discountService);
        outputView.printTotalBenefit(discountService);
        //TODO: 메서드 분리하기
        outputView.printDiscountedAmount(getDiscountedAmount(orderHistory, discountService));
        outputView.printBadge(getBadge(-discountService.getBenefit()));
    }
 
    // 중략
}
 
cs

 

그리고 여러 리팩토링을 거친 후의 모습이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class ChristmasPromotionController {
 
    private final InputController inputController;
    private final OutputView outputView;
 
    public ChristmasPromotionController(InputController inputController, OutputView outputView) {
        this.inputController = inputController;
        this.outputView = outputView;
    }
 
    public void run() {
        outputView.printWelcomeMessage();
        VisitDate date = inputController.getVisitDate();
        OrderHistory orderHistory = inputController.getOrderHistory();
        outputView.printPreviewEventBenefits(date);
        printOrderHistoryAndAmount(orderHistory);
        printEventResult(date, orderHistory);
    }
 
    private void printOrderHistoryAndAmount(OrderHistory orderHistory) {
        outputView.printOrderMenu(orderHistory);
        outputView.printTotalOrderAmount(orderHistory);
    }
 
    private void printEventResult(VisitDate date, OrderHistory orderHistory) {
        Promotion promotion = Promotion.of(date, orderHistory);
        outputView.printGiveawayMenu(promotion.getGiveawayMenu());
        printBenefit(promotion.getBenefit());
        outputView.printDiscountedAmount(promotion.getDiscountedAmount(orderHistory));
        outputView.printBadge(promotion.getBadge());
    }
 
    private void printBenefit(Benefit benefit) {
        outputView.printBenefitDetails(benefit);
        outputView.printTotalBenefit(benefit);
    }
}
cs

 

조금 나아진 것 같은 느낌이 들지 않는가?

 

 

객체를 자율적으로 만들자

어떤 메서드를 어떤 클래스로 이동시켜야 할까?

 

자신이 소유하고 있는 데이터를 자기 스스로 처리하도록 만드는 것이 자율적인 객체를 만드는 지름길이다. 따라서 메서드가 사용하는 데이터를 저장하고 있는 클래스로 메서드를 이동시키면 된다.