[OOP] 객체지향 5원칙(SOLID) - 리스코프 치환 원칙(Liskov Substitution Principle)

2022. 10. 4. 14:40TIL💡/Design Pattern

리스코프 치환 원칙(Liskov Substitution Principle)

리스코프 치환 원칙은 부모 객체와 이를 상속한 자식 객체가 있을 때 부모 객체를 호출하는 동작에서 자식 객체가 부모 객체를 완전히 대체할 수 있다는 원칙이다.

 

객체지향 언어에선 객체의 상속이 일어난다.

이 과정에서 부모/자식 관계가 정의된다. 자식 객체는 부모 객체의 특성을 가지며, 이를 토대로 확장할 수 있다.

하지만 이 과정에서 무리하거나 객체의 의의와 어긋나는 확장으로 인해 잘못된 방향으로 상속되는 경우가 발생한다.

 

리스코프 치환 원칙은 올바른 상속을 위해 자식 객체의 확장이 부모 객체의 방향을 온전히 따르도록 권고하는 원칙이다.

 

코드로 보는 리스코프 치환 원칙

리스코프 치환 원칙을 설명할 때 많이 사용하는 예제로 직사각형과 정사각형의 관계가 있다.

 

/**
 * 직사각형 클래스
 *
 * @author RWB
 * @since 2021.08.14 Sat 11:12:44
 */
public class Rectangle
{
    protected int width;
    protected int height;
    
    /**
     * 너비 반환 함수
     *
     * @return [int] 너비
     */
    public int getWidth()
    {
        return width;
    }
    
    /**
     * 높이 반환 함수
     *
     * @return [int] 높이
     */
    public int getHeight()
    {
        return height;
    }
    
    /**
     * 너비 할당 함수
     *
     * @param width: [int] 너비
     */
    public void setWidth(int width)
    {
        this.width = width;
    }
    
    /**
     * 높이 할당 함수
     *
     * @param height: [int] 높이
     */
    public void setHeight(int height)
    {
        this.height = height;
    }
    
    /**
     * 넓이 반환 함수
     *
     * @return [int] 넓이
     */
    public int getArea()
    {
        return width * height;
    }
}

Rectangle은 직사각형을 구현한 객체다. 너비와 높이를 지정, 반환할 수 있으며, 지정된 값을 통해 자신의 넓이를 계산할 수 있다.

정사각형은 직사각형의 한 종류이니, 직사각형을 상속하여 정사각형 객체를 빠르게 만들 수 있을 것이라 생각했다.

 

/**
 * 정사각형 클래스
 *
 * @author RWB
 * @since 2021.08.14 Sat 11:19:07
 */
public class Square extends Rectangle
{
    /**
     * 너비 할당 함수
     *
     * @param width: [int] 너비
     */
    @Override
    public void setWidth(int width)
    {
        super.setWidth(width);
        super.setHeight(getWidth());
    }
    
    /**
     * 높이 할당 함수
     *
     * @param height: [int] 높이
     */
    @Override
    public void setHeight(int height)
    {
        super.setHeight(height);
        super.setWidth(getHeight());
    }
}

위처럼 정사각형 객체 Square를 Rectangle의 상속을 통해 쉽게 구현할 수 있었다.

정사각형의 경우 직사각형과 달리 너비와 높이가 같으니, 너비나 높이를 지정하면 그에 맞게 너비와 높이를 모두 일치시켜주도록 오버라이딩을 수행했다.

구현한 Rectangle의 넓이를 구해보자.

/**
 * 메인 클래스
 *
 * @author RWB
 * @since 2021.06.14 Mon 00:06:32
 */
public class Main
{
    /**
     * 메인 함수
     *
     * @param args: [String[]] 매개변수
     */
    public static void main(String[] args)
    {
        Rectangle rectangle = new Rectangle();
        rectangle.setWidth(10);
        rectangle.setHeight(5);
        
        System.out.println(rectangle.getArea());
    }
}

정상저긍로 넓이 50이 반환된다.

 

리스코프 치환 원칙에 의하면, 자식 객체는 부모 객체를 완전히 대체할 수 있다고 했으므로, Rectangle을 상속받은 Square로 대체하여 넓이를 구해보자.

/**
 * 메인 클래스
 *
 * @author RWB
 * @since 2021.06.14 Mon 00:06:32
 */
public class Main
{
    /**
     * 메인 함수
     *
     * @param args: [String[]] 매개변수
     */
    public static void main(String[] args)
    {
        Rectangle rectangle = new Square();
        rectangle.setWidth(10);
        rectangle.setHeight(5);
        
        System.out.println(rectangle.getArea());
    }
}

어째서인지 넓이는 50이 아니라 25로 반환될 것이다. 이 객체는 리스코프 치환 원칙에 위배되는 코드다.

곰곰히 생각해보면 직사각형과 정사각형은 포함관계이긴 하나, 자식 객체인 정사각형은 직사각형의 정합성을 깨뜨려야 한다.

 

즉 직사각형의 특징을 서로 갖고 있긴 하지만, 하나가 다른 하나를 동일한 방식으로 처리할 수 없는 구조이다.

 

리스코프 치환 원칙을 준수한 코드

그렇다면 이 코드를 어떻게 리스코프 치환 원칙에 부합하게끔 구성할 수 있을까?

 

답은 올바른 상속과 구현에 있다.

앞서 설명했다시피, 직사각형과 정사각형은 상속의 관계가 성립되기 어렵다.

따라서 이보다 더 상위 개념인 사각형 객체를 구현하고 정사각형, 직사각형이 이를 상속받으면 될 것이다.

/**
 * 사각형 객체
 *
 * @author RWB
 * @since 2021.08.14 Sat 11:39:02
 */
public class Shape
{
    protected int width;
    protected int height;
    
    /**
     * 너비 반환 함수
     *
     * @return [int] 너비
     */
    public int getWidth()
    {
        return width;
    }
    
    /**
     * 높이 반환 함수
     *
     * @return [int] 높이
     */
    public int getHeight()
    {
        return height;
    }
    
    /**
     * 너비 할당 함수
     *
     * @param width: [int] 너비
     */
    public void setWidth(int width)
    {
        this.width = width;
    }
    
    /**
     * 높이 할당 함수
     *
     * @param height: [int] 높이
     */
    public void setHeight(int height)
    {
        this.height = height;
    }
    
    /**
     * 넓이 반환 함수
     *
     * @return [int] 넓이
     */
    public int getArea()
    {
        return width * height;
    }
}

위와 같이 Shape라는 사각형 객체를 구현한다.

/**
 * 직사각형 클래스
 *
 * @author RWB
 * @since 2021.08.14 Sat 11:12:44
 */
class Rectangle extends Shape
{
    /**
     * Rectangle 생성자 함수
     *
     * @param width: [int] 너비
     * @param height: [int] 높이
     */
    public Rectangle(int width, int height)
    {
        setWidth(width);
        setHeight(height);
    }
}
/**
 * 정사각형 클래스
 *
 * @author RWB
 * @since 2021.08.14 Sat 11:19:07
 */
class Square extends Shape
{
    /**
     * Square 생성자 함수
     *
     * @param length: [int] 길이
     */
    public Square(int length)
    {
        setWidth(length);
        setHeight(length);
    }
}

Shape를 상속받는 두 사각형 Rectangle과 Square 객체는 위와 같다.

Rectangle은 인스턴스 생성 시 width와 height를 파라미터로 받으며, Square는 각 변의 길이가 모두 동일하므로 length 하나만을 파라미터로 받는다.

/**
 * 메인 클래스
 *
 * @author RWB
 * @since 2021.06.14 Mon 00:06:32
 */
public class Main
{
    /**
     * 메인 함수
     *
     * @param args: [String[]] 매개변수
     */
    public static void main(String[] args)
    {
        Shape rectangle = new Rectangle(10, 5);
        Shape square = new Square(5);
        System.out.println(rectangle.getArea());
        System.out.println(square.getArea());
    }
}

이제 더 이상 Rectangle과 Square가 상속 관계가 아니므로, 리스코프 치환 원칙의 영향에서 벗어났다.

 

추가)

상속할 때 해서는 안되는 행위

1. 부모의 행위를 자식이 거부

→ 부모 클래스에서 구현 가능한 기능을 자식 클래스에서 사용을 못하게 막은 경우

큰 단위의 추상화 클래스를 기반으로 프로그래밍을 하는데 위와 같은 상황이 발생하면 에러가 발생할 가능서이 높아진다.

 

2. 퇴화 함수

→ super class에 있는 함수를 override한 다음에 못 쓰게 하는 경우를 말한다.

 

리스코프 원칙을 위반하게 되면

1. 모든 클래스에서 하위 클래스를 명시적으로 지정해서 코딩해야 함

2. OCP 위반 가능성이 발생

3. 코드의 복잡도를 높인다. → 만약 잘못된 상속을 할 경우 추후 유지보수를 할 때 상당한 어려움이 뒤따른다.

4. 부모 클래스가 자식 클래스를 알아야하는 경우도 발생한다.

 

리스코프 원칙을 준수하면

1. 상위 클래스를 기준으로 작성된 코드가 문제없이 동작

2. 추상화된 인터페이스 하나로 공통의 코드를 작성할 수 있음

 

정리

리스코프 치환 원칙은 상속되는 객체는 반드시 부모 객체를 완전히 대체해도 아무런 문제가 없도록 권고한다.

위의 직사각형과 정사각형의 케이스처럼 올바르지 못한 상속관계는 제거하고, 부모 객체의 동작을 완벽하게 대체할 수 있는 관계만 상속하도록 코드를 설계해야 한다.

 

리스코프 치환 원칙을 지키기 위해선 가급적 부모 객체의 일반 메소드를 그 의도와 다르게 오버라이딩하지 않는 것이 중요하다.

 

부모 객체의 오버라이딩은 주로 동일한 메소드를 자식 객체만의 동작을 추가하기 위해 한다는 걸 감안하며 준수하기 까다로운 원칙이다.

 

참고

- https://blog.itcode.dev/posts/2021/08/15/liskov-subsitution-principle

- https://kurediary.tistory.com/39