루비로 배우는 객체지향 디자인 - 1~2장

Created
Jul 1, 2020 02:24 PM
Tags

1장 - 객체지향 디자인


  • 변화를 주기 힘든 애플리케이션은 다루기 어렵다. 그런데 왜 수정이 어려운가?
    • 하나의 객체가 다른 객체를 너무 많이 알고 있기 때문. 뭐 하나 수정하려 할 때 협업 객체와 그 협업 객체의 협업 객체를 수정하는 등
    • 너무 많은 것을 알아버린 객체는 변화를 주기 어렵고, 재사용 및 테스팅이 어렵다.
    • 그렇다고 개발자가 미래를 예측하라는 것이 아니라, ‘언젠가 무언가는 변한다’, ‘지금 무엇이 변경될지 알 수 없다’ 라는 사실을 받아들이는 것이다. 즉 변화하고 움직일 수 있는 여지를 남겨 놓아 변화의 비용을 최소화한다.
  • 디자인 원칙들
    • SOLID
    • DRY
    • LoD
  • 디자인 패턴?: 패턴을 익히는 것은 좋지만 잘못 적용되면 오히려 혼란스러운 코드를 낳게 된다. 사용법을 완벽하게 익힐 필요가 있다. (이 책에서는 다루지 않는다.)
  • 언제 디자인을 해야하는가?
    • BUFD(Big Up Front Design, 커다란 것을 먼저 구상하는 디자인)은 그닥 맞는 방법이 아니다.
    • 애자일하게. “코드를 손쉽게 수정하려면 코드를 어떻게 배치해야 하는가?” 에 관심을 가진다.
  • 소프트웨어의 질을 측정하는 궁극적인 기준?: 주어진 시간 안에서 기능별 구현 비용 (실제로 계산하기 쉽진 않음)
    • 이를 위해 디자인에 시간이 필요하고, 결국 돈이 드는 것이다. 이 때 두 가지 고려 사항이 있는데:
      • 우리의 기술 수준
      • 우리에게 주어진 시간
  • 디자인에 투자한 시간으로 본전이라도 뽑으려면 디자인 이론을 이해하고 적절히 적용할 수 있어야 한다. 올바른 순간에 필요한 만큼 적용할 줄 알아야 한다는 것이다.
 

2장 - 단일 책임 원칙을 따르는 클래스 디자인하기


  • “클래스는 단순해야 한다” 라는 말을 명심하기
    • 지금 당장 해야 할 일을 할 줄 알고, 나중에도 쉽게 수정할 수 있는 클래스 모델링하기
  • 수정하기 쉽다?
    • 수정이 예상치 못한 부작용을 낳지 않는다.
    • 요구사항이 조금 변했을 때 연관된 코드를 조금만 수정하면 된다.
    • 현재 코드를 다시 사용하기 쉽다.
    • 코드를 수정하는 가장 쉬운 방법은 이미 수정하기 쉬운 코드에 새로운 코드를 추가하는 것이다.
  • 그렇다면 이런 특징이 있어야 한다.
    • Transparent: 수정의 결과가 뚜렷하게 드러나야 한다.
    • Reasonable: 수정 비용은 수정 결과를 통해 얻은 이득에 비례해야 한다.
    • Useable: 예상치 못한 상황에서도 현재 코드를 사용할 수 있어야 한다.
    • Exemplary: 코드 자체가 나중에 수정하는 사람에게 모범이 되어야 한다.
  • 코드 예시 - Gear
// 단순한 Gear 클래스가 class Gear { constructor(chainring, cog) { this.chainring = chainring; this.cog = cog; } get ratio() { return this.chainring / this.cog; } } // 기능이 추가되면서 복잡해지기 시작한다. Gear 는 정말로 타이어에 대해 알아야할까? class Gear { constructor(chainring, cog, rim, tire) { this.chainring = chainring; this.cog = cog; this.rim = rim; this.tire = tire; } get ratio() { return this.chainring / this.cog; } get gearInches() { const { ratio, rim, tire } = this; return ratio * (rim + tire * 2); } }
  • 한개 이상의 책임이 있는 클래스는 재사용이 어렵다. 이럴 때 원하는 행동만을 가져오도록 코드를 복붙하는 하는 방법이 있다.
    • 하지만 중복 코드는 유지보수를 어렵게 하고 버그를 생성하거나, 하나의 문제를 다른 문제로 가리는 수준에 지나지 않는다.
  • 하나의 클래스가 다른 클래스의 책임까지 짊어지고 있는지 어떻게 아나?
    • 클래스를 인격이 있는 존재처럼 가정하고 질문을 던져보는 방법
      • “Gear 씨, 당신의 기어 인치는 몇인가요?” (?) / “Gear 씨, 당신의 타이어 높이는 무엇인가요?” (X)
    • 클래스의 책임을 한 문장으로 만들어 보는 것
  • 클래스는 최대한 작으면서도 유용한 것만 행해야 한다는 것을 기억하자.
  • 응집력: 클래스 안의 모든 것들이 하나의 핵심 목표와 연관되어 있다. -> 하나의 책임을 가지고 있다(단일 책임 원칙).
  • 다른 객체와의 의존성이 생긴다면 Gear 클래스는 투명함과 적절함을 잃게 된다. 바로 이 순간 코드 재구성을 해야 한다. 새로운 의존성이 좋은 디자인을 결정하기 위한 정보를 제공해 준다.
  • 코드가 잘못된 의도를 전달하고 있다면 다른 개발자가 오해하고 잘못된 의도를 전파하지 않도록 명시적으로 알려주어야 한다.
  • 데이터(data)가 아니라 행동(behavior)에 기반한 코드를 작성하라
    • 인스턴스 변수를 숨기고 getter 를 만들기: getter 를 구현하여 ‘여러 곳에서 참조하고 있는 데이터’ 를 ‘단 한번만 정의된 행동’ 으로 바꾸었다.
  • 데이터를 ‘메세지를 이해하는 객체’ 처럼 취급할 때 얻게 되는 것
    • 가시성 (다른 챕터에서 좀 더 이야기됨)
    • 데이터와 객체 사이의 구분을 무의미하게 만듦: 대부분의 경우는 데이터를 그냥 일반적인 객체인 것처럼 이해해버리는 편이 낫다.
    • 개발자 자신으로부터 데이터를 감추는 편이 좋다.
      • 예상치 못한 변화로부터 코드를 보호할 수 있다.
      • 개발자도 데이터의 모든 행동을 다 모를 수도 있다.
  • 데이터 구조를 숨기자
    • // 클래스 구현체는 심플한데 들어가야 하는 데이터가 무조건 2차원 배열이라면? class ObscuringReferences { constructor(data) { this.data = data; } // cell 이 뭔줄 알고? get diameters() { return this.data.map((cell) => cell[0] + cell[1] * 2); } } console.log( new ObscuringReferences([ [622, 20], [622, 23], [559, 30], [559, 40], ]).diameters );
    • 데이터 구조가 바뀌면 코드도 변경되어야 하고, 애플리케이션 곳곳에서 배열이라는 자료구조에 대한 지식을 코드 곳곳에 흩뿌리게 되는 결과를 낳게 된다. 전혀 DRY 하지 않다.
    • 루비라면 Struct를 이용하여 데이터 구조를 감쌀 수 있다. 다른 언어를 사용한다면 별도의 데이터 클래스를 만들 수도 있겠다만, JS/TS 는 아직 그런게 없다. 알아서 만들자.
    • // 임의의 Struct. 디버깅을 위해서는 클래스로 선언하는게 더 나을지도. const Wheel = (rim, tire) => ({ rim, tire }) class RevealingReferences { constructor(data) { this.wheels = this._wheelify(data) } // 이제 이 getter 는 2차원 배열의 내부 구조에 대해 전혀 알 필요가 없다. get diameters() { return this.wheels.map(wheel => wheel.rim + (wheel.tire * 2)) } // data 의 구조에 대한 지식은 여기다 격리 _wheelify(data) { return data.map(([rim, tire]) => Wheel(rim, tire)) } } console.log( new RevealingReferences([ [622, 20], [622, 23], [559, 30], [559, 40], ]).diameters );
    • 이런 스타일의 코드는 외부 데이터 구조의 변화로부터 코드를 보호해주고, 보다 읽기 좋고 의미가 잘 드러나는 코드를 작성할 수 있게 해준다.
    • '데이터 구조를 들여다보는 작업' 을 '객체에 대한 메세지를 전송' 하는 것으로 대체한 것이다.
  • 모든 곳에서 단일 책임 원칙을 강제하라.
    • 클래스처럼 메서드도 하나의 책임만을 져야 한다.
    • 개별 객체에 행해지는 액션과 객체들을 나열하는 활동이 결합되어 있는 것은 쉽게 발견할 수 있는 중복 책임의 예이다.
    • // 아까의 diameters getter에서 get diameters() { return this.wheels.map((wheel) => this._getDiameter(wheel)); } _getDiameter(wheel) { return wheel.rim + wheel.tire * 2; }
  • 최종 디자인을 모르는데도 이런 식의 리팩터링을 해야 하나? 필요하다. 하지만 다음 단계의 디자인은 알고 있어야 한다.
    • 리팩터링은 디자인이 명확하기 때문에 필요한 것이 아니라 오히려 불명확하기 때문에 필요한 것이다. 좋은 습관이 좋은 디자인을 낳는다.
    • 여러 메서드가 각각 하나의 책임을 질 때 얻을 수 있는 이득
      • 예전에 몰랐던 특성이 드러난다. 클래스 자체도 명확하게 드러난다.
      • 주석을 넣어야 할 필요가 없어진다(혹은 줄어든다).
      • 재사용을 유도한다.
      • 다른 클래스로 옮기기 쉽다.
  • 클래스의 추가적인 책임 격리시키기
    • 아까 `Gear` 클래스로 돌아가서, 하나의 책임만 지게 하기 위해 자기 자신 외의 행동을 걷어내야 할 것이다. 그 전에 일단 내부에서 격리를 해 보자.
    • class Gear { // 좀 못생겼지만 JS에서도 이렇게 inner class 가 되긴 된다. static Wheel = class { constructor(rim, tire) { this.rim = rim; this.tire = tire; } get diameter() { return this.rim + (this.tire * 2); } }; constructor(chainring, cog, rim, tire) { this.chainring = chainring; this.cog = cog; this.wheel = new Gear.Wheel(rim, tire); } get ratio() { return this.chainring / this.cog; } get gearInches() { const { ratio, wheel } = this; return ratio * wheel.diameter; } }
    • 너무 많은 책임을 가지고 있는 클래스가 있다면 다른 클래스 속으로 그 책임을 분리해주고 핵심 클래스에 집중하자.
    • 클래스의 책임이 무엇인지 결정하고 그 결정을 꼼꼼히 체크하자.
    • 아직 제거하기 어려운 추가적인 책임은 격리시켜라.
    • // 최종본 class Gear { constructor(chainring, cog, wheel) { this.chainring = chainring; this.cog = cog; this.wheel = wheel; } get ratio() { return this.chainring / this.cog; } get gearInches() { const { ratio, wheel } = this; return ratio * wheel.diameter; } } class Wheel { constructor(rim, tire) { this.rim = rim; this.tire = tire; } get diameter() { return this.rim + this.tire * 2; } get circumference() { return this.diameter * Math.PI(); } } const wheel = new Wheel(26, 1.5); console.log(new Gear(52, 11, wheel).gearInches);