본문 바로가기

ComputerScience/DesignPattern

[SOLID] LSP: The Liskov Substitution Principle


* 이 글은
Agile Software Development, Principles, Patterns, and Practices - Robert Martin 책 내용을 번역 및 요약하여 작성하였습니다.

 

LSP: The Liskov Substitution Principle

OCP(개방-폐쇄 원칙)의 주요 메커니즘은 추상화(abstraction)와 다형성(polymorphism)입니다. C++ 및 Java와 같은 정적으로 형식화된 언어에서는 이러한 추상화와 다형성을 지원하는 주요 메커니즘 중 하나가 상속(inheritance)입니다. 상속을 사용함으로써 우리는 기본 클래스의 추상 메서드를 구현하는 파생 클래스를 생성할 수 있습니다.

이러한 상속 사용의 특정한 디자인 규칙과 최상의 상속 계층 구조의 특성을 다루는 것이  Liskov 대체 원칙(Liskov Substitution Principle, LSP)입니다.

LSP는 상속과 다형성을 사용할 때 파생 클래스가 기본 클래스의 역할을 충실히 대체할 수 있어야 함을 강조합니다. LSP는 상속을 사용하는 경우에 지켜야 하는 디자인 원칙 중 하나로, 파생 클래스가 기본 클래스의 인터페이스와 동작을 끝없이 확장하거나 수정할 수 있도록 하는데 중요한 역할을 합니다. 이를 통해 기본 클래스를 대체하는 파생 클래스를 안전하게 사용할 수 있고, 디자인을 확장 가능하게 유지할 수 있습니다.

A Simple Example of a Violation of the LSP

struct Point {double x,y;};

struct Shape {
  enum ShapeType {square, 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 {
  Square() : 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(); 
}

위 코드에서 Square와 Circle 클래스(구조체)는 Shape에서 파생되었고 Draw() 함수를 가지지만, Shape의 함수를 오버라이드하지는 않았습니다. Square와 Circle이 Shape로 대체될 수 없으므로 DrawShape 함수는 들어오는 Shape을 검사하고 해당 형식을 결정한 다음 적절한 Draw 함수를 호출해야 합니다.


Square와 Circle이 Shape로 대체될 수 없는 사실은 Liskov Substitution Principle(LSP)을 위반하는 것입니다. 이 위반으로 인해 DrawShape가 Open-Closed Principle(OCP)을 위반하도록 구현되었습니다. 따라서 LSP 위반은 OCP의 잠재적인 위반입니다. 

LSP는 하위 클래스가 상위 클래스를 대체할 수 있어야 함을 나타내며, OCP는 소프트웨어 엔티티(클래스, 모듈 등)를 확장할 수 있으면서 수정할 수 없어야 함을 나타냅니다. 이러한 설계는 LSP와 OCP 원칙을 위반하고, 다형성의 이점을 활용하지 못하는 비효율적인 설계로 이어집니다.

 

저 코드에서 LSP를 지키려면 struct Shape는 Draw라는 추상 메소드를 선언해야하고, 하위 클래스에서 상속받아야 합니다.

Square and Rectangle, a More Subtle Violation

상속은 종종 "IS-A" 관계로 표현된다고 말합니다. 다시 말해, 새로운 종류의 객체가 이전 종류의 객체와 "IS-A" 관계를 갖는다면 새로운 객체의 클래스는 이전 객체의 클래스에서 파생되어야 합니다.

하나 예를 들어봅시다. 정사각형은 직사각형입니다. 따라서 정사각형 클래스를 직사각형 클래스에서 파생된 것으로 보는 것은 논리적입니다. 

하지만 여기엔 문제가 있습니다.

정사각형(Square)이 높이(Height)와 너비(Width) 두 가지 멤버 변수가 모두 필요하지 않다는 것 입니다. 그런데 이러한 멤버 변수들은 직사각형(Rectangle)로부터 상속됩니다. 
정사각형은 SetWidth와 SetHeight 함수를 상속받을 것입니다. 그러나 이러한 함수는 정사각형에 적합하지 않습니다. 왜냐하면 정사각형의 너비와 높이는 동일해야 하기 때문입니다. 이것은 메모리 효율성에서 떨어집니다.

Rectangle and Square that are Self-Consistent.

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 Square::SetWidth(double w) {
  Rectangle::SetWidth(w);
  Rectangle::SetHeight(w);
}
void Square::SetHeight(double h) {
  Rectangle::SetHeight(h);
  Rectangle::SetWidth(h);
}

이제 정사각형(Square)과 직사각형(Rectangle)은 작동하는 것처럼 보입니다. 정사각형 객체에 대해 무엇을 하든지, 그것은 수학적인 정사각형과 일관성을 유지할 것입니다. 그리고 직사각형 객체에 대해 무엇을 하든지, 그것은 수학적인 직사각형으로 유지될 것입니다. 게다가 정사각형을 수학적인 직사각형을 나타내는 포인터 또는 참조를 허용하는 함수에 전달할 수 있으며, 정사각형은 여전히 정사각형처럼 작동하고 일관성을 유지할 것입니다.

The Real Problem

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

그러나 이 결론은 올바르지 않습니다. 다음과 같은 함수 g를 고려해봅시다.

이 함수는 가로(SetWidth)와 세로(SetHeight)를 직사각형(Rectangle)의 멤버로 가정하고 호출합니다. 이 함수는 직사각형에 대해서는 아무런 문제가 없지만 정사각형(Square)을 전달하면 에러를 발생시킵니다. Area는 4x4인 16이기 때문에 assert 문에서 에러가 발생합니다. 입니다.


함수 g는 정사각형/직사각형 계층 구조에 대한 취약성을 보여줍니다. 함수 g는 사실 직사각형 객체에 대한 포인터나 참조를 가져갈 수 있지만, 정사각형 객체에서는 올바르게 작동하지 않습니다. 이 함수에 대해서는 정사각형이 직사각형 대신 사용될 수 없으므로, 정사각형과 직사각형 간의 관계는 LSP를 위반합니다.
함수 g에 직사각형 객체를 호출한 사람은 너비와 높이가 독립적으로 변할 것 이라고 생각하고 함수를 호출했을 것 입니다.

정사각형 객체를 호출한 사람은 어차피 정사각형은 가로와 세로 모두 같으니, 크게 문제가 없을 것이라 생각했을 것 입니다.

하지만 결과적으론 정사각형 객체를 호출한 사람은 에러가 발생하였습니다. 

 

- Validity Is Not Intrinsic

 

LSP는 우리에게 매우 중요한 결론을 이끌어냅니다: 모델은 독립적으로 보았을 때 의미 있는 유효성을 검증할 수 없습니다. 모델의 유효성은 오직 해당 모델을 사용하는 클라이언트의 관점에서 표현될 수 있습니다. 예를 들어 우리가 정사각형(Square)과 직사각형(Rectangle) 클래스의 최종 버전을 독립적으로 검토할 때, 그들이 자체적으로 일관되고 유효하다는 것을 발견했습니다. 그러나 정사각형 객체를 함수 g에 전달한 사람의 관점에서 볼 때, 이 모델은 실패했습니다.
특정 디자인이 적절한지 여부를 고려할 때, 그 해결책을 독립적으로만 볼 수 없습니다. 그것을 해당 디자인의 사용자가 하는 합리적인 가정의 관점에서 봐야 합니다. 디자인 사용자가 어떤 합리적인 가정을 할 것인지를 누가 알 수 있을까요? 대부분 이러한 가정들은 쉽게 예상하기 어렵습니다. 실제로 모든 가정을 예측하려고 하면, 시스템에 불필요한 복잡성의 냄새를 넣게 될 가능성이 높습니다. 따라서 다른 모든 원칙과 마찬가지로 관련된 Fragility가 감지될 때까지 가장 명백한 LSP 위반만 제외하고 나머지를 연기하는 것이 종종 가장 좋습니다.

 

- ISA Is about Behavior


정사각형은 아마도 직사각형이지만, 함수 g의 관점에서 보면 정사각형(Square) 객체는 분명히 직사각형(Rectangle) 객체가 아닙니다. 왜냐하면 정사각형 객체의 동작은 함수 g가 기대하는 직사각형 객체의 동작과 일치하지 않기 때문입니다. 기능의 측면에서 정사각형은 직사각형이 아니며, 소프트웨어는 실제로 기능의 동작에 관한 것입니다. LSP는 OOD(객체지향 설계)에서 IS-A 관계가 합리적으로 가정되며 클라이언트가 의존하는 동작과 관련이 있다는 것을 명확하게 합니다.

즉, 정사각형과 직사각형의 상속 관계가 문제가 되는 것은 정사각형과 직사각형이 기능적인 측면에서 일치하지 않기 때문입니다. 클라이언트는 객체의 동작에 의존하므로 동작 측면에서의 호환성이 중요합니다. 함수 g는 직사각형과 정사각형 사이의 동작적 호환성 부재로 인해 LSP 위반입니다.

 

- Design by Contract

 

"합리적으로 가정되는" 동작이라는 개념에 대해 많은 개발자가 불편할 수 있습니다. 클라이언트가 실제로 무엇을 기대하는지 어떻게 알 수 있을까요? 이러한 합리적인 가정을 명시적으로 만들어 LSP(리스코프 치환 원칙)를 강제하는 기술이 있습니다. 이 기술은 디자인 바이 컨트랙트(Design by Contract, DBC)라고 하며 Bertrand Meyer에 의해 제안되었습니다.

DBC를 사용하면 클래스의 저자는 해당 클래스의 Contract를 명시적으로 선언합니다. Contract는 클라이언트 코드의 저자에게 의존할 수 있는 동작에 대한 정보를 제공합니다. Contract는 각 메서드의 사전조건(preconditions)과 사후조건(postconditions)을 선언하여 명시됩니다. 사전조건은 메서드가 실행되기 위해 참이어야 합니다. 메서드 완료 시, 메서드는 사후조건이 참임을 보장합니다.

다시 말하면, 기본 클래스 인터페이스를 통해 객체를 사용할 때 사용자는 기본 클래스의 사전조건과 사후조건만을 알게 됩니다. 따라서 파생된 객체는 기본 클래스가 요구하는 것보다 강한 사전조건을 지켜야 하는 사용자를 기대해서는 안 됩니다. 즉, 기본 클래스가 허용하는 것은 받아들여야 합니다. 또한 파생 클래스는 기본 클래스의 모든 사후조건을 준수해야 합니다. 그들의 동작과 출력은 기본 클래스에 대한 제약 사항을 위반해서는 안 됩니다. 기본 클래스의 사용자는 파생 클래스의 출력으로 혼동되서는 안 됩니다.

 

- Specifying Contracts in Unit Tests

 

Contract는 단위 테스트를 작성함으로써 지정할 수도 있습니다. 클래스의 동작을 철저하게 테스트함으로써, 단위 테스트는 클래스의 동작을 명확하게 합니다. 클라이언트 코드의 저자들은 클래스를 사용할 때 어떤 합리적인 가정을 할 수 있는지를 알기 위해 이러한 단위 테스트를 검토하고 싶어할 것입니다.

Conclusion

  • OCP(Open-Closed Principle)은 객체 지향 설계(OOD)에 대한 핵심입니다. 이 원칙이 적용되면 응용 프로그램은 더 쉽게 유지 관리 가능하고 재사용 가능하며 견고해집니다.
  • LSP(Liskov Substitution Principle)는 OCP의 주요한 구현 요소 중 하나입니다. 하위 유형의 대체 가능성을 허용하기 때문에, 기본 유형으로 표현된 모듈을 수정하지 않고 확장할 수 있습니다.
  • 이 대체 가능성은 개발자들이 암묵적으로 의존할 수 있는 것이어야 합니다. 따라서 기본 유형의 제약은 코드에서 명시적으로 강제되지 않더라도 명확하게 이해되거나 두드러지게 알려져야 합니다.
  • "IS-A"라는 용어는 하위 유형의 정의로 너무 넓습니다. 하위 유형의 진정한 정의는 "대체 가능성"으로, 이 대체 가능성은 명시적 또는 암묵적 제약에 의해 정의됩니다.