Swinject

저번 포스팅에서 SOLID를 공부한 이유는 사실 DI를 이해하고 이를 적용하기 위함이었다. 직접 프로토콜을 설계하고 작업하려다가 기존에 나와있는 DI Library 는 어떻게 활용하는지 궁금해서 문서를 읽고 예제를 만들어봤다. 오늘 소개할 라이브러리는 Swinject 라는 DI Library 이고, 추가로 SwinjectStoryboard를 간단하게 사용할 예정이다.

 

이번 포스팅에서는 문서에서 중요하다고 생각한 부분들을 추리고, 예제 및 문서의 코드를 직접 활용하면서 어떻게 사용했는지 설명 할 생각이다. 문서의 양이 얼마 되지 않으니 한번 전체를 읽어보고 다시 와서 핵심을 파악하는 것을 추천한다.

 

글이 너무 길어져서 읽기 힘들다는 생각이 들어 2개의 포스트로 나누었다. 첫번째 포스트에서는 Document 위주로 설명을 하는 포스트가 될 것이고, 두번째 포스트는 이를 토대로 실제 예제에 어떻게 적용을 시키는지에 대해서 작성을 할 예정이다. 레퍼런스의 경우 2번째 포스트의 맨 아래에 적어놓을 예정이다.

 

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

Swinject 사용하기 ( 2 / 2 )

Container

DI Container 로 이해하면 되며, 사실상 Swinject의 핵심이라고 할 수 있다. Container 에 원하는 serviceType을 register 하면 된다. 이후에 필요한 곳에서 resolve를 통해 해당 타입에 맞는 객체가 생성되어 나온다. 샘플 프로젝트에서 예시를 가져왔다.

let container = Container()
// CocktailNameListViewModeling (protocol)
// CocktailNameListViewModel (class)
// register
container.register(CocktailNameListViewModeling.self) { (_, alphabet: String) in
    return CocktailNameListViewModel(alphabet)
}
// resolve
let viewModel = container.resolve(CocktailNameListViewModeling.self, argument: targetAlphabet(at: indexPath))

ViewModel 을 실제로 등록하고 사용하는 부분의 코드이다. CocktailNameListViewModeling이라는 protocol을 serviceType 으로 등록하고 해당 프로토콜을 채택한 CocktailNameListViewModel을 리턴하도록 container 에 등록한다.

 

이후 ViewModel을 injection 해줘야 하는 부분에서 resolve를 통해 구체화된 객체 CocktailNameListViewModel을 가져온다. 이때는 등록할때의 클로저(factory closure) 에 필요한 인자들을 넘길 수 있다.

register 가 중복되는 경우는?

이 문서를 봤을 때 궁금했던 점은 동일한 프로토콜을 여러 곳에서 사용하려면 ( 여러 구현체가 있다면 ) 어떻게 해야할까? 라는 점이었다. 결론부터 말하자면 내부적으로 생성되는 Key인 Registration Key 를 통해서 구분할 수 있다. 라이브러리에 구현된 코드를 가져왔으며 이를 보면서 설명하겠다.

// Container.arguments.swift
@discardableResult
public func register<Service, Arg1>(
    _ serviceType: Service.Type,
    name: String? = nil,
    factory: @escaping (Resolver, Arg1, ...) -> Service
) -> ServiceEntry<Service> {
    return _register(serviceType, factory: factory, name: name)
}    

container 에 등록을 할때 내부적으로 Registration Key 가 생성되어 서로를 구분하게 된다. 이때의 키는 위에서 register 할때의 인자 3가지 를 통해서 구분하는 것을 확인할 수 있다. 인자는 다음과 같이 설명할 수 있다.

 

  1. type of the service ( serviceType )
  2. name of registration
  3. number and type of the arguments

이 덕분에 동일한 타입을 등록하더라도 다른 register 함수와의 차이를 판단하고 서로 다른 구현체를 resolve 할 수 있다. 2번에 대한 설명을 문서에 있는 코드와 함께 설명하겠다. 문서의 코드를 확인해보면 name: "cat" 라는 인자를 넘긴 것을 확인할 수 있다.

container.register(Animal.self, name: "cat") { _ in Cat(name: "Mimi") }    

또한 Registration Key를 구분하는 3가지의 인자중 하나라도 다른 경우 nil을 리턴하게 되니 이를 조심해야 한다.

Circular Dependencies

Swinject를 사용하면 자동으로 서로 resolve 되도록 설계할 수 있고, 이를 통해서등록이 되니 편함을 느낄 수 있다. 이때 만약 A를 만들때 B가 필요하고 B를 만들때 A가 필요한 Circular Dependencies가 생긴다면 이는 어떻게 해야할까? 생각해보면 생성하는 단계에서 무한으로 서로 resolve 할 가능성이 있어 보인다.

 

이를 해결하기 위해서는 A와B 둘중 하나라도 property injection이 들어가야 한다고 문서에서 설명한다. 즉 intializer/initialzer dependencies는 지원하지 않는다. injection 에 대한 설명은 문서에서 확인할 수 있고, 아래는 Initializer/Property Dependencies의 코드를 예시로 가져왔다.

protocol ParentProtocol: AnyObject { }
protocol ChildProtocol: AnyObject { }
// initializer injection
class Parent: ParentProtocol {
    let child: ChildProtocol?
    init(child: ChildProtocol?) {
        self.child = child
    }
}
// property injection
class Child: ChildProtocol {
    weak var parent: ParentProtocol?
}

initCompleted closure 에서 필요한 dependency를 property injection 방식으로 넘기면 된다. 이를 통해서 아래와 같이 사용하면 Circular Dependencies 를 해결할 수 있다.

let container = Container()
container.register(ParentProtocol.self) { r in
    Parent(child: r.resolve(ChildProtocol.self)!)
}
container.register(ChildProtocol.self) { _ in Child() }
    .initCompleted { r, c in
        let child = c as! Child
        child.parent = r.resolve(ParentProtocol.self)
     }

Object Scope

resolve를 통해서 생성된 객체를 유지하려면 어떻게 해야할까? 혹은 생성된 인스턴스의 lifecycle은 어떻게 될까? 해당 부분에 대한 설명은 Object Scope를 통해서 할 수 있다. Swinject 에서 제공하는 Object Scope는 4가지이다.

 

  1. Transient
    Transient 를 사용한다면 Instance는 공유되지 않는다. 즉 resolve를 할 때마다 Instance 가 생성된다. Circular Dependencies 에서 사용하는 경우는 주의를 해야한다.
  1. Graph(default)
    Transient 와 비슷하게 container 에서 resolve 를 하는 경우 항상 생성한다. Transient와의 차이는 factory closure에서 resolve 를 하게 되는 경우 resolution이 끝날때 까지 해당 인스턴스는 공유된다는 점이다.
  1. Container
    다른 DI frameworks의 singleton이라고 생각하면 된다. container 를 통해서 생성된 instance 는 해당 container 그리고 chile containers(child containers 가 궁금하다면 Container Hierarchy 문서를 확인해보자)까지 공유가 된다. 다시 말해서 어떤 container 에서 A 타입을 register 한 이후에 resolve 를 하게 되면, 이후 A 타입에 대한 resolve 는 앞서 생성한 instance 가 동일하게 리턴된다. ( 동일한 객체 )
  1. Weak
    재미있는 케이스이다. instance를 strong 으로 잡고 있는 경우는 Container 처럼 서로 공유하지만 더 이상 strong reference 가 없는 경우 더 이상 공유되지 않는다. 이후 resolve를 하게 되면 새로운 인스턴스가 생성이 된다.

Custom Scope

위에서 설명한 4가지 스코프는 Swinject에서 정한 스코프이다. 이 외에도 직접 Scope를 만들어서 사용할 수 있는데 이를 Custom Scope라고 한다. 이때 container scope 처럼 공유가 되는데 필요에 따라서 객체들을 reset 할 수 있다는 차이가 있다.

// 생성
extension ObjectScope {
    static let custom = ObjectScope(storageFactory: PermanentStorage.init)
}
// 제거
container.resetObjectScope(.custom)

조금 더 자세하게 알고 싶다면 해당 블로그에서 확인해보자

Assembly

위와 같이 container 자체로 활용할 수도 있지만 만약 도메인별로 관리하고 싶은 경우는 어떻게 해야할까? 혹은 연관된 기능들을 필요한 부분만큼만 관리하고 싶다면 어떻게 해야할까?

 

연관된 서비스를 그룹화하는 기능을 제공하기 위해서 Swinject 에서는 Assembly 라는 프로토콜을 제공한다. 이를 통해서 아래와 같은 기능을 사용할 수 있다.

  • 하나의 장소에서 여러개의 서비스를 관리할 수 있음
  • 공유된 Container 를 제공함
  • 서로 다른 설정을 가진 assembly를 등록할 수 있다. 따라서 mock implement에 유리하다.
  • 제대로 설정이 되면 ( 등록 과정이 끝나면 ) 알림을 받을 수 있다.

물론 다 옵션이므로 필요한 부분만 사용하면 된다. 아래 예시 코드를 간단하게 가져왔다. ManagerAssembly 에서 ServiceAssembly 에서 등록한 protocol을 사용하는 것을 확인할 수 있다.

class ServiceAssembly: Assembly {
    func assemble(container: Container) {
        container.register(FooServiceProtocol.self) { r in
           return FooService()
        }
        container.register(BarServiceProtocol.self) { r in
           return BarService()
        }
    }
}

class ManagerAssembly: Assembly {
    func assemble(container: Container) {
      // 다른곳에서 등록한 protocol 또한 활용할 수 있다.
        container.register(FooManagerProtocol.self) { r in                                                     
           return FooManager(service: r.resolve(FooServiceProtocol.self)!)
        }
        container.register(BarManagerProtocol.self) { r in
           return BarManager(service: r.resolve(BarServiceProtocol.self)!)
        }
    }
}

Assembler

하지만 위의 코드는(Assembly) 리소스를 준비하는 과정이고, 실제로 사용하기 위해서는 Assembler 를 사용해야 한다. 여러 곳에서 준비된 Assembly들을 잘 조합해서 Assembler를 사용하는 것이다.

let assembler = Assembler([
    ServiceAssembly(),
    ManagerAssembly()
])

let fooManager = assembler.resolver.resolve(FooManagerProtocol.self)!
// or lazy load assemblies
assembler.applyAssembly(LoggerAssembly())

Assembler 는 Assemblies 그리고 Container 를 관리한다. Assembler 를 통해서 등록된 Assembly 들만 Container 에서 사용할 수 있다는 것을 기억해두자. 또한 resolve하기 위해서 접근하는 resolver는 assembler의 resolver(assembler.resolver) 로 제한이 된다.

Thread Safety

Swinject는 concurrent applications 로 구현이 되어있다. 따라서 Container 는 thread safe 하지 않다. 하지만 이를 thread safe 하도록 하는 방법이 있는데 synchronize method를 이용해서 리턴되는 resolver를 사용하는 것이다.

let container = Container()
container.register(SomeType.self) { _ in SomeImplementation() }
let threadSafeContainer: Resolver = container.synchronize()
// Do something concurrently.
for _ in 0..<4 {
    dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0)) {
        let resolvedInstance = threadSafeContainer.resolve(SomeType.self)
        // ...
    }
}

container 가 thread safe 하지만 이는 resolve에만 해당하는 이야기이다. register 는 여전히 thread safe하지 않다. 따라서 registeration 은 단일 쓰레드에서 실행이 되어야 한다.

 

또한 만약 container 가 hierarchy 를 갖고 있다면 연결된 모든 컨테이너는 synchronize()를 사용해야 한다는 점을 알아두자

 

다음포스트 >

코드를 작성하다보면 SOLID Principle이라는 규칙을 자주 보고는 한다. 물론 이를 공부한다고 모든 코드를 SOLID에 입각한 코드로 변경하는 것은 무리다. 하지만 객체지향을 공부하는 사람으로써 바이블격인 SOLID를 공부하고 이를 정리해야겠다는 생각이 들었다. 글의 순서는 아래와 같다.

저 또한 배우고 있는 과정이기 때문에 문제가 있거나 잘못된 부분이 있다면 감사하겠습니다!

  • SOLID Principle
  • Single Responsibility Principle
  • Open/Close Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

SOLID Principle

  • 어떤 장점이 있는가?
    • 재사용성이 높은 코드를 만들어 컴포넌트화를 통한 생산성을 높힐 수 있다.
    • 테스트가 가능한 코드를 만들어 관리할 수 있다.
    • 여러곳에 묶여 있는 스파게티 코드를 만들지 않는 기준을 세울 수 있다.
    • 변경에 유연하며 확장성이 높은 코드를 만들 수 있다.
    • 결국 관리하는데 필요한 리소스가 줄어든다.

S (Single Responsibility Principle , 단일 책임 원칙 - SRP )

  • 하나의 모듈은 하나의 이해관계자를 만족시켜야 한다.
    • 여기서 모듈이란 데이터와 함수의 응집된 집합을 말한다. ( 보통 클래스 )
    • 이런 관점에서 쉽게 말하자면, 단일 클래스는 단일 책임을 가지고 있어야 한다.
  • 코드의 응집성을 높힌다.

Why?

처음에 모듈을 만들 때 이 규칙만큼은 쉽게 지킬 수 있을 것이다. 문제는 작은 기능이 하나둘씩 추가 되면서 시작된다. 처음에는 하나의 기능만 추가 된다고 하지만 하나만 추가 되리라는 보장은 없다. 계속해서 기능이 추가되고 점점 해당 모듈의 책임은 많아지게 된다.

해당 모듈의 책임이 많아 질수록 코드가 병합될 확률이 높아진다. 예를 들어 Alpha 클래스에 A, B 라는 책임을 갖고 있을때는 A와 B의 코드가 서로 병합되는 상황이 발생할 수도 있으며, 책임(기능)이 하나 둘씩 늘어날수록 그 가능성은 점점 커질 것이다.

이때 만약 A의 기능을 리팩토링 한다고 하자. 병합이 많아질수록 A 뿐만 아니라 다른 기능 또한 수정해야 할 가능성이 높아지며, 해당 기능이 제대로 동작하는지 또한 다시 확인해야 한다. 결국 이를 해결하기 위해서는 꼬인 끈을 모두 풀어내는 수 밖에 없으며, 기능이 많아질수록 이에 대한 비용은 기하 급수적으로 늘어나게 된다.

특히 iOS 의 경우 UIViewController가 대표적인 예라고 할 수 있다. UI, Network, Navigation 등등 모든 역할을 책임지기 때문이다. 이를 잘 나누는 것이 중요하다고 할 수 있다.

How?

첫번째로, 작은 피쳐를 그만 추가해라. 모듈, 컴포넌트, API 형식으로 분리하는것을 생각해봐라. 기능을 붙일 생각을 하지말고 라이브러리로 만들 생각을 해야한다.

그리고 하나의 기능을 할 수 있는 클래스를 만들어라. 만약 커다란 문제에 가로막혔다면 이를 분리해라. 그리고 분리한 클래스를 사용하는 클래스를 하나 만들어라

O ( Open / Close Principle , 개방/폐쇄 원칙 - OCP )

  • 확장에는 열려있고, 변경에는 닫혀있어야 한다.
    • 즉 개체의 행위는 확장할 수 있어야 하지만, 개체가 변경되어서는 안된다.

Why?

예를 들어서 Bread House 라는 클래스를 만들고 여기서 빵을 만들어서 판매한다고 하자. 초기에는 단팥빵만 판매한다고 생각했다. 따라서 단팥빵에 맞는 로고를 만들고, 기구를 들였으며, 저장 창고도 만들어놓았다. 그런데 어느날 Bread House 에서 카스테라를(확장) 팔아야 하는 상황이 왔다. 이미 단팥빵에 맞춰서 House를 설계했기 때문에 로고는 물론, 기구, 저장창고도 바꿔야 하는 최악의 상황이 나타날 수도 있다. 결국 이에 대한 비용은(변경) 굉장히 많을 것이다.

만약 처음부터 특정 빵에 전문화하지 않고, 다른 빵을 판매할 가능성이 있다고 고려한 다음 설계했다면 어땠을까? 확장에 대한 비용은 급격하게 줄어들 것이라고 확신한다. 이 방식이 OCP를 고려한 방식이다.

실제로 사용하는 예시를 들자면 network 에서 데이터를 가져온 뒤 이를 decode 하는 방식을 들 수 있다. 아래의 코드를 살펴보자 타입이 추가될때 마다 decode관련 함수를 추가해야할 것이다. 이때 타입이 수십개 추가된다면 어떻게 될까?

class Parent: Decodable { }
class Child: Decodable { }

func decodeParentData(_ data: Data) -> Parent {
    let decoder = JSONDecoder()
    return try! decoder.decode(Parent.self, from: data)
}

func decodeChildData(_ data: Data) -> Child {
    let decoder = JSONDecoder()
    return try! decoder.decode(Child.self, from: data)
}

How?

아우를 수 있는 확장성이 높은 코드를 사용하는 것이 좋다. 제네릭, 상속, 프로토콜을 사용하도록 하자. 결국 다른 타입이 추가 되더라도, 기존의 코드는 최대한 건드리지 않으면서 새로운 타입에 대해서도 기존에 사용했던 기능들은 그대로 사용할 수 있어야 한다.

위의 예시를 Generic과 Protocol을 사용해서 변경한다면 Parent 혹은 Child 심지어 다른 타입이 추가되더라도 Decodable 프로토콜을 채택한다면 전혀 문제가 없을 것이다. 즉, 다른 타입이 추가되는 확장에도 문제가 없으며, decode 함수가 변경될 일이 없으므로 변경에도 닫혀 있는 코드이다.

func decodeData<T: Decodable>(_ data: Data, _ type: T.Type) -> T? {
    let decoder = JSONDecoder()
    return try? decoder.decode(type.self, from: data)
}

L ( Likov Substitution Principle, 리스코브 치환 원칙 - LSP )

  • S가 자료형 T의 하위타입이라면 프로그램의 속성의 변경없이 자료형 T의 객체를 자료형 S의 객체로 교체할 수 있어야 한다.
    • 상위 타입의 객체를 사용하는 곳에 하위 타입의 객체를 사용하더라도 의도한대로 동작이 되어야 한다.
    • 객체는 프로그램의 정확성을 해치지 않으며, 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
    • 쉽게 말하자면 자식 클래스는 최소한 자신의 부모클래스의 행위는 수행할 수 있어야 한다는 말이다.

전형적인 위반 사례

해당 규칙이 제일 이해하기가 애매해서 대표적인 위반 사례를 가져왔다. Circle-elipse(sqaure-rectangle) Problem이다.

너비와 높이의 조회 및 할당 메서드를 가진 직사각형 클래스로부터 정사각형 클래스를 파생하는 경우를 예로 들 수 있다. 직사각형을 상위타입으로 생각하고, 정사각형을 하위타입이라고 하자.

하지만 직사각형과 다르게 정사각형은 넓이와 높이가 항상 같은 특징을 가지고 있다. 따라서 너비와 높이를 따로 관리하는 직사각형을 사용하는 프로그램에서 이와 다른 정사각형을 대체하는 경우 얘기치 못한 문제가 생길 가능성이 높다.

Why?

명확하게 경계를 긋지 않는다면 생길 수 있는 문제점들이 많기 때문에 해당 부분을 신경써야 한다. 위의 예시를 생각해보자. 직사각형을 부모로 생각하고 정사각형을 하위 요소로 생각했기 때문에 문제가 발생했다. 이는 잘못된 상속의 사례를 보여줌으로써 이를 활용할 수 있는 방안이 제한되는 것을 보여준다.

(클린 아키텍쳐에서는 이렇게 설명한다.) 이는 method의 override 가 어떻게 이루어져야 하는지를 설명하기도 한다. 예를 들어서 a라는 메서드를 오버라이드 했을 때 성공한 경우, 실패한 경우, 중간에 변경되어야 하는 경우 등의 흐름이 a라는 메서드와 동일해야 한다는 말이다.

a에서는 실패한 경우 message를 보내주는데, 오버라이드 한 메서드는 실패한경우 아무것도 일어나지 않는다면 이는 LSP를 어긴 것이다. 따라서 동일하게 동작한다는 것은 실제로 동일한 코드가 아니라 동일한 흐름을 가져야 한다는 것이다.

How?

다른 하위타입으로 대체가 되더라도 동일한 동작을 해야한다. 이를 위해서 상속의 범위를 잘 생각해야 한다. 사용하는 메서드가 전체를 아우르는 메서드가 맞다면 동일한 동작을 해야하며, 그렇지 않다면 따로 작성해야 함을 의미한다. ( Circle-elipse )

또한 method를 override 할 때 내부의 동작 흐름이 기존의 동작 흐름과 동일한지 생각해봐야 한다. 예를 들어서 a라는 메서드를 오버라이드 했을 때, 성공한 경우, 실패한 경우, 중간에 변경되어야 하는 경우 등의 흐름이 a라는 메서드와 동일해야 한다는 말이다. a에서는 실패한 경우 message를 보내주는데, 오버라이드 한 메서드는 실패한경우 아무것도 일어나지 않는다면 이는 LSP를 어긴 것이다. 따라서 동일하게 동작한다는 것은 실제로 동일한 코드가 아니라 동일한 흐름을 가져야 한다는 것이다.

따라서 오버라이드를 했는데 다른 부분을 수정하거나 추가한다면 이는 잘못된 것이다. ( 치환해도 동일하게 작동해야 한다는 뜻이 이 뜻이다. ) 서브클래스를 제외하고서는 다른 부분이 변경되어서는 안된다.

I ( Interface Segregation Principle, 인터페이스 분리 원칙 - ISP )

  • 일반적인 인터페이스보다 각각의 구체적인 인터페이스를 가지는 것이 낫다.
  • 불필요한 기능이 들어간 인터페이스 설계를 피해야 한다.

Why?

애초에 불필요한 기능은 넣을 필요가 없다. 하나의 일만 하는데 다섯개의 기능이 들어가 있다면, 나머지 4개의 코드를 관리하는데 드는 비용은 전혀 중요하지 않은 비용이 된다.

How?

Fat Interface 를 만들지 말자. 필요한 기능만 인터페이스에 추가해서 사용하도록 하며, 여러개를 사용하는 경우는 이를 합치면 된다. Swift에서의 대표적인 예로 Codable 이 있다. Codable의 경우 Decodable 과 Encodable protocol이 합쳐진 typealias 이다. 따라서 두개의 기능이 필요 없다면 Decodable, Encodable 한쪽만 사용하면 된다. Decode만 수행하면 되는데 굳이 Codable을 쓸 필요가 없다. 바나나를 원하는데 고릴라와 함께 온다면, 바나나는 얻었지만 고릴라는 귀찮은 존재가 된다. 그만큼 세분화 해서 나눠야 적절한 곳에 배치할 수 있다.

D ( Dependency Inversion Principle, 의존관계 역전 원칙 - DIP )

  • loose coupling module을 만들기 위한 principle이다.
  • 상위 레벨 클래스가 하위 레벨 클래스에 의존하지 않도록 한다.
    • 다시 말해서 상위 레벨 클래스는 하위 레벨 클래스의 구현과 독립되어야 한다.
    • 여기서 상위 레벨이란 ViewController / ViewModel 등을 말하며, 하위 레벨이란 Storage / Network Module 을 말한다.

Why?

decoupling 을 위해서 사용이 되는 principle이다. decoupling 이란 strong coupling이 아닌 loose coupling을 추구하는 과정을 말한다. 다른 구체화된 개체에 강하게 의존했을 때 strong coupling 이 생기게 된다. 이때 하나의 변수를 수정할 때에도 여러 의존성이 서로 묶여있기 때문에 쉽게 변경이 불가능 할 것이다. 이런 상황을 strong coupling 이라고 하며, 이가 많아질 수록 코드의 확장성은 떨어지고, 유지보수 비용은 급격하게 증가하게 된다.

또한 구체화된 개체보다 추상화된 개체를 선택하는 것이 변동성이 작기 때문이다. 프로토콜(추상화)를 의존한다면 내부의 동작(구체화된 개체)에 대해서는 신경쓰지 않아도 된다. 즉 구현체의 변경에 민감하지 않다. 반대로 구현체를 의존하게 된다면 구현체가 변경 될 때마다 민감하게 반응을 해야 할 것이다.

How?

high-level 에서 단순히 low-level을 참조하는 것이 아닌 추상화를 서로 참조하는 방식을 사용한다. 쉽게 말해 기존에 A → B의 형태로 코드가 작성되었을 때는 A 와 B가 tight coupling 이지만, A → B protocol ← B 형태의 코드를 작성하게 된다면 loose coupling 이 되며 서로의 변경에 영향이 적어지게 된다.

이를 설명할 때 dependency inversion은 low-level에서 high-level을 참조하는 것이 아니다. 단지 기존의 흐름을 바꾸는 것을 inversion으로 표현하는 것으로 알고 있다. 특히 예시의 화살표 방향을 살펴보면 B는 참조를 받는 입장에서 B protocol을 참조하는 방향으로 바뀌는것을 확인할 수 있다. 기존의 제어 흐름과 소스코드의 의존성이 반대가 되는 것이다.

reference

클린아키텍쳐 ( book )

Wiki - SOLID

[iOS] 객체지향과 SOLID 원칙 - Swift

Scaledrone realtime messaging service - Blog

Responder Chain을 정리하다보니 앱이 유저의 인터렉션을 어떻게 처리하는지를 정리할 수 있어서 (그래야만 이해할 수 있어서) 부제를 붙이게 되었다. 단순히 Responder Chain을 설명하기 보다는 어떤 요소들이 결합되어서 Responder Chain 이 어떻게 사용되는지 그리고 왜 사용되었는지를 설명 하고자 한다. 또한 처음부터 Article을 읽고 설명하기 보다는 각 요소들의 문서를 먼저 읽어보며 정리하고 마지막에는 Article을 종합하면서 마무리 할 예정이다.

 

대부분 문서의 내용을 해석했으며 핵심내용을 제외한 일부는 생략을 한 경우도 있다. 따라서 이해가 잘 되지 않는다면 직접 가서 읽는 것을 추천한다. 글의 순서는 아래와 같다.

 

  • UIResponder
  • UIEvent
  • UIControl
  • [ Article ] Using Responders and the Responder Chain to Handle Events
  • Summary

UIResponder

이벤트에 반응하고 다룰수 있도록 하는 추상 인터페이스이다.

 

UIResponder 의 인스턴스를 Responder Object라고 부른다. 또한 이는 UIKit app 에서 이벤트를 다루는 근간으로 이루어져 있다. 또한 UIApplication, UIViewController, all UIView 와 같은 여러 핵심 객체들 또한 responder이다. 이벤트가 발생하면 UIKit은 responder object로 이벤트를 전달하게 된다.

 

  • 우리가 UI를 위해서 사용하는 대부분의 요소들이 Responder이며 이벤트를 처리하기 위해서 이렇게 구성이 된다는 것을 확인할 수 있다.

특정한 이벤트의 경우 다루기 위해서는 상응하는 메서드를 override 해야 한다. 예를 들어서 터치를 다루기 위해서는 touchesBegan , touchesMoved, touchesEnded, touchesCancelled 를 override 해야 한다.

 

이벤트를 다루는 것 말고도, Responder 는 다루지 못한 이벤트를 앱의 다른 파트로 넘길 수 있어야 한다. 들어온 이벤트를 처리하지 않는다면 responder chain을 따라서 다음으로 넘긴다. UIKit은 responder chain 을 동적으로 관리하는데, 사전에 정의된 룰에 따라서 어떤 object가 next가 되어서 이벤트를 받을 지 결정한다. 예를 들어서 view는 superview로, root view는 viewController 로 진행된다.

 

  • 이 내용을 보면 responder chain을 통해서 Responder가 다루지 못한 이벤트가 전달되는 것을 확인할 수 있다. 이때 UIResponder의 property 중에서 next를 살펴보자. 해당 property의 정의를 확인해보면 Returns the next responder in the responder chain, or nil if there is no next responder. 즉 ResponderChain의 요소 중 다음 Responder를 리턴하는 역할이다. 결국 next를 통해서 연결된 Responder 들이 Responder Chain을 이룬다는 것을 알 수 있다.

기본적으로 Responder 는 UIEvent를 처리하지만 input view를 통해서 custom input을 받을 수도 있다. 특히 inputview의 명확한 예라고 할 수 있다. 예를 들어서 UITextfield , UITextView를 유저가 누르는 경우 first Responder가 되면서 inputView( 시스템 키보드 )를 보여준다. 이와 비슷하게 customview를 직접 만들어 responder가 active 되었을때 보여줄 수도 있다.

 

  • UIResponder란 이벤트를 처리하는 앱을 구성하는 요소들의 뼈대라고 설명할 수 있다.

UIEvent

앱에서 하나의 유저 인터렉션을 설명하는 객체

 

앱은 touch, motion, remote-control 과 같은 여러 타입의 이벤트를 받는다. ( 각 이벤트에 대한 설명 ). 이러한 이벤트는 type 와 subtype의 프로퍼티를 통해서 타입을 결정할 수 있다.

 

  • 유저 인터렉션이 type과 subTpye을 통해서 분류되는 것을 확인할 수 있다. 실제로 type에는 touches, moion, remoteControl, presses 등이 있으며, subType에는 motion, remoteControl의 이벤트를 조금 더 세분화 해서 분류한 것을 확인할 수 있다.

터치 이벤트는 이벤트와 관련된 터치들을 갖고 있다. 터치 이벤트는 하나 혹은 이상의 터치를 갖고 있으며, 각 터치는 UITouch Object이다. 터치 이벤트가 발생하면, 시스템은 적절한 repsonder를 찾아서 적절한 method를 실행시킨다.

멀티터치 단계에서는 UIKit이 동일한 UIEvent 객체를 터치데이터를 업데이트 하면서 재사용한다. 해당 객체를 갖고있어서는 안된다. 만약에 해당 객체를 갖고 있어야 한다면 값을 복사해서 갖고있어야 한다.

 

  • UIEvent란 여러개의 터치를 분석하거나, 유저 인터렉션에 대해서 타입을 분류해서 전달되는 것 이라고 할 수 있다.

UIControl

유저 인터렉션을 통해 특별한 액션과 의도를 갖춘 컨트롤을 관리하는 클래스이다.

 

네비게이션을 편하게 한다던지, 유저의 의도를 돕는다던지 하는 기능을 갖추고 있으며 UIButton, UISlider 등을 포함하고 있다. 이때 Control은 target-action mechanism을 이용하여 앱과 상호작용을 한다.

 

  • 정의만 보면 이해가 안 갈수도 있지만, 버튼이나 슬라이더 같은 특징을 생각해보면 이해가 갈 것이다. 예를 들어 슬라이더의 경우 유저가 드래그를 통해서 값을 변경하는 UX를 정리해 놓은 것이라고 할 수 있으며 UI를 통해서 이를 구현한 것이 UIControl이라고 설명할 수 있을 것이다.

UIControl을 직접 생성하지 말고 만약 커스텀 이벤트 컨트롤이 필요한 경우에는 이를 서브클래싱 하는것이 좋다. 확장이 필요하다면, 이미 존재하는 control class 를 상속받도록 하자.

 

control's state 를 통해서 contorl의 외관과 유저 인터렉션을 지원하는 것이 바뀐다. UIControl.State에 따라서 Control은 여러 상태중 하나의 상태를 가진다. 또한 앱의 필요에 따라서 상태를 변경시킬 수 도 있다.

 

The Target-Actio Mechanism

타겟-액션 메커니즘은 앱을 컨트롤하기 위한 코드를 단순화 시켜준다. 터치 이벤트를 쫓아다니면서 코드를 작성하는 대신에, contol-specific events 에 대해서 action method만 정리하면 된다. 

 

  • 여기서 설명하는 control-specific events는 .touchUpInside와 같은 특정한 방식의 인터렉션이다. 아래에도 설명이 있으니 참고하도록 하자.

action method를 control에 추가하기 위해서는 action method 그리고 addTarget(_:action:for:) method를 정의한 객체를 명시해야한다. target object 는 어떤한 객체도 될 수 있다, 하지만 전형적으로 control을 포함하는 viewControlle's 의 root view가 된다. 만약 target Object 를 nil 로 설정한다면, control은 responder chain 을 따라서 action method 를 정의한 객체를 찾아 나선다.

 

  • 보통 ViewController가 target method를 구현하므로 ViewController가 일반적인 targetObject가 될 것이라고 생각했는데, 무슨 이유에서 root view를 일반적인 케이스로 설명했는지 이해가 되지 않네요. 만약 이유를 알고 계신다면 댓글 달아주시면 감사하겠습니다. ( o o ) ( _ _ )
@IBAction func doSomething()
@IBAction func doSomething(sender: UIButton)
@IBAction func doSomething(sender: UIButton, forEvent event: UIEvent)		

action method는 전형적으로 위의 3개의 형식중 하나를 따른다. 이때 sender parameter는 action method를 호출한 contol이고, event parameter는 control-related 를 발생시키는 이벤트가 된다.

 

시스템은 컨트롤이 유저와 특정한 방식으로 인터렉션을 할 때 action method를 실행시킨다. UIControl.Event에 control이 발생시킬 수 있는 특정한 방식의 인터렉션을 정의해놨으며, 이러한 인터렉션은 컨트롤의 특정한 터치이벤트와 연관이 되어 있다. control을 설정할 때, 반드시 어떤 이벤트가 트리거 되어 method를 실행시키는지 명시해야 한다. 예를 들어서 버튼에는 touchDown, touchUpInside 가 사용 될 것이며, 슬라이더에서는 valueChanged 라는 이벤트를 사용할 수 있을 것이다.

 

위에서 할만 control-specific event가 발생했을 때, control은 연관된 action method를 즉시 호출한다. 현재 UIApplication은 만약 필요하다면 responder chain을 따라서라도, 메세지를 다룰 수 있는 적절한 객체를 찾고 이벤트를 전달한다.

 

 

  • UIControl이란 특정한 유저의 인터렉션을 도와주는 UIView이다.
  • 여기서 보면 Target-Action Mechanism 에서도 필요에 따라 responder chain을 따르는 것을 확인할 수 있다.
  • 이 외에도 Interface Builder Attributes, Internationalization, Accessibility, Subclassing 에 대한 설명이 있지만, responder chain을 이해하기 위한 내용을 중점적으로 정리하는 것이니 생략하였다. 자세한 내용을 알고 싶다면 하단의 reference를 통해서 문서를 참고하도록 하자.

[ Article ] Using Responders and the Responder Chain to Handle Events

앱을 통해서 전달되는 이벤트를 어떻게 다루는지 배워보자

Overview

앱은 responder object(이하 리스폰더)를 통해서 이벤트를 받고 처리한다. 리스폰더는 UIResponder class의 어떠한 인스턴스도 될 수 있으며, UIView, UIViewController, UIApplication 등이 그 예시이다. 리스폰더는 raw event data를 받게되면 반드시 이를 처리하거나 다른 리스폰더로 전달을 해야 한다. 이벤트를 받으면 UIKit은 자동으로 가장 적절한 리스폰더를 찾아서 전달하는데 이를 first responder 라고 한다.

 

동적으로 변경되는 app's responder chain을 따라서, 다뤄지지 않은 이벤트는 활성화된 responder chain에 따라서 리스폰더에서 리스폰더로 전달이 된다. 하단 사진을 통해서 label, textfield, button 그리고 두개의 background views로 구성된 앱의 리스폰더들을 확인할 수 있다. 화살표를 따라서 이벤트가 리스폰더에서 next(리스폰더)로 responder chain을 통해서 어떻게 전달되는지 확인할 수 있다.

 

  • 처음에는 동적으로 변경되는 responder chain에 대한 이해가 잘 되지 않았다. 전체적으로 읽고 생각을 해보니 터치로 인해서 first responder의 변경에 따른 responder chain의 변화가 있을 수도 있고, 상황에 따라서 next responder가 변경되는 경우도 아래 altering the responder chain에도 설명이 되어있다. 이러한 특성 때문에 동적으로 변경된다고 설명한 것 같다.

만약 textfield 가 이벤트를 처리하지 않는다면, UIKit 은 이벤트를 textfield 의 부모인 UIView로 전달하고, 이후에 window의 root view에 이르게 된다. root view 부터는 responder chain이 이벤트를 window로 전달하기 전, UIViewController로 전환이 된다. 만약, window 마저 해당 이벤트를 처리하지 못하면 이벤트는 UIApplication으로 전달이 되며, 만약 appDelegate가 UIResponder의 인스턴스라면 여기까지 전달이 될 것이다.

 

  • 결국 이벤트를 처리하기 위해서 responder chain을 살피게 되고 view -> superview -> ... -> root view -> view controller -> window -> uiapplication -> appdelegate 를 기본 흐름으로 event가 전달 된다고 생각하면 된다. 조금 더 자세한 방식은 아래를 살펴보자

Determining an Event's First Responder

UIKit은 이벤트 유형에 따라 이벤트의 first responder를 지정하는데, 이벤트의 타입과 규칙은 아래와 같다.

Event type First responder
Touch events The view in which the touch occurred.
Press events The object that has focus.
Shake-motion events The object that you (or UIKit) designate.
Remote-control events The object that you (or UIKit) designate.
Editing menu messages The object that you (or UIKit) designate.

Note accelerometers, gyroscropes, and magnetometer 같은 모션 이벤트는 responder chain을 따르지 않는다. 대신에 Core Motion가 특정한 객체로 이벤트를 전달하게 된다. 자세한 내용은 Core Motion FrameWork 를 확인하자.

 

컨트롤은 연관된 target object와 action message로 대화를 한다. 유저가 컨트롤과 상호작용을 하게 되면, 컨트롤은 action message를 target object로 전달을 하게 된다. Action message 는 이벤트가 아니지만, responder chain의 이점을 이용할 수 있다. 만약 target object가 nil 이라면, UIKit은 해당 object부터 시작해 적절한 action method를 구현한 객체를 찾을 때 까지 responder chain을 순회하게 된다. 예를 들어, UIKit은 editing menu의 행동이 발생하면 cut(:), copy(:), paste(_:) 와 같은 method를 구현한 객체를 찾기 위해서 responder chain을 순회하게 될 것이다.

 

  • 결국 컨트롤도 UIView의 subclass 로써 responder이며, action message(위에서 설명한 specific-control을 의미하는 것 같음)를 처리하기 위해서 responder chain을 사용하는 것으로 확인할 수 있다.

Gesture recognizer는 터치와 누르는 이벤트를 뷰보다 먼저 받는다. 만약 view's gesture recognizer 가 연속되는 터치 이벤트를 받지 못한다면, UIKit은 이를 뷰로 전달한다. 만약 뷰가 touch를 처리하지 못한다면, UIKit은 responder chain을 따라서 터치 이벤트를 전달한다. 조금 더 자세한 내용을 알고 싶다면 Handling UIKit Gestures 를 살펴보자.

 

Determining Which Responder Contained a Touch Event

UIKit은 touch event 가 어디에서 발생했는지 확인하기 위해서 view-based hit-testing 을 사용한다. 특히 UIKit은 touch location과 view hierarchy 에 있는 뷰 객체의 bounds 를 비교한다. UIView의 hitTest(_:with:) method는 view hierarchy를 순회하며, 특정 터치를 포함하는 가장 깊은 subview를 찾으며, 해당 view는 터치 이벤트의 first responder 가 된다.

 

Note 만약 터치 이벤트가 view's bounds 의 영역을 벗어난다면, hitTest(_:with:)은 해당 view 그리고 해당 view 의 모든 subview들을 무시한다. 그 결과로, view's clipsToBounds가 false 인 경우, view's bounds 의 바깥에 있는 subviews들은 터치를 포함하고 있더라도 리턴이 되지 않는다. hit-testing에 대해서 더 알아보고 싶다면 hitTest( _:with:) 의 discussion을 참고하자

 

  • 위의 설명과 Note를 참고해서 예시를 만들어 보았다. 이를 참고한다면 조금 더 이해가 쉬울 것이다. 아래 또한 clipsToBounds가 false라는 것을 인지하고 있도록 하자. 한번 보고 직접 만들어 테스트 해보는 것을 추천한다.
  • UI를 코드로 작성을 하다보면 layout 이 깨지는 경우가 생긴다. 이때 내부의 어떤 트리거도 동작을 안하는데 hitTest의 동작방식 때문이라는 것을 깨우치게 되었다. 버튼을 누르면 로그가 찍히는 간단한 예시의 레이아웃을 잡아 보았다.
view.addSubview(brokenView)
brokenView.snp.makeConstraints {
	$0.top.equalTo(view.safeAreaLayoutGuide)
	$0.leading.equalToSuperview()
	$0.trailing.equalToSuperview()
}
brokenView.addSubview(testButton)
testButton.snp.makeConstraints {
	$0.top.equalToSuperview().offset(30)
	$0.leading.equalToSuperview().offset(30)
	// $0.bottom.equalToSuperview()
}
  • 이 특성 덕분에 테이블 뷰에 터치를 시작해서 손가락을 테이블뷰 바깥으로 이동을 하더라도, 테이블 뷰는 계속해서 스크롤이 된다.

터치 이벤트가 발생하면, UIKit은 UITouch 객체를 만들어 view와 연결시킨다. 터치의 위치가 바뀌거나 다른 parameter가 변경 된다면, UIKit은 동일한 UITouch 객체를 새로운 정보로 업데이트 한다. 오로지 view만 변경되지 않는다. ( 만약 터치의 영역이 뷰를 벗어나더라도 view는 변경되지 않는다. ) 터치가 끝나면, UIKit은 UITouch 객체를 release 한다.

 

  • $0.bottom.equalToSuperview() 코드에 주석을 하게 되는 순간 레이아웃이 깨지면서 로그가 찍히지 않는다. 이유는 터치의 이벤트가 제대로 전달되지 않기 때문이다. 이벤트가 들어오면 first responder를 찾기 위해 hitTest 를 사용한다. 이때 우리의 의도는 firstResponder가 testButton가 되고 터치라는 이벤트를 전달 하는것이다. 하지만, 레이아웃이 깨지면서 brokenView의 bounds의 height이 0이 된다. 따라서 위에서 설명한 touch의 location이 view's bounds 를 벗어나게 되면서 해당 뷰와 모든 subview를 무시하게 되고, first responder가 nil이 되어 터치라는 이벤트가 testButton까지 전달이 되지 않는다.
  • 그렇다면 생각해보자. 주석은 그대로 남아있는 상황이다.
    • 만약 brokenView에 height을 10으로 할당하면 어떻게 될까? 버튼이 broken view의 bounds를 벗어나기 때문에 로그가 찍히지 않는다.
    • 혹은 200을 할당하면 어떻게 될까? bounds 영역 내부에 버튼이 있기 때문에 로그가 찍힌다.

터치 이벤트가 발생하면, UIKit은 UITouch 객체를 만들어 view와 연결시킨다. 터치의 위치가 바뀌거나 다른 parameter가 변경 된다면, UIKit은 동일한 UITouch 객체를 새로운 정보로 업데이트 한다. 오로지 view만 변경되지 않는다. ( 만약 터치의 영역이 뷰를 벗어나더라도 view는 변경되지 않는다. ) 터치가 끝나면, UIKit은 UITouch 객체를 release 한다.

 

  • 이 특성 덕분에 테이블 뷰에 터치를 시작해서 손가락을 테이블뷰 바깥으로 이동을 하더라도, 테이블 뷰는 계속해서 스크롤이 된다.

Altering the Responder Chain

리스폰더의 next property를 override 해서 responder chain을 변경할 수 있다. 이를 할 때, next responder는 리턴하는 값이 된다.

많은 UIKit classes 들이 next property를 override 해서 특정한 객체를 리턴하고 있다.

 

UIView를 생각해보자. 만약 뷰가 ViewController의 root view 라면, next property는 ViewController가 된다. 그렇지 않으면 ( root view 가 아니라면 ) next responder는 superview가 된다.

 

UIViewController를 생각해보자. 만약 ViewController's view 가 window 의 root view 라면, next responder는 window가 된다. 혹은 만약 ViewController가 다른 ViewController에 의해서 띄워졌다면, next responder는 presenting ViewController가 된다.

 

UIWindow를 생각해보자. next responder는 UIApplication이다.

 

UIApplication을 생각해보자. delegate가 UIResponder의 인스턴스이고, View, ViewController, app 이 아닌 경우에만 next responder가 app delegate가 된다.

 

  • Responder Chain은 Responder로 이루어진 연결 리스트이다. Responder에는 next 라는 property가 존재한다. 이를 통해서 다음 리스폰더를 찾을 수 있다.

Summary

Article을 읽다보니 잘 모르는 개념들을 공부하게 되었고, 결국 유저의 인터렉션을 앱에서 어떻게 처리하는가에 대해서 글을 작성하게 되었다. 간단하게 요약을 하자면 다음과 같다.

  1. 유저의 인터렉션이 발생한다. 이때 UIEvent가 생성된다.
  2. UIKit은 해당 Event를 처리할 가장 적절한 responder 를 찾아야 한다. 따라서 hitTest를 통해서 first responder를 찾는다.
  3. first responder가 해당 이벤트를 처리할 수도 있지만, 이벤트를 처리하지 못하는 경우 responder 의 특성에 따라 responder chain을 통해서 이벤트를 처리할 수 있는 responder를 찾는다.
  4. 결국 이벤트가 처리되거나 무시가 된다.

이번 글을 작성하면서 전체적인 그림을 이해할 수 있어서 굉장히 재밌었다. 특히, hitTest(_:with:)를 이해할 때 레이아웃 이슈가 생각나면서 시원하게 해결이 된 느낌을 받았다. 많은 도움이 되었으면 좋겠다.

 

Reference

UIResponder Document

UIResponder - next

UIEvent Document

UIControl Document

UIControl - UIControl.State

UIControl - UIControl.Event

[ Article ] Using Responders and the Responder Chain to Handle Events

Article - Core Motion FrameWork

Article - Handling UIKit Gestures

Article - hitTest(_:with:)

 

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

Swinject 사용하기 ( 1 / 2 )  (1) 2021.04.21
SOLID Principle  (0) 2021.04.12
Reducing Dynamic Dispatch ( 성능향상 )  (0) 2021.01.24
Encoding 정복하기  (0) 2020.11.23
Decoding 정복하기  (2) 2020.11.14

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에 한정에서 설명한 것이므로 오해하면 안된다는 사실을 말해주고 싶다.

Text Size 구하기

Label 과 TextView TextField 등을 통한 UI를 작업할 때 보통은 오토레이아웃으로 처리를 하지만, 가끔은 텍스트를 통해서 직접 사이즈를 구해 작업을 해야 할 때가 있다. 구글링을 해보면 boundingRect(with:options:attributes:context:)size(withAttributes:) 이 두가지 메서드를 자주 사용하는 것을 확인할 수 있었고, 두 메서드를 제대로 확인하고 비교하고자 해당 포스트를 작성하게 되었다.

# NSString.boundingRect(with:options:attributes:context:)

Calculates and returns the bounding rect for the receiver drawn using the given options and display characteristics, within the specified rectangle in the current graphics context.

먼저 boundingRect에 대해서 알아보자. 해당 메서드의 정의는 위와 같이 되어있다. 그렇다면 해당 메서드의 필요한 파라미터부터 살펴보자.

with size : 텍스트가 그려지려는 박스의 크기

options : String 에 대한 드로잉옵션

attributes : NSAttributedString에 대한 옵션과 동일하다. 단, NSString 의 경우에는 문자열 내의 범위가 아닌 문자열 전체에 적용 된다는 점이 다르다.

context : 수신자에 사용할 드로잉 컨텍스트

 

특정한 사각형 내부에서 옵션 들과 표시 특성들을 활용하여 경계 사각형을 계산하고 반환을 한다. 예를 들어서 한줄이 넘어가는 텍스트가 있다고 하자, 해당 텍스트는 특정한 뷰의 박스 내부에서 여러 개의 줄로 나뉘어 질 것이다. 그렇다면 해당 박스와 옵션들을 토대로 텍스트가 표현될 사이즈를 계산할 수 있는 것이다.

 

이번에는 Discussion을 살펴보자. 여러 줄이 있는 텍스트의 사이즈를 정확하게 알고 싶다면, parameter의 옵션에 usersLineFragmentOrigin을 전달하면 된다. 또한 계산된 값은 소수점으로 떨어진 값이다. 따라서 뷰에 그려지는 값을 알고 싶다면 ceil function을 이용해서 가까운 integer값을 찾아야 한다. 해당 메서드는 string 상형문자의 실제 경계를 나타낸다. 예를 들어 공백과 같은 상형문자는 layout을 겹치도록 할 수 있고, 이러한 경우에는 주어진 size의 width를 넘어갈 수 있음을 기억해야 한다.

# NSString.size(withAttributes:) 

Returns the bounding box size the receiver occupies when drawn with the given attributes.

size의 정의는 위와 같이 되어 있다. 해당 메서드에 필요한 파라미터는 boundingRect 의 attributes 와 동일하므로 설명은 생략하겠다. NSAttributedString에 적용되는 특성 들이며 string 전체에 적용이 된다. 그렇다면 해당 메서드의 Discussion은 어떨까? boudingRect보다 훨씬 더 간단하게 되어있다. 소수점으로 계산된 값이 리턴되고 view에 그려지는 값을 알고 싶다면 ceil을 이용해서 integer를 찾으라는 내용이다.

# boudingRect VS size

둘의 차이를 살펴보면 생각보다 간단하다. 만약 크기를 구하고자 하는 텍스트의 줄 수가 하나라면 size를 사용 하는 것이 간편할 것이고, 여러 줄의 텍스트의 크기를 알고 싶다면 boundingRect를 사용하는 것이 적절할 것이다.

 

하나의 팁을 전수 하자면 만약 width는 일정하고 height이 궁금한 경우가 있다고 하자. 이때 width에 고정된 값을 넣고, height에는 CGFloat.magnitudgreatestFiniteMagnitude(CGFloat가 가질 수 있는 최대 값)을 넣는다면 박스의 사이즈를 고민하지 않고 쉽게 값을 구할 수 있다. 다음과 같이 말이다.

1
2
3
4
5
let newText = "hello world hello world hello world"
NSString(string: newText).boundingRect(with: CGSize(width: 50.0, height: CGFloat.greatestFiniteMagnitude),
                                        options: .usesLineFragmentOrigin,
                                        attributes: [.font: label.font!], 
                                        context: nil)
cs

# One More

만약 중간에 특정한 부분만 폰트 혹은 값이 다르다면 어떻게 해야할까? 예를 들어서, HELLO WORLD 라는 텍스트의 크기를 구하고자 할 때, HELLO 의 font size는 16 이지만 WORLD 의 font size 는 12 라고 하자. 위 메서드들의 파라미터 중에서 attributes에 대한 설명을 살펴보면 String 전체에 적용되는 특징이라고 나와 있으며, 조금 더 살펴보면 NSAttributedString 과는 다르다고 나와있다. 따라서 해당 경우에 특징이 전체로 적용되는 NSString이 아닌 NSAttributedString.boudingRect ( 혹은 size ) 를 사용하는 것이 적절하다.

REFERENCE

[Document] size

[Document] boundingRect

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

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

+ Recent posts