아키텍처와 Composition

Written by Tejay
on 

아키텍처와 Composition

  • 객체를 조립해서 쓰는 방식을 Composition이라고 합니다. 로직을 여러 곳으로 분산시킨 후 재조립해 쓰면 많은 장점을 얻을 수 있습니다. iOS와 Swift, 그리고 잘 알려진 여러 아키텍처에서는 어떻게 Composition을 활용하고 있는자 아봅니다.

Composition (합성, 조합)

  • A way to combine objects and data types into more complex ones
  • 결국 어떤 아키텍처가 중요한 것이 아니라 객체를 어떻게 쪼개서 사용하느냐가 중요한 것이다. (Ex.Massive ViewController, Massive RIBs…)
  • 로직을 분산 시킨 다음 합쳐서 원하는 것을 구현
  • 유지보수에 용이
  • 테스트에도 용이(public API, 파라미터 적음)

상속이 항상 좋은 것은 아니다?!

  • 유연성이 떨어짐
    • 코드의 가장 강한 결합 형태이기 때문에 원치 않는 기능들도 들어올 수 있음.
    • 예) UIView의 Frame 프로퍼티 (UISwitch는 예외)

객체 Composition

class A: UIViewController {
  func setupViews() {
    // add subviews
  }
}

class B: A {
  override func setupViews() {
    super.setupViews()
    // add B View
  }
}
  • 화면 구현을 하다보면 위와 같은 경우의 화면이 대부분일 것이다. 이러한 화면을 구현할 때 대부분 위에 코드처럼 사용하게 될 것이다. 하지만 이것은 중장기적으로 보았을 때는 적절하지 못하다. 앞으로 추가될 때마다 A와 B와의 관계가 끈끈하기 때문에 추가되는 작업을 할때 고려해야 할 것들이 많아진다. 이것을 Composition으로 바꿔보면 아래와 같다.
class A: UIViewController {

}

class B: UIViewController {

}

class C: UIViewController {
  private let a: UIViewController
  private let b: UIViewController

  // add a,b to child viewcontroller
}

이렇게 A와 B 화면을 각각 객체를 가지는 새로운 화면 C를 만들어 사용하게 되면 이전에 구현된 A와 B의 관계를 좀더 유연하게 활용할 수 있다.

함수의 Composition

1. map 함수
import UIKit

[1, 2, 3].map { $0 + 1 }
-> [2, 3, 4]

[1, 2, 3].map { $0 + 1 }.map { "만 \($0) 살" }
-> ["만 2 살", "만 3 살", "만 4 살"]

let num: Int? = 1
num.map { $0 + 1 }
-> Optional(2)

let num: Int? = nil
num.map { $0 + 1 }
-> nil

let myResult: Result<Int, Error> = .success(2)
myResult.map { $0 + 1 }
-> success(3)


// 1. Generic 타입
// enum Optional<Wrapped>
// associatedtype Element
// enum Result<Success, Failure> where Failure : Error
// associatedtype Output

// 2. transform 함수를 인자로 받음

// t: A -> B
// F<A> -(map)-> F<B>

let ageString: String? = "10"
let result4 = ageString.map { Int($0) }
// result4의 타입은? -> Int??
// t: A -> Option<B>
// 왜? 원래의 Optional<A> -(map)-> Optional<Option<B>>
// 하지만 Int()의 이니셜라이즈는 옵셔널이기때문에 두번의 옵셔널처리가 됨
// 이것을 사용하기 위해서는

if let x = ageString.map(Int.init), let y = x {
  // y 사용
}

if case let .some(.some(x)) = ageString.map(Int.init) {
  // x 사용
}

if case let x?? = ageString.map(Int.init) {
  // x 사용
}

// flatMap사용

let result5 = ageString.flatMap { Int.init }
// t: A -> Option<B>
// Optional<A> -(flatMap)-> Optional<B>


// UIEvent -> IndexPath -> Model -> URL -> Data -> Model -> ViewModel -> ViewModel

// 예시

struct MyModel: Decodable {
  let name: String
}

let myLabel: UILabel()

if let data = UserDefaults.standard.data(forKey: "my_data_key") {
  if let model = try? JSONDecoder().decode(MyModel.self, form: data) {
    let welcomeMessage = "Hello \(model.name)"
    myLabel.text = welcomeMessage
  }
}

UserDefaults.standard.data(forKey: "my_data_key")
  .flatMap { try? JSONDecoder.decode(MyModel.self, from: $0) }
  .map(\.name)
  .map { "Hello \($0)" }

myLabel.text = welcomeMessage

모듈 Composition

  • 복잡한 기능이 단 하나의 모듈 안에 있다면 내부 객체들끼리 혼잡한 참조 관계에 매우 취약
  • 택시 호출을 위한 모듈만 해도 유저의 위치, 결제, 쿠폰 적용, 백엔드와의 통신 등 기능이 굉장히 많음

  • 이걸 독립적인 모듈로 나눠보면 복잡한 참고 관계를 많이 해소할 수 있음
  • public으로 공개되지 않은 부분은 다른 모듈에서 아예 접근조차 어렵기 때문에 사이드 이펙트의 영향도 덜함
  • public interface만 봐도 모듈 내부의 코드를 보지 않아도 무슨 역할을 하는지 쉽게 파악
  • UI도 각 모듈이 자신이 맡은 역할만 바꾼다면 모듈 내에서 빠르게 변동 가능

마무리

  • Composition은 앱 전반적인 구조를 담당하는 아키텍처 패턴에서도 핵심적인 요소임
Redux 기반 아키텍처의 Composition

  • Redux 기반의 아키텍처(The Composition Architecture, ReSwift, etc)를 예를 들어보면 이 아키텍처는 Reducer, Store, State, Action, View 요소로 로직을 분산 시킴
  • Reducer: 상태를 업데이트
  • Action: 유저가 발생시킨 액션
  • 앱이 커질수록 Reducer의 로직이 많아질 수 밖에 없음
  • 하지만 subReducer를 통해 분리를 시켜서 다시 조합하면 쉽게 유지보수할 수 있음.
  • Action도 마찬가지로 subState로 분리

Interactor, Router 기반 아키텍처의 Composition

  • Interactor, Router 기반 아키텍처(RIBs, VIPER)는 Builder, Router, Interactor, Presenter, View로 로직을 분리 시킴. 이 하나의 단위를 리블이라고 함
  • 리블렛은 한 화면 전체를 담당할 수도 있고 리브를 담당할 수도 있고 화면이 없을 수도 있음
  • 리블렛은 여러개의 child 리블렛으로 분리가 가능
아키텍처에서 Composition이 중요한 이유
  • 앱이 아무리 커지고 복잡해져 결국 맨 마지막에는 이해하기 쉬운 작은 요소들로 분해가 됨
  • 코드 이해가 쉬움
  • 원하는 코드를 찾기 쉬움