객체지향 프로그래밍

[객체지향 프로그래밍] 리스코프 치환 원칙(LSP)을 코틀린으로 알아보기

Alsong 2025. 1. 22. 16:26

최근에 봤던 회사의 기술면접에서 리스코프 치환 원칙에 대해서 물어보았습니다. 리스코프 치환 원칙이 무엇인지 대강은 알고 있었지만 막상 설명하려니 잘 나오지가 않더군요.. 게다가 그것을 위반한 코드의 예시와 그 코드를 올바르게 수정하려면 어떻게 해야 하느냐는 질문에 잘 답변하지 못했습니다 ㅠㅠ 이번 포스팅에서는 리스코프 치환 원칙에 대해서 제대로 공부도 할 겸 정리해 보려고 합니다.

 

SOLID원칙을 정리해 둔 블로그들을 보면 보통 자바로 정리되어 있는 경우가 많은데, 제 글이 코틀린을 주로 사용하시는 개발자 분들에게 도움이 되었으면 합니다.

 

1. 리스코프 치환 원칙이란?

리스코프 치환 원칙(LSP, Liscov Substitution Principle)은 객체지향 프로그래밍의 5원칙인 SOLID원칙 중 하나입니다. 

리스코프 치환 원칙이란, 상위 타입의 객체를 언제나 하위 타입으로 치환이 가능해야 한다는 원칙입니다.

말로는 이해가 어려우니 예제 코드를 통해 알아보도록 합시다!

open class Bird {
    open fun fly() {
        println("Bird is flying!")
    }
}
 
class Eagle : Bird() {
    override fun fly() {
        println("Eagle is flying!")
    }
 
    fun cry() {
        println("Eagle is crying!")
    }
}
 
fun main() {
    var bird: Bird = Bird()
    bird.fly() // Output: Bird is flying!
    bird = Eagle()
    bird.fly() // Output: Eagle is flying!
}

위 코드는 리스코프 치환 원칙을 준수한 코드입니다. 코드를 보시면 Bird라는 상위 타입의 객체가 있고 Eagle이라는 하위 타입의 객체가 있죠? 리스코프 치환 원칙은 상위 타입의 객체를 하위 타입으로 치환이 가능해야 한다는 원칙이라고 했죠? main() 함수를 보시면, bird변수는 처음에 Bird 객체를 참조하고 있었지만 이후에 Bird대신 Eagle객체로 치환하고 있는 것을 보실 수 있을 겁니다. 즉 어떻죠? 상위타입의 객체(Bird)를 하위타입의 객체(Eagle)로 대체했죠? 하지만 그래도 프로그램은 정상적으로 동작합니다. 이것이 바로 리스코프 치환 원칙의 의미입니다.

 

더 나아가서 상위타입 객체를 하위타입 객체로 치환해도 프로그램이 망가지지 않는다는 것은 상위 객체의 행동 규약을 하위 객체가 그대로 따라야 한다는 것을 의미하기도 합니다. 즉 하위객체는 상위객체를 상속받을 때 상위객체의 의의에 맞게 올바르게 상속받아야 한다는 뜻입니다. 

 

반대는 어떨까?

그럼, 혹시 반대로, 하위 타입의 객체를 상위 타입의 객체로 치환하는 것은 어떨까요? 이것도 리스코프 치환 원칙에 포함될까요?

open class Bird {
    open fun fly() {
        println("Bird is flying!")
    }
}
 
class Eagle : Bird() {
    override fun fly() {
        println("Eagle is flying!")
    }
 
    fun cry() {
        println("Eagle is crying!")
    }
}
 
fun main() {
    var bird: Bird = Eagle()
    bird.fly() // Output: Eagle is flying!
    bird = Bird()
    bird.fly() // Output: Bird is flying!
}

이번 코드는 아까와는 반대로, 먼저 bird 변수가 Eagle객체를 참조하고 하고, 그다음에 Bird로 참조를 바꿨습니다. 이래도 에러는 발생하지 않고 잘 실행 되죠? 독수리가 먼저 날고 그다음에 새가 날고 있네요. 문제가 없어 보이죠? 오~ 그럼 하위타입 객체를 상위타입 객체로 치환해도 괜찮은 건가?라고 생각이 드실 수 있겠습니다. 하지만 아니에요! 다음 코드를 봅시다.

이 코드는 컴파일 에러가 뜨네요. 왜냐하면 Bird객체는 cry()함수를 갖고 있지 않기 때문입니다. 아하~ 여기서 우리는 왜 하위타입 객체를 상위타입 객체로 치환할 수 없는지 알 수 있습니다. 하위타입 객체는 상위타입 객체를 상속받을 때 기존 기능을 토대로 추가 기능을 정의합니다. 즉 하위타입 객체는 상위타입 객체를 확장한다고 볼 수 있죠. 그렇다면 하위타입 객체에는 정의된 기능이지만 상위타입 객체에는 정의되지 않은 기능이 있을 수 있겠네요. 위의 예시에서의 cry()함수처럼 말이죠.

 

따라서 위와 같은 코드는 리스코프 치환 원칙을 논하기 전에 애초에 잘못 짠 코드라고 볼 수 있습니다. 단 cry()함수를 호출하지 않는다면(코드블럭에 있는 코드처럼요!) 에러도 발생하지 않고 LSP를 위반했다고 할 수도 없지만, 이 경우에는 딱히 LSP의 핵심 내용을 보여주는 예시는 아니라고 말할 수 있겠네요.

 

2. 리스코프 치환 원칙을 위반한 코드

그렇다면 리스코프 치환 원칙을 위반한 코드 예시에는 어떤 것들이 있을까요? 다음과 같은 경우를 생각해 볼게요.

open class Bird {
    open fun fly() {
        println("Bird is flying!")
    }
}
 
class Penguin : Bird() {
    override fun fly() {
        throw UnsupportedOperationException("Penguin can not fly!")
    }
}
 
fun main() {
    var bird: Bird = Bird()
    bird.fly() // Output: Bird is flying!
    bird = Penguin()
    bird.fly() // Exception thrown
}

Penguin이라는 객체를 정의하고, 펭귄은 새니까 Bird를 상속받았습니다. 그런데 문제가 하나 있었습니다. 펭귄은 새이긴 한데 날지 못하는 새라는 것이죠.. 그럼 어떻게 하지? 하다가 Birdfly()를 상속받으면서 예외를 던지도록 override 했습니다. 이러면 펭귄 객체의 fly()를 호출했을 때 예외가 던져지니까, 날지 못하는 새인 펭귄을 충분히 표현했다고 생각됩니다.

 

하지만 이 코드는 리스코프 치환 원칙을 위반한 코드입니다. 상위타입의 객체를 하위타입의 객체로 치환했을 때 프로그램이 정상 동작을 안하기 때문이죠. 

 

아까 리스코프 치환 원칙은 하위객체가 상위객체를 상속받을 때 상위객체의 의의에 맞게 상속받아야 함을 의미한다고도 했죠? Bird객체가 갖고 있는 fly()함수의 의의는 나는 것입니다. 하지만 Penguin이 이 함수를 override 하면서 날지 않고 예외를 던지게 했기 때문에 이는 올바르게 상속받지 않은 것입니다.

 

이 코드를 리스코프 치환 원칙을 준수하도록 올바르게 수정하려면 fly()함수를 Bird클래스 안에서 정의하지 않고, interface로 분리해야 합니다.

interface Flyable {
    fun fly()
}
 
open class Bird {
    open fun cry() {
        println("Bird is crying!")
    }
}
 
class Penguin : Bird() {
    override fun cry() {
        println("Penguin is crying!")
    }
}
 
class Eagle : Bird(), Flyable {
    override fun fly() {
        println("Eagle is flying!")
    }
 
    override fun cry() {
        println("Eagle is crying!")
    }
}
 
fun main() {
    var bird: Bird = Bird()
    bird.cry() // Output: Bird is crying!
   
    bird = Penguin()
    bird.cry() // Output: Penguin is crying!
   
    bird = Eagle()
    bird.fly() // Output: Eagle is flying!
}

위 코드는 새의 '날다'라는 기능을 새 객체 안에 정의하지 않고 Flyable이라는 별도의 인터페이스를 통해 분리했습니다. 이렇게 한 이유는 모든 새가 날 수 있는 것이 아니기 때문이지요. 이렇게 '날다'라는 기능을 분리하면 독수리와 같은 날 수 있는 새는 Flyable을 상속받아서 날게 할 수 있고 펭귄처럼 날지 못하는 새는 Flyable을 상속받지 않아서 나는 기능을 주지 않을 수 있습니다. 이렇게 하면 상위타입의 객체(Bird)를 하위타입의 객체(Penguin, Eagle)로 치환하더라도 프로그램이 문제없이 돌아가게 되죠! 👍

 

3. 헷갈리는 점

여러 블로그를 통해 리스코프 치환 원칙을 공부하면서 헷갈리는 점이 있을 수 있습니다. (제가 그랬습니다😵) 리스코프 치환 원칙의 다른 설명으로, 아래와 같이 설명하기도 합니다.

리스코프 치환 원칙이란, 하위타입은 언제나 상위타입으로 교체 가능해야 한다는 원칙입니다.

어라? 아까는 상위타입을 하위타입으로 치환 가능해야 한다고 하지 않았나? 아까의 설명과 반대되는 설명 아닌가요? 라고 생각하실 수 있겠습니다. 저도 이 설명 때문에 굉장히 헷갈렸네요 ㅎㅎ

 

하지만 이 설명과 위의 설명은 같은 설명입니다. 말만 다를 뿐입니다. 왜냐하면 이 설명에서 하위타입이 상위타입으로 교체 가능한다는 것은 타입이 교체된다는 것이지, 객체가 치환된다는 것이 아니거든요.

open class Bird {
    open fun fly() {
        println("Bird is flying!")
    }
}
 
class Eagle : Bird() {
    override fun fly() {
        println("Eagle is flying!")
    }
 
    fun cry() {
        println("Eagle is crying!")
    }
}
 
fun main() {
    var bird: Bird = Bird()
    val eagle: Eagle = Eagle()
 
    bird.fly() // Output: Bird is flying!
    bird = eagle
    bird.fly() // Output: Eagle is flying!
}

이 코드를 볼게요. 밑에서 두번째 라인에서 birdBird타입인데 Eagle타입인 eagle을 대입했죠? 상위타입을 기대하는 변수가 하위타입 인스턴스를 참조하는 상황이고, 이는 하위타입인 Eagle을 상위타입인 Bird로 바꿔서 사용할 수 있다!라고 이해하시면 되겠습니다. 어때요, 결국 상위타입의 객체를 하위타입의 객체로 치환한 것과 같은 예시가 되었죠?

 

정리

정리하자면, 리스코프 치환 원칙은 상위타입의 객체를 하위타입의 객체로 치환할 수 있어야 한다라는 원칙이고, 이는 하위객체는 상위객체를 상속받을 때 상위객체의 행동 규약을 따라야 함을 의미합니다. 즉 올바르게 상속 받아야 한다는 원칙이며, 이 원칙을 어긴다면 객체지향의 4대 특성 중 하나인 다형성을 활용하지 못하는 것이 됩니다!