[객체지향] SOLID: 리스코프 치환 원칙(Liskov Substitution Principle, LSP)
Reference
스프링 입문을 위한 자바 객체 지향의 원리와 이해, 위키북스, 김종민 지음
개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴, 인투북스, 최범균 지음
📌 LSP: 다형성에 관한 원칙
자료형 S가 자료형 T의 서브타입이라면 필요한 프로그램의 속성의 변경 없이 자료형 T의 객체를 S의 객체로 치환할 수 있어야 한다.
- 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다. 즉, 서브 타입은 언제나 자신의 기반(상위) 타입으로 교체 할 수 있어야 한다.
- 하위 클래스의 인스턴스는 상위형 객체 참조 변수에 대입해 상위 클래스의 인스턴스 역할을 하는 데 문제가 없어야 한다.
- 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
상위 타입인 SuperClass
, 하위 타입인 SubClass
가 존재할 때, Main 에서 someMethod 를 호출하는 메서드가 존재한다고 가정한다. 호출 메서드는 상위 타입을 파라미터로 사용하고 있는데 하위 타입의 객체를 전달해도 메서드가 정상적으로 호출되어야 한다는 것이 리스코프 치환 원칙이다.
class SuperClass {
public void someMethod() {
System.out.println("Some Method...");
}
}
class SubClass extends SuperClass {
}
public class Main {
public static void callSomeMethod(SuperClass sc) {
sc.someMethod();
}
public static void main(String[] args) {
callSomeMethod(new SuperClass());
callSomeMethod(new SubClass()); // 리스코프 치환 원칙
}
}
- LSP는 다형성에 관한 원칙이다. 만약 LSP 위반이 발생하면 추상화와 다형성을 이용한 OCP도 위반되는 연쇄 효과를 일으키게 된다.
상속의 원칙: 분류도
객체 지향에서의 상속은 조직도나 계층도가 아닌 분류도가 돼야 한다
- 서브 클래스 is a kind of 상위 클래스: 하위 분류는 상위 분류의 한 종류다
- 구현 클래스 is able to 인터페이스: 구현 분류는 인터페이스할 수 있어야 한다
- AutoCloseable: 자동으로 닫힐 수 있어야 한다
- Appendable: 덧붙일 수 있어야 한다
- Cloneable: 복제할 수 있어야 한다
- Runnable: 실행할 수 있어야 한다
- 공통되는 부분, 변경되는 부분을 추상화 시켜야 한다
- 객체 지향의 상속이라는 특성을 올바르게 이해하고 활용한다면 LSP는 자연스럽게 지켜진다.
계층도(조직도) 형태 상속의 문제점
- 상위 클래스의 객체 참조 변수에 하위 클래스의 인스턴스를 할당하게 되면 기존의 기능(계약)을 완벽히 수행할 수 없다. 즉, 서브 타입이 상위 타입을 완벽하게 대체할 수 없다.
// 전형적인 계층도 형태의 상속
class Mother {
}
class Son extends Mother {
}
Mother 석봉이 = new Son();
석봉이는 Mother
객체 참조 변수이기에 Mother
의 행위(메서드)를 모두 할 수 있어야 하는데 아들이 엄마의 역할을 한다는 것은 매우 이상하다.
- 위와 같은 전형적인 계층도(조직도) 형태의 상속은 LSP 위반의 정석이다
📌 LSP를 위반했을 때의 문제
1. 올바르지 못한 상속과 구현
class Rectangle {
private int width;
private int height;
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
class Square extends Rectangle {
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height);
}
}
public class Main {
static void increaseHeight(Rectangle rectangle) {
if (rectangle instanceof Square) { // 리스코프 치환 원칙 위배, 메서드가 Rectangle 의 확장에 닫혀 있다.
throw new IllegalArgumentException();
}
if (rectangle.getHeight() <= rectangle.getWidth()) {
rectangle.setHeight(rectangle.getWidth() + 10);
}
}
public static void main(String[] args) {
Rectangle rectangle = new Rectangle();
Rectangle square = new Square();
rectangle.setWidth(10);
rectangle.setHeight(5);
square.setWidth(10);
square.setHeight(5);
System.out.println("Rectangle's area: " + rectangle.getArea()); // 50
System.out.println("Square's area: " + square.getArea()); // 25
}
}
- 동작에 있어
Rectangle
클래스와Square
는 서로 일관되게 행동하지 않는다.Square
높이와 너비 설정을 따로 있을 필요가 없는 것이 좋고Rectangle
을 상속받는 것이 오히려 혼란스럽게 만든다. 둘은 개념적으로 상속관계에 있는 것처럼 보이지만 실제 구현에서는 상속 관계가 아님을 보여준다. - 정사각형과 직사각형은 사각형이라는 공통점이 있다. 이를 추상화 시켜 두 클래스를 형제 관계로 만든다면 LSP 위반을 해결할 수 있다.
interface Shape {
public int getArea();
}
class Rectangle implements Shape {
private int width;
private int height;
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
@Override
public int getArea() {
return this.width * this.height;
}
}
class Square implements Shape {
private int width;
private int height;
public void setSize(int length) {
this.width = length;
this.height = length;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
@Override
public int getArea() {
return this.width * this.height;
}
}
public class Main {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle();
Square square = new Square();
rectangle.setWidth(10);
rectangle.setHeight(5);
square.setSize(5);
System.out.println("Rectangle's area: " + rectangle.getArea()); // 50
System.out.println("Square's area: " + square.getArea()); // 25
}
}
2. 상위 타입에서 지정한 리턴 값의 범위에 해당되지 않는 값을 리턴
입력 스트림으로부터 데이터를 읽어 와 출력 스트림에 복사해 주는 복사 기능 CopyUtil
을 구현한 예제이다
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
class CopyUtil {
static void copy(InputStream in, OutputStream out) throws IOException {
byte[] data = new byte[512];
int len = -1;
while ((len = in.read(data)) != -1) { // 0이 리턴되어 무한루프가 된다.
out.write(data, 0, len);
}
}
public static void main(String[] args) throws IOException {
String filePath = "test.txt";
copy(new MyInputStream(), new FileOutputStream(filePath));
}
}
class MyInputStream extends InputStream {
@Override
public int read() throws IOException {
return 0; // -1이 필요하지만 0을 리턴
}
}
MyInputStream
이 상위 타입인InputStream
을 올바르게 대체하지 않아 문제가 발생한다- 상위에서는
-1
을 리턴하지만 서브타입에서0
을 리턴하고 있어 서브타입이 상위타입을 완벽하게 대체하지 못하고 있다
- 상위에서는
📌 LSP: 계약과 확장
1. 계약을 위반하지 않는다
하위형에서 상위형의 불변 조건은 반드시 유지돼야 한다.
LSP는 기능의 명세(계약)에 대한 내용이다. 기존의 계약을 위반 하는 사례는 다음과 같다.
- 명시된 명세에서 벗어난 값을 리턴
- 명시된 명세에서 벗어난 예외를 발생
- 명시된 명세에서 벗어난 기능을 수행
기존의 계약에 벗어난 행동을 하게 되면 이 계약을 바탕으로 작성된 코드는 비동상적으로 동작할 수 있기 때문에 항상 하위 타입은 상위 타입에서 정의한 명세를 벗어나지 않는 범위에서 구현해야 한다. 만약 상위 타입에서 정의한 명세를 벗어난 기능이 꼭 필요하다면 잘못된 상속 관계가 발생했음을 의미하니 상속관계를 다시 정의해야 한다.
- 정사각형 - 직사각형 문제에서 정사각형은 상위 타입 직사각형이 정의한 명세를 어긴 구현을 했다. 직사각형은 높이를 설정할 때 전달받은 값으로 높이를 설정하고 너비 값의 변경은 제공하지 않았다. 하지만 정사각형은 높이를 설정할 때 너비 값의 변경을 진행했다.
2. 확장에 열려 있어야 한다
instanceof
타입 확인 연산자는 LSP를 위반하는 전형적인 증상이다.
class Item {
int price;
public int getPrice() {
return price;
}
}
class SpecialItem extends Item {
}
class NewItem extends Item {
}
class Coupon {
int discountRate;
public int calculateDiscountAmount(Item item) {
if (item instanceof SpecialItem || item instanceof NewItem) { // LSP 위반
return 0;
}
return item.getPrice() * discountRate;
}
}
위 코드의 문제는 하위 타입이 상위 타입을 완벽하게 대체하지 못한다는 점이다. Item
에 관한 새로운 기능이 추가될 때 마다 Coupon
은 수정이 필요하게 되는데 이는 확장에는 열려 있고 수정에는 닫혀 있어야 하는 객체지향의 원칙을 위반하고 있다.
Item
에 대한 추상화가 필요하다. 변경되는 부분을 상위 타입에 추가한다.
class Item {
int price;
public int getPrice() {
return price;
}
public boolean canDiscount() {
return true;
}
}
class SpecialItem extends Item {
@Override
public boolean canDiscount() {
return false;
}
}
class NewItem extends Item {
@Override
public boolean canDiscount() {
return false;
}
}
class Coupon {
int discountRate;
public int calculateDiscountAmount(Item item) {
if (!item.canDiscount()) {
return 0;
}
return item.getPrice() * discountRate;
}
}
댓글남기기