본문 바로가기

iOS

[iOS] Singleton 사용시 주의점과 의존성 주입

Singleton 사용시 주의점과 의존성 주입

 

안녕하세요 :D

 

오늘은 평소에 자주 사용하고 있는 패턴 Singleton과 Dependency Injection에 대해서 좀 더 상세히 알아보고 제가 사이드 프로젝트에서

사용하고 있는 방식에 대해 소개해드릴게요!

 

 

Singleton


Dependency Injection을 설명하기 이전에 Singleton에 대해 먼저 알아볼게요!

 

모두가 알고 있듯 싱글톤 패턴은 생성 디자인 패턴입니다.

 

전역에서 접근이 가능한 단 하나의 인스턴스만을 생성하는 것을 보장하는 생성 패턴입니다.

 

싱글톤 패턴은 아래와 같은 경우에 많이 사용되요!

  1. 단 하나의 클래스 인스턴스만 존재하고자 하는 경우
  2. 모든 스레드, 어느 범위에서든 접근이 필요한 경우
  3. 외부 접근으로부터 생성자를 숨기는 경우

동일한 데이터를 필요로하는 화면 또는 객체가 N개 있는 상황을 가정해볼게요.

 

동일한 데이터의 전달을 보장하는 방법이 무엇이 있을까요?

 

이러한 상황에서 싱글톤 패턴은 유용하게 사용됩니다.

 

실제 iOS의 Core 내에서도 싱글톤 패턴은 많이 사용되고 있어요.

 

NotificationCenter, UserDefault와 같은 타입들이 default, shared, system 이라는 이름으로 싱글톤 인스턴스를 제공하고 있죠.

 

Apple은 이렇게나 싱글톤 패턴을 적극적으로 사용하고 있는데 왜 많은 사람들이 싱글톤 패턴을 사용하지 말라고 할까요?

 

사용방법이 쉽고 전역으로 접근이 가능한 등 다양한 장점이 있다보니 안티패턴으로 사용될 가능성이 있기 때문이라고 생각해요.

 

하지만 애플은 위에서 얘기한 3가지 경우를 해결하기 위해 이 싱글톤 패턴을 정확하게 사용하고 있어요.

 

실 예시로 NotificationCenter 클래스는 애플리케이션 내 어느 위치에서든 메세지를 보내고 받을 수 있는 기능을 보장합니다.

 

또한, 의도한대로의 시스템 작동을 보장하기 위해서 초기화 정보를 외부로부터 숨기고 있죠.

 

하지만 메시지 수신을 위한 옵저버 등록 동작이 메모리를 필요로하고 이 메모리를 해제해주지 않을 경우 지속하여 불필요한 메모리를 보관하게 됩니다.

 

싱글톤 패턴을 잘못 사용하게 되면 불필요한 메모리를 계속해서 보관하게 됩니다.

 

또, 무엇보다 싱글톤은 테스트가 어렵다는 단점을 가지고 있습니다.

 

싱글톤 패턴의 사용이 무조건 안좋은 해결법은 아니라고 생각해요.

 

다만, 이 패턴을 적재적소에 잘 활용하는 것이 아니라면 여러 부수적인 위험이 있을수도 있을것 같아요.

 

Dependency Injection


의존성 주입은 의존성 객체를 외부에서 주입받아 사용하는 패턴을 의미해요.

 

이를 통해 의존성 객체를 사용하는 객체는 의존성 객체 생성과 사용을 분리하여 볼 수 있고 생성에 대해서는 전혀 신경을 쓰지 않아도 되죠.

 

DI를 적용한 방식과 적용하지 않는 예제 코드를 하나 살펴볼게요!

 

protocol AServiceType {}

final class AService: AserviceType {}

final class ViewModel {
    private let service: AServiceType

    // DI를 적용하지 않는 경우
    init() {
        self.service = AService()
    }

    // DI를 적용한 경우
    init(service: AServiceType) {
        self.service = service
    }
}

2개의 생성자 예시가 있습니다.

 

첫번째 예시는 클래스 내부에서 생성자를 만들어서 초기화를 하는 형태로 의존성 생성과 사용이 클래스 내에 혼합되어 있는 형태에요.

 

두번쨰 예시는 클래스 외부에서 의존성을 주입받아 초기화 하는 형태로 의존성 생성과 사용 코드가 내외부로 분리되어 있어요.

 

DI를 적용하는 방식중에는 Factory, Container 등 다양한 방식을 사용하는데 이것은 중요하지 않아요.

 

중요한것은 DI 빌드 방법이 아니라 의존성을 관리하며 외부로부터 주입한다는 것과 그 과정에서 IoC가 일어난다는 것 입니다.

 

2번의 예시처럼 의존성을 외부에서 생성해서 주입하면 어떤 장점이 있을까요?

  • 객체간 결합도가 낮아집니다. DIP를 준수하여 제어가 역전되며 프로토콜 타입에 의존하며 해당 프로토콜을 준수하는 타입이라면 무엇이든지 주입받을수 있어요!
    이를 통해 보다 유연하고 확장하기 편한 상태를 유지할 수 있어요.

 

  • 코드 테스트가 훨씬 수월해집니다. 위 예시의 경우 결국 언제든지 원한다면 AServcieType 객체 생성이 필요한 방식과 관련된 동작을 다른 형태로 변경할 수 있습니다.
    실제 테스틀르 할 때는 해당 프로토콜을 준수하는 Mock 객체를 생성해서 주입한다면 테스트가 훨씬 수월해지겠죠~?

 

  • 의존성 객체 생성과 의존성 객체를 사용하는 클라이언트 코드 부분을 분리할 수 있습니다.
    ViewModel 클래스의 역할을 생각해볼게요. 이 클래스는 ASerivceType 프로토콜을 준수하는 객체를 활용한 비즈니스 로직을 처리합니다. 과연 이 클래스가 AServiceType을 준수하는 객체를 생성할 책임을 가지고 있을까요?

 

자 그럼 이제 DI를 적용하는 방식 중 가장 널리 사용되고 있는 DI Container를 활용하는 방식에 대해서 알아볼까요~?

 

 

DIContainer는 어떻게 써야하는가?


iOS 진영에서 가장 많이 사용하는 DI 프레임워크의 대표적인 예시로는 Swinject이 있어요.

 

내부를 살펴보면 container 라는 엔티티가 존재합니다.

 

이름만 봐도 어떤 역할과 책임을 가지고 있을지 대략적으로 알 수 있죠?

 

Container 는 전역 저장소로 의존성을 저장하고 관리하는 객체에요.

 

반드시 단일 인스턴스 형태로 존재해야 하지만 Singleton의 형태로 사용해야 한다는 것은 아닙니다 :D

 

(단일 인스턴스 제공을 보장 != 싱글톤)

 

위 맥락에서 중요한 포인트는 단일 초기화 지점이 보장되어야 한다는 것이에요.

 

일반적인 DI 프레임워크의 경우에는 의존성을 등록하는 register<T> 인터페이스와 등록한 의존성을 불러오는 resolve<T> 라는 인터페이스가 제공되요.

 

Container는 본질적으로 큰 해시테이블과 유사한 형태이기 때문에 의존성을 등록하기 위해서는 해당 의존성을 식별할 수 있는 키가 필요하고 일반적으로는 DIP를 준수하는 선에서 구체화 타입을 의존성으로 등록할때 해당 타입이 준수하고 있는 추상화 타입을 키로 사용해요!

 

위에서 살펴봤던 예시를 본다면 AService 클래스를 의존성으로 등록하는데 해당 키 값을 AServiceType 으로 사용하는 것 처럼요!

 

제가 현재 진행중인 사이드 프로젝트에서의 DIContaner 사용 방식에 대해 코드로 살펴보면서 좀 더 이해를 도와볼게요.

 

public protocol DependencyRegistable {
  func register<T>(_ serviceType: T.Type, _ object: T)
}

public protocol DependencyResolvable {
  func resolve<T>(_ serviceType: T.Type) -> T
}

public typealias ContainerType = DependencyAssemblable & DependencyResolvable

public final class DependencyContainer: ContainerType {

  // MARK: Properties
  private let container: Container

  // MARK: Initializer
  public init(container: Container) {
    self.container = container
  }

  // MARK: Methods
  public func resolve<T>(_ serviceType: T.Type) -> T {
    container.resolve(serviceType)!
  }

  public func resolve<T>(_ serviceType: T.Type, name: String?) -> T {
    container.resolve(serviceType, name: name)!
  }

  public func register<T>(_ serviceType: T.Type, _ object: T) {
    container.register(serviceType) { _ in object }
  }

 

일단은 의존성을 관리하기 위한 인터페이스가 필요하겠죠~?

 

의존성을 등록 기능의 메서드를 선언한 Registable 프로토콜과 등록한 의존성을 가져올 수 있는 Resovable 프로토콜을 분리했어요.

 

이 2개의 프로토콜은 프로젝트 전역에서 고루고루 사용되고 있고 이 2개의 프로토콜을 동시에 채택하는 타입 DependencyContainerType 을 typealias로 명명했어요.

 

그리고 이 타입을 준수하는 DependencyContainer 구현체가 있는데 resolve, register 와 같이 의존성 관리를 위한 메서드를 제공하고 있어요.

 

또, 내부를 보면 싱글톤 패턴을 사용하고 있지 않은것을 볼 수 있어요.

 

위에서도 얘기했듯 Container는 단일 초기화를 보장하는 것이 중요해요, 따라서 SceneDelegate에서 초기화 작업을 진행해주고 있어요.

 

그럼 이제 실제 Container에 의존성을 저장하고 가져오는 예시 코드를 살펴볼게요.

 

// Domain 계층 내 존재하는 의존성 등록 예시
public struct DomainAssembly: Assembly {

  // MARK: Methods
  public func assemble(container: Container) {
    container.register(FetchVideoPostUseCaseType.self) { resolver in
      let service = resolver.resolve(VideoPostServiceType.self)!
      return FetchVideoPostUseCase(videoPostService: service)
    }

    container.register(ExclameVideoPostUseCaseType.self) { resolver in
      let service = resolver.resolve(VideoPostServiceType.self)!
      return ExclameVideoPostUseCase(videoPostService: service)
    }

    container.register(LikeVideoPostUseCase.self) { resolver in
      let service = resolver.resolve(VideoPostServiceType.self)!
      return LikeVideoPostUseCase(service: service)
    }

    container.register(SignInUseCaseType.self) { resolver in
      let service = resolver.resolve(SignInServiceType.self)!
      return SignInUseCase(service: service)
    }

    ...
  }
}

// 등록한 의존성을 가져와서 사용하는 예시.
let useCase = injector.resolve(SignInUseCaseType.self)
let reactorDependency: SignInReactor.Dependency = .init(
  coordinator: coordinator,
  useCase: useCase
)
let reactor: SignInReactor = .init(dependency: reactorDependency)
let signInViewController: SignInViewController = .init(reactor: reactor)

이런식으로 의존성을 등록하고 가져오고 하는 방식으로 사용하고 있어요 :)

 

 

이상으로 오늘의 포스팅은 여기서 이만 마치도록 할게요!

혹시나 궁금하신 내용 혹은 틀린 내용이 있다면 반드시 무적권 댓글로 남겨주세요 :D

 

 
 
 
 

'iOS' 카테고리의 다른 글

[iOS] Swift Concurrency  (0) 2023.08.05
[iOS] Compositional Layout 활용 예시  (0) 2023.05.28
[iOS] Protocol Composition과 Default Implementation 활용하기  (0) 2023.05.22
[iOS] RunLoop  (0) 2023.05.20
[iOS] Actor, Task, Async&Await  (0) 2023.04.02