디자인 패턴과 SOLID 디자인 원칙 - ③ 리스코프 치환 원칙
디자인 패턴은 소프트웨어 개발과정에서 자주 마주치게 되는 문제들에 적용할 수 있는 몇 가지 패턴들을 의미합니다. 디자인 패턴을 활용하면 읽기 쉽고, 이해하기 쉽고, 수정하기 쉬우며 재사용성이 높은 코드를 작성할 수 있게 됩니다. 그리고 이런 효율적인 코드를 작성하기 위해서 제안된 중요한 다섯 개의 원칙이 있습니다. 이 원칙들의 앞글자를 따서 SOLID 디자인 원칙이라 부르고 그 다섯 개의 원칙은 다음과 같습니다.
- Single Responsibility Principle(SRP, 단일 책임 원칙)
- Open-Closed Principle(OCP, 열림-닫힘 원칙)
- Liskov Substitution Principle(LSP, 리스코프 치환 원칙)
- Interface Segregation Principle(ISP, 인터페이스 분리 원칙)
- Dependency Inversion Principle(DIP, 의존성 역전 원칙)
이 다섯개의 원칙들은 디자인 패턴이 존재하는 이유에 모두 녹아들어 있기 때문에 디자인 패턴을 본격적으로 다루기 전에 이 5개의 원칙들을 먼저 살펴볼 필요가 있습니다. 이번 글에서는 세 번째 리스코프 치환 원칙을 살펴보겠습니다.
Liskov Substitution Principle(LSP, 리스코프 치환 원칙)
리스코프 치환 원칙이란? "자료형 S가 자료형 T의 하위형이라면 필요한 프로그램의 속성의 변경 없이 자료형 T의 객체를 자료형 S의 객체로 교체(치환)할 수 있어야 한다."(위키피디아) 라는 원칙입니다. 다른 조금 더 쉽게 표현하면 "자식 객체에 접근할 때 그 부모 객체의 인터페이스로 접근하더라도 아무런 문제가 없어야 한다." 라는 뜻입니다. 이번에는 LSP를 만족하는 예시를 먼저 살펴보겠습니다.
LSP를 만족하는 설계
Animal 클래스를 상속받은 Cat, Dog 클래스가 있습니다.
class Animal {
public:
Animal() {}
virtual void Speak() = 0;
virtual ~Animal() {}
};
class Cat : public Animal {
public:
Cat() {}
void Speak() override { cout << "Mewo~" << endl; }
};
class Dog : public Animal {
public:
Dog() {}
void Speak() override { cout << "Bark~!" << endl; }
};
int main()
{
vector<Animal*> animals;
animals.push_back(new Cat());
animals.push_back(new Dog());
animals.push_back(new Cat());
animals.push_back(new Dog());
for (auto anim : animals)
{
anim->Speak();
delete anim;
}
return 0;
}
앞서 LSP의 의미가 "자식 객체에 접근할 때 그 부모 객체의 인터페이스로 접근하더라도 아무런 문제가 없어야 한다." 라는 했습니다. Cat/Dog가 Animal의 자식 객체입니다. 그래서 Cat/Dog의 객체에 접근할 때 Animal의 인터페이스로 접근하더라도 아무런 문제가 없기 때문에 LSP를 만족합니다.
LSP를 위배하는 설계
반면 다음과 같은 예시를 살펴봅시다.
class Animal {
public:
Animal() {}
virtual void Speak() = 0;
virtual ~Animal() {}
};
class Cat : public Animal {
public:
Cat() {}
void Speak() override { cout << "Mewo~" << endl; }
};
class Dog : public Animal {
public:
Dog() {}
void Speak() override { cout << "Bark~!" << endl; }
};
class Car : public Animal {
public:
Car() {}
void Speak() override { throw exception(); }
};
int main()
{
vector<Animal*> animals;
animals.push_back(new Cat());
animals.push_back(new Dog());
animals.push_back(new Cat());
animals.push_back(new Dog());
animals.push_back(new Car());
for (auto anim : animals)
{
anim->Speak();
delete anim;
}
return 0;
}
Car는 Animal의 자식 객체이지만, Car는 Speak할 수 없습니다. 그래서 Car 객체에서는 Speak가 호출될 경우 예외를 발생하도록 하였습니다. 이렇게 되니 Animal 포인터를 활용하여 Car 객체의 Speak함수를 호출할 수 없습니다. 따라서 위의 설계는 LSP를 위배하는 설계입니다. 자동차는 동물이 아니다 라는 것은 의미론적으로 너무 자명하기 때문에 이런 구조를 굳이 만드는 경우는 드물 것입니다.
하지만 아래의 예시를 살펴봅시다. 아래의 예시는 사각형 Rectangle을 상속받아 정사각형 Square를 만드는 예시입니다.
class Rectangle {
public:
Rectangle() {}
virtual void SetWidth(int width) { mWidth = width; }
virtual void SetHeight(int height) { mHeight = height; }
virtual ~Rectangle() {}
int Area() const { return mWidth * mHeight; }
protected:
int mWidth;
int mHeight;
};
class Square : public Rectangle{
public:
Square() {}
void SetWidth(int width) { mWidth = mHeight = width; }
void SetHeight(int height) { mWidth = mHeight = height; }
};
int main()
{
vector<Rectangle*> rectangles;
rectangles.push_back(new Rectangle());
rectangles.push_back(new Square());
rectangles.push_back(new Rectangle());
rectangles.push_back(new Square());
rectangles.push_back(new Rectangle());
for (auto rect : rectangles)
{
rect->SetWidth(10);
rect->SetHeight(20);
if (rect->Area() != 200)
{
cout << "Unexpected result" << endl;
}
delete rect;
}
return 0;
}
직사각형으로부터 상속받아 정사각형을 만들었기 때문에 정사각형은 부모 클래스인 직사각형의 인터페이스를 정상적으로 활용할 수 있어야 합니다. 그리고 상식적으로 직사각형의 가로를 10, 세로를 20으로 설정한다면 사각형의 넓이는 항상 200이 나와야 합니다. 하지만, 위의 코드에서 정사각형 객체의 경우 사용자의 기대와 다르게 넓이가 400인 결과가 나옵니다. 그래서 위 예시는 LSP를 위반하는 예시가 됩니다.
이를 LSP가 만족하도록 수정하기 위해서는 직사각형과 정사각형의 간의 상속관계를 없애는 방법 등을 고려해야 합니다. 모든 직사각형은 정사각형입니다. 그렇다고 해서 직사각형을 상속받아 정사각형을 만드는 것이 꼭 효율적인 방법이 아닐 수 있다는 교훈을 이번 예시에서 얻을 수 있습니다.
<출처>
유튜브 채널 "코드없는 프로그래밍": https://www.youtube.com/channel/UCHcG02L6TSS-StkSbqVy6Fg
길벗 출판사: 모던 C++ 디자인 패턴(Dmitri Nesteruk 저)
위키피디아: https://en.wikipedia.org/wiki/Liskov_substitution_principle