의존성 주입이라는 개념은 실전 개발에서 굉장히 널리 사용되는 개념입니다. 다만 의외로 그걸 설명하는 것은 힘듭니다. 이는 의존성 주입을 설명하는 용어들이 지나치게 어려워서 그런 것이 아니였나 생각해봅니다. 이번 포스트에서는 이 의존성 주입을 Swift를 이용해서 설명하고자 합니다. Swift는 객체 지향 프로그래밍 뿐 아니라 함수형 프로그래밍의 개념도 어느정도 차용하고 있는데, 이 함수형 프로그래밍을 활용하면 의존성 주입을 좀 더 쉽게 이해할 수 있습니다.

  • 의존성 주입이란?
    네트워크 통신을 통해서 이미지를 가져오는 로더 클래스를 만든다고 생각해봅시다. 에러처리를 생각하지 않고 만든다면 다음과 같은 구조가 될 것 입니다.

    final class ImageLoader {
        func loadImage(onComplete: @escaping (UIImage) -> Void) {
            /// 세부 구현
        }
    }
    

    실제로 구현을 한다면, loadImage 메소드의 몸체에는 다음과 같은 내용이 포함되어 있어야 합니다.

    1. 이미지를 불러올 소스(source)를 지정해야 합니다. 네트워크 통신을 할 것이기 때문에 URL이 될 것 입니다.
    2. 실제 네트워크 통신을 수행하는 코드가 들어가야 합니다.

    하지만 이러한 내용을 메소드 안에 직접 담는것은 문제의 여지가 있습니다.

    1. 하드코딩된 URL이외의 다른 이미지를 로딩하고 싶을때는 해당 클래스를 사용할 수 없습니다.
    2. 해당 메소드 안에서 직접 네트워크 통신 관련 코드가 호출되기 때문에, 프로토콜이 바뀌거나 네트워크 환경에 따른 대응이 힘들어집니다. 할 수 있다 해도 코드가 복잡해집니다.

    이러한 문제는 메소드가 역할을 수행하기 위해 필요한 정보와 기능들을 외부의 도움없이 스스로 결정해야 되기 때문에 생깁니다. 이를 해결하기 위해서는 메소드가 필요로 하는 정보와 기능들을 외부에서 주입해주는 것으로 해결이 가능합니다. 여기에는 몇가지 방법이 있는데, 크게 두가지로 나누면 다음과 같습니다.

    1. 객체에 정보를 저장하고 있다가, 메소드에서 참조하는 방법

         protocol NetworkLoader: AnyObject {
             func doNetworking(_ url: URL, onComplete: @escaping (Data) -> Void)
         }
      
         final class ImageLoader {
             let networkLoader: NetworkLoader
             let url: URL
      
             init(_ loader: networkLoader, url: URL) {
                 self.networkLoader = loader
                 self.url = url
             }
      
             func loadImage(onComplete: @escaping (UIImage) -> Void) {
             /// 세부 구현. networkLoader와 url은 여기서 참조된다.
             }
         }
      
    2. 메소드에 인자로 넘기는 방법

        final class ImageLoader {
            func loadImage(_ networkLoader: NetworkLoader, url: URL, onComplete: @escaping (UIImage) -> Void) {
            /// 세부 구현. networkLoader와 url은 여기서 참조된다.
            }
        }
      

    이것이 의존성 주입의 전부입니다. 즉, 어떤 기능을 수행하기 위해 필요한 세부 정보 혹은 하위 기능을 직접 결정하는 것이 아니라 외부에서 결정해주는 것입니다. 이렇게 하면 몇가지 이점이 있습니다.

    1. 코드 재사용을 용이하게 합니다. 공통적인 로직은 한번만 구현하고 필요한 정보 혹은 기능만 끼워넣어서 커스터마이징이 가능하기 때문에 여러 곳에서 재사용이 가능합니다.
    2. 테스트가 용이합니다. 실제 네트워크 통신과 같은 경우에는 네트워크 상황에 따라 다양한 이유로 실패할 수 있기 때문에 테스트가 오류가 없어도 실패할 수 있습니다. 하지만 이렇게 외부에서 기능을 주입할 수 있다면 실제 네트워크 통신을 하지 않고도 마치 한 것처럼 동작하도록 기능을 만들어서 주입하면, 실제 네트워크 통신을 하지 않아도 테스트가 가능해집니다. 이렇게 테스트 용으로 만든 것을 목(mock)이라고 합니다.(이는 뒤에서 좀 더 살펴보겠습니다.)
  • 함수형 프로그래밍과 의존성 주입
    위에서 의존성 주입의 방식으로 2가지를 이야기 했습니다. 하지만 이 두가지는 근본적으로 동일합니다. 객체의 생성시에 의존성을 주입하는지, 실제로 메소드를 실행할 때 주입하는 지만 다를 뿐입니다. 두 가지 방법에서 메소드 시그니처를 비교해보면 다음과 같습니다.

    /// 1. 객체가 의존성을 가지는 경우
    ((UIImage) -> Void) -> Void
    /// 2. 메소드에서 의존성을 주입하는 경우
    (NetworkLoader, URL, (UIImage) -> Void)
    

    그리고 함수형 프로그래밍은 2번을 1번으로 바꿀 수 있는 방법을 제공해줍니다. 이것은 커링(currying)이라고 하는 방법입니다.

    let original: (NetworkLoader, URL, (UIImage) -> Void)
    let converted: ((UIImage) -> Void) -> Void = { onComplete in
      let loader: NetworkLoader = // 로더 객체 생성
      let url: URL = //  URL 정보 생성
    
      original(loader, url, onComplete)
    }
    

    위에서 보다시피, 객체의 프로퍼티로 의존성을 주입하는 것은 특정 메소드에서 메소드의 인자를 고정해놓는 것과(커링) 동일합니다. 즉, 함수형 프로그래밍에서의 의존성 주입은 커링을 통해서 이루어질 수 있습니다.

  • 의존성 주입과 테스트
    이제 테스트의 관점에서 의존성 주입을 생각해봅시다. 만약 메소드가 순수 함수라면, 특정 데이터 값에 대해 의도된 결과를 도출하는지만 확인하면 됩니다. 순수함수는 주어진 인자값에 의해서만 결과가 바뀌기 때문에, 테스트하기가 매우 쉽습니다. 하지만 순수함수가 아니라면, 주어진 인자만으로는 결과를 예측할 수 없습니다. 위 예시에서는 네트워크가 항상 성공하는 것을 기대하지만, 현실에서는 실패 가능성을 무시할 수 없습니다. 이는 비순수함수가 아닌 경우에는 외부 상태에 의존하게 되고, 외부 상태는 애플리케이션 코드 레벨에서 통제할 수 없는 영역이기 때문입니다.

    그래서 우리는 이를 테스트 하기 위해서 외부상태를 시뮬레이션할 필요가 있고, 메소드가 실행될 외부 상태를 시뮬레이션할 수 있도록 해야 합니다. 여기에서 의존성 주입이 필요해집니다. 통제할 수 없는 외부 상태에 접근하는 코드 대신에, 외부 상태를 시뮬레이션하는 코드를 집어넣음으로써 비순수 함수의 결과는 예측이 가능하게 됩니다. 이 때 사용되는 시뮬레이션을 목(mock) 이라고 합니다.

    final class MockNetworkLoader: NetworkLoader {
        func doNetworking(_ url: URL, onComplete: @escaping (Data) -> Void) {
            onComplete(Data()) // 임의의 데이터를 내보낸다.
        }
    }
    

    실제 코드를 목으로 대체하기 위해서는 구체적인 정의가 아닌, 추상적인 인터페이스만으로 의존성을 다루어야 합니다. 이 인터페이스는 부모클래스를 두고 이를 상속하거나 프로토콜을 통하는 방법, 클로저 타입의 변수를 바꿔 끼우는 방식 등이 있습니다. 이 중 적절한 방법을 사용하면 됩니다. 이러한 방법은 제어의 역전(Inversion of Control)이라는 이름으로 알려져 있습니다. 이렇게 제어를 역전시키면, 각 객체들의 의존성을 끊어내어, 모듈화를 할 수 있는 부수적인 이점도 있습니다.


의존성 주입이나 테스트에 대해서는 다양한 주제들이 있고, 이들은 블로그 정도로는 다루기 힘들 정도로 내용이 방대한 경우도 있습니다. 하지만 핵심은 간결하고, 이 핵심에서 대부분의 내용이 파생될 수 있기 때문에 이 핵심을 이해하는 것은 중요합니다.