Struct

  • stack 영역을 사용한다. Heap을 쓰지 않으므로 Reference couting 이 필요하지 않음
  • 다른 변수에 할당한 경우 값 전체가 복사가 되며, 할당된 값을 변경하더라도 기존의 값이 변경되지 않는다.
    • 따라서 Thread 의 의도치 않은 공유로부터 안전하다.
  • Identity가 아닌 Value 자체가 중요하다.
    • 그렇다면 성능은?
      • 내부 데이터(property)가 만약 Heap 과 혼용되는 경우는 결국 값의 복사 + 레퍼런스도 카피가 된다.
      • 이때 말하는 내부 데이터를 예를 들자면 string, array, set, dictionary
    • 또한 COW(Copy On Write) 를 통해서 불필요한 작업을 줄임
    • 만약 immutable 하게 만들면 어떤가?
      • 실행할때 새로운 객체를 만드는 것은 비효율적이다. mutable + value type의 경우 값을 변경하면 되는데 immutable + reference 의 경우 객체를 생성해서 넘겨야하는 경우가 있다면. 변경하지 않아도 되는 값 마저 다 생성해서 넘기는 것이 맞는 것인가? 에 대한 생각이 든다.

Class

  • 언제쓰는가? Value 자체보다 Identity가 더 중요한 경우가 있다.
  • 대표적으로 UIView 를 생각해보자.

성능에 영향을 미치는 3가지

  1. stack vs heap
  2. reference counting
  3. method dispatch > static vs dynamic

stack vs heap - Heap 할당 줄이기

  • Heap 할당이 성능에 영향을 미치는 요소는 다음이 포함되어 있다.
    • 비워진 공간을 찾고 원하는 만큼 할당시켜야 하며, 이를 관리하는 것이 복잡하다.
    • 동시에 thread safe 해야하기 때문에 lock synchroniztion 동작등이 성능에 큰 영향을 미친다.
  • 예를 들어 Dictionary 의 key값을 설정할때 string 을 사용하지 말고 struct를 활용해보자
    • string을 사용하는 경우 변수에 할당할때 heap 에 저장하게 된다. 만약 loop 내부에서 돌게 된다면???
    • 따라서 value type인 struct를 써보자 이때 hashable 만 채택하면 된다.

reference counting

  • 정말 자주 사용된다. 또한 성능에 영향을 미치는 큰 이유중 하나가 thread safe 해야하기 때문
  • 따라서 최대한 줄이는 것이 좋다. 위와 마찬가지로 loop 내부에 있다면…?? value sementics에 비해 성능이 좋지 못할 것

Method Dispatch

  • static
  • 컴파일 시점에 메소드의 실제 코드 위치를 안다면 실행중 찾는 과정 없이도 해당 코드 주소로 점프가 가능함.
  • 컴파일러 최적화 및 메소드 인라이닝 가능
    • 메소드 인라이닝이란, 컴파일 시점에 메소드 호출부도 같이 넣은것 처럼 동작하는 것
    • 이로 인해 call stack overhead 가 줄어들고 루프 안에서 호출하는 경우는 큰 효과를 누릴 수 있음

  • dynamic
  • static 과 다르게 컴파일 시점에는 어디에 있는 메소드가 호출되는지 모른다. 따라서 런타임시 이를 찾아내야함. 이로 인해 컴파일러 최적화가 어려움.
  • 따라서 static 으로 강제하기 위해서 final, private 의 사용을 습관화 해야함
    • Xcode : Whole Module Optimization 옵션 사용할 것

추상화 기법들

  • Class 는 성능 안좋은 것은 다 가지고 있다.
    • 그나마 개선이 가능한 부분은 final 을 통한 static dispatch
  • Struct 를 사용하는데 내부에 Reference Sementics 가 많다면??
    • 값을 복사한다고 가정했을 때 내부의 reference Type 만큼 reference counting 이 일어나게 됨. 이는 성능에 영향을 미친다.
    • 참고로 String, Array, Dictionary 도 Struct 이지만 내부적으로는 reference 를 사용하고 있다.
    • 따라서 줄이기 위해서 enum 등을 사용하고, 가능하다면 reference type들을 묶을 수 있는 방법을 생각해보자.
  • Protocol 은 Value Type에서도 다형성을 구현할 수 있는 큰 장점이 있다.
    • 의문점 1 : 어떤 타입이 들어갈지 모르는데 사이즈를 어떻게 계산해 놓는가?
      • 일정 크기를 넘어가면 내부적으로 heap을 사용하고, 실제로는 주소만 갖고 있다. 프로토콜의 실제 값을 관리하는 Existential Container를 통해 이뤄진다.
      • 이렇게 일정 크기를 판단하고 Existential Container를 관리하는 주체는 VWT ( Value Witness Table ) 라고 한다. 이는 각 프로토콜 마다 존재한다.
    • 의문점 2 : 어떻게 적절한 함수를 호출하는가?
      • PWT ( Protocol Witness Table )이 있다. V-table과 유사함.
      • 위에서 설명한 VWT, PWT 는 Extential Containor 내부에서 참조할 수 있다.
      • 다시한번 볼것 VWT, PWT 차이점 등등
    • 만약 큰 값을 가진 Protocol 을 복사한다면?
      • 위에서 말한것 처럼 Heap을 사용하는 protocol을 복사하게 되면 heap을 또 사용해야한다. 즉, 복사를 할때마다 heap 을 할당해야함. 이는 비 효율적
      • 이는 COW ( Copy On Write ) 를 통해서 개선하게 되었다.
  • Generic
    • protocol 과 비슷하게 VWT, PWT 로 구현이 되어있다.
    • Generic 특수화 : 여러 타입을 대응하려고 Generic을 만들었지만, 만약에 성능을 생각해서 각 타입으로 나눠서 쓴다면 메소드 인라이닝이 가능해져서 성능이 좋아질 것이다. > 성능을 생각하면 Generic을 쓰면 안되는가?? > WMO가 이를 대신 해준다.
    • 이런 특수화는 정적 다형성(Static Polymorphism) 덕분에 가능하다. > 컴파일 시점에 부르는 곳 마다 타입이 정해져 있고, 런타임시 바뀌지 않으며, 특수화가 가능하다.
Reference

https://www.youtube.com/watch?v=z1Gf6EosaUQ

  • 발표자님께서 참고하신 WWDC
    • WWDC 2016 : S416 : Understanding Swift Performance
    • WWDC 2015 : S409 : Optimizing Swift Performance
    • WWDC 2015 : S414 : Building Better Apps With Value Types in Swift

'iOS & Swift' 카테고리의 다른 글

UICollectionViewCompositionalLayout  (0) 2022.10.31
UINib Basic  (3) 2021.10.04
Property Wrapper  (0) 2021.06.07
Apple 공식 Guide & Reference 모음  (0) 2021.05.21
Escaping Closure  (0) 2021.05.16

이번에는 Swift 5.1 부터 사용이 가능해진 Property Wrapper 에 대해서 알아보겠습니다. 간단하게 요약하자면 Property Wrapper 는 getter와 setter 관련된 로직을 포함하고 있는 프로퍼티를 쉽게 선언할 수 있도록 합니다. 문서의 예시와 더불어 설명이 부족한 부분은 직접 중간 중간에 추가하였습니다. 모든 레퍼런스는 하단에 준비되어 있습니다.

 

간단한 정의와 더불어 몇 개의 예시를 들면서 어떻게 사용하는 지 배워보겠습니다. Property Wrapper 의 정확한 정의는 값을 저장하는 코드와 정의하는 코드를 분리시킬 수 있도록 하는 레이어를 추가하는 것입니다. 예를 들어 몇가지 프로퍼티에서 값을 가져올때 thread-safe 를 체크하거나, 값을 저장할 때 database 에 저장해야 한다면 모든 프로퍼티에 일일이 설정을 해줘야 할 것입니다. 이런 반복되는 작업을 없애고 특정 프로퍼티에 Property Wrapper를 추가함으로써 공통된 코드를 없앨 수 있습니다. 한번 Property Wrapper를 정의한다면 다양한 프로퍼티에서 이를 쉽게 재사용할 수 있습니다.

 

정의 및 사용 예시

이런 Property Wrapper 를 정의할 때에는 structure, enumeration, class 를 통해서 wrappedValue 를 정의해줘야 합니다. 문서에 있는 Property Wrapper의 정의와 사용법에 대한 예시를 보겠습니다.

 

// Definition - 최대 12의 값을 가질 수 있는 프로퍼티를 만들고 싶다.
@propertyWrapper
struct TwelveOrLess {
    private var number = 0
    var wrappedValue: Int {
        get { return number }
        set { number = min(newValue, 12) }
    }
}	
// Use
struct SmallRectangle {
    @TwelveOrLess var height: Int
    @TwelveOrLess var width: Int
}

var rectangle = SmallRectangle()
print(rectangle.height)
// Prints "0"

rectangle.height = 10
print(rectangle.height)
// Prints "10"

rectangle.height = 24
print(rectangle.height)
// Prints "12"

 

@propertyWarpper 를 선언한 이름의 어노테이션 ( @{propertyWrapperName} )을 붙인 뒤 Structure 내부에서 wrappedValue를 정의한 것을 확인할 수 있습니다. 참고로 내부에 number는 private으로 외부에서 접근을 할 수 없는데, 이는 주어진 방식을 통해서 저장 혹은 값을 읽는 처리를 하기 위함이라고 이해하시면 됩니다. 사용하는 방법 또한 굉장히 간단합니다. 사용하고자 하는 프로퍼티 앞 혹은 위에 @{propertyWrapperName} 을 통해서 위에서 정의한 PropertyWrapper 를 사용할 수 있습니다.

 

TwelveOrLess 예시를 확인해보면 먼저 최대 12의 값을 가질 수 있는 Property Wrapper 를 정의하였습니다. 이후 height 과 width 프로퍼티를 Property Wrapper와 함께 선언해주었습니다. 이후 0을 할당한 경우 그대로 0 이 들어가며 마찬가지로 10을 할당한 경우 그대로 10이 할당됩니다. 여기서 height 에 24를 할당한 경우는 @TwelveOrLess 의 setter에서 정의한 대로 24와 12중 작은 값인 12가 할당됩니다.

 

초기값 정의하기

상단에서는 Property Wrapper 내부에서 초기 값을 정의했습니다. 이렇게 내부에서 처리하는 방식은 다른 초기값을 설정해 줄 수 없다는 문제점이 발생합니다. 초기값 그리고 Customization을 지원하기 위해서 PropertyWrapper 에서도 initializer를 추가할 수 있습니다. 아래의 예시를 보겠습니다.

 

// Definition
@propertyWrapper
struct SmallNumber {
    private var maximum: Int
    private var number: Int

    var wrappedValue: Int {
        get { return number }
        set { number = min(newValue, maximum) }
    }

    init() {
        maximum = 12
        number = 0
    }
    init(wrappedValue: Int) {
        maximum = 12
        number = min(wrappedValue, maximum)
    }
    init(wrappedValue: Int, maximum: Int) {
        self.maximum = maximum
        number = min(wrappedValue, maximum)
    }
}
// Use
struct UnitRectangle {
    @SmallNumber var height: Int = 1
    @SmallNumber var width: Int = 1
}

var unitRectangle = UnitRectangle()
print(unitRectangle.height, unitRectangle.width)
// Prints "1 1"

 

여기에서 height = 1 부분을 주목하시기를 바랍니다. 이렇게 직접 할당을 하는 경우 Property Wrapper 는 init(wrapperValue:) 를 찾아 이를 생성하려고 합니다. 만약 해당 이니셜라이저 대신 하나의 argument 를 가지지만 다른 프로퍼티를 초기화하는 intializer 가 있는 경우는 어떻게 될까요? 이 경우는 에러가 발생하여 원하는 결과를 얻을 수 없습니다.

 

@propertyWrapper
struct SmallNumber { 
  ...
  init(test: Int) {
    self.maximum = test
    self.number = test
  }
}

// ...
// Error: incorrect argument label in call (have 'wrappedValue:', expected 'test:')
@SmallNumber var height: Int = 1

 

그렇다면 init(wrappedValue:maximum:) 은 어떻게 사용할까요? 어노테이션을 사용하면서 이니셜라이저를 사용하면 됩니다. 아래의 예시를 보면 쉽게 이해할수 있습니다.

 

struct NarrowRectangle {
    @SmallNumber(wrappedValue: 2, maximum: 5) var height: Int
    @SmallNumber(wrappedValue: 3, maximum: 4) var width: Int
}

var narrowRectangle = NarrowRectangle()
print(narrowRectangle.height, narrowRectangle.width)
// Prints "2 3"

narrowRectangle.height = 100
narrowRectangle.width = 100
print(narrowRectangle.height, narrowRectangle.width)
// Prints "5 4"

 

해당 예제에서 height은 init(wrappedValue: 2, maximum: 5) 를 통해서 생성된 프로퍼티이며 초기값은 2를 가지고 최댓값은 5가 될 것입니다. width의 경우 init(wrappedValue: 3, maximum: 4)를 통해서 생성된 프로퍼티로 초기값은 3을 가지고 최댓값은 4가 될 것입니다. 따라서 첫번째 프린트에서는 초기값인 "2 3"이 나오는 것을 확인할 수 있으며, 이후에 각각 프로퍼티에 100을 할당하고 다시 프린트 한 경우는 각자의 최댓값이 나오는 것을 확인할 수 있습니다.

 

여기에서 조금 더 변형된 initalizer를 고려해보자면 아래와 같습니다. Swift 에서 PropertyWrapper의 assignment(할당)는 wrappedValue 에 인자를 넘기는 것과 동일하게 생각합니다. 따라서 wrappedValue와 다른 arguments가 결합된 형태라면 할당된 값은 wrappedValue에 전달되며 나머지 인자는 따로 구현을 해줘야 합니다. 아래의 예제를 살펴보겠습니다.

 

struct MixedRectangle {
    @SmallNumber var height: Int = 1 
  	// init(wrappedValue: 1)
    @SmallNumber(maximum: 9) var width: Int = 2
    // init(wrappedValue: 2, maximum: 9)
}

var mixedRectangle = MixedRectangle()
print(mixedRectangle.height)
// Prints "1"
mixedRectangle.height = 20
print(mixedRectangle.height)
// Prints "12"

 

width를 살펴보면 2를 할당하고 maximum은 따로 설정한 것을 확인할 수 있습니다. 제대로 보지 않으면 순서가 헷갈릴 수 있지만, 할당 자체가 wrappedValue 인자로 전달된다는 것을 인지한다면 크게 어렵지 않을 것입니다.

 

Projecting a Value From a Property Wrapper

Property Wrapper는 Wrapped Value와 더불어서 Projected Value라는 예약어가 있습니다. Projected Value는 $ 로 시작하는 것을 제외하고는 Wrapped Value와 동일합니다. 다만 이를 사용할 때는 $를 붙여서 사용하며 해당 값은 사전에 정의된 로직을 제외하고 따로 조작할 수 없다는 차이점이 있습니다. 아래 예제와 함께 설명하겠습니다.

@propertyWrapper
struct SmallNumber {
    private var number = 0
    var projectedValue = false
    var wrappedValue: Int {
        get { return number }
        set {
            if newValue > 12 {
                number = 12
                projectedValue = true
            } else {
                number = newValue
                projectedValue = false
            }
        }
    }
}
struct SomeStructure {
    @SmallNumber var someNumber: Int
}
var someStructure = SomeStructure()

someStructure.someNumber = 4
print(someStructure.$someNumber)
// Prints "false"

someStructure.someNumber = 55
print(someStructure.$someNumber)
// Prints "true"

 

우리가 배운 것 처럼 Property Wrapper는 정의에 따라서 사용자가 할당한 값 이외에 다른 값으로 변환되는 경우가 있습니다. 예제에서는 Projected Value를 이용해서 변환이 이루어졌는지 확인할 수 있도록 하였습니다. 따라서 4를 할당한 경우 변환이 이루어지지 않았기 때문에 Projected Value인 someStructure.$someNumber 는 false 를 리턴합니다. 하지만 55를 할당한 경우 내부 정의에 따라서 변환이 이루어졌기 때문에 해당 값은 true가 됩니다.

 

Property Wrapper는 어느 타입이던 상관없이 Pojected Value를 정의하고 리턴할 수 있습니다. 예시에서는 비교적 단순한 Bool 값을 사용했지만 더 많은 정보가 필요하다면 Data Type 을 따로 정의해 리턴 할 수도 있으며, 자기 자신을 리턴할 수도 있습니다. 이를 위해서는 단순히 projectedValue 라는 이름의 프로퍼티에 값을 정의해주면 됩니다. 아래는 Pojected Value를 String으로 사용하는 예시입니다.

 

@propertyWrapper
struct SmallNumber {
    private var number = 0
    var projectedValue: String = ""
    var wrappedValue: Int {
        get { return number }
        set {
            if newValue > 12 {
                number = 12
                projectedValue = "world"
            } else {
                number = newValue
                projectedValue = "hello"
            }
        }
    }
}
struct SomeStructure {
    @SmallNumber var someNumber: Int
}
var someStructure = SomeStructure()

someStructure.someNumber = 4
print(someStructure.$someNumber)
// Prints "hello"

someStructure.someNumber = 55
print(someStructure.$someNumber)
// Prints "world"

 

이렇게 문서를 참고하고 예시를 덧붙여서 개념을 알아봤습니다. 그렇다면 이런 Property Wrapper를 어떻게 사용하는지 그리고 왜 사용하는지에 대해서 한번 알아보겠습니다. 예시는 Proposals 문서를 참고하여 작성하였습니다.

 

How : User Default

처음 Property Wrapper의 개념을 공부하고 나서 User Default를 PropertyWrapper를 통해 사용한다면 정말 편하겠다는 생각을 했습니다. 마침 swift-evolution 문서에도 이 방식으로 Example을 제안한 것을 확인할 수 있었습니다. 값을 추출하는 방식과 값을 저장하는 방식을 미리 정의하고 이를 사용한다면 자동으로 User Default 에 원하는 값이 저장되고 추출 될 것입니다. 아래는 문서에서의 예시 입니다.

 

@propertyWrapper
struct UserDefault<T> {
  let key: String
  let defaultValue: T
  
  var wrappedValue: T {
    get {
      return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
    }
    set {
      UserDefaults.standard.set(newValue, forKey: key)
    }
  }
}

enum GlobalSettings {
  @UserDefault(key: "FOO_FEATURE_ENABLED", defaultValue: false)
  static var isFooFeatureEnabled: Bool
  
  @UserDefault(key: "BAR_FEATURE_ENABLED", defaultValue: false)
  static var isBarFeatureEnabled: Bool
}

 

먼저 예시에서 보이는 것 처럼 Property Wrapper는 제네릭으로도 사용이 가능합니다. 또한 필요한 부분들을 할당하지 않고 initializer 처럼 생성할 수 있습니다. 따라서 User Default 에 접근하기 위한 key 그리고 만약 값이 존재하지 않은 경우 defulatValue를 생성시에 받는 것을 확인할 수 있습니다. get 구문에 의해서 어떤 key의 값을 가져올 때 UserDefault 를 확인하고 만약 값이 nil이라면 defaultValue를 리턴하는 것을 확인할 수 있으며, set에 의해서 propertyWrapper 를 변경해준다면 해당 값이 UserDefault 에 저장되는 것을 확인할 수 있습니다. 예시에서는 Bool 값을 FOO_FEATURE_ENABLED, BAR_FEATURE_ENABLED 키를 통해서 UserDefault에 접근하는 것을 확인할 수 있습니다.

 

How : Delayed Initialization

Swift 에서는 Lazy 라는 키워드가 있습니다. 해당 키워드를 변수에 사용하게 되면 해당 값이 불릴 때 비로소 값이 할당되도록 하는 키워드 입니다. 보통 클래스 혹은 struct 에서 다른 프로퍼티를 참고하여 값을 할당해야 하는 경우 사용하게 됩니다. 해당 객체는 아직 생성되지 않았기 때문에 프로퍼티를 만드는 당시에 다른 프로퍼티를 참고할 수 없습니다. 따라서 Lazy 를 이용해 객체가 생성된 이후 시점에서 해당 값을 사용하는 로직을 위해 사용합니다. 이런 방식을 Property Wrapper 를 통해서도 구현이 가능합니다. 일단 프로퍼티와 타입을 정의해 놓고 값은 나중에 할당하는 방식입니다. 아래의 예시를 보겠습니다.

 

@propertyWrapper
struct DelayedMutable<Value> {
  private var _value: Value? = nil

  var wrappedValue: Value {
    get {
      guard let value = _value else {
        fatalError("property accessed before being initialized")
      }
      return value
    }
    set {
      _value = newValue
    }
  }

  /// "Reset" the wrapper so it can be initialized again.
  mutating func reset() {
    _value = nil
  }
}

// 이렇게만 선언해도 컴파일 에러가 발생하지 않습니다.
@DelayedMutable var hello: String

 

물론 DelayedMutable를 사용한다면 예상치 못한 경우 fatalError 가 발생할 수 있기 때문에 이를 Lazy 대신에 사용하는 것은 좋지 않은 방법입니다. 여기에서는 Lazy 또한 이런식으로 구현할 수 있겠구나 라고 생각하시면 될 것 같습니다. 또한 문서에 DelayedImmutable 이라는 PropertyWrapper 가 있는데 흥미로운 방식이니 이를 참고하시는 것 또한 추천드립니다.

 

마무리

이렇게 Property Wrapper 를 간단하게 알아봤습니다. 우리가 모듈이나 별도의 로직이 들어간 함수를 만들어 여러 곳에서 사용하는 것처럼 Property Wrapper 또한 프로퍼티를 위한 함수라고 생각해도 될 것 같습니다. 생각보다 Customization 이 굉장히 열려 있으며 ProjectedValue를 통해서 다양한 방식으로 활용이 가능한 것을 확인할 수 있었습니다. 이를 활용해서 다양한 방식으로 코드의 중복을 낮추고 효율을 높이셨으면 좋겠습니다.

 

Reference

https://docs.swift.org/swift-book/LanguageGuide/Properties.html#ID617

https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-wrappers.md

'iOS & Swift' 카테고리의 다른 글

UICollectionViewCompositionalLayout  (0) 2022.10.31
UINib Basic  (3) 2021.10.04
Apple 공식 Guide & Reference 모음  (0) 2021.05.21
Escaping Closure  (0) 2021.05.16
Intrinsic Size & CHCR Priorities  (2) 2021.05.09

자주 사용하고 있지만 정확한 개념을 설명하기 어렵다는 생각이 들어서 이번 포스팅을 통해 정리하고자 합니다. 처음 Swift를 공부하던 시절에 제일 헷갈렸던 부분이었던 것 같습니다. escaping closure를 이해하기 위해서는 몇가지 개념을 미리 알고 있는 것이 좋습니다. 비동기 코드를 쓰는 이유, scope의 개념, capturing values 를 먼저 설명한 뒤 escaping closure 그리고 이를 통해 생길 수 있는 strong reference cycle에 대해서 간단하게 설명하도록 하겠습니다. 

 

비동기 코드를 쓰는 이유

이미 다른 블로그를 통해서 동기와 비동기에 대해서 많이 설명을 해놨기 때문에 저는 직접적으로 iOS에서 활용하는 방식을 예를 들어 설명하겠습니다.

 

escaping closure 가 가장 많이 활용되는 곳은 네트워크를 통해서 데이터를 받아올 때 입니다. 기본적으로 우리가 함수를 작성하면 해당 함수는 동기적으로 작동합니다. 반면에 네트워크 API는 어떤 형식으로든 비동기적으로 작성을 할 수 있도록 유도하는 것을 많이 확인할 수 있습니다. 이유는 네트워크를 받아오는 시간동안 사용자가 아무런 동작을 못하는 freezing 현상을 방지하기 위함입니다.

 

func testEscaping(_ esClosure: @escaping (Data?) -> Void) {
  let urlRequest = URLRequest(url: URL(string: "hello world")!)

  session.dataTask(with: urlRequest) 
  { data, response, error in
		print("world")
		print("update UI or Alert")
	}.resume()
  
  print("hello")
}	

 

해당 코드를 실행해보면 session.dataTask ( Network )는 비동기 방식이기 때문에 실행 이후, 네트워크의 응답을 기다리지 않고 hello가 찍힐 것입니다. 그 이후 네트워크를 통해 응답을 받게되면 world, update UI or Alert 가 찍히게 되겠죠. 이때 hello 가 찍히고 나서 응답이 올때까지 기다리는 동안에도 사용자는 앱을 동작시킬 수 있다는 것이 비동기의 큰 장점입니다. 이를 통해서 사용자의 경험을 높힐 수 있기 때문입니다.

 

< 비동기 방식 : 요청 - 사용자 행동 ( 사용자 경험 Good ) - 응답 - 내부 동작 >

 

반대로 네트워크가 동기적으로 작동한다면 어떻게 될까요? 만약 네트워크가 느려서 응답을 받을 때까지 2초라는 시간이 걸린다고 생각을 해보겠습니다. 사실상 모바일 앱을 사용하면서 2초라는 시간은 굉장히 길고 2초동안 사용자가 어떠한 동작도 하지 못한다면 사용자의 경험을 해칠 수 있습니다.

 

< 동기 방식 : 요청 - 기다림 ( freezing , 사용자 경험 Bad ) - 응답 - 내부동작 >

 

Scope

 

Scope는 크게 global scope, local scope 2가지로 나누어집니다. global scope에 대한 자세한 내용은 여기에서 확인하는 것을 추천합니다. 예제를 통해서 local scope인 함수의 스코프에 대해서 간단하게 설명을 드리겠습니다.

 

func someFunc() -> Int {
	let hello = "world"
  return 0
}

someFunc()

 

someFunc 라는 함수가 있습니다. 그리고 내부에 hello 라는 변수가 있습니다. 일반적으로 함수의 스코프라 함은 함수의 바디 즉 { 부터 } 까지를 의미합니다. 해당 스코프는 함수가 실행될 때 활성화되며, 리턴이 되면 해당 스코프 내부에 있는 변수들은 소멸됩니다. 함수가 실행될 때에만 필요한 변수들은 실행 이후 리턴이 되는 순간 가지고 있을 필요가 없기 때문입니다. 이런 메커니즘을 가진 것이 local scope이며, 내부의 변수를 local variable 이라고 합니다.

 

하지만 local variable이 이런 local scope를 무시하고 클로저에서 계속 참조되는 것 처럼 보이는 예외의 경우가 있는데, 이 경우는 아래에서 설명하는 capturing values 를 통해서 이루어집니다.

 

capturing values

 

클로저를 실행할 때 필요한 변수들을 따로 캡쳐합니다. 문서를 한번 확인해보겠습니다.

 

A closure can capture constants and variables from the surrounding context in which it’s defined. The closure can then refer to and modify the values of those constants and variables from within its body, even if the original scope that defined the constants and variables no longer exists.

 

요약하자면 클로저는 주변에 있는 변수를 참조할 수 있으며, original scope 를 무시한다고 합니다. 즉, 기존의 스코프와 관계없이 클로저만의 변수를 갖고 있다고 생각하면 됩니다. 문서의 예제를 보며 설명드리겠습니다.

 

// 1번 예제
func makeIncrementer(forIncrement amount: Int) -> () -> Int {
    var runningTotal = 0
  
    func incrementer() -> Int {
        runningTotal += amount
        return runningTotal
    }
  
    return incrementer
}

// ** 실행 ** 
let incrementByTen = makeIncrementer(forIncrement: 10)
incrementByTen()
// returns a value of 10
incrementByTen()
// returns a value of 20

let incrementBySeven = makeIncrementer(forIncrement: 7)
incrementBySeven()
// returns a value of 7

incrementByTen()
// returns a value of 30

 

makIncrementer 함수 내부에 incrementer 함수만 살펴보면 runningTotal 과 amount 변수는 사실상 바깥에서 선언한 변수입니다. incrementer 만 본다면 에러가 나지만 runningTotal과 amount 를 capture 했기 때문에 내부에서 사용이 가능합니다.

 

또한 두번째로 살펴볼 것은 아래 실행 부분입니다. 위에서 클로저만의 변수를 갖고있다고 설명을 드렸듯이 incrementByTen, incrementBySeven 의 runningTotal 은 공유가 되지 않습니다. 각각 runningTotal 을 참조하는 변수를 가지고 있는 것이죠. 따라서 incrementBySeven() 을 실행시킨 뒤 incrementByTen() 을 실행시키더라도 각각의 플로우를 따르는 것을 확인할 수 있습니다.

 

하지만 변수가 공유되는 경우 또한 존재합니다. 아래의 예제를 살펴보겠습니다.

 

// 2번 예제
class ClosureTestManager {   
    var runningTotal = 0
    
    func add(_ amount: Int) -> () -> Int {
        return {
            self.runningTotal += amount
            return self.runningTotal
        }
    }
}

// ** 실행 **
let ma = ClosureTestManager()
let addTen = ma.add(10)
let addThree = ma.add(3)

addTen()
// returns a value of 10
addThree()
// returns a value of 13
addTen()
// returns a value of 23

 

결과에서 보이다시피 addThree() 를 실행시킨 경우 0 + 3 이 아닌 기존에 더해진 10 + 3 이 되어 13 이 리턴되는 것을 확인할 수 있습니다. 1번 예제에서는 runningTotal이 서로 공유되지 않았지만, 2번 예제에서는 서로 공유가 되어 결과가 다른 것을 확인할 수 있습니다.

 

이유는 reference type인 ClosureTestManager 의 인스턴스의 변수를 캡쳐했기 때문입니다. 단순히 값 타입을 복사한 경우는 새로운 복사본이 할당되어 사용할 수 있지만, reference type은 reference count를 증가시키면서 인스턴스의 주소를 가져옵니다. 따라서 동일한 주소의 변수를 접근하기 때문에 서로 공유가 되어 결과가 다른 것입니다. 따라서 클로저에서 값을 사용하는 경우에는 어떤 타입의 값을 캡쳐하는지 유의해서 작성을 해야 한다는 것을 알아두시길 바랍니다.

 

escaping closures

 

위의 내용들을 파악하고 이 부분을 읽는다면 큰 무리가 없을 것이라 예상됩니다. 먼저 문서를 한번 살펴보겠습니다. 정의는 이렇게 나와있습니다.

 

A closure is said to escape a function when the closure is passed as an argument to the function, but is called after the function returns.

 

인자로 전달받은 함수 중 함수의 리턴 이후에 실행할 수 있는 함수가 escaping closure 이다. 쉽게 말해 함수의 리턴 이후 실행할 수 있는 함수이다. 라고 이해하시면 될 것 같습니다. 계속해서 문서를 보겠습니다.

 

One way that a closure can escape is by being stored in a variable that’s defined outside the function. As an example, many functions that start an asynchronous operation take a closure argument as a completion handler. The function returns after it starts the operation, but the closure isn’t called until the operation is completed—the closure needs to escape, to be called later

 

바깥의 변수에 의해서 클로저가 저장되는 경우에 closure는 escape를 할 수 있다고 하네요. 대표적으로 사용하는 예시로는 비동기 작업을 하는 경우 completion handler로 escaping closure 를 사용하는 것을 확인할 수 있습니다. 작업이 시작되면 함수가 리턴되지만 클로저는 작업이 끝날 때 까지 호출되지 않습니다. 그리고 이 클로저를 나중에 호출하기 위해서 escape가 필요합니다.

 

여기서 escape의 의미를 생각해봐야합니다. 어디에서 탈출하는 걸까요? 함수의 스코프에서 탈출한다고 이해하면 좋을 것 같습니다. 함수가 리턴이 되면 일반적으로 해당 스코프는 사라지게 됩니다. 더 이상 접근할 수 없는 상태가 되는 것이죠. 하지만 비동기 작업이 끝나고 실행되어야 하는, 예외 상황에서는 사라진 스코프 내부에서 클로저를 실행시켜야 할 때가 있습니다. 이때 실행되는 이 클로저를 escaping closure 라고 하는 것입니다.

 

func testEscaping(_ esClosure: @escaping (Int) -> Void) { 
  let urlRequest = URLRequest(url: URL(string: "hello world")!)
  var testCount = 3
	session.dataTask(with: urlRequest) { data, response, error in 
    print("world")
    esClosure(testCount)
  }.resume() 

  print("hello")
} 

 

대표적인 예제를 하나 살펴보겠습니다. testEscaping를 살펴보면 내부에서 asyncronous operation 인 dataTask가 있습니다. 그리고 dataTask의 completion Hanlder 에서 esClosure를 사용하는 것을 확인할 수 있습니다. testEscaping을 외부에서 실행하는 순간 "hello"가 로그로 찍히고 내부의 스코프는 접근할 수 없지만, dataTask 작업이 끝나고 completionHandler 가 실행되며 "wolrd" 가 로그에 찍히고 esClosure 가 캡쳐된 인자와 함께 실행되는 것을 확인할 수 있을 것입니다.

 

Strong reference cycle for Closures

 

여태까지 escaping closure가 무엇인지, 어떻게 실행되는지, 왜 사용해야 하는지 등을 알아봤습니다. 하지만 escaping closure 뿐만 아니라 closure 자체를 사용할 때 주의해야할 점이 있는데, reference Value 를 캡쳐하게 될 때 Strong Reference Cycle 이 생길 수 있다는 점입니다.

 

ARC 를 살펴보면 reference Type 의 값은 참조가 되는 경우 reference count 가 증가하고 참조가 필요없는 경우는 count가 감소하며, 0 이 될때 인스턴스가 사라지게 됩니다. capturing values 또한 마찬가지로 변수에 값을 할당하는 것입니다. 이때 만약 self 에서 해당 클로저를 참조하고 클로저에서 self 를 참조하는 경우에 Strong Reference Cycle 이 생겨 인스턴스가 사라지지 않는 경우가 생길 수 있습니다. 이런 경우를 조심하고 Capture List 를 선언하여 이를 방지해야합니다.

 

이 포스트에서 정리를 하려고 했으나, 내용이 너무 길어질 것 같아서 관련된 ARC의 개념을 한번 정리를 한 다음 링크를 걸도록 하겠습니다. 자세한 내용을 알고싶다면 하단의 Strong Reference Cycle 관련 레퍼런스를 넣었으니 확인하시기를 바랍니다.

 

마무리

 

연관된 개념들을 하나씩 간단하게 정리를 하였습니다. 요약을 하자면 Escaping Closure는 함수가 리턴된 이후에 실행되는 함수 로써 이해를 하면 됩니다. 그리고 해당 클로저가 필요한 이유는 비동기, 스코프 등의 한계를 깨기 위해서 라고 생각합니다. 또한 내부의 동작은 capturing values 라는 개념이 존재했기 때문에 가능했다고 생각하면 될 것 같습니다.

 

문서를 최대한 참고하였으며, 설명이 부족하거나 이해가 안되는 부분은 직접 확인하시는 것이 제일 좋습니다. 혹여나 제가 잘못 알고 있는 부분이 있거나 전달이 부족한 부분이 있다면 댓글 부탁드립니다. 감사합니다!

 

reference

https://andybargh.com/lifetime-scope-and-namespaces-in-swift/#Scope

Capturing Values

Escaping Closure

Strong Reference Cycles for Closures

Resolving Strong Reference Cycles for Closures

'iOS & Swift' 카테고리의 다른 글

Property Wrapper  (0) 2021.06.07
Apple 공식 Guide & Reference 모음  (0) 2021.05.21
Intrinsic Size & CHCR Priorities  (2) 2021.05.09
Swinject 사용하기 ( 2 / 2 )  (0) 2021.04.21
Swinject 사용하기 ( 1 / 2 )  (1) 2021.04.21
글을 쓰다보니 존댓말을 쓰는 것이 조금 더 자연스러울 것 같아 이번 포스팅부터 존댓말을 쓰도록 하겠습니다 !

UI를 그리다보면 어쩔수 없이 레이아웃이 겹치는 부분 혹은 겹치지 않는 부분이 생기거나, 그 두가지가 공존하는 경우가 생기고는 합니다. 특히 라벨 버튼 등등 변동되는 텍스트를 작업할 때는 이런 상황이 생길수 밖에 없습니다. 이때마다 CHCRPriorities (ContentHugging , CompressionResistance Priority )를 사용하고는 하는데 늘 헷갈려서 찾아보고는 했습니다. 그래서 이번 기회에 정리하는 것이 좋다는 생각이 들었습니다.

 

핵심은 intrinsicContentSize( intrinsicSize ) 를 기준으로 생각하는 것이고 이보다 큰 경우를 대응하기 위해서는 contentHugging 을 설정하고, 이보다 작은 경우를 대응하기 위해서는 compresssionResistance 를 설정하면 됩니다.

intrinsicContentSize

intrinsicContentSize(이하 intrinsicSize)란 뷰가 가지고 있는 본연의 사이즈를 말합니다.

커스텀뷰는 일반적으로 레이아웃 시스템이 알지 못하는 방식으로 표시가 됩니다. 따라서 프로퍼티를 설정하여 콘텐츠에 맞춰서 사이즈를 시스템에 전달할 수 있도록 해야합니다. 일반적으로 커스텀뷰의 경우 내부의 콘텐츠를 레이아웃 시스템에 맞춰서 사이즈를 정해야 합니다. 이 사이즈는 변경된 높이를 기반으로 변경된 너비를 레이아웃시스템에 전달할 방법이 없기 때문에, 반드시 frame과는 무관해야 합니다.


대표적으로 intrinsicSize 를 갖고 있는 뷰로 UILabel 을 예시로 들수 있습니다. 자체적으로 Text 사이즈를 통해서 Label의 사이즈를 정하기 때문입니다. 따라서 UILabel 는 center를 superview 와 동일하게 잡기만 해도 layout 이 제대로 잡히는 것을 볼 수 있습니다. 이는 UIImageView 또한 마찬가지입니다.

 

반면에 intrinsicSize를 제대로 갖고 있지 않는 뷰의 경우는 center를 superview에 동일하게 잡더라도, 해당 뷰의 사이즈를 측정할 수 없기 때문에 layout 에러가 발생합니다. 레이아웃 시스템이 판단할 수 없기 때문입니다.

 

하지만 이렇게 intrinsicSize이 존재하더라도 복잡한 레이아웃이 되는 경우는 시스템이 판단할 수 없는 경우가 종종 있습니다. 이 경우를 해결하기 위해서 애플에서는 CompressionResistancePriority, ContentHuggingPriority 를 제시하여 해결할 수 있도록 하였습니다. 대부분 ContentHugging 은 250을 기본값으로 가지고 있으며, CompressionResistance 는 750을 기본값으로 갖고 있습니다. 자세한 내용은 Auto Layout Guide 를 참고하시길 바랍니다.

ContentHuggingPriority ( CH Priority )

intrinsic Size 에 비해서 더 늘어나야 하는 경우에 사용합니다. 위의 이미지를 보면 hugging 이 Text를 감싸고 있는 것을 볼 수 있습니다. 따라서 양쪽에서 압력을 가하는 정도 혹은 압력을 가하는 정도라고 해석하면 편합니다. 아래의 예제와 함께 설명하겠습니다.

 

여기에 두개의 라벨이 있습니다. 이때 (왼쪽)제목 라벨은 고정이고, (오른쪽)콘텐츠 라벨은 텍스트의 사이즈가 늘어날 수 있으며, 서로의 거리는 16 이상이 되어야 한다면 어떻게 해야할까요?

 

  1. 라벨간의 거리를 16으로 제약을 겁니다.
  2. 늘어나야하는 라벨 ( 콘텐츠 라벨 )의 contentHugging 을 고정된 라벨보다 낮게 측정합니다.

1번을 진행한다면 레이아웃이 깨지는 현상을 확인할 수 있습니다. 라벨들의 기본 intrinsicSize는 텍스트의 사이즈에 따라서 결정이 됩니다. 그렇기 때문에 적절한 텍스트를 적는다면 16의 제약을 만족할 수 있겠지만 위와 같은 일반적인 상황은 그렇지 않기 때문에 주어진 레이아웃을 만족하기는 어렵습니다.

 

따라서 2번의 제약을 걸어야 합니다. 오른쪽에 content Hugging 을 낮춰 왼쪽에 비해 오른쪽이 양쪽에서 안아주는 압력이 낮아지도록 하여 크기를 상대적으로 커질 수 있도록 하는 것입니다. 이를 통해 오른쪽 라벨의 width를 intrinsicSize 보다 키워서 16의 제약조건을 만족할 수 있도록 하는 것입니다.

 

 

CompressionResistancePriority ( CR Priority )

 

CompressionResistance는 intrinsicSize 에 비해서 줄어 들어야 하는 경우 사용합니다. 만약 두개의 라벨의 intrinsicSize가 너무 커서 레이아웃을 만족할 수 없다면 어떻게 될까요? 이런 경우에 사용하는 프로퍼티가 CompressionResistancePriority 입니다. 압축을 저항하는 정도로 해석하면 편합니다.

 

 

양 라벨과의 거리 제약은 8이며 왼쪽 라벨의 사이즈를 작게하고 싶습니다. 이 경우 압축에 저항하는 정도를 낮춘다 > 더 압축된다 라고 생각하면 되기 때문에 왼쪽의 라벨의 compression Resistance 를 낮추면 해결될 것 같습니다. 왼쪽 라벨의 경우 priorty가 749 이며, 오른쪽 라벨이 750 입니다. 따라서 압축을 저항하는 정도 ( compression Resistance ) 가 왼쪽라벨이 더 낮기 때문에, 크기가 intrinsicSize 보다 작아지게되어 ( 더 압축되어 ) 왼쪽 라벨이 줄어들게 됩니다.

 

CHCR Priorities ( ContentHugging & CompressionResistance )

 

그렇다면 이 두개를 동시에 사용하는 일이 있을까요? 네 있습니다. 위에서도 설명했듯이 CHCR Priority 는 intrinsicSize 를 기준으로 합니다. 따라서 상황에 따라서는 두가지를 모두 정확하게 작성을 해야 어느 텍스트가 들어오더라도 의도된 UI가 나올 것입니다.

 

 

CompressionResistancePriority를 설정한 예제에서 오른쪽 라벨의 intrinsic 사이즈만 줄인 경우입니다. 보시다시피 레이아웃이 시스템에 적절하지 않습니다. 이유는 ContentHuggingPriority가 동일하기 때문에 어떤 라벨의 사이즈를 더 키워야 할지 시스템에서는 모르기 때문입니다.

 

예시로 왼쪽의 라벨의 크기를 유동적으로 하는것으로 하겠습니다. 왼쪽 라벨의 CHPriority를 낮추는 경우 intrinsicSize가 작아 제약조건을 만족하지 못할때 왼쪽의 라벨이 커지게 되고, intrinsicSize 가 커서 제약조건을 넘어가는 경우 왼쪽 라벨의 크기가 줄어들게 됩니다.

 

 

마무리

이렇게 ContenteHugging 그리고 CompressionResistance Priority 에 대해 알아봤습니다. 활용할 부분도 굉장히 많고 UI 디버깅을 진행하다보면 다른 부분에서도 Priority 가 많이 적용된 것을 확인할 수 있습니다. 관심있게 보신다면 좋은 UI 작성 방식이 될 것이라고 생각합니다.

 

Reference

Apple - AutoLayoutGuide

https://developer.apple.com/documentation/uikit/uiview/1622600-intrinsiccontentsize

'iOS & Swift' 카테고리의 다른 글

Apple 공식 Guide & Reference 모음  (0) 2021.05.21
Escaping Closure  (0) 2021.05.16
Swinject 사용하기 ( 2 / 2 )  (0) 2021.04.21
Swinject 사용하기 ( 1 / 2 )  (1) 2021.04.21
SOLID Principle  (0) 2021.04.12

이전 포스트에 이어서 이번 포스트는 실제로 예제를 통해서 Swinject를 어떻게 사용하는지 설명하는 글이다. container를 활용하는 버전과 assembly 를 활용하는 버전 둘다 설명한다.

 

Swinject 사용하기 ( 1 / 2 )

Swinject 사용하기 ( 2 / 2 ) ( 현재 )

Example - CocktailMaster

012
cocktail master example

 

첫번째 화면에서는 어떤 알파벳으로 시작하는 칵테일을 볼 것인지를 선택한다. 이 셀을 누르게 되면 두번째 화면으로 넘어간다.

두번째 화면은 해당 알파벳으로 시작하는 칵테일 리스트를 볼 수 있다. 여기에서 셀을 누르게 되면 세번째 화면으로 넘어간다.

세번째 화면에서는 칵테일의 디테일 요소를 보여준다. ingredient 또한 확인하여 어떻게 구성되어있는지 확인할 수 있다.

 

칵테일을 검색하고 이를 만들기 위해서 어떤 요소들이 필요한지 확인할 수 있는 간단한 예제를 만들었다. MVVM 구조를 따르도록 하였으며 Swinject, SwinjectStoryboard를 사용하여서 어플리케이션을 구성하였다. 여기에서 예제를 확인할 수 있으며 태그를 확인해보면 Container만을 사용하는 seperatedContainer 태그와 Assembly를 활용한 assembly 태그가 있다. 각각 다운받은 뒤 pod install 을 하고 앱을 실행하여 확인해볼 수 있다.

SwinjectStoryboard

Storyboard 에 있는 뷰컨트롤러를 만들 때에도 dependency를 관리하고 싶은 경우 활용하면 좋다. SceneDelegate.swift 에 있는 코드를 가져왔다.

lazy var initialContainer: Container = {
  let container = Container()
  container.register(MainViewModeling.self) { _ in 
        return MainViewModel() 
    }
  container.storyboardInitCompleted(MainViewController.self) { (r, c) in 
        // initCompleted closure
        c.viewModel = r.resolve(MainViewModeling.self) 
    }
  return container
}()
// ....
let mainViewController = SwinjectStoryboard.create(name: "Main", bundle: nil, container: initialContainer)
                            .instantiateViewController(withIdentifier: "MainViewController")

MainViewController 에는 MainViewModel이 필요한데 이 뷰모델은 MainViewModeling 이라는 프로토콜을 채택해야 한다. 또한 MainViewController 에서는 구현체인 MainViewModel 이 아니라 MainViewModeling을 참조하도록 만들었다. MainViewController가 생성되면 자동으로 MainViewModel이 생성되어 injection이 된다. 이 과정을 아래와 같은 단계로 풀어서 설명할 수 있다.

 

  1. MainViewModeling 을 register 한다. resolve를 하게되면 MainViewModel instance가 생성될 것이다.
  2. SwinjectStoryboard.create 를 통해서 만들고자 하는 Storyboard 그리고 identifier 를 연결한다. 이때 container 인자에 사용하고자 하는 container 를 등록한다.
  3. 2에서 사용한 container 에 container.storyboardInitCompleted를 사용하여 initCompleted closure 에 viewModel 을 property injection 형태로 넣어준다.

위와 같은 과정을 거치면 MainViewController를 만들었을 때 자동으로 ViewModel 이 injection 된 ViewController instance 를 얻을 수 있다.

SwinjectStoryboard 단점

한가지 아쉬웠던 점은 SwinjectStoryboard.create 를 통해서 인자를 넘길 수 없다는 점이었다.

 

예를 들어서 MainViewModel에 id: String가 필요하다고 하자 SwinjectStoryboard.create 단계에서 id 인자를 넘기면 MainViewModeling을 resolve 할 때 이를 넣어주는 형태가 되었으면 조금 더 활용도가 높았을 텐데 하는 아쉬움이 있다. 문서를 찾아봤지만 이렇다 할 해법이 나와있지 않아 굉장히 아쉬웠다.

 

따라서 만약 ViewModel을 만들 때 어떤 인자가 필요하다면 initCompleted 를 사용하는 방식이 아닌, ViewController와 ViewModel을 각각 resolve 한 뒤 직접 injection 하는 방식으로 사용하거나, viewModel의 ObjectScope를 변경하여 관리하는 방식으로 사용해야 한다. 아래 예제에 설명이 나와있다.

tag - seperatedContainer

위에서 말했다시피 예제 코드를 보면 두가지의 태그가 있다 이중 첫번째 seperatedContainer 에 대한 간단한 설명을 하고자 한다. seperatedContainer는 필요한 경우 viewModel에서 container 를 만들어서 이를 활용하는 방식으로 어플리케이션을 구성하였다. 아래의 코드는 MainViewModel.swift 에서의 코드이다.

protocol MainViewModeling: BaseViewModeling {
    var alphabetListRelay: BehaviorRelay<[String]> { get }
    var cocktailNameListViewControllerRelay: PublishRelay<CocktailNameListViewController> { get }
    func targetAlphabet(at indexPath: IndexPath) -> String
    func didTapAlphabetCell(at indexPath: IndexPath)
}

final class MainViewModel: BaseViewModel, MainViewModeling {
    let alphabetListRelay = BehaviorRelay<[String]>(value: [])
    let cocktailNameListViewControllerRelay = PublishRelay<CocktailNameListViewController>()

    let container = Container()

    override init() {
        super.init()
        setAlphabetList()
        setContainer()
    }

    private func setAlphabetList() {
            // ...
    }

    private func setContainer() {
        container.register(CocktailNameListViewModeling.self) { (_, alphabet: String) in
            return CocktailNameListViewModel(alphabet)
        }

        container.storyboardInitCompleted(CocktailNameListViewController.self) { (r, c) in
            // do somthing if u want ...
        }
    }

    func targetAlphabet(at indexPath: IndexPath) -> String {
        // ...
    }

    func didTapAlphabetCell(at indexPath: IndexPath) {
        let viewModel = container.resolve(CocktailNameListViewModeling.self, argument: targetAlphabet(at: indexPath))
        guard let viewController = SwinjectStoryboard.create(name: "Main", bundle: nil, container: container).instantiateViewController(withIdentifier: "CocktailNameListViewController") as? CocktailNameListViewController else { return }
        viewController.viewModel = viewModel

        cocktailNameListViewControllerRelay.accept(viewController)
    }
}

SwinjectStoryboard를 설명하는 단계에서 나온 MainViewModeling , MainViewModel이다. MainViewModeling은 BaseViewModeling protocol을 채택하여야 한다.

 

이제 container를 보도록 하자 container를 만들고 init단계에서 setContainer()를 호출하여 register 를 하였다. 이후 MainViewController 에서 셀을 누르는 경우 didTapAlphabetCell(at: IndexPath) 가 실행된다. 여기서 다음 단계인 CocktailNameListViewController 를 만들기 위한 작업이 이루어진다.

 

CocktailNameListViewController에 필요한 ViewModel protocol은 CocktailNameListViewModeling protocol이며, 구현체가 CocktailNameListViewModel이 된다. 이때 CocktailNameListViewModel에는 리스트를 가져올 알파벳을 알아야 하기 때문에 인자로 alphabet을 받게 된다.

 

따라서 viewModel을 따로 resolve 하면서 targetAlphabet을 넣어준 다음, SwinjectStoryboard.create 를 통해서 만들어진 ViewController 에 만들어진 viewModel을 넣었다. 만들어진 ViewController는 relay에 넘겨서 navigationPush를 할 수 있도록 하였다.

Container의 단점?

사용은 편하게 하였으나 확장성에 의문이 생겼다. 만약 다른 곳에서 register 한 요소들을 가져오려면 어떻게 해야할까? 혹은 여기서 등록한 요소를 다른곳에서 어떻게 활용할 수 있을까?

 

Container Hierarchy 를 살펴보면 parent - child 형태로 연결된 container에서 parent container 내부에 등록된 요소들은 child container 에서 resolve 가 가능하다. ( 반대는 안된다. ) 이를 활용하면서 object scope를 적절하게 사용한다면 어느 정도 확장성에 대한 해결이 될것이라고 생각한다.

tag - assembly

위에서 말한 container hierarchy 를 사용하면 마치 클래스의 상속처럼 parent - child 형식으로 범위를 넓혀나갈 수 있을 것 같다. 하지만 프로토콜과 같이 어디서든 쉽게 붙였다 떼었다 할 수 있으면 조금 더 활용도가 높을 것 같다는 생각이 들었다. 문서를 살펴보니 assembly protocol이 있었고, parent-child hierarchy를 사용하는 것 또한 가능했으며, 이를 프로젝트에 직접 활용해보았다.

// MainViewModel.swift
class MainAssembly: Assembly {
    func assemble(container: Container) {
        container.register(CocktailNameListViewModeling.self) { (_, alphabet: String) in
            return CocktailNameListViewModel(alphabet)
        }
    }
}
// CocktailNameListViewModel.swift
class CocktailListAssembly: Assembly {
    func assemble(container: Container) {
        container.register(CocktailDetailViewModeling.self) { (_, id: String) in
            // 제대로 assemble이 되었는지 확인하는 로그
            let test = container.resolve(CocktailNameListViewModeling.self, argument: "z")
            if let test = test as? CocktailNameListViewModel {
                print("Integration")
                print(test.startedAlphabet)
            } else {
                print("not Integrated")
            }

            return CocktailDetailViewModel(id)
        }
    }
}
// 실제 사용하는 코드
let assembler = Assembler([MainAssembly(), CocktailListAssembly()])
...
let viewModel = assembler.resolver ~> (CocktailDetailViewModeling.self, argument: selectedCocktail.idDrink)
// assembler.resolver.resolve(...) 과 동일하다.
// ~> 는 SwinjectAutoregistration을 받으면 사용할 수 있다.

먼저 SwinjectAutoregistration를 사용해서 ~> 라는 기호를 사용했는데 여기에서는 특별한 의미가 없고 단순 resolve를 저렇게 표현했다고 생각하면 된다. 관심이 있다면 해당 라이브러리도 참고 하는것을 추천한다.

 

제일 중요하게 봐야될 키워드는 Assembly 그리고 Assembler 이다. Assembly는 서로 다른 파일에 구현이 되어있다. 그리고 사용이 되는 코드에 Assembler 를 보면 MainAssembly 그리고 CocktailListAssembly 두개를 합친 것을 볼 수 있다. 그리고 CocktailListAssembly 에서 CocktailDetailViewModeling를 resolve 할때 MainAssembly에 등록된 프로토콜이 제대로 resolve 되는지 확인하기 위한 로그도 남겨놓았다.

 

개인적으로는 Assembly를 사용하는 것이 Container 를 사용하는 것 보다 조금 더 확장성이 있다고 느껴진다. 어떤 파일에 있던 가져올 수 있다면 쉽게 붙였다 떼는것이 가능할 뿐 더러, hierarchy 그리고 objectScope 모두 지원한다.

마치며

글이 이렇게 길어질지는 몰랐는데 생각보다 설명할 부분이 많았던 것 같다. 이 내용을 토대로 앞으로 프로젝트를 Assembly를 활용하여 관리할 생각이다. 또한 이번에 SwinjectAutoregistration를 제대로 활용하지는 않았는데 이 또한 활용하면 재밌을 것 같다는 생각이 들었다. 한번 활용해보고 예제를 만들어 tag 를 추가하게 된다면 이 포스트에 사용법을 적어놓을 예정이다. 많은 도움이 되었기를 바라며 마치겠다. 👏

( 언제든지 피드백은 환영입니다. 👍🏻 )

Reference

직접 만든 예제 - CocktailMaster

예제에서 사용한 API - CocktailDB

Swinject

Swinject - Document

SwinjectStoryboard

SwinjectAutoRegistration

laywenderlich - Swinject Tutorial for iOS: Getting Started

Swinject in practice

'iOS & Swift' 카테고리의 다른 글

Escaping Closure  (0) 2021.05.16
Intrinsic Size & CHCR Priorities  (2) 2021.05.09
Swinject 사용하기 ( 1 / 2 )  (1) 2021.04.21
SOLID Principle  (0) 2021.04.12
Responder Chain ( 앱은 유저의 인터렉션을 어떻게 처리하는가 ? )  (0) 2021.03.18

iOS 개발을 하다보면 access control 에 대해서 관심이 생길때가 있다. final 혹은 private 을 붙히면 성능에 더 좋다라고 막연히 듣기만 했는데 이번에 한번 그 이유를 정리하고 기록을 남기고자 한다.

Increasing Performance Reducing Dynamic Dispatch

프로그램의 성능을 높히기 위해서 Dynamic Dispatch 를 줄이는 방법이 있다. 그렇다면 Dynamic Dispatch란 무엇이고 이를 줄이면서 어떻게 성능이 개선되는지 한번 알아보자

Dispatch

먼저 Dispatch 의 개념부터 알아보도록 하자. Dispatch는 어떤 메소드를 호출할 것인가를 결정하여 그것을 실행하는 과정이다. Dispatch 방식은 Static Dispatch, Dynamic Dispatch로 두가지가 있다.

Static Dispatch

컴파일 시점에 어떤 메소드가 사용될지 명확하게 결정되는 것을 Static Dispatch 라고 한다.

Dynamic Dispatch

런타임 시점에 어떤 메소드가 실행될지 결정되는 것, 즉 컴파일 시점에서는 어떤 함수가 실행될 지 모른다. Swift 에서는 class 마다 vtable을 갖고 있고 이를 참조하면서 함수가 호출되기 때문에 이에 따른 overhead가 발생하게 된다.

Static VS Dynamic Dispatch

위의 설명만 들으면 이해가 잘 안될수도 있다. 아래 예시를 살펴보자

// Static Dispatch
struct HelloStruct {
  func printHello() {
    print("hello")
  }
}
let helloStruct = HelloStruct()
helloStruct.printHello()

struct 는 Value Type 이다. 즉, 다른곳에서 해당 값의 레퍼런스를 가지고 있을 필요가 없으며 상속도 되지 않는다. 따라서 컴파일러의 시각에서 생각해보면 helloStruct.printHello() 는 HelloStruct의 printHello()가 호출된다는 것이 명확해진다. 즉 다른 방법이 없다. 이때 Static Dispatch 방식으로 함수가 호출되는 것이다.

// Dynamic Dispatch
class HelloClass {
  func printHello() {
    print("hello")
  }
}

class HelloOtherClass: HelloClass { }

let helloClass: HelloClass = HelloOtherClass()
helloClass.printHello()

class 는 Reference Type 이며 Struct 와 다르게 상속이 가능해진다. 다른 곳에서도 함수를 호출할 가능성이 존재한다는 것이다. 위의 helloClass 변수를 보자 타입은 HelloClass 지만 실제 값은 HelloOtherClass 의 인스턴스이다.

실제로 함수가 override 되었는지 안 되었는지는 중요하지 않다. 즉, 컴파일러가 봤을 때 printHello()가 상속으로 인해서 다른 클래스에서 호출될수도 있겠구나 그러면 바로 HelloClass 의 printHello를 직접 접근하지 말고 참조 형식으로 둬야 겠다 라는 방향으로 컴파일을 한다는 것이다. 따라서 특별하게 지정하지 않는 이상 해당 함수는 Dynamic Dispatch 로 인해서 불리게 된다.

Increasing Performance by Reducing Dynamic Dispatch

이제 본문으로 돌아오면 스위프트도 다른 언어와 마찬가지로 여러 method 혹은 properties 를 슈퍼클래스로부터 override 할 수 있다. 이는 다시말해서 프로그램이 런타임시에 indirect call & indirect access 를 통해서 어떤 method 혹은 property를 호출하는지 정하는 것을 말한다. 이를 다이나믹 디스패치라고 말하고 indirect usage 를 사용할 때 마다 overhead 가 발생하게 된다. 따라서 성능이 중요한 코드에서는 이런 overhead 는 바람직하지 못하다. 이런 역동성을 제거하는 방식에는 크게 3가지가 있고 예시와 함께 이를 설명하겠다.

먼저 역동성을 제거하지 않은 예시를 살펴보자

class ParticleModel {
    var point = ( 0.0, 0.0 )
    var velocity = 100.0

    func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
        point = newPoint
        velocity = newVelocity
    }

    func update(newP: (Double, Double), newV: Double) {
        updatePoint(newP, newVelocity: newV)
    }
}

var p = ParticleModel()
for i in stride(from: 0.0, through: 360, by: 1.0) {
    p.update((i * sin(i), i), newV:i*1000)
}

위의 코드가 실행이 된다면 컴파일러는 dynamic dispatch call을 방출하는데 순서는 다음과 같다.

  1. Call update on p.
  2. Call updatePoint on p.
  3. Get the property point tuple of p.
  4. Get the property velocity of p.

ParticleModel의 Method나 Property 를 override 해서 새로운 구현을 하기 위해서는 이런 dynamic dispatch는 필수적이다 ( 즉, 반대는 필요 없다는 말 ). Swift에서는 Dynamic Dispatch를 구현할 때 Method Table에서 해당 function을 찾고 indirect call을 호출한다. 이는 direct call보다 느리며 compiler 의 최적화 방향에도 좋지 않은 영향을 미친다. 따라서 성능을 높히기 위해서는 이런 방식을 지양해야 한다.

Use Final

Use final when you know that a declaration does not need to be overridden.

해당 키워드는 클래스의 method 혹은 property 에 override 를 제한한다. 따라서 컴파일러는 이로 인한 indirect call, access 를 무시할 수 있다.

class ParticleModel {
    final var point = ( x: 0.0, y: 0.0 )
    final var velocity = 100.0

    final func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
        point = newPoint
        velocity = newVelocity
    }

    func update(newP: (Double, Double), newV: Double) {
        updatePoint(newP, newVelocity: newV)
    }
}        

final keyword를 붙힌 point, velocity, updatePoint() 를 살펴보자. 이제 point 와 velocity 의 경우 직접적으로 객체의 stored property 에 접근할 수 있게되며, updatePoint() 또한 direct function call로 호출할 수 있게 된다. 따라서 overhead 가 줄어들고 성능이 향상된다. 하지만 여전히 update()는 dynamic dispatch를 통해 indirect function call 로 호출이 되며 overhead 가 발생하고, 성능이 안좋아졌지만 서브클래스에서 override 가 가능하다.

final class ParticleModel {
    var point = ( x: 0.0, y: 0.0 )
    var velocity = 100.0
    // ...
}

final은 이렇게 class 앞에도 붙힐 수 있으며 이때는 클래스에서 구현된 모든 property 와 method는 direct call로 불리게 되고 override 가 불가능하다.

applying the private keyword

Infer final on declarations referenced in one file by applying the private keyword.

정의할 때 private keyword를 사용하는 것은 참조할 수 있는 곳을 현재 파일로 제한한다는 뜻이다. 따라서 컴파일러는 private 키워드가 참조될 수 있는 곳에서 잠재적으로 override 가 될 수 있는지 없는지를 판단한다. 이때 따로 override 하는 곳이 없다면 스스로 final 키워드를 추론하고 indirect call & access 을 제거한다.

class ParticleModel {
    private var point = ( x: 0.0, y: 0.0 )
    private var velocity = 100.0

    private func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
        point = newPoint
        velocity = newVelocity
    }

    func update(newP: (Double, Double), newV: Double) {
        updatePoint(newP, newVelocity: newV)
    }
}

이 또한 위의 final 과 마찬가지로 클래스 앞에 private Keyword를 붙이게 되면 내부의 모든 property 그리고 method에도 private keyword가 붙힌 것으로 간주된다.

private class ParticleModel {
    var point = ( x: 0.0, y: 0.0 )
    var velocity = 100.0
    // ...
}

Whole Module Optimization

Use Whole Module Optimization to infer final on internal declarations.

internal access level(default 접근 제한자)은 정의된 모듈 내에서만 접근이 가능하다. 기본적으로 swift complier 는 모듈별로 컴파일을 하기 때문인데. 컴파일러는 기본적으로 internal access level에 대해서 서로 다른 파일에서 override 되었는지 확인이 불가능하다. 만약 whole module optimization 을 사용한다면 모든 모듈을 한번에 compile을 하게 된다. 따라서 이때 internal level 에 대해서 override가 되는지 추론을 할 수있게 되고 그렇지 않은 경우 내부적으로 final을 붙힌다. ( 따라서 direct call을 하게 된다. ) 아래 예시를 살펴보자

public class ParticleModel {
    var point = ( x: 0.0, y: 0.0 )
    var velocity = 100.0

    func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
        point = newPoint
        velocity = newVelocity
    }

    public func update(newP: (Double, Double), newV: Double) {
        updatePoint(newP, newVelocity: newV)
    }
}

var p = ParticleModel()
for i in stride(from: 0.0, through: times, by: 1.0) {
    p.update((i * sin(i), i), newV:i*1000)
}

이때 whole module Optimization 을 키게 된다면 point, velocity, updatePoint() 에 대해서 자동으로 final을 추론하게 되고 direct call로 호출할 수 있게 되는것이다.

참고로 Xcode 8 부터 Whole Module Optimization은 release 할 때 켜져 있습니다. 프로젝트파일에서 build setting - Compilation Mode 를 확인하면 release 에서 whole module을 확인할 수 있습니다.

Reference

wiki - 동적 디스패치

Swift의 Dispatch 규칙

Apple Blog - Increasing Performance Reducing Dynamic Dispatch

Swift.org - Whole-Module Optimization

'iOS & Swift' 카테고리의 다른 글

SOLID Principle  (0) 2021.04.12
Responder Chain ( 앱은 유저의 인터렉션을 어떻게 처리하는가 ? )  (0) 2021.03.18
Encoding 정복하기  (0) 2020.11.23
Decoding 정복하기  (2) 2020.11.14
Codable  (0) 2020.10.25
  • knowledge Base
  • SubClass Encode Issue
  • encode ?
  • container ?
  • Tip
  • 03.07 수정

Knowledge Base

이전 포스트와 동일하게 보편적으로 우리는 json 형식으로 데이터를 감싸는 방식을 자주 활용한다. 다시 한번 개념을 돌아보자면, 외부의 표현을 내부의 표현으로 변경하는 것이 decode, 내부의 표현을 외부의 표현으로 변경 하는 것이 encode 이다. 이때 외부의 표현은 여러가지로 해석될 수 있으며 해당 포스트에서는 JSON 형식을 갖춘 데이터이다.

사실 encode는 decode와 반대의 개념이기 때문에, 이전 포스트를 통해 decode는 어떤 방식으로 활용 했는지 알고 오는 것이 좋을 것이라고 생각한다. 또한 Codable(Encodable & Decodable)이 아닌 Encodable을 활용할 예정이고, 해당 프로토콜에 대한 설명과 기본 사용법은 이 포스트를 참고하도록 하자.

SubClass Encode Issue

이전 Decode 포스트의 상황과 굉장히 유사하다. 유저의 데이터를 계속해서 보내야 한다고 생각해보자. 어떤 부분은 유저의 데이터 그리고 핸드폰 정보를 보내야 하는 반면, 어느 부분에서는 유저의 데이터와 함께 주소 정보를 보내야 한다. 동일한 유저의 데이터를 활용하기 위해서 상속을 통해서 해결하려고 한다.

class UserData: Encodable {
    var name: String?
    var age: Int?
}

class UserAndPhone: UserData {
    var phone: String?
    var number: String?
}

class UserAndAddress: UserData {
    var address: String?
    var postCode: String?
}

그리고 이제 UserAndPhone의 정보를 모두 채우고 encode를 해보자. 우리가 원하는 방향은 name, age, phone, number의 정보를 모두 외부의 표현, 즉 JSON 형식으로 변경하는 것이다. 그런데 예측과는 다른 결과가 나온다.

let userAndPhone = UserAndPhone()
userAndPhone.name = "onemoon"
userAndPhone.age = 25
userAndPhone.phone = "iPhone X"
userAndPhone.number = "01012341234"

let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(userAndPhone)
if let text = String(data: data, encoding: .utf8) {
    print(text)
}

//{
//  "name" : "onemoon",
//  "age" : 25
//}

분명히 phone 과 number 에 대한 데이터를 넣어 주었음에도 불구하고 데이터에는 생략 되었다. 왜 이런 일이 발생하는 걸까?

이유는 상속에 있다. 객체를 데이터로 바꾸는 Encode과정을 생각해보면 JSONEncoder에서 각 객체의 encode함수를 호출해서 data를 만드는 것이다. UserAndPhone 의 encode(_:) 의 호출은 사실상 UserData의 encode만 호출되기 때문에 name과 age 만 데이터로 변환이 되는 것이다. 즉 이를 해결하기 위해서는 UserAndPhone(SubClass)에서 encode(_:)를 override 해주면 된다.

class UserAndPhone: UserData {
    var phone: String?
    var number: String?

    enum CodingKeys: String, CodingKey {
        case phone
        case number
    }

    override func encode(to encoder: Encoder) throws {
        try super.encode(to: encoder)
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(phone, forKey: .phone)
        try container.encode(number, forKey: .number)
    }
}

//{
//  "age" : 25,
//  "phone" : "iPhone X",
//  "number" : "01012341234",
//  "name" : "onemoon"
//}

생각해보면 굉장히 간단 하지만 에러가 나지 않기 때문에 꼼꼼하게 보지 않는다면 놓치기 쉬운 부분이다. 꼭 유념해서 encode를 진행 하기를 바란다.

Encode ?

encode(_ value: T)에 대해서 조금 더 자세하게 알아보자. 어떤 프로퍼티를 옵셔널로 선언하고 encode를 한다면 해당 키에 대한 정보는 보내지지 않을 것이다. 문제는 실제로 데이터를 다루다 보면 항상 예외 상황이라는 것이 존재한다. 특정 프로퍼티는 없으면 보내지 말고, 또 다른 프로퍼티는 없어도 기본 값으로 보낸다던지 말이다. 사실 개발을 하다가 이런 예외 상황을 마주치면 처리하기가 힘들거나 깔끔하게 코드를 작성하기 어렵다.

이때 encode를 직접 정의 해준다면 어느정도 해결 될지도 모른다. 게시물을 업데이트 하는 API를 호출하려고 한다.

struct UpdatePostBody: Encodable {
    var user: UserData?
    var postId: String?
    var title: String?
    var content: String?
}

이때 서버 팀에서 user와 postId 는 항상 존재 해야한다는 요청이 들어왔다. 만약 실수로 user 혹은 postId 에 nil이 들어가는 경우에는 키 값조차 전달되지 않기 때문에 디버깅에 문제가 생길 수 있다. 이 경우 다음과 같은 방식으로 데이터를 보낼 수 있을 것이다.

// Version 1
struct UpdatePostBody: Encodable {
    var user: UserData?
    var postId: String?
    var title: String?
    var content: String?

    enum CodingKeys: String, CodingKey {
        case user, postId, title, content
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(user ?? UserData(), forKey: .user)
        try container.encode(postId ?? "", forKey: .postId)
        try container.encodeIfPresent(title, forKey: .title)
        try container.encodeIfPresent(content, forKey: .content)
    }
}
// Version 2
struct UpdatePostBody: Encodable {
    var user: UserData = UserData()
    var postId: String = ""
    var title: String?
    var content: String?
}

Version 1 과 같이 encode를 작성한다면 user 와 postId 에 nil 이 들어가더라도 UserData() , 그리고 ""로 들어가게 될 것이다. container.encode의 경우에는 값이 무조건 존재해야 하며 해당 값을 통해서 매칭되는 키 값과 함께 데이터가 만들어진다. container.encodeIfPresent 의 경우 만약 값이 nil이라면 키 와 값 모두 보내지 않는다. 이렇게 데이터가 만들어진다. 물론 Version 2 처럼 작성을 해도 동일하게 encode는 동작을 할 것이다. 여기에서는 encode 가 저런 방식으로 작성이 된다는 것을 알아 두자. 다양한 활용 법은 아래에서 알아 볼 것이다.

Container ?

함수의 구현을 살펴보면 container라는 struct를 확인해볼 수 있을 것이다. decode와 동일하게 container 는 다음과 같이 세가지 container로 분류된다.

1. KeyedEncodingContainer

2. UnKeyedEncodingContainer

3. SingleValueContainer

KeyedEncodingContainer의 경우 위에서 사용한 것과 같이 CodingKey를 채택한 타입이 필요하다. CodingKey에 들어온 키 값에 따라서 값들을 encode하여 data로 바꿀 수 있다.

UnkeyedEncodingContainer 는 키가 없이 데이터 배열로 값을 변환하여 데이터로 만든다. 아래의 예시를 보는 것이 이해가 빠를 것이다. 타입도 다를 수 있으며 Key: Value 형태가 아니라는 것을 주목하자.

class UserData: Encodable {
    var name: String? = "onemoon"
    var age: Int? = 25

    func encode(to encoder: Encoder) throws {
        var container = encoder.unkeyedContainer()
        try container.encode(name)
        try container.encode(age)
    }
}

//[
//  "onemoon",
//  25
//]

SingleValueContainer 는 말 그대로 객체를 하나의 값인 데이터로 변경 해주는 것이다. 말 그대로 singleValue 이기 때문에 encode는 단 한번만 사용될 수 있으며, 여러번 encode를 하면 에러가 발생한다. 따라서 모든 값을 사용할 필요도 없으며 단 하나의 데이터 값만 표현하면 된다.

class UserData: Encodable {
    var name: String? = "onemoon"
    var age: Int? = 25

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(name)
    }
}

//"onemoon"

Tip

  • Enum 최대한 활용하기
  • 데이터 조합하기 ( Data = struct + struct )

Enum을 활용하는 것과 데이터의 조합은 decode편에서도 다룬 내용이다. 단 decode에서는 init을 활용 했다면 encode에서는 함수를 활용 한다는 점이다.

Enum 최대한 활용하기

사실 데이터를 보내기 전에 UI 에서 부터 Enum으로 데이터를 자주 관리하는 편이다. 이 경우에도 동일하게 enum의 rawValue 대신에 따로 데이터에 들어갈 값을 설정해 줄 수 있다. 또한 만약 enum에 관련된 값을 따로 추출해서 보내야 하는 경우 어떻게 처리할까? 다음 예시를 생각해보자

enum Phone: String, Encodable {
    case galaxy
    case iPhone
    case pixel

    var textForEncode: String {
        switch self {
        case .iPhone:
            return "IPHONE"
        case .galaxy:
            return "GALAXY"
        case .pixel:
            return "PIXEL"
        }
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(self.textForEncode)
    }

    var os: String {
        switch self {
        case .iPhone:
            return "iOS"
        case .galaxy, .pixel:
            return "AOS"
        }
    }
}

rawValue 와 다르게 server에는 모두 대문자로 된 값을 전송해야 한다고 가정하며, phone의 기종과 더불어 OS 를 보내야 한다고 가정하자. 각각의 프로퍼티를 만들어서 직접 둘다 할당해줄 수도 있지만 아래와 같이 쉽게 처리할 수 있다.

struct UserData: Encodable {
    var name: String?
    var age: Int?
    var phone: Phone?

    enum CodingKeys: String, CodingKey {
        case name
        case age
        case phone
        case phoneOS
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encodeIfPresent(name, forKey: .name)
        try container.encodeIfPresent(age, forKey: .age)
        try container.encodeIfPresent(phone, forKey: .phone)
        try container.encodeIfPresent(phone?.os, forKey: .phoneOS)
    }
}
//{
//  "age" : 25,
//  "phone" : "IPHONE",
//  "name" : "onemoon",
//  "phoneOS" : "iOS"
//}

여기서 두가지 부분을 한번 주목 해보자.

첫번째 enum 에서 encode를 처리했기 때문에 encodeIfPresent(phone, forKey: .phone) 부분에는 rawValue가 아닌 대문자로 변환된 값이 들어간다.

두번째 phoneOS 라는 프로퍼티를 따로 만들어 줄 필요 없이 CodingKey만 따로 관리한다면 데이터를 보내는 키 값을 쉽게 처리할 수 있다는 점이다. CodingKeys에 phoneOS라는 케이스를 만들고 이를 encode 에서 phone?.os로 처리할 수 있다. 이렇게 처리한다면 UI와 데이터의 변환이 편하게 될 것이다. ( 단, encode를 직접 구현하지 않는다면 codingKeys와 프로퍼티가 대응이 안되기 때문에 에러가 발생할 것이다. )

사실 Enum 뿐만 아니라 struct 나 class 등에 encode를 직접 구현 해준다면 코드가 상당부분 줄어듦과 동시에 관리 하기에도 훨씬 더 편해질 것이라고 장담한다.

예전에 나는 네트워크에서 받은 Entity를 통해서 업데이트를 위한 Body를 만들었는데, 이때 Body를 통해서 UI에 표현될 데이터와 실제로 보내질 데이터의 프로퍼티를 따로 관리했던 기억이 난다. (예를 들면 전자는 name 이 될 것이고, 후자는 Id값이 될 것이다.) 이 때문에 작성을 해야하는 프로퍼티는 계속해서 늘어나게 되고 결국에는 관리가 힘들었던 기억이 난다. 지금 이 지식을 알고 나서 작업 했다면 적어도 30%의 시간이 줄어들 것이라고 생각한다.

데이터 조합하기 ( Data = struct + struct )

서비스가 커지면 기존의 데이터 구조를 활용하는 것이 훨씬 더 간편한 경우가 많을 것이다. 이런 경우를 위해서 여러 개의 데이터를 조합해서 body를 직접 만들어 보자. 서버에서 원하는 구조와 현재 사용 되는 데이터가 다음과 같다고 생각해보자.

// -----------------
// 서버에서 받아야 하는 body값
{
    "name": String
    "age": Int
    "kakaoProfileURL": String
    "kakaoId": String
    "howManyKakaoFriends": Int
}
// -----------------

struct UserData: Encodable {
    var name: String?
    var age: Int?
}

struct KakaoUserData: Encodable {
    var profileURL: String
    var id: String
    var friends: [KakaoUserData]
}

서버에서 원하는 body 값을 보내기 위해서는 어떻게 해야할까? user 와 kakaoUser 프로퍼티로 받아달라고 서버에 요청할 수도 있겠지만, 직접 한번 해결해보자 위와 같은 경우 UserData와 KakaoUserData를 조합한다면 쉽게 처리가 가능할 것이다. 다섯개의 프로퍼티를 갖고 있는 새로운 struct를 만들 필요가 없다. 아래를 살펴보자

struct UserAndKakaoUser: Encodable {
    var originUserData: UserData
    var kakaoUserData: KakaoUserData

    enum CodingKeys: String, CodingKey {
        case name, age, kakaoProfileURL, kakaoId, howManyKakaoFriends
    }

    func encode(to encoder: Encoder) throws {
        try self.originUserData.encode(to: encoder)
        try self.kakaoUserData.encode(to: encoder)
    }
}

이렇게 두개의 객체 조합을 통해서 하나의 바디를 간단하게 만들 수 있다. Key 값을 직접 처리하여 마치 하나의 객체에 다섯 개의 프로퍼티가 있는 것 처럼 표현할 수 있으며, kakaoUserData.friends.count와 같이 필요한 경우 데이터를 변경해서 처리할 수도 있다.

03.07 수정

  • enum Keys -> CodingKeys로 변경하였다.

실행 여부는 확인했으니 상관없으나 혼돈을 방지하고자 CodingKeys 로 이름을 변경하였다. Swift 에서는 키를 해석하는 default CodingKey로 CodingKeys를 사용하기 때문에 굳이 다른 이름을 붙일 필요가 없다고 판단했다.

 

  • Data 조합 파트에서 encode 로직 변경
    // 기존에 사용된 코드
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encodeIfPresent(originUserData.name, forKey: .name)
        try container.encodeIfPresent(originUserData.age, forKey: .age)
        try container.encodeIfPresent(kakaoUserData.profileURL, forKey: .kakaoProfileURL)
        try container.encodeIfPresent(kakaoUserData.id, forKey: .kakaoId)
        try container.encodeIfPresent(kakaoUserData.friends.count, forKey: .howManyKakaoFriends)
    }

 

기존에는 직접 키값을 뽑아서 값을 넣어주는 방식으로 진행했었다.(물론 이 방식도 제대로 동작한다.) 하지만, 직접 사용을 하면서 생각해보니 각 프로퍼티에서 encode를 실행시키면 훨씬 쉽고 보기에도 편하다고 생각했다. 이를 변경하였으니 참고하도록 하자

'iOS & Swift' 카테고리의 다른 글

Responder Chain ( 앱은 유저의 인터렉션을 어떻게 처리하는가 ? )  (0) 2021.03.18
Reducing Dynamic Dispatch ( 성능향상 )  (0) 2021.01.24
Decoding 정복하기  (2) 2020.11.14
Codable  (0) 2020.10.25
Text Size 구하기  (0) 2020.04.26

Decoding 정복하기

  • Knowledge Base
  • SubClass Decode 이슈
  • Key가 없는 경우에 대한 이슈
  • container ?
  • Tip
  • 03.07 수정

들어가며

네트워크를 통해서 데이터를 주고 받을 때 우리가 편한 객체로 변환하거나 변환 되어서 데이터를 관리 하고는 한다. 해당 포스트는 데이터를 처리하는 방식에 대해서 말해 보고자 한다. 기본적인 개념도 있지만 그 보다는 실제로 데이터를 주고 받으면서 발생했던 이슈 들을 조금 더 집중해서 다루고 공유하기 위해서 해당 포스트를 작성하였다.

Knowledge Base

API를 통해 데이터를 주고 받기 위해서는 json 형식이 보편적으로 사용된다. json으로 데이터를 쉽게 처리하기 위해서 우리는 Codable 혹은 Decodable, Encodable 프로토콜을 사용한다. ( 개인적으로 목적에 따라서 Decodable, Encodable 하나만 채택해서 사용하는 편이다. ) 해당 프로토콜에 대한 설명과 기본 사용법은 포스트를 참고하도록 하자.

Subclass Decode Issue

회사의 도메인 특성상 필드 그리고 엔티티가 굉장히 많고 세부적으로 나뉘어져 있다. 단순히 Struct로 모든 엔티티를 관리하기에는 많은 어려움이 있어 생각한 것이 Class inheritance 를 이용하는 것이었다.

하지만 처음에 상속받은 클래스를 Decode 하는 과정에서 이슈가 하나 있었는데 이 경험을 공유하고자 한다. 아래의 데이터를 보자

 

let jsonData = """
{
    "hello": "world",
    "wow": "wonderful",
    "onemoon": null
}
""".data(using: .utf8)!

let jsonData2 = """
{
    "hello": "world",
    "wow": "wonderful",
    "changed": "field"
}
""".data(using: .utf8)!

 

위와 같이 데이터가 들어온 경우 <hello, wow> 필드를 공통으로 가지는 클래스를 만들고 , 필드를 각각 가지는 Subclass 를 아래와 같이 만들었다.

 

class A: Decodable {
    var hello: String
    var wow: String
}
class Aa: A {
    var onemoon: String?
}
class Ab: A {
    var chagned: String?
}

let jsonDecoder = JSONDecoder()
do {
    let subClassData = try jsonDecoder.decode(Aa.self, from: jsonData)
    let subClassData2 = try jsonDecoder.decode(Ab.self, from: jsonData2)
    print("SUCCESS \\n")
    dump(subClassData)
    dump(subClassData2)
} catch let err {
    print("FAIL")
    print(err.localizedDescription)
}

// 결과 값 
// SUCCESS
//
//▿ __lldb_expr_7.Aa #0
//  ▿ super: __lldb_expr_7.A
//    - hello: "world"
//    - wow: "wonderful"
//  - onemoon: nil
//
//▿ __lldb_expr_7.Ab #0
//  ▿ super: __lldb_expr_7.A
//    - hello: "world"
//    - wow: "wonderful"
//  - chagned: nil

 

결과적으로 SUCCESS 와 두 객체가 로그에 남지만, 실제로는 제대로 decode가 되지 않았다. 문제는 catch 구문을 거치지 않기 때문에 놓치기 쉬울 수 있다. 실제로 배포가 된 이후, 나중에서야 파악이 될 수도 있다는 것이다. 그렇다면 아래의 객체와 데이터를 살펴보자. <hello, wow> 필드는 제대로 값을 받아 왔지만 < changed > 필드는 값을 받아오지 못한다. 왜 제대로 decode 가 되지 않았을까 ?

 

알고 보면 굉장히 단순하다. Ab 클래스가 Decode 될 때 required init(from decoder: Decoder) 가 제대로 호출되지 않았기 때문이다. 상속의 특성을 생각해보자 subclass 에서 별도로 initializer 를 설정하지 않는 이상, subclass의 initializer 대신 super class 의 initializer가 호출이 된다. 따라서 Ab 클래스 대신 A의 init만 호출 되었기 때문에 A의 필드인 <hello, wow>만 제대로 값을 가져온 것이다. 이를 해결하기 위해서는 Ab 클래스에서 init을 다시 작성하면 된다.

 

class Ab: A {
    var chagned: String?
    enum Keys: String, CodingKey {
        case changed
    }
    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Keys.self)
        self.chagned = try container.decode(String?.self, forKey: .changed)
        try super.init(from: decoder)
    }
}

//SUCCESS
//▿ __lldb_expr_15.Ab #0
//  ▿ super: __lldb_expr_15.A
//    - hello: "world"
//    - wow: "wonderful"
//  ▿ chagned: Optional("field")
//    - some: "field"

 

Ab Class 에서 decoder를 통한 init이 호출이 되고 < chagned > 필드를 decode 한 다음, super.init을 통해서 부모클래스인 A 에서도 init이 호출 됨을 파악할 수 있다. 이로써 온전하게 상속받은 클래스를 decode 할 수 있다.

Key가 없는 경우에 대한 Issue

실제로 데이터를 주고 받는 과정에서 키 값이 일정하지 않고 유동적으로 변경되는 상황이 종종 일어나고는 한다. 이 상황에서의 문제점은 Decoding 된 객체를 아예 만들지 못한다는 점인데, 자칫하면 UI를 그리지 못하기 때문에 사용성이 굉장히 떨어질 수 있다. 아래 예시를 살펴보자.

 

// origin Data -> Good
{ 
"name": "onemoon",
"thumbnailURL": "<https://ooo.com>"
}

// changed Data -> Bad ( Swift.DecodingError.keyNotFound )
{ 
"name": "onemoon"
}

struct UserData: Decodable {
    let name: String
    let thumbnailURL: String
}

 

위와 같이 thumbnailURL 을 받아 오다가 어떠한 이유로 인해서 더 이상 해당 키에 대한 값이 사라지는 경우 DecodingError.KeyNotFound 에러가 발생하고 UserData는 생성되지 않는다. 이에 대한 해결책을 생각해보자.

 

첫번째, 키 값이 없는 프로퍼티의 타입이 옵셔널인 경우에는 ( thumnailURL: String? 인 경우 ) 해당 에러가 발생하지 않는다.

 

두번째 ,만약 옵셔널을 처리하기 번거롭거나 기본 값이 필요한 경우에는 다음과 같이 Decode에 실패한 경우 할당할 수 있다.

 

struct UserData: Decodable {
    let name: String
    let thumbnailURL: String

    enum Keys: String, CodingKey {
        case name, thumnailURL
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Keys.self)
        self.name = try container.decode(String.self, forKey: .name)
        self.thumbnailURL = (try? container.decode(String.self, forKey: .thumnailURL)) ?? "default Thumbnail URL"
    }
}

 

세번째, container.decodeIfPresent를 활용하는 것이다. 값 자체에 옵셔널을 처리하는 방식과 동일한데 이런 방식도 있다는 것을 참고하도록 하자.

 

struct UserData: Decodable {
    var name: String
    var thumbnailURL: String?

    enum Keys: String, CodingKey {
        case name, thumnailURL
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Keys.self)
        self.name = try container.decode(String.self, forKey: .name)
        self.thumbnailURL = try container.decodeIfPresent(String.self, forKey: .thumnailURL)
                // 키가 있는 경우 decode를 진행하고 없는 경우 nil을 할당한다.
    }
}

 

참고로 init(decoder:)을 직접 구현하는 경우 모든 프로퍼티를 정확하게 써야 하는데, 놓칠 수 있으니 주의를 하도록 하자. 현재는 프로퍼티가 let 으로 선언이 되어서 init에 하나의 프로퍼티라도 빠지는 순간 에러가 발생하지만 var로 선언한 경우 경우는 실제로 데이터가 있어도 값을 가져오지 못하는 경우가 생길 수 있기 때문이다. 아래를 간단하게 참고하도록 하자

 

// Data
{
"name": "onemoon"
}

struct UserData: Decodable {
    var name: String?
    var thumbnailURL: String

    enum Keys: String, CodingKey {
        case name, thumnailURL
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Keys.self)
                // name 에 대한 decode를 실수로 작성하지 못한 경우
        self.thumbnailURL = (try? container.decode(String.self, forKey: .thumnailURL)) ?? "default Thumbnail URL"
    }
}

// Log ::: UserData(name: nil, thumbnailURL: "default Thumbnail URL")
// 값을 제대로 가져오지 못하는 것을 확인할 수 있다.

Container ?

포스트를 작성하다가 container 에 대해서 조금 더 알아봤을 때 container(keyedBy:) 말고도 singleValueContainer(), unkeyedContainer()의 존재를 알게 되었다. 간단하게 짚고 넘어 가보자

SingleValueContainer

Key : Value 가 아닌 단순히 값 하나만 들어온 경우 이를 decode할 수 있다.

 

// jsonData2 > "hello singleValueContainer"

struct SimpleData: Decodable {
    let text: String

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        self.text = try container.decode(String.self)
    }
}

print(try! JSONDecoder().decode(SimpleData.self, from: jsonData2))
// Log ::: SimpleData(text: "hello singleValueContainer")

unkeyedContainer

배열에서 타입이 일정하지 않은 경우를 생각해보면 해당 container로 쉽게 해결할 수 있다.

 

// jsonData3 > ["a", 1, true, 20.0, 50.0]

struct SpecialArrayData: Decodable {
    var a: String?
    var b: Int?
    var c: Bool?
    var d: Double?
//    var e: Double?

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        a = try container.decode(String?.self)
        b = try container.decode(Int?.self)
        c = try container.decode(Bool?.self)
        d = try container.decode(Double?.self)
//        e = try container.decode(Double?.self)
    }
}

print(try! JSONDecoder().decode(SpecialArrayData.self, from: jsonData3))

// Log ::: SpecialArrayData(a: Optional("a"), b: Optional(1), c: Optional(true), d: Optional(20.0))

 

참고로 해당 container는 데이터와 객체 프로퍼티의 순서를 맞춰야 한다는 것에 유의하도록 하자. 특히 unkeyedcontainer가 재밌다고 느껴졌는데, 복잡한 데이터에서 원하는 값만 추출할 때 유용하게 사용할 수 있을 것 같다. 아마 내부적으로 3가지의 container를 JSON data에 맞춰 적절히 사용하여 Decodable이 구현되었을 것이라고 생각한다.

Tip

  • Enum 최대한 활용하기
  • 데이터 조합하기 ( Data = struct + struct )

포스팅을 작성하면서 여러 가지를 느꼈다. 이전에 내가 겪었던 이슈들도 생각해보고 이렇게 저렇게 활용하면 좋겠다는 생각이 들었다. 내가 느끼고 활용할 수 있는 방식들을 직접 테스트 해보고 공유하고자 한다.

Enum 최대한 활용하기

먼저 Enum을 생각해보자 나는 Decoding을 할때 만약 데이터가 몇 가지의 케이스로 나뉘어서 들어온다면, String으로 그냥 값을 decoding 하는 것 보다는 Enum 을 최대한 활용하는 편이다. 보통 다음과 같이 사용한다.

 

enum Phone1: String, Decodable {
    case iPhone
    case galaxy
    case pixel = "Pixel"
}

 

이렇게 되면 Enum의 RawValue 가 Key처럼 되어 "iPhone"이라는 텍스트가 들어온다면 Phone1.iPhone으로 활용할 수 있는 것이다. 하지만 다음과 같은 상황에서는 Enum을 통해 Decoding 하기가 힘들어진다.

  1. RawValue를 이미 다른 값 ( Int, Double ) 로 사용하는 경우, 이를 다시 String으로 변경하거나 처리하는 과정이 복잡하다. ( 혹은 귀찮다. )
  2. associated Value 를 사용하는 경우 decode 단계에서 RawValue를 사용할 수 없다. 따라서 decodable을 채택하는 순간 에러가 발생한다.
  3. 데이터 자체가 결함(?)이 있는 경우 ( 앞 글자가 대문자, 변환이 필요한 경우 등등... )

해당 포스트를 작성하기 전에는 나도 위와 같은 경우 대부분 String으로 처리를 하거나 다른 방식을 찾았던 것 같다. 그렇다면 이 과정을 어떻게 하면 쉽게 처리할 수 있을까? 방법은 init(from:)를 직접 활용하면 된다. 아래 예시를 보자

 

// User1Phone > "blackberry"
// User2Phone > "IPHONE"

enum Phone: Decodable {
    case iPhone
    case galaxy
    case pixel
    case uncommon(whatKind: String)

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        var value = try container.decode(String.self)
        value = value.lowercased() // 받아온 데이터를 직접 처리할 수 있다.

        if value == "iphone" {
            self = .iPhone
        } else if value == "galaxy" {
            self = .galaxy
        } else if value == "pixel" {
            self = .pixel
        } else {
            self = .uncommon(whatKind: value)
        }
    }
}

print(try! JSONDecoder().decode(Phone.self, from: User1Phone)) // uncommon(whatKind: "blackberry")
print(try! JSONDecoder().decode(Phone.self, from: User2Phone)) // iPhone

 

이렇게 직접 decoder를 작성하는 순간 위에서 언급한 세가지 이슈가 모두 해결 된다. 1. RawValue는 신경 쓸 필요가 없어진다. 2. 따라서 RawValue를 신경 안쓰고 decoder에서 직접 값을 만들어 주면 된다 ( uncommon 확인 ). 3. 데이터가 중간에 대문자가 포함되어 있거나 값을 처리해야 하는 경우도 직접 처리하면 된다.

데이터 조합하기 ( Data = struct + struct )

서비스를 하다 보면 분명히 기존에 사용한 데이터가 잘 정리 되어 있을 것이다. 이는 서버도 마찬가지이다. 만약 User와 Device라는 데이터를 분리해서 ( 혹은 다른 키 값으로 ) 가져오다가, 어느날 두 가지를 합쳐서 준다면 어떻게 처리할까? 아래 예시를 한번 보자

 

// 기존에는 User 혹은 Device만 따로 데이터가 오는 경우 활용한 struct 들
struct UserEntity: Decodable {
    var name: String
    var age: Int

        enum CodingKeys: String, CodingKey {
        case name, age
    }
}

struct DeviceEntity: Decodable {
    var name: String
    var capacity: String

    enum CodingKeys: String, CodingKey {
        case name = "phoneName"
        case capacity = "phoneCapacity"
    }
}
// 새로운 데이터
var newUserAndPhoneData = """
{
"name": "onemoon",
"age": 25,
"phoneName": "iphone 12",
"phoneCapacity": "256G"
}

""".data(using: .utf8)!

 

데이터를 보자 마자 드는 생각은 두 개를 합치면 되겠다는 생각이 들 것이다. 아래의 형식처럼 되면 얼마나 좋겠는가?

 

struct UserAndDevice: Decodable {
	var user: UserEntity
	var device: DeviceEntity
}

 

물론 현실은 생각만큼 호락호락 하지 않다. newUserAndPhoneData를 보면 UserEntity와 DeviceEntity가 할당되는 키가 없기 때문에 이렇게 작성하면 decodeError 가 발생한다. UserAndDevice는 Key값이 user 와 device인 경우에만 동작하기 때문이다.

그렇다면 이를 어떻게 바꾸면 좋을까? decoder 를 직접 작성한다면 놀랍게도 위의 객체를 바로 활용할 수 있다. 아래와 같이 바꿔 보자

 

struct UserAndDevice: Decodable {
    var user: UserEntity
    var device: DeviceEntity

    init(from decoder: Decoder) throws {
	self.user = try UserEntity.init(from: decoder)
	self.device = try DeviceEntity.init(from: decoder)
    }
}

 

간단하게 생각해서 받아온 데이터로 UserEntity 그리고 DeviceEntity 를 생성 하면 되는 것이다. UserEntity가 만들어 질때 name과 age를 통해서 객체를 만들고 나머지 phoneName, phoneCapacity 는 무시하게 된다. 또한 DeviceEntity를 만들때는 그 반대가 될것이다. 이렇게 하면 간단하게 기존에 사용하던 객체를 활용하여 조합으로 데이터를 표현할 수 있을 것이다.

 

내가 든 예시는 극히 일부분이고 데이터가 점점 더 복잡해지고 처리 하면서 init(from decoder:)를 직접 활용 해보면 생각보다 많은 수고가 들지 않을 것이라고 생각한다.

 

03.07 수정 내용

 

수정 전 내용은 UserEntity, DeviveEntity에 enum Keys: String, CodingKey {} 형식으로 진행했고 UserAndDevice에 decode를 진행할 때 Key 를 직접 집어넣어서 decode 하는 형식으로 글을 작성했다. 물론 테스트를 직접 하고 진행했기 때문에 동작에는 문제가 없다. 하지만 조금 더 편한 방식이 있지 않을까 생각하던 도중 위와 같이 decoder 자체를 넘기면 조금 더 편하겠다는 생각이 들었다.

 

문제는 CodingKey의 이름이었는데 기본적으로 CodingKeys라는 이름을 가져야 decoder 에서 자동으로 키를 변환하고 값을 할당한다. 따라서 이를 변경하였고 decoder를 넘기는 방식으로 수정하였다. 

 

 

'iOS & Swift' 카테고리의 다른 글

Responder Chain ( 앱은 유저의 인터렉션을 어떻게 처리하는가 ? )  (0) 2021.03.18
Reducing Dynamic Dispatch ( 성능향상 )  (0) 2021.01.24
Encoding 정복하기  (0) 2020.11.23
Codable  (0) 2020.10.25
Text Size 구하기  (0) 2020.04.26

우리는 네트워크를 통해서 데이터를 받아오는데 이때의 형식은 대부분 JSON을 사용하고는 한다. 이때 미리 정해놓은 프로토콜을 통해서 외부의 데이터와 내부의 데이터간의 전환을 도와준다. 이 프로토콜이 Codable 이다.



Codable은 A type that can convert itself into and out of an external representation. 로 정의되어 있다. 즉 자신의 타입을 다른 표현으로 변경하거나 그 반대의 역할을 할 수 있도록 하는 것이 Codable 이라고 한다. 따라서 정확하게는 두개의 프로토콜을 합쳐놓은 타입 이라고 말할 수 있다.

 

자신의 타입을 다른 외부 표현으로 변경하는 작업을 encode라고 하고, 외부의 표현을 내부의 표현으로 변경하는 작업을 decode라고 한다. Codable은 decode를 도와주는 Decodable과 encode를 도와주는 Encodable을 채택했기 때문에 두가지의 역할을 할 수 있는것이다.

Decodable



위에서 말했듯이 Decodable은 decode를 용이하게 한다. 즉 외부의 표현을 내부의 표현으로 바꾸는 것이다. 여기에서 외부의 표현은 JSON 형태의 데이터가 되는 것이고, 내부의 표현은 우리가 작성한 객체가 될 것이다. 다음 예시를 살펴보자

 

// 서버로 부터 받은 JSON Data
{"userId":1,"id":1,"title":"sunt aut facere repellat provident occaecati excepturi optio reprehenderit","body":"quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"}

 

네트워크 요청을 통해서 JSON 데이터를 받아왔다. 우리가 decode를 하기 전에는 해당 데이터는 텍스트에 불과하며 직접 원하는 값을 추출하기에는 상당한 노고가 필요 할 것이다. 이를 쉽게 변경하고 우리가 원하는 키 값을 찾기 위해서 decode를 사용한다.



// JSON Data -> TestModel
struct TestModel: Decodable {
    var userId: Int
    var id: Int
    var title: String
    var body: String
}

let testModel = try! JSONDecoder().decode(TestModel.self, from: data)

 

JSON 데이터를 변환하기 위해서 JSONDecoder의 decode 함수를 캡쳐했다. 보다시피 decode는 generic method 이다. 변환되기 원하는 타입( T )에 Decodable을 채택시키고 이를 첫번째 인자로 사용한다. 두번째 인자는 jsonData를 넣으면 될 것이다. 해당 작업을 통해서 decode가 이루어지고 예시에서 보이는 것 처럼 JSON 데이터를 TestModel 이라는 객체로 사용할 수 있는 것이다.

 

이때 JSONDecoder 이기 때문에 JSON 형식이어야 하고, 키값이 정확해야 한다는 사실을 인지하자. 키 값이 꼭 동일할 필요는 없는데 이는 아래의 CodingKeys에서 조금 더 명확하게 설명하겠다.

Encodable



 

그렇다면 이제 반대의 경우를 생각해보자. 내부의 표현을 외부의 표현으로 즉 객체를 JSON으로 바꾸는 것이다. Swift에서의 객체는 내부에서는 키와 값으로 매칭하지만 JSON 에서 어떻게 표현해줘야 할지 우리가 정해줘야 한다. Encodable을 채택한 객체는 이를 명확하게 정해줄 수 있도록 도와준다. 보통 post, fetch 등등 서버로 데이터를 보내줘야 하는 경우 사용된다.

 



let dataForServer = try! JSONEncoder().encode(testModel)

decode와 마찬가지로 encode 또한 generic type이다. Encodable을 채택한 객체를 인자로 받고 해당 객체를 JSON 형식으로 변환한 Data를 리턴한다.

CodingKey

위에 decode에서 설명했듯이 키 값이 꼭 서버의 형식과 동일할 필요는 없다. 예시를 들어서 설명하자면 나같은 경우는 CamelCase (HappyBirthday) 를 사용하여 변수를 지정하는 편이다. 하지만 서버에서는 SnakeCase(happy_birthday) 형식으로 값을 보내준다면 어떻게 처리해야 할까? 이를 위해서 CodingKey라는 protocol이 사용된다.

// Data From Server ( Snake Case )
{
  "name": "kan",
  "happy_birthday": 12
}
// Struct Without CodingKey < X >
struct Birthday: Decodable {
  var name: String
  var happy_birthday: Int
  // CodingKey를 사용하지 않는다면 위와 같이 서버의 네이밍 규칙에 따라야 한다.
}
// Struct With CodingKey ( CamelCase ) < O >
struct Birthday: Decodable {
  var name: String
  var happyBirthday: Int
  enum Codingkeys: String, CodingKey {
    case name
    case happyuBirthday = "happy_birthday"
  }
}
let birthday = try! JSONDecoder().decode(Birthday.self, from: data)

 

보이는 것 처럼 CodingKey가 없었다면 서버의 네이밍 규칙에 따라 Snake Case를 그대로 사용 했을 것이다. 물론 데이터가 변경되거나 문제가 생기는 것은 아니다. 다만 내부 규칙에 따라서 통일성있게 정리할 수 없다는 큰 단점이 있다. 이를 해결하기 위해서 아래 객체에서는 CodingKey를 채택한 CodingKeys라는 enum을 만들었다. 해당 enum을 통해서 각 프로퍼티가 어떤 네이밍 규칙을 따를지 rawValue로 정리해준다면 손쉽게 변경된 키값의 객체를 사용할 수 있다.

마치며

정말 초반에는 Codable의 뜻도 몰랐으며, encode와 decode도 헷갈리고 제대로 설명할 수도 없었던 기억이 난다. 해당 포스트를 통해서 조금 더 정확하게 Codable을 알고 encode와 decode를 설명할 수 있기를 바란다. 사실 encode 와 decode는 조금 더 넓은 의미를 가지고 있으며 해당 포스트에서는 JSON에 한정에서 설명한 것이므로 오해하면 안된다는 사실을 말해주고 싶다.

+ Recent posts