본문 바로가기

iOS

[iOS] Swift 참조 타입이 앱 기동 시간에 안 좋은 영향을 끼치는 이유 (번역)

  앱 기동(launch) 경험은 앱 사용자에 대한 첫 인상입니다. 앱이 시작될 때까지 기다리는 시간은 수치상으로는 짧지만 실제로 짧게 느껴지지 않을 수 있는 중요한 시간입니다. 해당 앱을 하루에 여러번 사용되는 경우 사용자는 앱 기동을 계속해서 기다려야 합니다. Apple은 첫 번째 프레임을 400ms 미만으로 그릴 것을 권장합니다. 이렇게 하면 Springboard의 앱 열기 애니메이션이 완료 될 때 앱을 사용할 수 있습니다.

 

  400ms 시간 내에서 개발자는 실수로 앱 시작 시간을 늘리지 않도록 유의해야 합니다. 그러나 앱 기동은 하는 일이 너무 많아서 정확히 어떤 것이 기동 시간을 늘리는지 알기가 어렵습니다. Swift 참조 유형이 바이너리 크기와 느려진 앱 시작 시간에 어떻게 영향을 미치는지 알아보도록 하겠습니다.

 

dyld

  Mach-O 실행 파일이 dyld에 의해 로드되면 앱이 시작합니다. dyld는 앱 사용 준비를 담당하는 Apple의 프로그램입니다. 작성한 코드와 동일한 프로세스에서 실행되며 시스템 프레임워크를 포함하여 모든 의존하고 있는 프레임워크를 로드하며 시작합니다. dyld의 작업 중 하나는 소스 코드의 타입을 나타내는 바이너리 메타데이터의 포인터를 리베이스(rebase)하는 것 입니다. 이 메타데이터는 다이나믹 런타임 기능을 제공하지만 바이너리 사이즈를 키우는 원인이 될 수 있습니다. 다음은 컴파일 된 앱 바이너리 파일에 있는 ObjcClass의 레이아웃 입니다.

 

struct ObjcClass {
    let isa: UInt64
    let superclass: UInt64
    let cache: UInt64
    let mask: UInt32
    let occupied: UInt32
    let taggedData: UInt64
}

 

  각 UInt64는 또 다른 메타 데이터 일부의 주소입니다. 이것은 앱 바이너리에 있으므로, 전 세계의 모든 사람들이 앱 스토어에서 정확히 동일한 데이터를 다운로드합니다. 그러나 앱이 실행될 때마다 ASLR(Address Space Layout Randomization)로 인해 메모리의 다른 위치(항상 0에서 시작하는 것이 아님)에 할당됩니다. 이것은 특정 기능이 메모리에 있는 위치를 예측하기 어렵게 만드는 보안적인 기능입니다.

 

  ASLR의 문제는 앱 바이너리에 하드 코딩된 주소값이 잘못되어, 임의(random)의 시작 위치로 오프셋된다는 것입니다. dyld는 유일한(unique) 시작 위치를 고려하도록 모든 포인터를 리베이스하여 이를 정확하게 맞춥니다. 이 프로세스는 실행 파일의 모든 포인터와 재귀적으로 형성된 의존성을 포함하여 모든 의존하고 있는 프레임워크에 대해 수행됩니다. 바인딩과 같이 시작 시간에 영향을 미치는 다른 종류의 메타데이터 설정이 dyld에 의해 수행되지만 이번에는 리베이스에 초점을 맞추어 얘기할 것 입니다.

 

  이 모든 포인터 설정은 앱 시작 시간을 증가시키므로 이를 줄이면 앱 바이너리 사이즈가 작아지고 시작 시간이 빨라집니다. 이것이 어디에서 부터 비롯됐는지, 정확히 어떤 영향을 미칠 수 있는지 알아보겠습니다.

 

Swift와 Objective-C

  리베이스 시간이 앱의 Objective-C 메타데이터로 인해 발생하는 것을 확인했지만 Swift 앱에서의 원인은 정확히 무엇일까요? Swift에는 Objective-C 코드임을 표시하는 @objc 속성이 있지만, Swift 유형이 Objective-C 코드에 표시되지 않는 경우에도 메타데이터가 생성됩니다. 이는 모든 Swift 클래스 타입이 Apple 플랫폼에서 Objective-C 메타데이터를 포함하기 때문입니다.

 

final class TestClass { }

 

  위 코드는 순수한 Swift 이며, NSObject를 상속하지 않고 @objc 도 사용하고 있지 않습니다. 그러나 이것은 바이너리에 Objective-C 클래스 메타데이터 항목을 생성하고 리베이스가 필요한 9개의 포인터를 추가합니다. 이를 알아내려면 Hopper와 같은 도구로 바이너리를 검사한 후 순수 Swift 클래스의  objc_class 항목을 보면 확인할 수 있습니다.

 

앱 바이너리에 있는 Objective-C 메타데이터

 

  DYLD_PRINT_STATISTICS_DETAILS 환경 변수를 1로 설정하여 앱을 시작하는데 필요한 포인터 리베이스의 정확한 양을 볼 수 있습니다. 이렇게 하면 앱 시작 후 콘솔에 리베이스가 일어난 총 횟수가 출력됩니다. 

 

 

  모든 Swift 타입이 동일한 수의 리베이스를 추가하는 것은 아닙니다. 부모 클래스를 오버라이딩 하거나 objc 프로토콜을 준수하여 메소드를 작성한다면 더 많은 리베이스를 추가하게 됩니다. 또한 Swift 클래스의 모든 속성은 Objective-C 메타데이터에서 ivar를 생성합니다.

 

측정하기

  실제로 리베이스의 시작 시간에 대한 영향은 어떤 디바이스에서 실행중이냐에 따라 다르게 나타납니다. 일반적으로 지원되는 가장 오래된 디바이스 중 하나인 아이폰5S에서 측정한 결과를 공유하도록 하겠습니다.

 

  앱 기동은 간단하게 웜(warm) 스타트와 콜드(cold) 스타트로 분류할 수 있습니다. 웜 스타트는 시스템이 이미 앱을 시작하고 일부 dyld 설정 정보를 캐싱한 경우입니다. 앱 시작 시간 값은 콜드 스타트로 측정하였습니다. 반복하여 실행하면 웜 스타트로 인해 리베이스 시간이 빨라지는 것 또한 발견했습니다.

 

Class Count Rebases Rebase Time (ms)
0 17715 8.71
1000 26726 9.23
10000 107726 43.31
20000 197721 104.23
40000 377724 195.26

 

  이 경우 2000개의 리베이스 작업 당 최대 1ms가 증가하는 것으로 나타났습니다. 일부 작업을 병렬로 수행할 수 있기 때문에 시작 시간이 절대적으로 비례하여 증가하는 것은 아니지만 40000개의 리베이스를 통해 이미 Apple의 권장 사항인 400ms의 절반 수준에 도달한 것을 볼 수 있었습니다.

 

  실제로 인기있는 앱에서 리베이스 작업 수를 측정하면 이러한 작업이 얼마나 일어나고 있는지 알 수 있습니다.

 

$ xcrun dyldinfo -rebase TikTok.app/TikTok | wc -l
2066598

 

  틱톡은 200만개가 넘는 리베이스를 보유하고 있으며, 이로 인해 전체 시작 시간이 약 1초 입니다.

 

해결방법

  각 클래스가 리베이스 작업을 증가 시키지만 모든 Swift 클래스를 구조체로 바꾸는 것은 권장하지 않습니다. 큰 구조체 또한 바이너리 사이즈를 늘릴 수 있으며, 경우에 따라 참조 타입만 필요할 수도 있기 때문입니다. Emerge 툴은 앱에 있는 리베이스의 수, 어떤 모듈에서 비롯됐는지, 해당 모듈의 어떤 타입이 가장 큰 영향을 끼치는지 확인할 수 있습니다. 측정을 먼저 한 후에 앱에서 개선할 부분을 찾아야 합니다. 다음은 몇 가지 일반적인 경우입니다.

 

Composition vs Inheritance

  다음 코드를 살펴보겠습니다.

 

class Section: Decodable {
    let name: String
    let id: Int
}

final class TextRow: Section {
    let title: String
    let subtitle: String

    private enum CodingKeys: CodingKey {
        case title
        case subtitle
    }

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        title = try container.decode(String.self, forKey: .title)
        subtitle = try container.decode(String.self, forKey: .subtitle)
        try super.init(from: decoder)
    }
}

final class ImageRow: Section {
    let imageURL: URL
    let accessibilityLabel: String

    private enum CodingKeys: CodingKey {
        case imageURL
        case accessibilityLabel
    }

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        imageURL = try container.decode(URL.self, forKey: .imageURL)
        accessibilityLabel = try container.decode(String.self, forKey: .accessibilityLabel)
        try super.init(from: decoder)
    }
}

 

  위 코드는 많은 메타데이터를 생성합니다. 그러나 데이터 계층에서 선호되는 값 타입으로 동일한 방식을 구현할 수 있습니다. 그렇게 되면 22% 적은 리베이스를 하게 됩니다. 여기에는 object를 상속하는 것 대신 associated value나 제네릭 타입을 활용한 enum과 같은 값 타입으로 구성하는것이 포함됩니다.

 

struct Section<SectionType: Decodable>: Decodable {
    let name: String
    let id: Int
    let type: SectionType
}

struct TextRow: Decodable {
    let title: String
    let subtitle: String
}

struct ImageRow: Decodable {
    let imageURL: URL
    let accessibilityLabel: String
}

 

Categories in Swift

  Swift는 카테고리가 아닌 extension을 사용하지만, objc 함수를 사용하는 extension을 선언하여 카테고리 바이너리 메타데이터를 여전히 생성할 수 있습니다. 다음 예제를 살펴보도록 하겠습니다.

 

extension TestClass {
    @objc
    func foo() { }
 
    override func bar() { }
}

 

  두 함수 모두 바이너리 메타데이터에 포함되어 있지만 extension 에서 선언되었기 때문에 TestClass의 synthesized category에서 참조됩니다. 바이너리에 포함되는 카테고리 메타데이터의 overhead를 피하기 위해 이러한 @objc 함수를 원래 클래스 선언으로 이동시켜야 합니다. 한 단계 더 나아가 iOS14에 도입된 클로저 기반 콜백을 사용하여 @objc를 피할 수 있습니다.

 

Many properties

  Swift 클래스의 프로퍼티는 클래스가 final인지 여부에 따라 3~6개의 리베이스 작업이 추가됩니다. 실제로 이것들은 20개 이상의 프로퍼티를 가진 큰 클래스에서 더 늘어날 수 있습니다.

 

final class TestClass {
    var property1: Int = 0
    var property2: Int = 0
    ...
    var property20: Int = 0
}

 

  구조체로 변환하면 리베이스 수정 횟수를 60% 정도 감소시킬 수 있습니다!

 

final class TestClass {
    struct Content {
        var property1: Int = 0
        var property2: Int = 0
        ...
        var property20: Int = 0
    }
 
    var content: Content = .init()
}

 

Codegen

  가장 높은 ROI 변화를 줄 수 있는 방법 중 하나는 codegen을 개선하는 것입니다. codegen의 일반적인 용도는 코드베이스간에 공유되는 데이터 모델을 만드는 것입니다. 여러 타입으로 이 작업을 수행하는 경우 추가할 수 있는 Objective-C 메타데이터의 양에 주의해야 합니다. 그러나 값 타입 조차도 코드 크기 및 리베이스 수정들에 overhead가 있습니다. 가장 좋은 해결책은 사용자 정의 타입을 생성된 함수로 대체하는 경우에도 codegen된 타입의 양을 최소화하는 것입니다.

 

  위 예제들은 바이너리 크기가 앱 시작 시간을 증가시키는 몇 가지 방법에 불과합니다. 또 다른 원인은 디스크에서 메모리로 크드를 로드하는데 걸리는 시간입니다. 코드가 많을수록 더 오래 걸립니다. 앱의 크기와 시작 시간을 줄이는 것은 적지 않은 고민과 다양한 시도가 필요할 것입니다.

 

원글

Why Swift Reference Types Are Bad for App Startup Time - Noah Martin

태그