SOLID 원칙과 리스코프 치환 원칙(LSP) | Today I Learned

hoya·2022년 9월 5일
0

Today I Learned

목록 보기
9/11
post-thumbnail

피드백은 언제나 환영합니다 :)


👨‍👩‍👧‍👦 리스코프 치환 원칙(Liskov Substitution Principle)

프로그램의 부모 타입은 자식 타입으로 대체가 가능해야 한다.
즉, 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용해도 아무런 문제가 없어야 한다.

가장 이해하기 어려웠던 리스코프 치환 원칙이다. 아래 코드를 보자.

abstract class 개발자 {
    abstract fun 개발하기()
    abstract fun 회의하기()
    abstract fun 기획하기()
}

class 주니어 : 개발자() {
    override fun 개발하기() {
        println("주니어 개발자가 개발하고 있읍니다~")
    }

    override fun 회의하기() {
        println("주니어 개발자가 회의하고 있읍니다~")
    }

    override fun 기획하기() {
        throw Error("주니어는 그런거 못해!") // Error
    }
}

class 시니어 : 개발자() {
    override fun 개발하기() {
        println("시니어 개발자가 개발하고 있읍니다~")
    }

    override fun 회의하기() {
        println("시니어 개발자가 회의하고 있읍니다~")
    }

    override fun 기획하기() {
        println("시니어 개발자가 기획하고 있읍니다~")
    }
}

fun main() {
    val 개발자A = 주니어()
    val 개발자B = 시니어()

    개발자A.개발하기()
    개발자B.개발하기()

    개발자A.회의하기()
    개발자B.회의하기()

    개발자A.기획하기() // Error
    개발자B.기획하기()
}

대표님은 한 시니어 개발자의 행동을 보고 개발자라면 당연히 개발도 하고, 회의도 하고, 기획도 하는 매우 유능한 사람들이라고 정의했다.

하지만, 우리같은 주니어 개발자는 내공이 부족한 나머지 기획까지는 무리라서 기획하기() 메소드를 호출하면 그런거 못해! 를 시전한다.

대표님께서 한 시니어 개발자를 보고 개발자라는 사람들의 특성을 정의했지만, 실제로는 모든 개발자가 그렇지 않다는 것이다.

이런 상황이 바로 자식 타입이 부모 타입의 동작을 실행하지 못하는 경우이다. 당연하게도 개발자 클래스는 주니어 클래스로 대체가 불가능하다.

주니어 클래스를 사용하는 개발자 입장에서는 기획하기() 메소드를 실행할 때 정상적으로 동작하지 않아 적잖이 당황했을 것이다.


이런 상황에서는 더 구체적으로 역할을 분리할 필요가 있다.

interface 개발자 {
    fun 개발하기()
    fun 회의하기()
}

abstract class 주니어개발자 : 개발자 {
    abstract fun 커피_배달하기()
}

abstract class 시니어개발자 : 개발자 {
    abstract fun 기획하기()
}

class 주니어 : 주니어개발자() {
    override fun 개발하기() {
        println("주니어 개발자가 개발하고 있읍니다~")
    }

    override fun 회의하기() {
        println("주니어 개발자가 회의하고 있읍니다~")
    }

    override fun 커피_배달하기() {
        println("주니어 개발자는 커피를 나르고 있어요. . .")
    }
}

class 시니어 : 시니어개발자() {
    override fun 개발하기() {
        println("시니어 개발자가 개발하고 있읍니다~")
    }

    override fun 회의하기() {
        println("시니어 개발자가 회의하고 있읍니다~")
    }

    override fun 기획하기() {
        println("시니어 개발자가 기획하고 있읍니다~")
    }
}

fun main() {
    val 개발자A: 주니어개발자 = 주니어()
    val 개발자B: 시니어개발자 = 시니어()

    개발자A.개발하기()
    개발자B.개발하기()

    개발자A.회의하기()
    개발자B.회의하기()

    개발자A.커피_배달하기()
    개발자B.기획하기()
}

개발자 인터페이스를 확장하는 주니어개발자 클래스와 시니어개발자 클래스를 새로 생성하고, 각 클래스에서 이를 상속받도록 수정하였다.

이제 모든 메소드가 정상적으로 실행되고, 부모 클래스 역시 자식 클래스로 대체가 가능해졌다.

만약 새로운 개발자, 예를 들어 C++시니어개발자 가 회사에 입사하여 클래스를 생성해주어야 한다면, 시니어개발자() 를 상속받고 기타 필요한 메소드를 구현해주면 된다.

핵심은 사전에 약속한대로 구현한 부모 클래스의 원칙을 자식 클래스에서 깨지 않는 것이다.


🫧 조금만 더 깊게

위의 간단한 예시를 살펴보면 리스코프 치환 원칙이 얼핏 이해가 될 것 같지만, 사실 리스코프 치환 원칙은 더 많은 조건을 요구로 한다.

  • 하위형에서 선행조건은 강화될 수 없다.
  • 하위형에서 후행조건은 약화될 수 없다.
  • 하위형에서 상위형의 불변조건은 반드시 유지되어야 한다.

텍스트가 굉장히 난해한데, 하나씩 예를 들어가며 더 상세하게 리스코프 치환 원칙을 살펴보도록 하자.


하위형에서 선행 조건은 강화될 수 없다.

  • 선행 조건은 함수가 오류 없이 실행되기 위한 모든 조건을 정의한 것을 의미한다.
class 채용절차(private val 지원자: 지원자) {
    fun 서류(나이: Int) {
        try {
            지원자.서류지원(나이)
            면접()
        } catch (exception: Exception) {
            println("나이 요건을 맞추지 못했습니다.")
        }
    }

    private fun 면접() {
        println("면접 중. . .")
    }
}

open class 지원자 {
    open fun 서류지원(나이: Int): Int {
        if (나이 < 20) {
            throw Error("성인만 지원할 수 있습니다!")
        }
        return 나이
    }
}

class 잡코리아_지원자 : 지원자() {
    override fun 서류지원(나이: Int): Int {
        if (나이 < 18) {
            throw Error("18세 미만은 지원할 수 없습니다!")
        }
        return 나이
    }
}

class 원티드_지원자 : 지원자() {
    override fun 서류지원(나이: Int): Int {
        if (나이 < 25) {
            throw Error("25세 미만은 지원할 수 없습니다!")
        }
        return 나이
    }
}

fun main() {
    val 지원자: 지원자 = 지원자()
    채용절차(지원자).서류(20)

    val 지원자2: 지원자 = 잡코리아_지원자()
    채용절차(지원자2).서류(20)

    val 지원자3: 지원자 = 원티드_지원자()
    채용절차(지원자3).서류(20)
}

부모 클래스인 지원자 클래스에서는 기본적으로 지원자가 20세 이상일 것을 요구하고 있다. 그리고 이를 상속받는 두 개의 클래스 잡코리아_지원자원티드_지원자 가 있다.

여기서 하위형, 즉 자식 클래스에서 선행 조건을 강화하는 클래스는 무엇일까?

바로 원티드_지원자 이다. 20세보다 높은 25세를 제한 나이로 지정함으로서 금지된 값의 범위가 넓어졌기 때문이다.

이렇게 되면 부모 클래스와 동일하게 20세의 조건을 걸었음에도 자식 클래스에서 정상적으로 실행되지 않아 리스코프 치환 원칙을 어기게 된다.

🤨 만약 선행 조건의 강화, 약화에 대한 인식이 어렵다면 금지된 값의 범위가 넓어질 수록 선행 조건이 강화됐다고 생각하면 된다.


하위형에서 후행 조건은 약화될 수 없다.

  • 후행 조건은 수행이 완료된 후에 만족되어야 하는 조건을 의미한다.
class 채용절차(private val 지원자: 지원자) {
    fun 서류(이름: Any) {
        try {
            지원자.서류지원(이름)
            면접()
        } catch (exception: Exception) {
            println(exception)
        }
    }

    private fun 면접() {
        println("면접 중. . .")
    }
}

open class 지원자 {
    open fun 서류지원(이름: Any): Any {
        if (이름 !is String) {
            throw Error("이름란에는 문자열 입력만 가능합니다!")
        }
        return 이름
    }
}

class 잡코리아_지원자 : 지원자() {
    override fun 서류지원(이름: Any): Any {
        return 이름
    }
}

class 원티드_지원자 : 지원자() {
    override fun 서류지원(이름: Any): Any {
        if (이름 !is String && 한글체크()) {
            throw Error("문자열을 입력하지 않았거나 한글 기입을 하지 않았습니다!")
        }
        return 이름
    }
}

fun 한글체크(): Boolean {
    val result = false
    // 한글인지 체크. . .
    return result
}

fun main() {
    val 지원자: 지원자 = 지원자()
    채용절차(지원자).서류("Hoya")

    val 지원자2: 지원자 = 잡코리아_지원자()
    채용절차(지원자2).서류(20)

    val 지원자3: 지원자 = 원티드_지원자()
    채용절차(지원자3).서류("김얍얍")
}

앞선 예시와 거의 비슷하지만, 이번에는 Any 타입의 이름 을 매개 변수로 받고 적합한 인자가 들어왔을 때 면접 메소드를 실행하는 코드이다.

부모 클래스인 지원자 클래스에서는 이름String 형이 아니면 에러를 출력하고 있다. 즉, String 형만 출력하게끔 허용했다는 의미이다.

그러나 자식 클래스인 잡코리아_지원자 에서는 조건을 모두 없애고 단순히 이름 만 출력하도록 코드가 전개되고 있다. 즉, 리턴하는 이름에 대한 조건을 약화시킨 것이다.

이렇게 되면 잡코리아_지원자 에서 실수로 문자열이 아닌 숫자를 입력하게 됐을 때 예외가 처리되지 않아 서류 상에 지원자의 이름이 숫자로 입력되는 참사가 일어날 수 있다.

물론, 원티드_지원자 와 같이 후행 조건을 강화하는 것은 허용된다.


하위형에서 상위형의 불변 조건은 반드시 유지되어야 한다.

open class 지원자() {
    var 나이 = 0

    open fun 나이_수정(나이: Int) {
        if(나이 < 0) this.나이 = 0
        else this.나이 = 나이
    }
}

class 잡코리아_지원자 : 지원자() {
    override fun 나이_수정(나이: Int) {
        this.나이 = 나이
    }
}

fun main() {
    val 지원자 = 잡코리아_지원자()

    지원자.나이_수정(-1)
    println(지원자.나이)
}

위의 코드를 보면 부모 클래스인 지원자 에서는 나이가 0이거나 양수이도록 설정해둔 상황이다.

그러나 자식 클래스인 잡코리아_지원자 에서는 아무런 조건 없이 나이를 수정할 수 있도록 하면서 부모 클래스에서 지정한 불변 조건을 깨버리게 됐다.

역시, 리스코프 치환 원칙을 어긴 케이스이다.


✍️ TL;DR

  • 서브 타입은 언제나 부모 타입으로 교체될 수 있어야 한다.
    • 기존 코드에서 보장하던 조건을 수정하거나 적용시키지 않아 예상치 못한 오류가 발생할 수 있다.
  • 가장 쉽게 리스코프 치환 원칙을 지키는 방법은 아예 오버라이딩을 하지 않는 것이다.
  • 개념적으로 상속이 될 것 같아도 여러 경우를 꼼꼼하게 살펴보아야 한다.
  • 사전에 약속한 기획대로 구현하고, 상속 시 자식은 부모에서 구현한 원칙을 꼭 따라야 한다.
  • 리스코프 치환 원칙을 지키지 않은 것은 개방 폐쇠 원칙을 지키지 않은 것과 같다.

참고 및 출처

SOLID : 리스코프 치환 원칙
StackOverFlow

profile
즐겁게 하자 🤭

0개의 댓글