Swift OOP) LSP - 리스코프 치환원칙(Liskov Substitution Principle)

LSP - 리스코프 치환원칙(Liskov Substitution Principle)

오늘은 LSP 리스코프 치환원칙에 대해서 정리해보도록 하겠습니다. 리스코프란 사람이 정의한것으로 뭔가 굉장히 어려운 말같지만 정의는 매우 간단합니다.

“자식클래스는 부모클래스의 역할을 완벽히 수행할 수 있어야한다”

즉, ‘자동차’의 기능을 상속받은 ‘스포츠카’는 자동차의 역할을 모두 수행할 수 있어야 한다는 말 입니다.

“객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위타입의 인스턴스로 치환할 수 있어야 한다

LSP원칙은 상속에 있어서 가장 중요한 기본원칙을 제시하고 있습니다. 그리고 앞에서 살펴본 추상화개념이 들어간 OCP 또한 가능하게 해주는 원칙이 됩니다.

결국 LSP는 상속함에 있어서 문제가 있어서 나온 원칙인데.. 과연 어떤 문제들이 발생되며 또 잘못된 상속은 어떤것인지 알아보겠습니다.

잘못된 상속이란?

상속할때 해서는 안되는 행위는 부모의 행위를 자식이 거부 할때 발생하는 문제입니다. 간단한 예를 통해 알아볼까요?

직사각형과 정사각형
  • 직사각형: 네 각이 모두 직각인 사각형
  • 정사각형: 네각이 모두 직각인 사각형 + 네 변의 길이가 모두 같은 사각형

수학적인 개념으로 정사각형이 직사각형에 포함되는 개념입니다. 모든 정사각형은 직사각형이 될 수 있지만 반대로 모든 직사각형은 정사각형이 될 수 없기 때문입니다.

언뜻 보기에는 직사각형이 넓은 범주로서 부모클래스, 정사각형이 자식클래스 일 것 같지만 두개를 클래스화 해보면 다르다는 것을 알 수 있습니다.

class 직사각형 {
    var 넓이: CGFloat { get }
    
    init(가로: CGFloat, 세로: CGFloat)
    
    func 가로설정(_ 가로: CGFloat)
    func 세로설정(_ 세로: CGFloat)
}

class 정사각형 {
    var 넓이: CGFloat { get }
    
    init(한변의길이: CGFloat)
    
    func 한변의길이설정(_ 한변의길이: CGFloat)
}

이제 앞에서 언급한것 처럼 직사각형이 넓은 범주이기에 부모클래스가 되면 어떻게 될까요?

부모-직사각형, 자식-정사각형
class 직사각형 {
    private var 가로: CGFloat
    private var 세로: CGFloat
    var 넓이: CGFloat { return 가로 * 세로 }
    
    init(가로: CGFloat, 세로: CGFloat) {
        self.가로 = 가로
        self.세로 = 세로
    }
    
    func 가로설정(_ 가로: CGFloat) {
        self.가로 = 가로
    }
    
    func 세로설정(_ 세로: CGFloat) {
        self.세로 = 세로
    }
}

class 정사각형: 직사각형 {
    
    override init(가로: CGFloat, 세로: CGFloat) {
        super.init(가로: 가로, 세로: 세로)
        
    }
    
    override func 가로설정(_ 가로: CGFloat) {
        self.한변의길이설정(가로)
    }
    
    override func 세로설정(_ 세로: CGFloat) {
        self.한변의길이설정(세로)
    }
    
    func 한변의길이설정(_ 한변의길이: CGFloat) {
        self.가로설정(한변의길이)
        self.세로설정(한변의길이)
    }
}

정사각형의 경우 한변의 길이만 필요하게 되어서 위와 같이 가로 세로 설정을 하기위에 한변의길이설정 이라는 함수를 추가하였습니다. 그럼 어떤 문제가 발생할까요?

func 사각형넓이변경(_ 직사각형: 직사각형) {
    직사각형.가로설정(25)
    직사각형.세로설정(4)
    print(직사각형.넓이)
}

사각형의 넓이를 100으로 만드는 사각형넓이변경 함수를 만들었습니다. 이제 이것을 사용할때 매개변수로 직사각형정사각형 을 넘겼을때 문제가 아래처럼 발생하게됩니다.

// 직사각형을 넘겨줄때
var 사각형 = 직사각형(가로: 0, 세로: 0)
사각형넓이변경(사각형) // 100이 출력된다.

// 정사각형을 넘겨줄때
var 사각형 = 정사각형(가로: 0, 세로: 0)
사각형넓이변경(사각형) // 16이 나온다.

직사각형 의 인스턴스를 만들어 넘겼을때는 정상적으로 넓이가 100이 되어 출력이 됩니다. 하지만 정사각형을 넘겨 줄때는 넓이가 16이 나오게 됩니다. 그 이유는 정사각형은 네개의 변의길이가 모두 동일한 것을 유지해야하기 때문에 정사각형의 가로, 세로 설정함수가 모두 한변의길이설정 함수를 호출 하게 되어 결국엔 마지막에 호출되는 길이설정함수에 의해 넓이가 16이 나오게 됩니다.

// 정사각형의 길이 설정 함수
override func 가로설정(_ 가로: CGFloat) {
    self.한변의길이설정(가로)
}

override func 세로설정(_ 세로: CGFloat) {
    self.한변의길이설정(세로)
}

func 한변의길이설정(_ 한변의길이: CGFloat) {
    self.가로설정(한변의길이)
    self.세로설정(한변의길이)
}
우선 문제부터 해결해보자
  1. If-else로 부모자식 대응하자
if 사각형 is 직사각형 {
    직사각형넓이변경(사각형)
} else {
    정사각형넓이변경(사각형)
}

위와 같이 사각형이 직사각형인지 정사각형인지 일일이 브랜치로 체크해서 거기에 맞는 함수를 또 작성하기도 했습니다. 그러면 상속의 구조를 만든 의미가 무색해지게 됩니다.

  1. 함수를 부모자식에 변경하자

부모인 직사각형을 매개변수로 넘겨줘도 넓이가 100, 자식인 정사각형을 넘겨줘도 100이 나오게 할려면 어떤 수정이 필요할까요?

func 사각형넓이변경(_ 직사각형: 직사각형) {
    직사각형.가로설정(10)
    직사각형.세로설정(10)
    print(직사각형.넓이)
}

정사각형을 위해 가로, 세로 설정을 각각 10으로 변경했습니다. 이렇게 하면 우선 정상적으로 기능은 하겠죠.. 하지만 이게 옳은 방법일까요? 부모 클래스인 직사각형 뿐만 아니라 자식클래스인 직사각형의 동작까지 생각하면서 기능함수를 작성해야합니다.

물론 정사각형만 있다는 보장도 없습니다. 마름모나 사다리꼴 .. 등 앞으로 더 복잡해질 상속구조를 위해 모든 것을 고려해서 함수를 작성한다는 것은 불가능에 가깝습니다.

이제 잘못된 상속,즉 LSP를 위반했을시에 어떤 문제가 발생하는지 예제를 통해 알아보았습니다. LSP의 정의 즉 “자식은 부모의 역할을 완벽하게 수행할 수 있어야한다” 를 지키지 않았기 때문에 발생한 문제입니다.

올바른 상속

올바르게 상속을 바로잡으려면 정사각형을 부모 클래스로 직사각형을 자식으로 변경해야합니다.

class 정사각형 {
    private var 가로: CGFloat
    private var 세로: CGFloat
    var 넓이: CGFloat { return 가로 * 세로 }
    
    init(한변의길이: CGFloat) {
        self.가로 = 한변의길이
        self.세로 = 한변의길이
    }
    
    func 한변의길이설정(_ 한변의길이: CGFloat) {
        self.가로 = 한변의길이
        self.세로 = 한변의길이
    }
}

class 직사각형: 정사각형 {
    
    func 가로설정(_ 가로: CGFloat) {
        self.가로 = 가로
    }
    
    func 세로설정(_ 세로: CGFloat) {
        self.세로 = 세로
    }
}

정사각형이 부모클래스로서 직사각형의 기능 까지 포함할 수 있게 구현이 되어있습니다.

그리고 앞에서의 넓이를 100으로 만드는 함수에서 더이상 변경 없이 부모클래스의 속성인 정사각형 을 바탕으로 구현했고

func 사각형넓이변경(_ 정사각형: 정사각형) {
    정사각형.한변의길이설정(10)
    print(정사각형.넓이)
}

// 직사각형을 넘겨줄때
var 사각형 = 직사각형(한변의길이: 0)
사각형넓이변경(사각형) // 100이 출력된다.

// 정사각형을 넘겨줄때
var 사각형 = 정사각형(한변의길이: 0)
사각형넓이변경(사각형) // 100이 출력된다.

이처럼 부모클래스의 속성만을 믿고 코드를 짤 경우 직사각형인 자식 클래스에서도 사각형넓이변경 함수가 정상적으로 작동되는 것을 확인할 수 있습니다.

일반적인 개념과 상속은 별개

image

앞에서 예제를 통해서 보았듯이 일반적인 관념 상 정사각형은 직사각형의 한 종류입니다. 따라서 넓은 범주인 직사각형이 부모클래스가 되야하는게 어떻게 보면 당연해 보입니다. 하지만 직사각형은 가로,세로 두개의 변을 반면에 정사각형은 한변의 길이만 값에 영향을 받기 때문에 직사각형(부모)-정사각형(자식) 의 구조는 부모의 정합성을 깨뜨리게 됩니다.

이와 비슷한 예로 상위레벨의 클래스에 보다 복잡한 기능이나 특징들을 넣게 되면 이를 상속 받은 하위클래스에는 지대한 영향을 미쳐 그 기능을 퇴화 시키는 등의 방법 밖에는 없습니다.

image

  • 돌고래: 헤엄치기
  • 개: 걷기, 뛰기, 헤엄치기

예를들어 개, 고양이 등의 육지동물 클래스에 걷기,뛰기,헤엄치기 등의 행위를 동물 클래스의 상위 레벨의 클래스로 올리게 될 경우 물고기나 돌고래등의 바다동물 클래스는 걷기,뛰기 의 행위를 할 수 없기 때문에 이 기능을 퇴화 시킬 수 밖에 없습니다.

그렇기 때문에 걷기, 뛰기, 헤엄치기 등의 행위는 동물클래스의 상속과는 별도로 존재 해야합니다.

iOS에서 LSP 위반 사례는?

베어코드-[Swift OOP]LSP(리스코프치환원칙) 의 내용을 정리해보았습니다.

iOS의 UIKit의 경우 UIView아래 다양한 뷰들이 상속되어 구현이 되어져 있습니다.

    @IBOutlet var label: UILabel?
    @IBOutlet var button: UIButton?
    @IBOutlet var segmentedControl: UISegmentedControl?
    @IBOutlet var textField: UITextField?
    @IBOutlet var slider: UISlider?
    @IBOutlet var switchButton: UISwitch?
    @IBOutlet var activityIndicator: UIActivityIndicatorView?
    @IBOutlet var progressView: UIProgressView?
    @IBOutlet var stepper: UIStepper?
    @IBOutlet var imageView: UIImageView?

영상에서 한가지 테스트를 해보는데 UIKit을 상속받은 다양한 IBOutlet들을 생성하고 이들을 배열에 넣어서 색상을 변경하고 height 값을 변경하여 최종적으로 모든 view의 height를 더해서 정상적으로 나오는지 확인하고 있습니다.

        let views: [UIView] = self.view.subviews
        
        for view: UIView in views {
            view.frame.origin.x = 0
        }
        
        for view: UIView in views {
            view.layer.borderColor = UIColor.red.cgColor
            view.layer.borderWidth = 1
        }
        
        for view: UIView in views {
            view.frame.size.height = 30
        }
        
        let sumOfHeight: CGFloat = views.reduce(0) { $0 + $1.frame.height }
        NSLog("sumOfHeight = \(sumOfHeight)")

// 기대: 10개의 IBOutlet이므로 300
// 실제: 275

하지만 기대한 300이 나오지 않고 275가 나오는걸 확인 할 수 있었습니다.

  • UISwitcher
  • UIProgressView
  • UIStepper

이 3가지 컨트롤러가 고정된 height 값을 가지고 있기 때문에 예상했던 결과 값이랑 다르게 나오게 된 것입니다. 위 3가지의 컨트롤러는 고정된 height 값을 가지게 되고 결국 높이값 30을 대입하는 과정을 무시하게 된것입니다.

이것은 UIKit에서도 LSP를 위반하는 경우를 보여주는 대표적인 예라고 할 수 있습니다. 하지만 LSP를 위반하는 것은 맞지만 이것이 UIKit을 사용하는데 있어서 큰 불편함을 주지 않을 뿐더러 개발자는 이미 경험을 통해 알고 있기 때문에 LSP를 위반하지만 치명적인 문제가 되지는 않습니다.

하지만 UIKit과 같이 적은 수의 경우는 별 문제가 되지 않지만 그 수가 많아지게 될 경우 상속의 모든 관계를 숙지한다는게 힘들기 때문에 결국 문제가 됩니다.

LSP를 위반하게 되면

앞에서 예제를 통해 살펴 봤듯이 잘못된 상속으로써 발생되는 문제가 결국 LSP위반의 문제점이 될 수 있습니다.

  • 모든 클래스에서 하위 클래스를 명시적으로 지정해서 코딩
    • ex) 다양한 UIKit 하위클래스 아래 고정된 height 값을 가지는 subview 를 접근할때 하나하나 고려해서 작업해야함
  • OCP를 사용할 수 없게 됨
    • 전적으로 부모를 믿고 자식 클래스를 작업하지만 위와 같이 퇴화되는 기능에 대해서는 OCP가 의미가 없어진다.
  • 코드의 복잡도를 높임
  • 부모 클래스가 자식 클래스를 알아야하는 경우도 발생
    • 불필요한 if-else 구문이 추가가 되거나 인스턴스를 접근하는 행위에 대해 모든 하위클래스를 고려해서 작업을 해야하는 경우가 발생 이는 상속구조가 무의미 하게 된다.

LSP는 믿음

자바의 컬랙션프레임워크나 swift의 복잡한 상속관계의 프레임워크를 사용하면서도 별 문제없이 사용할 수 있었던 것은 LSP를 잘지켜 주고 있기 때문에 가능한 일입니다.

다양한 하위클래스를 사용할때도 상위클래스의 행위에 대해 전적으로 작동된다는 보장이 되어있기 때문에 가능한 것이고 결국 LSP를 준수하는것이 이것들을 만들어 준다고 할 수 있습니다.

구리고 추상화된 인터페이스 하나로 공통의 코드를 작성할 수 있게 됩니다. 공통된 행위를 swift에서 프로토콜로 정의해서 그것들을 채택하여 다른 코드에 영향을 주지않고 작업을 할 수 있게 됩니다. 그리고 swift에서는 Protocol extension 이 가능하기 때문에 다양한 extesion을 통해 원하는 동작을하는 함수를 추가하여 작업을 할 수 있게 된 것도 LSP를 준수하기 때문에 가능한 것입니다.

현실적인 이야기

현실적으로는 모든 코드에서는 LSP를 지키기는 어렵다고 합니다. 당장에는 허용할 만한 선에서 타협을 하지만 추후에 복잡한 상속 구조를 가지게 되면 문제가 발생할 수 있다는 것을 염두해두고 작업을 해야합니다.

더 나아가 LSP를 충분히 이해하고 준수하고자 하면 결국 깊게 보았을 때는 상속과 swift protocol 등의 설계 부분에 대해 많은 영향을 끼치기 때문에 LSP는 곧 설계에 직접적인 영향을 미치게 됩니다. 앞에서 보았던 잘못된 상속의 문제점, LSP의 위반시 발생한 문제들을 설계 부분에서 사전에 방지할 수 있게하고 무엇보다 올바른 상속 구조 를 설계하는데 도움을 주게 됩니다.

Reference





© 2020. by Gaki

Powered by gaki