
'개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴'을 읽고 학습합니다. ( 최범균 저 )
이전의 개발 폐쇄 원칙은 추상화와 더불어 다형성을 이용하여 구현했는데, 리스코프 치환 원칙은 개방 폐쇄 원칙을 받쳐 주는 다형성에 관한 원칙을 제공한다.
리스코프 치환 원칙
→ ' 상위 타입의 객체를 하위 타입 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다. '
이 말의 의미를 코스 수준에서 살펴보자면 상위 타입 클래스와 하위 타입 클래스를 통해 파악할 수 있다.
아래의 코드로 예시를 살펴보자.
public void somMethod(SuperClass sc) {
sc.someMethod();
}
someMethod()는 상위 타입인 sc 객체를 사용하는 메서드이다.
그리고 만약 이 객체의 하위 타입을 대신 사용해도 정상적인 동작이 수행되어야 한다는 것이다.
public void somMethod(SubClass sc) {
sc.someMethod();
}
만약 리스코프 치환 원칙이 지켜지지 않는다면, 다형성에 기반한 설계가 무너지기 때문이다.
이전의 개방 폐쇄 원칙도 리스코프 치환 원칙이 먼저 지켜지지 않는다면, 유연한 사용을 구성할 수 없다.
리스코프 치환 원칙을 지키지 않을 때의 문제
리스코프 치환 원칙을 설명할 때 자주 사용되는 예시가 직사각형 - 정사각형 예시이다.
먼저 직사각형 객체인 Rectangle 클래스이다.
public class Rectabgle {
private int width;
private int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getWidth(){
return width;
}
public int getHeight(){
return height;
}
}
그리고 정사각형이 직사각형의 특수한 경우라도 생각하여, 정사각형을 표현하기 위한 Square 클래스를 Rectangle 클래스를 상속받도록 구현 했다고 고려해보자.
정사각형은 가로와 세로가 모두 동일한 값을 가지므로, Square 클래스는 다음과 같은 로직을 지녔다.
public class Square extends Rectangle {
@Override
public class setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public class setHeight(int height) {
super.setWidth(height);
super.setHeight(height);
}
}
한편, Rectangle 클래스에서는 객체의 높이와 폭을 비교해서 높이를 더 길게 만들어주는 기능을 제공하는 메서드가 있다.
public void increaseHeight(Rectangle rec) {
if (rec.getHeight() <= rec.getWidth()) {
rec.setHeight(rec.getWidth() + 10);
}
}
increaseHeight() 메서드를 사용하는 코드는 실행 후 height의 값이 width 값보다 10 더 크다고 가정할 것이다.
그런데 해당 메서드에 대한 파라미터로 Square 객체가 들어가면 이 가정은 깨지게 될것이다.
set 메서드를 사용하면 높이와 폭을 모두 같은 값으로 만들도록 동작하기 때문이다.
이를 해결하려면, instanceof 연산자를 통해서 객체의 타입을 확인하고 로직을 작성할 수 있을 것이다.
그러나 instanceof 연산자의 사용은 곧 리스코프 치환 원칙이 깨지는 것을 의미하며, 더 나아가서는 개방 폐쇄 원칙의 위반까지 도달함을 의미하게 된다. 즉, Rectangle의 확장에는 열려 있지않다는 것이다.
public void increaseHeight(Rectangle rec) {
if(rec instanceof Square) {
throw new CantSupportSquareException();
}
if (rec.getHeight() <= rec.getWidth()) {
rec.setHeight(rec.getWidth() + 10);
}
}
이와 같은 직사각형 - 정사각형 문제는 개념적으로 상속 관계에 있는 것처럼 보일지라도 구현에서는 상속 관계가 아닐 수 있음을 보여준다.
개념상 정사각형은 높이와 폭이 같은 직사각형이므로, Rectangle 클래스를 상속받아 Square 클래스를 구현하는 것이 합리적으로 보일 수 있으나, 실제 프로그램에서는 이 둘을 상속 관계로 묶을 수 없는 것이다. ( 그리고 이를 쉽게 파악하는 한 가지 방법은 IS-A 관계일 때, 상속을 사용하는 것이다. 이유는 리스코프 치환 원칙은 객체 지향적 관점에서 상속의 장점을 살리기 위한 원칙이기에.. )
따라서 클래스만 보고 올바른 설계인지 판단하지 어려우며, 그것이 실제로 어떻게 사용되는 것인지에 대한 세심한 고려의 필요성이 필요하다.
리스코프 치환 원칙을 여기는 또 다른 흔한 예는 상위 타입에서 지정한 리턴 값의 범위에 해당하지 않는 값을 리턴하는 경우이다.
예를 들어, 입력 스트림으로 데이터를 읽어와서 출력 스트림에 복사하는 기능은 아래와 같이 구현된다.
public class CopyUtil {
public static void copy(InputStream is, OutputStream out) {
byte[] data = new byte[512];
int len = -1;
// InputStream.read() 메서드는 스트림의 끝에 도달하면 -1을 리턴
while(len = is.read(data) != -1) {
out.write(data, 0 ,len);
}
}
}
InputStream의 read() 메서드는 스트림의 끝에 도달해서 더 이상 데이터를 읽어올 수 없는 경우 -1을 리턴한다.
그리고 CopyUtil 클래스의 copy() 메서드는 규칙에 따라 리턴값이 -1이 아닌 경우까지 반복해서 데이터를 읽고 out에 쓰는 동작을 수행한다.
그런데 만약 InputStream을 상속한 하위 타입에서 read() 메서드를 아래와 같이 구현한다면?
public class SatanInputStream implements InputStream {
public int read(byte[] data) {
...
return 0; // 데이터가 없을 떄 0을 리턴하도록 구현
}
}
이 사탄 스트림 객체는 데이터가 없는 경우 0을 리턴하도록 구현되었다.
그리고 해당 객체를 구현체로 사용한다면 이를 사용하는 객체는 실수를 저지를 수 있으며
아래와 같은 상황을 가정해보자.
InputStream is = new SatanInputStream();
// 사탄 스트림을 사용
public class CopyUtil {
public static void copy( InputStream is , OutputStream out ) {
// is가 사탄 스트림이다.
// -1을 리턴하지 않기에 아래 코드는 무한 루프에 빠진다.
while((len = is.read(data) != -1) {
out.write(data, 0 , len);
}
}
}
이런 문제가 발생하는 것은 SatanInputStream 타입의 객체가 상위 타입인 InputStream을 올바르게 대체하지 않기 때문이다.
즉, 리스코프 치환 원칙을 지키지 않았기에 문제가 발생한 것이다.
리스코프 치환은 계약과 확장에 대한 것
리스코프 치환 원칙은 기능의 명세( 또는 계약 )에 대한 내용이다. 앞서 직사각형 - 정사각형 문제의 예에서 Rectangle 클래스의 setHeight() 메서드는 이 메서드의 사용자에게 다음과 같은 계약을 제공하는 것이다.
높이 값을 파라미터로 전달받은 값으로 변경한다.
폭 값은 변경되지 않는다.
그런데, Square 클래스는 높이와 폭을 함께 변경한다. 따라서 기존 Rectangle 클래스에서 정의한 동작(명세, 책임)의 기대와 달리 작동하여 예상하지 못한 결과를 반환한다.
이처럼 기능 실행과 계약과 관련하여 흔히 발생하는 위반 사례는 다음과 같은 것들이 있다.
명시된 명세에서 벗어난 값을 리턴
명시된 명세에서 벗어난 예외 발생
명시된 명세에서 벗어난 기능을 수행
만약, 하위 타입이 위와 같이 명세에서 벗어난 동작을 수행하면, 이 명세에 기반하여 구현한 코드는 비정상적으로 동작할 수 있기에, 하위 타입은 상위 타입에서 정의한 명세를 벗어나지 않은 범위의 구현이 필요하다는 것이 ' 리스코프 치환 ' 원칙이다.
또한 리스코프 치환 원칙은 확장성과 관련이 있는데, 그 이유는 추상화와 다형성을 사용한 개방 폐쇄 원칙은 리스코프 치환 원칙이 보장된 상태에서 사용할 수 있기 때문이다.
만약, 리스코프 치환 원칙이 깨진 상태라면, 개방 폐쇄 원칙을 지키기 어려워진다.
예를 들어, 아래의 코드처럼 상품에 쿠폰을 적용해서 할인되는 액수를 구해주는 기능을 구현할 경우, 다음 코드처럼 Coupon 클래스에서 Item 클래스의 값을 구한 뒤 할인되는 금액을 계산할 수 있을 것이다.
public class Coupon {
public int calculateDiscountAmount(Item item) {
return item.getPrice() * discountRate;
}
}
위의 클래스에서느 메서드는 파라미터로 받은 상품의 가격에 할인될 값을 구한다.
그런데, 특수 상품은 무조건 할인하지 않는 정책이 추가되어 Item 클래스를 상속받는 SpecialItem 클래스를 추가했다고 고려하자.
이 경우, SpecialItem 이면 할인 금액을 0으로 처리해 주어야 하는데, 그럼 아래와 같은 구현사항을 변경할 수 있을 것이다.
public class Coupon {
public int calculateDiscountAmount(Item item) {
if(item instanceof SpecialItem) {
return ();
}
return item.getPrice() * discountRate;
}
}
이제 변경된 요구에 맞춰 구현이 바뀌었지만, 이건 리스코프 치환 원칙 위반이자 개방 폐쇄 원칙 위반이다.
특정 타입의 존재를 알아야하는 구현체에 의존하여 로직을 수행하고 있는 데, 하위 타입이 상위 타입을 대체할 수 없다는 의미이며 리스코프 치환 원칙이 깨졌다. 그리고 새로운 종류의 하위 타입이 생길 때마다, 절차 향적 코드를 만들게 된다.
결국 기능 확장에 열려있고 이를 사용하는 클라이언트인 Coupon 객체에는 닫혀 있어야하는 개방 폐쇄 원칙도 깨진 것이다.
이와 같은 케이스는 Item 객체에 대한 추상화가 덜 되었기 때문이다. 할인되지 않는 상품 타입이 추가되었다는 의미는 이후 비슷한 요구가 발생할 수 있다는 가능성이 높다는 것이다.
예를 들어, 신규 상품은 한 달간 할인이 안 된다거나 하는 식으로 추가 요구가 발생할 수 있다.
따라서 상품의 가격 할인 여부는 Item과 Item 하위 타입의 객체에서 변화되는 부분으로 해당 부분을 Item 클래스에 추가하여 리스코프 치환 원칙을 지켜야 한다.
아래와 같은 Item 클래스를 더 추상화할 수 있다.
public class Item {
// 변화되는 기능을 상위 타입에 추가
public boolean isDiscountAvailable() {
return true;
}
...
}
public class SpecialItem extends Item {
// 하위 타입에서 알맞게 오버라이딩
@Override
public boolean isDiscountAvailable() {
return false;
}
}
상위 타입의 Item 클래스에 가격 할인 여부를 판단하는 기능을 추가하고, SpecialItem 클래스는 이 기능을 알맞게 재정의했다.
이처럼, 변화되는 부분을 상위 타입에 추가하여 클라이언트의 로직은 더 이상 instanceof 연산자를 사용하지 않고 로직 작성이 가능해진다. 아래와 같은 코드를 통해 살펴보자.
public class Coupon {
public int calculateDiscountAmount(Item item) {
if ( !item.isDiscountAvailable() ) {
return 0;
}
return item.getPrice() * discountRate;
}
}
이제 클라이언트 코드가 Item 객체의 기능 확장에 닫힌 모습이다.
리스코프 치환 원칙 위반은 곧 개방 폐쇄 원칙의 위반으로 이어지기에 리스코프 치환 원칙을 지키지 않으면, 기능의 확장의 측면에서도 어려움이 생긴다.
'Java > 객체지향' 카테고리의 다른 글
| 객체 지향과 디자인 패턴 Chapter 05 - 의존 역전 원칙(SOLID) (2) | 2024.11.15 |
|---|---|
| 객체 지향과 디자인 패턴 Chapter 05 - 인터페이스 분리 원칙(SOLID) (1) | 2024.11.14 |
| 객체 지향과 디자인 패턴 Chapter 05 - 개방 폐쇄 원칙(SOLID) (1) | 2024.11.05 |
| 객체 지향과 디자인 패턴 Chapter 05 - 설계원칙: 단일 책임 원칙(SOLID) (1) | 2024.11.02 |
| 객체 지향과 디자인 패턴 Chapter 04 - 재사용: 상속보단 조립 (0) | 2024.10.30 |
댓글