2019.3 버전에 들어서면서 Addressable Asset System이 정식으로 릴리즈 되었다.

Addressable Asset System은 유니티의 새로운 에셋 관리 시스템인데,

기존의 Resources, Remote AssetBundle의 통합하는 새로운 시스템이다.

 

잘 사용만 한다면, 기존에 에셋 로딩 방식이 가지던 단점들을 보완하는 꽤 좋은 시스템이다.

그래서 앞으로 게임 개발 시엔 이 Addressable Asset System이 기본이 될 것이며 유니티도 이 시스템을 밀어줄 것이라 생각한다.

(따지고 보면, 기존의 에셋 번들 시스템을 개선한 버전이기는 하다.)

 

시대에 뒤처지지 않기 위해, 나도 이것저것 테스트해보는 중이다.

테스트해보면서 알게 된 몇 가지 특징들이 있는데,

잊지 않기 위해서 여기에 적어두려고 한다.

 

(이 부분은 어느 정도 Addressable Asset System에 대해 이해가 있어야지 이해가 쉬울 것이다.)

  • Addressable에서 에셋을 분류하는 단위인 Group은 기존의 번들 개념과 유사하다.
    • 기존 에셋에 번들 이름을 부여해서, 나중에 번들 이름 단위로 데이터를 생성하듯이, 기본적으로 어드레서블은 그룹 단위로 에셋 번들을 만든다.
    • 그래서 기존의 에셋번들 시스템에서 어드레서블로 컨버팅을 하면 에셋 번들 이름이 그룹으로 처리되면서, 그룹 별로 에셋들이 분류된다.
    • 옵션 변경해서 그룹 단위가 아닌 개별 에셋, 혹은 레이블 단위로 에셋 번들을 만들 수 있다.
  • Addressable에서 LoadAsset과 DownloadDependencies는 Key 또는 Label 값을 파라미터로 받는다.
    • 이 부분이 아직 나에게 낯선 개념인데, 에셋을 그룹 단위로 로드할 수 없다. 에셋의 Key값이나, Label을 통해서만 로드할 수 있다. 여기서 레이블이란, 일종의 Tag개념으로 에셋마다 여러 개 레이블을 가질 수 있다. 그래서 레이블로 로딩 시, 해당 레이블을 가진 모든 데이터를 로드한다.
    • 이유는 위에서 설명한 옵션 때문인데, 에셋 번들이 무조건 그룹 단위로 만들어지는 것이 아니기 때문에, 그룹 단위로 처리하는 함수는 없다.
  • LoadAsset, Instantiate은 에셋이 리모트 에셋일 경우, 다운로드를 한다
    • 굳이 다운로드 함수를 호출하지 않아도, 로컬에 캐싱된 에셋 번들이 없다면 자동으로 다운로드를 해서 캐싱을 한다.
    • 로컬 캐싱관련 옵션을 끄면 매번 로드할 때마다 다운로드를 한다.
  • LoadAsset, Instantiate, DownloadDependencies를 이용해 에셋을 로드하면, 관련 의존된 에셋 또한 알아서 다운로드하고 로드해준다.
    • 에셋 하나를 로드하는데 의존 에셋이 있다면 같이 다운로드한다. 그게 설령 의존에 의존에 의존 에셋이라도.
    • 예를 들어, 리모트 번들 A에 메쉬 Prefab이 있고, 리모트 번들 B에 메쉬의 Material이 있고, 리모트 번들 C에 머테리얼의 Texture가 있으면, Prefab 에셋을 로드할 때, 자동으로 번들 B와 C까지 알아서 같이 로드한다.
  • 그룹 단위의 처리 방식이 아니기 때문에 새로운 방식의 에셋 데이터 관리 방향을 제시할 수 있다.
    • 기존 게임들은 게임 시작 시에 필요한 데이터를 사전에 전부 다운로드하도록 하지만, 위에 설명한 어드레서블의 특징 덕분에 그룹 단위로 에셋 데이터를 관리하지 않아도 되기 때문에, 유저가 게임을 플레이를 하면서 필요한 데이터만 다운로드하게 할 수 있다.
  • LoadAsset로 불러온 에셋은 사용 후 Release를 호출해줘야한다.
    • 기존 변수를 Null로 처리하는 것 또한 잊으면 안 된다.
  • Instantiate으로 생성한 오브젝트는 ReleaseInstance를 호출해줘야 한다.
    • 다만, 호출하면, 오브젝트가 Destroy 되므로 풀링을 했다면, 더 이상 필요 없어진 시점에 해야 할 것이다.
    • Release를 호출해도 똑같이 작동한다.
    • 반대로 LoadAsset으로 불러 온 Prefab 또한 ReleaseInstance로 처리해도 동일하게 처리된다.
  • LoadAsset으로 불러온 Prefab으로 생성한 인스턴스는 Release 대상이 아니다.
  • AsyncOperationHandle로 Release를 하면 로드/생성된 에셋 또한 릴리즈 된다.
    • 때문에 Instantiate의 AsyncOperationHandle을 이용해 해제하면, 인스턴스를 Release 한 것처럼 오브젝트가 Destroy 된다.
  • 에셋끼리 동일한 키값을 가질 수 있다. 해당 키로 에셋을 로드할 때, LoadAssets로 에셋을 불러오면, 배열로 로드할 수 있다.
  • LoadAssets으로 한 번에 여러 에셋을 불러올 때 특정 데이터 타입으로 하면, 같은 값(키 또는 레이블)을 가진 에셋 중에 해당 타입을 가진 에셋만 불러온다.
  • LoadAssets으로 한 번에 여러 에셋을 불러올 때 object로 타입을 정하면, 같은 값(키 또는 레이블)을 가진 모든 에셋을 불러온다.

 

기능을 더 확장하고 활용하려면, Provider 같은 인터페이스들을 상속하고 커스터마이즈 하는 법까지 알아야 할 텐데.

그 부분은 나에게 너무 어렵다...

 

이 시스템을 이용해서 어떻게 번들 데이터들의 버전 업데이트 시스템을 만들 수 있을지는 아직 고민해보지 않아서,

이건 차차 업데이트할 예정이다.

 

 

'Unity' 카테고리의 다른 글

왜 Unity의 GC(Garbage Collector)는 무식할까  (0) 2020.04.24
Script Execution Order  (0) 2020.04.05

성능에 조금이라도 관심 있는 사람은 GC에 대하여 들어본적이 있을 것이다.

그래서 유니티의 GC에 대해 공부하다보면, 발견할 수 있는 점이 .NET의 GC와 Unity의 GC가 다르다는 점이다.

 

필자는 유니티의 GC를 언뜻 듣고, .NET의 GC를 공부하고 나서,

당연히 유니티도 C#을 쓰니 같은 GC겠거니 했는데, 그게 아니었다.

 

이번 글에선 왜 그런지를 알아보려고 한다.

 

.NET Framework Garbage Collector

일단, 각 GC가 어떻게 동작하는지 심플하게 설명해보자.

.NET Framework의 GC는 세월이 흐르면서 여러 방법으로 업그레이드 되어 왔다.

 

기본적으로 .NET의 GC는 세대(Generation) 방식을 사용한다.

할당된 메모리별로 0~2세대까지 세대를 부여하여,

통계적으로 (자기네들이 생각하기에) 최적의 타이밍에 필요한 메모리를 해제할 수 있도록 관리한다.

 

그리고 힙영역은 SOH(Small Object Heap)과 LOH(Large Object Heap)으로 구분하여,

큰 메모리를 할당하고 해제할 때의 부담을 줄도록 만들었다.

 

SOH에는 작은 메모리들이 할당되고, GC가 일어날 때, 정리된 세대의 메모리의 재정렬이 일어난다.

LOH에는 큰 메모리들이 할당되고(기준은 85,000바이트라고 한다), GC가 일어날 때,

성능 저하를 피하기 위해서 메모리 재정렬은 하지 않는다.

 

그리고 GC시에는 GC 작업으로 인한 부하를 더 줄이려고, 전용 쓰레드를 쓰고,

또 거기에 로직을 넣고 적절한 시기에 쓰레드를 돌리는 등 여러가지가 있다.

 

Unity Garbage Collector

유니티의 GC는 꽤나 단순무식하다.

그말인즉, 성능은 상대적으로 좋지 않다는 뜻이다.

 

세대 구분? 없다.

SOH와 LOH? 없다.

메모리 재정렬? 없다.

 

그냥 하나의 힙 메모리 영역만 있으며 메모리가 할당되면 죄다 여기에 할당한다.

해제 시 재정렬을 하지 않아, 할당과 해제를 자주하면 메모리에 구멍이 송송 나있어서,

공간은 많은데 메모리 할당을 못하는 경우도 있다.

물론, 내부적으로 메모리가 부족하면 힙 영역을 늘리지만, 결국 근본적인 문제는 해결되지 않고

게임은 메모리 먹는 하마가 된다.

 

게다가 GC시엔 그냥 앱의 다른 동작을 정지시키기 때문에 렉도 심하다.

 

이정도면 너무하지 않나 싶을 정도이다.

 

왜?

왜 유니티는 이런 구린 GC를 쓰는걸까?

궁금해서 구글링을 해봤다.

 

그리고 StackOverflow에서 흥미로운 코멘트를 읽었다.

(검증은 안했으므로 카더라 일수도 있는 점 양해바랍니다.)

 


It was in early 2008 that Unity and Mono announced their collaboration, and at that time Unity licensed the Mono runtime (GPL covered for open source usage) so as to embed it. And the Boehm GC was the primary GC in Mono then.
Time passed and Mono 4.x/5.x by default uses SGen GC with generational/compacting features. However, Unity did not want to pay the licensing again. Thus, you see the documentation remains it was.

Microsoft acquired Xamarin in 2016, and hence gained control of Mono core assets. It republished the code base under MIT, so solving the licensing issue for ever. Unity joined .NET Foundation and started to work with Microsoft/Xamarin to incorporate the latest Mono runtime into the game engine.

That effort is still undergoing and should soon reach maturity (currently an experimental feature).

BTW, Unity cannot use the standard .NET GC yet. Microsoft does not open source its GC in .NET Framework, but a version in .NET Core. That GC is different from Mono's, and would require more efforts to be embedded into Unity. I guess that's why Mono 5 was chosen to be integrated right now. Maybe in the future Unity would migrate to the .NET Core GC.

https://stackoverflow.com/questions/46574407/unitys-garbage-collector-why-non-generational-and-non-compacting

 

Unity's garbage collector - Why non-generational and non-compacting?

I've just read in Unity's docs that Unity’s garbage collection – which uses the Boehm GC algorithm – is non-generational and non-compacting. “Non-generational” means that the GC must sweep throu...

stackoverflow.com


대충 요약하자면

'과거에 유니티는 가난해서 최신 Mono의 GC를 적용할 돈이 없었어서 초장기 계약한 구식 GC를 계속 사용 중이었다. (구식 C# 버전과 함께)

이제는 Xamarin(Mono개발사)이 마이크로소프트에 인수되어, Mono가 MIT 라이센스로 풀렸기 때문에, 유니티는 최신 버전을 적용할 수 있게 되었고, 열심히 작업 중이다'

 

결국, 유니티는 가난했고, 마소의 자마린 인수 후 오픈소스 정책으로 이제야 적용하는 중이라는 것이다.

 

그래서 찾아보면 현재 유니티가 점진적 가비지 콜렉션을 조금씩 시범 적용 중인 것을 알 수 있는데,

그 기능이 적용된 이유가 아마 이것과 관련이 있지 싶다.

 

결론?

마소를 찬양하라...

아마 마소가 자마린 인수 안했으면, MIT로 전환하지 않았으면,

우리는 아직도 구닥다리 GC와 C#을 사용하고 있었을지도 모른다.

 

 

'Unity' 카테고리의 다른 글

Addressable Asset System에 대한 자잘한 특징??  (0) 2020.05.19
Script Execution Order  (0) 2020.04.05

의존 역전 원칙 (DIP : Dependency Inversion Principle)

SOLID 원칙의 마지막 원칙이다.

이 원칙을 지키기 위해 해야할 행동은 꽤나 심플하기 때문에 그리 어렵지 않은 원칙이다.

 

우선, 사전적으로 이 원칙은 다음과 같은 내용을 가지고 있다.

1. 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.

2. 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.

 

나는 사전적 의미가 싫다. 쉽게 직설적인 의미로 설명하지 않기 때문이다.

상위 모듈과 하위 모듈이 뭘까?

 

이 부분을 정확히 정의하기 위해 구글링을 엄청나게 했는데,

감이 잘 오지 않는다.

 

어디는 상위 모듈이 유저 인터페이스와 가까운 레이어에 있는 모듈이라고 하고,

다른데는 하위 모듈이 유저 인터페이스와 가까운 레이어라고 하고,

또 다른 곳은 의미 있는 단일 기능을 구현한게 상위 모듈이라 하고,

자바와 관련해서 설명하는 곳은 변화가 쉬운 것에 의존하지 않는게 DIP라고 하고,

사람 더 헷갈리게 만드는 설명들이 따로 없다.

누가 추상화를 장려하는 원칙 아니랄까봐 그런지 설명들도 참 추상적이다.

 

어떤 곳은 상위 모듈이 추상화, 인터페이스를 뜻하고, 하위 모듈은 구현 클래스를 뜻한다고 하는데,

이건 너무 결과만 놓고 본 설명이다,.

 

영문 사이트도 뒤져보고 이것저것 정보를 추합해서 이해한 결과.

정말 직설적으로 게임 개발쪽과 연관지어서 설명하자면,

 

상위 모듈: UI 같이 게임을 개발할 때 유저에게 노출되는, 최종 구현 클래스가 되는 부분

하위 모듈: Service, Manager 클래스 혹은 라이브러리 같이 게임 뒷단에 있는 코어가 되는 부분

 

이렇게 설명할 수 있다.

(왜 이렇게 확 와닿게 설명하는 곳이 없는건지 모르겠다.)

 

이 정의를 토대로 의존 역전 원칙의 성립 조건을 다시 설명하자면,

'최종 구현 클래스에서 매니저 클래스 자체를 참조하지 말고, 인터페이스를 통해 추상화한 타입으로 참조하라'라고 할 수 있다.

 

이 정의를 한번 더 가공하면 이렇게 설명할 수 있다.

'인터페이스 써라'

 

의존 역전을 위한 의존성 주입 (Dependency Injection)

의존 역전을 실천하기 위해서 의존성 주입이라는 패턴에 대해 알아야할 필요가 있다.

이름은 무시무시한데, 정말 이론 자체는 정말 간단하다.

 

'내부 구현 클래스에서 참조(또는 생성)하던 외부 클래스를 인터페이스를 통해 외부로 부터 주입되게 하는 것'이다.

 

예를 들어, 게임 내에 광고 기능을 추가한다고 생각해보자.

 

회의를 통해 우리는 A 회사에서 제공하는 광고 플러그인을 사용하기로 하고,

A사의 플러그인 API에 맞춰서 매니저 클래스와, 광고 시청 버튼을 만들었다.

만약, 유저가 광고 버튼을 누르면 광고 매니저 클래스를 통해, 광고를 재생시킬 것이다.

public class AdvertisementButton
{
    public void OnClick()
    {
    	AdvertisementManager.Instance.PlayAd();
    }
}

public class AdvertisementManager
{
    public static AdvertisementManager Instance;

    public void PlayAd()
    {
    	// A사의 광고 플러그인 API 호출
    }
}

AdvertisementManager는 많은 개발자가 사랑하는 싱글턴 클래스로 되어 있다. (디테일한 구현은 넘어가자)

그리고 AdvertisementButton클래스는 그 매니저 클래스를 직접 접근해서 호출하고 있다.

 

이런 경우가 바로 의존 역전 관계를 지키지 않는 경우이다.

만약, 추가한 광고 플러그인이 맘에 안들거나 문제가 있어서, 다른 회사의 광고 플러그인을 넣는다면?

그리고 그 회사의 플러그인의 API가 기존에 구현한 인터페이스와 다르다면?

(그리고 또 결정이 바뀌어서 다시 기존의 광고 플러그인을 다시 사용하기로 한다면???)

 

우린 AdvertisementManager를 사용하는 모든 클래스를 수정해야할 것이다.

이 문제를 해결하기 위해서 사용되는 것이 바로 의존성 주입 패턴이다.

 

일단, 의존성 주입을 하기 위해선 기존 클래스들을 인터페이스로 추상화 시켜야한다.

public interface IAdvertisementManager
{
    void PlayAd();
}

public class AdvertisementManager : IAdvertisementManager
{
    public void PlayAd()
    {
    }
}

어떤 광고 플러그인이던 가장 중요한 광고를 재생하는 함수는 존재할 것이다,

그래서 광고 재생 함수를 인터페이스로 추상화 시켰다.

 

이제 이 인터페이스를 광고 버튼 클래스에 넣어주면 되는데,

그냥 사용하는게 아니고 외부로부터 인터페이스의 참조를 주입시키는 통로를 만들어줘 값을 대입시키게 만들어야한다.

public class AdvertisementButton
{
    private IAdvertisementManager _advertisementManager;
    
    public void Initialize(IAdvertisementManager advertisementManager)
    {
    	_advertisementManager = advertisementManager;
    }

    public void OnClick()
    {
    	_advertisementManager.PlayAd();
    }
}

 

 

예시 코드에선 함수를 통해 참조값을 주입시켰다.

이렇게 하면, 어떤 광고 플러그인을 쓰던 버튼 클래스는 인터페이스를 통해 광고를 재생시키기 때문에,

전혀 코드를 수정할 필요가 없다. 그저 외부에서 버튼에게 실제 참조 클래스를 주입시키는 코드만 바꿔주면된다.

 

이게 바로 의존성 주입이다. 이름 그대로 '외부에서 의존성을 주입시켜준다'해서 붙여진 이름이다.

앞서 얘기한대로, 의존성 주입은 다양한 통로로 넣어줄 수 있다. 속성이던 함수이던 생성자이던 상관없다.

구현 클래스를 내부에서 직접 생성하거나 참조만 하지 않으면 된다.

 

이렇게 의존성 주입 패턴을 사용하면, 의존 역전 원칙을 지킬 수 있다.

 

외부 어디에서 의존성을 붙여주는가?

의존성 주입 패턴을 사용하면,

분명 어딘가에는 인터페이스의 참조값을 넣어주는 클래스가 존재해야한다.

 

그럼 이걸 어디에서 호출해줘야할까?

이건 각자 정의하기 나름이다.

보편적으로는 프로그램이 정상적으로 작동하기 위해서

프로그램 시작할 때 맨 처음 호출 되는 부분에 인터페이스들의 구현 클래스들을 생성하는 코드들 만들어주곤 한다.

 

public class SomeClass
{
    public AdvertisementButton _adButton;

    public void OnStartUp()
    {
        AdvertisementManager adManager = new AdvertisementManager();
        _adButton.Initialize(adManager);
    }
}

(어딘가에는 이런 코드가 존재한다.)

 

싱글톤과 서비스 로케이션을 버려야한다.

싱글톤과 서비스 로케이션 패턴은 편리하면서도 보편적으로 쓰이는 패턴이다.

하지만 의존 역전 관계에서 이들은 안티 패턴(지양해야할 패턴)들이다.

 

public class AdvertisementButton
{
    private IAdvertisementManager _advertisementManager;
    
    public void Initialize()
    {
    	_advertisementManager = Locator.GetInstance<IAdvertisementManager>();
    }

    public void OnClick()
    {
    	_advertisementManager.PlayAd();
    }
}

서비스 로케이터의 전역 함수를 통해, 인터페이스 타입을 가져오지만,

AdvertisementButton은 다시 Locator에 의존성을 가지고 있다.

 

Locator은 모든 코드에 전반적으로 쓰이는 코드인데, 만약에 이 클래스의 함수들이 바뀐다면?

AdvertisementManager에서 발생한 문제와 똑같은 문제를 일으킬 것이다.

결국 뭔가 괜찮은 방법을 넣긴했으나, 의존성의 문제점은 여전히 가지고 있는 셈이다.

 

static 인스턴스 사용은 SOLID 원칙에서는 지양해야할 코드 습관 중 하나이다.

 

왜 '의존 역전 원칙'일까?

솔직히 쉽고 정확하게 딱 이해되진 않지만,

일반적으로는 기존(Button에서 Manager에 의존하는) 의존 관계의 개념이 뒤집어졌다는 뜻에서

역전이라는 의미를 뜻한다고 한다.

 

어떤 곳에서는

----

상위

하위

----

에서

----

인터페이스 상위

하위

----

개념으로 의존 방향이 역전 되어서 역전이라고 의존 역전이라고 설명하는 곳도 있긴하다.

정확한 개념은 나도 모르겠다.

 

어차피 이 원칙에서 제일 중요한건 클래스들 간에 의존성을 가지지 않도록 해야한다는거다.

 

의존성 주입하는 방법은 다양하게 있다

여기서 말하는 방법은 메소드, 생성자 주입 같은 것을 말하는게 아니라

이전 OnStartUp() 예시처럼 인스턴스를 초기화 하고 의존성을 주입 것에 대한 방법을 말한다.

 

예시처럼 시작 단계에서 초기화해서 수동적으로 주입하는 방법도 있고,

IoC 프레임워크에서 자동으로 주입하는 방법도 있다.

 

제어의 역전(역행)(IoC : Inversion of Control)과 DI Framework

사전적인 의미부터 설명하자면,

 

일반적인 프로그래밍처럼, 프로그래머가 외부 라이브러리를 호출하는게 아닌,

외부 라이브러리가 프로그래머가 짠 코드를 호출하여, 제어의 흐름이 역전되었다는 뜻을 가진 개념(혹은 패턴)이다.

 

프레임워크가 바로 이런 개념을 가진 대표적인 예이다.

(프레임워크: 특정한 구조와 규약을 가져서, 개발자가 그 위에서 작업을 하면, 자동으로 코드를 관리하고 제어하도록 만들어진 구조.)

 

의존성 주입 개념에 프레임워크 개념을 이용하여, 프로그래머가 수동으로 의존성을 주입하는게 아닌,

인스턴스를 생성하고 관리하는 컨테이너라고 불리는 제어자를 통하여 자동으로 의존성이 주입되도록 하는 DI Framework가 존재한다.

 

이런 개념들에 대해 찾다보면, IoC, IoC Container, DI, DI Framework 용어들에 대한 시간적인 순서가 조금 헷갈린데,

찾아본바에 의하면, IoC -> DIP -> IoC Container -> DI, DI Framework 인거 같다.

 

추측하기로는

DIP 개념으로부터 인스턴스들의 의존성을 자동으로 관리해주는 프레임워크 개념인 IoC Container가 나왔고,

IoC Container의 IoC 단어는 우리가 아는 의존성 주입의 개념으로써 사용되고 있었다.

그런데 이 IoC의 개념이 일반적인 IoC의 개념과 충돌하여 혼동의 여지가 있기 때문에

IoC를 DI라고 명명하여 사용하기 시작했고 DI Container(Framework)란 단어가 생긴 것 같다.

 

의존성 주입을 위한 프레임워크 쓰자!

의존성 주입을 간편하게 사용하기 위한 다양한 프레임워크 존재한다.

비주얼 스튜디오에서도 존재하고 자바에선 스프링이란 프레임 워크도 존재하고 유니티에서도 존재한다.

 

필자는 유니티에서 Zenject라는 플러그인을 사용한적이 있다.

매우 강력한 프레임워크 플러그인으로 이걸 사용 하면,

각종 시스템 클래스들을 직관적이게 초기화 할 수 있고,

구현 클래스에서 싱글톤이나 로케이터 패턴보다 훨씬 편하고 깔끔하게 접근할 수 있다.

 

https://github.com/modesttree/Zenject

(에셋 스토어에도 있다.)

 

인터페이스 분리 원칙 (ISP : Interface Segregation Principle)

드디어 어려운 원칙이 지나갔다.

이제는 행복해질 시간이다. 이 원칙은 상대적으로 쉽고, 단일 책임 원칙(SRP)을 알고 있다면, 더 이해하기 쉬운 원칙이다.

 

이 원칙의 사전적 정의는 '클라이언트는 자신이 사용하지 않는 인터페이스 멤버에 대하여 의존적이면 안된다'이다.

 

클라이언트라고 하니 딱딱하다. 흔히 이 정의는 이렇게로도 많이 알려져 있다.

'하나 인터페이스가 하나의 동작만을 하도록 인터페이스가 분리되어야 한다.'

 

이유

우리가 좋아하는 예를 들어보자.

이 예시에서 사용하지 않는 인터페이스 멤버에 대해 의존성을 가진다는 게 어떤 건지, 이로 인한 문제가 뭔지 살펴볼 것이다.

public interface IDataManager
{
    void Load();
    
    void Prepare();
    
    void Save();
}

public class DataLoader : IDataManager
{
    public void Load()
    {
    }

    public void Prepare()
    {
    }
    
    public void Save()
    {
    }
}

DataLoader클래스는 인터페이스로 IDataManager를 상속하고 있다.

그런데 DataLoader는 Load 함수만 필요한 클래스인데, 원치않게 Prepare와 Save까지 구현을 해야 되는 것을 볼 수 있다.

 

만약 설상가상으로, Save의 시그니처(파라미터, 리턴, 함수명에 대한 정의)가 변경되어, 리턴 값으로 bool을 갖게 된다면?

DataLoader도 똑같이 수정을 해야 할 것이다.

 

이게 바로 불필요한 인터페이스 멤버에 대한 의존성이다.

이런 문제를 해결하기 위해서 우리는 인터페이스를 잘게 쪼갤 필요가 있다.

 

그리고 이 원칙을 따라야 하는 또 다른 이유가 하나가 더 있는데,

인터페이스를 잘게 쪼게면. 인터페이스를 사용하는 개발자가 잘못된 방법으로 인터페이스를 사용하도록 예방하고,

필요한 동작만을 수행할 수 있게 유도할 수 있다는 것이다.

 

위의 코드를 예를 들어, IDataManager 파라미터를 이용한 함수를 구현하는데,

IDataManagerDataLoader가 전달되는 상황이라고 가정해보자.

public class AnotherClass
{
    public void LoadData(IDataManager dataLoader)
    {
        dataLoader.Prepare();
        dataLoader.Load();
    }
}

 

구현된 함수를 보면, 이 함수를 개발한 개발자가 IDataManager의 인터페이스만 보고, Prepare를 사전에 호출해야 하는 함수로 착각해,

호출한 것을 알 수 있다. 인터페이스를 잘게 쪼갠다면, 이런 문제를 사전에 방지할 수 있다.

 

인터페이스 분리하기

원칙을 적용하는 법은 간단하다. 하나의 인터페이스를 여러 개의 인터페이스로 분리하면 된다.

그런데 이 원칙은 단일 책임 원칙 마냥 적절히 쪼개는 것에 그치지 않고, 아예, 인터페이스 하나 당, 하나의 멤버만을 갖도록 정의하고 있다.

public interface IDataLoader
{
    void Load();
}

public interface IDataPreparer
{
    void Prepare();
}

public interface IDataSaver
{
    void Save();
}

public class DataLoader : IDataLoader
{
    public void Load()
    {
    }
}

public class DataPreparer : IDataPreparer
{
    public void Prepare()
    {
    }
}

public class DataSaver : IDataSaver
{
    public void Save()
    {
    }
}

그래서 이렇게, 각자 쪼갠 인터페이스 별로 구현을 하거나,

public interface IDataLoader
{
    void Load();
}

public interface IDataPreparer
{
    void Prepare();
}

public interface IDataSaver
{
    void Save();
}

public class DataManager : IDataLoader, IDataPreparer, IDataSaver
{
    public void Load()
    {
    }
    
    public void Prepare()
    {
    }
    
    public void Save()
    {
    }
}

혹은 이렇게 다중 상속을 이용해서 하나의 클래스에 구현할 수 있다.

 

public class AnotherClass
{
    public void LoadData(IDataLoader dataLoader)
    {
        dataLoader.Load();
    }
}

그래서 인터페이스를 잘 분리하면, 다른 클래스에서 인터페이스를 사용할 때, 다른 개발자가 쓸데없는 멤버를 사용하는 것을 막을 수 있다.

 

다만, 이 원칙에서 주의할 점은 분리한 인터페이스를 다시 하나의 인터페이스로 병합하는 경우인데,

public interface IDataLoader
{
    void Load();
}

public interface IDataPreparer
{
    void Prepare();
}

public interface IDataSaver
{
    void Save();
}

public interface IDataManager : IDataLoader, IDataPreparer, IDataSaver
{
}

이렇게 기껏 분리한 인터페이스를 다시 병합하지 않게 조심해야 한다.

(이런 경우를 '인터페이스 수프(Soup)'라고 부른다.)

 

단일 책임 원칙과의 관계

몇몇 구글에 퍼진 얘기로 인터페이스 분리 원칙과 단일 책임 원칙은 같은 문제에 대한 두 가지의 다른 해결책이다라는 말이 있는데,

내가 볼 때 이건 잘못된 얘기다. 누군가 잘못 이해하고 생산한 걸 퍼 나르고 퍼 나르다 보니 사실인 것처럼 퍼져 있는 거 같은데,

 

단일 책임 원칙은 클래스와 메서드가 하나의 책임만을 갖도록 하는 원칙이고,

인터페이스 분리 원칙은 인터페이스가 하나의 멤버만을 갖도록 분리하는 원칙이다.

 

걍 쉽게 예를 들어, 위에 있는 예시 코드를 보면 답이 나온다.

public interface IDataLoader
{
    void Load();
}

public interface IDataPreparer
{
    void Prepare();
}

public interface IDataSaver
{
    void Save();
}

public class DataManager : IDataLoader, IDataPreparer, IDataSaver
{
    public void Load()
    {
    }
    
    public void Prepare()
    {
    }
    
    public void Save()
    {
    }
}

이 예시는 인터페이스들이 하나의 목적만을 정의하고, DataManager는 Data 관리에 대한 단일 책임을 가지고 있으므로,

단일 책임 원칙과, 인터페이스 분리 원칙 둘 다 적용된 사례이다.

 

그리고 상식적으로 생각해봐도 '객체지향 설계의 5대 원칙'인데,

우리가 흔히 #대 원칙을 말할 때, 'A 원칙과 B 원칙은 양립할 수 없다.'라고 얘기하진 않는다.

 

생각해볼 점

이 원칙에 가장 걸리는 부분은 아무래도,

인터페이스 하나 당, 하나의 멤버라는 점일 것이다.

 

상식적으로 인터페이스에 하나의 멤버만 넣는 것은 불가능이다.

당장 자기가 만든 프로젝트만 생각해도, 그렇게 적용했다간 프로젝트 내에 인터페이스 파일이 수백 개가 될 것이고,

인터페이스 관리에 헬게이트가 열릴 것이 분명하다.

 

다른 사람들도 이 부분에 같은 생각인지, 잘게 쪼개는 건 동의하지만, 적정선까지만 쪼개는 게 더 나을 것이라고 얘기하고 있다.

 

이 글을 읽는 사람도 이 부분에 대해선 어떻게 실전에 적용하는 게 좋을지 생각해볼 필요가 있다.

'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

리스코프 치환 원칙 (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