의존 역전 원칙 (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

개방 폐쇄 원칙 (OCP: Open-Closed Principle)

단일 책임 원칙보다는 조금 애매하고 추상적인 원칙이다.

단일 책임 원칙은 '클래스가 한 동작만 하게 쪼개라!'라는 명확한 방향성이 있지만, 개방 폐쇄 원칙은 조금 다르기 때문이다.

 

일단, 어떤 원칙이냐에 대해 설명하자면, 이론적으로는 '확장에 대해서는 개방적이되, 수정에는 폐쇄적이여야 한다'이다.

이 원칙을 돌직구로 해석하면, 이렇다. '확장(상속)할 수 있는 구조를 잡아서, 기존 코드를 수정할 일이 없도록 만들어라.'이다.

그냥 알아서 나중까지 생각해서 구조와 코딩을 잘 해라이다.

 

참 꼰대 같은 원칙이 아닐 수 없다. 마치, 실력 없는 개발자가 아는 게 별로 없을 때, 애매하게 하는 말 같다.

정확한 행동 지침도 없다. 어떻게, 어느 정도로, 잘하냐는 온전히 개발자의 몫이기 때문이다.

 

C#을 기준으로 이 원칙에 대해 좀 더 포인트를 넣어 하나씩 설명하자면 이렇다.

  • 기존 코드는 되도록 수정이 되어선 안된다.
  • 코드를 수정하면 안 되므로 항상 코드는 확장을 있는 여지를 남길 수 있게 구현해야 한다.
  • 확장성을 위해, 다형성과 추상화를 이용한다. 이를 위해 클래스 상속과 인터페이스 상속 사용한다.
  • 클래스의 상속은 자식이 상위 부모들에게 의존적이게 만드는 단점이 있다. 부모가 수정되면, 자식도 수정될 가능성이 크다.
    때문에 되도록 상속 단계는 낮게 (1, 2 단계 정도만) 해야 한다.
  • 인터페이스 상속이 클래스 상속보다, 더 구조의 유연함을 가져다준다. 하지만, 인터페이스 정의 시, 모든 상속 클래스가 수정을 해야 하므로 수정이 되지 않도록 잘 선언해야 한다.
  • 어떻게 상속 구조를 짤지, 어떤 걸 추상 멤버(메소드, 속성)로 할지는 개발자의 몫이다.
    미래의 변화에 대해 예측을 잘해서 추상화를 해야 하며.
    너무 과하게 예상해서 추상시켜도 안되고, 아무것도 확장할 수 없게 다 폐쇄적으로 숨기면 안 된다. 적당히 알아서 잘해야 한다.
  • 확장할 수 있는 것과 없는 것을 명확하기 위해, 상속해서는 안 되는 클래스는 sealed 키워드를 사용하라.

볼드체 한 부분이 문제다. abtract class, interface, virtual, sealed 키워드 같은 것을 이용해 확장의 여지를 고려해야 하는데,

어떻게, 어느 정도로 해야 하는지는 기준이 없기 때문이다.

이 부분은 순전히 경험으로 극복하는 수밖에 없다. 많은 경험을 해서, 개방해야 될 것과 감춰야 할 것을 구별할 수 있어야 한다.

 

리팩토링에 있어서, 조심해야 할 적은 바로 과한 수정과 예측이 아닐까 싶다.

어떤 코드를 수정하기 전에, 해당 코드가 정말 리팩토링이 필요한지 고려를 잘해야 한다. 시간과 돈, 인력 자원은 한정적이다.

 

한정된 자원으로 적절하게 시간을 투자해야 하는데, 구조적으로 투박하고 문제가 많아 보이는 코드라도 작동에 문제가 없고,

미래에 수정의 여지가 없는 코드이면, 무리하게 시간을 써가면서까지 수정을 할 필요가 없다.

리팩토링이라는게 의존성을 없애기 위해 하는 작업이기에,

클래스와 코드가 많아지고 파일이 분산돼서 구조가 이전보다 복잡해질 수 있기 때문이다.

 

반대로, 너무 심하게 미래에 미래에 미래까지 예측해서, '이건 한 5년 뒤면 필요할 거야' 하면서 구현하거나

'이 구조는 이런 게 문제고 저 구조는 이런게 문제고...' 하면서 추상화 디자인에 너무 시간을 많이 소비하는 것 경우도 있는데.

이 또한 조심해야 한다. 코드 구조가 알아볼 수 없게 이전보다 복잡해지거나, 기획만 하다가 시간이 다 지날 수 있기 때문이다.

 

항상 개발할 때, 주어진 시간을 적절히 고려해서 어느 게 최선인지를 적절히 생각하며 적절히 작업하는 게 중요하다.

(근데, 솔직히 이런 거 잘할 줄 알면, 다 프로그래밍 초고수였을 거다.)

리팩토링과 SOLID 원칙은 서로 간에 연관이 아주 깊다.

둘 다 모두 유지보수가 쉽고, 이해하기 쉬운 코드를 짜기 만들기 위함이라는 목적이 있기 때문이다.

 

단일 책임 원칙 (SRP: Single Responsibility Principle)

보통 '클래스, 메서드는 하나의 책임만을 가져야 한다'라고 알려진 원칙이다.

책임이라고 하니 뜻이 되게 애매한데, 그냥 '각각의 클래스, 메서드는 한 가지 짓만 해야 한다'라고 하면 이해하기 쉬울 것이다.

 

예를 들어, 유저의 계정을 관리하는 AccountManager라는 클래스가 있는데,

계정 관련 기능을 다 AccountManager에 구현했다고 가정해보자.

public class AccountManager : IAccountManager
{
    public void LogIn()
    {
    	...
    }
	
    public void LogOut()
    {
    	...
    }
    
    public AchievementData GetAchievementData()
    {
    	...
    }
    
    public void UpdateAchievementData()
    {
    	...
    }
    
    public StatisticsData GetStatisticsData()
    {
    	...
    }
    
    public void UpdateStatisticsData()
    {
    	...
    }
    
    ...
}

 

예시 코드를 보면 단순해 보이지만, 만약 내부 함수와 실제 구현 코드를 포함하면 더 많은 코드를 포함하고 있을 것이다.

만약 이 클래스가 다른 사람이 구현한 코드이고, 중간에 자신이 참여해서 업적 관련 기능을 수정한다고 해보자.

분명 다른 구현된 기능들이 뒤섞여 있어서, 업적 관련 코드를 파악하기도 어렵고, 제대로 디버깅하기도 힘들 것이다.

 

이 문제를 해결 해기 위해서 코딩을 할 때, 또는 위와 같은 코드를 리팩토링 할 때는, 단일 책임 원칙을 따를 필요가 있다.

 

원칙을 지키는 법은 간단하다, 앞서 말한대로 클래스는 한 가지 짓만 해야 하므로 기능 별로 분리하면 된다.

여기서는 로그인(LogInService), 업적(AchievementService), 기록(StatisticsService) 클래스로 분리할 수 있을 것이다.

public class LogInService : ILogInService
{
    private IAccountDataService accountDataService;

    public LogInService(IAccountDataService accountDataService)
    {
    	
    }

    public void LogIn()
    {
    	...
    }
    
    public void LogOut()
    {
    	...
    }
}

public class AchievementService : IAchievementService
{
    public AchievementData GetData()
    {
    	...
    }
    
    public void UpdateAchievementData()
    {
    	...
    }
}

public class StatisticsService : IStatisticsService
{
    public StatisticsData GetStatisticsData()
    {
    	...
    }
    
    public void UpdateStatisticsData()
    {
    	...
    }
}

이렇게 하면 나중에 기능 추가, 수정도 쉽고, 관련 작업자들도 기존 기능을 파악하기 쉬울 것이다.

+ Recent posts