Advanced iOS App Architecture - Ch4: Object & Their Dependencies (이론 부분)

객체들을 어떻게 작은 객체들로 나누고, 함께 묶을지 설계하는 것은 구조적 기술에서 핵심적이다.

기대할 수 있는목표

이 단원의 의존성 관리 기술을 배우게 되면 기대할 수 있는 목표는 다음과 같다.

  • Maintainability: ‘사이드 이펙트’ 없이 코드의 변경이 용이
  • Testability
  • Substitutability: 컴포넌트 간에 대체 능력이 높아져 A/B 테스팅이 쉬워진다.
  • Deferability: ‘어떤 DB를 선택할까?’ 등의 큰 결정을 미룰 수 있다.
  • Parallel work streams: 부분이 잘 나뉘어져 있어 협업이 용이
  • Control during development
  • Minimizing obejct lifetimes: state를 줄여줘 앱을 관리하기 쉬워지게 함.
  • Reusability

관련 용어

  • Dependency: 다른 객체가 특정 작업을 하기 위해 의존하는 객체
  • Transitvie dependencies: ‘의존성’이 또 다른 객체에 의존하는 것
  • Object-under-consturction(Ouc): Dependencies에 의존하는 객체
  • Consumer: Object-under-construction을 사용하는 객체

Ouc라고 줄여서 표현하겠다.

의존성이 어떻게 생기는가?

  • 거대한 클래스를 리팩토링할 때: 역할이 너무 많은 클래스의 경우 이것을
  • 중복 코드를 제거할 때: 중복 로직을 하나의 객체에 모아 이 객체에 의존하게 함

의존성 관리에서 핵심적 고려사항

의존성에 접근(Access)하는 것

From the inside

  • 전역 프로퍼티
  • 초기화: 의존성이 일시적이라면 Ouc보다 오래 살아 있을 필요가 없다. Ouc가 의존성을 초기화하도록 한다.

From the outside

  • 초기화 인자(argument): 의존성이 Ouc의 초기화 인자값으로부터 생성됨
  • Mutable 저장 프로퍼티: 이미 생성된 Ouc의 mutable한 저장 프로퍼티에 넣어주는 방법
  • 메서드: Ouc의 메서드에서 관리

대체 가능성(Subsitutability)의 결정

모든 앱에서 대체 가능성이 필요한 것은 아니다. 예를 들어 순수한 비즈니스 로직만 가지고 있어서 의존성이 아무런 사이드 이펙트가 없다면 대체 가능성이 필요없을 것이다. 그러나 의존성이 디스크에 write한다든지, 네트워크 요청을 한다든지, analytic 이벤트를 보내는 등 이러한 의존성의 기능은 개발 또는 테스트 시에는 대체하여 사용하기를 원할 것이다.

Designing substitutability

만약 이러한 대체 가능성이 필요하다면 어느 시기에 필요한 지도 결정해야 한다. 만약 A/B 테스팅을 하고 싶다면 런타임 시에, 그게 아니고 테스트만이 목적이라면 컴파일 타임으로 하면 된다.

의존성 패턴

  • Dependency Injection: Ouc 바깥에서 모든 의존성을 주입
  • Service Locator: 의존성을 생성하고 의존성을 붙들고 있는 Service Locator 객체를 이용. 만약 Ouc가 의존성이 필요하다면 Service Locator에 요청하기만 하면 된다.

그 밖의 패턴들…

  • Environment: Service Locator와 비슷하게 Ouc가 필요한 의존성을 제공한다. 그러나 차이점은 Environment는 Ouc 내부에서 접근하는 반면, Service Locator는 Ouc에 제공되어 진다(?)는 점이다.
  • Protocol Extension

Dependency Injection

  • 주요 목표는 Ouc 안에서 의존성을 찾는 것이 아니라 바깥에서 의존성을 제공하는 것
  • 이것은 바깥에서 의존성을 관리할 수 있다는 장점을 가져 객체가 어떤 의존성을 가지는 지 쉽게 확인이 가능함.

  • DI는 의존성 역전 원칙에 기반한다.

참고 링크: SOLID with Swift - PacaLog

주입의 유형

  • Initializer
    • 초기화 시에 파라미터로 전달
    • 의존성이 nil이 되거나 의존성이 바뀌는 것을 신경쓰지 않아도 됨.
  • Property
    • Ouc의 초기화가 끝난 뒤에 전달해야 할 때 유용하게 사용
    • 만약 마땅한 기본값이 없을 경우 Optional로 선언
    • Interface Builder를 통해 초기화되는 VC의 경우, 이런 주입을 많이 사용
  • Method
    • 거의 드물게 사용
    • 의존성이 메서드 실행 시에만 필요한 경우

A good rule of thumb

  • Ouc가 의존성없이 기능을 하지 못할 경우: 초기화 주입을 사용
  • 의존성없이도 기능을 할 경우: 다른 주입을 사용해도 되지만 초기화 주입을 선호

Substituting dependency implementations

  • 주입을 사용하는 것만으로는 testability를 얻을 수 없음
  • 대체 가능성을 위해서는, 의존성의 프로토콜 선언하여 이를 따르는 여러 클래스들을 사용처에서 주입할 수 있어야
  • If-else 구문을 통해 컴파일 시에는 fake type을, 실제 product에는 진짜 type을 넣어줌으로써 유연함을 증대시킴

Compile-time substitution

테스트 스킴을 만들어 #if와 #endif를 통해 Test와 Run 스킴 간의 실행 코드를 달리 한다.

Run-time substitution

  • 의존성의 초기화 시에 if 구문을 넣어 특정 값(예: 앱 버전 값)을 통해 분기 처리
  • 앱의 launch 인자값들을 통해 분기 처리

DI 적용 방법

On-demand approach

DI를 배우기 위해서 만들어진 접근법이다. On-demand에서는 Consumer가 새로운 Ouc가 필요할 때 Ouc의 초기화 시에 요구되는 의존성을 생성하거나 찾는다. 다시 말해 Consumer가 필요한 의존성을 제공하는 책임을 가지고 있다.

Initializing ephemeral dependencies

Ouc보다 더 오래 살 필요가 없는 의존성의 경우 Ouc에 의해 소유(owned)되며, Consumer가 간단히 의존성을 초기화하고 제공해주면 된다. 이 경우 Ouc는 의존성을 ‘프로토콜’ 타입으로 사용하기 때문에 구체적인 동작은 알지 못하고 Consumer는 알아야 한다.

Finding long-lived dependencies

의존성이 Ouc보다 오래 살아야 하는 경우, Consumer는 의존성에 대한 레퍼런스를 유지해야 한다. 이는 Consumer가 직접 가지고 있을 수도 있고, 상위 부모가 가지고 있을 수도 있다.

Pros

  • 설명과 이해가 쉽다.
  • Fake 구현으로 대체가 용이해 Testable하다.
  • 결정을 미룰 수 있다. 예를 들어 DB를 고를 동안 메모리 저장 방식을 사용하고 이를 대체한다고 했을 때 메모리 의존성 구현부의 초기화 부분을 DB로 바꾸기만 하면 되기 때문이다.
  • 한 기능을 같이 개발할 수 있다. Ouc와 의존성을 따로 개발함으로써 이는 가능하다.

Cons

  • 의존성 초기화가 분산되어 있어 중복이 일어날 수 있다.
  • 의존성이 다른 의존성에 의존할 수 있기 때문에 한 Ouc를 여러 Consumer가 사용할 경우 불필요한 중복 로직을 타야 한다.

Factories approach

On-demand 접근법은 확장 가능성이 낮은 분산 접근법이고, 이는 중복된 의존성 초기화 로직을 쓰게 한다. 이와 반대로 Factories 접근법은 중앙 집권적 접근법이다.

이 접근법은 Ephemeral한 의존성에만 동작하며 싱글턴 같은 Long-lived 의존성에는 적합하지 않다.

Factories class

  • 많은 팩토리 메서드로 이루어지며 이 메서드들은 Ouc나 의존성을 생성한다.
  • State를 가지지 않으며 어떠한 저장 프로퍼티도 가지지 않는다.
  • 목표 중 하나는 Consumer가 의존성 그래프를 알지 못해도 Ouc를 생성하는 것이 가능한 것이다.

Dependency factory methods

Resolving protocol dependencies

  • 의존성 팩토리 메서드는 전형적으로 대체 가능성을 위해 ‘프로토콜’ 반환 타입을 가진다.
  • 이를 통해, 의존성 팩토리 메서드는 프로토콜과 실제 구현 타입의 매핑을 감싼다. 이를 resolution이라 한다.
  • 이유는 의존성 팩토리 메서드가 특정 프로토콜 의존성을 위해 어떤 구현을 생성해야 할지 풀어내기(resolving) 때문이다.

Ouc factory methods

Ouc 팩토리 메서드의 책임은 Ouc를 초기화하는 데 필요한 의존성 그래프를 생성하는 것이며 의존성 팩토리 메서드와 비슷하게 생겼다. 유일한 다른 점은 Ouc 팩토리 메서드는 밖에서 불리는 반면, 의존성 팩토리 메서드는 안에서 불린다.

Substituting dependency implementations

On-demand 방식과 비슷하게 의존성 resolution 부분을 조건문으로 감싸면 된다. 하지만 더 쉬운 점은 Factory 클래스 내에서 모두 관리되기 때문에 모든 Consumer의 부분에 조건문을 넣을 필요없이 클래스 내의 의존성 resolution부분만 감싸면 된다.

Pros

  • Ephemeral한 의존성의 생성이 한 곳에서 이루어져 코드 제어가 쉬움
  • 테스트를 위한 fake 구현으로의 대체도 한 곳에서 변경이 가능해 쉬움

Cons

  • 큰 앱의 경우, 팩토리 클래스가 매우 커질 수 있음
  • Ephemeral에만 적용이 됨 => Container 접근법에서 해결

Single-container approach

Contianer는 Factories 클래스의 stateful 버전으로 long-lived 객체를 가질 수 있다. 예를 들어 데이터 저장소 같은 경우이다.

Dependency factory methods

Ephemeral 의존성을 생성하는 방식은 Factories 접근법과 같다. 하지만 long-lived 의존성을 다루는 부분이 다르다. Long-lived 의존성을 위해 저장 프로퍼티에서 의존성을 가져온다. 이 때문에 init 메서드에서 파라미터를 추가하지 않아도 된다는 이점이 생긴다.

Ouc factory methods

위와 마찬가지로 저장 프로퍼티를 통해 Long-lived 의존성을 가져와 Ouc의 생성에 넣게 되고 파라미터를 추가하지 않아도 된다는 이점을 가진다. Consumer는 파라미터의 전달없이 Ouc를 생성해 사용이 가능하다.

Creating and holding a container

Factories와 달리 Container는 재사용되는 의존성을 잡고 있기 때문에 하나만 생성되어야 한다. 그래서 앱의 launch 시기에 Container를 생성하는 것이 일반적이다.

Pros

  • Container가 앱 전체의 의존성 그래프를 관리할 수 있다.
  • Container가 싱글턴을 관리할 수 있어 싱글턴이 전역으로 관리되지 않아도 된다.
  • Container 클래스 밖에서 코드 변경없이 객체의 의존성 그래프를 변경할 수 있다.

Cons

  • 한 Single-Container 클래스가 매우 커질 수 있다. => Container Hierarchy

Designing container hierarchies

Single Container의 이슈들

  • 자라나는 컨테이너 클래스: 앱이 커질 수록, 너무 커진다.
  • 옵셔널 프로퍼티: 이상적으로 사용하지 않는 것이 좋다.

이 문제들을 해결하기 위해 다음과 같이 한다.

Object scopes

객체가 사용될 수 있는 범위를 만들어 그 안에서는 이 객체가 있다고 보장할 수 있게 함으로써 불필요한 옵셔널을 줄인다. 대부분의 앱에서는 다음과 같은 scope를 가진다.

  • App scope: 앱의 생명 주기와 같이 동작
  • User scope: 한 유저의 계정과 관련

  • Feature scope

  • Interaction scope

Container hierarchy

Container는 갖고 있는 의존성들의 생명 주기를 관리한다. 그래서 설계한 Scope 하나당 하나의 Container가 매핑된다.

Designing a container hierarchy

  • Child conatiner는 Parent container에게 의존성을 요청할 수 있다. 그 반대는 불가하다.
  • App Container는 항상 root다.
  • Child Container의 init에는 Parent Container를 인자로 넣는다. 또는 필요한 의존성을 갖고 있는 Parent Container를 저장 프로퍼티로 갖고 있는다.

이러한 방식으로 첫 번째 이슈를 해결할 수 있다.

Capturing data

Container는 data model을 가지고 관리하는데, 이 값은 Container의 생명 주기동안에는 immutable하다. Data를 Captureing 한다는 의미는 mutable한 값을 immutable하게 변환한다는 뜻이다.

예를 들어 AppContainer 가 user session을 관리한다고 하자. 그러면 UserContainerAppContainer 를 인자로 받아 초기화 할 경우에 유저의 로그인 상태에 대해 신경쓸 필요가 없다. UserContainer 는 유저의 로그인 세션이 있어야만 동작하기 때문에 애초에 UserContainer 안에서는 불변 값으로 취급하고 동작할 수 있다.

Pros

  • Scoping을 통해 의존성을 설계하고 불필요한 싱글턴을 줄일 수 있다.
  • Scope 안에 값을 가둬두는 것으로 mutable한 데이터를 immutable하게 다룰 수 있다.
  • Container 클래스가 작게 쪼개진다.

Cons

  • Single-Container 보다 좀 더 복잡하다.
  • 복잡한 앱일 경우, 긴 Container 클래스를 갖게 된다.

Reference

  • Advanced iOS App Architecture - Ch4