Post

SOLID 디자인 원칙5 : DIP

전체 SOLID 디자인 원칙의 링크들

  1. SRP : 단일 책임 원칙
  2. OCP : 열림-닫힘 원칙
  3. LSP : 리스코프 치환 원칙
  4. ISP : 인터페이스 분리 원칙
  5. DIP : 의존성 역전 원칙

SOLID 디자인 원칙의 다섯 번째, 의존성 역전 법칙(Dependency Inversion Principle, DIP)

DIP는 두가지로 정의되어 있다.

  1. 상위 모듈이 하위 모듈에 종속성을 가져서는 안 된다. 양쪽 모두 추상화에 의존해야 한다.
    예를 들어, ILogger 인터페이스를 상속받는 ConsoleLogger 클래스가 있고, ConsoleLogger를 상속받아 LogReporting 컴포넌트를 구현한다고 가정해보자. 이 때, LogReporting 컴포넌트는 ConsoleLogger에 의존해서는 안되고 ILogger에만 의존해야 한다는 것이 1번 법칙이다. 이 경우 LogReportLogReporting으로 구별해서 Reporting 컴포넌트를 상위모듈로 취급하고, 반면에 Log를 파일 입출력이나 스레드 처리에 중점을 두므로 하위 모듈로 취급한다.

  2. 추상화가 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.
    이 부분 또한 종속성이 실 구현 타입이 아니라 인터페이스 또는 부모 클래스에 있어야 한다는 것을 말한다. 이 원칙이 지켜져야만 구성에 대한 설정이 편리해지고 모듈을 테스트하는 것도 쉬워진다. 만약 사용하고 있는 프레임 워크가 이러한 편의성을 제공한다면 의존성 역전 원칙이 잘 적용되었다는 의미이다.
    그렇다면, 의존성 역전 원칙이 지켜지도록 구현하려면 어떻게 해야 할까? 사실 많은 작업이 필요하다. 위의 두 가지 요구사항 A,B가 기술하고 있는 것들을 명시적으로 코드로 나타내야 한다. 예를 들어 ReportingILogger에 의존해야 하고 이 부분은 아래와 같이 코드로 나타낼 수 있다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    class Reporting {
     ILogger& logger;
    public:
     Reporting(const ILogger& _logger)
         :logger(_logger)
     {}
     void prepare_report() {
         logger.log_info("Preparing the report");
         // To do
     }
    };
    

    그런데, 이 클래스를 인스턴스화 하려면 구현 클래스를 호출해야 하는 문제가 있다.(Reporting(ConsoleLogger())등의 방법으로 구현해야 한다. ConsoleLoggerILogger 인터페이스를 상속받은 클래스이다.) 만약 리포팅 클래스가 5개의 서로 다른 인터페이스를 사용해야 한다면 어떻게 될까? 만약 ConsolerLogger가 자체적으로 다른 종속성을 가지고 있다면 어떻게 해야 할까? 이 문제들을 해결하려면 아주 많은 코드를 작성해야 한다. 하지만 다행히도 더 나은 방법이 있다.
    오늘날 의존성 역전 원칙을 구현하는 가장 인기 있고 우아한 방법은 종속성 주입(Dependency Injection) 테크닉을 활용하는 것이다.
    예를 들어, 자동차를 생각해보자. 이 자동차는 엔진과 로그 기능을 필요로 한다고 하자. 즉, 이 두 기능에 자동차가 의존성을 가진다. 먼저, 엔진을 다음과 같이 정의할 수 있다.

1
2
3
4
5
6
7
8
9
10
class Engine {
private:
	float volume = 5;
	int horse_power = 400;

public:
	friend ostream& operator<<(ostream& _os, const Engine& _obj) {
		return os << "volume: " << obj.volume << " horse_power: " << obj.horse_power;
	}
};

이제 자동차에 IEngine 인터페이스를 따로 추출할지 말지는 우리의 선택에 달려 있다. 그렇게 할 수도 있고 안 할 수도 있다. 이 부분은 설계 차원의 의사 결정이다. 만약 엔진들이 어떤 계층을 이루거나, 테스트를 위한 NullEngine이 필요하다면 엔진을 추상화하여 IEngine 인터페이스를 따로 추출해야 한다.
로깅의 경우도 여러가지 방법으로(콘솔 출력, 이메일, 핸드폰 SNS, 프린터 등등)할 수 있으므로 ILogger 인터페이스를 두는 것이 좋을 것이다.

1
2
3
4
5
class ILogger {
public:
	virtual ~ILogger() {}
	virtual void Log(const string& _s) abstract;
};

이 인터페이스의 구현 클래스로 아래와 같이 ConsoleLogger가 있을 수 있다.

1
2
3
4
5
6
7
8
class ConsoleLogger : ILogger {
public:
	ConsoleLogger() {}

	void Log(const string& _s) override {
		cout << "LOG: " << _s.c_str() << "\n";
	}
};

우리가 정의할 자동차는 엔진과 로깅 두 컴포넌트 모두에 의존하므로 두 컴포넌트를 내부에서 접근할 수 있어야 한다.이를 위해 포인터를 사용할 수도 있고, 참조를 사용할 수도 있고, unique_ptr/shared_ptr 또는 뭔가 다른 방법을 사용할 수도 있다. 이 부분은 전적으로 개발자의 자유이다. 여기서는 생성자 파라미터로 받아 unique_ptr/shared_ptr로 저장하기로 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Car {
private:
	unique_ptr<Engine> engine;
	shared_ptr<ILogger> logger;

public:
	Car(unique_ptr<Engine> _engine, const shared_ptr<ILogger>& _logger)
		: engine(move(_engine))
		, logger(_logger) {
		logger->Log("making a car");
	}

	friend ostream& operator << (ostream& _os, const Car& _obj) {
		return _os << "car with engine: " << *_obj.engine;
	}
};

이렇게 하면, Engine 클래스와 ILogger에게 상속받은 Car 클래스를 만들 수 있다. Car는 인터페이스를 상속 받음으로 ILogger 클래스를 상속받은 다른 클래스가 있더라도 ILogger 인터페이스를 컴포넌트로 가지고 있음으로써 그 클래스 또한 받을 수 있다.

요약

DIP는 상위 모듈이든 하위 모듈이든 인터페이스를 상속받거나 컴포넌트로 가져야 한다는 원칙이다.
이를 위해 최대한 인터페이스를 상속받도록 클래스를 구현해야 하며, Setter 또는 생성자를 사용해서 종속성 주입을 한다.

This post is licensed under CC BY 4.0 by the author.