SWE

리스코프 치환 원칙

백명석 님의 클린 코더스 강의를 듣고 요약정리한 글입니다. 문제가 있을 경우 삭제 조치하도록 하겠습니다.

1. Liskov Substitution Principle

public class LSP {
    static P p = new P();
	
    static class T {
        public void doSomething() {
            System.out.println("T#doSomething called");
        }
    }
    
    static class S extends T { // 5. S는 T의 서브타입
        public void doSomething() {
            System.out.println("S#doSomething called");
        }
    }
    
    static class P { // 3. T 타입을 사용하는 모든 프로그램 P에서
        public void doSomething(T p) {
        	p.doSomething();
        }
    }
    
    public static void main(String[] args) {
        T p = new T(); // 1. T 타입의 객체 p
        S c = new S(); // 2. S 타입의 객체 c
        
        p.doSometing(p);
        p.doSomething(c); // 4. c가 p를 대체해도 P에는 어떤 변경도 없다면
    }
}

만약 위처럼 c가 p를 대체했을 때, P에 어떤 변경사항도 없다면 S는 T의 서브 타입이라 할 수 있다.  만약 P#doSomething에서 T를 받던지, T의 서브 타입을 받는다면 프로그램이 잘 돌아간다면 LSP를 만족한다고 할 수 있다.

다시 말하자면, 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램이 정상적으로 동작해야 한다는 원칙이다. 업캐스팅 안전 원칙이라고도 하는 이 원칙은 부모 쪽으로 업캐스팅하는 것이 안전함을 보장하기 위해 존재한다. 상위 타입 T에 대해 기대되는 역할과 행동 규약이 있는데, 이를 벗어나면 안된다.

일반적으로 객체지향에서 재사용을 이야기할 때 많이 언급되는 것은 슈퍼 클래스에 많은 기능을 넣어두고 이를 재사용하기 위해 서브 클래스를 만드는 것이다.(구현 상속) 하지만 이런 방법은 다운 캐스트를 자주 사용하게 된다. 그리고 다운 캐스트를 자주 사용하면 타입에 대한 의존성을 만들게 된다. (지독한 의존성) LSP는 이러한 것을 하지 말아야 한다고 한다.

2. OCP vs LSP

OCP

  • abstraction, polymorphism (inheritance)를 이용해서 구현

LSP

  • OCP를 받쳐주는 polymorphism에 관한 원칙을 제공
  • LSP가 위반되면 OCP도 위반됨
  • LSP를 위반하면 subtype이 추가될 때마다 클라이언트들이 수정되어야 함
  • instanceof/downcasting을 사용하는 것은 전형적인 LSP 위반의 징조
  • 서브 클래스에서 슈퍼 클래스를 사용하지 않도록 한다. (명석님 견해)

3. Rectangle 예제

public class Rectangle {
    private int width;
    private int height;
    
    public int area() {
        return width * height;
    }
    
    public void setWidth(int width) {
        this.width = width;
    }
    
    public void setHeight(int heigth) {
        this.height = heigth;
    } 
}
public class RectangleTest {
    private final int width = 5;
    private final int height = 3;
    
    @Test
    public void
    should_return_area_by_multiplying_and_height() {
        // given
        Rectangle rectangle = createRectangle(new Rectangle());
        
        // when
        int area = rectangle.area();
        
        // then
        assertThat(area, is(width * height));
    }
    
    private Rectangle createRectangle(Rectangle rectangle) {
        rectangle.setWidth(width);
        rectangle.setHeight(height);
        return rectangle;
    }
}

LSP와 OCP를 위반하게 되는 경우

  • Rectangle은 시스템의 여러 곳에 퍼져있다.
  • 정사각형(Square)을 서브 타입으로 추가하려고 한다.
  • Square IS-A Rectangle
public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        super.setHeight(width);
        super.setWidth(width);
    }
    
    @Override
    public void setHeight(int heigth) {
        super.setHeight(height);
        super.setWidth(height);
    }
}

테스트 코드 중 Rectangle을 생성하는 메서드에서 setWidth와 setHeight를 각각 호출하다 보니 area 각각 3*3, 5*5가 될 것이다. 따라서 테스트 코드는 실패한다. 또한 만약 Square에 setSide를 따로 만들어도 이를 사용하기 위해서는 클라이언트에서는  instanceof, downcast를 사용해야 한다.

즉, 상위 타입에 대해 기대되는 행동 양식을 벗어나게 된다. (width와 heigth는 따로 설정 가능하다는 조건) 이는 LSP 위반이며 결국 instanceof를 호출하게 만드니 OCP 위반이다. (타입이 추가될 때마다 변경이 발생)

private void assertArea(Rectangle rectangle) {
    if(rectangle instanceof Square) {
        assertThat(rectangle.area(), is(height * height);
    } else {
        assertThat(rectangle.area(), is(width * height);
    }
}

4. The Representative Rule

  • 대리인은 자신이 대리하는 어떤 것들에 대한 관계까지 대리(공유) 하지는 않는다.
  • 이혼 소송 변호사들(대리인)이 이혼하는 부부들의 관계(부부)를 대리(공유)하지 않는 것처럼. 남편의 변호사와, 아내의 변호사가 부부인가?
  • 따라서 기하학에 따르면 Square IS-A Rectangle이지만
  • 이들을 표현/대리(represent)하는 SW는 그들의 관계(IS-A)를 반드시 공유하지 않는다. (Rectangle-Square의 관계)

5. Conclusion

만약 코드에 instanceof나 다운캐스트, 서브클래스에서 슈퍼클래스 호출이 만연하다면 상속관계를 끊고 합성(composition) 관계로 변경하는 것을 고려해 볼 수 있다.