의존성 주입과 협업에 관해

소프트웨어의 구조는 OCP를 준수하도록 설계해야 한다고 말합니다.

"** 아키텍쳐"라고 불리는 것들은 이러한 목표를 달성하기 위한 구체적인 방법을 제시한 것이라 할 수 있습니다.

그러나, 이러한 설계는 의존성 주입의 도움 없이는 쉽게 달성하기 힘들 수 있습니다.

이점을, 예제를 통해 알아 보겠습니다.

원초적 의존 관계

우선, 우리는 엔터프라이즈 급 어플리케이션을 제작하고 있다고 가정을 합니다.
그래서, 업무 분장을 모듈(C# 프로젝트) 단위로 나눌 것입니다.

이러한 창대한 가정에도 불구하고, 우리 어플리케이션은 두 개의 모듈로 구성되었습니다.

namespace 갑;
public class Client 
{
   public void Do() { }
}
namespace 을;
public class Service
{
   public void Do() { }
}

갑 모듈은 어플리케이션 규칙의 하나를 담당하는 상위 모듈이고, 을 모듈은 갑이 다루는 규칙 내부의 비지니스 규칙을 제공하는 하위 모듈중 하나입니다.

Client 가 Service를 사용하기 위해서는 모듈 간 의존성은 아래와 같아야 합니다.

갑 → 을

프로젝트 참조를 시킨 후 Client 가 Service를 사용하는 코드입니다.

using 을;

namespace 갑;
public class Client
{
   public void Do()
   {
      Service service = new Service();
      service.Do();
   }
}

이러한 코드는, 동작하는 것에는 아무런 문제가 없음에도 불구하고, "강한 결합"으로 인해 좋지 않은 코드로 많이 알려져 있습니다.

그 이유는, Service 를 다른 구현으로 변경할 때, 갑 모듈도 변경되야 하기 때문 - 다시 말하면 OCP를 위배하기 때문입니다.

피상화(Abstraction)

이 문제를 해결하기 위해, Service를 피상화합니다.

// 갑 모듈.
namespace 갑.계약서;
public IClientService
{
   void Do();
}
// 갑 모듈
using 갑.계약서;
using 을;

namespace 갑;
public class Client
{
   public void Do()
   {
      IClientService service = new Service();
      service.Do();
   }
}

을 모듈은 갑의 계약서를 구현해서 제공해야 합니다.

// 을 모듈
using 갑.계약서;

namespace 을;
public class Service: IClientService
{
   public void Do()
   {      
   }
}

경험 많은 분들은 이미 눈치채셨겠지만, 위의 코드는 불가능합니다.

Service가 갑의 계약서(IClientService)를 구현하기 위해서는 의존 관계는 아래와 같아야 합니다.

을 → 갑

동시에 Client가 Service 를 사용하기 위해서는 아래와 같은 의존성도 필요합니다.

갑 → 을

프로젝트 순환 참조가 발생한 것이죠.

이 순환 참조 문제로 인해, 프로젝트 단위로 분업할 때는 상위 모듈이 계약을 가지면 안 됩니다.

피상(Abstraction) 모듈 도입

이 순환 참조 상태를 해소할 수 있는 방법 중 하나는 계약을 별도의 모듈로 분리하는 것입니다.

// 계약 모듈
namespace 표준계약서

public ICientService
{
   void Do();
}

이제 을 모듈은 계약 모듈을 참조하여, 순환 참조 문제를 해결합니다.

을 → 표준계약서

using 표준계약서;

namespace 을;
public class Service: ICientService
{
   public void Do() { }
}

갑 모듈도 이제는 계약 모듈을 참조합니다.

// 갑 모듈
using 표준계약서;
using 을;

namespace 갑;
public class Client
{
   public void Do()
   {
      ICientService service = new Service();
      service.Do();
   }
}

전체적으로 돌아 보면, 변경 전 구조는 아래와 같았습니다.

갑 → 을

변경 후 구조는 아래와 같습니다.

갑 → 표준계약서
갑 → 을
을 → 표준계약서

계약을 별도의 모듈로 분리했음에도 불구하고, 을에 대한 갑의 의존성은 없어지지 않았습니다.

이는 프로젝트 단위로 협업할 때는, 단순한 피상의 도입만으로는 구현의 대체가 자유롭지 않음을 의미합니다.

예를 들어, 을 모듈을, 개선된 구현을 제공하는 병 모듈로 변경한다고 가정을 해봅시다.

using 표준계약서;

namespace 병;
public class Service: ICientService
{
   public void Do() { Console.WriteLine("완료되었음."); }
}

갑 모듈이 병 모듈을 사용하기 위해서는 아래와 같이 코드가 변경되어야 합니다.

// 갑 모듈
using 표준계약서;
// using 을;
using 병;

namespace 갑;
public class Client
{
   public void Do()
   {
      ICientService service = new Service();
      service.Do();
   }
}

비록 ‘을’ 을 '병’으로 한 글자가 바뀌었지만, 소스 코드는 변경된 것이라, 다시 빌드를 해야 어플리케이션은 정상 동작합니다.

이는 피상 모듈이 도입되어도 갑 모듈은 변경에 닫혀 있지 않은 상태, 다시 말하면 OCP 를 위배하고 있음을 의미합니다.

이러한 문제가 발생한 이유는 상위 모듈이 여전히 구체(Concrete, Details) 클래스에 의존하고 있기 때문입니다.

public class Client
{
   public void Do()
   {
      ICientService service = new Service(); // <= 범인
      service.Do();
   }
}

저 범인을 잡지 않는 한, 갑 모듈은 OCP 위배를 해소할 수 없습니다.
의존성 주입을 이용하면, 저 범인은 간단히 체포할 수 있습니다.

의존성 주입

의존성 주입은 의존 객체를 외부에서 주입해주는 것입니다.
우선 근원적 질문 몇 개를 해결하고 가봅니다.

Q: 누가 주입해주는데?

A: 의존성 주입기가!!

Q: 그게 누군데?

A: IServiceProvider!!

그렇습니다.

닷넷에서는 그 유명한 IServiceProvider 가 의존성 주입기입니다.

다시 예제로 돌아 와서, 의존성 주입기가 존재한다면, 갑 모듈을 아래와 같이 수정할 수 있습니다.

using 표준계약서;
// using 을;

namespace 갑;
public class Client(IClientService service)
{
   public void Do()
   {
      service.Do();
   }
}

마침내, 갑 모듈은 피상에만 의존하게 되었고, 모듈 간 의존성 관계는 아래와 같이 단출해집니다.

갑 → 표준계약서
을 → 표준계약서

즉, 의존성 주입의 도입으로 인해, 비로소 로버트 마틴이 주창한 제어의 역전(IoC) 원칙을 준수할 수 있게 된 것입니다.

즉, 우리 어플리케이션은 원래 이랬다가,

갑 → 을
image

이렇게 바뀐 것이죠.

갑 → 표준계약서 ← 을
image

이제 갑 모듈은 을이 병, 정, … 으로 바뀌어도 코드 수정과 재빌드가 필요하지 않습니다.

뭣이 중헌디

지금까지 살펴 본 대로, 프로젝트 단위로 협업을 할 수 있도록 소프트웨어를 설계하려면, 마틴이 제시한 제어의 역전 원칙을 지켜야 하는데, 이때 의존성 주입기가 있다면, 매우 쉽게 달성할 수 있습니다. .

이는 반대로 의존성 주입기가 없다면, 프로젝트 단위 분업은 달성하기 힘들 수도 있다는 의미도 됩니다.

닷넷의 프레임워크 중에는 의존성 주입기를 기반해서 설계된 것도 있고, 아닌 것도 있습니다.

그렇게 설계된 프레임워크를 사용하는 경우, 아래와 같은 코드를 적을 일은 없습니다.

var window = new Window();

왜냐하면, 프레임워크 대표 객체들은 의존성 주입기에 의해서 생성되도록 설계되었기 때문입니다.

대표적인 예로 Asp net core 인데, 이것을 사용하시는 분들 중에 컨트롤러, 페이지, 컴포넌트 객체의 생성자를 호출한 기억이 없을 것입니다. 그 반대인 경우, 생성하지 않은 기억이 없으실 것입니다.

이는 사용하는 프레임워크에 따라 "프로젝트 단위로 분업하는 구조로 설계하는 난이도가 달라진다**는 점을 의미합니다.

여기에 더해 의존성 주입기가 없는 프레임워크를 사용하면, 객체를 피상 객체로 한번 더 감싸는 일도 부질 없는 일이 될 수도 있습니다.

부질없는 피상화

앞서, 계약을 도입할 때, 의존성 주입기가 없는 경우,

  1. 갑이 계약을 보유하는 것은 순환 참조 때문에 불가능하다.
  2. 계약을 피상 모듈로 옮겨도 OCP 를 준수할 수 없다

는 점을 보여드렸습니다.

마지막으로, 을 모듈이 계약을 보유하는 경우에 대해 살펴봅니다.

// 을 모듈
namespace 을.계약서;
public ICientService
{
   void Do();
}
// 을 모듈
using 을.계약서;

namespace 을;
public class Service : IClientService
{
   public void Do() { }
}

이 경우는 원초적 의존 관계 만으로 모든 게 정상 동작합니다.

갑 → 을(.계약서)

그러나, 여기에는 어플리케이션이 비대해지며 성장하는 위험이 숨어있습니다.

예를 들어, 을 모듈을 대체하는 병 모듈을 도입하는 경우, 병.Service 의 코드는 아래와 같을 것입니다.

// 병 모듈
using 을.계약서;

namespace 병;
public class Service: IClientService
{
   public void Do()
   {      
   }
}

보시다시피, ‘을’ 이 계약서를 가지고 있기 때문에, 병은 을을 참조해야 합니다.
이로 인해 을은 제거도 안되고, 모듈 간 의존 관계만 복잡해지게 됩니다.

갑 → 을
갑 → 병
병 → 을

이는 어플리케이션에 돌이 한 번 박히면, 그 돌을 빼내지 못하는 것과 같습니다.

이런 모듈이 한 두개가 아니라면, 어플리케이션은 박힌 돌들을 잔뜩 끌어 안고 무겁게 무겁게 성장하게 되는 것이죠.

박힌 돌을 빼는 방법은 병이 계약서를 제공하는 것 뿐입니다.
그런데, 그렇게 하면, 갑 코드가 변경되야 합니다.

즉, 모든 새로운 구현은 갑 코드의 변경을 유발하게 됩니다.

갑 모듈을 담당하던 사람은 자기 코드는 잘 써놓고도, pull 한 번으로 에러를 보게 되는 것이죠.

마치며

소프트웨어 구조에 영향을 주는 요인은 많지만, 그 중에 하나가 “협업” 여부입니다.

사실 위에 보여드린 모든 객체를 한 모듈에 다 때려 넣으면, 앞서 드러났던 문제들이 사라지는 경우가 대부분입니다.

그런 문제들이 사라졌다고 안심할 일은 아닙니다.
지금까지 보여드린 것처럼, 그 코드로는 프로젝트 단위로 분업이 불가능하거나, 코드 수정의 범위가 매우 넓어질 수 있습니다. 그래서,

응… 너 혼자 독박!

개인적인 의견을 드리자면, 시작은 혼자 하더라도, 언제든지 협업할 수 있는, 다시 말하면 일을 나눠 줄 수 있는 구조로 프로젝트를 설계하는 습관을 가질 필요가 있습니다.

이 습관은 비단 협업 측면 뿐만 아니라, TDD 측면에서도 도움이 됩니다.

갑 모듈을 예로 든다면, 아래와 같이 제어의 역전을 준수하는 코드는, 구현 모듈이 없어도 정상 빌드 되기 때문에, IClientService 목업 객체로 얼마든지 테스트를 할 수 있습니다.

using 표준계약서;

namespace 갑;
public class Client(IClientService service)
{
   public void Do()
   {
      service.Do();
   }
}

결론적으로 이러한 구조는 의존성 주입기가 있다면 매우 쉽게 달성할 수 있습니다.

물론, 의존성 주입기 없이도 달성할 수 있습니다만, 코드가 아키텍쳐에 매몰되는 경향이 너무 심하게 느껴져 개인적으로 선호하지 않습니다. 헥사고날 아키텍쳐 같은 것들 말이죠.

16 Likes

훌륭한 아티클이네요!

image

개인적으로 이 부분이 킬포라고 생각합니다.

3 Likes

윗 댓글은 장난이고…

말씀하신 계약에 관한 설명은 꽤 정석적이라고 생각합니다.

인터페이스를 별도 모듈로 분리하여 사용하는 방식은 그래도 아주 널리 알려져있어서 단순히 의존성만을 해결한다는 측면의 하나의 기법처럼 보이는 것인데,

이것이 계약이라는 추상적인 개념이 접목되어서 이해와 설명이 들어간다면 피상 모듈로만 분리한다는 것은 단순한 코딩 문법 & 기법적인 문제가 아니라 객체가 추구하는 컴퓨터 세계에서 인간이 정의하는 하나의 메세지/의미가 될 수 있기 때문입니다.

훌륭한 아티클이네요!

2 Likes

술 깨고, 맨정신으로 다시 적었더니, 킬포가 사라졌네요. ^^

3 Likes

닷넷 제공 DI 를 단순히 알고나니 편하네 처럼 별생각없이 쓰고 있었는데 이런 이유가 있었군요.
유니티할때 순환참조로 골머리좀 싸멨던 경험이 있어서 공감이되네요 어쩐지 닷넷배우고 그럴일이 별로 없다 싶었어요 ㅎ

3 Likes

의존성 주입이 20년 전에 나온 기술이라니… :rofl:

2 Likes

어느 정도 규모의 프로젝트면 이런 구조가 효율성을 갖게 될까요?
일정 규모 이하에서는 한 모듈에 때려박는게 더 효율적일 것 같다는 생각이… 잘모르는 입장에서는 듭니다
아니면 아묻따 기본으로 갖고 가는게 좋을까요?

1 Like

저도 최근 들어서 의존성 주입으로 WPF 프로젝트를 만들고 있는데.
모든 프로젝트가 해당 구조에서 효율성을 갖는다고 생각합니다.

  1. 작은 프로젝트
    결국 고객이 있는 프로젝트는 수정을 하기 마련입니다.
    프로젝트가 수정에 용이해지기 때문에 효율적입니다.

  2. 큰 프로젝트
    무조건 이득입니다.

  3. 혼자 프로젝트
    모듈화가 되어있기 때문에 동일 구조로 프로젝트를 만든다면 해당 모듈을 떼서 다른 프로젝트에 붙이기 좋습니다.
    → 개발을 하다보면 모듈들이 버전이 생기고 점점 성능이 향상됩니다.

  4. 협업 프로젝트
    무조건 이득입니다.

  5. 기간이 짧은 프로젝트
    사실 여기엔 좀 애매하긴 하지만. 저는 최대한 구조를 가져가려고 고객에게 유도하는 편입니다. 고객께서 나중에 수정요청을 하고싶어도 구조때문에 안된다. 복잡하다고 하지않고 최대한 맞춰드릴수있게 잘 만들고 있는중이라고 안내하고 있습니다.

어찌보면 구조를 너무 믿는거로 보이긴하는데… 의존성 주입과 모듈화로 인해 달라진 프로젝트를 보면… 강추입니다. 안해보셨다면 꼭 해보시길바랍니다 ㅎㅎ

3 Likes

아무리 모노리식으로 간단하게 구성한다 해도, 최소한 상단 모듈에 계약서를 두지 않는 게 좋습니다.

예를 들면, WPF 앱 프로젝트 하나만 작성하는데, 그 프로젝트에 IRepository 를 선언하는 것이죠.

이러한 구조에서는, 인터페이스의 구현은 결국 WPF 앱 프로젝트에만 넣을 수 있습니다. 원글에서 보여드린 대로, 프로젝트 순환 참조 때문에, 다른 프로젝트에 넣을 수 없기 때문입니다.

1 Like