개발 & CS지식/CS

Dependency Inversion/Dependency Inversion Principle(DIP)를 자세히 그리고 쉽게 알아보자!

LYHyoung 2023. 10. 28. 17:29
728x90

Dependency Inversion Principle (DIP)은 class들 간의 의존성 부패(Dependency Rot)를 해결하기 위한 일반적인 디자인 원칙입니다. 이 원칙은 Robert C. Martin이 1996년에 발표한 'The Dependency Inversion Principle'을 통해 알려지게 되었습니다.

'고차원 모듈은 저차원 모듈에 의존하면 안된다. 이 모듈 모두 다른 추상화된 것에 의존해야 한다.

추상화 된 것은 구체적인 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야 한다.'

- Martin, Robert C. -

위의 [그림 1]에서 Client A와 Service B에 DIP를 적용할 때, 다음과 같은 단계로 적용됩니다:

[그림 3]

1. '고차원 모듈은 저차원 모듈에 의존하면 안된다.' [그림 3]에서 A가 B를 바라보는 의존성을 제거합니다.

[그림 4]

2. '이 모듈 모두 다른 추상화된 것에 의존해야 하며, 추상화된 것은 구체적인 것에 의존하면 안된다.' [그림 4]에서 A는 Abstract를 참조하지만 Abstract는 B에 의존하면 안됩니다.

[그림 5]

3. '구체적인 것이 추상화된 것에 의존해야 합니다.' [그림 5]에서 B는 Abstract를 상속받아 의존성을 역전시킵니다. 그림만으로는 이해하기 어려울 수 있으므로 아래에 구체적인 예제 코드를 통해 설명하겠습니다.

 

class SwitchButton {
    let lamp = Lamp()
    var isOn = false
    
    func toggle() {
        isOn = !isOn
        if isOn {
            lamp.lightOn()
        } else {
            lamp.lightOff()
        }
    }
}

class Lamp {
    func lightOn() {
        print("lamp on")
    }
    
    func lightOff() {
        print("lamp off")
    }
}

[코드 1] Dependency 문제가 있는 SwitchButton

 

[코드 1]은 SwitchButton을 toggle하면 Lamp가 on/off 되는 간단한 예제 코드입니다.

위 class들의 관계를 그려보면 아래( [그림6] )와 같습니다.

[그림 6] SwitchButton과 Lamp의 Class Diagram

[그림 6]의 SwitchButton과 Lamp의 Class Diagram을 살펴보면 [그림 1]과 동일한 종속성 관계가 나타납니다. 이미 언급한대로, [그림 6]에서 재사용 가능한 구성요소는 Lamp 뿐이며, SwitchButton은 재사용할 수 없습니다. 현재 상태에서 램프 대신 자동차 시동을 조작하는 버튼이 필요하다면, SwitchButton을 활용할 수 없고 대신 EngineSwitchButton을 새로 구현해야 합니다.

 

[코드 2]는 [코드 1]의 의존성 문제를 DIP를 적용하여 해결한 코드입니다.

class SwitchButton {
    let lamp: SwitchButtonInterface = Lamp()
    var isOn = false
    
    func toggle() {
        isOn = !isOn
        if isOn {
            lamp.on()
        } else {
            lamp.off()
        }
    }
}

protocol SwitchButtonInterface {
    func on()
    func off()
}

class Lamp: SwitchButtonInterface {
    func on() {
        lightOn()
    }
    
    func off() {
        lightOff()
    }
}

[코드 2] DIP가 적용된 SwitchButton

[그림 7] Dependency Inversion이 적용된 모습

[코드 1]에서는 SwitchButton이 Lamp를 직접적으로 의존하고 있었지만, [코드 2]에서는 SwitchButton이 Lamp 대신 SwitchButtonInterface를 참조하도록 변경되었고, 각 Lamp와 Engine이 SwitchButtonInterface를 상속받도록 구성하여 의존성을 뒤집었습니다. 따라서 이제 SwitchButton은 Lamp과 같은 구현체와 직접적인 의존성이 없어 재사용이 가능한 코드가 되었습니다.

때로는 클래스 다이어그램만을 보고 Strategy Pattern과 혼동하는 경우가 있지만, [그림 8]을 살펴보면 개념적으로 완전히 다른 것임을 알 수 있습니다.

[그림 8] DIP와 Strategy Pattern과의 차이점

[그림 8]에서 DIP의 인터페이스는 A에서 정의하고 B에서 구현하고 있습니다. 그러나 Strategy Pattern에서는 그 반대입니다. 인터페이스는 B에서 정의하고 A에서 이를 의존합니다.

Dependency Inversion이 적용된 디자인과 그렇지 않은 디자인을 애플리케이션 아키텍처 수준에서 바라봤을 때, 다음과 같은 모습을 갖습니다.

[그림 9] Dependency Rot이 발생한 프로젝트

[그림 9]는 의존성이 체계적으로 정리되지 않아 의존성 부패가 발생한 애플리케이션 아키텍처의 모습입니다. 클래스들이 스파게티처럼 복잡하게 얽혀 있어, 재사용성이 떨어지며 코드의 가독성과 유지 보수성도 저하됩니다. 의존성이 강하므로 각 클래스는 모의 객체로 대체할 수 없어 단위 테스트도 어려운 코드 대부분입니다. 이러한 디자인 결함은 더 큰 문제를 야기할 수 있으며 계속해서 확장될 수 있습니다.

[그림 10] Dependency Inversion이 적용된 프로젝트

[그림 10]은 의존성 역전 원칙을 적용하여 의존성이 체계적으로 정리된 애플리케이션 아키텍처를 나타냅니다. 상위 레이어와 하위 레이어 간의 강한 의존성이 제거되고, 추상 레이어를 도입하여 두 레이어가 추상 레이어를 통해 필요한 서비스에 의존하도록 했습니다. 각 레이어는 명확하게 정의되었고, 통제된 인터페이스를 통해 응집력 있는 서비스만을 제공하도록 디자인되었습니다.

가장 많이 알려진 MVC 패턴에 DIP가 적용된 예시는 [그림 11]과 같이 보여집니다.

[그림 11] MVC에서의 DIP

요즘에는 MVC를 사용하는 동안 Controller가 지나치게 복잡해지고 UI와 비즈니스 로직이 혼합되는 문제가 발생하는 경우가 많다는 의견이 있습니다. 그러나 이러한 문제의 대부분은 MVC 아키텍처 자체의 문제가 아니라 올바른 의존성 관리와 디자인 원칙의 부족 때문에 발생합니다. 즉, MVC를 비난하기보다는 개발자들이 프로젝트의 아키텍처를 올바르게 설계하고 관리하지 못하는 문제입니다. 아마도 의존성에 대한 충분한 이해가 없으면 어떤 디자인 패턴을 적용하더라도 비슷한 문제가 발생할 것으로 생각됩니다.

예를 들어, 안드로이드 앱에서 Activity를 Controller처럼 사용하거나, 과도한 중앙 집중화로 인해 제어 로직을 하나의 컨트롤러에서 처리하려는 시도, 또는 동적 동작에 대한 고려 없이 물리적인 로직만 클래스로 분리하고 동작을 고려하지 않는 경우 등이 있습니다. 이러한 문제는 MVC 외의 다른 디자인 패턴을 적용해도 여전히 나타날 수 있는 문제입니다. (더 자세한 MVC에 대한 내용은 나중에 별도의 글에서 다루겠습니다.)

다시 돌아가 DIP가 적용된 [코드 2] 예제에 대해 이야기해 보겠습니다. SwitchButton과 Lamp에 Dependency만 Inversion한다고 의존성 문제가 해결될까?

class SwitchButton {
    let lamp: SwitchButtonInterface = Lamp()
    ...
}

[코드 3] Class Dependency

위 [코드 3]에서 2line을 보면 SwitchButton이 concrete class인 Lamp를 직접 생성하고 있습니다. 의존성을 뒤집어 Interface를 참조하도록 하였지만, 아직 class dependency가 남아 있다. Factory Pattern을 적용하면 아래와 같이 Lamp와의 class dependency를 제거할 수 있습니다.

class SwitchButton {
    let lamp: SwitchButtonInterface = Factory.getObject()
    ...
}

위 [코드 4]에서 Factory Pattern을 적용하여 SwitchButton의 Lamp class dependeny를 제거하였습니다. Factory는  SwitchButton이 알지 못하게 SwitchButtonInterface를 구현한 어떤 concrete class를 반환할 것 입니다.

[그림 12] Factory Pattern을 적용한 SwitchButton

[그림 12]에서 Factory Pattern을 적용한 SwitchButton은 이제 Lamp 대신 Factory에 강한 의존성을 가지고 있습니다. 결과적으로 SwitchButton은 Factory와 긴밀하게 결합되었으며, 다른 프로젝트에서 재사용하려면 Factory를 수정해야 할 수 있습니다. 이로 인해 SwitchButton은 여전히 완전히 독립된 컴포넌트가 되지 못합니다. 더욱이, 이러한 디자인으로 decoupling을 적용하면 프로젝트 내의 모든 클래스가 각자 자체의 factory를 가져야 할 수도 있으며, 이는 프로젝트의 복잡성을 증가시킬 수 있습니다.

따라서 Factory Pattern을 사용할 때, 재사용성과 유지 보수성을 고려하여 Factory와의 의존성을 관리하는 것이 중요하며, 가능하다면 공통의 Factory를 활용하거나 의존성 주입과 같은 방법을 사용하여 Factory에 대한 의존성을 완화할 수 있습니다.

 

728x90