LODY/정리

애플 플랫폼 백과사전 / SwiftUI의 내부 동작 원리

SwiftUI의 내부 동작 원리

원문: Behind the scenes of UI: Part 2 - SwiftUI by Vitaly Batrakov (2024.03.26)

SwiftUI 커버


들어가며

UIKit은 훌륭하지만 SwiftUI가 미래라는 말을 자주 듣습니다. 하지만 UIKit을 너무 빨리 묻어버리지 마세요. 지금부터 보게 될 것처럼, SwiftUI가 내부적으로 어떻게 동작하는지 이해하려면 UIKit에 대한 이해가 여전히 매우 유용합니다.

영상으로 보는 것이 편하다면 여기에서 확인할 수 있습니다.


SwiftUI 개요

SwiftUI는 선언형(declarative)이면서 데이터 기반(data-driven)의 UI 프레임워크입니다.

  • 선언형(Declarative): 뷰의 body를 통해 원하는 UI와 동작을 기술하면, SwiftUI가 세부 구현을 처리합니다
  • 데이터 기반(Data-driven 또는 state-driven): UI가 하위 데이터/상태와 결합되어 있으며, 상태가 바뀌면 UI에 자동으로 반영됩니다
struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
    }
}

body의 구조는 뷰의 레이아웃(layout)콘텐츠(content) 를 동시에 정의합니다.

주의 사항: 앞으로 논의할 내용은 공식 문서화된 내용이 아니며, 실제 SwiftUI 코드/구현에 대한 접근 권한도 없습니다. 관찰과 추론에 기반한 설명입니다.

SwiftUI body와 view의 관계

용어 설명

  • 선언형 UI: "어떻게" 그릴지가 아니라 "무엇을" 보여줄지를 기술하는 방식. React, Flutter, SwiftUI가 대표적
  • 데이터 기반: 상태(state)가 바뀌면 UI가 자동으로 업데이트되는 방식. 명령형으로 UI를 직접 조작하지 않음
  • body: SwiftUI 뷰의 내용과 레이아웃을 기술하는 computed property
  • some View: Swift의 opaque return type. 구체적인 View 타입을 숨기면서도 타입 안전성을 유지

View 트리와 Render 트리

UIKit과 마찬가지로 SwiftUI에도 view tree가 있습니다. 그러나 UIView가 객체(class) 인 UIKit과 달리, SwiftUI의 뷰는 struct입니다. 이로 인해 view tree는 일시적(ephemeral) 입니다.

  • SwiftUI의 뷰 값은 일시적이며 상태가 변경될 때마다 다시 생성됩니다
  • view tree를 직접 조작할 수 없습니다
  • 뷰를 직접 변경하는 대신 상태를 수정하면, SwiftUI가 새로운 view graph를 계산합니다
  • 상태가 변경될 때마다 뷰가 재생성되고 body가 재평가됩니다
  • SwiftUI는 변경되지 않은 subview의 body는 다시 실행하지 않도록 최적화합니다
  • 이를 위해 Attribute Graph라는 내부 컴포넌트를 사용합니다. Attribute Graph는 데이터와 관련 view tree 간의 의존성 그래프를 구축합니다
  • 일시적인 view tree와 달리, Attribute Graph는 영속적(persistent) 입니다. body가 재실행된 이후에도 유지됩니다
  • view tree가 재생성된 이후에도 유지되어야 하는 상태를 보관합니다
  • 매 새로운 view tree 세대마다, SwiftUI는 현재 render tree 상태와 diff를 수행하여 노드를 추가/제거하면서 필요한 변경만 효율적으로 적용합니다

Chris Eidhof는 이 개념의 이해를 단순화하기 위해 Attribute GraphRender Tree표현합니다.

용어 설명

  • view tree: SwiftUI에서 화면 구성의 논리적 구조를 표현하는 트리. struct 기반이라 재생성이 저렴
  • render tree (Attribute Graph): SwiftUI 내부에서 유지되는 영속적 트리. 상태 변화에 따른 diff의 기준점이 됨
  • ephemeral: 휘발성. 필요할 때 생성되었다가 금방 사라지는 것
  • persistent: 영속적. 여러 render loop 이터레이션에 걸쳐 유지되는 것
  • diff: 이전 상태와 새 상태를 비교해 실제로 바뀐 부분만 추려내는 작업

View Render Loop

SwiftUI의 render loop는 세 가지 단계(phase)로 나뉩니다:

  1. Evaluation Phase (평가 단계)
  2. Layout Phase (레이아웃 단계)
  3. Rendering Phase (렌더링 단계)

Evaluation 단계

다시 한번 강조하면, view tree는 일시적이지만 render tree는 영속적입니다.

평가 단계의 동작 순서는 다음과 같습니다:

  1. Render tree의 root 노드가 먼저 생성됩니다 (보통 ContentView에 해당하는 노드)
  2. 다음으로 view tree가 구축됩니다
  3. ContentViewbody가 평가되어 프로세스가 시작됩니다
  4. superview에서 subview 방향으로 한 단계씩 view tree를 준비합니다
  5. some View 뒤에 숨겨진 복잡한 제네릭 구조로 조합됩니다
  6. 이렇게 준비된 view tree를 사용하여 render tree를 구축합니다
  7. Render tree 구축이 완료되면 전체 view tree는 폐기됩니다

render tree 노드의 상태가 변경되면, 영향을 받는 render sub-tree가 무효화(invalidated)됩니다.

그 다음 SwiftUI는 해당 view subtree를 다시 구축하고, 필요한 경우 body를 재평가하여 render tree 구조를 업데이트합니다. 이 단계의 결과로 SwiftUI는 render tree를 재구축한 상태로 이후 단계를 진행할 준비가 됩니다.

용어 설명

  • evaluation: SwiftUI가 view 선언을 실제 타입 구조로 "평가"하여 render tree로 변환하는 작업
  • invalidated: 해당 영역이 더 이상 유효하지 않다고 표시된 상태. 다음 render loop에서 재구축 대상
  • 제네릭 구조: VStack<TupleView<(Text, Image, Button)>> 처럼 타입 수준에서 중첩되는 SwiftUI의 뷰 구성

Layout 단계

Attribute Graph가 (재)구축되고 준비되면, SwiftUI는 부모 뷰 혹은 컨테이너 내에서 각 뷰의 위치, 크기, 배치를 결정하는 layout 계산을 수행합니다.

더 깊이 파고들고 싶다면 다음 글들을 추천합니다:

Layout은 top-down 방식으로 진행됩니다. 부모 뷰가 자식 뷰에게 사용 가능한 공간을 제안(propose)하고, 자식은 그 제안을 바탕으로 자신의 크기를 결정합니다.

SwiftUI의 layout 과정은 세 단계로 이루어집니다:

1. 부모가 자식에게 크기를 제안

루트 뷰가 먼저 Text에 제안 크기를 제공합니다. 예를 들어, 주황색 사각형으로 표시되는 화면의 safe area 전체를 제안할 수 있습니다.

2. 자식이 자신의 크기를 결정

Text는 자신의 콘텐츠를 그리기 위해 필요한 만큼의 크기만 요청합니다. 부모는 자식의 선택을 반드시 존중해야 합니다. 자식을 늘리거나 압축하지 않습니다.

3. 부모가 자식을 부모의 좌표 공간에 배치

이제 루트 뷰는 자식을 어딘가에 배치해야 하므로, 가운데에 놓습니다.

SwiftUI의 layout은 쉬워 보이다가 갑자기 어려워집니다.

단계 자체는 간단해 보이지만, SwiftUI에서 layout이 복잡해지는(특히 UIKit을 오래 다뤄온 개발자에게) 이유는 각 뷰(또는 view modifier)가 제안 크기로부터 실제 크기를 결정하는 방식이 서로 다르기 때문입니다. 예를 들어:

  • Shape는 제안 크기에 항상 자신을 맞춰 채웁니다
  • 수평 HStack은 자식에게 필요한 크기만큼(제안 크기를 넘지 않는 선에서) 자신의 크기를 결정합니다
  • Text는 텍스트를 렌더링하는 데 필요한 크기를 가집니다. 제안 크기를 초과하면 텍스트가 잘립니다

용어 설명

  • Proposed size: 부모가 자식에게 제안하는 사용 가능한 크기
  • view modifier: .padding(), .frame() 처럼 뷰에 수정을 가하는 함수 체이닝 API
  • safe area: 노치, 홈 인디케이터 등 시스템 UI에 가려지지 않는 안전한 화면 영역

Rendering 단계

레이아웃이 완료되면 뷰는 화면에 렌더링됩니다. SwiftUI가 정확히 어떤 방식으로 렌더링을 수행하는지는 아직 불분명합니다.

하지만 내부적으로 UIKit, Core Animation, Core Graphics, Metal을 활용한다는 것은 합리적으로 추정할 수 있습니다.

이 점을 고려하면, SwiftUI의 view/render tree에서 UIKit view tree로 변환되는 일종의 매핑이 반드시 존재해야 합니다.

이런 맥락에서 Rendering phase는 오히려 Mapping to UIKit phase(UIKit으로의 매핑 단계) 로 부르는 것이 더 적절할 수도 있습니다.


Render loop 뒤편 살펴보기

앞에서는 이론적인 내용을 많이 다뤘습니다. 이제 디버거로 실제 세부 사항을 들여다보겠습니다.

뷰의 body에 breakpoint를 설정해서 관찰해보면 다음과 같은 단서들을 얻을 수 있습니다.

Render loop 디버깅 흐름

  • AG::Graph는 Attribute Graph를 나타냅니다
  • __lldb_unnamed_symbol123456은 숨겨진 SwiftUI 내부 코드를 의미합니다

breakpoint call stack

call stack의 하단을 살펴보면 다음과 같은 흐름을 발견할 수 있습니다:

확장된 call stack

  • UIApplicationMain
  • → __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__
  • → CATransaction.commit()
  • → UIView(CALayerDelegate).layoutSublayersOfLayers

스택 트레이스 하이라이트

꽤 친숙하지 않나요?

이전 글에서 다룬 UIKit의 render loop와 동일합니다.

주목할 점은 이 흐름이 UIView를 단 하나도 직접 추가하지 않은 순수 SwiftUI 애플리케이션에서 나타난다는 것입니다.

이는 SwiftUI가 내부적으로 UIKit과 Core Animation을 포함하며, 앞서 논의한 평범한 UIKit의 render loop를 그대로 상속하고 있음을 실증적으로 보여줍니다.

매 runloop iteration마다, implicit CATransaction의 layout 단계의 일부로 CALayer/UIView 계층 순회가 일어날 때, SwiftUI는 SwiftUI 뷰 계층을 호스팅하는 UIView(정확히는 _UIHostingView)의 layoutSublayers(of:) 호출에서 시작하여 자신의 render loop를 수행합니다.

용어 설명

  • AG::Graph: SwiftUI의 Attribute Graph 내부 구현 심볼
  • _UIHostingView: SwiftUI 뷰 계층을 담는 UIKit UIView. SwiftUI와 UIKit 세계를 잇는 다리 역할
  • layoutSublayers(of:): CALayerDelegate 메서드. 레이아웃 단계에서 각 UIView 경유로 호출됨
  • implicit CATransaction: layer tree를 변경할 때 Core Animation이 자동으로 시작하는 트랜잭션

CATransaction과의 관계

UIKit과 마찬가지로, SwiftUI에서도 implicit CATransaction이 commit될 때 뷰가 화면에 렌더링됩니다.

간단한 예제를 통해 SwiftUI와 CATransaction의 관계를 관찰할 수 있습니다.

주의: 아래는 데모 목적일 뿐, 실제 SwiftUI 코드베이스에서 CATransaction을 직접 사용하는 것은 강력히 비권장합니다. 재미 삼아 보는 예시입니다.

CATransaction 관계 다이어그램

예제 1: Transaction 스택 깨뜨리기

struct SwiftUIView: View {
 
    @State var color: UIColor = .red
 
    var body: some View {
        Circle()
            .fill(Color(color))
            .onTapGesture {
                color = .yellow
                CATransaction.begin()
                color = .green
            }
    }
}

CATransaction.begin()을 호출하면 새로운 transaction 객체가 생성되어 공유 transaction 스택에 push됩니다.

그런데 이후 CATransaction.commit()을 호출하지 않으면, transaction이 동작하는 방식 때문에 transaction 스택이 깨져버립니다.

그 이후부터는 앱에서 render server로 어떤 layer tree 변경도 commit되지 않습니다.

따라서 위 예제에서는 탭 이후에 노란색이든 초록색이든 어떤 색 업데이트도 화면에서 볼 수 없습니다.

이 사실은 SwiftUI와 CATransaction 사이의 관계를 명확히 보여줍니다. SwiftUI는 UIKit과 동일한 방식으로 transaction에 의존합니다.

예제 2: Explicit transaction과 sleep

struct SwiftUIView: View {
 
    @State var color: UIColor = .red
 
    var body: some View {
        Circle()
            .fill(Color(color))
            .onTapGesture {
                CATransaction.begin()
                color = .yellow
                CATransaction.commit()
                sleep(3) // main thread를 3초 동안 블록
                color = .green
            }
    }
}

비교를 위해 UIKit 버전도 다시 보겠습니다:

@objc func buttonTapped() {
    CATransaction.begin()
    button.backgroundColor = .yellow
    CATransaction.commit()
    sleep(3) // main thread를 3초 동안 블록
    button.backgroundColor = .green
}

UIKit 케이스 결과

UIKit 예제에서는 main thread가 sleep()으로 블록되기 직전에 explicit transaction이 commit되어 중간에 노란색이 잠깐 보입니다.

하지만 SwiftUI에서는 동일한 방식으로 동작하지 않는 것처럼 보입니다. 왜 그럴까요?

차이점 이해하기

CATransaction이 어떻게 동작했는지 복기해봅시다. Layer tree의 새로운 변경 사항을 render server에 commit하는 것이 본질입니다.

하지만 SwiftUI 예제에서 우리는 explicit transaction 안쪽(begin과 commit 사이)에서 어떤 CALayer도 변경하지 않았습니다.

CATransaction.begin()
color = .yellow // color라는 state 프로퍼티만 바꿨을 뿐, CALayer 변경 없음
CATransaction.commit()

우리가 바꾼 것은 SwiftUIView의 상태뿐입니다. 이 변경이 실제로 하위 layer에 반영되려면 SwiftUI가 한 번 더 render loop를 돌면서 view tree와 render tree를 다시 구축해야 합니다.

이것이 body 평가나 onAppear 호출 이후의 변경이 즉시 렌더링되지 않는 이유이기도 합니다. body 평가 / onAppear / onChange 시점에는 아직 하위 CALayer 프로퍼티가 바뀌지 않은 상태이므로, 그 순간 commit할 것 자체가 없는 것입니다.

전체 SwiftUI render loop가 돌아야 비로소 하위 layer가 변경되고, 그 변경이 implicit transaction과 함께 commit되어 화면에 렌더링됩니다.

확장 예제: 레이어 직접 조작

이 예제를 조금 더 확장해봅시다. 이미 알다시피 SwiftUI 계층의 루트에는 _UIHostingView가 자리하고 있습니다.

이 객체는 UIKit UIView이므로 내부에 CALayer를 가지고 있습니다.

SwiftUI 코드에서 _UIHostingView에 접근할 방법은 없지만, UIKit 쪽에서 _UIHostingView를 전역 변수로 설정해두면 onTapGesture 안에서 참조할 수 있습니다.

(실제 프로덕션 코드에서는 절대 이런 짓을 하지 마세요.)

var hostingView: UIView? // 전역으로 설정
 
struct SwiftUIView: View {
 
    @State var color: UIColor = .red
 
    var body: some View {
        Circle()
            .fill(Color(color))
            .onTapGesture {
                CATransaction.begin()
                hostingView?.backgroundColor = .yellow
                color = .yellow
                CATransaction.commit()
                sleep(3) // main thread를 3초 동안 블록
                color = .green
            }
    }
}

Hosting view 배경색 변경

Transaction 흐름

이 예제는 SwiftUI 코드 안에서도 CATransaction이 layer tree 변경에 대해 기대대로 동작한다는 것을 보여줍니다. 동시에, SwiftUI에서 explicit CATransaction을 사용하는 것은 사실상 의미가 없다는 점도 분명히 드러냅니다. 평소에 SwiftUI에서 Core Animation layer tree를 직접 건드릴 일이 없고, 건드려서도 안 되기 때문입니다.


HostingView, SwiftUI의 호스트

앞선 예제에서 hosting view를 사용했지만 자세히 다루지는 않았습니다. 이제 그 이야기를 해보겠습니다.

일반적인 UIKit 프로젝트 구조와 SwiftUI 애플리케이션 구조를 비교해봅시다.

UIKit 프로젝트 구조

SwiftUI 프로젝트 구조

SwiftUI의 모든 view tree는 _UIHostingView에 의해 호스팅되며, 이 뷰는 다시 UIHostingController에 의해 제어됩니다.

SwiftUI가 내부적으로 _UIHostingView와 그 subview들에 적용하는 뷰 구조 변경은, 앞에서 논의한 동일한 Core Animation render loop에 의해 렌더링됩니다.

ContentViewbody에 새로운 SwiftUI 뷰를 추가하면, SwiftUI는 필요한 경우에 _UIHostingView에 UIKit subview를 추가합니다.

"필요한 경우에"라고 한 이유는, 모든 SwiftUI 뷰가 내부적으로 UIKit UIView에 직접 대응되지는 않기 때문입니다.

SwiftUI에서 그래픽을 렌더링하는 방법은 여러 가지입니다. SwiftUI는 그것들을 표시하기 위해 내부적으로 UIView를 만들 수도 있고, 만들지 않을 수도 있습니다.

예를 들어 VStack과 같이 layout에는 참여하지만 내부적으로 자기만의 UIView를 갖지 않는 뷰도 있습니다.

SwiftUI 뷰 구조 예제

struct ContentView: View {    
    var body: some View {
        VStack {
            Image(uiImage: .strokedCheckmark)
            Text("Text")
            TextField("Title", text: .constant("TextField"))
        }
    }
}

some View에 숨겨진 body의 제네릭 타입은 다음과 같습니다:

VStack<
    TupleView<
        (Image, Text, TextField<Text>)
    > 
>

흔한 오해는 다음과 같은 UIKit 구조가 깔려 있으리라는 것입니다:

  • 세로로 쌓인 UIStackView
  • 그 안에 UIImage(를 담은 UIImageView), UILabel, UITextField

그러나 실제로는 일부만 맞습니다. 모든 UIView 뒤에 CALayer가 있는 것과 달리, 모든 SwiftUI 뷰 뒤에 UIView가 있는 것은 아닙니다. 일부 SwiftUI 뷰는 내부적으로 UIKit을 사용하지만, 전부 그렇지는 않습니다.

Hosting view 내부 구조

위 예제의 실제 내부 구조는 다음과 같습니다:

  • Image: _UIGraphicsView
  • Text: CGDrawingView
  • TextField: ViewHost(UIViewRepresentable) 내부에 호스팅된 UITextField

SwiftUI 뷰 분류

UIKit 백킹 관점에서 본 SwiftUI 뷰 분류

뒤에 UIView가 없는 뷰

  • 레이아웃/구조 목적의 뷰: Spacer, V(H/Z)Stack, LazyH(V)Stack

_UIGraphicsView로 뒷받침되는 뷰

  • 그래픽 기반 뷰: Color, Image, Divider

CGDrawingView(_UIGraphicsView의 서브클래스)로 뒷받침되는 뷰

  • 텍스트 기반 뷰: Text, Button, TextEditor
  • CG 접두사는 SwiftUI가 텍스트 렌더링에 UIKit처럼 Core Graphics를 사용한다는 것을 나타냅니다

UIViewRepresentable을 통해 특정 UIKit.UIView를 호스팅하는 뷰

  • TextField(내부에 UITextField), ProgressView, Slider, List

위의 뷰들을 조합한 형태

용어 설명

  • _UIGraphicsView: SwiftUI가 그래픽을 그리기 위해 사용하는 내부 UIView 서브클래스
  • CGDrawingView: _UIGraphicsView의 서브클래스로 텍스트 렌더링에 사용됨. Core Graphics 기반
  • UIViewRepresentable: UIKit의 UIView를 SwiftUI 뷰로 감싸는 프로토콜. 내부적으로 ViewHost를 통해 호스팅
  • ViewHost: UIViewRepresentable로 감싼 UIKit 뷰를 실제로 담는 내부 컨테이너 UIView

구현에 대한 주의

하위 구현은 시간이 지나면서 바뀔 수 있다는 점을 기억해야 합니다.

Apple이 SwiftUI의 구현을 비공개로 유지하는 이유도 여기에 있다고 봅니다. 구현은 시간에 따라 자유롭게 바꿀 수 있지만, SwiftUI API는 동일하게 유지할 수 있습니다.

현재 SwiftUI는 대부분 UIKit, Core Animation, Core Graphics 같은 기존 UI 프레임워크를 감싸는 형태로 동작합니다. 미래에 Apple이 원한다면, SwiftUI API를 바꾸지 않고도 UIKit을 deprecate하고 토대를 새로 작성할 수 있습니다.

다만 그 길은 아주 길어 보이기 때문에, UIKit은 한동안 여전히 남아 있을 것입니다.

요약 다이어그램


정리

SwiftUI의 본질

  • SwiftUI는 선언형(declarative) 이면서 데이터 기반(data-driven) UI 프레임워크입니다

body의 역할

  • 뷰의 body레이아웃(layout)과 콘텐츠(content)를 모두 정의합니다
  • body 뒤에서는 view treerender tree(Attribute Graph) 를 중심으로 복잡한 시스템이 동작합니다

View Tree와 Render Tree

  • SwiftUI에도 view tree가 있지만, UIKit과 달리 뷰는 일시적인(transient) struct 입니다
  • 상태가 바뀌면 뷰가 재생성되며, SwiftUI는 Attribute Graph를 활용해 상태 변화를 추적하여 이 과정을 최적화합니다

View Render Loop

  • View render loop는 세 단계로 구성됩니다:
    • Evaluation 단계: view tree 재구축, diff, body 평가
    • Layout 단계: top-down으로 진행 — 부모가 공간을 제안하고 자식이 크기를 결정
    • Rendering 단계(혹은 Mapping to UIKit 단계): 내부적으로 UIKit, Core Animation, Core Graphics에 의존

CATransaction과 SwiftUI

  • SwiftUI는 UIKit/Core Animation에 의존하기 때문에, layer tree 변경을 관리할 때 implicit CATransaction을 내부적으로 사용합니다
  • SwiftUI 앱에서 직접 CATransaction을 사용하지는 않지만, 프레임워크는 여전히 Core Animation과 CATransaction을 통해 드로잉과 애니메이션을 수행합니다
  • Render server와 함께 Core Animation은 여전히 iOS와 SwiftUI의 토대(foundation)입니다

HostingView와 UIKit 통합

  • SwiftUI view tree는 _UIHostingView에 의해 호스팅되며, UIHostingController가 이를 제어합니다
  • 일부 뷰는 내부적으로 UIKit 뷰를 사용합니다 (예: TextFieldUITextFieldViewHost를 통해 호스팅). 하지만 모든 SwiftUI 뷰가 UIKit 뷰로 매핑되지는 않습니다
  • 현재 SwiftUI는 UIKit, Core Animation, Core Graphics 같은 기존 UI 프레임워크를 감싸는 형태로 동작합니다. 이로써 Apple은 API를 건드리지 않고도 향후 하위 구현을 진화시킬 수 있는 여지를 확보해두고 있습니다

UIKit은 여전히 유용합니다. SwiftUI가 미래라는 말이 틀린 것은 아니지만, 그 "미래"가 여전히 UIKit과 Core Animation 위에서 돌고 있다는 사실을 이해해 두면 어느 쪽이든 더 효과적으로 다룰 수 있습니다.