Flutter

Dependency Injection 의 의미

flutter developer 2024. 7. 23. 18:08

링크:  https://product.kyobobook.co.kr/detail/S000003470645

 

Dependency Injection Principles, Practices, and Patterns | Seemann, Mark - 교보문고

Dependency Injection Principles, Practices, and Patterns |

product.kyobobook.co.kr

Dependency Injection - Mark Seemann, Steven van Deursen 을 읽고 의존성 주입에 대한 포스트를 작성했습니다 :)

 


1.  왜 의존성 주입을 해야할까?

 

왜 의존성 주입을 해야할까? 
결론부터 말하자면 객체간의 느슨한 관계 형성을 위함이다. 이에 대해 조금 더 자세히 들여다보자.

 

객체는 자신의 역할에만 집중할 수 있어야한다. 다른 객체와의 소통은 오직 메세지로만 이루어진다.

다시말해 객체는 본인이 맡은 역할 이외에 알 것은 "메세지" 뿐이다. 이외의 정보는 소음이다.

 

하지만 이러한 메세지를 알기위해 A객체 내에서 또 B객체를 만들고 또 그 안에서 C객체를 만든다면 어떻게 될까? 2가지 문제점이 발생한다.

첫번째, 상위객체가 하위객체에 대해 불필요하게 많은 정보를 알게 된다.


A객체(상위 객체)가 B객체(하위 객체)에 대해 접근제한 internal 이상의 모든 정보를 알게 되고 B객체 또한 C객체에 대한 internal 이상의 모든 정보를 알게된다. 이는 A객체가 B객체를 통해서 C객체의 internal 이상의 모든 정보를 알게된다는 의미이기도 하다. 이것이 왜 문제가 될까? 바로 복잡성이 증가하기 때문이다. 

 

 

개발자로써 우리가 해야할 의무 중 하나는 복잡함을 감추고 단순함을 추구하는 것이다.  자동차 운전을 예로 들어보자. 자동차의 속력을 높이라는 표현을 어떻게 하는게 좋을까? 먼저 연료 분사와 점화라는 출발점과 함께 변속기에 동력을 전달하고 바퀴를 굴러가게 한 다음 공기 저항을 최소화하여... etc  이러한 복잡한 과정을 우리는 한 단어로 축약한다. 엑셀 좀 더 밟아봐. 운전자 입장에서는 이것만 알면된다. 모든걸 다알고 운전을 한다면 첫 운전대를 잡는데 최소 5년은 걸릴 것이다.

 

객체에 입장에서도 마찬가지다. 자신의 역할에 대한 복잡성만 알면될 뿐 다른 객체들에 대한 복잡성까지 모두 알게 된다면 하나의 객체를 이용하기 위해서 종속된 모든 객체를 알아야하고, 개발자의 생산성은 수직낙하할 것이다.

 

두번째, 객체들이 프로그램 실행 순서에 종속되어 자율성이 크게 떨어진다.

 

 

 

앞선 전통적 방식에서 프로그램은 사용자에게 명령어를 제시하고 ("이름을 입력하세요", "주소를 입력하세요"), 사용자의 입력에 따라 다음단계로 진행된다. 프로그램이 전체흐름을 제어하며 개발자는 각 단계마다 한 땀 한 땀 코드를 작성해나간다.

 

하지만 IoC를 적용한다면 프로그램이 주도하는 것이 아닌 객체가 주도하는 방식으로 바뀐다. 더 이상 개발자가 한 땀 한 땀 실행 순서에 따른 행동 코드를 작성하는 것이 아닌 역할을 맡은 객체들에게 생명을 불어넣어 스스로 똑똑하게 일을 해내게끔 코드를 작성한다. 

 

이로써 개발자는 더이상 어떻게 할 것인가? 인가에 대한 고민보다 각 객체가 무엇을 할 것인가? 에 대한 고민에 집중할 수 있게한다. 


2.  의존성 주입의 진짜 의미

자 지금까지 우리는 의존성 주입을 왜 해야하는지 살펴보았다 그렇다면 다음으로 생기는 의문, 어떻게 의존성을 주입해야하는가? 아니, 어떻게 주입하기에 앞서 무엇을 주입해야할까?

 

무엇을 주입해야하는가는 앞서 살펴본 주입해야하는 이유 속에서 엿볼 수 있다. 엑셀레이터가 어떤 기능을 하는가? 밟으면 어떤 일이 일어나는가? 정도는 알아야한다. 우리는 소통하는 객체의 "역할"을 주입해야한다. 이때 역할은 책임이며 책임은 곧 행동이다. 다시말해 우리는 객체가 맡은 역할과 직결된 "행동"을 주입해야한다. 주입받는 객체의 행위에 필요한 것들 (특히 데이터)은 소음이다. 해당 객체가 맡은 핵심적인 행동 이외의 모든 것들은 포장지로 감싸(encapsulation) 외부에서는 알 수 없도록 만들어야한다.

 

객체가 맡은 역할의 행동을 가진 객체라면 어떤 객체라도 괜찮다. 타입이 구체타입으로 딱 정해진 것이 역할이 명시된 인터페이스(Swift로 치면 프로토콜)를 가지고 있다면 괜찮기 때문에 의존성 주입순간에 인터페이스를 가지고 있는 어떤 객체라도 넣을 수 있다. 이러한 의존성 관계를 Volatile Dependency(가변적 의존성)이라 부른다. 런타임시 언제든지 바뀔 수 있기 대문이다.

 

Mark Seeman은 의존성 주입의 세 가지 의미를 제시하는데 바로 객체 합성(Object Composition), 가로채기(Interception), 객체의 생명 관리(Life time memory management)이다 - 이 세가지 기능이 의존성 주입의 진짜 의미라 볼 수 있다. 세 가지 의미에 대해 조금더 자세히 살펴보자

 

첫번째, 의존성 주입을 통해 객체 합성을 다양한 방법으로 할 수 있게 된다. 객체 합성(Object Composition)의 의미를 되짚어보면 각각의 컴포넌트를 외부에서 만든뒤 특정 컴포넌트에서 조합하는 것을 의미한다. 즉 의존성 주입은 마치 외부에서 만든 블록을 가지고 와서 더 큰 블록을 만드는 것과 같다. 따라서 이미 "완성"된 블록들이 모여야하기 때문에 런타임에서 합성이 이루어진다.

 

두번째, 의존성 주입은 런타임에 일어나기 때문에 주입 직전에 주입할 객체를 납치해서 우리가 원하는 다른 옷을 덧입히고 다시 집어넣을 수 있다. 바로 가로채기(Interception)이다. 가로채기를 통해 AOP(aspect oriented programming)를 가능케해준다.

 

세번째, 의존성 주입을 통해 여러 객체의 생명주기를 한 곳에서 관리한다. 객체의 생명 범위는 크게 3가지 종류 - Singleton, Transient, Scoped - 로 나눌 수 있다. 

 

그래서 생명범위를 알아서 어디다 쓰는건데? 라고 물어볼 수 있다. 바로 어떻게 적용되는지 살펴보자 

 

객체의 생명 관리(Life time memory management)

객체의 생명 주기(Lifetime Management)는 단순한 기술적 선택이 아니라 시스템의 확장성, 성능, 유지보수성을 결정하는 핵심 요소다. 잘못된 객체 생명 주기 관리로 인해 메모리 누수, 동시성 이슈, 그리고 불필요한 객체 재생성으로 인한 성능 저하가 발생할 수 있다. 객체의 생명 주기는 크게 싱글톤(Singleton), 스코프(Scoped), 트랜지언트(Transient) 세 가지로 나뉜다.

  1. 싱글톤(Singleton)
    • 애플리케이션 전체에서 하나의 인스턴스만 유지된다.
    • 전역적으로 공유되므로, 상태를 가지는 객체일 경우 동시성 문제를 초래할 수 있다.
    • 메모리 사용량을 줄일 수 있지만, 필요 이상으로 메모리에 머물 경우 리소스 낭비가 발생한다.
    • 대표적인 예: DB 커넥션 풀, 설정(config) 객체
  2. 스코프(Scoped)
    • 특정 컨텍스트(예: HTTP 요청 단위)에서 동일한 인스턴스를 유지한다.
    • 요청이 끝나면 객체가 해제되므로, 부적절한 의존성 관리 시 순환 참조 문제가 발생할 수 있다.
    • 대표적인 예: 웹 애플리케이션의 세션, 사용자 컨텍스트 객체
  3. 트랜지언트(Transient)
    • 객체가 매번 새롭게 생성된다.
    • 상태를 가지지 않는 순수한 함수형 객체에서 유용하며, 공유될 필요가 없는 경우 적절하다.
    • 그러나 생성 비용이 높은 객체(예: HTTP 클라이언트)를 트랜지언트로 관리하면 성능이 저하될 수 있다.
    • 대표적인 예: 로깅 서비스, 유틸리티 객체

 

객체 합성(Object Composition) 

class ServiceA { func execute() { print("ServiceA 실행") } }
class ServiceB { func execute() { print("ServiceB 실행") } }

class Client {
    private let service: ServiceA

    init(service: ServiceA) {
        self.service = service
    }

    func run() {
        service.execute()
    }
}

let client = Client(service: ServiceA()) // 의존성을 외부에서 주입
client.run()

예제를 통해 알아보자. 객체 합성의 핵심은 객체 생성 책임을 외부로 이동시키고 런타입에 필요한 구현체를 조립하는 것이다.  기존의 상속(Inheritance) 기반 설계는 재사용성을 높일 수 있지만, 강한 결합도를 초래해 유지보수가 어려워지고 코드 확장이 제한되는 문제가 있다. 반면, 객체 합성을 활용하면 각 객체를 독립적으로 설계하고, 런타임에서 동적으로 결합하여 보다 유연한 설계를 가능하게 한다. 

 

 

가로채기(Interception)

의존성 주입의 핵심 원리는 가로채기(interception)이다. 가로채기 이름 그대로 알 수 있듯이 런타임에 객체를 "가로채서" 다른 무언가를 할 수 있다는 것이다. 이를 통해 객체의 행동을 동적으로 변경하거나, 관심사 분리(Separation of Concerns, SoC)를 유지하면서 부가적인 기능을 추가할 수 있게 된다.

 

이러한 의존성 주입에서 가로채기의 강력함은 아래와 같이 활용될 수 있다.

  1. 트랜잭션 관리(Transaction Management)
    • 모든 데이터베이스 작업을 가로채어 트랜잭션을 자동으로 관리한다.
    • 실패 시 롤백하고, 성공 시 커밋하는 기능을 일괄적으로 적용할 수 있다.
  2. 로깅 및 모니터링(Logging & Monitoring)
    • 모든 서비스 호출을 가로채어 로그를 남기고, 실행 시간을 측정한다.
    • 실시간 모니터링과 성능 분석에 유용하다.
  3. 보안 및 인증(Security & Authentication)
    • 요청을 가로채어 JWT 토큰 검증, API 키 체크, OAuth 인증 수행 등의 보안 기능을 추가한다.
    • 사용자 권한에 따라 접근을 차단할 수도 있다.
  4. 캐싱 및 성능 최적화(Caching & Optimization)
    • 특정 메서드를 가로채어 결과를 캐싱하고 불필요한 재연산을 방지한다.
    • 데이터베이스 쿼리나 API 호출의 성능을 향상시킬 수 있다.

2.  의존성 주입에 대한 정형화된 패턴


DI Patterns

이러한 객체의 행동을 주입하는 방법은 여러가지가 있지만 디자인 패턴과 같이 정형화된 DI pattern 과 DI Anti-patterns이 있다. 

 

먼저 의존성 주입 방법에는 3가지가 있다. 생성자를 통한 주입, 프로퍼티를 통한 주입, 메서드를 통합 주입이다. 이를 각각 Constructor Injection, Method Injection, Property Injection 이라 부른다. 용어가 다소 거창한데 지금까지 당연하게 써오던 방법들일 것이다.  아래 코드를 통해 살펴보자.

 

1. 생성자 주입 (Constructor Injection)

// MARK: - Constructor Inejction (생성자 주입)
protocol DataServiceProtocol {
    func fetchData()
}

class DataService: DataServiceProtocol {
    func fetchData() {
        print("Data fetched")
    }
}

class ViewController {
    private let dataService: DataServiceProtocol

    init(dataService: DataServiceProtocol) {
        self.dataService = dataService
    }

    func loadData() {
        dataService.fetchData()
    }
}

let dataService = DataService()
let viewController = ViewController(dataService: dataService)
viewController.loadData()

생성자 주입은 객체의 생성 시 필요한 의존성을 명확하게 전달하는 방식이다. 이를 통해 객체가 필요한 모든 의존성을 보장받을 수 있으며, 불변성을 유지하는 데에도 유리하다.

 

장점

의존성이 반드시 제공되므로 안정성이 높다.

불변성(Immutability)을 유지할 수 있다.

컴파일 타임에 의존성 확인이 가능하여 런타임 오류를 줄일 수 있다.

 

단점

의존성이 많아질 경우 생성자의 파라미터가 길어지는 문제(생성자 과다 주입, Constructor Over-injection)가 발생할 수 있다.

프레임워크에서 기본 생성자(파라미터 없는 생성자)를 요구할 경우 사용하기 어려울 수 있다.

 

2. 메서드 주입 (Method Injection)

// MARK: - Method Injection (메서드 주입)
class Logger {
    func log(message: String) {
        print("Log: \(message)")
    }
}

class UserProcessor {
    func processUser(id: String, logger: Logger) {
        // 사용자 처리 로직...
        logger.log(message: "Processed user with ID: \(id)")
    }
}

let logger = Logger()
let userProcessor = UserProcessor()
userProcessor.processUser(id: "123", logger: logger)

메서드 주입은 특정 메서드를 호출할 때마다 의존성을 전달하는 방식이다. 이는 의존성이 매번 변경되거나 메서드 호출마다 특정 컨텍스트가 필요한 경우에 유용하다. 

 

장점

매 호출마다 다른 의존성을 사용할 수 있어 유연성이 높다.

의존성을 메서드 호출 시 전달하므로, 필요할 때만 사용 가능하다.

테스트 시 목(Mock) 객체를 손쉽게 주입할 수 있다.

 

단점

의존성을 매번 전달해야 하므로 메서드 호출이 복잡해질 수 있다.

메서드 호출 순서가 중요한 경우 Temporal Coupling(시간적 결합) 문제가 발생할 수 있다.

 

3. 프로퍼티 주입 (Property Injection) 

// MARK: - Property Injection (프로퍼티 주입)
protocol AnalyticsServiceProtocol {
    func trackEvent(event: String)
}

class AnalyticsService: AnalyticsServiceProtocol {
    func trackEvent(event: String) {
        print("Event tracked: \(event)")
    }
}

class ProductViewController {
    var analyticsService: AnalyticsServiceProtocol?

    func displayProductDetails() {
        analyticsService?.trackEvent(event: "Product Details Displayed")
    }
}

let analyticsService = AnalyticsService()
let productViewController = ProductViewController()
productViewController.analyticsService = analyticsService
productViewController.displayProductDetails()

프로퍼티 주입은 클래스의 속성(Property)을 통해 의존성을 설정하는 방식이다. 이는 의존성이 선택적(optional)일 경우에 적합하며, 객체가 생성된 후에도 의존성을 변경할 수 있다.

 

장점

  • 의존성을 선택적으로 설정할 수 있어 유연성이 높다.
  • 생성자를 수정하지 않고도 외부에서 의존성을 변경할 수 있다.
  • 기본 구현체(Default Implementation)를 제공하면서도 확장성을 유지할 수 있다.

단점

  • 객체 생성 후 의존성이 설정되지 않으면 Null Reference Exception이 발생할 수 있다.
  • 의존성이 변경될 가능성이 높아 예측 가능한 코드 작성이 어렵다.
  • Temporal Coupling(시간적 결합) 문제가 발생할 수 있다.

 3.  의존성 주입에서 지양해야할 패턴 - DI Anti Pattern

먼저 Composition Roots에 대해 알아보자.

 

 

Composition Root를 정의하자면 객체 관리를 한 곳에 집중시키는 DI pattern이다. 객체를 생성하고, 메모리 해제와 같은 생명주기를 한 곳에서 집중시킨다. 그리고 이 집중시킨 "Root"를 프로그램의 시작지점에서 실행시킨다. 

 

우리가 주목해야할 키워드는 바로 "시작지점"이다. 왜 시작지점이 중요할까? Composition Root의 가장 중요한 특징은 "시작지점에서 모든 의존성을 설정하고 객체의 생성을 책임진다"는 것이기 때문이다.  그리고 시작지점이라는 한 곳에서 관리하는 순간 의존성 횟수를 줄여 복잡도를 낮출 수 있다.  그렇다면 의존성 횟수에 대해 좀더 알아보자

 

Dependency Injection Principles(Mark Seemann, Steven van Deusen)

느슨한 의존성의 기준들중 하나는 의존성의 "횟수"이다. (느슨한 의존성에서 중요한 것은 객체수가 아니라 객체간 의존 횟수이다) 그렇다면 왼편과 오른편의 의존성 관계도를 살펴보자. 어떤 설계의 의존성이 더 느슨해 보이는가? 얼핏보면 왼쪽이 더 느슨하다고 볼 수 있으나 실상은 왼쪽 설계가 더 강결합되어있다 볼 수 있다.

 

이유는 이산수학에서 쓰이는 추이관계(transitive relation)를 통해 알 수 있다.

https://www.splashlearn.com/math-vocabulary/transitive-relations

 

집합간의 관계에는 3가지 관계가 존재한다. 반사관계, 대칭관계, 추이관계다. 여기서 추이관계란  세 요소 A, B, C가 있을 때 A와 B사이, B와 C 사이에 관계가 있다면 자동으로 A와 C사이에도 동일한 관계가 성립하는 관계를 의미한다. 마치 친구의 친구도 내 친구다라는 의미라고 볼 수 있다. 이를 의존성 관계에 대입하면 Entry Point는 UserInterface Layer에만 의존성을 가진 것이 아닌 엮여있는 모든 하위 객체에 대한 의존성을 가지고 있다고 볼 수 있다.

 

이러한 추이관계에 근거하여 왼쪽 설계의 실제 의존성 관계도는 아래와 같다.

Dependency Injection Principles(Mark Seemann, Steven van Deusen)

기존 Compostion Root의 의존성 횟수는 5번인 반면 왼편 의존성 횟수는 6번이다. 따라서 Composition Root가 더 느슨한 관계이다.이렇게 Composition Root가 의존성 형성 역할을 수행함으로써 횟수를 줄여주게 된다.

 

그럼 이제 본격적으로 DI 안티패턴에 대해 알아보자. 책에서는 4가지 안티패턴을 소개한다.

 

1. Control Freak

Control Feak은 간단히 말해 interface를 쓰지않고 바로 구현체를 주입하는 경우이다. Control Freak의 가장 큰 문제점은 의존성 주입에 대한 추상화의 장점이 전혀 없다는 것이다!  이렇게 구체 타입이 지정되어있게 되면 OCP(Open Closed Principle)을 위반하게 된다. 객체가 변경될 때 소비자 객체에도 수정이 필요하여 유지 보수가 어려워지는 것이다.

 

2. Service Locator 

서비스 로케이터의 정의부터 살펴보자면 이렇다

서비스 로케이터는 디자인 패턴으로써 서비스를 얻는 과정을 추상화한 객체이다.
"서비스 로케이터"라 불리는 곳에 서비스를 "등록(register)"하고
서비스가 필요한 객체가 "요청(request)"하면 해당 요청에 "응답(resolve)" 한다.

 

위와 같은 요소가 왜 안티패턴으로 작용하는걸까? 암묵적 의존성으로 인해 객체의 실제 필요 의존성이 명확히 드러나지 않기 때문이다. 이 말은 즉슨 잘못된 의존성 관리라도 컴파일 에러에서 잡아낼 수 없다. 따라서 자동적으로 런타임 에러 가능성이 증가하고 향후 의존성에서 에러가 날 경우 디버깅도 어려워진다.

 

3. Ambient Context 

class Logger {
  void log(String message) {
    if (GlobalConfig.isDebugMode) {
      print("[DEBUG] $message");
    }
  }
}

Ambient Context는 예제코드를 통해 이해해보자. Logger 클래스는 GlobalConfig.isDebugMode라는 전역 상태에 의존한다. 즉 외부 환경이 변경되면 Logger의 동작도 바뀌어 어떤 행동을 할지 예측할 수가 없게된다. 이를 정리하면 Ambient Context는 객체가 특정 환경(Context)에 의존하지만, 해당 의존성이 명시적으로 드러나지 않는 패턴을 의미한다. 객체가 전역적으로 설정된 상태(Global State)나 환경 변수에 암묵적으로 의존할 때 발생하는 문제인 것이다.

 

4. Constrained Construction

class User {
  final String name;

  // private 생성자 (밖에서 직접 new User() 사용 불가)
  User._(this.name);  

  // 정적 팩토리 메서드로만 객체 생성 가능
  static User create(String name) {
    return User._(name);
  }
}

void main() {
  // ❌ User를 직접 생성할 수 없음
  // var user = User("John"); // 컴파일 에러!

  // ✅ 반드시 팩토리 메서드를 써야 함
  var user = User.create("John");
}

 

Constrained Construction(제한된 생성)은 객체를 생성하는 방식이 특정한 제약(Constraints)에 의해 제한되는 패턴을 의미한다.
즉, 객체 생성 방식이 너무 경직되어 있어 유연성이 부족한 경우를 말한다. 이렇게 유연성 부족하면 새로운 방식으로 객체를 만들기 어렵고 의존성 주입(DI)과 충돌하여 DI 컨테이너를 활용할 수 없게된다.


4.  DI Container 와 Pure DI

https://jwchung.github.io/DI%EB%8A%94-IoC%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EC%A7%80-%EC%95%8A%EC%95%84%EB%8F%84-%EB%90%9C%EB%8B%A4

자 그러면 실전에서 의존성 관리 객체는 어떻게 만드는게 좋을까? 저자는 대표적인 방법으로 DI Container와 PureDI를 소개한다. 먼저

DI Container(의존성 주입 컨테이너)는 객체의 생명주기와 의존성을 자동으로 관리하는 도구이다. 즉, 객체를 직접 생성하고 주입하는 대신, DI 컨테이너가 알아서 해주는 방식이야. 보통 라이브러리를 사용하여 의존성을 관리하게 된다. Pure DI(순수한 의존성 주입)는 프레임워크나 라이브러리 없이 직접 객체를 생성하고 주입하는 방식이야. DI 컨테이너 없이 개발자가 직접 의존성을 관리하게 된다. 거기서 거기인 것 같아보이지만 두 방식의 장단점은 극명하다.

DI Container의 장점

자동화된 의존성 관리: 객체 생성 및 의존성 주입을 자동으로 처리하여 코드가 간결해짐.

유지보수성 향상: 객체 간의 결합도를 낮추고, 변경이 용이함.

라이프사이클 관리: Singleton, Transient, Scoped 등 객체의 생명주기를 쉽게 관리할 수 있음.

컨벤션 기반 개발 가능: 설정(Configuration) 없이 규칙(Convention)만으로 객체를 주입 가능.

DI Container의 단점

학습 곡선이 존재: DI Container마다 사용법이 다르고, 초기 설정이 필요함

마법 같은 동작: 객체가 자동으로 생성되다 보니, 내부 동작이 불명확할 수 있음.

런타임 에러 가능성 증가: 잘못된 의존성 설정이 있더라도 컴파일 타임에 잡히지 않을 수 있음. 

Pure DI의 장점

직관적인 코드: 객체의 생성 및 의존성 주입이 명확하여 코드 가독성이 높음.

컴파일 타임 안전성: 의존성이 명확하므로, 컴파일 타임에 오류를 잡을 수 있음.

추가적인 프레임워크 의존 없음: DI Container 없이도 DI 원칙을 적용할 수 있음.

Pure DI의 단점

수작업 증가: 의존성이 많아질수록 직접 객체를 생성하는 코드가 복잡해짐.

객체 생명주기 관리 어려움: Singleton, Transient 등의 라이프사이클을 직접 관리해야 함.

대규모 프로젝트에선 유지보수가 어려울 수 있음: 의존성이 많아지면 Composition Root가 커지고 복잡해질 수 있음.

 

이렇게 DI Container와 Pure DI는 각각의 장단점이 있으며, 규모와 필요에 따라 적절한 방식을 선택하는 것이 중요하다. 보통 작은 프로젝트에서는 Pure DI가 더 간단하고 유지보수가 쉬울 수 있고 대규모 프로젝트에서는 DI Container가 유지보수성과 확장성 측면에서 유리하다고 볼 수 있다.


5.  실전 상황에서 적용할 수 있는 예

의존성 주입(Dependency Injection, DI)은 유지보수성과 확장성을 높이는 중요한 수단이다. 하지만 잘못된 방식으로 적용하면 코드가 복잡해지고, 테스트가 어려워질 수 있다. 여기서는 실전에서 적용할 수 있는 DI 원칙과 개선 방안을 살펴보자

 

1. 의존성 레벨 시키기 -> 인터페이스는 인터페이스끼리, 구현체는 인터페이스에 의존성

protocol DataService {
    func fetchData() -> String
}

class NetworkDataService: DataService {
    func fetchData() -> String {
        return "Network Data"
    }
}

class ViewModel {
    private let dataService: DataService

    init(dataService: DataService) {
        self.dataService = dataService
    }

    func loadData() -> String {
        return dataService.fetchData()
    }
}

DI를 올바르게 적용하려면 인터페이스와 구현체의 관계를 명확히 구분해야 한다. 특정 클래스가 다른 구체 클래스에 직접 의존하면 유지보수가 어려워지고, 테스트가 힘들어진다. 따라서 인터페이스를 활용해 의존성을 줄이고, 구현체는 변경 가능하도록 설계하는 것이 중요하다.

 

위코드와 같이 설계하면 ViewModel은 DataService 프로토콜에만 의존하므로, DataService의 다른 구현체가 추가되더라도 ViewModel 코드를 수정할 필요가 없다. 이를 통해 확장성을 높이고, 테스트도 용이해진다.

 

2. 구체 타입에 대한 의존성 가지기 or 주입시키기 -> 구체타입을 추상화시키기

protocol Logger {
    func log(message: String)
}

class ConsoleLogger: Logger {
    func log(message: String) {
        print("Log: \(message)")
    }
}

class Service {
    private let logger: Logger

    init(logger: Logger) {
        self.logger = logger
    }

    func doSomething() {
        logger.log(message: "서비스 실행 중")
    }
}

구체 타입에 직접 의존하면 코드가 특정 구현체에 종속되어 변경이 어려워진다. 예를 들어, 로깅 기능을 추가하려고 할 때, ConsoleLogger를 직접 사용하면 다른 로깅 방식으로 변경하기 어렵다. 이를 해결하려면 추상화를 활용하여 Logger 프로토콜을 만들고, 이를 구현한 여러 클래스를 사용할 수 있도록 한다.

 

3. 생성자에 너무 많은 파라미터가 주입될 경우 -> 빌더패턴이나 파사드 패턴으로 바꾸기

class Configuration {
    var apiKey: String?
    var baseUrl: String?

    func setApiKey(_ key: String) -> Configuration {
        self.apiKey = key
        return self
    }

    func setBaseUrl(_ url: String) -> Configuration {
        self.baseUrl = url
        return self
    }

    func build() -> Service {
        return Service(apiKey: apiKey!, baseUrl: baseUrl!)
    }
}

 생성자에서 너무 많은 파라미터를 받으면 가독성이 떨어지고 유지보수가 어려워진다. 예를 들어, API 키, 베이스 URL, 타임아웃 설정 등을 생성자로 전달해야 한다면, 코드가 복잡해질 수 있다. 이를 해결하기 위해 빌더 패턴을 사용하면, 객체를 단계적으로 생성할 수 있다.

 
 

4. 의존성 라이브러리의 잘못된 사용 -> DI Container 라이브러리 컴포지션 루트에 묶어서 한곳에서 사용하기

import Swinject

let container = Container()
container.register(DataService.self) { _ in NetworkDataService() }
container.register(ViewModel.self) { r in
    ViewModel(dataService: r.resolve(DataService.self)!)
}

let viewModel = container.resolve(ViewModel.self)!

DI 컨테이너를 여러 곳에서 사용하면 관리가 어려워진다(서비스 로케이터). 이를 해결하기 위해 Composition Root에서 한 번만 설정하고, 이후에는 이 설정을 재사용하는 것이 좋다. iOS에서는 Swinject 같은 라이브러리를 활용해 DI 컨테이너를 구성할 수 있다.

 

5. Leaky Abstraction -> 추상팩토리로 주입

protocol DataServiceFactory {
    func createDataService() -> DataService
}

class NetworkDataServiceFactory: DataServiceFactory {
    func createDataService() -> DataService {
        return NetworkDataService()
    }
}

구현체가 여러 곳에서 직접 사용되면, 내부 구현이 외부로 노출될 위험이 있다. 이를 방지하기 위해 추상 팩토리 패턴을 활용할 수 있다. 예제코드와 같이 인터페이스를 활용하면 내부 구현이 외부로 노출되지 않아 유지보수성이 향상된다.


6.  주관적 결론

의존성 주입(Dependency Injection, DI)은 단순히 객체를 외부에서 주입하는 기술적 개념을 넘어, 객체 합성(Object Composition), 객체의 생명주기 관리(Lifetime Management), 가로채기(Interception)라는 세 가지 핵심 원칙을 기반으로 한다. DI 를 구현하는 여러 방법이 있지만 중요한 것은 세가지 원칙을 잊으며 프로젝트에 알맞게 사용하는 것이다. 

 

개인적으로 내린 결론은 DI 구현에 있어 가장 매력적인 방법은 DI Container 라이브러리를 사용하면서도 의존성을 컴파일 타임에 체크해주는 것이라고 생각했다. 이를 통해 DI Container의 장점은 취하고 단점을 상쇄할 수 있다 보았기 때문이다. 컴파일 타임에서 의존성을 체크해주는 DI 라이브러리의 대표적인 예로 Dagger (Java/Kotlin), Hilt (Android용 DI), Swift에서의 Needle, Dart의 Injectable 등이 있다.  향후 의존성 관리에 있어 위 라이브러리를 적극적으로 사용할 예정이다 :)


7.  참고 자료