[개발자_객체지향_디자인패턴] 5 - 3. 리스코프 치환 원칙(Liskov Substitution Principle)

박상준·2024년 9월 13일
0

정의

  • 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.

예시

public class SuperClass {
    public int someMethod() {
        // 항상 양의 정수를 반환
        return 1;
    }
}

public class SubClass extends SuperClass {
    @Override
    public int someMethod() {
        // 음의 정수를 반환
        return -1;
    }
}
  • SuperClasssomeMethod 가 항상 양의 정수 반환
  • 그러나
  • SubClasssomeMethod 가 항상 음의 정수 반환
  • 하위 클래스의 메서드는 SuperClass 의 동작을 기대하고 작성되었기에, 음의 정수가 반환되는 경우 오류가 발생할 수 있음.
    • LSP 위반

위반 예시1

직사각형 - 정사각형 문제

  • Rectangle 클래스를 통해 직사각형을 표현하는 경우
    • 가로 - width

    • 세로 - height

      값을 설정하고 가져오는 메서드 제공
      public class Rectangle {
          protected int width;
          protected 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;
          }
      }
    • 정사각형의 경우 모든 변의 길이가 같은 직사각형의 일종이다.

    • Rectangle 크래스를 상속해 Square 클래스를 만드는 경우

    • 정사각형의 경우 가로, 세로가 동일해야하기에,

      public class Square extends Rectangle {
          @Override
          public void setWidth(int width) {
              this.width = width;
              this.height = width; // 가로와 세로를 동일하게 설정
          }
      
          @Override
          public void setHeight(int height) {
              this.width = height; // 가로와 세로를 동일하게 설정
              this.height = height;
          }
      }
      
    • 직사각형 클래스에서 만약에

      public void increaseHeight(Rectangle rec) {
          if (rec.getHeight() <= rec.getWidth()) {
              rec.setHeight(rec.getWidth() + 10);
          }
      }
      
    • 같은 가로, 세로 길이를 조정하는 메서드가 등장한 경우

    • 정사각형 객체 Square 의 경우 상당히 곤란해진다

      • 정사각형은 가로, 세로 길이가 동일하기 때문이다.
    • 그래서 개발자는 급하게 직사각형의 객체에서

      public void increaseHeight(Rectangle rec) {
          if (rec instanceof Square) {
              throw new IllegalArgumentException("Square 객체는 지원되지 않습니다.");
          }
      
          if (rec.getHeight() <= rec.getWidth()) {
              rec.setHeight(rec.getWidth() + 10);
          }
      }
      
    • 하위 클래스에서 사용하지 못하도록 instanceof 를 통해 사용 객체를 제한한다.

    • LSP 상에서 분명히

      • 하위 타입에서도 상위 타입의 메서드를 사용이 가능하고, 의도대로 동작해야 한다고 말했었다..
      • instanceof 가 해당 행위를 제한해버리니 이는
      • LSP 를 위반한 케이스가 된다.
    • 만약 해당 2가지 타입의 경우에는 상속이 아니도록 다른 도메인으로 분리하거나, Square 클래스내에서 멤버 변수로 선언하는 등의 행위가 있다.

위반 예시2.

InputStream 예시

public abstract class InputStream {
    public abstract int read(byte[] data) throws IOException;
}
public class CopyUtil {
    public static void copy(InputStream is, OutputStream os) throws IOException {
        byte[] buffer = new byte[512];
        int len;
        while ((len = is.read(buffer)) != -1) {
            os.write(buffer, 0, len);
        }
    }
}
  • InputStream 는 자바에 정의되어 있는 클래스입니다.
    • 스트림의 끝에 도달하면 -1 을 반환하도록 정의되어 있다고 합니다.
  • CopyUtil 클래스의 경우, InputStream 이 -1 을 반환할 때까지 데이터를 읽어와서 출력 스트림에 쓰는 역할을 합니다.
  • 만약에 IS 를 상속한 하위 클래스의 read 메서드가 데이터를 더 이상 읽을 수 없다면 0 을 반환하도록구현되어 있다면 문제가 발생합니다.
     public class ZeroReturningInputStream extends InputStream {
        @Override
        public int read(byte[] data) throws IOException {
            // 데이터가 없을 때 0을 반환
            return 0;
        }
    }
    InputStream is = new ZeroReturningInputStream();
    OutputStream os = new FileOutputStream("output.txt");
    CopyUtil.copy(is, os); // 무한 루프 발생
    
    • CopyUtils 은 IS 가 -1 을 반환할 때까지 기다릴겁니다.
      • 그러면.. 0 을 반환해버릴테니 결국 루프가 종료되지 않게 됩니다.
    • 결국
      • ZeroReturningInputStream 가 InputStream 의 계약을 준수하지 않는다고 볼 수 있습니다.

리스코프 치환 원칙은 계약과 확장에 대한 것이다.

  • LSP 의 경우 OOP 에서 기능의 명세 또는 계약 에 관한 내용이다.
    • 하위 클래스가 상위 클래스의 계약을 준수해야 함을 의미하는데, 이를 통하여 프로그램의 안정성, 일관성을 유지할 수 있다.

계약의 중요성

  • 직사각형 - 정사각형 문제 에서 Rectangle 클래스에서 setHeight 메서드는
    1. 높이 값을 전달 된 값으로 변경
    2. 폭 값은 변경 X
    • 이라는 계약을 기반으로 동작합니다.
  • 만약
    • Square 클래스에서
      • 해당 메서드를 재정의하면서 폭을 조정해버린다면..
      • 기존의 계약이 깨지게되고, 코드의 의도는 엉망이 되어 버립니다.

계약 위반 사례

  1. 명시된 범위를 벗어난 값을 반환
    • 메서드가 0 이상의 값을 반환하도록 되어 있음.
    • 음수 값을 반환
  2. 명시되지 않은 예외 발생
    • IOException 만 발생시키도록 명세
    • IllegalArgumentException 를 발생시킴
  3. 명시된 기능 외의 동작 수행
    • 데이터 저장만 해야하는데, 추가로 로그 기록하고 다른 사이드 이펙트 발생시킴

확장과 리스코프 치환 원칙

  • LSP 는 확장 이 주요합니다.
    • 해당 원칙을 지키지 않는 경우 LSP 를 준수하기 어렵다고 합니다.
  • 상품에 쿠폰 적용하여 금액을 계산하는 기능의 경우
    public class Coupon {
        public int calculateDiscountAmount(Item item) {
            return item.getPrice() * discountRate;
        }
    }
    • Item 객체의 가격을 기반으로 할인 금액을 계산하는데,

    • 특정 상품은 할인에서 제외해야하는 요구사항이 생겨

    • SpecialItem 이라는 하위 클래스를 추가했다고 가정한다

      public class Coupon {
          public int calculateDiscountAmount(Item item) {
              if (item instanceof SpecialItem) // 리스코프 치환 원칙 위반
                  return 0;
              return item.getPrice() * discountRate;
          }
      }
    • 해당 케이스는 SpecialItem 이 상위 타입인 Item 을 완전히 대체하지 못함을 보여준다.

    • LSP 를 위반하는 것이다.

    • 문제점

      1. OCP 위배
        • 새로운 하위 타입 추가시마다 Coupon 클래스가 수정되어야 한다
        • 확장에 닫혀있다
      2. 유지보수성 저하
        • 코드가 걍 더러워짐
    • 해결방안

      • 해결을 위하여 추상화 수준 을 증대해야함

        public class Item {
            public boolean isDiscountAvailable() {
                return true;
            }
        }
        
        public class SpecialItem extends Item {
            @Override
            public boolean isDiscountAvailable() {
                return false;
            }
        }
        
    • isDiscountAvailable 를 통하여 각 객체가 할인 가능 한지 추가하고

      public class Coupon {
          public int calculateDiscountAmount(Item item) {
              if (!item.isDiscountAvailable())
                  return 0;
              return item.getPrice() * discountRate;
          }
      }
      
    • 해당 아이템의 상위 ~ 하위 타입까지 할인가능 여부를 고려하면 된다.

profile
이전 블로그 : https://oth3410.tistory.com/

0개의 댓글