[SOLID] 리스코프 치환 법칙(LSP)
정의
- 객체 지향 프로그래밍과 소프트웨어 설계에서 사용되는 원칙 중 하나로, 바바라 리스코프(Barbara Liskov)에 의해 소개되었습니다.
- 이 원칙은 "서브타입은 그들의 기반 타입을 대체할 수 있어야 한다"라는 원리를 제시합니다.
- LSP는 상속과 다형성의 올바른 사용을 강조하며, SOLID 설계 원칙 중 하나입니다.
특징
- 서브타입과 기반 타입: LSP는 서브 타입(subtype)이 기반 타입(basetype)을 대체할 수 있어야 함을 주장합니다. 이 말은 기반 타입의 객체가 사용되는 모든 위치에서 서브타입의 객체로 대체되어도 프로그램의 동작이 올바르게 유지되어야 함을 의미합니다.
- 메서드 시그니처: 서브타입은 기반 타입에서 상속받은 메서드를 오버라이딩할 때, 메서드 시그니처를 유지해야 합니다. 이는 매개변수의 타입, 반환 타입 및 개수가 일치해야 함을 의미합니다.
- 계약 조건: 서브타입은 기반 타입의 계약(불변식, 전제조건, 후속조건 등)을 준수해야 합니다. 이는 기반 타입에서 정의된 동작을 변경하지 않고, 프로그램의 안정성을 보장하는 데 도움이 됩니다.
장점
- 코드의 재사용성 향상: LSP를 준수하면, 기반 타입과 서브타입 간의 호환성이 보장되므로 코드의 재사용성이 향상됩니다. 이를 통해 개발자들은 이미 작성된 코드를 새로운 상황에 적용할 수 있으며, 중복 코드를 줄이고 개발 시간을 절약할 수 있습니다.
- 확장성과 유연성 증가: LSP를 따르면 기존 코드를 수정하지 않고도 새로운 기능을 추가하거나 수정할 수 있습니다. 이는 기반 클래스나 인터페이스를 수정하지 않고도 새로운 서브타입을 쉽게 추가할 수 있음을 의미합니다. 이를 통해 프로그램의 확장성과 유연성이 증가하게 됩니다.
- 견고성 및 안정성 제공: LSP를 준수하면, 기반 타입과 서브타입 사이의 계약을 유지함으로써 프로그램 전반에 걸쳐 안정성과 견고성을 보장할 수 있습니다. 이 원칙을 따르면, 기반 타입의 객체를 서브타입의 객체로 대체했을 때 프로그램이 여전히 올바르게 동작하므로, 런타임 에러를 줄이고 디버깅 과정을 간소화할 수 있습니다.
- 의존성 관리 개선: LSP는 프로그램의 의존성 관리를 개선하고, 모듈 간의 느슨한 결합(loose coupling)을 촉진합니다. 이를 통해 프로그램이 더 유지 관리하기 쉽고 변경에 덜 취약하게 됩니다.
- 코드 가독성 향상: LSP를 준수하는 코드는 일관성이 있고 예측 가능한 동작을 제공하므로, 코드를 이해하고 유지 관리하기가 더 쉽습니다. 이는 개발자들이 프로그램의 전체 구조와 동작에 대한 명확한 이해를 가질 수 있게 돕습니다.
나쁜 예제
import Foundation
// 직사각형 클래스 정의
class Rectangle {
var width: CGFloat
var height: CGFloat
// 초기화 함수
init(width: CGFloat, height: CGFloat) {
self.width = width
self.height = height
}
// 면적을 반환하는 함수
func getRectangleSize() -> CGFloat {
return width * height
}
// 너비를 설정하는 함수
func setWidth(width: CGFloat) {
self.width = width
}
// 높이를 설정하는 함수
func setHeight(height: CGFloat) {
self.height = height
}
}
// 정사각형 클래스 정의 (직사각형을 상속받음)
class Square: Rectangle {
// 초기화 함수
init(size: CGFloat) {
super.init(width: size, height: size)
}
// 너비를 설정하는 함수 (정사각형의 경우 높이도 변경)
override func setWidth(width: CGFloat) {
self.width = width
self.height = width
}
// 높이를 설정하는 함수 (정사각형의 경우 너비도 변경)
override func setHeight(height: CGFloat) {
self.width = height
self.height = height
}
}
// 정사각형 인스턴스 생성 및 테스트
let rectangle = Square(size: 5)
rectangle.setHeight(height: 5)
rectangle.setWidth(width: 2)
print(rectangle.getRectangleSize()) // 출력: 4
이 코드는 리스코프 치환법칙(LSP)을 완전히 준수하지 않습니다.
정사각형은 너비와 높이가 항상 같은 특성을 갖지만, 직사각형은 너비와 높이가 다를 수 있습니다.
그러나 현재 코드에서는 정사각형이 직사각형을 상속받고, 너비와 높이를 따로 변경할 수 있는 setWidth와 setHeight 메서드를 오버라이드하고 있습니다. 이로 인해 정사각형이라고 가정한 정사각형 객체의 속성이 깨지게 됩니다.
이 코드를 개선하려면. OCP 와 같은 코드를 생각할 수 있습니다.
코드를 개선하기 위해 직사각형과 정사각형 클래스를 독립적으로 정의하고, 이들의 공통 속성 및 메서드를 갖는 프로토콜(Protocol)을 사용하는 방식을 적용할 수 있습니다.
좋은 예제
import Foundation
// 공통 속성 및 메서드를 갖는 프로토콜 정의
protocol Shape {
var width: CGFloat { get set }
var height: CGFloat { get set }
func getSize() -> CGFloat
}
// 직사각형 클래스 정의
class Rectangle: Shape {
var width: CGFloat
var height: CGFloat
init(width: CGFloat, height: CGFloat) {
self.width = width
self.height = height
}
func getSize() -> CGFloat {
return width * height
}
}
// 정사각형 클래스 정의
class Square: Shape {
var width: CGFloat {
didSet {
height = width
}
}
var height: CGFloat {
didSet {
width = height
}
}
init(size: CGFloat) {
width = size
height = size
}
func getSize() -> CGFloat {
return width * height
}
}
// 테스트
let rectangle = Rectangle(width: 5, height: 10)
print(rectangle.getSize()) // 출력: 50
let square = Square(size: 5)
print(square.getSize()) // 출력: 25
위 코드에서는 Shape프로토콜을 사용하여 공통 속성과 메서드를 정의 하였으며, Rectangle 과 Square 클래스를 독립적으로 구현하였습니다.
이렇게 변경 함으로써 리스코프 치환법칙을 준수 하면서도, 클래스 간의 관계를 더 명확하게 표현할 수 있습니다.
또한 기능 추가에도 용이합니다. 다른 기능을 추가할때도 프로토콜을 채택하여 기능을 별도로 정의해주기만 하면 됩니다.
위 코드에서 기능 추가로 삼각형의 너비를 계산을 구하는 로직을 생성해보겠습니다.
class Triangle: Shape {
var width: CGFloat
var height: CGFloat
init(width: CGFloat, height: CGFloat) {
self.width = width
self.height = height
}
func getSize() -> CGFloat {
return (width * height) / 2
}
}
let triangle = Triangle(width: 6, height: 8)
print(triangle.getSize()) // 출력: 24
기존의 소스를 수정하거나 건드릴 필요 없이, 삼각형 너비를 계산하는 로직이 생성 되었습니다.
학습하면서 느낀점
하위 클래스가 상위 클래스의 역할을 완벽하게 대체하는 것은 매우 중요하다고 생각합니다.
실무에서 기존 클래스의 결합도가 높아 상위 클래스를 수정하면 하위 클래스에 영향이 가는 경우가 많았습니다. 상속이 이루어진 상태에서 영향을 받는 부분을 찾는 것은 시간이 오래 걸리고 매우 어려웠습니다.
그러나 리스코프 치환 법칙을 학습하고 실무에 적용하면서 이러한 불필요한 문제가 줄어들게 되었습니다.
이를 통해 요청 사항이 기존 소스 코드에 영향을 주지 않고 간단하게 추가할 수 있게 되었습니다.