공부하는 블로그

#9 좋은 디자인 = 유연한 소프트웨어 : 변하지 않는 것은 없다 (2) 본문

design patterns

#9 좋은 디자인 = 유연한 소프트웨어 : 변하지 않는 것은 없다 (2)

devtimothy 2019. 4. 5. 16:31

#9 좋은 디자인 = 유연한 소프트웨어 : 변하지 않는 것은 없다 (2)

본 블로깅은 Head first OOAD: 세상을 설계하는 객체지향 방법론 (한빛미디어) 책을 Typescript 문법으로 전환하며 공부하는 글입니다.
글을 읽기 전에, 광고 배너 한번씩만 클릭 부탁드립니다. 블로그 운영에 큰 보탬이 됩니다 :)

프로그램을 변경할 때마다 모든 코드의 부분을 뜯어고치는 일은 굉장히 짜증나고 성가신 일이다. 나는 제왕적 사고방식에 찌들어 있던 사람이다보니, 이런 일이 빈번했다. 그러던 중 소프트웨어를 유연하고 튼튼하게 만들 방법은 없을까? 하는 생각이 든다. 객체지향이 어떻게 프로그램을 유연하게 만들까? 어떻게 높은 응집도가 결합도에 도움이 되는지도 이 글을 통해서 공부해본다.

악기 검색 프로그램을 돌아가보자

코드에서 몇가지 문제를 생각해본다.

  • addInstrument()는 악기 타입별로 해당되는 코드가 있어서 새로운 악기 서브클래스 추가시마다 코드를 수정해야 함
  • 각 Instrument 마다 search() 메서드가 있음.
  • 악기가 추가될때마다 그에 대한 클래스와 코드를 추가해야 함

어떻게 해결하면 좋을까?

InstrumentSpec을 인자로 받는 search() 메소드를 만든다. 다양한 악기를 반환할 수 있도록 하는 것이다. 그리고 InstrumentSpec은 더 이상 추상클래스가 아닌 일반 클래스로 만들도록 한다. 이는 구현 클래스가 아닌 인터페이스에 초점을 두기 위함이다.

우선 Inventory 클래스를 수정해보자.

export const Inventory = class {
  private _inventory: any[];
  constructor() {
    this._inventory = new Array<Guitar>();
  }
  addInstrument(
    serialNumber: string,
    price: number,
    spec: InstrumentSpec
  ): void {
    let instrument: Instrument = null;
    if (spec instanceof GuitarSpec) {
      instrument = new Guitar(serialNumber, price, spec as GuitarSpec);
    } else if (spec instanceof MandolinSpec) {
      instrument = new Mandolin(serialNumber, price, spec as MandolinSpec);
    }
    this._inventory.push(instrument);
  }
  public get(serialNumber: string): Instrument {
    return this._inventory.filter(
      (item: Instrument): boolean => item.serialNumber == serialNumber
    )[0];
  }
  public search(searchSpec: InstrumentSpec): Instrument[] {
    return this._inventory.filter(
      (item: InstrumentSpec): boolean => {
        return searchSpec.matches(item);
      }
    );
  }
};

왜 Instrument 클래스가 필요할까? 대부분의 악기들이 일련번호, 가격 같은 공통된 속성들이 몇몇 존재한다. Instrument는 공통의 속성들을 저장하고, 특정 악기 타입들은 Instrument를 상속한다. 상속된 악기들은 다른 Spec 을 가지고 있기 때문에 각 악기는 다른 생성자를 갖는다.

보통 서브 클래스를 만드는 이유는 서브 클래스의 행동이 슈퍼 클래스와 다르기 때문이다. 이 프로그램에서 Guitar의 행동이 Instrument의 행동과 다른가? 사실 그놈이 그놈이다. (악기의 연주 방식을 표현했다면 다를 수 있겠지만)

그렇다면 어떻게 만드는 게 좋을까. 속성들은 다르지만, 각 악기마다 서브클래스를 만드는 일이 맞는 일일까? 객체지향 원리를 살펴보며 디자인 개선을 해보자.

  1. 상속: Instrument와 InstrumentSpec 클래스, 그리고 서브 클래스들에 이미 상속을 사용함. 하지만 Instrument 클래스의 서브 클래스 상속 말고는 하는일 없음.
  2. 다형성: search() 메소드에서 객체에 관계없이 사용. addInstrument 도 적용되면 좋을 듯
  3. 추상화: InstrumentSpec은 새로운 속성을 Instrument 클래스에 영향 주지 않고 추가할 수 있게 함. 각 악기의 세부 사항들을 instrument 클래스 자신으로부터 추상화함.
  4. 캡슐화: 캡슐화를 많이 사용하지만, 더 사용할 수 있을 듯. 변경되는 것들은 캡슐화한다. 악기에서 변하는 것들은 속성들인데, Instrument와 InstrumentSpec으로부터 완전히 분리해서 캡슐화 할 방법이 있을까?

악기의 행동은 변하지 않는데, 악기 타입마다 서브 클래스를 꼭 만들어야 하는 것일까? Instrument 클래스가 InstrumentSpec에 대해 참조하고 있고 속성의 차이는 그 클래스들이 다룰 수 있다. 프로그램을 복잡하게 할 필요가 없는 것이다.

그렇다면 이제 우리는 어떻게 해야할까?

디자인의 죽음

악기 타입별로 새로운 Instrument의 서브 클래스를 만드는 것은 말이 안 된다. 우리에게 중요한 건 처음에 가진 생각을 버리는 일이다.

필자의 이야기를 잠시 하고자 한다. 개인적으로 어제 회사에서 코딩을 하다가 데이터를 underscore.js의 chain을 걸어서 작업해야 하는 일이 있었다.

private mergeReservableTimes(restaurant: Restaurant): Restaurant {
  const reservableTimes = _.chain(restaurant.reservableTimes)
    .groupBy((element) => element.dayOfWeek)
    .defaults({0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: []})
    .values()
    .map((dayArray) => _.sortBy(dayArray, 'level'))
    .value();

  return _.omit(restaurant, 'reservableTimes')
    .extend({ reservableTimes })
}

위와 같이 작업이 되어있었는데, 아래 return 구문에 omit, extend에 chain을 걸지 않아서 버그가 발생했다. 팀 개발자들이 모여서 코드를 확인하는데 아래 return 구문 쪽을 chain 구문으로 해결하려고 하니 문제가 발생했다.

return _.chain(restaurant)
  .omit('reservableTimes')
  .extend({ reservableTimes })
  .value()

Type '_Chain' is missing the following properties from type 'Restaurant': idx, name, address1, address2, and 70 more.ts(2740)

아무래도 underscore 쪽에서 value()로 리턴하는 부분이 문제가 발생한 것 같았다. 그래서 삽질을 좀 했었는데, 동료 개발자가 보더니 "어, 이거 굳이 chain으로 할 필요 없잖아요?" 하는 것이었다.

private mergeReservableTimes(restaurant: Restaurant): any {
  const reservableTimes: any = _.chain(restaurant.reservableTimes)
    .groupBy((element) => element.dayOfWeek)
    .defaults({ 0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [] })
    .values()
    .map((dayArray) => _.sortBy(dayArray, 'level'))
    .value();
  restaurant.reservableTimes = reservableTimes;
  return restaurant;
}

위와 같이 바꾸고 나니 뒤늦게서야 저럴 필요가 없었음을 깨달았다. 먼저 구현되었던 코드가 underscore chain을 사용하다보니 계속 chain을 쓰려고 하는 '지식의 저주'에 걸려버렸던 것이다 😣

위와같이 우리가 처음 디자인한 코드에 대한 생각을 버리는 일은 쉽지 않다. 처음에는 일리 있는 코드였다고 생각하고, 그것이 이미 잘 동작한다고 여기면, 그 생각을 바꾸는 일이 쉽지 않다. 코드 리뷰나 Pull Request를 잘 날리는 일이 그래서 중요한 것 같다. 변경을 두려워말라. 차후에는 바뀐 디자인이 우리의 시간을 많이 줄여주는 효과가 날 것이다.

사실 내가 짠 코드에 대해 태클(?)이 들어오면 자존심이 상하기도 할텐데, 우리에게 중요한 것은 그 ego를 버리는 일이다. 사람들에게 상처받을 것이 두려워서 자신이 짠 코드를 남들에게 공개하는 일을 두려워하지 말자.

코드 구현

우리가 구현에 앞서 결정할 것은, 가장 먼저는 악기의 서브 클래스들을 모두 없애는 일이다. 새로운 악기를 추가한다고 해도 클래스를 만들 필요가 없게 만들자.

속성 값에 접근할 수 있게 하면서 새로운 속성이 추가되더라도 InstrumentSpec을 수정하지 않아도 되는 타입이 있을까? Map 은 다양한 타입의 속성을 다루고, 새로운 속성들을 언제나 추가할 수 있는 좋은 방법이다.

이제 Instrument와 InstrumentSpec은 모두 구상 클래스가 된다. 개념이 아닌 실제 악기를 나타낸다. InstrumentSpec은 고객들이 검색할 때 그들이 원하는 사양을 전달하기 위해 사용하기도 하고, Instrument가 악기의 속성들을 저장하기 위해 사용하기도 한다.

Guitar, Mandolin 같은 클래스들은 이제 없앨 수 있다. Instrument 클래스를 직접 사용하기 때문이다. 사실 각 악기의 서브 클래스들에서 한 일은 새로운 생성자를 만드는 일밖에 없었다. 프로그램의 유연성을 해치고, 그래서 아무 도움도 되지 않았던 것이다.

또한 Map을 사용함으로서 InstrumentSpec 이하의 서브 클래스들을 Map에 넣을 수 있었다. (사실 Java의 언어적 속성 때문에 이런 설명이 들어있는 것인데, TS에서는 또 다른 표현들이 가능하다.)

객체지향 설계에서 가장 중요한 원리 중 하나는 변하는 것에 대한 캡슐화이다. InstrumentSpec에서 빼내서 Map에 넣음으로서 새로운 속성이 있는지 검사할때마다 Properties Map에서 이름 값/쌍으로 새로운 속성을 넣을 수 있다.

클래스 수가 적어짐으로서 소프트웨어가 유연해지냐? 하면 사실은 캐바캐다. 지금의 경우에는 그렇지만, 어떤 경우는 클래스를 더 추가함으로서 디자인이 유연해지는 경우도 있다. 사실 이런 감이 처음부터 생기는 건 아니다. 우리가 이런 감을 늘릴 수 있는 방법은 사이드 프로젝트를 자주 하며 경험을 늘리는 것이리라.

좋은 디자인은 나쁜 디자인의 분석을 통해서 나온다. 실수, 변경을 절대 두려워 말라.

변경 사항

코드 변경한 내용은 이곳 을 참조하라.

  1. Inventory 클래스에는 이제 search() 메소드가 하나 있다. 그리고 그 메소드는 여러 종류의 악기 타입을 반환할 수 있다.
  2. Instrument 클래스는 더 이상 추상 클래스가 아니고, 타입별 악기 특유의 서브 클래스들도 없앴다.
  3. InstrumentType이라는 새로운 열거형 타입을 추가해서 악기 타입을 나타낸다.
  4. InstrumentSpec도 더 이상 추상클래스가 아니다. 그리고 Map을 이용해서 모든 속성들을 저장하고 있으며, 그래서 각 악기 타입마다 서브 클래스를 만들 필요가 없다.

우리가 프로그램을 개선했으나, 점검해볼 사항들이 있다.

  1. 프로그램을 변경하는 것이 얼마나 쉬운가?
  2. 잘 디자인 되었는가?
  3. "응집되었다"는게 무슨 말일까?

크게는 이렇게 세가지이다. 좀더 자세히 톺아보면...

  1. 새로운 타입의 악기를 제공하려면 몇개의 클래스를 추가해야 할까? => 없다. Instrument, InstrumentSpec의 서브 클래스들이 없지 않은가.
  2. 새로운 타입의 악기를 지원하려면 몇개의 클래스를 변경해야 하는가? => InstrumentType에만 새로운 악기 타입을 추가한다.
  3. 제조일자를 관리하기로 했다면 몇개의 클래스를 변경해야 하는가? => 없다. InstrumentSpec의 Map에 악기 제조일자를 넣으면 된다.
  4. 기타 손잡이의 neckWood라는 속성을 추가한다면 몇개의 클래스를 변경해야 하는가? => 최악의 경우 하나. Wood enum 값을 변경해야할 수 있다.

소프트웨어 응집도

응집된 클래스는 하나의 일을 정말 잘하고 그 외의 일은 하려고 하지 않는다.

  1. 클래스마다 응집도가 높을수록 소프트웨어 응집도가 높아진다.
  2. 응집된 클래스들은 특정한 일에 집중하고 있다. Inventory 클래스는 재고 목록만 걱정하지, 어떤 나무가 사용되었는지는 상관하지 않는다.
  3. Instrument는 검색이나 어떤 나무 사용하는지에 대해 다루지 않는다. 오직 악기를 설명함.
  4. 클래스의 메소드를 살펴보자. 클래스와 관련이 있는가? 관련 없는 메소드가 있다면 다른 클래스에 들어가야 하는 메소드일지도 모른다.

응집도: 응집도는 하나의 모듈, 클래스, 또는 객체들을 이루는 원소들 사이에 연결의 정도를 나타냄. 소프트웨어 응집도가 높을수록, 프로그램에서 각 클래스의 역할들이 잘 정의되어 있고, 잘 연결되어 있는 것이다. 각 클래스는 밀접하게 연결되어 있는 하나의 매우 특정한 집합의 일들을 수행한다.

응집도라는 것이 그냥 프로그램이 얼마나 쉽게 변경될 수 있는지를 나타내는 것일까?

  • 그렇지 않다. 클래스, 객체, 패키지의 구성에 초점을 맞춘다고 보면 좋다. 한 클래스가 몇가지 일만 한다면 응집도가 높은 프로그램, 클래스 하나가 관련 없는 여러 일을 하면, 낮은 응집도의 프로그램이라고 보면 됨

높은 응집도의 소프트웨어는 느슨하게 결합된 것인가?

  • 맞다. 소프트웨어가 더 응집되어 있을수록, 클래스간 더 느슨한 결합을 갖게 됨.
  • Inventory 클래스는 재고 목록만 걱정하면 됨. 악기 사양이 어떤지는 상관하지 않음. => 응집도가 높은 클래스임을 나타냄
  • 이는 프로그램의 각 부분이 느슨하게 결함되어 있음을 나타냄. => Instrument 변경한다고 Inventory가 영향받지 않음.

소프트웨어가 변경하기 더 쉬울 것이지 않는가?

  • 대부분의 경우 맞다. 프로그램 처음에는 Guitar만 있었던게, Mandolin 추가되고, 또 프로그램을 변경하고… 나중에는 여러 종류를 추가하는 것처럼. 응집도 자체가 소프트웨어가 변경하기 쉬운지에 대한 척도는 아니지만 동작 방식을 대폭 수정하는 경우가 아니라면 응집도 높은 소프트웨어가 대개 변경이 용이하다.

높은 응집도가 낮은 응집도보다 나은가?

  • 그렇다. 좋은 객체지향 설계에서는 각 클래스와 모듈이 하나의 기본적인 일을 한다.

응집도가 높은 소프트웨어가 변경 뿐 아니라 재사용도 쉽지 않는가?

  • 그렇다. 높은 응집도 + 느슨한 결함 = 소프트웨어 안 객체들이 서로 의존하지 않게 함. 확장 용이, 재사용 쉬움.
  • SRP 원칙을 생각해보자.

소프트웨어 다시 검토하기

  1. 1장에서 작성했던 프로그램을 기억하는가? 두 개의 클래스가 있었고, 잘 응집되지도 않았었다.
  2. 만돌린을 지원하고자 했더니 프로그램을 재설계 해야 하는 일이 있었으나, 프로그램 응집도는 높았다.
  3. 여러 타입의 악기를 지원하고자 했더니, 오히려 응집도가 2번 때보다 낮아졌다.
  4. 새로운 악기를 넣는다는 것은 엄청난 참사였다
  5. 지금 만들어놓은 디자인은 응집도가 높고, 느슨한 결합으로 확장과 재사용이 용이하다.
  6. 소프트웨어 변경 때마다 점점 응집도가 올라가는지 확인하자.

사실 소프트웨어 디자인은 한번 시작하면 한도 끝도 없다. 고객의 요구사항을 너무 깊게 생각하여 투머치로 작성하게 될 수도 있고, 때로는 돈이 없어서 디자인을 멈춰야 하는 경우도 있다. 일단 소프트웨어가 잘 동작하고, 고객이 만족스러워하고, 소프트웨어 디자인에 최선을 다했다면 그것으로 충분하다. "완벽한 소프트웨어"를 작성하기 위해 많은 시간을 들이는 것은 시간낭비이다.

분석과 설계

  • 잘 디자인된 소프트웨어는 변경과 확장이 쉽다.
  • 기본적인 캡슐화와 상속 같은 객체지향 원리를 사용하여 소프트웨어를 좀 더 유연하게 만들라.
  • 디자인이 유연하지 않으면, 변경하라. 변경해야 하는 것이 내 디자인이어도, 결코 나쁜 디자인은 고수하지 말라.
  • 각 클래스의 응집도를 높게 하라. 클래스 각각은 하나의 일을 정말 잘 하는 것에 중점을 두어야 한다.
  • 소프트웨어 디자인을 진행하며, 항상 높은 응집도를 위해 노력하라.

객체지향 원리

  • 변하는 것을 캡슐화하라
  • 구현에 의존하기보다는 인터페이스에 의존하도록 코딩하라
  • 각 클래스는 변경 요인이 오직 하나이어야 한다.
  • 클래스는 행동과 기능에 관한 것이다.
Comments