객체지향 설계

'객체지향의 사실과 오해' 7장 '함께 모으기'에 대한 내용입니다. 도메인 모델링부터 최종 코드까지 구현 과정을 간략하게 살펴보고, 구현된 클래스를 개념 관점, 명세 관점, 구현 관점에서 바라보겠습니다.

커피 전문점 도메인

커피 전문점에서 커피를 주문하는 과정을 객체들의 협력 관계로 구현하는 예제를 가지고 도메인 모델링부터 해보자

  • 메뉴판에는 아메리카노, 카푸치노, 카라멜 마키아또, 에스프레소의 네 가지 커피 메뉴가 있다
    • 메뉴판은 네 개의 메뉴 항목 객체들을 포함하는 객체
  • 손님은 메뉴판을 보고 바리스타에게 원하는 커피를 주문한다
    • 손님 객체는 메뉴판 객체 안에 적힌 메뉴 항목 객체들 중에서 자신이 원하는 메뉴 항목 객체 하나를 선택해 바리스타 객체에게 전달
  • 바리스타는 주문을받는 메뉴에 따라 적절한 커피를 제조한다

도메인 모델간의 관계를 나타낸 그림은 아래와 같다.

설계

협력 찾기

  • 협력 설계 방식
    • 메세지를 먼저 만들고 그 메세지를 수신하기에 적절한 객체를 선택해야 한다
    • 각 객체들이 수신하는 메세지가 객체의 인터페이스가 된다
    • 일반적으로 클래스를 이용해서 객체의 타입을 구현한다. 클래스의 퍼블릭 메서드가 공용 인터페이스가 된다.

  • 첫 번째 메세지 만들기
    • 현재 설계하고 있는 협력은 커피를 주문하는 것이다(유스케이스)
    • 첫 번째 메세지는 '커피를 주문하라'가 된다.

  • 첫 번째 메세지를 수신할 객체 찾기
    • '커피를 주문하라'라는 메세지를 처리하기 적합한 객체. 즉, 커피를 주문할 책임을 지는 객체는 손님 객체다.
    • 따라서 손님 객체가 메세지를 수신한다.
  • 다음 메세지를 만들고 수신할 객체 찾기
    • 손님은 메뉴 항목을 모른다
    • '메뉴 항목을 찾아라' 메세지를 만든다
    • 메뉴 항목은 메뉴판이 모두 포함하고 있기 때문에 메뉴판 객체가 메세지를 처리한다
  • 나머지 메세지 만들고 수신할 객체 찾기
    • 손님은 이제 커피를 제조해달라고 메세지를 전달한다
    • 메세지의 인자로 메뉴 항목을 전달하고 커피를 반환 받는다
    • 커피를 제조할 책임은 바리스타에게 있으므로 바리스타가 메세지를 수신한다

인터페이스 정리

  • 객체가 수신한 메세지가 객체의 인터페이스가 된다
  • 객체의 타입은 일반적으로 클래스를 이용해 구현하고, 클래스의 공용(public) 메서드가 인터페이스가 된다
class Customer {
  public order(menuName: string): void {}
}

class Menu {
  public choose(name: string): MenuItem {}
}

class Barista {
  public makeCoffee(menuItem: MenuItem): Coffee {}
}

class Coffee {
  constructor(menuItem: MenuItem) {}
}
ts

구현

  • Customer의 order() 메서드의 인자로 Menu, Barista 객체를 전달받는 방식으로 참조를 전달받는다
class Customer {
  public order(menuName: string, menu: Menu, barista: Barista): void {
    const menuItem = menu.choose(menuName)
    const coffee = barista.makeCoffee(menuItem)
    return coffee
  }
}
ts
  • Menu는 메뉴 항목을 포함해야 하므로 MenuItem 목록을 가지고 있도록 한다
class Menu {
  private items: Array<MenuItem>

  constructor(items: Array<MenuItem>) {
    this.items = items
  }

  public choose(name: string): MenuItem {
    return this.items.find((item) => item.getName() === name)
  }
}
ts
  • Barista는 MenuItem을 이용해 커피를 제조한다
class Barista {
  public makeCoffee(menuItem: MenuItem): Coffee {
    const coffee = new Coffee(menuItem)
    return coffee
  }
}
ts
  • Coffee는 생성자에서 MenuItem에 요청을 보내 커피 이름과 가격을 받아온다
class Coffee {
  private name: string
  private price: number

  constructor(menuItem: MenuItem) {
    this.name = menuItem.getName()
    this.price = menuItem.cost()
  }
}
ts
  • MenuItem은 getName(), cost()를 통해 이름, 가격을 노출한다
class MenuItem {
  private name: string
  private price: number

  constructor(name: string, price: number) {
    this.name = name
    this.price = price
  }

  public cost() {
    return this.price
  }

  public getName() {
    return this.name
  }
}
ts

코드와 세 가지 관점

  • 개념 관점, 명세 관점, 구현 관점에서 보자
  • 하나의 클래스 안에 세 가지 관점을 모두 포함하면서도 각 관점에 대응되는 요소를 깔끔하게 드러내야 한다

  • 개념 관점(도메인)
    • 구현된 클래스들은 도메인을 구성하는 중요한 개념과 관계를 반영한다
    • 소프트웨어 클래스와 도메인 모델 사이의 간격이 좁으면 좁을수록 가능을 변경하기 쉽다
  • 명세 관점(인터페이스)
    • 클래스의 public 메서드는 다른 클래스가 협력하 수 있는 공용 인터페이스를 드러낸다
    • 인터페이스는 수정하기 어렵다
    • 변화에 안정적인 인터페이스를 만들기 위해서는 인터페이스를 통해 구현과 관련된 세부 사항이 드러나지 않게 해야 한다
  • 구현 관점(캡슐화)
    • 클래스의 메서드와 속성은 구현에 속하며 공용 인터페이스의 일부가 아니다
    • 따라서 메서드의 구현과 속성의 변경은 원칙적으로 외부 객체에 영향을 미쳐서는 안 된다