LODY/정리

애플 플랫폼 백과사전 / SwiftUI의 Identity · Lifetime · Dependencies

SwiftUI의 Identity · Lifetime · Dependencies

원문: SwiftUI Under the Hood: Deep Dive into SwiftUI Performance by Omar Radwan (2023.07.20)

관련 시리즈 — Behind the scenes of UI

이 글은 위 2편이 다룬 "SwiftUI가 어떻게 렌더되는가"의 짝이 되는, "SwiftUI가 어떻게 뷰를 식별·추적·갱신하는가" 쪽을 다룬다. Apple의 Demystify SwiftUI 세션(WWDC21 #10022, WWDC23 #10160)의 핵심 개념인 identity · lifetime · dependencies 세 기둥을 예제로 풀어낸다.

SwiftUI Under the Hood 커버


들어가며

오늘의 주제는 SwiftUI다. 뷰나 modifier, 애니메이션을 설명하는 게 아니라 SwiftUI가 내부적으로 어떻게 동작하는지 파고든다.

SwiftUI를 처음 배우는 사람은 물론이고, 이미 SwiftUI 경험이 있지만 좀 더 깊이 이해하고 싶은 사람에게 이 글이 도움이 될 것이다.


프레임워크로서의 SwiftUI

SwiftUI가 선언형(declarative) UI 프레임워크라는 건 모두가 아는 사실이다. 그리고 선언형 프레임워크는 "무엇을 할지" 를 기술하는 것이지 "어떻게 할지" 를 기술하는 게 아니다. 즉, 앱이 어떤 모습·동작이어야 하는지를 고수준으로 기술하면 구체적인 구현 방식은 SwiftUI가 알아서 결정한다. 이것이 SwiftUI가 UIKit 같은 명령형(imperative) 프레임워크보다 훨씬 쉬워 보이는 이유다.

명령형 프로그래밍은 결과를 얻기 위한 단계들을 프로그래머가 직접 지정하는 기법이다.

용어 설명

  • 선언형(declarative): 결과의 모양만 기술하고 구현 디테일은 프레임워크에 위임하는 프로그래밍 스타일
  • 명령형(imperative): 어떤 순서로 상태를 변경해 원하는 결과에 도달할지 명시적으로 적는 스타일. UIKit, AppKit이 대표적
  • modifier: .padding(), .frame()처럼 체이닝으로 뷰에 수정을 가하는 SwiftUI API

SwiftUI가 무대 뒤에서 하는 일

SwiftUI에 코드를 작성하면, 프레임워크는 이를 세 가지 행동으로 번역한다:

  • Identity (정체성)
  • Lifetime (생명주기)
  • Dependencies (의존성)

Identity

Identity는 SwiftUI가 여러 번의 업데이트에 걸쳐 요소들을 같은 것으로 볼지, 다른 것으로 볼지 결정하는 방식이다. 본격적으로 파고들기 전에 아래 그림을 보자.

같은 고양이인가, 다른 고양이인가

이 두 그림이 서로 다른 자세의 한 마리 고양이인지, 아니면 다른 두 마리의 고양이인지 물으면, 당장은 답할 수 없을 것이다. 즉, 뷰는 상태와 속성(색상·위치·프레임 등)이 바뀌면서 전이(transition)될 수도 있고, 아예 다른 뷰일 수도 있다는 뜻이다.

코드에서는 identity가 어떻게 표현되는지, SwiftUI가 사용하는 두 가지 identity 종류를 살펴보자.

Explicit Identity (명시적 정체성)

커스텀이나 data-driven 식별자를 이용해 직접 이름을 붙이는 방식이다. 이름/식별자를 부여하는 것 자체가 explicit identity의 한 형태다. 이 방식은 모든 고유 이름을 추적해야 하는 부담이 따른다. 어느 고양이가 어느 고양이인지 이름으로 알아야 동일성 여부를 판별할 수 있다 (SwiftUI가 판별해야 하지, 내가 직접 할 일은 아니다).

UIView/NSView의 포인터 identity

UIKit과 AppKit은 포인터 identity를 사용한다. 이것도 explicit identity의 한 형태지만, SwiftUI는 이 방식을 쓰지 않는다. SwiftUI는 struct 기반 value type을 쓰고, UIKit·AppKit은 class 기반 reference type을 쓰기 때문이다.

모든 UIViewNSView는 자신의 메모리 할당에 대한 고유 포인터를 갖는다. 개별 뷰를 포인터만으로 지칭할 수 있고, 두 뷰의 포인터가 같으면 같은 뷰임을 보장할 수 있다.

SwiftUI가 class 대신 value type을 쓰는 이유는 이 링크에서 확인할 수 있다.

이제 SwiftUI가 이런 특별한 식별자로 성능을 개선하고 올바른 전이(transition)를 생성하는 예시를 살펴보자.

dogTagID로 뷰 전이 추적

구조 구역(rescue dogs)의 강아지 하나가 입양 구역(adopted dogs)으로 이동하면, SwiftUI는 dogTagID를 통해 변화를 이해하고 올바른 방식으로 부드러운 애니메이션을 수행한다.

기억할 것: SwiftUI의 모든 뷰는 identity를 가진다. explicit이든 structural이든.

Structural Identity (구조적 정체성)

뷰를 타입과 뷰 계층에서의 위치로 구분하는 방식이다. Structural identity는 식별자 없이도 SwiftUI가 뷰 계층을 이해하게 해 준다. SwiftUI는 뷰 계층의 타입 구조를 들여다보는 방식으로 이를 수행한다.

아래 예시로 structural identity의 내부 동작을 살펴보자:

struct ExampleView: View {
    let isLogged: Bool
    var body: some View {
        if isLogged {
            FeedView()
        } else {
            LoginView()
        }
    }
}
 
print(Mirror(reflecting: ExampleView(isLogged: false).body))
/* 다음으로 번역됨:
    _ConditionalContent<
        FeedView,
        LoginView
    >
*/

SwiftUI는 뷰 계층을 트리 형태로 취급하고, if/else분기(branch)가 된다. SwiftUI는 이 if/else_ConditionalContent라는 뷰로 번역하는데, 이 뷰는 @ViewBuilder property wrapper에 의해 true 콘텐츠·false 콘텐츠를 제네릭으로 받는다.

View 프로토콜은 자신의 body property를 암묵적으로 ViewBuilder로 감싸고, 이 ViewBuilder가 property 안의 로직 구문들을 하나의 제네릭 뷰로 조립한다. 이 변환은 Swift의 result builder의 한 형태다.

이제 SwiftUI는 런타임에 true 분기가 항상 FeedView이고 false 분기가 항상 LoginView임을 안다. 상태가 바뀌면 SwiftUI는 이전 뷰의 상태를 파괴(destroy)하고 메모리에서 해제한다.

기억할 것: 상태가 바뀌면 SwiftUI는 이전 상태와의 비교를 거쳐 달라진 부분만 처리한다. "뭐가 바뀌었는지 보고 그 부분만 바꾼다"는 감각이 중요하다.

분기 알고리즘을 좀 더 이해하기 위해 다른 예를 보자:

struct ExampleView: View {
    let isEnabled: Bool
    var body: some View {
        if isEnabled {
            FeedView()
        } else {
            FeedView()
                .disabled(true)
        }
    }
}

이 경우에도 if/else로 분기가 있고, isEnabled가 토글되면 SwiftUI는 뷰를 파괴하고 다시 만든다. 결과적으로 FeedView 인스턴스의 상태를 잃는다. 분기 알고리즘 자체는 강력하지만 서로 다른 뷰 간에 유용하고, 같은 뷰일 때는 Apple이 권장하는 더 효율적인 방법이 있다:

struct ExampleView: View {
    let isEnabled: Bool
    var body: some View {
        FeedView()
            .disabled(isEnabled ? true : false)
    }
}

이쪽이 성능상 훨씬 낫고 코드도 짧다. 조건을 view modifier 안에 인라인함으로써 상태와 structural identity를 그대로 유지할 수 있고 파괴·재생성이 발생하지 않는다.

SwiftUI가 뷰를 어떻게 식별하는지 이해했으니, 이제 identity가 어떻게 뷰·데이터의 lifetime과 연결되는지 살펴보자.

용어 설명

  • _ConditionalContent: SwiftUI가 if/else를 표현하기 위해 내부적으로 쓰는 제네릭 뷰 타입
  • @ViewBuilder: 여러 뷰 구문을 하나의 뷰로 합성하는 result builder property wrapper
  • view modifier: .disabled(), .padding() 처럼 뷰에 설정을 덧붙이는 함수. 체이닝 가능

Lifetime

먼저 질문: 뷰나 데이터의 lifetime이란 무엇인가?

Lifetime은 SwiftUI가 뷰와 데이터의 존재를 시간 축 위에서 추적하는 방식이다. 이 개념을 이해하면 SwiftUI의 동작 원리가 훨씬 명확해진다. 예시를 보자.

시간에 따라 상태가 바뀌는 뷰

뷰(emoji)가 시간이 흐르며 서로 다른 값들을 거쳐 자기 lifetime을 따라 이동하는 모습을 볼 수 있다. Identity는 서로 다른 값에 대해 하나의 안정된 요소를 정의해 준다. 즉, 매 상태는 그 뷰가 갖는 서로 다른 값일 뿐이다.

PurrDecibelView의 상태 비교

예컨대 PurrDecibelView라는 단일 뷰가 있고 이 뷰의 initializer가 시간에 따라 변하는 intensity를 받는다고 하자. 값이 바뀔 때마다 SwiftUI는 이전 상태를 파괴한다. 이때 SwiftUI는 비교용으로 값의 복사본을 유지해서 뷰가 정말 바뀌었는지를 판단한다.

뷰의 identity가 onAppear()부터 disappear까지 이어지다가 뷰가 제거되면, 그 lifetime도 끝난다.

기억할 것: 뷰의 lifetime은 곧 메모리 안에서의 identity 지속 기간이다.

모델 타입에 안정적인 identity 개념을 부여하려면 Identifiable 프로토콜을 준수시켜서 lifetime 추적을 효율화해야 한다.

struct Emoji: Identifiable {
    var id: UUID()
    var shape: String
}

중요: identity는 lifetime 동안 안정적이고 바뀌지 않아야 한다.

용어 설명

  • lifetime: 뷰/데이터가 만들어져 파괴되기까지 메모리상에 존재하는 기간
  • Identifiable 프로토콜: id 프로퍼티로 인스턴스를 고유 식별 가능하게 해 주는 Swift 표준 프로토콜
  • state comparison: SwiftUI가 새 값과 이전 값을 비교해서 달라진 부분만 다시 렌더링하는 메커니즘

Dependencies

SwiftUI는 인터페이스가 언제 업데이트되어야 하는지를 "무엇에 의존하는가"로 파악한다. dependency는 단순히 뷰에 주입된 입력이다. dependency가 바뀌면 뷰는 새 body(계층)를 생성하도록 요구받는다.

struct CatView: View {
    @Binding var cat: Cat   // -> Dependency 1
    var food: Food          // -> Dependency 2
 
    var body: some View {   // -> Body
        Button {
            cat.eat(food)   // -> Action
        } label: {
            Text("Eat")
        }
    }
}

표면적으로 보면 dependency 1, 2는 뷰에 주어진 입력일 뿐이다. 하지만 dependency가 바뀌면 뷰는 새로운 body(뷰 계층)를 만들어야 한다. body 안에 Button이 있고, action이 그 안에 있다. action은 뷰 dependency를 변화시키는 트리거다.

[Cat과 Food] 주입 → [CatView] 생성, 안에 [Button] 존재 → 제스처가 발생하면 [Action] → 고양이가 먹은 후 상태 변경 → 고양이가 더 먹고 싶으면 다시 [Cat과 Food] 주입 → [CatView] → [Button] → [Action] ... 같은 사이클이 반복된다.

dependency가 바뀌면 body는 재계산되어 새 body가 생성된다.

의존성 트리

SwiftUI에서 각 뷰는 자신만의 dependency 집합을 가질 수 있다. 여기까지는 여전히 트리처럼 보인다.

하지만 여러 뷰가 동일한 상태나 데이터에 의존하는 경우가 있다. 예를 들어 하위 뷰 중 하나가 cat에 또 의존할 수 있고, 이런 상황은 다른 dependency에서도 생길 수 있다. 그러면 시작은 트리였지만, 구조는 더 이상 트리로 보이지 않게 된다.

사실, 선이 겹치지 않게 재배치하면 아래 형태가 된다. 이는 트리가 아니라 그래프라는 걸 드러낸다.

의존성 그래프

이 구조를 "Dependency graph" 라고 부른다.

이 구조가 중요한 이유는, 새 body가 필요한 뷰만 효율적으로 업데이트할 수 있게 해 주기 때문이다.

그래프의 핵심은 dependency가 바뀌면 그 dependency에 연결된 뷰만 무효화(invalidate) 된다는 것이다. SwiftUI는 각 뷰의 body를 호출해 새 body 값을 만든다. 그리고 무효화된 각 뷰의 body 값을 인스턴스화한다.

이 과정에서 또 다른 dependency가 연쇄적으로 바뀔 수도 있지만, 항상 그렇지는 않다. 뷰가 value type이기 때문에 SwiftUI는 뷰를 효율적으로 비교해서 실제로 업데이트가 필요한 부분만 갱신할 수 있다.

뷰의 "값(value)"은 짧은 수명을 가진다. struct 값은 비교용일 뿐이고, 뷰 자체는 더 긴 lifetime을 가진다.

이 덕분에, 중간에 있는 뷰는 새 body를 만들지 않고 건너뛸 수 있다.

Identity는 dependency graph의 척추다.

용어 설명

  • dependency graph: 뷰와 상태 간의 의존 관계를 방향 그래프로 표현한 SwiftUI의 내부 구조. 트리가 아니라 DAG에 가까움
  • invalidate: 해당 뷰/데이터가 더 이상 유효하지 않아 재계산이 필요하다고 표시하는 동작
  • value type의 효율 비교: struct는 복사 비용이 낮고 동등성 비교가 예측 가능해서, diff 기반 최적화에 유리

정리

SwiftUI는 UIKit보다 많은 면에서 단순하고 강력하지만, 최대 성능을 뽑아내려면 내가 지금 무엇을 쓰고 있는지를 정확히 이해해야 한다. 이 글에서 다룬 세 개념 — Identity · Lifetime · Dependencies — 을 확실히 잡아두면 Apple이 말하는 "Better apps with less code" 원칙에 한층 가까워진다.

세 줄 요약

  • Identity — 여러 번의 업데이트에 걸쳐 뷰를 같은 것/다른 것으로 식별하는 방식. explicit(Identifiable, id())과 structural(_ConditionalContent 같은 타입 구조)로 나뉨
  • Lifetime — identity가 메모리 안에 유지되는 기간. 분기 알고리즘은 lifetime을 끊어버리므로, 같은 뷰에서는 분기보다 inline condition을 선호
  • Dependencies — 뷰를 업데이트할지 말지를 결정하는 입력. 여러 뷰가 같은 상태에 의존하면 트리가 아니라 그래프(dependency graph) 가 된다. Identity가 이 그래프의 척추다

참고 자료


이 글이 다룬 "SwiftUI가 뷰를 어떻게 식별하고 업데이트하는가"와 짝이 되는, "그 업데이트가 실제 화면에 도달할 때 UIKit·Core Animation 위에서 어떻게 돌아가는가"Behind the scenes of UI 시리즈에서 확인할 수 있다.