5 분 소요

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;
	}
}

댓글남기기