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

리스코프 치환 원칙은 SOLID 원칙 중에서도 가장 애매하고 오해하기 쉬운 원칙이다.

 

이전과 마찬가지로 이 원칙의 이론적 정의를 설명하자면,

'B가 A의 자식 타입이면 부모 타입인 A 객체는 자식 타입인 B로 치환해도, 작동에 문제가 없어야 한다'.

 

기본적인 뜻

이 원칙은 자식 클래스를 구현하는 개발자가 기존 프로그램이 문제없이 안정적으로 작동할 수 있도록

가이드라인을 알려주는 원칙이라고 볼 수 있을 것이다.

 

가장 기본적인 정의로는 A - B의 부모 자식에 대한 정의가 논리적으로 제대로된 상속이어야.

기존 프로그램이 자식 클래스인 B로 치환해도 문제 없이 작동해야한다는 것이다.

 

일반적으로 많이 드는 예시가 바로 직사각형을 상속한 정사각형 클래스의 예시인데,

정사각형 클래스가 직사각형 클래스를 상속해버리면,

정사각형의 특징인 '네 변의 길이는 동일하다'와 그렇지 않은 직사각형의 차이로 인해,

직사각형을 정사각형 클래스로 치환해서 사용할 때,

네 변의 길이에 대한 두 클래스의 특징 차이 때문에 기존 프로그램이 오작동 할 수 있다는 얘기이다.

 

포괄적인 뜻

기본적인 뜻은 그렇지만, 이 원칙은 자식 클래스의 잘못된 상속 구현으로 인한 문제에 대해 좀 더 포괄적인 의미를 내포하고 있다.

개발자 관점으로 더 자세히 얘기하면,

'부모 클래스 타입인 A를 사용하는 기존의 프로그램 코드가 자식 클래스 B로 대입 시켰을 때도 문제 없이 작동하도록 하기 위해서,

자식 클래스는 부모 클래스가 따르던 계약 사항을 자식도 따라야한다.'이다.

 

여기서 계약이란, 클래스의 멤버가 어떻게 작동하는지에 대한 구현 조건 사항 같은 것을 말한다.

 

문제 예시

좀 더 쉬운 이해를 위해 예를 들어보자.

public static void Main()
{
    Parent parent = new Parent();
    Content content = parent.GetContent();
    content.WriteLog();
}

public class Parent
{
    public virtual Content GetContent()
    {
    	Content content = new Content();
    	return content;
    }
}

public class Child : Parent
{
    public override object GetContent()
    {
    	return null;
    }
}

public class Content
{
    public void WriteLog()
    {
    }
}

A라는 개발자가 Parent라는 클래스를 만들고, Content 클래스를 리턴하는 GetContent 함수를 추가했다.

GetContent는 무슨 일이 있어도 최소한 디폴트 값을 가진 null이 아닌 값을 리턴하도록 기획이 되어,

A는 GetContent가 디폴트 값을 리턴하도록 구현해놓았다.

근데 나중에 GetContent에 대한 변경 사항이 생겨서 함수의 내부 로직을 바꿔야하는 상황이 왔다.

여기서 B라는 다른 개발자는 개방 폐쇄 원칙에 의거해, Parent 클래스를 수정하는 게 아닌, 새로운 Child 클래스를 구현해,

GetContent를 오버라이드 하기로 했다.

그런데 B 개발자는 디폴트 값으로 null을 리턴하도록 작성했고,

나중에 Parent를 Child로 치환한 Main 함수에서는 NullReferenceException에 의해 오류가 발생한다.

 

이게 리스코프 치환 원칙의 중요 포인트다. 자식 클래스로 부모 클래스의 내용을 상속하는데,

기존 코드에서 보장하던 조건을 수정하거나 적용시키지 않아서, 또는 엉뚱한 자식 클래스를 구현해서,

기존 부모 클래스를 사용하는 코드에서 예상하지 않은 오류를 발생시킨 것이다.

 

사전에 약속한 기획대로 구현하고, 상속 시, 부모에서 구현한 원칙을 따라야 한다가 이 원칙의 핵심이다.

 

좀 더 자세하게 (이론적으로)

... 솔직히 여기서 끝내려고 했는데, 구글링으로 이론에 대해 제대로 이해하고 보충하려고 찾다가 보니,

자세하게 이 이론에 대해 설명하는 곳이 없어서 좀 더 이론적인 부분에 대해 적어보려고 한다.

 

그럼 여기서 자식 클래스가 부모 클래스의 계약을 제대로 수행한다는게 어떤 의미를 거지고 있을까?

갓갓 위키피디아에서는 이 원칙을 충족시키기 위한 조건을 제대로 설명을 하고 있다.


리스코프의 원칙은 새로운 객체 지향 프로그래밍 언어에 채용된 시그니처에 관한 몇 가지 표준적인 요구사항을 강제한다.

  • 하위형에서 메서드 인수의 반공변성 
  • 하위형에서 반환형의 공변성
  • 하위형에서 메서드는 상위형 메서드에서 던져진 예외의 하위형을 제외하고 새로운 예외를 던지면 안 된다.

여기에 더하여 하위형이 만족해야 하는 행동 조건 몇 가지가 있다. 이것은 계약이 상속에 대해 어떻게 상호작용 하는지에 대한 제약조건을 유도하는 계약에 의한 설계 방법론과 유사한 용어로 자세히 설명되어있다.

  • 하위형에서 선행 조건은 강화될 수 없다.
  • 하위형에서 후행 조건은 약화될 수 없다.
  • 하위형에서 상위형의 불변 조건은 반드시 유지되어야 한다.

https://ko.wikipedia.org/wiki/%EB%A6%AC%EC%8A%A4%EC%BD%94%ED%94%84_%EC%B9%98%ED%99%98_%EC%9B%90%EC%B9%99

(위키피디아 답게 사람이 이해할 수 있는 언어로 설명하고 있지 않고 있다.)

 

일단 위에 첫째 문단은 좀 더 나중에 설명하고 아래 문단부터 살펴보자.

아래 문단은 앞에서 설명했던 것을 더 자세하게 설명한 것이다.

 

하위형에서 선행 조건은 강화될 수 없다.

여기서 하위형은 자식 클래스를 말한다.

선행 조건은 사전 조건이라고도 하는데, 사전적 의미로 함수가 오류 없이 실행되기 위한 모든 조건을 정의한 것이라고 한다.

대충 함수를 처리할 때, 전달된 파라미터 값이 옳지 그른지 체크하는 것을 말한다.

public void Method(int data)
{
    // 값이 음수여선 안된다.
    if (data < 0)
    {
    	throw new ArgumentOutOfRangeException("data", "데이터 값은 음수이면 안됩니다.");
    }
    
    // 코드
}

이와 같이 data 파라미터가 제대로 된 값이 들어오는지 확인하는 것을 선행 조건이라고 한다.

 

강화된 조건은 선행 조건을 추가하는 것을 말한다. 조건을 더 까다롭게 만든다라고 생각할 수도 있다.

public void Method(int data)
{
    // 값이 0이거나 음수여선 안된다.
    if (data <= 0)
    {
    	throw new ArgumentOutOfRangeException("data", "데이터 값은 0보다 커야합니다.");
    }
    
    // 코드
}

코드를 보면 Data 값이 0이면 안된다라는 조건을 추가시켰다. 리스코프 원칙은 이렇게 짜선 안된다는 것을 말한다.

부모 클래스와 동일한 수준의 선행 조건을 기대하고 사용하는 프로그램 코드에서 예상치 못한 문제를 일으킬 수 있기 때문이다.

(그럼 완화는 괜찮냐에 대해선 이미 프로그램에서 함수 호출 시에 선행 조건을 충족시켜서 값을 전달하기 때문에,

완화된 조건이 들어오는 일은 일어날 수 없기 때문에 괜찮다.)

 

하위형에서 후행 조건은 약화될 수 없다.

후행 조건은 사후 조건이라고도 하는데,

사전적 의미로 함수가 호출된 후에 객체(데이터)가 유효한 상태로 존재하는지 여부를 검사하는 것이라고 한다.

대충 함수 내에서, 처리 후에 리턴 값이나 참조 파라미터로 전달된 값이 제대로 정의된 값인지 검사하는 것이다.

 

public int Method_A(int data)
{
    int result;
    
    // 실행 코드
    
    if (result < 0)
    {
    	result = 0;
    }
    
    return result;
}

public void Method_B(Money money, int data)
{
    // 실행 코드
    
    if (money.Amount < 0)
    {
         throw new ArgumentOutOfRangeException("money", "money.Amount 결과값이 음수 입니다.");
    }
}

두 함수가 바로 후행 조건의 예시이다.

이처럼 함수 종료 시점에 전달될 객체 값이 유효한 값인지 검사하는 것을 말한다.

 

그럼 약화된 다는 뜻은 무엇일까?

약화된다는 뜻은 후행 조건이 느슨해지는(완화되는) 것을 말한다. (위키피디아의 단어 선택은 정말 대단하다.)

public int Method_A(int data)
{
    int result;
    
    // 실행 코드
    
    return result;
}

위 예시에서 음수 조건을 제거해서, 후행 조건을 완화시켰다.

당연하겠지만, 이 행동은 음수 조건을 리턴함으로써 Method_A를 호출하는 코드가 오작동을 일으키도록 만들 것이다.

후행 조건을 완화시키는 건 선행 조건을 추가하는 것과 같이 프로그램 코드에서 문제를 일으키게 만들어서 해서는 안 되는 행동이다.

 

하위형에서 상위형의 불변 조건은 반드시 유지되어야 한다.

불변 조건이라는 말보단 불변 데이터라는 말이 더 와 닿을 것이다.

부모 클래스에 있는 데이터에 정의한 값의 조건은 하위형에서도 계속 유지되어야 한다는 것이다.

 

public class Parent
{
    public int Data;
    {
    	get
        {
            return _data;
        }
        set
        {
            if (value < 0)
            {
                _data = 0;
                return;
            }
            
            _data = value;
        }
    }
    
    protected int _data;
}

위 코드는 _data 값이 항상 0이나 양수를 갖도록 하고 있다.

 

그런데 자식 클래스에서 다른 함수를 만들어 _data 값을 바꾼다고 해보자.

public class Child : Parent
{
    public void Method(int data)
    {
         _data = data;
    }
}

자식 클래스에서 _data를 아무런 조건 처리 없이 그대로 덮어쓰기를 하고 있다.

이렇게 되면 부모 클래스에서 외부로부터 유지하던 불변성이 깨져버려 프로그램에 문제를 일으키게 된다.

그래서 부모 클래스를 구현할 시에 개방 폐쇄 원칙을 잘 지키거나, 자식 클래스를 구현하는 사람이 불변성을 깨뜨리지 않도록 해야 한다.

 

하위형에서 메서드는 상위형 메서드에서 던져진 예외의 하위형을 제외하고 새로운 예외를 던지면 안 된다.

공변성과 반공변성을 들어가기에 앞서, 그나마 무난한 부분부터 살펴보자.

이 조건은 꽤나 직관적이라 이해하기 쉽다.

 

자식 클래스의 함수에서 부모 클래스의 함수에서 던지는 예외를 제외하고 다른 예외를 던지지 말라는 얘기인데,

다시 말해, 사전에 약속하지 않은 예외를 던지면, 기존 프로그램 코드는 해당 예외를 캐치할 수 없어서 (혹은 적절한 처리를 할 수가 없어서) 문제를 일으킬 수 있다는 것이다.

 

공변성과 반공변성

드디어 죽음의 데스가 찾아왔다.

꽤나 생소한 단어인데, 뜻도 생소하다.

 

공변성: B가 A를 상속하는 상속관계 B -> A가 존재할 때, C<T>가 C<B> -> C<A>를 만족하면 이를 공변성을 띈다고 말한다.

반공변성: B가 A를 상속하는 상속관계 B -> A가 존재할 때, C<T>가 C<A> -> C<B>를 만족하면 이를 반공변성을 띈다고 말한다.

 

어렵다.

일단, 공변성과 반공변성은 제네릭과 관련되어 있는 정의이다.

무난한 공변성부터 설명하자면, 우리가 평소에 쓰던 Array가 바로 공변성의 한 예이다.

public static void Main()
{
    Parent[] array = new Child[];
}

public class Parent { }

public class Child : Parent { }

우리는 다형성에 의해서, Parent를 상속하는 Child의 Array는 Parent[]로 치환해서 사용할 수 있다는 것을 너무나도 잘 알고 있다.

Child[]를 Parent[]로 치환할 수 있는 것, 이게 공변성이다.

 

이외에도 IEnumerable가 있다.

IEnumerable<Child>가 IEnumerable<Parent>로 캐스팅될 수 있는 이유는 IEnumerable<T>가 공변성을 띄고 있기 때문이다.

 

반공변성은 이와 반대되는 개념이다. 대표적인 예로 delegate<T>(Action<T>)가 있다.

public static void Main()
{
    Action<Parent> parentAction = (parent) => { };
    Action<Child> childAction = parentAction;
    
    childAction(new Child());
}

public class Parent { }

public class Child : Parent { }

이 코드는 전혀 문제가 없는 코드이다. Child는 Parent를 상속하기 때문에, Action<Parent>를 Action<Child>로 치환해서 호출해도,

기존 Action<Parent>에 있는 람다식 함수는 작동에 전혀 문제가 없다.

Action<Parent>Action<Child>로 치환할 수 있는 것, 이게 반공변성이다.

 

다른 반공변성의 예로는 IComparable<T>이 있다.

IComparable<Parent>를 IComparable<Child>로 치환해도 문제없이 작동한다.

 

in / out 키워드

C#에선 제네릭 타입의 interface와 delegate에 대해, 형 변환을 지원할 수 있게, 공변성과 반공변성을 위한 키워드를 제공하고 있는데,

바로 in (반공변성)과 out (공변성)이다.

 

앞서 예시로 언급한 IEnumerable<T>과 IComparable<T>의 정의를 MSDN에서 보면, 이 키워드들이 적용되어있는 것을 알 수 있다.

public interface IEnumerable<out T> : System.Collections.IEnumerable
public interface IComparable<in T>

 

하위형에서 메서드 인수의 반공변성 / 반환형의 공변성

공변성과 반공변성은 각각 리턴 값과 파라미터와 관련이 깊다.

리턴값과 파라미터가 공변성과 반공변성을 반드시 지켜줘야 작동에 문제가 없기 때문이다.

 

공변성과 리턴 값에 대한 예시부터 살펴보자.

public static void Main()
{
    Func<Parent> method1 = () => new Child();
    
    Parent parent = method1();
}

public class Parent { }

public class Child : Parent { }

이 예시는 리턴 값의 공변성을 지원하는 Func<T>에 대한 예시이다.

Child에서 Parent로 형 변환이 가능해서, Func가 호출되어도, 문제가 없다.

하지만 아래와 같이 리턴 값에 반공변성을 지원해주면 코드는 문제가 된다,

public static void Main()
{
    Func<Parent> method1 = () => new Child_B();
    Func<Child_A> method2 = method1;
    
    Child_A child = method2();
}

public class Parent { }

public class Child_A : Parent { }

public class Child_B : Parent { }

기본적으로 C#에서 Func<T>에 공변성(out 키워드)을 적용시켰기 때문에 컴파일이 되지 않는 코드지만,

만약 Func<T>가 Func<Parent>에서 Func<Child_A>로의 반공변성까지 지원한다면, 

함수 호출 시, 리턴 값에 문제가 발생할 수 있다는 것을 알 수 있다.

 

이번엔 반공변성과 파리미터에 대하여 알아보자.
앞서 보여준 Action<T> 예시를 다시 살펴보자.

public static void Main()
{
    Action<Parent> parentAction = (parent) => { };
    Action<Child> childAction = parentAction;
    
    childAction(new Child());
}

public class Parent { }

public class Child : Parent { }

이 예시는 파라미터의 반공변성을 지원하는 Func<T>에 대한 예시이다.

파라미터 타입이 Parent에서 Child으로 바뀌어도 상관없다.

어차피 실제로 호출되면 Parent로 형 변환되어 파라미터 값이 들어올 것이기 때문이다.

하지만 아래와 같이 파라미터에 공변성을 지원해주면 코드는 문제가 된다,

 

public static void Main()
{
    Action<Child_A> childAction = (child) => { };
    Action<Parent> parentAction = childAction;
    
    parentAction(new Child_B());
}

public class Parent { }

public class Child_A : Parent { }

public class Child_B : Parent { }

이것도 마찬가지로 C#에서 Action<T>에 공변성(in 키워드)을 적용시켰기 때문에 컴파일이 되지 않는 코드지만,

만약 Action<T>가 Action<Child_A>에서 Func<Parent>로의 반공변성까지 지원한다면, 

함수 호출 시, 파라미터를 전달할 때 문제가 발생할 수 있다는 것을 알 수 있다.

 

자식 클래스를 구현하는데, 함수의 공변성과 반공변성을 왜 따지지?

왜 치환 원칙에서 어려운 공변성과 반공변성에 대한 조건을 요구하는 걸까?

이유는 부모 클래스에서 자식 클래스로 치환했을 때, 작동에 문제가 없으려면,

프로그램 코드에 노출되어 호출되는 함수 또한 공변성과 반공 변성 또한 보장되어야 하기 때문이다.

특히, C#에서는 제네릭 타입을 지원하기 때문에, 이에 대한 개념이 필요한 것이다.

다행히 C#에는 in / out 키워드가 있기 때문에, 공변성과 반공변성 적용에 머리를 끙끙거리며 고민할 필요는 없다.

 

 

마치며

이번 원칙은 참 어려운 원칙이다. 정의 자체가 확 와 닿지도 않고, 이해하기 어려운 공변성, 반공변성 정의까지 들어가 있는 데다, 

그렇다고 이 원칙이 알게 된다고 앞으로의 내 개발 환경이 막 드라마틱하게 좋아지거나 변하지 않기 때문이다.

 

그리고 요번에 포스팅을 위해 제대로 설명하려고, 구글에서 엄청 찾아봤는데,

다들 대충 대충 겉핥기식으로 설명하고 넘어가서 제대로 설명하는 곳은 거의 없었다.

특히, 공변성/반공변성 언급하는 곳은 거의 손에 꼽을 정도이고,

설사 설명해도 그냥 사전에 나와있는 정도로만 설명할 뿐, 제대로 이해할 수 있도록 설명하는 블로그는 거의 없었다.

심지어 잘못 이해해서 잘못 설명하는 블로그도 보였다.

 

대부분 '상속 관계를 제대로 만들어야한다는게 이 원칙의 정의이다' 라고 소개하는데,

이 글에 언급한 위키피디아의 치환 관계가 제대로 성립되기 위한 조건들을 살펴보면

단순히 상속관계만 신경쓴다고 해서 지켜지는 원칙이 아닌 것을 알 수 있다.

 

정말로... 이 원칙에 대해 알아보느라 몇 시간 동안 진땀을 뺐다.

(이 포스팅 작성하는데, 6시간 가까이 걸렸다.)

 

요점만 놓고 보면 그리 대단한 원칙도 아닌데 배우기만 힘든 원칙이다.

 

 

'Refactoring & Clean Code' 카테고리의 다른 글

SOLID : 의존 역전 원칙  (0) 2020.04.14
SOLID : 인터페이스 분리 원칙  (0) 2020.04.12
SOLID : 개방 폐쇄 원칙  (0) 2020.04.10
SOLID : 단일 책임 원칙  (0) 2020.04.10
리팩토링에 대한 개인적인 생각  (0) 2020.04.10

+ Recent posts