Computer Science/객체지향

LSP (리스코프 치환 원칙) in SOLID

sh1mj1 2025. 2. 11. 18:35

소개

이전 글에서는 SOLID 원칙 중 첫 번째와 두 번째 원칙인 단일 책임 원칙(SRP)개방-폐쇄 원칙(OCP)을 다뤘습니다.
아직 읽어보지 않았다면 먼저 확인해 보시길 추천합니다.

🔹 SRP(단일 책임 원칙) 글 보기
🔹 OCP(개방-폐쇄 원칙) 글 보기

이번 글에서는 세 번째 원칙인 리스코프 치환 원칙(Liskov Substitution Principle, LSP)을 살펴보겠습니다.
이 원칙은 객체 지향 시스템에서 유연하고 안정적인 구조를 설계하는 핵심 개념입니다.


LSP(리스코프 치환 원칙)란?

리스코프 치환 원칙(Liskov Substitution Principle, LSP)은 다음과 같이 정의됩니다.

"부모 클래스(상위 타입)의 객체를 자식 클래스(하위 타입)의 객체로 대체하더라도
프로그램의 정확성이 유지되어야 한다."

 

쉽게 풀어보면:

  • B 클래스가 A 클래스의 하위 클래스라면, A의 객체가 필요한 곳에서 B의 객체를 사용할 수 있어야 한다.
  • 상위 타입을 하위 타입으로 치환할 때, 프로그램이 정상적으로 동작해야 한다.
  • 하위 클래스는 상위 클래스의 계약(Contract)을 위반해서는 안 된다.

이 원칙을 위반하면, 상속을 잘못 사용한 결과로 코드가 예상과 다르게 동작하게 됩니다.
이를 실제 코드 예제를 통해 확인해보겠습니다.


LSP를 위반하는 예제

📌 초기 구현 (OCP를 만족하는 코드)

이전 글에서 다룬 OCP(개방-폐쇄 원칙)를 준수한 코드입니다.
로또를 판매하는 `LottoSeller`를 인터페이스로 정의하고, 이를 구현한 `NormalLottoSeller`와 `DiscountedLottoSeller`를 분리하였습니다.

class Customer {
    fun buyLotto(
        money: Int,
        lottoSeller: LottoSeller,
    ): List<Lottery> = lottoSeller.soldLotto(money)
}

interface LottoSeller {
    fun soldLotto(money: Int): List<Lottery>
}

class DiscountedLottoSeller : LottoSeller {
    override fun soldLotto(money: Int): List<Lottery> {
        val count = money / LOTTO_PRICE
        return List(count) { LotteryGenerateStrategy().autoGenerate() }
    }

    companion object {
        private const val LOTTO_PRICE = 500
    }
}

class NormalLottoSeller : LottoSeller {
    override fun soldLotto(money: Int): List<Lottery> {
        val count = money / LOTTO_PRICE
        return List(count) { LotteryGenerateStrategy().autoGenerate() }
    }

    companion object {
        private const val LOTTO_PRICE = 1000
    }
}

data class Lottery(val numbers: List<Int>) {
    init {
        numbers.forEach {
            require(numbers.size == NUMBER_COUNT) { "Invalid lotto number count" }
            require(it in MIN_NUMBER..MAX_NUMBER) { "Invalid lotto number" }
        }
    }

    companion object {
        private const val MIN_NUMBER = 1
        private const val MAX_NUMBER = 45
        val numberRange = (MIN_NUMBER..MAX_NUMBER)

        const val NUMBER_COUNT = 6
    }
}

위 코드에서 LottoSeller 인터페이스를 구현한 두 개의 클래스는 OCP를 준수하고 있습니다.
그러나 새로운 요구사항이 추가되면 LSP를 위반할 가능성이 발생합니다.


📌 새로운 요구사항: 로또와 도형(Shape) 속성 추가

새로운 기능 요구사항

로또 시스템에서 각 로또에는 특정한 도형(Shape) 속성이 추가됩니다.
로또는 직사각형(Rectangle) 또는 정사각형(Square)을 포함할 수 있습니다.

다음과 같이 `Square` 클래스를 `Rectangle`의 하위 클래스로 구현해보겠습니다.

data class Lottery(
    val numbers: List<Int>, 
    val rectangle: Rectangle = Rectangle()
) {
    // ...
}

open class Rectangle {
    private var height = 0
    private var width = 0

    open fun setHeight(height: Int) {
        this.height = height
    }

    open fun setWidth(width: Int) {
        this.width = width
    }

    fun area(): Int = height * width
}

class Square : Rectangle() {
    override fun setHeight(height: Int) {
        setSide(height)
    }

    override fun setWidth(width: Int) {
        setSide(width)
    }

    private fun setSide(side: Int) {
        super.setHeight(side)
        super.setWidth(side)
    }
}

🔍 문제 발생: LSP 위반

이제 테스트 케이스를 작성해보면, 문제가 발생하는 것을 확인할 수 있습니다.

Given("A square, type of it is Rectangle (super type of Square)") {
    val square: Rectangle = Square()

    When("set width 2 and height 5") {
        square.setWidth(2)
        square.setHeight(5)
        Then("area should be 10 but actually it is 25") {
            shouldThrow<AssertionFailedError> {
                square.area() shouldBe 10
            }
            square.area() shouldBe 25
        }
    }
}

LSP가 위반된 이유는 다음과 같습니다:

🚨 잘못된 치환 (Incorrect Substitution)

  • `Square` 클래스를 `Rectangle`의 하위 클래스로 사용했지만, 정사각형(Square)은 항상 가로=세로가 같아야 하는 제약이 있습니다.
  • 즉, `setWidth()`와 `setHeight()`를 개별적으로 조작할 수 없어야 하지만, `Rectangle`을 확장하면서 이런 문제가 발생했습니다.

🚨 잘못된 상속 관계 (Inheritance Misuse)

  • `Square`는 `Rectangle`의 진정한 하위 클래스가 아닙니다.
  • 두 클래스는 'is-A' 관계로 설계되었지만, 실제로는 `Square`가 `Rectangle`을 대체할 수 없습니다.

🚨 계약 위반 (Contract Violation)

  • `Rectangle`의 기본 동작을 변경하는 `Square`는 부모 클래스의 기대 동작을 깨뜨렸습니다.
  • 이는 LSP 위반의 전형적인 사례입니다.

📌 LSP를 준수하는 방법: 상속 대신 인터페이스 사용

LSP를 준수하기 위해, 우리는 `Rectangle`과 `Square`의 관계를 상속이 아닌 인터페이스로 변경해야 합니다.

✅ 리팩토링된 코드

interface Shape {
    fun area(): Int
}

class Rectangle(var height: Int = 0, var width: Int = 0) : Shape {
    override fun area(): Int = height * width
}

class Square(var side: Int = 0) : Shape {
    override fun area(): Int = side * side
}

🔍 개선된 점

✅ `Rectangle`과 `Square`는 이제 독립적인 개체로 동작합니다.
✅ `Shape` 인터페이스를 구현하면서, 각 클래스는 자체적인 동작을 정의할 수 있습니다.
✅ `Rectangle`과 `Square`가 상속 관계가 아니므로, LSP 위반이 발생하지 않습니다.
✅ 이제 새로운 도형(예: 원, 삼각형)도 손쉽게 추가할 수 있습니다.


결론

Liskov Substitution Principle (LSP)하위 클래스(또는 구현체)가 상위 클래스의 객체를 대체할 수 있어야 함을 보장합니다.

🚨 LSP를 위반하는 원인은 잘못된 상속 관계 때문입니다.
✔ 상속(구현 상속)이 아닌 인터페이스 또는 컴포지션(Composition)을 활용하는 것이 더 적절할 수 있습니다.
is-A 관계만 고려하는 것이 아니라, 실제로 대체 가능(Substitutable)한지 고민해야 합니다.


다음 글 예고

다음 글에서는 ISP(인터페이스 분리 원칙, Interface Segregation Principle)을 다루겠습니다.
ISP를 통해 불필요한 의존성을 제거하고 더 유연한 설계를 하는 방법을 배워보겠습니다! 🚀


Liskov Substitution Principle

📌 추가 참고 자료

📖 위키백과: 리스코프 치환 원칙