Neoself의 기술 블로그

SwiftUI 심층정리 본문

개발지식 정리/Swift

SwiftUI 심층정리

Neoself 2024. 12. 16. 15:17

이 글은 SwiftUI 관련 WWDC 세션들의 주요 내용을 기초부터 심화까지 포괄적으로 다루고 있습니다. SwiftUI를 처음 접하시거나, 익숙치 않은 분들에게 도움이 되었으면 합니다.

 

0. 목차

더보기

1. SwiftUI 소개

1.1 SwiftUI의 핵심 특징

1.2 저수준 API

1.3 앱 정의에도 사용되는 SwiftUI

2. 선언형 UI의 특성

2.1 예측 가능한 상태 관리

2.2 스레드 안정성

2.3 성능 저하 요소 최소화

2.4 넓은 범위의 적응성(Adaptivity)

3. SwiftUI의 상태 관리

3.1 상태 기반 UI 뷰 업데이트 사이클

3.2 Property Wrapper와 의존성 관리

3.3 Single Source of Truth (SSOT)

3.3.1 데이터 모델과 UI(ViewModel)의 분리

3.3.2 데이터의 생명주기 관리

3.3.3 올바른 속성 래퍼 선택

4. 뷰의 정체성과 생명주기

4.1 SwiftUI의 정체성 관리 방식

4.2 SwiftUI 렌더 로직과 정체성의 관계

4.3 뷰의 정체성과 생명주기

4.4 종속성과 식별자의 안정성

4.5 ViewBuilder와 AnyView

4.6 데이터 기반 뷰 구성 (ForEach)

5. 성능 최적화

5.1 의존성 범위 최소화

5.2 Branch 최적화

5.3 비용이 높은 연산 최적화

5.4 StateObject 생명주기 최적화

5.5 뷰 본문의 순수성 유지

6. UIKit 및 AppKit과의 통합

6.1 상호 운용성의 필요성

6.2 UIKit/AppKit을 SwiftUI에서 사용하기

6.3 SwiftUI를 UIKit/AppKit에서 사용하기

6.4 통합 시 고려사항

 

1. SwiftUI는 뭐죠

1.1 SwiftUI의 핵심 특징

SwiftUI는 Apple이 제공하는 선언형 UI 프레임워크입니다. 어떻게 UI를 구현시킬지에 집중했던 기존 명령형 프레임워크인 UIKit과 달리, SwiftUI는 "무엇"을 만들지 선언하는 방식으로 UI를 구성합니다. 이는 개발자가 UI의 구현 세부사항보다는 원하는 결과에 집중할 수 있게 해줍니다.

  • 선언형 문법: UI를 명령형이 아닌 선언형으로 정의
  • 자동 UI 업데이트: 상태 변화에 따른 자동 UI 갱신
  • 크로스 플랫폼: 모든 Apple 플랫폼에서 일관된 개발 경험
  • 높은 출발점 제공: 유동적 글자 크기 조절, 현지화 관련 설정 등 다양한 내장 기능 갖추고 있음
  • 실시간 프리뷰: Swift Preview를 통한 즉각적인 UI 확인
    (하지만 UIKit 프레임워크를 사용하는 뷰 컨트롤러에서도 이제 SwiftUI를 import하면 사용할 수있게 되었죠...)

1.2 저수준 API

SwiftUI에서는 높은 성능의 명령형 드로잉을 수행하거나 Metal 셰이더를 SwiftUI 뷰에 직접 적용할 수 있는 Canvs와 같이 필요에 따라 저수준 API를 사용해 커스텀 컴포넌트를 구현할 수 있습니다.

Canvas API를 활용한 컴포넌트

1.3 앱 정의에도 사용되는 SwiftUI

@main
struct SwiftApp: App {
    var body: some View {
        WindowGroup {
            ContentView()
        }
    }
}

 

SwiftUI의 기능은 뷰를 넘어서며, 전체 앱 정의 또한 뷰와 동일한 원칙으로 구성됩니다. 따라서 앱은 장면별로 정의된 선언적 구조라고 볼 수 있습니다.

 

2. 선언형 UI의 특성

SwiftUI와 UIKit 간 가장 큰 차이점을 꼽으라 한다면, 자료구조에 있다고 저는 생각합니다.
UIKit는 시간이 지남에 따라 명령형 구문을 받아야하는 객체 인스턴스로서 동작해야하기 때문에 Refernce Type인 Class를 채택합니다.

하지만, SwiftUI는뷰의 시각적 구성이 아닌 UI가 어떤 상태여야하는지에 대한 설명입니다. 이는 시간이 지나도 변하지 않는 내용이므로, 구조체로 구현이 가능합니다. SwiftUI가 값 타입을 채택함으로 인해 생기는 이점은 다음과 같습니다.

 

2.1 예측 가능한 상태 관리

Value type은 변경되지 않고 복사됩니다. 이는 새로운 뷰 복사본으로 대체되면서 렌더가 진행되는, SwiftUI의 뷰 업데이트 사이클과도 밀접한 관련이 있는 특성인데요. 각 뷰들은 모두 독립적으로 기본 속성값들을 보유하고 관리하기 때문에 의도치 않은 공유 상태 문제를 방지할 수 있습니다. 물론 SSoT를 준수하기 위해 뷰 간에 데이터를 공유해야하는 상황이 생기면, 속성 래퍼를 통해 이를 극복할 수 있습니다.

struct UserProfileView: View {
    var user: User  // 이 뷰만의 독립적인 user 복사본
    
    // 다른 뷰에서 이 user를 변경해도 이 뷰의 user는 영향받지 않음
    var body: some View {
        Text(user.name)
    }
}

 

2.2 스레드 안정성

SwiftUI는 뷰가 다른 스레드로 전달될때마다, 구조체에 대한 복사본이 생성되어 전달됩니다. Referece 타입인 Class를 채택하였다면, 여러 스레드에서 동일한 인스턴스를 참조하게 되면서, 데이터 경쟁(data race)가 발생하게 되겠지만, 각 스레드가 자신만의 독립적인 데이터 복사본을 지니게 되므로, 이를 원천 방지할 수 있게 됩니다.

// 값 타입
struct UserData {  
    var name: String
    var score: Int
}

var userData = UserData(name: "Kim", score: 100)

DispatchQueue.global().async { // 스레드 1
    var copy1 = userData  // 복사본 생성
    copy1.score += 10     // 독립적인 복사본을 수정
}

DispatchQueue.global().async { // 스레드 2
    var copy2 = userData  // 다른 복사본 생성
    copy2.name = "Lee"    // 다른 독립적인 복사본을 수정
}


// MARK: - 참조 타입
class UserData { 
    var name: String
    var score: Int
    
    init(name: String, score: Int) {
        self.name = name
        self.score = score
    }
}

let userData = UserData(name: "Kim", score: 100)

// 스레드 1과 2가 같은 인스턴스를 참조
// 동시에 수정하면 데이터 경쟁 발생 가능

 

2.3 성능 저하 요소 최소화

SwiftUI 코드는 시각적 구성이 아닌 설명 자체에 해당됩니다. 때문에 뷰 하나를 여러 컴포넌트로 나누어 구성하여도 앱 성능이 저하되지 않습니다. 

 

2.4 넓은 범위의 적응성(Adaptivity)

SwiftUI에서 제공하는 뷰 컴포넌트는 시각적 구성보다는 기능의 목적을 설명합니다. Button 컴포넌트의 경우, action과 label이라는 핵심 속성만 공유하며, 메뉴, Form과 같은 다양한 맥락에 사용이 가능하며, Toggle 컴포넌트 또한 스위치, 체크박스 등 여러 맥락에 적용될 수 있습니다.

 

3. SwiftUI의 상태 관리

 

3.1 상태 기반 UI 뷰 업데이트 사이클

 

앞서, SwiftUI는 UI가 어떤 상태여야하는지에 대한 설명을 명시한다고 말씀드렸죠. 그러면 UI 구현 로직은 어디에서 구현되는 것일까요?

이는 SwiftUI 프레임워크의 뷰 업데이트 사이클을 보면 알 수 있습니다. SwiftUI 프레임워크는 코드에 명시된 아래 3가지 요소들을 인식하는데, 이 중 의존성의 변화를 감지할 경우, 알아서 UI를 업데이트 해주는 선언적 시스템을 사용합니다.

  • 정체성: SwiftUI가 앱의 여러 업데이트에서 요소들을 동일하거나 구별되는 것으로 인식하는 방법
  • 수명: 뷰와 데이터를 시간에 따라 추적하는 방식
  • 의존성: 인터페이스가 업데이트되어야 할 시점과 그 이유를 이해하는 데 도움을 줌

의존성이 변경되면, SwiftUI 프레임워크는 아래 사이클을 순회합니다.

  1. 상태 변화 감지: Property Wrapper를 통해 상태 변화를 감지
  2. 뷰 무효화: 변경된 상태에 의존하는 뷰들을 무효화
  3. 뷰 재계산: 무효화된 뷰의 body를 재계산
  4. 차이점 계산: 이전 뷰 상태와 새로운 뷰 상태의 차이를 계산
  5. UI 업데이트: 필요한 부분만 효율적으로 업데이트

 

여기서 사이클을 트리거하는 요소를 이벤트 소스(도식에서는 Event)라고 칭하는데요. 앞서 말씀드린 의존성 또한 이벤트 소스 중 하나로 볼수 있습니다. 이 외에도 아래와 같은 예시가 있습니다..

  • 사용자 상호작용: 버튼탭과 같은 직접적인 액션
  • 시스템 이벤트: 타이머, NotificationCenter...
  • 퍼블리셔: onReceive, onChange, onOpenURL, onContinueUserActivity...

 

그러면 우리는 이러한 의존성의 변화를 어떻게 SwiftUI에게 알릴 수 있을까요?

 

먼저 ObservableObject에 대해 이해해야 합니다. ObservableObject는 Combine 프레임워크의 프로토콜로, objectWillChange Publisher를 통해 객체의 변화를 감지할 수 있는 기능을 제공합니다. 이 프로토콜 자체만으로는 UI가 업데이트되지 않지만, SwiftUI의 상태 관리 시스템과 연동되어 데이터 변화를 UI에 반영하는 핵심 역할을 합니다.

 

3.2 Property Wrapper와 의존성 관리

SwiftUI는 다음과 같은 프로퍼티 래퍼들을 통해 의존성을 관리하고 변화를 전파합니다. 이들은 모두 객체의 변화를 발행한다는 공통점이 있으며, 전문용어로 데이터와 UI 간의 의존성을 정의해 데이터를 바탕으로 UI를 일관되게 유지하는 역할을 수행합니다.

 

  • @State: 뷰 내부 UI에 국한된 일시적인 상태 처리에 사용됩니다. 상태 변화가 이벤트 소스가 됩니다.
  • @Binding: 상위 뷰의 상태에 대한 참조를 제공하여 읽기/쓰기를 가능하게 합니다. 원본 상태가 변할 때 UI가 업데이트됩니다.
  • @StateObject: ObservableObject의 생명주기를 뷰와 연결합니다. 객체가 뷰의 생명주기 동안 유지됩니다.
  • @ObservedObject: 외부에서 제공된 ObservableObject를 관찰합니다. 이때 뷰의 종속성, 즉 뷰 업데이트의 소유권을 관리하는책임이 생성됩니다. 관찰 대상의 변화가 UI 업데이트를 트리거합니다.
  • @EnvironmentObject: 뷰 계층 전체에서 공유되는 객체를 관리합니다. 공유 객체의 변화가 의존하는 모든 뷰의 업데이트를 트리거합니다.

 

그리고 이렇게 정의한 의존성을 SwiftUI에서는 Source of Truth라는 개념으로 칭합니다. SwiftUI는 Source of Truth가 변경되면, 변화가 반영된 새로운 뷰가 복사생성되고, 이 복사본이 최종 UI렌더에 사용됩니다.

 

3.3 SSOT(Single Source of Truth)

Apple은 이러한 Source of Truth가 애플리케이션 내에서 단 한 곳에서만 관리되어야 한다고 강조합니다. 이로써, 데이터의 일관성을 보장하고 잠재적인 버그를 예방할 수 있기 때문이죠. Apple에서는 SSoT 원칙 준수를 위해 아래 사항들을 강조합니다.

 

3.3.1 데이터 모델과 UI(ViewModel)의 분리

각 뷰에 필요한 데이터와 조작방식을 확립하기 위해 UI와 별도로 데이터 모델을 설계하고 관리하는 것을 지향합니다. 

Data 자체는 불변성과 예측 가능성이 더 높은 Value type로 유지하되, 부수효과, 생명주기관리, 상태 관리와 같이 실시간 처리가 필요로 하는 로직들은 Reference type인 ViewModel이나 Data store로 설계하여 분리하는 것이죠. 이는 각 Reference 타입 객체가 특정 책임에 집중할 수 있게 분리할 수 있게 되는 점에서 단일책임원칙에도 부합합니다. 그리고 앱 내에서는 데이터 저장&복원 로직 등으로 데이터의 영속성과 동기화를 처리하고, 이로인한 부작용을 관리하는 등 데이터의 생애 주기를 관리하는 로직 또한 수행해야합니다.

// 데이터 모델 (Value Type)
struct User {
    let id: UUID
    var name: String
    var email: String
}

// ViewModel (Reference Type)
class UserProfileViewModel: ObservableObject {
    @Published var displayName: String = ""
    @Published var isEmailVerified: Bool = false
    
    private let user: User // 데이터 모델 참조
    
    init(user: User) {
        self.user = user
    }
    
    func formatDisplayName() {
        // UI를 위한 데이터 가공
        displayName = "👤 " + user.name
    }
}

위 도식들은 UI와 데이터 모델을 구분하고 관리할 수 있는 방안들을 WWDC에서 도식화한 자료입니다.

단일 ObservableObject로 구성해 전체 데이터 모델을 중앙집중화할 경우, 애플리케이션 내 가능한 상태변화를 쉽게 이해할 수 있게 되겠죠. 반면, 여러 개의 ObservableObject로 나눌 경우, 보다 세분화된 데이터 노출이 가능할 것입니다.

 

 

3.3.2 데이터의 생명주기관리

설계 간에 데이터를 누가 소유해야하는지를 고민해야하며, 데이터의 생애주기 관리 및 소스로서의 권한을 올바르게 배분해야합니다.

Apple은 공통 조상을 통해 데이터를 공유하는 것을 지향하며, 크게 데이터는 다음 세 가지 범위에서 관리될 수 있습니다:

 

View 범위: ObservableObject 프로토콜을 뷰의 생명주기와 연결시키는 StateObject 속성 래퍼, 혹은 State를 적용시켜야 합니다.

Scene 범위: SceneStorage 속성 레퍼를 통해 Scene 별로 데이터 저장

*Scenes는 고유한 뷰 트리를 가지므로 데이터의 중요한 조각을 트리의 루트에 연결할 수 있으며, 각 Scene인스턴스는 독립적인 SoT를 가질 수 있습니다.

App 범위: AppStorage를 통해 앱 전체에서 유지

Settings 뷰에서 AppStorage 프로퍼티 래퍼를 추가하고 키를 설정하여 사용할 수 있다

  • Process Lifetime: 앱이 종료되거나 재시작될 경우, 초기화됩니다.
  • Extended Lifetime: 앱 범위의 전역저장소로 확장된 생명주기를 갖고 있으며, 저장 및 복원이 자동으로 이루어집니다.
  • Custom Lifetime: ObservableObject는 서버에 저장하거나, 다른 서비스와 통신하는 등 고수준의 커스텀 동작이 가능토록 하는 도구로 설계되었습니다.

3.3.3 올바른 속성 래퍼 선택

SwiftUI에서 @StateObject는 데이터 일관성 보장, 명확한 소유권 부여, 의존성 명확화를 통해 Single Source of Truth (SSOT) 원칙 준수에 핵심적인 역할을 합니다. SSoT를 준수한 상태에서 상태를 변경하면, 관련된 모든 뷰가 동시에 업데이트되도록 할 수 있겠죠.

 

SwiftUI의 경우, Value Type인 구조체로 뷰를 정의한다고 말씀드렸죠. 뷰를 정의하는 구조체는 뷰 렌더 시, 잠깐 사용된 후 사라지는 반면, 뷰의 생명주기는 이보다 더 깁니다. 즉, 뷰 정의 구조체와 뷰 생명주기는 구분되는 개념이죠.

이러한 특성은 @StateObject의 동작 방식과 밀접하게 연관됩니다.

  • @StateObject
    • ObservableObject를 뷰의 생명주기와 연결
      • body 처음 평가되기 직전에 초기화
      • 뷰 생명주기 동안 동일 인스턴스 유지
      • onDisappear 필요없이 뷰가 필요없어지면 자동으로 할당 해제
    • 뷰 재생성/업데이트와 무관하게 데이터 일관성 보장
  • @ObservedObject
    • 뷰 구조체 생성 시점에 초기화
    • 뷰 재생성 시 새로운 인스턴스 생성 가능
    • 데이터 일관성 보장되지 않을 수 있음
struct ContentView: View {
    // 1. 뷰 구조체 인스턴스가 생성될 때
    @StateObject private var viewModel = ViewModel()  // 아직 초기화되지 않음
    
    // 뷰가 재생성될 때마다 새로운 인스턴스가 생성될 수 있음
    @ObservedObject var observedViewModel = ViewModel()
    init() {
        print("ContentView initiated")
        // 이 시점에서 StateObject는 아직 초기화되지 않은 상태
    }
    
    // 2. body가 계산되기 직전
    var body: some View {
    	VStack{
        // 이 시점에서 StateObject가 초기화되고 유지됨
            Text(viewModel.data)  // 여기서부터 viewModel 사용 가능
            Text(observedViewModel.data)
        }
    }
}

 

이제 StateObject를 통해 데이터를 소유하는 뷰를 명시하였습니다. 하지만, 만약 이 데이터를 여러 뷰에서도 사용해야하 한다면 어떻게 해야할까요? 이러한 상황에서 SSoT를 위한 설계 고민이 필요합니다. 자칫하면 여러 뷰에서 동일한 데이터를 생성하면서, SSoT에 위배될 수 있기 때문이죠.

struct ParentView: View {
    @StateObject private var viewModel = ViewModel()  // 부모가 소유권을 가짐
    
    var body: some View {
        ChildView(viewModel: viewModel)  // 자식에게는 ObservedObject로 전달
    }
}

struct ChildView: View {
    @ObservedObject var viewModel: ViewModel  // 소유하지 않고 관찰만 함
    
    var body: some View {
        Text(viewModel.data)
    }
}
  • ObservedObject: 가장 먼저, 데이터를 소유하지않고 ObservedObject 속성래퍼로 StateObject를 참조할 수 있습니다.
  • Binding: Binding 속성 래퍼를 통해 SSot를 보존하면서 사용자와의 상호작용을 반영할 수도 있죠.
  • EnvironmentObject: 만약, 의존성 그래프의 형태가 복잡해자면, 뷰 계층 전체에 공유할 수 있는 EnvironmentObject 속성 래퍼를 사용해 의존성 그래프를 단순화 할수도 있습니다.

 

이처럼 의존성 그래프의 모양과 데이터가 필요로하는 뷰의 개수 및 위치에 따라 적절한 속성 래퍼를 사용해 데이터의 일관성을 유지하면서, 데이터 흐름을 예측 가능하게 의존성을 명확화시키는 것이 SSoT에 핵심이라고 볼 수 있습니다.

 

4. 뷰의 정체성

4.1 SwiftUI의 정체성 관리 방식

SwiftUI는 Value type을 사용하기 때문에, ReferenceType을 사용했던 UIKit에서 사용한 포인터 정체성을 통해 영속적인 정체성을 유지할 수가 없습니다. 이러한 한계를 극복하기 위해 SwiftUI에서 뷰의 정체성은 두 가지 방식으로 관리됩니다:

  1. 명시적 정체성: id 파라미터를 통한 직접 지정
  2. 구조적 정체성: 뷰 계층 구조를 통한 자동 부여 / 조건문을 사용해 각각의 뷰에 대해 명확한 정체성을 부여 (Branch)
// 명시적 정체성 예시
ForEach(users, id: \.id) { user in
    UserRow(user: user)
}

// Identifiable 프로토콜을 통한 자동 식별
struct User: Identifiable {
    let id: UUID
    var name: String
}

위 코드의 경우 id 파라미터를 통해 명시적 정체성을 사용한 것을 확인할 수 있습니다. 이로 인해 리스트에서는 변경사항을 이해하고 적절한 애니메이션을 생성 및 보간할 수 있죠. 하지만, 모든 뷰가 위와 같이 명시적 정체성을 지닐 필요는 없습니다. 명시적이 아니여도 모든 뷰는 구조적 정체성으로 식별됩니다.

 

4.2 SwiftUI 렌더 로직과 정체성의 관계

SwiftUI는 서로 다른 상태 간의 연결을 통해 뷰의 정체성을 이해하고, 그에 맞춘 전환을 결정합니다. Apple은 아래 화면 전환을 예시로 이러한 SwiftUI의 UI 업데이트 동작방식과 정체성의 관계를 설명하는데요. 동일한 레이아웃을 지닌 코드라 할지라도, 전환 전후 2개 뷰의 정체성이 동일하다면, 발모양의 아이콘의 위치만 Transition되지만, 뷰 정체성이 아예 다를 경우 화면 전체가 변경됩니다.

위 자료는 양쪽 뷰에 동일한 정체성을 부여할 수 있는 2가지 접근방식입니다. 1번째는 구조적 정체성을 바탕으로 동일한 위계에서 조건문으로 동일 컴포넌트 2개를 분기하였고, 2번째는 뷰를 단일로 구성하고 수정자 내부에 상태변경에 따른 설명을 삽입하였는데요. SwiftUI는 두 접근방식 모두 같은 정체성을 가진 뷰의 다른 상태로 인식하고, 상태가 변경될때, 뷰를 새로 생성하지 않고, 기존 뷰의 내용만 업데이트하게 됩니다. 하지만, 1번째 접근방식의 경우, 위계가 달라질 경우 다른 정체성을 지닌 뷰로 인식되기 때문에 Apple은 정체성을 더 명확히 유지시킬 수 있는 2번째 접근방식을 지향합니다.

 

4.3 뷰의 정체성과 생명주기

만약 동일한 이름의 애완동물이 시간이 지남에 따라 (밥을 먹거나 잠을 잠으로써)다른 상태를 보인다 할지라도 우리는 이를 항상 같은 존재로 생각합니다. 여기서 애완동물의 이름이 SwiftUI에서의 정체성과 같은 개념입니다. 시간이 지남에 따라 뷰는 다른 상태에 있을 수 있지만, 정체성이 부여됨으로서 뷰의 연속성을 보장합니다. 즉, SwiftUI는 시간에 따라 속성값 변경으로 인해 뷰의 값이 변화하더라도 동일한 뷰 정의를 유지하며 정체성을 연결합니다.

Apple은 뷰의 정체성의 개념을 위 자료를 통해 설명합니다. 시간이 지남에 따라 PurrDecibelView 내부 속성값이 변경되며 새로운 뷰값이 생성되지만, SwiftUI 관점에서는 동일한 정체성을 지니고 있기 동일한 뷰라고 인식합니다. 따라서 뷰의 생명주기는 해당 뷰에 연결된 정체성의 지속시간을 의미하며, 뷰의 정체성이 변경되거나 뷰가 제거될때 생명주기가 종료됩니다.

이는 앞서 설명드렸던 State와 StateObject 속성 래퍼와도 연관되는 내용입니다. 이 두 속성 래퍼로 감싸진 객체의 생명주기는 뷰의 생명주기, 즉 뷰 정체성의 지속시간과 연관된 지속적인 저장소이기 때문에, 도중에 뷰값이 바뀌어 상태가 변하더라도, 위 저장소를 지속적으로 유지하며 뷰의 본문을 다시 평가할 수 있는 것입니다.

 

즉, 뷰의 값은 뷰의 정체성이랑 구분되는 개념이 됩니다.

앞서 설명했던 "뷰 정의 구조체와 뷰 생명주기가 구분된다"는 내용과 일부분 겹쳐지는데요. 이렇게 뷰 렌더 과정 간에 개념이 구분되는 근본적 원인은 Value type을 채택하고 있기 때문이라고 생각합니다.

 

따라서, 개발자인 저희는 일시적인 생애주기를 지니는 뷰의 값 대신, 뷰의 정체성을 제어하는 것이 필요합니다. 그리고, 뷰 정체성의 지속시간을 늘리기 위해선 안정적인 식별자가 필수적으로 요구됩니다.

 

4.4 종속성과 식별자의 안정성

SwiftUI에서 종속성은 뷰에 대한 입력으로 작용하며, 종속성이 변경될 때마다 뷰는 새로운 body를 생성합니다. 이러한 종속성 시스템의 주요 특징은 다음과 같습니다:

  • 각 뷰는 고유한 종속성을 가질 수 있으며, 여러 뷰가 동일한 상태나 데이터에 의존할 수 있습니다. 이러한 특성을 기존 뷰 트리 구조에 적용하면 그래프 형태가 형성됩니다.
  • 종속성의 그래프 구조를 통해 SwiftUI는 필요한 뷰만 효율적으로 업데이트할 수 있습니다.
  • 의존성이 변경될 경우 그에 의존하는 뷰만이 무효화되며, SwiftUI는 이를 기반으로 각 뷰의 body를 새롭게 생성합니다.

식별자의 안정성은 SwiftUI에서 뷰의 수명에 직접적인 영향을 미칩니다. 불안정한 식별자는 상태 손실이나 성능 저하를 초래할 수 있습니다. 

 

불안정한 식별자의 예시로 위 두 케이스를 들 수 있습니다. 좌측 코드의 경우, Pet 데이터 모델의 id 값을 클로저로 정의하고 있기 때문에, 매번 새로운 값을 반환하며, 우측 코드의 경우, pets 배열 내부의 단순 offset값을 식별자로서 사용하고 있기 때문에, 새로운 펫 객체가 배열의 0번째나 중간에 삽입될 경우, 다른 객체들 또한 위치가 변경됨에 따라 정체성이 변경됩니다.

 

따라서 데이터 베이스 고유의 id값과 같은 안정적인 id값을 삽입해 식별자에 안정성을 부여하는 것을 Apple에서 지향합니다.

4.5 ViewBuilder와 AnyView

SwiftUI에서 View의 body 속성은 ViewBuilder로 내부적으로 래핑되어 있습니다. 여기서 ViewBuilder는 여러 뷰를 조합해 자연스럽게 하나의 뷰로 만들어주는 결과 빌더로, 속성 내부 로직을 단일, generic 뷰 구조로 치환해줍니다.

자세히 설명드리자면, ViewBuilder는 내부적으로 조건문, 반복문 등의 다양한 제어 흐름 내의 여러 뷰들을 감지하고, 이들을 하나의 복합 뷰 타입으로 변환시키는데요. 그 후 이 변환된 단일 뷰 타입을 최종 반환하여 단일한 뷰 타입으로 결합합니다.

따라서, 조건부 구문을 사용할때 AnyView를 제거하고 ViewBuilder 속성을 적용하면 return 문과 AnyView 래퍼 없이도 코드를 작성할 수 있게 됩니다.

*여기서 예시에 사용된 AnyView는 "타입 지우기 래퍼 타입"으로, 래핑된 뷰의 타입 정보를 감추어 SwiftUI가 코드의 조건 구조를 인식하지 못하게 만듭니다. 이는 구조적 정체성을 해치므로 위와 같이 ViewBuilder 도구로 뷰 계층을 표현해주는 것이 좋습니다.

4.6 데이터 기반 뷰 구성 (ForEach)

SwiftUI는 데이터를 기반으로 한 UI 구성을 강조합니다. 이는 UI가 데이터의 상태를 반영하고, 데이터의 변화에 따라 자동으로 업데이트되는 구조를 만들 수 있게 해줍니다. ForEach는 SwiftUI에서 가장 기본적인 데이터 주도 구성요소입니다. 이는 컬렉션의 각 요소를 기반으로 새로운 UI 프로토타입을 쉽게 구현할 수 있도록 합니다. 

초기화 시 식별자 필수: ForEach 초기화 시 컬렉션과 식별자(KeyPath)가 필요하며, 이 식별자는 반드시 Hashable 해야 합니다.

이때 데이터 타입에 안정적인 정체성 제공을 목적으로 하는 Identifiable 프로토콜을 적용하면 식별자를 명시적으로 지정하지 않아도 됩니다.

 

위와 같이 코드를 구현하면, SwiftUI는 아래 도식처럼 각 데이터 요소마다 동일한 뷰 구조체를 생성해 일관된 UI를 구성합니다.

 

 

5. 성능 최적화

따라서, 아래 기법들을 사용해 SwiftUI 앱의 성능을 최적화해볼 수 있습니다.

5.1 의존성 범위 최소화

상태 관리의 범위를 최소화하는 것이 중요합니다. 가능한 한 상태를 가장 낮은 레벨의 뷰에서 관리하면, 상태 변경 시 업데이트되는 뷰의 범위를 최소화할 수 있습니다.

struct OptimizedView: View {
    // 상태를 가능한 가장 낮은 레벨의 뷰에서 관리
    var body: some View {
        VStack {
            IndependentStateView()
            AnotherIndependentView()
        }
    }
}

struct IndependentStateView: View {
    @State private var localState = false  // 이 뷰에서만 필요한 상태
    
    var body: some View {
        Toggle("Toggle", isOn: $localState)
    }
}

 

5.2 Branch 최적화

SwiftUI에서 조건부 분기(Branch)를 사용할 때는 뷰의 정체성과 성능을 고려해야 합니다. Branch를 사용하면 실제로는 두 개의 서로 다른 뷰가 생성되어 정체성이 분리되고, 이는 성능에 영향을 미칠 수 있습니다.

이렇게 Branch 구조를 단일 뷰로 통합하면 다음과 같은 이점이 있습니다:

  • 뷰가 단일 정체성을 지닌다는것을 명시할 수 있게 되며, 뷰의 정체성이 일관되게 유지됩니다
  • 상태 변경의 영향 범위를 수정자 내부로 제한시켰기에, 성능이 최적화됨
  • SwiftUI의 뷰 업데이트 최적화를 더 효과적으로 활용할 수 있습니다

특히, 뷰 수정자에 삽입한 opacity(1.0)은 비활성 수정자 중 하나로, SwiftUI 단에서 뷰 렌더 결과에 영향을 주지 않는 수정자로 판단하고 제거합니다.비활성 수정자는 opacity(1) 외에도, padding(0)과 transformEnvironment(...) {}이 있습니다. 

 

이에 Apple은 이러한 비활성 수정자를 통해 성능을 최적화시키는 것을 WWDC 세션에서 제안하고 있으며,

위와 같이 브랜치 사용 시, 성능최적화를 위해 아래 요소에 대한 고려가 필요하다고 강조합니다.

  1. 브랜치가 정말 필수적인지
  2. 브랜치 내부에 여러 뷰를 표현하고 있는지 아니면 단일 뷰의 두 상태를 나타내고 있는지

 

5.3 비용이 높은 연산 최적화

SwiftUI는 이벤트 기반 시스템을 사용합니다. 즉 이벤트가 발생할때 뷰를 업데이트하기에 비용이 높은 연산은 실제로 필요한 시점에만 수행되도록 최적화해야 합니다. 

  • 이벤트가 없을 때는 뷰가 불필요하게 재계산되지 않습니다.
  • 비싼 계산은 사용자 액션(이벤트)이 있을 때만 실행됩니다.
  • 클로저는 비동기로 실행될 수 있어서 UI 블로킹을 방지할 수 있습니다.
struct ExpensiveView: View {
    @State private var text = ""
    
    // ❌: 매 뷰 렌더링마다 비싼 계산 발생
    var body: some View {
        Text(calculateExpensiveText())
    }
    
    // ✅: 이벤트(버튼 탭)가 발생할 때만 비싼 계산 실행
    var body: some View {
        VStack {
            Text(text)
            Button("Calculate") {
                // 클로저 내에서 비싼 계산을 실행
                text = calculateExpensiveText()
            }
        }
    }
    
    func calculateExpensiveText() -> String {
        Thread.sleep(forTimeInterval: 1)
        return "Result"
    }
}

 

 

5.4 StateObject 생명주기 최적화

ObservableObject의 생성과 관리는 메모리 사용과 성능에 큰 영향을 미칩니다. StateObject를 적절히 사용해 객체의 생명주기를 관리하는 것이 중요합니다.

ReadingListViewer의 body가 생성될때마다, ReadingListStore가 메모리에 할당됨 → 객체의 새로운 복제본을 매번 생성 & 매번 초기화됨에 따라 데이터도 손실됨

// 비최적화: 매 렌더링마다 새로운 객체 생성
struct ReadingListViewer: View {
    let store = ReadingListStore()  // 매번 새로운 인스턴스 생성
    
    var body: some View {
        List(store.books) { book in
            Text(book.title)
        }
    }
}

// 최적화: StateObject로 생명주기 관리
struct ReadingListViewer: View {
    @StateObject private var store = ReadingListStore()
    
    var body: some View {
        List(store.books) { book in
            Text(book.title)
        }
    }
}

 

5.5 뷰 본문의 순수성 유지

SwiftUI의 뷰 본문은 순수 함수여야 합니다. 즉, 동일한 입력에 대해 항상 동일한 출력을 생성하고, 부작용이 없어야 합니다. 이는 SwiftUI가 효율적으로 뷰를 업데이트하고 캐시할 수 있게 해줍니다.

struct PureView: View {
    let title: String
    
    // 순수 함수: 동일 입력에 대해 항상 동일 출력
    var body: some View {
        Text(title.uppercased())
    }
}

struct ImpureView: View {
    let title: String
    
    // 비순수 함수: 외부 상태에 의존
    var body: some View {
        Text(title + " " + Date().description)  // 매번 다른 결과
    }
}
 

 

6. UIKit 및 AppKit과의 통합

UIKit과 AppKit은 Apple의 전통적인 명령형, 객체 지향 UI 프레임워크입니다. SwiftUI는 이러한 기존 프레임워크들과의 원활한 통합을 지원하여, 개발자가 필요에 따라 두 접근 방식을 모두 활용할 수 있게 해줍니다.

6.1 상호 운용성의 필요성

기존의 많은 앱들이 UIKit이나 AppKit으로 구축되어 있기 때문에, SwiftUI는 이러한 레거시 코드와의 통합을 중요하게 고려합니다. 특히:

  • 기존 뷰 컴포넌트의 재사용
  • 복잡한 빌딩 블록의 재활용
  • 점진적인 SwiftUI 도입 가능

6.2 UIKit/AppKit을 SwiftUI에서 사용하기

SwiftUI에서 UIKit이나 AppKit의 뷰를 사용하고자 할 때는 ViewRepresentable 프로토콜을 활용합니다:

  • iOS/tvOS에서는 UIViewRepresentable
  • macOS에서는 NSViewRepresentable

이 프로토콜들을 통해 기존 UIKit/AppKit 뷰를 SwiftUI 뷰처럼 사용할 수 있으며, SwiftUI의 선언적 구문으로 해당 뷰들을 구성하고 관리할 수 있습니다.

 

6.3 SwiftUI를 UIKit/AppKit에서 사용하기

반대로 SwiftUI 뷰를 UIKit이나 AppKit 기반 앱에 통합하고자 할 때는 Hosting Controller를 사용합니다:

  • iOS/tvOS에서는 UIHostingController
  • macOS에서는 NSHostingController
 

6.4 통합 시 고려사항

  • 생명주기 관리: SwiftUI와 UIKit/AppKit의 생명주기 차이를 이해하고 적절히 처리해야 합니다.
  • 데이터 흐름: 두 프레임워크 간의 데이터 바인딩 방식 차이를 고려해야 합니다.
  • 성능: 프레임워크 간 전환 시 발생할 수 있는 오버헤드를 고려해야 합니다.

Reference

WWDC: Data Essentials in SwiftUI

https://www.youtube.com/watch?v=V2yKZHrXRYA&t=2s
WWDC: Demystify SwiftUI
https://www.youtube.com/watch?v=XwdVz0Ef1vU&t=2s

WWDC: SwiftUI Essentials
https://www.youtube.com/watch?v=HyQgpxX__-A&t=440s