루비로 배우는 객체지향 디자인 - 5장

Created
Jul 7, 2020 01:20 PM
Tags
루비로 배우는 객체지향 디자인 - 4장
💡
참고: 타입스크립트는 기본적으로 구조적 타이핑을 지원하기 때문에 엄밀히 따지면 오리 타입을 지원한다고 할 수 있다. 오리 타입을 활용하면 컴파일러가 철저하게 타입을 검사하는 것을 상쇄한다고 볼 수도 있으나, 상황에 따라 유연한 인터페이스를 구성하는데 사용할 수 있을 것이다. (일단 예제는 기존처럼 JS 로 작성)

오리 타입으로 비용 줄이기

  • 오리 타입은 특정 클래스에 종속되지 않는 퍼블릭 인터페이스이다. 여러 클래스를 가로지르는 값비싼 의존성을 메세지를 활용하는 부드러운 의존성으로 대치시킨다. 그리고 애플리케이션을 굉장히 유연하게 만들어준다.
    • 오리 타입 객체는 객체의 클래스보다는 행동에 의해 규정되는 카멜레온이다.
    • "객체가 오리처럼 꽥꽥대고 오리처럼 걷는다면 이 객체는 오리가 맞다. 객체의 클래스는 중요치 않다."
  • 클래스는 객체가 퍼블릭 인터페이스를 갖추기 위한 하나의 수단일 뿐이다.
    • 객체가 클래스를 통해 얻게 된 퍼블릭 인터페이스는 이 객체가 가진 여러 개의 퍼블릭 인터페이스 중 하나에 불과할 수 있다.
    • 애플리케이션은 특정 클래스에 종속되지 않은 퍼블릭 인터페이스를 정의할 수 있고, 이런 인터페이스는 여러 클래스 사이를 관통하며 존재한다.
    • 진짜 중요한 것은 객체가 무엇인가가 아니라 어떻게 행동하는가이다.
  • 모든 상황에서 모든 객체가 예상한 바대로 움직인다고 믿을 수 있다면, 그리고 모든 객체가 어떤 타입이든 될 수 있다고 믿을 수 있다면 디자인의 무한한 가능성이 열릴 것이다.
    • 유연한 디자인을 만드는데 이용될 수도 있고, 반대로 끔찍한 혼란을 낳을 수도 있다.
    • 현명하게 사용하기 위해서, 오리 타입은 명시적이고 잘 정리된 계약서와 같은 퍼블릭 인터페이스를 가지고 있어야 한다.
  • 안좋은 예부터 발전시켜나가기
class Trip { constructor({ bicycles, customers, vehicle }) { this.bicycles = bicycles; this.customers = customers; this.vehicle = vehicle; } // ... prepare(mechanic) { mechanic.prepareBicycles(this.bicycles); } // ... } class Mechanic { prepareBicycles(bicycles) { bicycles.forEach((bicycle) => this.prepareBicycle(bicycle)); } prepareBicycle(bicycle) { // ... } }
  • 현재로썬 반드시 Mechanic 클래스의 인스턴스여야만 Trip#prepare 를 실행할 수 있는 상황에 가깝다. 그런데 prepare 메서드를 실행할 때 각자의 준비를 할 수 있는 객체들이 늘어나는 경우라면?
class Trip { constructor({ bicycles, customers, vehicle }) { this.bicycles = bicycles; this.customers = customers; this.vehicle = vehicle; } // ... prepare(preparers) { preparers.forEach((preparer) => { if (preparer instanceof Mechanic) { preparer.prepareBicycles(this.bicycles); } else if (preparer instanceof TripCoordinator) { preparer.buyFood(this.customers); } else if (preparer instanceof Driver) { preparer.gasUp(this.vehicle); preparer.fillWaterTank(this.vehicle); } }) } // ... } class TripCoordinator { buyFood(customers) { //... } } class Driver { gasUp(vehicle) { // ... } fillWaterTank(vehicle) { // ... } }
  • 우리가 구현한 디자인은 클래스에 종속적인데, 우리가 전송하는 메세지를 이해하지 못하는 클래스를 다뤄야 하는 상황에 봉착하면 새로운 객체가 이해할 수 있는 메세지를 찾아 헤메야한다.
    • 예시는 instnaceof 를 사용하여 클래스를 구분하여 올바른 메세지를 전송하고 있는데, 이 경우 의존성이 어떻게 엮이게 되는지 보일 것이다.
    • 그리고 새로운 preparer 가 추가될 수록 if 절은 늘어날 것이다.
  • 이런 의존서을 제거하기 위해서는 한 가지 중요한 사실을 이해해야 한다.
    • Trip#prepare 메서드는 '하나의 목적' 을 갖고 있기 때문에 그 인자 역시 목적을 이루기 위해 협업하는 객체라는 것이다.
    • 인자가 주어진 작업을 할 수 있다고 prepare 메서드가 믿기만 하면 디자인은 훨씬 간단해질 것이다.
class Trip { constructor({ bicycles, customers, vehicle }) { this.bicycles = bicycles; this.customers = customers; this.vehicle = vehicle; } // ... prepare(preparers) { preparers.forEach((preparer) => { preparer.prepareTrip(this); }); } // ... } class Mechanic { prepareTrip(trip) { trip.bicycles.forEach((bicycle) => this.prepareBicycle(bicycle)); } prepareBicycle(bicycle) { // ... } } class TripCoordinator { prepareTrip(trip) { this.buyFood(trip.customers); } buyFood(customers) { //... } } class Driver { prepareTrip(trip) { const vehicle = trip.vehicle; this.gasUp(vehicle); this.fillWaterTank(vehicle); } gasUp(vehicle) { // ... } fillWaterTank(vehicle) { // ... } }
  • prepareTrip 을 구현하고 있는 객체가 Preparer 이다. 반대로 이야기하면 Preparer 와 협업하는 객체는 이 객체들이 Preparer 의 퍼블릭 인터페이슬르 구현하고 있다고 믿어야 한다. 이 추상화를 이해하고 나면 코드를 수정하는 것은 매우 수비다.
    • 코드를 이해하기 위해 조금 더 노력해야 하지만 대신 손쉬운 확장성을 제공한다.
    • 객체지향 디자인은 구체적인 코드를 작성하는 비용과 추상적인 코드를 작성하는 비용 사이의 긴장에서 결코 자유로울 수 없다. 구체적인 코드는 이해하기 쉽지만 확장비용이 높고, 추상적인 코드는 처음에는 불명확해 보이지만 한번 이해하고 나면 수정하기 훨씬 쉽다.
    • 오리 타입을 사용하면 코드는 구체적인 것이서 추상적인 것으로 바뀐다. 확장하기 쉬워지지만 그 아래 숨겨진 클래스르 파악하는데 더 많은 노력을 기울여야 한다.
    • 객체를 클래스에 의해 정의된 것이 아니라 그 행동을 통해 정의된 것으로 이해하기 시작할 때 우리는 표현력 있고 유연한 디자인의 새로운 세계에 발을 딛을 수 있다.
  • 폴리모피즘
    • 객체지향 프로그래밍에서 사용하는 '폴리모피즘'은 같은 메세지에 반응할 수 있는 여러 객체의 능력을 의미한다.
    • 메세지의 송신자는 수신자의 클래스를 신경 쓸 필요가 없다. 수신자는 주어진 행동에 걸맞은 자신만의 행동을 제공한다.
    • 폴리모피즘을 구현하기 위한 방법은 여러가지가 있지만 오리 타입도 그 중 하나다. (상속 등의 방법은 다른 장에서)
  • 오리 타입이 적용 가능한지 어떻게 알아볼 수 있을까?
    • 특정 클래스의 인스턴스인지 검사하고 있는가?
    • 루비도 다양한 방법으로 확인할 수 있고, JS 는 instanceof 등으로 (ES6 클래스에 적용되는 어떤 속성이 있었는데 기억이 나지 않는다. 아는 분은 제보 부탁드립니다.) 인스턴스의 타입을 검사하고 있다면 오리 타입이 숨겨져 있다는 사실을 알려준다.
// 예시 1 if (preparer instanceof Mechanic) { preparer.prepareBicycles(this.bicycles); } else if (preparer instanceof TripCoordinator) { preparer.buyFood(this.customers); } else if (preparer instanceof Driver) { preparer.gasUp(this.vehicle); preparer.fillWaterTank(this.vehicle); } // 예시 2 if (preparer.hasOwnProperty('prepareBicycles')) { // ... } else if (preparer.hasOwnProperty('buyFood')) { // ... } // ...
  • 위의 모든 경우 코드는 이렇게 말하고 있다: "나는 네가 누구인지 알고 있고, 그렇기 때문에 네가 무엇을 하는지도 알고 있다."
    • 협업하는 객체에 대한 믿음이 부족하고, 협업하는 객체가 운신할 수 있는 폭을 줄인다. 결국 코드를 수정하기 어렵게 만드는 의존성을 불러온다.
    • 이런 스타일의 코드는 어떤 객체를 하나 놓치고 있다는 사실을 말해준다. 실제로는 이 객체가 구체 클래스가 아니라 오리 타입이지만, 그 사실은 전혀 중요하지 않다.
    • 중요한 것은 발굴하지 못한 인터페이스가 있다는 점이지 이 인터페이스를 구현하고 있는 클래스가 아니다.
    • 위에서 보이는 패턴을 발견했다면, 문제가 있는 코드가 무엇을 원하는지 살펴보자. 그리고 그 코드가 원하는 바를 이용하여 오리 타입을 찾아야 한다. 일단 오리 타입을 머릿속에 그려낼 수 있다면 그 인터페이스를 정의하자.
    • 이 인터페이스를 필요로 하는 곳에 인터페이스를 구현하고 인터페이스를 구현하고 있는 객체들이 제대로 행동하리라 믿어야 한다.
  • 오리 타입의 문서화는 좋은 테스트로 대체한다고 할 수 있는데, 이번 장에서는 다루지 않는다.
  • 정적 타입을 사용하는 사람에게
    • 동적 언어를 두렵게 느끼는 프로그래머들은 객체의 클래스를 확인하는 습관이 있다. 이런 확인 자체가 동적 언어의 힘을 반감시키고 오리 타입을 사용할 수 없도록 만들어 버린다.
  • 정적 타입에는 아래와 같은 이점이 있을 것이다. 각각의 상응하는 가정도 있다.
    • 컴파일 시점에 컴파일러가 타입 에러를 잡아낼 수 있다. → 컴파일러가 타입을 확인하지 않으면 런타임 타입 에러가 발생할 것이다.
    • 눈에 보이는 타입 정보가 문서의 역할을 한다. 프로그래머는 전체 맥락에서 객체의 타입을 추측할 수 없고, 코드를 이해하지 못할 것이다.
    • 컴파일된 코드는 빠르게 동작할 수 있도록 최적화되어 있다. 이러한 최적화를 거치지 않으면 애플리케이션이 너무 느릴 것이다.
  • 이후의 내용은 동적 타입을 받아들이는 것에 대한 저자의 설명이라 생략하지만, 몇 구절만 바로 인용
    • 컴파일러는 의도치 않은 타입 에러로부터 우리를 구해주지 못한다. 변수의 타입 변형(casting)을 지원하는 모든 언어는 타입 에러의 위험에 노출되어 있다. 타입 변형을 사용하는 순간 모든 확신은 사라진다.
    • 코드는 우리가 작성한 테스트만큼 믿을 수 있을 뿐이고 런타임 실패는 여전히 발생할 수 있다.
  • 다시 한번 강조: '객체가 누구인지' 가 아니라 '객체가 무엇을 하는지' 를 살펴볼 것