프로그램의 유연성 향상에 도움이되는 좋은 자료이다.

 

왜 Interface를 이용해 설계를 해야하는지,

 

IoC(Inversion of Control) 컨테이너를 왜 사용하고 어떻게 구성해야하는지 설명하고 있다.

 

[MSDN 매거진 - 응용 프로그램 유연성 향상을 위한 소프트웨어 종속성 제어 바로가기]

 

 

 

Winform의 MVP, WPF의 MVVM 패턴들은 View와 Business Logic 및 Model 간에 결합도를 낮추기 위해 사용한다.

 

결합도를 낮춘다는 것은 유지보수에 매우 유리하다.

 

이와 같은 장점이 있긴하지만 내 생각에는 MVC, MVP, MVVM 등등의 패턴을 사용하는 중요한 이유는 단위테스트 때문이라고 생각한다.

 

 

이번 프로젝트는 Winform에 MVP 패턴을 적용하여 구현하였지만..

 

".NET 예제로 배우는 단위테스트" 라는 책을 읽으며 내가 작성한 MVP 패턴은 잘못된것임을 알았다.

 

물론, View, Presenter, Model의 역할을 분리함으로써 코드의 가독성과 유연성은 증가 되었지만,

 

몇몇 Model의 Singleton 패턴으로 인한 단위 테스트가 어려운 점이 잘못된 설계라고 생각이 든다.

 

개발 완료 후 열심히 단위테스트를 익히는 도중, 아쉽게도 회사의 경영 악화로 인한 신규 프로젝트 중지로 인해

 

다음달 퇴사를 하게 되었다.

 

이번 프로젝트에는 단위 테스트는 꼭 적용하고 싶었는데... 아쉬움이 많다.

 

아직 퇴사하기까지 1달이 남았으니 단위 테스트를 어떻게 적용해야할지 좀 더 고민해봐야겠다.

 

단위 테스트 작성(효과적인 단위테스트)이 익숙해 진 후의 프로젝트는 TDD(Test Driven Development)로 진행하고 싶다.

 

물론, 이직한 회사의 팀장님이 관심이 있어야겠지만...

 

 

경영악화로 인한 퇴사인 만큼 안정적인 회사로 이직하고싶다. 아~ 좋은 자료 올리려다보니 주저리주저리 적었다.

 

아직 나이도 어리고 2년 경력인 만큼 열심히 하다보면 좋은 날이 있지 않을까?

 

 

 

 

 

혹시위의 링크가 잘릴까봐 기사의 내용을 아래에 붙여넣었다.

 

문제가 될시 삭제하겠습니다(__)

 

 

 

응용 프로그램 유연성 향상을 위한 소프트웨어 종속성 제어
James Kovacs


이 기사에서 다루는 내용:
  • 밀접하게 결합된 아키텍처의 문제점
  • 테스트 및 종속성 문제
  • 종속성 반전
  • 종속성 주입
이 기사에서 사용하는 기술:
.NET Framework
코드 다운로드 위치: DependencyInjection2008_03.exe (5408 KB)
Browse the Code Online

느슨하게 결합된 설계를 위해 애쓰는 것을 바람직하다고 생각하는 사람은 거의 없을 것입니다. 그런데 안타깝게도 일반적으로 설계되는 소프트웨어들은 원래의 의도보다 훨씬 더 밀접하게 결합되어 있습니다. 설계가 밀접하게 결합되어 있는지 여부는 어떻게 확인할까요? NDepend와 같은 정적 분석 도구로 종속성을 분석할 수도 있지만 응용 프로그램의 결합 상태를 알아보는 가장 쉬운 방법은 클래스 중 하나를 격리된 상태로 인스턴스화하는 것입니다.
비즈니스 계층에서 InvoiceService와 같은 클래스를 하나 선택하고 해당 코드를 새 콘솔 프로젝트에 복사합니다. 이 코드를 컴파일합니다. 아마 Invoice, InvoiceValidator 등의 일부 종속성이 손실될 것입니다. 해당 클래스를 콘솔 프로젝트에 복사한 다음 다시 컴파일합니다. 이번에는 다른 손실된 클래스가 발견될 것입니다. 결국 코드베이스의 상당 부분을 새 프로젝트에 추가해야 컴파일할 수 있게 됩니다. 마치 풀린 실 한 가닥을 잡아당기면 스웨터 한 벌이 다 풀려버리는 것과 같습니다. 설계에서 모든 클래스는 다른 모든 클래스와 직간접적으로 결합되어 있습니다. 이러한 시스템에서는 클래스를 하나만 변경해도 나머지 시스템 전체에 영향을 미치기 때문에 시스템을 변경하기가 매우 어렵습니다.
요점은 결합을 완전히 방지하자는 것이 아니며, 그렇게 할 수도 없습니다. 예를 들어 보겠습니다.
string name = "James Kovacs";
이 예에서는 코드를 Microsoft® .NET Framework의 System.String 클래스와 결합했습니다. 이러한 결합이 잘못된 것일까요? 아니라고 생각합니다. System.String 클래스가 바람직하지 않은 방식으로 변경될 가능성은 극히 미미하고, System.String과 상호 작용하는 방식을 수정해야 할만큼 요구 사항이 변경될 가능성도 마찬가지로 낮습니다. 따라서 이 결합에서 문제로 생각되는 부분은 없습니다. 여기서 이야기하고자 하는 요지는 결합을 제거하자는 것이 아니라 신중을 기해 현명하게 결합을 선택해야 한다는 것입니다.
많은 응용 프로그램의 데이터 계층에 자주 사용되는 코드 예를 하나 더 살펴보겠습니다.
SqlConnection conn = new SqlConnection(connectionString);
또는 다음과 같은 예도 가능합니다.
XmlDocument settings = new XmlDocument();
settings.Load("Settings.xml");
데이터 계층이 SQL Server®하고만 통신할 것이라고, 또는 항상 Settings.xml이라는 XML 문서에서만 응용 프로그램 설정을 로드할 것이라고 얼만큼 확신할 수 있습니까? 우리는 지금 무한하게 확장할 수 있지만 엄청나게 복잡하고 실용성이 없는 제네릭 프레임워크를 구축하려는 것이 아닙니다. 논점은 가역성(reversibility)입니다. 결정된 설계에 대한 생각을 얼만큼 쉽게 바꿀 수 있습니까? 변경에 잘 대응하는 응용 프로그램 아키텍처를 보유하고 있습니까?
필자가 이렇게 변경에 신경을 쓰는 이유는 무엇일까요? 사실상 이 업계에서 변하지 않는 유일한 것이 바로 변화 그 자체이기 때문입니다. 요구 사항이 변하고, 기술이 변하고, 개발자가 변하고, 비즈니스가 변합니다. 여러분은 이러한 변화에 대응해야 하는 위치에 있습니까? 느슨하게 결합된 설계를 만들면 소프트웨어는 필연적인, 그리고 많은 경우 예고 없이 발생하는 변화에 보다 효과적으로 대응할 수 있습니다.


내부 종속성 문제
일반적인 계층형 응용 프로그램 아키텍처에서 볼 수 있는 결합도가 매우 높은 전형적인 설계를 살펴보도록 하겠습니다(그림 1 참조). 간단한 계층 구조에서는 UI 계층이 서비스(또는 비즈니스) 계층과 통신하고, 이 서비스 계층이 리포지토리(또는 데이터) 계층과 통신합니다. 이러한 계층 간의 종속성은 동일하게 아래로 흐릅니다. 따라서 리포지토리 계층은 서비스 계층을 인식하지 못하고 서비스 계층은 UI 계층을 인식하지 못합니다.
그림 1 일반적인 계층형 아키텍처 
프레젠테이션 계층이나 워크플로 계층과 같이 몇 개의 계층이 더 있기도 하지만 계층에서 하위 계층만 인식하는 패턴에는 변함이 없습니다. 계층을 일관된 책임 클러스터로 구현하는 것은 바람직한 설계 기법입니다. 하지만 상위 계층을 하위 계층과 직접 결합하면 결합이 증가하고 응용 프로그램 테스트가 어려워집니다.
테스트 용이성에 대해서는 왜 신경을 써야 할까요? 테스트 용이성은 결합에 대한 좋은 지표이기 때문입니다. 테스트에서 클래스를 손쉽게 인스턴스화할 수 없다면 결합 문제가 있는 것입니다. 에를 들어 서비스 계층은 리포지토리 계층에 깊숙히 연관되며 종속적입니다. 서비스 계층은 리포지토리 계층과 분리해서 테스트할 수 없습니다. 실질적으로 이는 대부분의 테스트에서 기본 데이터베이스, 파일 시스템 또는 네트워크에 액세스함을 의미합니다. 결과적으로 낮은 테스트 속도, 높은 유지 관리 비용과 같은 다양한 문제가 발생합니다.
느린 테스트 테스트를 엄격하게 메모리 내에서만 실행할 수 있다면 테스트당 시간은 밀리초 범위가 됩니다. 테스트에서 데이터베이스, 파일 시스템, 네트워크 등의 외부 리소스에 액세스하는 경우 테스트당 시간은 대부분 100밀리초 이상으로 늘어납니다. 면밀한 테스트를 거치는 일반적인 프로젝트의 경우 수백 또는 수천 번의 테스트가 실행된다는 점을 고려하면 이는 테스트 시간이 몇 초냐, 아니면 몇 분 또는 몇 시간이냐의 차이를 의미합니다.
잘못된 오류 격리 데이터 계층 구성 요소의 오류는 많은 경우 상위 계층 구성 요소의 테스트 실패로 이어집니다. 몇 개의 테스트만 실패한다면 문제를 간단히 해결할 수 있겠지만, 그보다는 수백 개의 테스트가 실패할 가능성이 높으므로 문제를 찾아내기도 어렵고 시간도 많이 걸리게 됩니다.
높은 유지 관리 비용 대부분의 테스트에는 일종의 초기 데이터가 필요합니다. 이러한 테스트에서 데이터베이스에 액세스하는 경우 각 테스트 전에 데이터베이스가 알려진 상태인지 확인해야 합니다. 또한 각 테스트의 초기 데이터가 다른 테스트의 초기 데이터에 대해 독립적인지 확인하지 않으면 테스트 순서 문제가 발생하여 순서에 맞지 않게 실행할 경우 특정 테스트가 실패할 수 있습니다. 데이터베이스를 알려진 정상 상태로 유지 관리하는 작업에는 많은 시간이 소모되며 오류가 발생하기도 쉽습니다.
또한 하위 계층의 구현을 변경해야 할 경우 하위 계층에 대한 암시적/명시적 종속성으로 인해 상위 계층까지 변경해야 하는 경우가 많습니다. 응용 프로그램을 계층화했지만 느슨한 결합은 달성하지 못한 것입니다.
구체적인 예로, 송장을 받는 서비스를 살펴보겠습니다(그림 2 참조). InvoiceService.Submit는 송장 전송을 수락하기 위해 클래스의 생성자에 의해 만들어지는 AuthorizationService, InvoiceValidator 및 InvoiceRepository에 의존합니다. 이러한 구체적인 종속성 없이 InvoiceService에 대해 단위 테스트를 실행할 수는 없습니다. 즉, 단위 테스트를 실행하기 전에 InvoiceRepository가 새 송장을 삽입할 때 데이터베이스에서 기본 키 또는 고유 키 위반이 발생하거나 InvoiceValidator가 유효성 검사 실패를 보고하지 않도록 데이터베이스 상태를 확인해야 합니다. 또한 AuthorizationService가 "전송" 작업을 허용하도록 단위 테스트를 실행하는 사용자에게 올바른 사용 권한이 있는지도 확인해야 합니다.
public class InvoiceService {
  private readonly AuthorizationService authoriazationService;
  private readonly InvoiceValidator invoiceValidator;
  private readonly InvoiceRepository invoiceRepository;
 
  public InvoiceService() {
    authoriazationService = new AuthorizationService();
    invoiceValidator = new InvoiceValidator();
    invoiceRepository = new InvoiceRepository();
  }
 
  public ValidationResults Submit(Invoice invoice) {
    ValidationResults results;
    CheckPermissions(invoice, InvoiceAction.Submit);
    results = ValidateInvoice(invoice);
    SaveInvoice(invoice);
    return results;
  }
 
  private void CheckPermissions(Invoice invoice, InvoiceAction action) {
    if(authoriazationService.IsActionAllowed(invoice, action) == false) {
      throw new SecurityException(
        "Insufficient permissions to submit this invoice");
    }
  }
 
  private ValidationResults ValidateInvoice(Invoice invoice) {
    return invoiceValidator.Validate(invoice);
  }
 
  private void SaveInvoice(Invoice invoice) {
    invoiceRepository.Save(invoice);
  }
}

어려운 일이지요. 코드 오류든 데이터 오류든 이러한 종속 구성 요소 중에서 문제가 발생하면 InvoiceService 테스트가 예기치 않게 실패합니다. 테스트에 통과하더라도 데이터베이스에 올바른 데이터를 설정하고 테스트를 실행하고 테스트 시에 생성된 데이터를 정리하기까지 총 실행 시간은 수백 밀리초가 됩니다. 테스트를 일괄 처리로 그룹화한 후 일괄 처리 전후에 스크립트를 실행하는 방법으로 설정 및 정리에 소요되는 비용을 상쇄하더라도 여전히 메모리 내에서 테스트를 실행할 때보다 훨씬 더 많은 시간이 걸립니다.
더 모호한 문제도 있습니다. 예를 들어 InvoiceRepository에 감사 지원을 추가하려면 AuditingInvoiceRepository를 만들거나 InvoiceRepository 자체를 수정할 수밖에 없습니다. InvoiceService와 하위 구성 요소 간의 결합 때문에 시스템에 새 기능을 도입할 때 취할 수 있는 방법이 많지 않습니다.


종속성 반전
다음과 같이 구체적인 클래스가 아니라 인터페이스를 통해 상호 작용하면 하위 구성 요소 종속성으로부터 상위 구성 요소 InvoiceService를 분리할 수 있습니다.
public class InvoiceService : IInvoiceService {
    private readonly IAuthorizationService authService;
    private readonly IInvoiceValidator invoiceValidator;
    private readonly IInvoiceRepository invoiceRepository;
    ...
}
인터페이스 또는 추상 기반 클래스를 사용하도록 하는 이 간단한 변경은 모든 종속성에 대해 대체 구현을 사용할 수 있음을 의미합니다. InvoiceRepository 대신 AuditingInvoiceRepository를 만들 수 있습니다(AuditingInvoiceRepository가 IInvoiceRepository를 구현한다는 가정 하에). 또한 이는 테스트 시에 Fake나 Mock를 대체할 수 있음을 의미합니다. 이 설계 기법을 계약으로의 프로그래밍(programming to contract)이라고 합니다.
필자가 상위 구성 요소와 하위 구성 요소의 분리에 적용하는 원칙은 종속성 반전 원칙입니다. Robert C. Martin이 이 주제에 대한 자신의 칼럼(objectmentor.com/resources/articles/dip.pdf)에서 설명했듯이 "상위 모듈은 하위 모듈에 종속되어서는 안 되고, 두 가지 모두 추상화에 의존해야 합니다."
이 경우 InvoiceService와 InvoiceRepository는 이제 IInvoiceRepository가 제공하는 추상화에 의존합니다. 그러나 문제가 완전히 해결된 것이 아니라 다른 곳으로 이동했을 뿐입니다. 구체적인 구현은 인터페이스에만 의존하지만 구체적인 클래스가 서로를 어떻게 "찾느냐"의 문제가 남아 있습니다.
InvoiceService에는 여전히 종속성의 구체적인 구현이 필요합니다. 단순히 이러한 종속성을 InvoiceService의 생성자에서 인스턴스화할 수 있지만 전보다 별로 나아질 것이 없습니다. AuditingInvoiceRepository를 사용하려면 여전히 InvoiceService를 수정하여 AuditingInvoiceRepository를 인스턴스화해야 합니다. 또한 AuditingInvoiceRepository를 대신 인스턴스화하려면 IInvoiceRepository에 의존하는 모든 클래스를 수정해야 합니다. 전역에 걸쳐 InvoiceRepository를 AuditingInvoiceRepository로 교체하는 손쉬운 방법은 없습니다.
한 가지 해결책은 팩토리를 사용하여 IInvoiceRepository 인스턴스를 만드는 것입니다. 이렇게 하면 중앙 위치에서 팩토리 메서드만 바꾸어 AuditingInvoiceRepository로 전환할 수 있습니다. 이 기법은 서비스 로케이션이라고도 하며, 인스턴스를 관리하는 팩토리 클래스를 서비스 로케이터라고 합니다.
public InvoiceService() {
  this.authorizationService = 
    ServiceLocator.Find<IAuthorizationService>();
  this.invoiceValidator = ServiceLocator.Find<IInvoiceValidator>();
  this.invoiceRepository = ServiceLocator.Find<IInvoiceRepository>();
} 
ServiceLocator 내의 기능은 구성 파일 또는 데이터베이스에서 읽은 데이터를 기반으로 하거나 코드에 직접 연결될 수 있습니다. 어떤 방법을 사용하든 종속성에 대한 중앙 집중식 개체 생성이 가능합니다.
격리된 구성 요소에 대한 단위 테스트는 실제 구현 개체 대신 Fake 또는 Mock 개체를 사용하여 서비스 로케이터를 구성하는 방법으로 실행할 수 있습니다. 예를 들어 테스트 중에 ServiceLocator.Find<IInvoiceRepository>는 FakeInvoiceRepository를 반환할 수 있습니다. FakeInvoiceRepository는 저장 시 송장에 알려진 기본 키를 할당하지만 실제로 송장을 데이터베이스에 저장하지는 않습니다. 복잡한 데이터베이스 설치 및 해체를 없애고 Fake 종속성으로부터 알려진 데이터를 반환할 수 있습니다. 자세한 내용은 "종속성 가장은 현명한 방법인가?" 보충 기사를 참조하십시오.
그러나 서비스 로케이션에는 몇 가지 단점도 있습니다. 우선, 종속성이 상위 클래스에 숨겨져 있습니다. 공개 서명을 통해서는 InvoiceService가 AuthorizationService, InvoiceValidator, InvoiceRepository 중 어디에 종속되는지 알 수 없고, 확인하려면 코드를 검사하는 수밖에 없습니다.
같은 인터페이스에 대해 다른 구체적 형식을 제공해야 하는 경우 오버로드된 Find 메서드에 의지해야 합니다. 이를 위해서는 팩토리 클래스를 구현할 때 대체 형식이 필요한지 여부를 결정해야 합니다. 예를 들어 특정 IInvoiceRepository 요청을 위해 배포 시에 AuditingInvoiceRepository를 대체하도록 ServiceLocator를 다시 구성할 수 없습니다. 하지만 이러한 단점에도 불구하고 서비스 로케이션은 이해하기 쉽고 종속성을 하드 코딩하는 것보다 나은 방법입니다.


종속성 주입
종속성 가장은 현명한 방법인가?
종속성을 가장하는 데 따른 위험성이 없는지 궁금할 것입니다. 가양성 결과가 발생하지는 않을까요? 실제 InvoiceRepository와 같은 종속성이 올바르게 작동하는지를 확인하는 테스트가 이미 있어야 합니다. 이러한 테스트에서는 실제 데이터베이스와 통신하여 InvoiceRepository가 올바르게 작동하는지 확인해야 합니다.
InvoiceRepository.Save가 제대로 작동하고 있음을 아는 상태에서 InvoiceRepository에 종속적인 모든 테스트 항목에 대해 일일이 다시 테스트하는 이유는 무엇일까요? 데이터베이스에 연결함에 따라 상위 테스트 속도가 느려지고, InvoiceRepository에 문제가 있다면 InvoiceRepository 테스트는 물론 InvoiceService 테스트를 비롯하여 InvoiceRepository에 종속적인 다른 모든 구성 요소의 테스트까지 실패하게 됩니다.
InvoiceService 테스트는 실패했지만 InvoiceRepository 테스트는 성공했다면 InvoiceRepository에 대한 테스트가 누락된 것입니다. 이러한 종류의 결함은 구성 요소를 구체적인 종속성과 함께 테스트하는 통합 테스트를 통해 보다 효과적으로 찾을 수 있습니다. 이러한 방법은 속도는 더 느리지만 Fake/Mock 종속성을 사용하는 단위 테스트보다 실행 횟수는 대부분 더 적습니다.
이제 단위 테스트를 통과했으므로 InvoiceRepository가 작동하는 것으로 가정하면 두 가지 중 하나를 선택할 수 있습니다. 첫째, 데이터베이스의 데이터가 올바른지 확인하는 복잡한 스크립트를 작성 및 유지 관리하여 InvoiceRepository가 각 InvoiceService 테스트에서 예상된 데이터를 반환하도록 할 수 있습니다. 둘째, 예상된 데이터를 반환하는 Fake 또는 Mock InvoiceRepository 구현을 만들 수 있습니다. 두 번째 방법이 훨씬 더 쉽고 실제 환경에서 잘 작동합니다.

상위 구성 요소를 단위 테스트할 때는 종속성에 대한 Fake 또는 Mock 구현을 제공해야 합니다. 그러나 Fake 또는 Mock를 사용하여 서비스 로케이터를 구성한 후 상위 구성 요소가 이를 조회하도록 하는 대신 매개 변수화된 생성자를 통해 상위 구성 요소에 종속성을 직접 전달할 수 있습니다. 이 기법을 종속성 주입이라고 합니다. 그림 3에서 예를 볼 수 있습니다.
[Test]
public void CanSubmitNewInvoice() {
  Invoice invoice = new Invoice();
  ValidationResults validationResults = new ValidationResults();
  IAuthorizationService authorizationService = 
    mockery.CreateMock<IAuthorizationService>();
  IInvoiceValidator invoiceValidator = 
    mockery.CreateMock<IInvoiceValidator>();
  IInvoiceRepository invoiceRepository = 
    mockery.CreateMock<IInvoiceRepository>();
 
  using(mockery.Record()) {
    Expect.Call(authorizationService.IsActionAllowed(
      invoice, InvoiceAction.Submit)).Return(true);

    Expect.Call(invoiceValidator.Validate(invoice))
      .Return(validationResults);
    invoiceRepository.Save(invoice);
  }
 
  using(mockery.Playback()) {
    IInvoiceService service = new InvoiceService(authorizationService, 
      invoiceValidator, invoiceRepository);
    service.Submit(invoice);
  }
}

이 예에서는 InvoiceService의 종속성에 대한 Mock 개체를 만든 후 이를 InvoiceService 생성자로 전달합니다. Mock 개체 프레임워크에 대한 자세한 내용은 Mark Seemann의 "Unit Testing: Test Double의 연속성 살펴보기"(msdn.microsoft.com/msdnmag/issues/07/09/MockTesting)를 참조하십시오. 요약하자면, 테스트 실행 후에 InvoiceService의 상태를 확인하는 대신 InvoiceService가 Mock와 상호 작용하는 방식을 정의함으로써 InvoiceService의 동작을 지정합니다.
종속성 주입을 사용하면 단위 테스트에 상위 구성 요소를 종속성과 함께 손쉽게 제공할 수 있습니다. 그러나 단위 테스트 외부, 즉 응용 프로그램 실행 중이나 통합 테스트에서 클래스의 종속성을 어떻게 찾을 것인지의 문제가 여전히 남습니다. UI 계층에서 서비스 계층을 종속성과 함께 제공하거나, 서비스 계층에서 리포지토리 계층을 종속성과 함께 제공할 것으로 기대하는 것은 어리석인 일입니다. 처음보다 훨씬 더 심각한 문제가 발생할 수 있습니다. 하지만 다음과 같이 UI 계층이 종속성과 함께 서비스 계층을 제공하는 역할을 한다고 가정해 보겠습니다.
// Somewhere in UI Layer
InvoiceSubmissionPresenter presenter = 
  new InvoiceSubmissionPresenter(
    new InvoiceService(
      new AuthorizationService(), 
      new InvoiceValidator(), 
      new InvoiceRepository()));
여기에서 볼 수 있듯이 UI가 자체의 종속성뿐만 아니라 데이터 계층에 이를 때까지 모든 종속성의 종속성까지 인식해야 합니다. 이는 물론 바람직한 시나리오가 아닙니다. 이러한 딜레마를 해결하는 가장 손쉬운 방법은 경제적인 종속성 주입이라는 기법을 활용하는 방법입니다.
경제적인 종속성 주입에서는 상위 구성 요소의 기본 생성자를 사용하여 종속성을 제공합니다.
public InvoiceService() :
  this(new AuthorizationService(), 
    new InvoiceValidator(), 
    new InvoiceRepository()) { }
가장 많이 오버로드된 생성자에 위임됨을 볼 수 있습니다. 이렇게 하면 인스턴스를 만드는 데 사용된 생성자에 관계없이 클래스의 초기화 논리가 동일하게 유지됩니다. 클래스는 기본 생성자를 통해서만 구체적인 종속성과 결합됩니다. 단위 테스트 중에 클래스의 종속성을 제공할 수 있게 해 주는 오버로드된 생성자가 있으므로 클래스는 테스트 가능 상태로 유지됩니다.


컨테이너
이제 종속성을 중앙에서 관리할 수 있는 IoC(제어 반전) 컨테이너에 대해 살펴보겠습니다. 실제 환경에서 컨테이너는 인터페이스 대 형식 구현의 복잡한 사전에 불과합니다. 가장 간단한 형태로 보면 IoC 컨테이너는 다른 이름의 서비스 로케이터일 뿐입니다. 서비스 로케이션 외에 컨테이너의 다른 여러 가지 역할에 대해서는 나중에 설명하도록 하겠습니다.
원래의 문제로 돌아와, InvoiceService를 해당 종속성의 구체적인 구현과 완전히 분리해야 합니다. 모든 소프트웨어 문제가 그렇듯이 이 문제 역시 다른 간접 계층을 추가하여 해결할 수 있습니다. 인터페이스를 구체적인 구현에 매핑하는 종속성 확인자 개념을 도입하는 것입니다. 그런 다음 인터페이스 T를 받아 해당 인터페이스를 구현하는 형식을 반환하는 제네릭 메서드를 사용합니다.
public interface IDependencyResolver {
    T Resolve<T>();
}
사전을 사용하여 인터페이스와 해당 인터페이스를 구현하는 개체 간의 매핑 정보를 저장하는 SimpleDependencyResolver를 구현해 보겠습니다. 처음에 사전을 채울 방법이 필요한데, 여기에는 Register<T>(object obj) 메서드가 사용됩니다(그림 4 참조). SimpleDependencyResolver의 작성자만 종속성을 등록하므로 Register 메서드는 IDependencyResolver 인터페이스에 있을 필요가 없습니다. 일반적으로 이 작업은 응용 프로그램 시작 시에 Main 메서드에서 호출되는 도우미 클래스에 의해 이루어집니다.
public class SimpleDependencyResolver : IDependencyResolver 
{
  private readonly Dictionary<Type, object> m_Types = 
    new Dictionary<Type, object>();
    
  public T Resolve<T>() {
    return (T)m_Types[typeof(T)];
  }

  public void Register<T>(object obj) {
    if(obj is T == false) {
      throw new InvalidOperationException(
        string.Format("The supplied instance does not implement {0}",
        typeof(T).FullName));
    }
    m_Types.Add(typeof(T), obj);            
  }
}

CompanyService는 어떻게 SimpleDependencyResolver를 찾아 해당 종속성을 찾을 수 있도록 할까요? 필요로 하는 모든 클래스에 IDependencyResolver를 전달할 수도 있지만 이렇게 하면 작업 부담이 금방 커집니다. 가장 손쉬운 해결책은 구성된 SimpleDependencyResolver 인스턴스를 전역에서 액세스 가능한 위치에 넣는 방법입니다. 이는 정적 게이트웨이 패턴을 사용하여 수행할 수 있습니다. 단일 항목 패턴을 사용할 수도 있지만 단일 항목은 테스트하기가 너무 어렵습니다. 본질적으로 전역 변수와 다를 바 없는 단일 항목은 테스트하기 어려운 밀접하게 결합된 코드가 발생하는 가장 큰 이유 중 하나입니다. 가능하면 사용하지 마십시오.
정적 게이트웨이에 대해 살펴보겠습니다. 정적 게이트웨이는 IoC로 칭하겠습니다. DependencyResolver라고 할 수도 있지만 IoC가 더 짧으니까요. IoC의 정적 메서드는 IDependencyResolver의 메서드와 일치합니다. 정적 클래스는 인터페이스를 구현할 수 없으므로 IoC는 IDependencyResolver를 구현하지 않습니다. 실제 IDependencyResolver를 받는 Initialize 메서드도 있습니다. IoC 정적 게이트웨이는 구성된 IDependencyResolver로 모든 Resolve<T> 요청을 전달하는 역할만 합니다.
public class IoC {
  private static IDependencyResolver s_Inner;

  public static void Initialize(IDependencyResolver resolver) {
    s_Inner = resolver;
  }

  public static T Resolve<T>() {
    return s_Inner.Resolve<T>();
  }
}
응용 프로그램을 시작하는 동안 구성된 SimpleDependencyResolver를 사용하여 IoC를 초기화합니다. 이제 기본 생성자에서 경제적인 종속성 주입을 IoC.Resolve로 대체할 수 있습니다.
public InvoiceService() :
  this(IoC.Resolve<IAuthorizationService>(), 
  IoC.Resolve<IInvoiceValidator>(), 
  IoC.Resolve<IInvoiceRepository>()) { }
내부 IDependencyResolver는 응용 프로그램 시작 후에 읽히기만 하고 업데이트되지는 않으므로 이 개체에 대한 액세스를 동기화할 필요는 없습니다.
IoC 클래스는 추가적인 장점도 제공합니다. 즉, 응용 프로그램에서 손상 방지 계층 역할을 합니다. 다른 IoC 컨테이너를 사용하려면 IDependencyResolver를 구현하는 어댑터만 구현하면 됩니다. 응용 프로그램 전반에 걸쳐 IoC가 광범위하게 사용되지만 특정 컨테이너에 결합되지는 않습니다.


완전한 IoC 컨테이너
SimpleDependencyResolver와 같은 단순한 IoC 컨테이너를 사용하여 느슨하게 결합된 구성 요소를 서로 연결할 수 있습니다. 그러나 이렇게 하면 완전한 IoC 컨테이너가 제공하는 다음과 같은 여러 가지 기능을 사용할 수 없습니다.
  • XML, 코드 또는 스크립트와 같은 폭넓은 구성 옵션
  • 단일 항목, 임시, 스레드별, 풀링 등의 수명 관리
  • 종속성 자동 연결
  • 새 기능을 연결하는 기능
이러한 각 기능을 자세히 살펴보도록 하겠습니다. 널리 사용되는 공개 소스 IoC인 Castle Windsor를 구체적인 예로 사용하겠습니다. 많은 컨테이너는 외부 XML 파일을 통해 구현이 가능합니다. 예를 들어 Windsor는 다음과 같이 구성할 수 있습니다.
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <components>
    <component id="Foo"
      service="JamesKovacs.IoCArticle.IFoo, JamesKovacs.IoCArticle"
      type="JamesKovacs.IoCArticle.Foo, JamesKovacs.IoCArticle"/>
  </components>
</configuration>
XML 구성의 경우 변경 내용을 적용하려면 응용 프로그램을 다시 시작해야 하는 경우가 많지만 응용 프로그램을 다시 컴파일하지 않고도 수정할 수 있다는 장점이 있습니다. 물론 XML 구성은 복잡해지기 쉽고, 런타임까지 오류를 감지하지 못하며, 제네릭 형식이 익숙한 C# 제네릭 표기법 대신 CLR의 백틱(backtick) 표기법을 사용하여 선언된다는 단점도 있습니다(Company.Application.IValidatorOf<Invoice>가 Company.Application.IValidatorOf`1[[Company.Application.Invoice, Company.Application]], Company.Application으로 작성됨).
XML 외에 C#이나 다른 Microsoft .NET Framework 호환 언어를 사용하여 Windsor를 구성할 수 있습니다. 구성 코드를 별도의 어셈블리에 격리하면 구성 어셈블리를 다시 컴파일하고 응용 프로그램을 다시 시작하기만 하면 구성을 변경할 수 있습니다.
Windsor 구성은 Binsor를 사용하여 스크립팅할 수 있습니다. Binsor는 Windsor 구성을 위해 특별히 제작된 DSL(Domain-Specific Language)입니다. Binsor를 사용하면 구성 파일을 Boo로 작성할 수 있습니다. Boo는 정적 형식 CLR 언어로, 언어와 컴파일러의 확장성에 중점을 두었기 때문에 DSL 작성에 적합합니다. Binsor에서는 앞의 XML 구성 파일을 다음과 같이 다시 작성할 수 있습니다.
import JamesKovacs.IoCArticle
Component("Foo", IFoo, Foo)
Boo가 완전한 프로그래밍 언어이고, 따라서 XML 기반 구성과 마찬가지로 구성 요소 등록을 수동으로 추가할 필요 없이 Binsor를 사용하여 Windsor에 형식을 자동으로 등록할 수 있다는 사실을 알고 나면 더욱 흥미롭습니다.
import System.Reflection
serviceAssembly = Assembly.Load("JamesKovacs.IoCArticle.IoCContainer")
for type in serviceAssembly.GetTypes():  
  continue if type.IsInterface or type.IsAbstract or 
    type.GetInterfaces().Length == 0
  Component(type.FullName, type.GetInterfaces()[0], type)
Boo에 익숙하지 않더라도 코드의 목적은 쉽게 이해할 수 있습니다. JamesKovacs.IoCArticle.Services 네임스페이스에 새 서비스를 추가하기만 하면 해당 서비스가 서비스 인터페이스에 대한 기본 구현으로 자동 등록됩니다. 다음과 같은 클래스를 만든다고 가정해 보겠습니다.
public class AuthorizationService : IAuthorizationService {
   ...
}
다른 클래스에서 IAuthorizationService를 생성자의 매개 변수로 포함하여 IAuthorizationService에 대한 종속성을 선언할 경우 구성 파일에 해당 종속성을 명시적으로 지정하지 않아도 Binsor가 자동으로 연결합니다. Binsor에 대한 자세한 내용은 ayende.com/Blog/category/451.aspx를, Boo에 대한 자세한 내용은 boo.codehaus.org를 참조하십시오.


수명 관리
SimpleDependencyResolver는 항상 인터페이스에 대해 등록된 동일한 인스턴스를 반환하며, 이로써 해당 인스턴스는 사실상 단일 항목이 됩니다. 인스턴스가 아니라 구체적인 형식을 등록하도록 SimpleDependencyResolver를 수정할 수 있습니다. 그러면 다른 팩토리를 사용하여 구체적인 형식의 인스턴스를 만들 수 있습니다. 단일 항목 팩토리는 항상 동일한 인스턴스를 반환하고 임시 팩토리는 항상 새 인스턴스를 반환합니다. 스레드별 팩토리는 요청 스레드당 하나의 인스턴스를 유지합니다.
인스턴스 전략은 상상력만 있다면 무궁무진합니다. 이러한 부분이 바로 Windsor가 제공하는 것입니다. XML 구성 파일에 특성을 적용하면 특정한 구체적인 형식의 인스턴스를 만드는 데 사용되는 팩토리 형식을 변경할 수 있습니다. 기본적으로 Windsor는 단일 항목 인스턴스를 사용합니다. 컨테이너에서 IFoo가 요청될 때마다 새 Foo를 반환하려면 구성을 다음과 같이 변경하기만 하면 됩니다.
<component id="Foo"
  service="JamesKovacs.IoCArticle.IFoo, JamesKovacs.IoCArticle"
  type="JamesKovacs.IoCArticle.Foo, JamesKovacs.IoCArticle"
  lifestyle="transient"/>


종속성 자동 연결
종속성 자동 연결은 컨테이너가 요청된 형식의 종속성을 확인하고 개발자가 기본 생성자를 제공하지 않아도 해당 종속성을 자동으로 만드는 것을 의미합니다.
public InvoiceService(IAuthorizationService authorizationService,
  IInvoiceValidator invoiceValidator, 
  IInvoiceRepository invoiceRepository) {
  ...
}
클라이언트가 컨테이너에서 IInvoiceService를 요청하면 컨테이너는 구체적인 형식에는 IAuthorizationService, IInvoiceValidator 및 IInvoiceRepository의 구체적인 구현이 필요하다는 점을 인식합니다. 컨테이너는 적절한 구체적 형식을 조회하여 이러한 형식에 있는 종속성을 해결하고 형식을 생성합니다. 그런 다음 이러한 종속성을 사용하여 InvoiceService를 만듭니다. 자동 연결은 기본 생성자를 유지 관리할 필요성을 제거하므로 코드가 단순화되고 IoC 정적 게이트웨이에 대한 여러 클래스의 종속성이 제거됩니다.
구체적인 구현 대신 계약으로 코딩하고 컨테이너를 사용하면 아키텍처의 유연성과 변경에 대한 적응성을 크게 높일 수 있습니다. InvoiceRepository에 대해 구성 가능한 감사 로깅을 구현하려면 어떻게 해야 할까요? 밀접하게 결합된 아키텍처에서는 InvoiceRepository를 수정해야 합니다. 또한 감사 로깅의 사용 여부를 지정하기 위해 몇 가지 응용 프로그램 구성도 설정해야 합니다.
느슨하게 결합된 아키텍처에서는 더 나은 방법이 있을까요? IInvoiceRepository를 구현하는 AuditingInvoiceRepositoryAuditor를 구현할 수 있습니다. 이 감사자는 감사 기능만 구현하고 생성자에 제공되는 실제 InvoiceRepository로 위임합니다. 이러한 패턴을 데코레이터라고 합니다(그림 5 참조).
public class AuditingInvoiceRepository : IInvoiceRepository {
  private readonly IInvoiceRepository invoiceRepository;
  private readonly IAuditWriter auditWriter;

  public AuditingInvoiceRepository(IInvoiceRepository invoiceRepository, 
    IAuditWriter auditWriter) {
    this.invoiceRepository = invoiceRepository;
    this.auditWriter = auditWriter;
  }

  public void Save(Invoice invoice) {
    auditWriter.WriteEntry("Invoice was written by a user.");
    invoiceRepository.Save(invoice);
  }
}

감사를 활성화하려면 IInvoiceRepository에 대한 요청을 받을 때 AuditingInvoiceRepository가 지정된 InvoiceRepository를 반환하도록 컨테이너를 구성합니다. 클라이언트는 여전히 IInvoiceRepository와 상호 작용하므로 이에 대해 알지 못합니다. 이 방식에는 다음과 같은 여러 이점이 있습니다.
  1. InvoiceRepository가 수정되지 않으므로 코드가 손상될 염려가 없습니다.
  2. AuditingInvoiceRepository를 InvoiceRepository에 독립적으로 구현하고 테스트할 수 있습니다. 따라서 실제 데이터베이스가 있는지 여부에 관계없이 감사가 정상적으로 이루어집니다.
  3. InvoiceRepository의 복잡성을 높이지 않고 감사, 보안, 캐싱 등의 용도로 여러 데코레이터를 작성할 수 있습니다. 즉, 느슨하게 결합된 시스템의 데코레이터 방식은 새 기능을 추가할 때 그 확장성이 부각됩니다.
  4. 컨테이너가 유용한 응용 프로그램 확장 메커니즘을 제공합니다. AuditingInvoiceRepository를 InvoiceRepository 또는 IInvoiceRepository와 동일한 어셈블리에 구현할 필요가 없고, 구성 파일에서 참조되는 타사 어셈블리에 쉽게 구현할 수 있습니다.


변화를 위한 느슨한 결합
소프트웨어 아키텍처가 계층화된 경우에도 계층이 서로 밀접하게 결합되어 있으면 응용 프로그램 테스트나 평가에 방해가 될 수 있습니다. 그러나 설계를 분리할 수 있습니다. 종속성 반전과 종속성 주입을 사용하면 구체적인 구현이 아니라 계약으로 코딩하는 데 따른 이점을 얻을 수 있습니다. 컨트롤 컨테이너의 반전 개념을 도입하면 아키텍처의 유연성을 높일 수 있습니다. 결과적으로 여러분의 느슨하게 결합된 설계는 변화에 보다 잘 대응할 수 있게 됩니다.


James Kovacs는 다재다능한 독립 설계자, 개발자, 강사로 .NET Framework를 활용한 민첩한 개발 분야의 전문가이며 알베르타주 캘거리에 거주하고 있습니다. 또한 Solutions Architecture의 Microsoft MVP이기도 한 그는 하버드 대학에서 박사 학위를 취득했습니다. 문의 사항이 있으면 jkovacs@post.harvard.edu 또는 www.jameskovacs.com을 통해 연락하시기 바랍니다.

 

 

 

 

+ Recent posts