클린 소프트웨어 - 애자일 설계

dante Yoon·2022년 1월 1일
1

독서

목록 보기
15/38
post-thumbnail

클린 소프트웨어를 읽으며 챕터 2를 정리한 글입니다.

애자일 설계

잘못된 설계의 증상

다음은 설계가 잘못되었을 때 다음의 증상들이다.

  • 경직성(Rigidiy): 설계를 변경하기 어려움
  • 취약성(Fragility): 설계가 망가지기 어려움
  • 부동성(Immobility): 설계를 재사용하기 어려움
  • 점착성(Viscosity): 제대로 동작하기 어려움
  • 불필요한 복잡성(Needless Complexity): 과도한 설계
  • 불필요한 반복(Needless Repetition): 마우스 남용
  • 불투명성(Opacity): 혼란스러운 표현

원칙

다음은 최적의 설계를 구성할 수 있도록 돕는, 객체 지향 설계 원칙들이다.

  • SRP: 단일 책임 원칙(Single Responsibility Principle)
  • OCP: 개방 폐쇄 원칙(Open-Closed Principle)
  • LSP: 리스코프 치환 원칙(Liskov Substitution Principle)
  • DIP: 의존 관계 역전 원칙(Dependency Inversion Principle)
  • ISP: 인터페이스 분리 원칙(Interface Segregation Principle)
    객체 지향 설계의 원칙으로 소개되고 있지만 많은 소프트웨어 개발자와 연구자의 고찰과 저술의 집합체이며, 소프트웨어 공학에서 오랫동안 믿어져 오고 있는 원칙들이다.

애자일에서는 원칙이라는 이유만으로 맹목적으로 따르지 않는다. 원칙에 대한 맹종은 불필요한 복잡성이란 설계의 악취로 이어진다.

소프트웨어 개발 생명주기를 검토한 후, 공학 설계의 기준을 실제로 만족시킬 유일한 소프트웨어 문서는 소스 코드 목록뿐임을 알 수 있었다. - 잭 리브스

UML 다이어그램은 설계의 일부를 나타낼 수는 있고 소프트웨어 프로젝트의 모듈, 클래스, 메소드등 프로그램의 형태와 구조는 다양한 매체로 표현될 수는 있지만 최종적인 구현은 소스 코드이다.

설계 = 코드

무엇이 소프트웨어의 부패를 촉진하는가?

요구사항 변경에 따라 설계가 퇴화하게 된다. 원래의 설계 철학에 익숙하지 않은 개발자들이 이를 맡게되면 설계를 변경하게 되고 이는 원래의 설계를 위반하는 일이다. 이런 위반이 축적되고 설계는 악취를 풍기기 시작한다.

요구사항 변경 때문에 설계가 실패한다면, 개발자의 설계 방식에 문제가 있는 것이다. 탄력적인 설계를 만드는 방식을 찾아 소프트웨어를 부패로부터 보호해야 한다. 시스템의 설계를 명료하고 단순하게 유지하고 많은 단위 테스트와 인수 테스트로 시스템의 단순함과 명료함을 뒷받침한다.

Copy 프로그램

키보드에서 프린터로 문자를 복사하는 프로그램을 작성한다고 가정한다.

초기 설계

프로그램의 설계를 그려본다. 이 프로그램은 3개의 모듈로 이루어져 있다. Copy 모듈은 다른 2개를 호출한다. Read Keyboard 모듈에서 문자를 가지고 와서 Writer Printer 모듈에게 보낸다.


P.116 그림 7-1 Copy 프로그램 구조 차트

// Copy 프로그램
void copy() {
  int c;
  while((c = Rdkbd()) != EOF)
    WrtPrt(c);
}

컴파일에 문제가 발견되지 않았지만, 코드 작성 직후 요구사항에 변경이 생긴다.
이 요구사항은 애초에 만들어질 때 정의한 이 프로그램의 목적과 다소 다를 수 있다. 기차의 선로를 완전히 뒤바뀌어 버리는 것 같은 이 요구사항은 종이테이프 판독기에서도 문자를 읽는 것인데, Copy 프로그램 설계의 우아함을 망가뜨릴 수 있다는 경고에도 불구하고 고객의 요구사항은 완고하다.

Copy 함수에 boolean 타입 인자를 추가해서 true면 종이테이프 판독기에서 읽어오고 false면 기존과 동일하게 키보드에서 읽어온다. Copy 프로그램을 사용하는 다른 프로그램들이 많기 때문에 인터페이스를 변경할 수는 없다. 인자를 사용할 수 없다면 전역변수를 이용해야 한다(젠장).

bool ptFlag = false // 호출자는 이 플래그를 항상 false로 되돌려 놓아야 합니다.
void Copy(){
  int c;
  while((c = (ptFlag ? RdPt() : Rdkbd())) != EOF)
    WrtPrt(c);
}

종이테이프 판독기에서 입력받기를 원하는 호출자는 ptFlag 값을 true로 변경해야 한다. 작업이 끝난 직후 반드시 ptFlag를 반드시 재설정해야 한다. 이와 같은 필수요소들을 다른 사용자들에게 상기시키기 위해 주석을 추가해둬야 한다.

시간이 조금 흐르고 또 다른 요구사항이 전달된다(아니 이건 너무한거 아니냐고). 이 요구사항을 만족시키기 위해 또 다른 전역변수를 추가한다. 이제 조금씩 프로그램 구조가 비틀거리기 시작했다.

bool ptFlag = false // 호출자는 이 플래그를 항상 false로 되돌려 놓아야 합니다.
bool punchFlag = false // 호출자는 이 플래그를 항상 false로 되돌려 놓아야 합니다.

void Copy(){
  int c;
  while((c = (ptFlag ? RdPt() : Rdkbd())) != EOF)
    punchFlag ? WrtPrt(c) : WrtPrt(c);
}

변화를 예상하라

변화에 대비되지 않은 설계에 있어 요구사항의 변경은 높게 쌓은 젠가 탑에 던져지는 돌맹이와 같다. 프로그램 설계가 젠가와 같이 느껴진다면 애자일 방식대로 하고 있지 않은 것이다.

Copy 프로그램의 애자일 설계

class Reader {
  public: virtual int read() = 0;
}

class KeyboardReader : public Reader {
  public: virtual int read() {
    return RdKbd();
  }
}

KeyboardReader GdefaultReader;

void Copy(Reader & reader = GdefaultReader) {
  int c;
  while ((c = reader.read()) != EOF)
    WrtPrt(c);
}

애자일 팀은 개방 폐쇄 원칙(OCP: Open-Closed Principle)을 따른다. 이 원칙은 개발자가 모듈을 수정하지 않고도 설계를 확장할 수 있도록 이끈다.
변화를 예측했지만 얼마나 변경될 것인지 예상하려고 하지 않고 가장 간단한 방식으로 작성했다는 점을 주목해야 한다.

위의 코드에서 개발자는 입력 장치의 변경에 대응하기 위해 추상클래스를 만들었다.

초기 설계인 그림 7-1을 보면 Copy 모듈이 KeyboardReader와 PrinterWriter에 직접 의존한다. Copy 모듈은 앱의 정책을 결정하고 문자를 복사하는 방법을 알 수 있다. 하위 수준의 세부 사항이 바뀔 때, 상위 수준의 정책이 영향을 받게 된다. 애자일 개발자가 이런 부분을 발견하면 Copy 모듈에서 입력 장치로 향하는 의존성을 거꾸로 뒤집어 Copy가 더 이상 입력장치에 의존하지 않도록 해야 한다. 이러한 역전 작업에는 STRATEGY 패턴을 사용한다.

가능한 한 좋은 상태로 설계 유지하기

설계는 시험삼아 해보는 약속이 아니며 애자일 개발자는 몇 주 마다 한번씩 설계를 바꾸지 않는다. 매 순간마다 소프트웨어를 가능한 명료하고, 간단하고, 표현적인 상태로 유지한다. "집에 갈래, 나중에 고쳐야지" 라는 말은 소프트웨어의 부패를 가속화시킨다.

애자일 설계는 소프트웨어의 구조와 가독성을 향상하기 위한 반복적인 과정이다. 간단하고 명료하고 표현적으로 설계를 유지하려는 노력이다.

단일 책임 원칙(SRP)

한 클래스는 단 한가지의 변경 이유만을 가져야 한다.
하나의 클래스가 하나의 책임만을 가지는 것이 왜 중요할까? 책임은 변경의 축이기 때문이다.
요구사항이 변경될 때, 이 변경은 클래스 안에서의 책임 변경을 통해 명백해진다. 한 클래스가 하나 이상의 책임을 맡는다면, 그 클래스는 변경할 하나 이상의 이유가 있을 것이다.


그림 8-1 하나 이상의 책임

두 가지 어플리케이션이 Rectangle을 사용한다. 하나는 계산 기하학을 위한 애플리케이션으로 Rectangle을 이용해 넓이를 계산하고 하나는 그래픽을 위한 애플리케이션으로 직사각형을 그린다.
Rectangle 클래스는 직사각형을 그리고 넓이를 계산하는 것 두 가지의 책임을 가진다. 이는 단일 책임 원칙을 위반한다. SRP 위반은 귀찮은 문제를 유발한다. GraphicalApplication에서의 변경이 Rectangle의 변경을 유발한다면, 이 변경 때문에 ComputationalGeometryApplication을 재빌드, 재테스트, 재배포 해야 한다.

다음은 책임을 분리해 설계를 개선한 모습니다. 두가지 책임을 2개의 온전히 다른 클래스로 분리해 넣었다.


P.126 그림 8-2 분리된 책임

이제 직사각형이 그려지는 방식에 대한 변경은 ComputationalGeometryApplication에 영향을 주지 않는다. SRP의 맥락에서 책임변경을 위한 이유로 정의한다. 클래스 변경에 있어서 한 가지 이상의 이유가 있다면 한가지 이상의 책임을 맡고 있는 것이다.

아래의 인터페이스의 4가지 기능은 모뎀에 있는 것이 타당해 보인다.

interface Modem {
  public void dial(Stringpno);
  public void hangup();
  public void send(char c);
  public char recv();
}

그러나 dial과 hangup 함수는 모뎀의 연결을 관리하는 반면, send, recv 함수는 데이터를 주고받으며 통신한다. 두 책임이 분리되어야 할까?


그림 8-3 분리된 Modem 인터페이스

애플리케이션이 연결함수의 시그니처에 영향을 주는 방식으로 바뀐다면, send, recv를 호출하는 클래스는 좀 더 자주 재컴파일되고 재배포되어야 하므로 경직성이 올라가므로 위와 같이 책임을 변경하는 것으로 대응할 수 있다.

한편, 애플리케이션이 서로 다른 두 가지의 책임의 변경을 유발하는 방식으로 바뀌지 않는다면, 분리할 필요가 없다. 오히려 분리가 불필요한 복잡성을 높일 수 있다.

개방 폐쇄 원칙(OCP)

변화를 겪으면서 안정적이고 오래 남는 설계를 만들기 위함
소프트웨어 개체(클래스, 모듈, 함수)는 확장에 대해 열려 있어야 하고 수정에 대해서는 닫혀 있어야 한다.
프로그램 한 군데를 변경했을 때 의존적인 모듈에서 단계적인 변경을 불러일으키면 경직성에 문제가 있는 것이다. OCP가 적용된다면 원래 코드를 변경하는게 아니라 새로운 코드를 덧붙임으로써 변경을 할 수 있게 된다.

상세 설명

  1. 확장에 대해 열려 있다.
    애플리케이션의 요구사항이 변경될 때, 이 변경에 맞게 새로운 행위를 추가해 모듈을 확장할 수 있다.
  2. 수정에 대해 닫혀있다.
    어떤 모듈의 행위를 확장하는 것이 소스코드의 변경을 초래하지 않는다.

    해결책은 추상화다

    어떤 모듈의 소스 코드를 변경하지 않고도 그 모듈의 행위를 바꾸는 일은 추상화를 통해 가능하다.
    추상화는 고정되기도 해도 제한되지 않은 가능한 행위의 묶음을 표현하는 방법이다. 모듈이 추상화에 의존하면 수정에 대해 닫힌 상태를 유지할 수 있으며, 모듈의 행위는 추상화의 파생 클래스를 만듦으로 확장이 가능하다.

    P.132 그림 9-1

그림 9-1은 클라이언트 객체가 다른 서버 객체를 사용하게 하려면 클라이언트 클래스가 새로운 서버 클래스를 지정하도록 변경해야 하므로 OCP를 어긴다.


P.132 그림 9-2
ClientInterface클래스는 추상 멤버 함수를 포함한 추상 클래스다.

클라이언트가 서버 클래스 말고 추가 기능을 사용하길 원한다면, ClientInterface 클래스의 새 파생 클래스를 생성하면 된다. 추가 기능이 구현되어도 클라이언트 클래스에는 변경사항이 생겨나지 않는다.


P.133

또 다른 대안은 그림 9-3에서 보여지는 템플릿 메소드 패턴을 이용하는 것이다. 자바를 기준으로 추상메소드를 Policy 클래스에 만들고 실제 구현은 Implementation 클래스라는 서브타입에서 구현한다. 행위의 명시는 Policy 클래스에서 맡지만 실제 기능 구현은 서브 타입이 담당한다.

예상과 자연스러운 구조

OCP를 따르는데는 많은 비용이 들고 올바른 설계를 구현하기 위해서는 경험에 따른 통찰력이 필요하다.
추상화의 단계가 깊어질 수록 소프트웨어 설계의 복잡성은 높아진다. 소프트웨어의 변경을 미리 예측할 수 있을까

올가미 놓기

일어날 수 있는 변경에 대해 올가미를 구현해놓는 것은 불필요한 복잡성을 야기할 수 있다. 이는 유지보수 비용을 늘린다.

불필요한 복잡성을 피하기 위해서는 처음 주어지는 변경 요구사항을 몸소 경험하고 해당 종류의 변경을 경험에 따라 대응하는 방법을 취할 수 있다. 소프트웨어 고도화에 따른 개발 복잡도가 일정 선을 넘기 전에 이러한 변경이 빨리 일어나면 좋을 것 같다.

변경 촉진하기

  • 테스트를 먼저 작성한다. 테스트 가능한 변경은 용납 가능한 수준이다.
  • 아주 짧은 (일 단위) 주기로 개발한다.
  • 기반 구조보다 기능 요소를 먼저 개발하고 이 기능 요소를 이해 당사자에게 보여준다.
  • 가장 중요한 기능 요소를 먼저 개발한다.
  • 최대한 소프트웨어를 빨리 릴리즈하고 자주 고객에게 시연한다.

명시적인 폐쇄를 위해 추상화 사용하기

특정 기능, 이를테면 DrawAllShapes라는 함수가 순서의 변경에 대해 닫혀있게 하고 싶으면 순서 추상화가 필요하다.

리스코프 치환 원칙(LSP)

서브타입은 그것의 기반타입으로 치환 가능해야 한다.

타입 S의 각 객체 a1과 타입 T의 각 객체 a2가 있을 때, T로 프로그램 P를 정의했음에도 불구하고 a2를 a1로 치환할 때 P의 행위가 변하지 않으면, S는 T의 서브타입이다. - Barbara Liskov

LSP 위반을 방지하는 것이 중요한 점은 런타임 에러로 이어지기 때문이며 잠재적인 OCP의 위반으로 이어진다.
OCP 위반을 방지하는 것이 중요한 점은 소프트웨어의 설계가 변경에 취약하게 만들기 때문이다.

LSP 위반의 간단한 예

struct Point {
  double x,  y;
};

struct Shape {
  enum ShapeType {
    squre, circle
  }
  itsType;
  Shape(ShapeType t): itsType(t) { } 
};

struct Circle : public Shape {
  Circle() : Shape(circle) {
  }
  void Draw() const
  Point itsCenter;
  double itsRadius;
};

struct Square : public Shape {
  Squre() : Shape(square) { }
  void Draw() const
  Point itsTopLeft;
  double itsSide;
}

void DrawShape(const Shape& s) {
  if (s.itsType == Shape :: square)
    static_cast<const Square&>(s).Draw();
  else if (s.itsType == Shape :: circle)
    static_cast<const Circle&>(s).Draw();
}

예제 코드에서 DrawShape 함수는 Shape 클래스의 모든 가능한 파생 클래스를 알아야 하므로 OCP를 위반한다.

해당 코드를 작성한 프로그래머는 다형성의 부하를 피하기 위해 설계에서 가상함수 사용을 금지했다.
Square과 Circle 구조체는 Shape에서 파생되었고 Draw 함수를 갖지만 Shape의 함수를 오버라이드 하지 않는다. Circle과 Square가 Shape을 대체할 수 없으므로 DrawShape 함수 내부에서 타입을 검사하고 형을 결정해야 한다. Square와 Circle이 Shape을 대체할 수 없다는 것은 LSP 위반이며, DrawShape 함수의 OCP 위반을 유발한다.

LSP 위반의 좀더 미묘한 예

종종 상속은 IS-A 관계라고 한다. 새 객체가 어떤 객체에서 파생될 수 있어야 상속 관계에 있다고 할 수 있다.

모든 정사각형은 직사각형이므로 Square클래스가 Rectangle클래스에서 파생되는 것은 타당해보인다.

하지만 수 많은 객체들이 Square객체에서 필요없는 멤버 변수인 itsHeight와 itsWidth를 가지고 프로그램 안에서 생성된다면, 프로그램이 실행되는 환경에 따라 불필요한 오버헤드를 일으킨다.

Square객체는 항상 가로와 세로의 길이가 같기 때문에 Rectangle 클래스에 정의한 setWidth, setHeight가 불필요하다. 이 문제를 비껴가보자.

 void Square :: SetWidth(double w) {
   Rectangle :: SetWidth(w);
   Rectangle :: SetHeight(w);
 }
 
 void Sqaure :: SetHeight(double h) {
   Rectangle :: SetHeight(h);
   Rectangle :: SetWidth(h);
 }

Square의 불변성이 지켜지므로 타당해 보이지만 다음 함수를 살펴보자.

void f(Rectangle & r) {
  r.SetWidth(32); //Rectangle::SetWidth를 호출한다.
}

Square 객체에 대한 참조값을 이 함수에 넘겨준다면, 해당 객체의 세로 값이 가로값에 맞춰 바뀌지 않기 때문에 LSP 위반이다. f함수는 인자의 파생 클래스 형에 대해 제대로 동작하지 않는다.
SetWidth와 SetHeight가 Rectangle에서 virtual로 선언되어있지 않아 다형적이지 않기 때문에 발생하는 문제이다.

모순 없는 Rectangle과 Square를 만들어보자

class Rectangle {
	public:
    	virtual void SetWidth(double w) {
        	itsWidth = w;
        }
        virtual void SetHeight(double h) {
        	itsHeight = h;
        }
        double GetHeight() const {
        	return itsHeight;
        }
        double GetWidth() const {
        	return itsWidth;
        }
   private:
   		Point itsTopLeft
        double itsHeight;
        double itsWidth;
}

class Square : public Rectangle {
	public:
    	virtual void SetWidth(double w);
        virtual void SetHeight(double h);
}

void Sqaure :: SetWidth(double w) {
	Rectangle :: SetWidth(w);
    Rectangle :: SetHeight(w);
}

void Square :: SetHeight(double h){
	Rectangle :: SetHeight(h);
    Rectangle :: SetWidth(h);
}

Rectangle에 대한 포인터나 참조값을 받아들이는 함수에 Square 인자를 넘겨줘도 Square는 정사각형처럼 동작하고 모순도 없게 된다.

다음 g 함수를 보자.

void g(Rectangle& r) {
	r.SetWidth(5);
    r.SetHeight(4);
    assert(r.Area() == 20);
}

Square를 넘겨 받는다면 단정 에러를 선언한다. g의 작성자는 Rectangle의 가로 길이를 바꾸는 것이 세로 길이를 바꾸지는 않을 것이라고 생각하고 작성한 것이다. Rectangle로 넘겨질 수 있는 모든 객체가 이 가정을 만족하는 것은 아니다. 함수 g는 Square/Rectangle 계층 구조에 대해 취약하다.
이런 함수에서 Square와 Rectangle과 치환가능하지 않기 때문에 LSP를 위반한다.
Rectangle 클래스에서 불변식은 가로와 세로의 길이가 독립적이라는 것이고 g 함수의 설계자는 이를 어기지 않았기 때문에 불변식을 위반한 것은 Square의 제작자이다.

Square 제작자는 Square의 불변식을 위반하지는 않았지만 Rectangle에서 Square를 파생시킴으로써 Rectangle의 불변식을 위반하게 되었다.

유효성은 본래 갖추어진 것이 아니다.

LSP는 모델만 별개로 보고 그 모델의 유효성을 충분히 검증할 수 없다점을 시사한다.
어떤 모델의 유효성(validity)는 오직 고객의 관점에서만 표현될 수 있다. Square과 Rectangle 클래스의 최정 버전을 각각 별개로 검사한다면 자체 모순이 없고 유효하다는 결론을 내릴 수 있으나, 기반 클래스인 Rectangle의 불변식만을 따르는 프로그램의 관점에서 만든 g 함수의 입장에서 이 유효성은 깨지게 된다.

특정 설계가 적잘한지 아닌지를 판단할 때는 단순히 별개로 봐선 해답을 찾을 수 없다.
g라는 함수가 만들어지기 전까지 이러한 모순을 예상하는 것은 매우 어렵다. 예상한다고 하더라도 불필요한 복잡성을 불러일으키기에 가장 명백한 LSP 위반을 제외한 나머지의 처리는 연기하는 것이 최선이다.

g의 관점에서 볼 때 Square객체는 Rectangle 객체와 IS-A 관계가 아니다. 이는 행위 측면에서 볼때를 의미한다고 좀 더 일반화 시킬 수 있다.

계약에 의한 설계

계약에 의한 설계(design by contract ; DBC)는 합리적인 추정을 명시적으로 만들어 LSP를 강제할 수 있게 한다. DBC를 사용하면, 어떤 클래스의 작성자는 그 클래스의 계약사항을 명시적으로 정해 신뢰할 수 있는 행위에 대해 알려준다. 이 계약은 메소드의 사전조건과 사후조건을 선언하는 것으로 구체화 할 수 있다.

Rectangle::SetWidth(double w)의 사후조건을 다음과 같이 볼 수 있다.

// old는 SetWidth를 호출하기 전의 Rectangle 값이다.
assert((itsWidth == w) && (itsHeight == old.itsHeight));

다음은 버트런드 마이어(Bertrand Meyer)가 설명한 파생클래스의 사전조건과 사후조건에 대한 규칙이다.

파생 클래스에서 루틴 재선언은 오직 원래 사전조건과 같거나 더 약한 수준에서 그것을 대체할 수 있고, 원래 사후조건과 같거나 더 강한 수준에서 그것을 대체할 수 있다.
기반 클래스의 인터페이스를 통해 어떤 객체를 사용할 때 사용자는 그 기반 클래스의 사전조건과 사후조건만 알 수 있다. 파생된 객체는 이런 사용자가 기반 클래스가 요구하는 것보다 더 강한 사전조건을 따를 것이라고 기대할 수 없다.파생된 객체는 기반 클래스가 받아들일 수 있는 것은 모두 받아들여야 한다. 또한 파생 클래스는 기반 클래스의 모든 사후조건을 따라야 한다.

Square::SetWidth(double w)의 사후조건은 Rectangle::SeWidth(double w)의 사후조건보다 약하다. 제약조건 (itsHeight == old.itsHeight)을 강제하지 않기 때문이다. 따라서 Square의 SetWidth메소드는 기반 클래스의 계약을 위반한다.

이러한 계약사항은 단위 테스트를 작성함으로써 구체화 할 수 있다.

휴리스틱과 규정

기반 클래스보다 덜한 동작을 하는 파생클래스는 기반 클래스와 치환이 불가능하므로 LSP를 위반한다.

public class Base {
	public void f(){ /* 일부 코드 */ }
}

public class Derived extends Base {
	public void f() {}
}

Derived의 설계자는 함수 f가 필요없다고 생각해 f를 퇴화함수로 만들었다.
파생 클래스에 퇴화 함수가 존재한다고 무조건 LSP를 위반한다고 할 수는 없지만 위반 여부를 살펴볼 만한 가치가 있다.

의존 관계 역전 원칙(DIP)

  • 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다.
  • 추상화는 구체적인 사항에 의존해서는 안 된다. 구체적인 사항은 추상화에 의존해야 한다.

전통적인 소프트웨어 개발 방법에서는 소프트웨어 구조에서 상위 수준의 모듈이 하위 수준의 모듈에 의존하는 경향이 있었기 때문에 역전이라는 단어가 붙게 되었다. 잘 설계된 객체지향 프로그램의 의존성 구조는 일반적으로 만들어진 의존성 구조가 역전되어진다.

상위 수준의 모듈이 하위 수준의 모듈에 의존한다면, 하위 수준 모듈의 변경은 상위 수준 모듈에 직접적인 영향을 미칠 수 있게 된다. 하위 수준의 구체적인 모듈에 영향을 주어야 하는 것은 정책을 결정하는 상위 수준의 모듈이다.

레이어 나누기

잘 구조화된 모든 객체 지향 아키텍처는 레이어를 분명하게 정의했다. 여기서 각 레이어는 잘 정의되고 제어되는 인터페이스를 통해 일관된 서비스의 집합을 제공한다. - 부치(Booch)

P.168 그림 11-1

각 상위 수준 레이어는 그것이 필요로 하는 서비스에 대한 추상 인터페이스를 선언한다. 하위 수준의 레이어는 이 추상 인터페이스로부터 실체화된다. 각 상위 수준 클래스는 추상 인터페이스를 통해 다음 하위 수준의 레이어를 사용한다. 상위 레이어는 하위 레이어에 의존하지 않는다. 하위 레이어는 상위 레이어에 선언된 추상 서비스 인터페이스에 의존한다. PolicyLayerdml MechanismLayer에 대한 직접적인 의존성도 없다.

소유권의 역전

역전은 의존성 뿐만 아니라 인터페이스 소유권에 대해서도 해당된다.
하위 수준의 모듈은 상위 수준의 모듈 안에 선언되어 호출되는 인터페이스의 구현을 제공한다.
그림 11-1에서 소유권의 역전을 사용하면, PolicyLayer는 MechanismLayer나 UtilityLayer의 어떤 변경에도 영향을 받지 않는다. PolicyLater는 PolicyServiceInterface에 맞는 하위 수준 모듈을 정의하는 어떤 문맥에서든 재사용될 수 있다. 의존성을 역전시킴으로써 튼튼하고 이동이 쉬운 구조를 만들어냈다.

추상화에 의존하자

DIP의 해석은 추상화에 의존하자라는 간단한 접근 방식이다. 어떤 프로그램의 모든 관계는 어떤 추상 클래스나 인터페이스에서 맺어져야 한다.

  • 어떤 변수도 구체 클래스에 대한 포인터나 참조값을 가져선 안된다.
  • 어떤 클래스도 구체 클래스에서 파생되어서는 안된다.
  • 어떤 메소드도 그 기반 클래스에서 구현된 메소드를 오버라이드해서는 안된다.

비휘발성인 구체 클래스의 대표적인 예시는 자바의 어떤 문자열을 모사하는 String 클래스이다. 이 클래스는 휘발적이지 않다. 즉, 자주 바뀌지 않으므로 이것에 직접 의존하는 것은 해가 되지 않는다.

간단한 예

의존성 역전은 한 클래스가 다른 클래스에 메시지를 보내는 장소라면 어디든 적용할 수 있다.
Button 객체는 외부 환경을 감지한다. Poll 메시지를 받으면, 이 객체는 사용자가 그것을 '눌렀는지' 판단한다. Lamp 객체는 외부 환경에 영향을 미친다. 이 객체는 TurnOn 메시지를 받으면 어떤 종류의 조명을 밝히고, TurnOff 메시지를 받으면 그 조명을 끈다. 프로그램의 목적은 Button 객체가 Lamp 객체를 제어하는 것이다.


P.171 그림 11-3

그림 11-3은 미성숙한 설계를 보여준다. Button객체는 Poll 메세지를 통해 그 버튼이 눌렀는지를 결정하고, TurnOn이나 TurnOff 메시지를 Lamp에 보낸다. 이 구조는 Button 클래스가 Lamp 클래스에 직접 의존하고 있는 것을 보여준다.

  • Button이 Lamp의 변경에 영향을 받는다.
  • Button은 오직 Lamp 객체를 제어할 수 있기 때문에 Motor 객체를 제어하는 곳에 재사용될 수 없다.
public class Button {
  private Lamp itsLamp;
  public void poll() {
    if(/* 어떤 조건 */)
      itsLamp.turnOn();
  }
}

이 해결책은 DIP를 위반한다. 상위 수준의 정책이 하위 수준 구현에서 분리되어 있지 않다.

내재하는 추상화를 찾아서

상위 수준의 정책이란 애플리케이션에 내재하는 추상화이자 구체적인 것이 변경되더라도 바뀌지 않는 어떤 진실이다. 시스템 안의 시스템이며 메타포(metaphor)다. Button/Lamp 예에서 내재하는 추상화는 사용자로부터 켜고 쓰는 동작을 탐지해 그 동작을 대상 객체에 전해주는 것이다.


P.172 그림 11-4

그림 11-4는 그림 11-3의 Lamp 객체의 의존성을 역전시킨 것이다. ButtonServer는 Button이 어떤 것을 켜거나 끄기 위해 사용할 수 있는 추상메소드를 제공하고 Lamp는 ButtonServer 인터페이스를 구현한다. Lamp는 이제 의존을 당하는 것이 아니라 반대로 의존하게 된다.

나는 이 부분을 다음과 같이 이해했다. 인기 연예인은 광고주로부터 직접 광고를 수주 받지 않는다. 연예인 기획사가 광고를 수주 받는다. 연예인은 광고주와 직접적으로 연관을 맺지 않는다. 광고주는 연예인 기획사라는 인터페이스를 통해 어떤 연예인과도 광고를 찍을 수 있게 된다.

용광로 사례

어떤 용광로의 조절기를 제어하는 소프트웨어가 있다. IO채널에서 온도를 읽고 다른 IO채널에 명령어를 전송하여 용광로를 켜거나 끈다.

#define THERMOMETER 0x86
#define FURNACE 0x87
#define ENGAGE 1
#define DISENGAGE 0

void Regulare(double minTemp, double maxTemp) {
  for(;;){
    while (in(THERMOMETER > minTemp)
      wait(1);
    out(FURNACE, ENGAGE);
    
    while (in(THERMOMETER) < maxTemp)
      wait(1);
    out(FURNACE, DISENGAGE);
  }
}

이 코드는 많은 하위 수준의 구체적인 내용으로 어지럽혀 있다. 다른 제어 하드웨어에서는 재사용될 수 없다.

의존성을 역전시켜보자.


P.174 그림 11-5

조절함수가 두개의 인터페이스를 받는다.
Regulate 알고리즘이 필요로 하는 것은 이게 전부다.
아래처럼 상위 수준의 조절 정책이 자동 온도 조절기나 용광로의 구체적인 사항에 의존하지 않게 의존성을 역전시켰다.

void Regulare(Thermometer& t, Heater& h, double minTemp, double maxTemp) {
  for (;;) {
    while (t.Read() > minTemp)
      wait(1);
    h.Engage();
    
    while (t.Read() < maxTemp)
      wait(1);
    h.Disengage();
  }
}

동적 다형성과 정적 다형성

위에서 동적 다형성(추상클래스, 인터페이스)를 통해 의존성의 역전을 해결했다.
다음은 C++의 템플릿이 제공하는 다형성의 정적 형태를 사용해 의존성의 역전을 해결한 모습니다.

template <typename THERMOMETER, typename HEATER>
class Regulare(THERMOMETER& t, HEATER& h, double minTemp, double maxTemp) {
  for (;;) {
  	while (t.Read > minTemp)
      wait(1);
    h.Engage();
    
    while (t.Read() < maxTemp)
      wait(1);
    h.Disengage();
  }
}

정적 다형성은 소스코드의 의존성을 깔끔하게 끊어주지만, 동적 다형성만큼 많은 문제를 해결해주지는 않는다. 템플릿을 통한 접근 방법의 단점은

  • HEATHER와 THERMOMETER의 형이 런타임 시에 바뀔 수 없으며
  • 새로운 종류의 HEATHER와 THERMOMETER 사용이 재컴파일과 재배포를 필요로 한다는 점이다.

인터페이스 분리 원칙(ISP)

비대한 인터페이스를 가지는 클래스는 응집력이 없는 인터페이스를 가지는 클래스다.
Interface Segragation Principle은 응집력이 없는 인터페이스를 필요로 하는 객체가 있다는 것을 인정하지만 클라이언트는 그것을 하나의 단일 클래스로 생각해서는 안 됨을 시사한다. 오히려 클라이언트는 응집력이 있는 인터페이스를 가지는 추상 기반 클래스에 대해 알고 있어야 한다.

인터페이스 오염

class Door {
  public :
    virtual void Lock() = 0;
    virtual void Unlock() = 0;
    virtual bool IsDoorOpen() = 0;
}

이 클래스는 추상 클래스이기 때문에 클라이언트는 Door의 특정한 구현에 의존하지 않고도 Door 인터페이스를 따르는 객체를 사용할 수 있다.

다음은, 문이 열린채로 너무 오랜 시간이 지나면 알람을 울려야 하는 TimedDoor 객체를 생각해보자. 이 객체는 Timer라는 또 다른 객체와 통신하며 Door의 구현체이다.

class Timer {
  public : void Register(int timeout, TimerClient* client);
}

class TimerClient {
  public : virtual void TimeOut() = 0;
}

제한 시간 초과 여부에 대한 정보를 받고 싶은 객체는 Timer Register 함수를 호출한다. Register 함수는 제한 시간과 제한 시간이 초과되었을 때 호출되는 TimeOut 함수를 포함하는 TimerClient 객체에 대한 포인터가 된다.

TimerClient 클래스가 TimedDoor 클래스와 통신하여 TimedDoor의 코드에서 제한 시간 초과 여부를 어떻게 통지받게 할 수 있을까?


P.179

그림 12-1에서는 Door, TimedDoor가 TimerClient를 상속받는다. TimerClient가 Timer를 통해 자신을 등록하고 TimeOut 메시지를 받을 수 있음을 확실하게 해준다.

Door클래스가 TimerClient에 의존하게 되었다. Door의 모든 변형 클래스가 타이머 기능을 필요로 하는 것은 아니다. 특정 변형 클래스는 TimeOut 메서도의 구현을 퇴화시켜야 하고 이는 잠재적인 LSP 위반이다.
게다가 이 변형 클래스를 사용하는 애플리케이션은 TimerClient 클래스를 사용하지 않는다 하더라도 이것을 임포트해야한다.

이 인터페이스는 서브클래스 중 하나의 이득 때문에 이 메소드를 포함시켜야 했다. 이러한 설계는 파생클래스가 새로운 메소드를 필요로 할 때마다 그 메소드가 기반 클래스에도 추가되어야 한다. 이것은 인터페이스를 비대하게 만들어 오염시킨다.

클라이언트 분리는 인터페이스 분리를 의미한다.

클라이언트가 자신이 사용하는 인터페이스에 영향을 끼치기 떄문에 클라이언트가 분리되어 있으면 인터페이스도 분리된 상태로 있어야 한다.

클라이언트가 인터페이스에 미치는 반대 작용

소프트웨어의 변경의 사이드 이펙트를 고려할 때 보통은 인터페이스 변경이 어떻게 그 사용자에게 영향을 미칠 수 있는지 생각한다. TimerClient 인터페이스의 변경은 TimerClient의 모든 사용자에게 영향을 미칠 것이다. 하지만 때로는 사용자가 인터페이스 변경을 불러일으킨다.

Timer의 사용자가 여러명이라고 했을 때 Door가 열린 직후 Timer에 Register 메시지를 전송해 제한 시간 초과 판정을 요청한다. 이 시간이 다 지나기 전에 Door가 닫히고 곧바로 다른 사용자에 의해 Door가 열린다. 첫번 째 사용자가 요청한 Timer의 초과 판정이 뒤늦게 발동하여 Door는 잘못된 알람을 울리게 된다.

다음과 같이 각 타이머 사용자 등록에 고유의 timeOutId 코드를 포함시켜 해결해보자.

class Timer {
  public : void Register(int timeout, int timeOutId, TimerClient* client);
}

class TimerClient {
  public : virtual void TimeOut(int timeOutId) = 0;
}

이 변경은 TimerClient의 모든 사용자에게 영향을 미친다. 이러한 변경은 Door와 Door의 모든 클라이언트에 영향을 미치므로 경직성과 점착성에서 문제를 일으킨다.

인터페이스 분리 원칙(ISP)

클라이언트가 자신이 사용하지 않는 메소드에 의존하도록 강제될 때, 이 클라이언트는 이런 메소드의 변경에 취약하다.

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글