의존관계 역전과 의존성 주입

Posted on Tuesday, 26 Mar 2019csharp oop study wpf 

이 포스트에서는 WPF를 사용하여 개인 프로젝트를 개발하는 과정에서 배운 의존관계 역전 원칙(Dependency Inversion Principle)과 의존성 주입(Dependency Injection) 패턴에 대해 정리합니다.

WPF 앱을 개발할 때는 보통 MVVM(Model-View-ViewModel) 구조를 사용합니다. Model 레이어에는 앱의 비즈니스 로직과 데이터가 정의되어 있고, View 레이어는 화면에 표시되는 사용자 인터페이스입니다. 마지막으로 ViewModel은 간단히 말하자면 뷰를 위한 모델‌로, 뷰의 특정 영역에 표시될 데이터와 버튼이나 메뉴 등을 클릭했을 때 실행될 명령 등이 이 레이어에 정의되어 있습니다. 대부분의 WPF 앱에서 View는 데이터 바인딩을 통해 ViewModel과 직접 연결되어 있고, ViewModel은 자신의 속성(화면에 표시할 데이터 등)이 변경될 경우 View에 이벤트를 전달하는 식으로 간접적으로 연결되어 있습니다.

Model-View-ViewModel 아키텍처 구조

문제

WPF와 MVVM을 공부하고 앱을 만들어 보는 과정에서 저를 오랫동한 고민하게 만든 문제가 있습니다. 바로 MVVM 아키텍처를 최대한 준수하면서 아래 동작을 구현하는 것이었습니다.

  1. 사용자가 어떤 버튼을 누르면 메시지 박스가 표시된다.
  2. 메시지 박스에서 [확인] 버튼을 누르면 다음 동작을 수행한다.

방법 1: View에서 직접 처리하기

보기에는 간단한 문제 같습니다. 실제로 View(XAML과 code-behind)에 다음과 같이 코드를 작성하면 바로 메시지 박스를 띄우고 원하는 동작을 하도록 할 수 있습니다.

<!-- View (XAML) -->
<Button Content="Test" Click="Button_Click" />
// View (Code-behind)
private void Button_Click(object sender, RoutedEventArgs e)
{
  var result = MessageBox.Show("계속 하시겠습니까?", "경고", MessageBoxButton.YesNo);

  if (result == MessageBoxResult.Yes)
  {
    // 동작 수행
  }
}

보시다시피 위와 같은 방법을 사용하면 별도의 보일러플레이트 코드 없이 구현할 수 있습니다. 하지만 이 방법은 다음과 같은 문제점을 가지고 있습니다.

  • View에 작성된 코드는 대개 테스트하기가 힘듭니다.
  • View의 이벤트 처리 메서드에서 presentation logic을 다루고 있습니다. 이는 ViewModel에서 다뤄져야 합니다.

이러한 이유로 이 방법은 간단한 앱을 개발할 때 고려해 볼 만 하다고 하겠습니다.

방법 2: ViewModel의 Command에서 처리하기

ViewModel에서 ICommand 인터페이스를 구현하는 개체를 만들고 속성을 통해 노출시키면, View에서 데이터 바인딩을 통해 버튼과 명령을 연결시킬 수 있습니다. 사용자가 버튼을 클릭하면 연결된 ICommand 개체의 Execute(object) 메서드가 호출됩니다.

<!-- View (XAML) -->
<Button Content="Test" Command="{Binding DoSomethingCommand}" />
// ViewModel
private RelayCommand _doSomethingCommand;

public RelayCommand DoSomethingCommand
  => _doSomethingCommand ?? (_doSomethingCommand = new RelayCommand(ExecuteDoSomething));

private void ExecuteDoSomething()
{
  var result = MessageBox.Show("계속 하시겠습니까?", "경고", MessageBoxButton.YesNo);

  if (result == MessageBoxResult.Yes)
  {
    // 동작 수행
  }
}

버튼을 클릭했을 때의 처리를 ViewModel로 이동했기 때문에 이제 View에서는 UI의 모습만 신경쓰면 됩니다. ViewModel에서는 ICommand 인터페이스를 직접 구현한 클래스나 서드 파티 MVVM 프레임워크에서 제공하는 RelayCommandDelegateCommand 등을 사용하면 간편하게 명령 개체를 만들 수 있습니다.

하지만 위 방법에는 다음과 같은 커다란 문제가 있습니다.

  • WPF에서 제공하는 MessageBox 클래스는 View와 관련된 클래스입니다. ViewModel은 메시지 박스가 무엇을 사용해서 어떻게 표시되는지 알면 안 됩니다.
  • 저는 레이어 간의 확실한 분리를 위해 ViewModel 클래스를 WPF나 기타 GUI 관련 어셈블리를 참조하지 않는 별도의 클래스 라이브러리 프로젝트에 정의합니다. 이런 경우 ViewModel에서 MessageBox 클래스를 직접 사용할 수 없겠죠.

방법 2a: 의존관계 역전 원칙 적용

이런 상황에서 적용할 수 있는 것이 바로 의존관계 역전 원칙(Dependency Inversion Principle)‌입니다. SOLID의 “D”에 해당하는 이 원칙의 요지는 어떤 클래스가 구체적인 구현에 의존하지 않고 추상화된 대상(인터페이스)에 의존해야 한다는 것입니다.

“방법 2”의 구조는 아래와 같은 클래스 다이어그램으로 표현할 수 있습니다. ViewModel 클래스가 WPF에서 제공하는 MessageBox 클래스에 직접적으로 의존함을 알 수 있습니다.

ViewModel이 MessageBox에 직접 의존하는 관계를 나타낸 클래스 다이어그램

ViewModelMessageBox에 의존하기 때문에 DIP를 적용하기 위해서는 둘 중에서 MessageBox를 추상화해야 합니다. MessageBox.Show 메서드에는 다양한 오버로드가 있기 때문에 메시지의 내용, 메시지 박스의 제목, 메시지와 함께 표시될 아이콘, 사용자가 응답에 사용할 버튼의 종류 등을 필요에 따라 사용자 지정할 수 있지만, 여기서는 아래의 네 가지 유형의 메시지 박스만 만들 수 있도록 제한하겠습니다.

  • [확인] 버튼이 있는 안내 메시지 상자
  • [확인] 버튼이 있는 오류 메시지 상자
  • [예], [아니오] 버튼이 있는 질문 메시지 상자
  • [예], [아니오], [취소] 버튼이 있는 질문 메시지 상자

이를 C# 인터페이스로 작성하면 아래와 같을 것입니다.

public interface IMessageBoxProvider
{
  void ShowInfo(string message, string title);
  void ShowError(string message, string title);
  bool ShowYesNo(string message, string title);
  bool? ShowYesNoCancel(string message, string title);
}

이제 ViewModel에서 IMessageBoxProvider 타입의 개체를 참조하는 변수나 속성을 사용하면 구체적인 구현 사항에 신경쓰지 않고 메시지 박스를 표시할 수 있습니다.

// ViewModel
protected IMessageBoxProvider MessageBoxProvider { get; set; }

private void ExecuteDoSomething()
{
  if (MessageBoxProvider.ShowYesNo("계속 하시겠습니까?", "경고"))
  {
    // 동작 수행
  }
}

DIP를 적용함으로써 얻을 수 있는 효과는 강한 의존 관계의 해소 말고도 한 가지 더 있는데, 바로 ViewModel의 코드를 수정하지 않고도 메시지 박스의 표시 방식을 바꿀 수 있다는 것입니다.

예를 들어, WPF의 MessageBox 클래스를 래핑한 WpfMessageBoxProvider를 쓰고 있었다고 해 봅시다. 앱의 개발이 진행됨에 따라 앱의 전반적인 디자인이 확정되어 획일화된 메시지 박스 대신 앱의 자체 스타일이 적용된 메시지 박스를 사용자에게 보여주고 싶어졌습니다. 이 경우에는 단순히 IMessageBoxProvider를 구현한 다른 클래스인 CustomMessageBoxProvider를 만들고 ViewModel이 그것을 사용하도록 하면 됩니다. 또 한 가지 가능한 시나리오는, 작성한 ViewModel에 대한 자동화된 테스트를 수행하는 경우입니다. 이 때는 메시지 박스를 표시하지 않고 사용자가 [확인] 버튼을 누른 것으로 간주하는 더미 구현체인 NullMessageBoxProvider를 만들어 사용하기만 하면 됩니다.

위의 내용을 클래스 다이어그램으로 나타내면 다음과 같습니다.

의존관계 역전의 원칙을 적용한 후의 클래스 다이어그램

의존성 주입

이제 ViewModel은 구체적인 구현 사항에 관계 없이 사용자에게 메시지 박스를 표시할 수 있게 되었습니다. 그런데 마지막으로 한 가지 질문이 남아 있습니다. ViewModel이 실제로 메시지 박스를 표시하기 위해서는 어쨌든 IMessageBoxProvider 인터페이스를 구현한 클래스의 인스턴스가 필요합니다. ViewModel이 어떤 버전의 구현을 사용할 지는 어떻게 알려줘야 하죠?

…클래스 안쪽에 정의할까요?

class ViewModel
{
  protected IMessageBoxProvider MB { get; set; } = new WpfMessageBoxProvider();
}

이렇게 해 놓고 보니 애써서 메시지 박스를 추상화하고 의존관계 역전 원칙을 적용한 것이 헛되어 보입니다. 이 때 필요한 기법이 바로 의존성 주입(Dependency Injection; DI)‌입니다. 의존성 주입은 말 그대로 클래스 바깥에서 어떤 클래스의 인스턴스가 의존하는 개체를 주입하는 것인데, 이는 크게 세 가지 방법으로 수행할 수 있습니다.

생성자를 사용한 의존성 주입

생성자의 매개변수에 생성되는 인스턴스가 의존하게 될 객체를 전달하여 객체의 생성과 동시에 의존성을 주입하는 방식입니다.

class ViewModel
{
  protected IMessageBoxProvider MB { get; set; }

  public ViewModel(IMessageBoxProvider mb)
  {
    MB = mb;
  }
}

// ViewModel을 생성하는 곳에서
var vm = new ViewModel(new WpfMessageBoxProvider());

속성을 사용한 의존성 주입

클래스에서 노출하는 속성의 값을 설정하여 의존성을 주입하는 방식입니다.

class ViewModel
{
  public IMessageBoxProvider MessageBoxProvider { get; set; }
}

// ViewModel을 생성하는 곳에서
var vm = new ViewModel();
vm.MessageBoxProvider = new WpfMessageBoxProvider();

클래스의 바깥에서 속성에 접근하기 위해서는 해당 속성의 접근 제한 수준이 충분히 느슨해야 하지만, 리플렉션을 사용하면 public이 아닌 속성에 접근할 수도 있긴 합니다.

메서드의 매개변수를 사용한 의존성 주입

의존성을 사용하는 메서드의 매개변수에 객체를 전달하여 의존성을 주입하는 방식입니다.

class ViewModel
{
  public SomeMethod(IMessageBoxProvider mb)
  {
    if (mb.ShowYesNo("...", "...")) {
      // ...
    }
  }
}

IMessageBoxProvider의 기능을 사용하는 메서드가 많거나, 한 메서드가 여러 개의 인터페이스에 의존하는 경우에는 코드가 상당히 복잡해질 것 같습니다. 특히 ICommand를 사용하는 경우에는 하나뿐인 커맨드 파라메터로 의존성을 전달해야 할텐데, 현실적으로 곤란해 보입니다.