SOLID 디자인 원칙
SOLID는 다음과 같은 디자인 원칙들을 아우르는 약어이다.
- 단일 책임 원칙(Single Responsibilty Principle, SRP)
- 열림-닫힘 원칙(Open-Closed Principle, OCP)
- 리스코프 치환 원칙(Liskov Substitution Principle, LSP)
- 인터페이스 분리 원칙(Interface Segregation Principle, ISP)
- 의존성 역전 원칙(Dependency Inversion Principle, DIP)
의존성 역전 원칙(Dependency Inversion Principle, ISP)
원전에서는 DIP를 다음과 같이 정의하고 있다.
A. 상위 모듈이 하위 모듈에 종속성을 가져서는 안 된다. 양쪽 모두 추상화에 의존해야 한다.
이것이 기본적으로 의미하는 것은, 예를 들어 로깅 기능이라면, 로그 리포팅 컴포넌트가 실 구현체인 ConsoleLogger에 의존해서는 안 되고 ILogger 인터페이스에만 의존해야 한다는 것이다. 이 경우 리포팅 컴포넌트를 상위 모듈로 취급하고(즉, 비즈니스 도메인에 더 가깝다), 반면에 로깅은 파일 입출력이나 스레드 처리에 중점을 두므로 하위 모듈로 취급한다.
B. 추상화가 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.
이 부분 또한 종속성이 실 구현 타입이 아니라 인터페이스 또는 부모 클래스에 있어야 한다는 것을 말한다. 추가적인 설명이 없더라도 이 원칙의 올바름은 자명하게 느껴지기를 바란다. 이 원칙이 지켜지면 구성에 대한 설정이 편리해지고(configurability) 모듈을 테스트하는 것도 쉬워진다(testability). 만약 사용하고 있는 프레임워크가 이러한 편의성을 제공한다면 의존성 역전 원칙이 잘 적용되었다는 의미이다.
이제 가장 중요한 질문이 남았다. 의존성 역전 원칙이 지켜지도록 구현하려면 어떻게 해야 하는가? 사실 많은 작업이 필요하다. 위의 두 가지 요구사항 A, B가 기술하고 있는 것들을 명시적으로 코드로 나타내야 한다. 예를 들어 리포팅은 ILogger에 의존해야 하고 이 부분은 아래와 같이 코드로 나타낼 수 있다.
class Reporting
{
ILogger& logger;
public:
Reporting(const ILogger& logger) : ILogger(logger) { }
void prepare_report()
{
logger.log_info("Prepareing the report");
}
}
그런데, 이 클래스를 인스턴스화하려면 구현 클래스를 호출해야 하는 문제가 있다. 만약 리포팅 클래스가 5개의 서로 다른 인터페이스를 사용해야 한다면 어떻게 될까? 만약 ConsoleLogger가 자체적으로 다른 종속성을 가지고 있다면 어떻게 해야 할까? 이 문제들을 해결하려면 아주 많은 코드를 작성해야 한다. 하지만 다행히도 더 나은 방법이 있다.
오늘날 의존성 역전 원칙을 구현하는 가장 인기 있고 우아한 방법은 종속성 주입(Dependency Injection) 테크닉을 활용하는 것이다. 종속성 주입은 Boost.DI와 같은 라이브러리를 이용해 어떤 컴포넌트의 종속성 요건이 자동적으로 만족되게 한다는 의미이기도 하다.
예를 들기 위해 자동차를 생각해 보자. 이 자동차는 엔진과 로그 기능을 필요로 한다고 하자. 즉, 이 두 기능에 자동차가 의존성을 가진다. 먼저, 엔진을 다음과 같이 정의할 수 있다.
struct Engine
{
float volume = 5;
int horse_power = 400;
friend ostream& operator<< (ostream& os, const Engine& obj)
{
return os
<< "volume: " << obj.volume
<< "horse power: " << obj.horse_power;
} // thanks, ReSharper!
}
이제 자동차에 엔진을 제공할 때 IEngine 인터페이스를 따로 추출할지 말지는 우리의 선택에 달려있다. 그렇게 할 수도 있고 안 할 수도 있다. 이 부분은 설계 차원의 의사 결정이다. 만약 엔진들이 어떤 계층을 이루거나, 테스트를 위한 NullEngine이 필요하다면 엔진을 추상화하여 IEngine 인터페이스를 따로 추출해야 한다.
로깅의 경우도 여러 가지 방법으로 (콘솔 출력, 이메일, 핸드폰 SMS, 프린터 등등)할 수 있으므로 ILogger 인터페이스를 두는 것이 좋을 것이다.
struct ILogger
{
virtual ~ILogger() {}
virtual void Log(const string& s) = 0;
}
이 인터페이스의 구현 클래스로 아래와 같이 ConsoleLogger가 있을 수 있다.
struct ConsoleLogger : ILogger
{
ConsoleLogger() {}
void Log(const string& s) override
{
cout << "Log: " << s.c_str() << endl;
}
}
우리가 정의할 자동차는 엔진과 로깅 두 컴포넌트 모두에게 의존하므로 두 컴포넌트를 내부에서 접근할 수 있어야 한다. 이를 위해 포인터를 사용할 수도 있고, 참조를 사용할 수도 있고, unique_ptr / shared_ptr 또는 뭔가 다른 방법을 사용할 수도 있다. 이 부분은 전적으로 개발자의 자유이다. 여기서는 생성자 파라미터로 받아 unique_ptr / shared_ptr로 저장하기로 한다.
struct Car
{
unique_ptr<Engine> engine;
shared_ptr<ILogger> logger;
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;
}
};
Car의 생성자에 unique_ptr / shared_ptr의 호출이 있으리라 생각했을 것이다. 하지만 "종속성 주입"에서는 그렇게 하지 않는다. 대신 Boost.DI를 이용한다. 먼저 ILogger를 ConsoleLogger에 연결하는 bind를 정의한다. 이 정의는 "누구든지 ILogger를 요청하면 ConsoleLogge를 전달하라"라를 것을 의미한다.
auto injector = di::make_injector(di::bind<ILogger>().to<ConsoleLogger>())
이 코드로 injector가 설정되었으므로 아래와 같이 Car를 생성하여 이용할 수 있다.
auto car = injector.create<shared_ptr<car>>();
위 코드는 온전히 인스턴스화된 Car 객체를 가리키는 shared_ptr <Car>를 만든다. 목적하던 바로 그 동작이다.
이러한 접근 방법의 가장 큰 장점은 사용할 ILogger 인스턴스의 타입을 바꿀 때 단 한 곳. 즉, bind가 수행되는 부분만 수정하면 자동으로 ILogger를 사용하는 모든 곳에 적용된다는 점이다. 이러한 방식은 단위 테스트도 쉽게 할 수 있게 해 준다. 단 한 줄만 수정하여 종속성이 있는 객체에 실제 동작하는 구현 객체를 사용할 수도 있고 테스트용 더미 객체를 사용하게 바꿀 수도 있다.
출처
'프로그래밍' 카테고리의 다른 글
비주얼 스튜디오 코드로 UML 그리기 (0) | 2023.12.19 |
---|---|
디자인 패턴이란? (1) | 2023.11.29 |
SOLID 디자인 원칙 - 인터페이스 분리 원칙 (1) | 2023.11.20 |
SOLID 디자인 원칙 - 리스코프 치환 원칙 (2) | 2023.11.20 |
Tortoise SVN externals 설정하기 (0) | 2023.11.15 |
댓글