클린 소프트웨어 - 리스코프 치환 원칙

리스코프 치환 원칙

OCP는 추상화와 다형성의 매커니즘을 내포한다. 다양한 언어에서 추상화와 다형성을 지원하는 매커니즘으로 상속을 지원한다.

상속의 특별한 사용을 규율하는 설계 법칙은 무엇일까? 가장 바람직한 상속 계층 구조의 특징은 무엇일까? OCP를 따르지 않은 계층 구조를 만들게 해버리는 함정에는 어떤 것이 있을까?

LSP는 위와 같은 질문에 답을 준다.

리스코프 치환 원칙

Liskov Substitution Principle

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

class B: A {}

func foo(_ bar: A) {}

// 아래의 코드가 기대하지 않은 잘못된 동작을 함
foo(B())

어떤 함수가 기반 클래스 A의 레퍼런스를 요구하는데, A의 파생 클래스 B가 함수에 넘겨져서 함수가 잘못된 동작을 하게 한다면, 파생 클래스 B는 LSP를 위반한 것이다. 파생 클래스 B는 함수에 대해 취약하다.

함수의 작성자는 이 때 파생 클래스 B에 대한 테스트를 넣어 파생 클래스 B를 넘겨받아도 함수가 제대로 동작하게 할 수 있을 것이다. 하지만 함수가 기반 클래스 A의 모든 파생 클래스에 대해 닫혀 있지 않으므로 OCP를 위반하게 된다.

LSP 위반의 간단한 예

LSP 위반은 대개 OCP를 위반하는 런타임 타입 정보 사용으로 이어진다. 조건문을 사용하여 객체의 타입을 검사하고 그 타입에 맞는 행위를 선택할 수 있게 하는 코드를 작성하는 것이다.

struct Point {
let x: Double
let y: Double
}

class Shape {
enum ShapeType {
case square
case circle
}
let shapeType: Shape.ShapeType
init(shapeType: Shape.ShapeType) {
self.shapeType = shapeType
}
}

class Circle: Shape {
let center: Point
let radius: Double
func draw() { ... }
}

class Square: Shape {
let topLeft: Point
let side: Double
func draw() { ... }
}

func drawShape(_ shape: Shape) {
switch shape.shapeType {
case .circle:
(shape as? Circle)?.draw()
case .square:
(shape as? Square)?.draw()
}
}

drawShape(_:) 함수는 명백하게 OCP를 위반한다. 이 함수는 기반 클래스 Shape의 모든 파생 클래스를 알아야 하며, 새로운 파생 클래스가 생길 때마다 변경되어야 하기 때문이다.

위의 코드에서 기반 클래스 Shape는 특별한 동작(메소드)을 포함하고 있지 않으며, 이를 상속받은 파생 클래스에서 특정한 동작을 구현하고 있다. 때문에 파생 클래스들은 기반 클래스를 대체할 수 없어 LSP 위반이며, 이로 인해 drawShape(_:) 함수의 OCP 위반을 유발하다.

그러므로 LSP 위반은 잠재적인 OCP 위반이다.

정사각형과 직사각형, 좀 더 미묘한 위반

class Rectangle {
private var _topLeft: Point
private var _width: Double
private var _height: Double

func setWidth(_ width: Double) { self.width = width }
func setHeight(_ height: Double) { self.height = height }
var width: Double { return _width }
var height: Double { return _height }
}

직사각형을 나타내는 위와 같은 Rectangle 클래스가 있고, 이를 잘 사용하고 있다고 가정하자. 이후 사용자는 정사각형을 사용하기를 원했다. 개발자는 상속이 IS-A 관계로 이해되는 것에 따라 Rectangle 클래스를 상속받은 Square 클래스를 만들기로 결정했다.

정사각형은 직사각형이기 때문이다. (Square is a Rectangle)

class Square: Rectangle { ... }

하지만 이러한 방식의 생각은 미묘하지만 심각한 문제를 낳을 수 있다. 일반적으로 이러한 문제는 코드에서 보게 되기 전까지는 예측할 수 없다.

다음의 문제에 따라 코드에서 정사각형은 직사각형이 될 수 없다.

  • SquareRectangle_width_height 프로퍼티를 필요로 하지 않는다.

정사각형의 모든 변의 길이는 같기 때문에, 너비와 높이를 분리하여 생각할 필요가 없다. 하지만 SquareRectangle로부터 _width_height를 상속받는다. 이는 분명히 소모적이다.

  • SqaureRectanglesetWidth(_:)setHeight(_:) 메소드를 상속받는다.

위에서 언급한 대로, 정사각형의 모든 변의 길이는 같기 때문에, 이 두 개의 메소드는 Sqaure에서 부적절하다.

위와 같은 문제를 피하기 위해 Square는 상속받은 메소드를 오버라이드할 수 있을 것이다.

class Square: Rectangle {
// ...
override func setWidth(_ width: Double) {
super.setWidth(width)
super.setHeight(width)
}

override func setHeight(_ height: Double) {
super.setWidth(height)
super.setHeight(height)
}
// ...
}

이러한 코드를 작성하여 Square 객체는 수학적으로 정확한 정사각형이 된다.

본질적인 문제

위의 설계는 이제 올바르다고 말할 수 있을까? 모순이 없는 설계라고 해서 반드시 모든 사용자에 대해 모순이 없는 것은 아니다.

func foo(_ rectangle: Rectangle) {
rectangle.setWidth(5)
rectangle.setHeight(4)
assert(rectangle.width * rectangle.height == 20)
}

위의 함수는 인자에 Rectangle의 객체가 넘겨졌을 때는 문제가 없지만, Square의 객체가 넘겨졌을 때는 assertion이 발생한다.

따라서 본질적인 문제는, foo 함수의 작성자는 Rectangle의 너비를 바꾸는 것이 높이를 바꾸지 않을 것이라고 생각한다’는 것에 있다.

Rectangle 객체가 너비를 바꾸는 것이 높이에 영향을 미치지 않는 것, 높이를 바꾸는 것이 너비에 영향을 미치지 않는다고 생각하는 것은 당연하지만, 위의 설계로 인해 Rectangle로서 넘겨질 수 있는 모든 객체가 이 가정을 만족하지 않게 되었다.

그러므로 함수 fooSquare / Rectangle 계층 구조에 대해 취약하다. SquareRectangle로 치환 가능하지 않으며, 그렇기 때문에 이 객체 간 관계는 LSP를 위반한다.

Rectangle 객체와 Sqaure 객체 각각에 대한 불변식은 위반되지 않았다. SquareRectangle에서 파생됨으로써 Rectangle의 불변식을 위반하게 된 것이다.

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

LSP는 ‘모델만 별개로 보고, 그 모델의 유효성을 충분히 검증할 수 없다’라는 아주 중요한 결론을 내린다. 모델의 유효성은 오직 모델을 사용하는 클라이언트의 관점에서만 표현될 수 있다. 위의 예제에서 SquareRectangle 각각의 모델만 바라보았을 때는 모순이 없고 유효하다는 결론을 내릴 수 있으나, 실제로는 그렇지 않다는 것이 증명되었다.

그러므로 특정 설계가 적합한지 아닌지를 판단할 때는 사용자가 택한 합리적인 가정의 관점에서 보아야 한다.

합리적인 가정이 무엇이며, 어떻게 모든 합리적인 가정을 예상할 수 있을까? 모든 가정을 예상하고 대처하려 하는 것은 시스템을 불필요한 복잡성의 악취에 빠지게 할 것이다. 그러므로 관련된 취약성의 악취를 맡을 때까지 가장 명백한 LSP 위반을 제외한 나머지의 처리는 연기하는 것이 최선이다.

‘IS-A’는 행위에 대한 것이다

일반적으로 ‘정사각형은 직사각형이다’라는 명제는 성립하지만, 코드로 표현했을 때 이러한 관계가 성립하지 않았다. Square 객체의 행위가 이를 사용하는 고객이 기대하는 Rectangle 객체의 행위와 일치하지 않기 때문이다. 행위 측면에서 볼 때 SquareRectangle이 아니고, 행위야말로 소프트웨어의 모든 것이다.

LSP는 객체 지향 개발에서, IS-A 관계는 합리적으로 가정할 수 있고 클라이언트가 의존하는 행위와 관련이 있다는 점을 분명히 한다.

계약에 의한 설계

‘합리적 추정’이란 무엇인가? 고객이 정말로 기대하는 것을 개발자가 어떻게 알 수 있을까?

이러한 합리적인 추정을 명시적으로 만들어 LSP를 강제할 수 있는데, 이를 계약에 의한 설계DBC: Design By Contract라고 한다.

  • 클래스의 작성자는 해당 클래스의 계약 사항을 명시적으로 정한다.
  • 계약은 이 클래스를 사용하는 모든 고객에 대한 코드를 작성하는 사람이 신뢰할 수 있는 행위에 대해 알려준다.
  • 이 계약은 각 메소드의 사전조건과 사후조건을 선언하는 것으로 구체화된다.
    • 메소드를 실행하기 위해서는 사전조건이 참이 되어야 한다.
    • 메소드는 완료되고 나면 사후조건이 참이 됨을 보장한다.

예를 들어 Rectangle.setWidth(_ width: Double)의 사후조건을 다음과 같이 설정할 수 있을 것이다.

// 현재 너비는 인자로 들어온 값과 같아야 한다.
// 현재 높이는 메소드 실행 이전의 높이와 같아야 한다.
assert((self.width == width) && (self.height == old.height))

사전조건과 사후조건에 대한 규칙은 다음과 같다.

파생 클래스에서 로직을 다시 작성할 때, 오직 원래 사전조건과 같거나 더 약한 수준에서 로직을 대체할 수 있고, 원래 사후조건과 같거나 더 강한 수준에서 로직을 대체할 수 있다.

정리하자면, 기반 클래스의 인터페이스를 통해 어떠한 객체를 사용할 때, 사용자는 해당 기반 클래스의 사전조건과 사후조건만을 알 수 있다. 그러므로 파생된 객체는 기반 클래스가 요구하는 것보다 더 강한 사전조건을 따를 것이라고 기대할 수 없다. 즉 파생된 객체는 기반 클래스가 받아들일 수 있는 모든 것을 받아들일 수 있어야 한다.

또한 파생 클래스는 기반 클래스의 모든 사후조건을 따라야 한다. 즉 파생 클래스의 행위와 출력은 기반 클래스의 제약을 위반해서는 안된다. 기반 클래스의 사용자가 파생 클래스의 출력에 의해 혼란스러워해서는 안 된다.

예를 들어 Square.setWidth(_ width: Double)의 사후조건을 다음과 같이 설정할 수 있을 것이다.

// 현재 너비는 인자로 들어온 값과 같아야 한다.
// 현재 높이는 인자로 들어온 값과 같아야 한다.
assert((self.width == width) && (self.height == width))

Rectangle.setWidth(_:)에 대한 로직을 다시 작성한 Sqaure.setWidth(_:)에 대하여, 파생 클래스가 기반 클래스의 사후조건을 모두 충족하지 않으므로, 해당 메소드의 사후조건은 기반 클래스의 사후조건보다 약하다. 따라서 Square.setWidth(_:) 메소드는 기반 클래스의 계약을 위반한다.

각 메소드의 주석에서 사전조건과 사후조건을 문서화해두면 유용할 것이다.

단위 테스트에서의 계약사항 구체화하기

계약은 단위 테스트를 작성하는 것으로 구체화될 수 있다.

단위 테스트는 클래스의 행위를 철저하게 테스트하여, 해당 클래스의 행위를 좀 더 분명하게 만들어준다.

고객 코드를 작성하는 사람은 단위 테스트를 관찰하여 이들이 사용하는 클래스에 대한 합리적 추정이 무엇인지 알 수 있을 것이다.

실제 예

동기

서드 파티 클래스 라이브러리는 다음과 같이 구성되어 있다.

  • BoundedSet : 배열 기반. 정적 할당
  • UnboundedSet : 연결 리스트 기반. 동적 할당

서드 파티 라이브러리가 제공하는 위의 클래스의 인터페이스를 그대로 사용하기에 불편해서 나중에 좀 더 나은 클래스로 교체하고 하였고, 이를 위해 애플리케이션 코드가 이들에 의존하지 않도록 하고 싶다. 이를 위해 추상 인터페이스를 정의하여 이 서드 파티 컨테이너를 포장한다.

Set이라는 이름을 가진 추상 클래스를 만들고, 요소의 추가 및 삭제, 포함 여부를 확인할 수 있는 메소드를 정의한다. 이 구조를 통해 BoundedSetUnboundedSet을 통합하고 공통의 인터페이스를 통해 이들에 접근할 수 있게 된다.

클라이언트 코드는 Set 타입의 인자를 받을 수 있고, 이것이 실제로 BoundedSet인지 UnboundedSet인지는 신경 쓰지 않는다.

// Swift로는 다음과 같이 표현할 수 있을 것이다.
protocol Set {
associatedtype Element
func add(_: Element)
func delete(_: Element)
func contains(_: Element) -> Bool
}

extension BoundedSet: Set { ... }
extension UnboundedSet: Set { ... }

개발자가 각 특정 인스턴스에서 어떤 종류의 Set이 필요한지 결정할 수 있고, 클라이언트 코드는 이 결정에 영향을 받지 않는다.

문제

영속성을 갖는 PersistentSet을 위의 계층 구조에 추가하고자 한다. 이는 PersistentObject 클래스에서 파생된 객체를 받아들인다. 그러므로 이를 Set을 사용한 계층 구조에 포함시킬 때, BoundedSet이나 UnboundedSetPersistentObject로부터 상속되지 않으나 해당 인터페이스에 전달될 수 있게 된다.

클라이언트가 멤버를 추가할 때 이것이 실제로는 PersistentObject인지 알 수 없다. 자신이 추가하는 원소가 PersistentObject에서 파생된 것인지 알 수 없다.

protocol PersistentSet: Set {}

PersistentSet에 대한 멤버 추가 메소드는 먼저 인자로 들어온 Set 타입의 객체를 PersistentObject로 캐스팅한다. 이것이 실패하면 런타임 에러가 발생한다. Set의 클라이언트 중 그 어느 것도 멤버를 추가하는 동작에서 예외가 발생할 것이라고 기대하지 않는다. 이러한 동작은 Set의 파생 클래스에서 혼동되므로 LSP를 위반한다.

LSP를 따르지 않는 해결책

저자는 이러한 문제를 소스 코드가 아닌 규정convention에 따라 해결했다. 이를 잘 이해하지 못한 다른 개발자들은 이 규정을 위반하기 쉬웠고, 이 규정을 각 개발자에게 끊임 없이 알려야 했다.

규정은 완벽한 해결책이 되지 못한다.

LSP를 따르는 해결책

PersistentSetSet이지만, 행위에 있어서 IS-A 관계가 성립하지 않으므로, PersistentSetSet을 상속받는 구조를 작성해서는 안된다.

LSP 관점에서 문제가 되는 행위는 멤버를 추가하는 행위에 대한 것이므로, SetPersistentSet의 행위 중 공통된 것을 묶는 별도의 것을 만들고, 각각에 대해서 멤버를 추가하는 행위를 정의하여 해결할 수 있을 것이다.

protocol MemberContainer {
associatedtype Element
func remove(_: Element)
func contains(_: Element) -> Bool
}

protocol Set: MemberContainer {
func add(_: Element)
}

protocol PersistentSet: MemberContainer {
func add(_: Element)
}

파생 대신 공통 인자 추출하기

LineLineSegment의 관계를 살펴 본다. (Line은 선, LineSegment는 선을 구성하는 어떠한 구획을 의미한다.)

LineSegmentLine에서 선언된 모든 멤버 변수와 메소드를 필요로 하면서, 길이를 구하는 고유 메소드를 추가로 갖고, 어떠한 점이 선 위에 있음을 반환하는 메소드를 오버라이드한다.

선의 y절편이 선의 위에 있다는 것이 참임을 기대할 수 있으나, 이를 재정의한 대부분의 선의 구획에 대해서는 y절편이 선의 구획 위에 있다는 것을 참으로 판정하지 않는다.

이 때 다음의 선택지 중 하나를 고를 수 있을 것이다.

  • 설계를 고쳐서 LSP를 따르는 설계를 만들기
  • 다형적인 행위에 있어서 미묘한 결점은 그대로 놔두기

완벽을 추구하는 것과 타협안을 받아들이는 것 사이의 트레이드오프를 생각한다.

좋은 엔지니어는 완벽보다 타협이 유리할 때를 안다. 하지만 LSP를 가볍게 포기해서는 안 된다. 기반 클래스가 사용되는 곳에서 서브 클래스가 항상 제대로 동작하게 하는 것은 복잡성을 다루는 강력한 방법이기 때문이다. 이를 어긴다면 각 서브 클래스를 개별적으로 다루어야 할 것이다.

이 때 두 클래스의 공통된 원소를 추출하여 추상 기반 클래스를 만드는 것으로 해결할 수 있다. 각 클래스가 이를 상속받는 구조를 갖게 된다.

기존에 LineSegmentLine을 상속받았는데, 이 두 개의 클래스의 공통 원소를 추출하여 만든 LineObject를 각각의 클래스가 상속받도록 하는 구조로 변경할 수 있다.

공통 인자 추출은 많은 양의 코드가 작성되지 않았을 때 가장 적용하기 편한 설계 수단이다.

어떤 클래스 집합이 모두 같은 책임을진다면, 공통 슈퍼 클래스에서 그 책임을 상속받아야 한다.

공통 슈퍼 클래스가 아직 존재하지 않는다면, 하나 만들어서 공통 책임을 이 클래스에 넘겨라. 언젠가 쓸모가 있게 될 것이다.

휴리스틱과 규정

휴리스틱heuristic : 체험적인

규정convention

기반 클래스에서 어떻게든 기능성을 제거한 파생 클래스에 대해 적용한다. 기반 클래스보다 덜한 동작을 하는 파생 클래스는 일반적으로 기반 클래스와 치환할 수 없으므로 LSP를 위반한다.

파생 클래스에서의 퇴화 함수

class Base {
func foo() { ... }
}

class Derived: Base {
override func foo() {}
}

파생 클래스에서 메소드는 퇴화된다. 이것이 무조건 LSP를 위반한다고 할 수는 없지만, 이것이 일어났을 때 위반 여부를 살펴볼 만한 가치는 있다.

파생 클래스에서의 예외 함수

기반 클래스가 발생시키지 않는 예외를 파생 클래스의 메소드에 추가한다. 기반 클래스의 사용자가 예외를 기대하지 않는다면 치환 가능하지 않다. 사용자의 기대가 변하거나, 파생 클래스가 그 예외를 발생시키지 않아야 한다.

결론

OCP는 객체 지향 개발의 핵심이다. 이 원칙을 준수했을 때 애플리케이션은 좀 더 유지보수 가능해지고, 재사용 가능해지고, 견고해진다.

LSP는 OCP를 가능하게 하는 주요 요인 중 하나이다. 기반 타입으로 표현된 모듈을 수정 없이도 확장 가능하게 만드는, 서브 타입의 치환 가능성을 말한다.

‘IS-A’라는 용어는 서브 타입의 정의가 되기에는 그 의미가 지나치게 넓다. 서브 타입의 진실된 정의는 ‘치환 가능성’이다. 이는 명시적 또는 암묵적 계약에 의해 정의된다.