공부하는 블로그

#12 디자인 원리 본문

design patterns

#12 디자인 원리

devtimothy 2019. 4. 21. 11:01

#12 디자인 원리

내 나름대로 잘 짠 코드가 아닌 디자인 원리가 적용된 코드. 이는 유지 보수가 용이하고, 유연하고, 확장성이 좋다.

OCP, 개방 폐쇄 원칙

(기존 코드가) 수정에는 닫혀있고, 확장에는 열려 있는 코드. 스타크래프트로 생각해보자면, <테란 시민> 클래스가 있는데, 시민 자체는 변경하지 않고, 다른 유닛으로 확장하는 것으로 생각할 수 있겠지? class 테란시민 extends 마린 {} 등의 코드로 확장할 수 있겠다.

앞서서 살펴본 악기 상점 프로그램을 기억해보자.

InstrumentSpec 클래스는 기저 추상 클래스였고, 이를 확장해서 GuitarSpec, MandolinSpec 등이 matches() 메서드에 구현을 했다. InstrumentSpec은 수정에는 닫혀있고, 확장에는 열려있다고 생각할 수 있겠다.

OCP는 유연성에 대한 내용이다. 잘 돌아가고 있는 코드를 바꾸는 일과, 잘 돌아가고 있는 코드를 확장해서 사용하는 것은 완전 다른 이야기이다. 회사에서 구조적으로 짜지 못했던 코드로 인해서 고통받게 되는 경험을 했었다. 하나를 고치면 되는데 전부를 고쳐야 하는 상황은 만들지 않도록 하자. ㅠㅠ

OCP가 캡슐화의 일종인가? 궁금할수 있다. OCP는 캡슐화 + 추상화 조합이다. 변경되지 않는 내용 (부모 클래스의 공통된 행동)으로부터 캡슐화 한다. 단순히 상속만이 OCP를 의미하지는 않는다. 한 클래스 내에서도 private 메소드가 여러개 있다면, 이들은 수정에는 닫혀있는 것이다. 이를 public 메소드를 통해 여러 방법으로 호출한다면, 이는 확장에는 열러 있는 것이다.

개인적으로는 회사에서 프로젝트를 하면서 dao 클래스에서 OCP 원리를 많이 적용했던 것 같다.

DRY, Don't Repeat Yourself.

반복을 줄임으로서 유지보수와 재사용성이 좋은 코드를 만든다. 앞서 강아지 문 프로그램을 개발할 때, 이미 자동으로 닫히는 기능을 만들면서 공통된 코드를 추상화한 이력이 있다.

// BarkRecognizer.ts
public recognize(bark: Bark): void {
    console.log(`BarkRecognizer: Heard a ${bark.sound}`);
    this._door.allowedBarks.forEach((allowedBark: Bark) => {
      if (allowedBark.equals(bark)) {
        this._door.open();
      }
    });
  }

// Remote.ts
public pressButton(): void {
    console.log("Pressing the remote control button...");
    if (this._door.isOpen()) {
      this._door.close();
    } else {
      this._door.open();
    }
  }

// DogDoor.ts
public open(): void {
    console.log("The dog door opens.");
    this._open = true;
    setTimeout(() => {
      this.close();
    }, 5000);
  }

중복 코드를 추상화해서 끄집어내는 것은 DRY 사용의 좋은 출발이다. 그러나 그것이 전부는 아니다. (이게 심해지면 util 클래스 하나 만들어서 거기에 다 짱박게 되는 안타까운 일도 생긴다.) 오히려 시스템의 정보와 기능이 있어야할 곳에 있도록 배치하는 것을 의미한다. 시스템의 정보와 기능이 어디에 있는지를 정확히 아는 것이 중요하다. DRY는 코드 이상의 내용을 담고 있는 것이다.

강아지 소리 인식기가 강아지 문을 닫는 기능에 대해서, 하나의 원천이 되기를 바라진 않을 것이다. (강아지 문이 인식기에게 자기 닫아달라고 매번 부탁하진 않는다.)

SRP, 단일 책임의 원리

객체의 책임. 즉, 시스템에서 어떤 객체가 무슨 일을 하는지에 대한 원리이다. 내가 디자인한 객체가 하나의 책임에만 집중하는 것이다. 그 책임이 변한다면 변화를 반영하기 위해 어느 부분을 봐야하는지 정확히 알게될 것이다.

SRP와 DRY가 비슷한 내용 같아 보이지만, DRY는 나의 기능을 한 곳에 두자는 내용이다. SRP는 클래스가 한 가지 일만 잘하게 하자는 내용이다. 좋은 앱에서는 한가지 클래스는 한가지 일을 잘 하고, 다른 클래스들은 같은 일을 하지 않는다.

초보 개발자들은 SRP에 능숙하지 않아서 클래스 크기를 오히려 더 크게 만들어 놓는 경우도 생긴다. 그러나 SRP를 사용하면 대개 클래스 크기가 줄고, 전체 앱을 더 간단하게 유지보수할 수 있다. 이는 응집도와도 관련된다. (응집도를 높인다 = SRP 적용이 잘 된다.)

Automobile 클래스를 SRP 적용하기

  • 출발 (Automobile)
  • 정지 (Automobile)
  • 타이어 교체 -> Mechanic (정비공) 클래스
  • 운전 -> Driver (운전자) 클래스
  • 세차 -> CarWash (세차장) 클래스
  • 오일 점검 -> Mechanic (정비공) 클래스
  • 기름 얻기 (Automobile)

여러가지를 생각해보았다. 그러나 여기서 Automobile 클래스가 가질 SRP는 출발, 멈춤, 기름얻기 정도이다. 나머지 SRP와 맞지 않는 내용들은 끄집어내 말이 되게 만든다.

LSP, Liskov Substitution Principle

리스코프 치환 원리로서, 자식타입들은 부모타입들이 사용되는 곳에 대체될 수 있어야 한다. LSP는 잘 디자인된 상속에 관한 내용이다. 부모 클래스를 상속할 때, 부모 클래스가 사용되는 곳은 아무문제없이 자식 클래스도 사용할 수 있어야 한다. 그렇지 않다면 상속을 잘못 사용하고 있는 것이다.

앞서 만들었던 게임 프레임웍에서, 공중전 지원을 위해서 2차원 게임 보드가 3차원이 되어야 한다면? Board 부모 클래스를 상속해야 할 것이다. 그러나 이 접근은 많은 문제가 있다. Board 클래스의 메소드들이 3D 환경과 맞지 않아서 대체하기가 어렵다. 이는 LSP를 위반하는 것이다.

자식 타입은 부모타입이 사용되는 곳에 사용될 수 있어야 한다. 기술적으로는 문제 없어보이지만, 3D 보드의 객체를 2D 보드 객체처럼 사용하면 여러가지가 문제가 된다. 이해도 어렵고 디버깅도 힘들다.

상속을 오용하는 코드들은 이해하기 어렵다. 상속을 하게되면 자식 클래스는 원하든, 그렇지 않든 부모 클래스의 모든 메소드를 상속받는다. 이는 의미없는 많은 메소드들을 상속받게 되기도 한다. 이를 위해서는 LSP 준수를 해야한다.

상속 없이 3DBoard의 문제를 해결할 수는 없을까? Board 클래스는 3DBoard 클래스가 필요로 하는 행동이 있지만, 3DBoard 의 부모 타입은 아니다. 이는 확장보다는 연관을 사용해야 한다. 그래야 LSP를 위반하지 않으며 Board의 행동을 사용할 수 있기 때문이다. 3DBoard에서는 3차원 좌표를 사용하여 배열에서 어떤 Board 객체를 사용해서 x,y 좌표를 Board에 위임할지를 알아야 한다.

위임 (Delegation)과 구성(Composition)

1B9D2DA9-4392-4625-92DF-370FB8B84B50

위임은 한 클래스가 다른 클래스에 어떤 일을 맡기는 것을 의미한다. 3DBoard 문제를 상속에 의지하지 않고 해결하는 데 사용한다. 이는 다른 클래스의 기능을 그 클래스의 행동의 변경 없이 그대로 사용하고자 할 때 잘 사용된다.

때로는 위임이 우리의 필요가 아닐 수 있다. 위임하고 있는 객체의 기능이 변하지 않는다. 3DBoard는 항상 Board 객체들을 사용하고, Board 메소드들의 행동은 항상 같다. 물론 다른 경우도 있다.

Weapon 인터페이스를 개발하여, 인터페이스를 다르게 행동하는 여러 클래스들이 구현되어 있다고 생각해보자. Unit 클래스는 이런 클래스들의 행동을 잘 사용해야 한다. Unit은 weapon을 가지고 있고, weapon은 Weapon 인터페이스를 구현한 클래스여야 한다. 유닛은 무기를 바꿀 수 있다.

언제 구성(Composition)을 사용할까? Unit 클래스처럼 여러 종류의 기능을 참조하면 구성을 사용하는 것이다. 구성은 인터페이스에 정의된 기능을 사용하고, 컴파일이나 실행 중에 그 인터페이스의 여러 구현 클래스 중 선택하기를 원할 때 위력을 발휘한다.

피자의 경우가 구성의 좋은 예다. 여러 토핑으로 구성되지만, 전체 피자 조각은 그대로 둔 채, 토핑을 바꿀 수 있다.

구성에 대해 언급하지 않았던 한가지 중요한 점은, 한 객체가 다른 객체들로 구성되고, 소유하고 있는 객체가 없어질 때 구성의 부분인 객체들도 같이 사라진다. 다시 설명하자면, Unit에 Sword 객체를 할당하였는데, Unit 객체가 사라지면, Sword 객체도 사라진다.

이같이 구성에 참여한 행동들은 그 구성의 외부에서는 존재하지 않는다.

집합(Aggregation): 사라지지 않는 구성

구성의 모든 이점 (행동을 선택하고 LSP를 준수하는)이 필요하지만 구성에 참여한 객체들이 주 객체의 외부에서도 존재해야 한다면 어떻게 될까? 이때 필요한 게 집합이다.

우리는 악기 프로그램 만들면서 이미 집합을 사용했다. Instrument와 InstrumentSpec이 그러하다.

하얀 다이아몬드로 표시된 선은 집합을 의미한다. 집합을 이용해 악기 특유의 서브클래스들을 사용하지 않을 수 있었다. InstrumentSpec은 Instrument의 부분으로 사용되나, 외부에서도 존재할 수 있다. (고객이 검색을 사용할때...)

집합 vs 구성

언제 집합을 사용하고, 언제 구성을 사용할까. 혼동을 피하는 방법은 내가 사용하고 싶은 행동을 가진 객체가 그 행동을 사용하는 객체의 외부에서도 존재하는가? 를 묻는 것이다. 독립적 존재도 의미가 있다면 집합을 사용해야 한다. 그렇지 않으면 구성을 사용하라. 그러나 객체 용도가 조금만 바뀌어도 결정을 바꿔야 하는 경우도 생긴다.

위임, 구성, 그리고 집합을 상속보다 더 선호하면, 대개의 경우 소프트웨어는 더 유연하고, 유지보수와 확장성, 그리고 재사용성이 더 좋아진다.

Comments