클린 소프트웨어 - 의존 관계 역전 원칙

의존 관계 역전 원칙

의존 관계 역전 원칙

Dependency Inversion Principle

  • 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다.
  • 추상화는 구체적인 사항에 의존해서는 안 된다. 구체적인 사항은 추상화에 의존해야 한다

구조적 분석 설계와 같은 전통적인 소프트웨어 개발 방법에서는 상위 수준의 모듈이 하위 수준의 모듈에 의존하고, 정책이 구체적인 것에 의존하는 경향이 있다. 이는 상위 수준의 모듈이 하위 수준의 모듈을 호출하는 방법을 묘사하는 서브프로그램의 계층 구조를 정의하는 것이었다.

이 경우, 정책은 상위 수준의 모듈에 있는데, 하위 수준의 모듈의 변경이 상위 수준의 모듈에 영향을 미쳐 상위 수준의 모듈의 변경을 불러일으킬 수 있다.

사실 하위 수준의 구체적인 모듈에 영향을 주어야 하는 것은 정책을 결정하는 상위 수준의 모듈이므로, 위와 같은 경우가 일어나서는 안 된다. 상위 수준의 모듈은 어떤 식으로든 하위 수준의 모듈에 의존해서는 안 된다.

이러한 관계를 ‘역전’시켜, 하위 수준의 모듈이 상위 수준의 모듈에 의존하게 한다.

하위 수준의 모듈이 상위 수준의 모듈에 의존하게 되는 경우, 상위 수준은 재사용하기 쉬워질 것이다. 이는 프레임워크 설계의 핵심이다.

레이어 나누기

  • 상위 수준의 Policy 레이어
  • 하위 수준의 Mechanism 레이어
  • 구체적인 Utility 레이어

Policty 레이어는 Mechanism 레이어를 사용하고, Mechanism 레이어는 Utility 레이어를 사용한다. 이 때 Policy 레이어는 Utility 레이어의 모든 변화에 민감하다.

Policy -> Mechanism -> Utility

의존성은 이행적이다. Policy 레이어는 Utility 레이어에 의존하는 다른 것에도 의존하며, 이들의 변화에도 민감하다.

이 때 각 상위 수준 레이어는 그것이 필요로 하는 서비스에 대한 추상 인터페이스를 선언할 수 있을 것이다, Policy 레이어에 대하여 Policy Service Interface 인터페이스를, Mechanism 레이어에 대하여 Mechanism Service Interface 인터페이스를 선언할 수 있을 것이다.

하위 수준의 레이어는 상위 수준의 추상 인터페이스로부터 실체화되고, 각 상위 수준 클래스는 추상 인터페이스를 통해 다음 하위 수준의 레이어를 사용한다.

따라서 상위 레이어는 하위 레이어에 의존하지 않으며, 반대로 하위 레이어는 상위 레이어에 선언된 추상 서비스 인터페이스에 의존한다.

PolicyUtility에 대한 이행적 의존성뿐만 아니라, Mechanism에 대한 직접적인 의존성도 끊을 수 있게 되었다.

상위 수준의 모듈이 하위 수준의 모듈을 직접적으로 사용하여 의존하는 대신, 상위 수준이 사용하는 하위 수준의 인터페이스를 상위 수준에 정의하고, 하위 수준의 모듈이 해당 인터페이스를 구현하는 방식이 된다.

소유권의 역전

역전이란, 의존성에 대해서만이 아니라, 인터페이스의 소유권에 대한 것도 의미한다.

DIP가 적용되면 클라이언트가 추상 인터페이스를 소유하고, 추상 인터페이스로부터 서버가 파생해 나온다.

이는 할리우드 원칙으로 알려져 있다. (전화하지 마세요. 우리가 연락드릴게요.)

하위 수준이 상위 수준에 연락하지 않고, 상위 수준이 하위 수준을 필요로 할 때 연락하는 것이다.

하위 수준의 모듈은 상위 수준의 모듈 안에 선언되어 호출되는 인터페이스의 구현을 제공한다.

이를 통해 상위 수준의 레이어는 하위 수준의 어떠한 변경에도 영향을 받지 않으며, 하위 수준의 인터페이스에 맞는 하위 수준 모듈을 정의하는 어떤 문맥에서든 재사용될 수 있다.

의존성을 역전시켜 좀 더 유연하고, 튼튼하고, 이동이 쉬운 구조를 만들어낼 수 있다.

추상화에 의존하자

경험적으로 접근했을 때, DIP는 ‘추상화에 의존하자’라는 간단한 방식으로 해석할 수 있다.

말하자면, 구체 클래스에 의존해서는 안 되고, 어떠한 추상 클래스나 인터페이스와 프로그램 간 관계가 맺어져야 한다는 것이다.

이에 따라 다음과 같은 행동을 이끌어낼 수 있다.

  • 어떠한 변수도 구체 클래스에 대한 포인터나 참조값을 가져서는 안 된다.
  • 어떠한 클래스도 구체 클래스로부터 파생되어서는 안 된다.
  • 어떠한 메소드도 그 기반 클래스에서 구현된 메소드를 오버라이드해서는 안 된다.

위의 조건은 너무 엄격하다. 모든 프로그램에서 한 번 이상 위반되는 것이 보통이다.

  • 어느 것인가는 구체 클래스의 인스턴스를 생성해야 하고, 이러한 일을 하는 모듈은 해당 클래스에 의존해야 한다.
  • 구체적이긴 하지만 변경되지 않는 것이 보장되는 클래스에 대하여, 이와 비슷한 파생 클래스가 만들어지지 않는다면, 해당 클래스에 의존해도 괜찮아 보인다.

예를 들어 대부분의 시스템에서 문자열을 나타내는 클래스는 구체적이며, 자주 변경되지 않는다. 그러므로 이것에 직접 의존하여 문자열을 표현하는 것은 전혀 해가 되지 않는다.

하지만 우리가 애플리케이션을 만들면서 작성하는 대부분의 구체 클래스는 자주 변경될 가능성이 있고, 우리는 이것에 직접적으로 의존하지 않기를 바란다. 자주 변경되는 성질을 추상 클래스 뒤에 숨겨 분리할 수 있다.

하지만 위와 같은 클래스의 인터페이스를 변경해야 하는 상황이 오면, 이 클래스를 표현하는 추상 클래스를 변경해야 할 것이므로 완벽한 해결책이 되지 못한다. 이러한 변경은 추상 인터페이스의 분리 상태를 망가뜨린다.

그러므로 위에서 언급한 세 가지 접근 방법은 고지식하다. 클라이언트 클래스가 자신이 필요로 하는 서비스 인터페이스를 선언한다는 방식으로 접근한다면, 클라이언트가 변경을 필요로 할 때만 해당 인터페이스가 변경될 것이다. 추상 인터페이스를 구현하는 클래스의 변화는 클라이언트에 영향을 주지 않는다.

간단한 예

의존성 역전은 한 클래스가 다른 클래스에 메세지를 보내는 장소라면 어디든 적용될 수 있다.

class Button {
let lamp = Lamp()

func poll() {
if ... {
lamp.turnOn()
} else if ... {
lamp.turnOff()
}
}
}

class Lamp {
func turnOn() { ... }
func turnOff() { ... }
}

Button 클래스는 Lamp 클래스에 의존하여, Lamp에 대한 변경에 영향을 받을 수 있다. Button이 다른 객체를 제어할 수 있게 재사용하는 것도 불가능하다.

Button 객체는 Lamp 객체만을 제어한다.

결과적으로 위의 코드는 DIP를 위반한다. 상위 수준 정책은 하위 수준 정책과 분리되어 있지 않다. 추상화는 구체적인 것에 분리되어 있지 않다. 결과적으로 상위 수준 정책은 하위 수준 모듈에 의존하게 된다. 추상화는 구체적인 것에 의존하게 된다.

내재하는 추상화를 찾아서

상위 수준의 정책이란, 애플리케이션이 내포하는 추상화이자, 구체적인 것이 변경되더라도 변하지 않는 진실이다. 시스템 안의 시스템이며, 메타포다.

위의 예제가 내포하는 추상화는, 사용자로부터 켜고 끄는 동작을 탐지하여 그 동작을 대상 객체에게 전달하는 것이다.

이 과정에서 사용자의 동작을 탐지하기 위해 사용되는 매커니즘이나, 대상 객체가 무엇인가는 신경쓸 필요가 없다. 이것은 추상화에 영향을 주지 않는 구체적인 것들이기 때문이다.

protocol ButtonServer {
func turnOn()
func turnOff()
}

class Lamp: ButtonServer {
func turnOn() { ... }
func turnOff() { ... }
}

class Button {
let buttonServer: ButtonServer
init(buttonServer: ButtonServer) {
self.buttonServer = buttonServer
}

func poll() {
if ... {
buttonServer.turnOn()
} else if ... {
buttonServer.turnOff()
}
}
}

위와 같이 코드를 수정하여 Lamp 객체의 의존성을 역전시킬 수 있다.

ButtonServerButton이 어떠한 것을 켜거나 끄기 위해 사용할 수 있는 추상 메소드를 제공하고, LampButtonServer 인터페이스를 구현한다. 따라서 Lamp는 의존당하지 않고, 반대로 의존한다.

이제 ButtonButtonServer 인터페이스를 구현하는 어떠한 장치도 제어할 수 있어 프로그램이 유연해지고, 아직 만들어지지 않은 객체도 제어할 수 있는 가능성이 있다.

하지만 ButtonServer라는 이름으로 인해 Button에 의해 제어되길 원하는 객체에 제약이 걸린다. 제어되는 장치가 Button이 아닌 Switch나 다른 객체에 의해서 제어되기를 원할 수도 있기 때문이다.

그렇기 때문에 인터페이스의 이름을 좀 더 일반화하여 SwitchableDevice와 같이 이름 지으면 이름으로 인한 의존성을 제거할 수 있다. 또한 ButtonSwitchableDevice가 개별 라이브러리에 존재하는 것을 확실하게 하여, SwitchableDevice를 사용하는 것이 곧 Button을 사용하는 것임을 의미하지 않게 할 수 있다.

protocol SwitchableDevice {
func turnOn()
func turnOff()
}

class Lamp: SwitchableDevice {
func turnOn() { ... }
func turnOff() { ... }
}

class Button {
let switchableDevice: SwitchableDevice
ini(switchableDevice: SwitchableDevice) {
self.switchableDevice = switchableDevice
}

func poll() {
if ... {
switchableDevice.turnOn()
} else if ... {
switchableDevice.turnOff()
}
}
}

용광로 사례

용광로의 조절기를 제어하는 소프트웨어가 있다. 이것은 IO 채널에서 현재 온도를 읽고, 다른 IO 채널에 명령어를 전송하여 용광로를 켜거나 끈다.

protocol Thermometer {
func read()
}

protocol Heater {
func engage()
func disengage()
}

class IOChannelThermometer: Thermometer {
func read() { ... }
}

class IOChannelHeater: Heater {
func engage() { ... }
func disengage() { ... }
}

func regulate(thermometer: Thermometer, heater: Heater, minimumTemperature: Double, maximumTemperature: Double) { ... }

위와 같이 설계하면 상위 수준의 조절 정책이 온도 조절기나 용광로의 구체적인 사항에 의존하지 않게 된다. 알고리즘은 재사용 가능하게 되었다.

동적 다형성과 정적 다형성

추상 클래스나 인터페이스를 사용한 동적 다형성을 활용하여 의존성을 역전시켰고, regulate 함수를 일반적인 것으로 만들었다.

이를 정적 다형성을 사용하여 해결할 수도 있다.

func regulate<T: Thermometer, H: Heater>(thermometer: T, heater: H, minimumTemperature: Double, maximumTemperature: Double) { ... }

정적 다형성은 소스 코드의 의존성을 깔끔하게 끊어주지만, 동적 다형성과 비교하여 다음의 문제는 해결해주지 못한다.

  • 런타임에서 HeaterThermometer의 타입이 변경될 수 없다.
  • 새로운 종류의 HeaterThermometer를 사용할 때 재컴파일과 재배포를 필요로 한다.

그러므로 속도가 매우 절실하게 필요한 것이 아니라면, 동적 다형성이 더 나은 선택이 될 것이다.

결론

전통적은 절차 지향 프로그래밍은 정책이 구체적인 것에 의존하는 의존성 구조를 만든다. 구체적인 사항의 변경이 정책의 변경을 불러 일으키므로 좋지 않다.

객체 지향 프로그래밍은 이러한 의존성 구조를 역전시켜, 구체적인 사항과 정책이 모두 추상화에 의존하고, 클라이언트가 서비스 인터페이스를 소유하도록 한다.

의존성 역전이 잘 되어 있으면 객체 지향 설계를 잘 했다고 말할 수 있다. 프로그램의 의존성이 역전되어 있다면 객체 지향 설계를 한 것이며, 그렇지 않다면 절차 지향 설계를 한 것이다.

재사용 가능한 프레임워크를 만들기 위해서 DIP를 응용할 줄 알아야 한다. 또한 변경에 탄력적인 코드를 작성하기 위한 전략이 된다. 추상화와 구체적인 사항이 서로 분리되어 있으므로 유지보수하기 쉬워진다.