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

Created
Jul 3, 2020 12:20 PM
Tags
루비로 배우는 객체지향 디자인 - 1~2장

3장 - 의존성 관리하기

  • 잘 디자인된 객체는 하나의 책임만 지고 있기 때문에 객체가 복잡한 작업을 수행하기 위해서 다른 객체와 혀법하지 않을 수 없다.
    • 이 협업은 매우 강력하지만 동시에 위험하다. 협업을 위해 다른 객체에 대한 지식이 있어야 하기 때문이다.
    • 그 지식은 의존성을 만들어낸다.
  • 객체는 다음과 같은 내용을 알고 있을 때 의존성을 갖는다.
    • 다른 클래스의 이름
    • 자기 자신을 제외한 다른 객체에게 전송할 메세지의 이름
    • 메세지가 필요로 하는 인자들
    • 인자를 전달하는 순서
  • 위의 의존성을 하나의 객체가 변경될 때 어쩔 수 없이 다른 객체도 수정되어야 한다는 것을 의미한다. 이 의존성이 불필요하다면, 코드가 덜 적절해진다.
  • 둘 이상의 객체가 강하게 결합되어 있을 때 이들은 한 덩어리로 움직이고 별도로 재사용하는 것이 불가능해진다. 그러니 약하게 결합된 코드를 작성해보자.
// 지난 시간의 Gear 클래스 class Gear { constructor(chainring, cog, rim, tire) { this.chainring = chainring; this.cog = cog; this.rim = rim; this.tire = tire; } get gearInches() { return this.ratio * new Wheel(rim, tire).diameter; } // ... } new Gear(52, 11, 26, 1.5).gearInches;
  • 겉으로 보기에 별 문제 없어보이지만 GearWheel 에게 말을 걸려면 일단 Wheel 클래스의 인스턴스를 만들어야 한다. 변경 사항이 발생하면 Wheel 을 다른 것으로 바꾸기만 하면 될까?
    • 위의 코드는 GearWheel 외의 다른 객체와 협업하기를 거부하고 있는 것이다.
  • 위의 코드는 고정된 타입에 불필요하게 들러붙어 있는 클래스에 얼마나 문제가 많은지 보여준다. 중요한 것은 '객체의 클래스가 무엇인지' 가 아니라 '우리가 전송하는 메세지가 무엇인지' 이다.
class Gear { constructor(chainring, cog, wheel) { this.chainring = chainring; this.cog = cog; this.wheel = wheel // 어느 클래스의 인스턴스인지는 Gear가 알바 아니다. } get gearInches() { return this.ratio * this.wheel.diameter } // ... } new Gear(52, 11, new Wheel(26, 1.5)).gearInches;
  • 이렇게 수정한 덕분에 Geardiameter 를 구현하고 있는 어떤 객체와도 협업할 수 있게 되었다.
  • 불필요한 의존성을 이렇게 제거할 수 없는 경우라면 클래스 안에 격리시켜 놓아야 한다.
    • constructor 안에서 의존성 있는 클래스의 인스턴스를 생성하거나
    • 명시적으로 정의된 메서드를 통해 의존성 있는 클래스의 인스턴스를 만들 수 있다.
class Gear { constructor(chainring, cog, rim, tire) { this.chainring = chainring; this.cog = cog; this.wheel = Wheel.new(rim,tire) } get gearInches() { return this.ratio * this.wheel.diameter } // ... } class Gear { constructor(chainring, cog, rim, tire) { this.chainring = chainring; this.cog = cog; this.rim = rim this.tire = tire } get gearInches() { return this.ratio * this.getWheel().diameter } getWheel() { return this.wheel || new Wheel(this.rim, this.tire) } // ... }
  • 의존성을 오히려 감추기보다 뚜렷하게 드러내어 재사용이 수월하고, 리팩터링이 가능한 상황일 때 쉽게 수정할 수 있도록 만들었다. 코드가 좀 더 유연해졌고 미래의 변화에 더 쉽게 적응할 수 있도록 해 준 것이다.
  • 의존성을 주입하는 코딩 습관을 들일 때 클래스는 자연스럽게 덜 결합된 형태를 띈다. 이런 클래스는 새로운 요구사항을 받아들이기 쉽다.
  • 그런데 만약 gearInches 메서드가 더 복잡해지면?
get gearInches() { // 무시무시한 수학 공식 몇 줄 const foo = someIntermediateResult * this.wheel.diameter; // 무시무시한 수학 공식 추가 }
  • this.wheel.diameter 는 복잡한 다른 연산에 의해 묻혀버렸는데, 결국 this.wheel 은 외부에 대한 의존을 표현하고 있기 때문에 gearInches 를 변화에 취약하게 만들고 있다. 그 와중에 내부가 복잡하다.
  • 이럴 때 외부의 의존성을 내부의 메서드로 캡슐화하여 변화에 대한 부담을 완화할 수 있다.
get gearInches() { // 무시무시한 수학 공식 몇 줄 const foo = someIntermediateResult * this._getDiameter(); // 무시무시한 수학 공식 추가 } _getDiameter() { return this.wheel.diameter; }
  • 원래 gearInches 의 코드는 this.wheeldiameter 를 가지고 있다는 사실을 알고 있었다. 이로 인해 외부 객체와 그 객체의 메서드들에 결합시켜버리는 위험한 의존성이 태어나고 말았다. 수정 이후에는 Wheel 이 diameter 의 이름이나 내부 구현이 바뀐다 하더라도 Gear 클래스에 미치는 영향은 래퍼 메서드에 한정될 것이다.
    • 클래스 안에서 변하기 쉬운 메세지를 참조하고 있는 경우 이 기술을 유용하게 사용할 수 있다.
    • 참조하는 지점을 격리시키는 것은 변화로부터 조금 더 안정적인 대응이 가능하도록 만든다.
    • 모든 경우에 선제적으로 대응할 필요는 없지만 가장 위험해 보이는 부분을 찾아서 감싸는 작업은 시도해 볼만하다.
  • 메세지와 함께 인자를 전송해야 할 경우 인자에 대한 지식이 필요한데, 이 의존성은 피해갈 수 없지만 더한 의존성으로 '인자의 전달 순서' 라는 의존성이 생겨버린다. 인자들의 순서가 변하면 모든 송신자 역시 이 순서에 맞게 수정되어야 하기 때문이다.
    • 잘못하면 인자들을 변경하지 않으려 할 수도 있다. 더 나은 디자인을 위해서 인자를 변경해야 하겠지만 엮여있는 의존성을 같이 변경해주고 싶지 않기 때문이다.
  • 인자의 순서 문제를 해결하기 위해 아래의 방법을 시도할 수 있다.
    • 객체 리터럴 형식으로 인자를 전달한다.
    • 인자의 기본값을 할당한다.
class Gear { constructor({ chainring = 40, cog = 18, rim, tire }) { this.chainring = chainring; this.cog = cog; this.rim = rim; this.tire = tire; } // ... } new Gear({ chainring: 52, cog: 11, rim: 26, tire: 1.5 });
  • 하지만 우리가 메서드를 수정할 수 없는 상황이라면? 예를 들어 외부 프레임워크의 한 부분이라던가.
    • 해당 메서드를 감싸서 우리가 원하는 방식대로 인자를 받을 수 있도록 처리해주는 래퍼 모듈을 만든다.
    • 외부에 대한 의존성이 코드 속으로 스며들게 내버려두지 말고 애플리케이션이 직접 통제할 수 있도록 만드는 것이다.
// 외부 모듈의 구현체 예 export class ExternalGear { constructor(chainring, cog, rim, tire) { this.chainring = chainring; this.cog = cog; this.rim = rim; this.tire = tire; } // ... } // 이걸 실제로 가져다 쓴다면... import { ExternalGear } from "some-framework"; const externalGearFactory = ({ chainring, cog, rim, tire }) => new ExternalGear(chainring, cog, rim, tire); externalGearFactory({ chainring: 52, cog: 11, rim: 26, tire: 1.5, });
  • 지금까지의 예시는 GearWheel 이나 diameter 에 의존하고 있었다. 의존성의 방향을 바꾸는 것도 해법의 하나가 될 수 있다.
class Gear { constructor(chainring, cog) { this.chainring = chainring; this.cog = cog; } getGearInches(diameter) { return this._ratio * diameter; } get _ratio() { return this.chainring / this.cog; } } class Wheel { constructor(rim, tire, gear) { this.rim = rim; this.tire = tire; this.gear = gear; } get diameter() { return this.rim + this.tire * 2; } get gearInches() { return this.gear.getGearInches(this.diameter); } } const gear = new Gear(52, 11); new Wheel(26, 1.5, gear).gearInches;
  • 당장에는 큰 차이가 없어 보일지라도 우리가 선택한 의존성의 방향은 이후 애플리케이션의 발전 과정에 뚜렷한 족적을 남긴다. 지금 올바른 선택을 내린다면 유지보수하기 쉽고 작업하기 좋은 애플리케이션이 되겠지만, 잘못된 결정을 내린다면 의존성은 점점 애플리케이션을 집어 삼키고 갈수록 더 수정하기 힘든 애플리케이션이 될 것이다.
  • 그런데 의존성의 방향은 어떻게 결정해야 하는가? → "자기 자신보다 덜 변하는 것에 의존하라"
    • 어떤 클래스는 다른 클래스에 비해 요구사항이 더 자주 바뀐다.
      • 언어의 표준 라이브러리 같이 변경될 가능성이 적은 것이 있는가 하면, 우리가 의존하고 있는 프레임워크의 코드가 개발 단계라면 자주 바뀔 가능성도 있다.
      • 애플리케이션에 사용되는 모든 클래스를 '다른 클래스와 비교하여 얼마나 변경되지 않는지' 를 기준으로 순위를 매겨볼 수도 있다. 이 순위가 의존성의 방향을 결정하는데 핵심 지표가 된다.
    • 구체 클래스는 추상 클래스보다 수정해야 하는 경우가 잦다.
      • 코드의 구체성과 추상성을 이해할 필요가 있따.
      • 예를 들어 이전의 GearWheel 없이는 쓸 수 없는 클래스였지만, WheelGear 에 주입하는 방식으로 코드를 수정하면서 훨씬 추상적인 것에 의존하게 되었다. Geardiameter 메시지에 반응하기만 하면 어떤 객체든 상관 없게 된 것이다. 이렇게 구체적인 것을 분리하여 추상화할 수 있다.
      • 추상화된 인터페이스는 인터페이스가 기반하고 있던 구체 클래스보다 변경될 일이 훨씬 적다.
    • 의존성이 높은 클래스를 변경할 때 코드의 여러 곳에 영향을 미친다.
      • 작은 수정 하나 때문에 애플리케이션 전체를 뜯어고치게 만드는 클래스가 있다는 사실 자체가 코드를 수정하고 싶지 않게 만든다.
    • 위의 요소들을 종합적으로 고려했을 때 '자기 자신보다 덜 변하는 것들에 의존하라' 라는 격언이 등장한다.
  • 의존성 관리의 핵심은 그 방향을 관리하는 것이다. 그리고 그 방향은 자기 자신보다 덜 변하는 것에 의존하는 형태로 가야 한다.
루비로 배우는 객체지향 디자인 - 4장