LODY/정리

애플 플랫폼 백과사전 / SwiftUI 의 render loop

SwiftUI 의 render loop

원문: The SwiftUI render loop — rensbr.eu 저자: Rens Breur 발행: 2022년 5월 9일

UIKit 과 마찬가지로 SwiftUI 도 UI 코드에 메시지를 전달하는 event loop 위에서 동작한다. UI 코드는 다시 화면의 일부를 다시 그리도록 만들 수 있다. 메시지 처리와 화면 렌더링이 모여 앱의 render loop 를 이룬다. 모든 UI 프레임워크는 render loop 기반이지만 SwiftUI 는 이걸 유독 잘 숨겨 놓는다. 대부분은 그냥 아래에서 알아서 돌아가기 때문에 우리가 신경 쓸 필요가 없다. event loop 가 뭔지 이해하지 않아도, 화면을 얼마나 자주 다시 그려야 하는지 걱정하지 않아도 UI 코드를 쓸 수 있다는 건 놀라운 일이다. 하지만 가끔은 뒤에서 무슨 일이 벌어지는지 아는 게 도움이 된다.

먼저 SwiftUI render loop 의 동작을 아는 게 유용한 경우들을 몇 가지 살펴보자. 그다음에 render loop 를 좀 더 자세히 들여다보며 이런 질문을 던진다. SwiftUI view 의 body 는 정확히 언제 평가되는가? "언제" 라는 건 어떤 조건에서가 아니라, 시간 축의 어느 지점에서를 말한다. body 가 평가된 직후에 view 가 항상 화면에 그려지는가? body 평가와 화면 렌더링은 얼마나 연관되어 있는가? 우리는 view 의 body 를 평가한다는 의미로 "render" 라는 단어를 쓰기도 하는데, 그게 말이 되는가?


예제: onAppear

SwiftUI 에는 UIKit 에서 보던 view 생명주기 알림이 다 있지 않다. view 가 등장할 때 어떤 동작을 하려면 쓸 수 있는 함수가 하나뿐이다. onAppear. 그런데 정확히 언제 호출되는가? viewWillAppear 처럼 view 가 그려져 화면에 보이기 전에 호출되는가? 그리고 우리가 그 타이밍에 기댈 수 있는가?

다음 view 와 view model 을 보자.

class ViewModel: ObservableObject {
    @Published var statusText: String = "invalid"
 
    func fetch() {
        self.statusText = "loading"
        // ...
    }
}
 
struct ContentView: View {
    @StateObject var model = ViewModel()
 
    var body: some View {
        Text(model.statusText)
            .padding()
            .onAppear { model.fetch() }
    }
}

view model 의 fetch 함수가 onAppear 에서 불려야만 이 view 가 표시할 준비가 된다. 실제로 돌려 보면 괜찮아 보인다. 앱이 뜨자마자 라벨이 "loading" 으로 보이고, 잠깐이라도 "invalid" 가 보이는 일은 없다. 그런데 얼마나 신뢰할 수 있는 동작인가? 느린 iPhone 이나 더 많은 프레임을 그릴 수 있는 아주 빠른 iPhone 에서는 어떤가? 디스플레이 refresh rate 타이밍이 안 좋게 맞아서 "loading" 으로 바뀌기 전에 "invalid" 가 잠깐 보일 수도 있지 않을까? transition 을 추가하면 문제를 만들지는 않을까? 얼마나 비효율적인가? body 가 두 번 평가되리라는 건 눈에 보인다. 그럼 실제 내용도 두 번 그려지는가?


예제: preference key 로 만드는 custom layout

stack, alignment guide, frame 같은 기본 layout 도구만으로는 만들 수 없는 layout 이 있다. 어떤 view 가 자식 view 의 크기를 알아야 하는 layout 이라면 기본 도구로는 부족할 때가 있다.

예를 들어 HStack 처럼 동작하지만 자식이 화면 폭을 넘어서는 순간 다음 줄에 이어서 배치해 주는 container view 를 만들고 싶다고 해 보자.

Flow layout

이런 종류의 layout 문제에 대한 일반적인 해법은 이렇다.

struct Flow<Content: View>: View {
  let content: [Content]
 
  @State private var sizes: [CGSize] = []
 
  var body: some View {
    ZStack(alignment: .topLeading) {
      ForEach(0 ..< sizes.count, id: \.self) { i in
        content[i]
          .background(GeometryReader {
            Color.clear
              .preference(key: SizesPreferenceKey.self, value: [$0.size])
          })
          .offset(self.calculateOffset(i)) // uses self.sizes
        }
      }
    }
    .onPreferenceChange(SizesPreferenceKey.self) {
      sizes = $0
    }
  }
 
  // ...
}

먼저 자식 view 크기를 담을 @State 변수를 만든다. 그다음 GeometryReader 를 자식 view 의 background 에 써서 크기를 읽고, preference 로 container view 에 전달한다. (이 예제에서는 container view 와 자식 view 가 같은 SwiftUI view 에서 만들어지니까 굳이 preference 까지 안 써도 되지만, 실무에선 분리돼 있는 경우가 더 흔하다.) 부모 view 에서는 onPreferenceChange 핸들러에서 이 preference 를 받아 state 를 업데이트한다. sizes 변수가 채워지면 이제 자식 view 들을 제대로 배치할 수 있다.

이 기법은 동작하지만, container view 의 body 가 두 번 평가되어야 한다. 처음 평가될 때는 sizes 가 아직 비어 있어서 자식을 제대로 배치하지 못한다. 자식이 평가되는 순간 size preference 가 업데이트되고, 그다음에 container view 의 body 가 두 번째로 평가되어 그제서야 자식을 제대로 배치한다.

첫 번째 body 평가 시점에는 표시할 준비가 안 되어 있으니까 앞 예제에서 했던 질문이 다시 생긴다. 그리고 이번엔 body 가 두 번 평가되는 일이 처음 표시할 때만 일어나는 게 아니라 자주 반복될 수 있다. container view 가 잘못된 초기 레이아웃으로 렌더링되는 일이 절대 없다고 확신할 수 있을까? 불필요한 렌더링을 얼마나 하고 있는 걸까?


Hardware symphony

두 예제에 대한 답은 이렇다. glitch 는 절대 생기지 않고, 성능 영향도 사실상 없다. view 의 body 를 두 번 평가해야 한다면 첫 번째 body 는 절대 화면에 렌더링되지 않는다. 그리고 body 평가는 렌더링과 같은 것이 아니다. 많은 경우 view body 를 평가하면 해당 view 가 다시 그려져야 하지만, 항상 그런 것도 아니고 즉시 그려지지도 않는다. 이걸 확인하려면 SwiftUI 앱이 실제로 어떻게 돌아가고 어떻게 화면에 내용을 그리는지 봐야 한다.

우선 view 는 애초에 어떻게 화면에 표시되는가? iPhone 의 화면에는 특정 refresh rate 가 있다. 대부분의 iPhone 은 60Hz 다. 화면이 초당 60번 새로고침되고, 한 프레임은 1/60 초 지속된다. 최상위 iPhone 은 최대 120Hz 까지 가는 가변 refresh rate 를 갖는다. GPU 는 두 번의 디스플레이 refresh 사이에서만 video frame 을 바꿔야 한다. 그렇지 않으면 서로 다른 두 프레임이 섞여서 tearing 같은 그래픽 artifact 가 생긴다.

GPU 뿐 아니라 CPU 로 내용을 그리는 부분도 있다. 이 경우 이미지가 bitmap 으로 먼저 생성되고, 이게 GPU 로 전달된다. GPU 는 그래픽을 변환하고 합성한다. 특정 view 나 그래픽이 렌더링 비용이 크다면 GPU 메모리에 저장해 둘 수 있다. 한 프레임 전체를 렌더링하는 데 걸리는 시간은 디스플레이 refresh 주기의 역수보다 짧아야 한다.

화면에 내용을 보여주는 건 절반의 얘기일 뿐이고, 사용자 입력도 받아야 한다. touch 입력은 일반적으로 일정 주파수로 샘플링된다. 이 주파수가 디스플레이 refresh rate 보다 높을 수도 있다. 샘플링 주파수가 refresh rate 와 같더라도 둘이 완벽히 동기화되어 있는 건 아니다. 최신 iPhone 의 touch sampling rate 는 120Hz 로, 디스플레이 refresh rate 의 두 배다. 화면은 그만큼 자주 갱신하지 못하더라도 이 추가 touch 데이터를 활용해 더 정교한 그래픽을 보여 줄 수 있다. 드로잉 앱이라면 더 많은 touch 로 더 정교한 stroke 를 그릴 수 있다.

게임과 달리 일반 앱은 refresh rate 만큼 최대한 많은 프레임을 뽑아내려는 update loop 에 기반하지 않는다. 대신 데이터가 바뀌었을 때 시스템이 실행해 주는 drawing 코드와, touch 같은 이벤트에 응답하는 코드 를 제공한다. OS 는 이런 이벤트를 처리해야 할 때 앱을 깨우고, 앱은 그 기회를 이용해 UI 프레임워크로 화면 일부를 다시 그린다.

입력 이벤트를 등록하고 이를 토대로 화면에 이미지를 그리는 과정은 아주 정밀하게 조율되어야 한다. 앱을 짤 때는 이걸 직접 신경 쓸 필요가 없다. 그냥 gesture 나 control event 를 쓰고 view 내용을 바꾸면 된다. 하지만 OS 는 디스플레이가 refresh 될 때마다 정확히 한 프레임을 만들기에 딱 맞는 만큼의 이벤트를 — 더도 덜도 아니고 — 그리고 가능한 가장 낮은 latency 로 전달하도록 이벤트를 세심하게 배치해 준다.


The run loop

Apple 플랫폼에서 모든 앱의 핵심에 있는 event loop 는 CFRunLoop 인스턴스다. 이 Core Foundation 객체는 Mac OS X 10.0 과 함께 나온 Carbon API 의 일부였고, 수많은 UI 프레임워크와 세대를 거쳐 지금까지 살아남았다. Carbon 앱이 쓰다가, UIKit 이 쓰다가, 지금은 SwiftUI 도 쓴다. main dispatch queue 도 CFRunLoop 위에 구현돼 있고, Swift Concurrency 의 MainActor 도 그렇다.

CFRunLoop 가 어떻게 동작하는지 보려면 직접 run loop 를 하나 만들어 보면 된다. 사용자 입력을 기다렸다가 동작하는 간단한 커맨드라인 앱을 짠다고 하자.

while let input = readLine() {
  print(input)
}

사용자 입력을 루프에서 읽고, 들어오면 뭔가 한다. 이게 run loop 다. 프로그램은 두 가지 상태를 오간다. 첫 번째 상태에서는 idle 이고, 사용자 입력을 기다린다. 스레드는 sleep 상태가 되고, CPU 시간은 다른 프로세스가 쓴다. 입력이 들어오면 OS 가 스레드를 깨워 처리하게 한다.

같은 스레드에서 들어오는 네트워크 이벤트도 함께 처리하고 싶다면? 이제 readLine 은 못 쓴다. 사용자 입력이 들어올 때까지 스레드를 블록하기 때문이다. 여러 OS 이벤트 중 아무거나 기다리는 방법은 여러 가지다. 어느 쪽이든 커널 지원이 필요하다. 커맨드라인 프로그램이라면 보통 select 나 Dispatch source 를 쓴다. CFRunLoop 내부에서는 mach port 를 쓴다.

다음은 CFRunLoop 를 앞서의 커맨드라인 앱과 비교한 도식이다.

CFRunLoop

Xcode 디버거에서 iOS 앱을 일시 정지해 봤을 때 앱이 idle 상태라면 main thread 의 스택 트레이스 맨 위가 이런 모양이다.

* frame #0: libsystem_kernel.dylib`mach_msg_trap + 10
  frame #1: libsystem_kernel.dylib`mach_msg + 59
  frame #2: CoreFoundation`__CFRunLoopServiceMachPort + 319
  frame #3: CoreFoundation`__CFRunLoopRun + 1249

mach_msgCFRunLoop 가 여러 이벤트 중 아무거나 기다리는 데 쓰는 시스템 콜이다. 이 시점에 앱은 CPU 를 쓰고 있지 않다. 적어도 main thread 는 그렇다.

CFRunLoop 는 이벤트를 전달하는 input source 의 집합으로 구성된다. 앱이 실행되면 main thread 에서 touch 이벤트를 전달할 input source 를 붙인 run loop 가 돈다. 나중에 다른 input source 도 붙일 수 있다. 보조 스레드에서 새 run loop 를 시작할 수도 있다. 사용자 입력과 네트워크 이벤트를 두 개의 input source 로 붙인 CFRunLoop 로 커맨드라인 프로그램을 구현할 수도 있다.

CFRunLoop 에 연결된 input source 들의 이벤트는 정해진 순서 로 처리된다. input source 는 4 가지 타입 이 있고, 이외에 run loop observer 가 있다.

  • Type 0 input sources. CFRunLoop 함수를 수동으로 불러 이벤트를 전달하는 custom input source. iOS 앱의 touch 이벤트는 보조 스레드에서 처리된 다음 type 0 input source 를 통해 main thread 의 run loop 로 전달된다.
  • Type 1 input sources. mach port 기반이다. 그리기 코드를 디스플레이 refresh rate 에 동기화할 때 쓰는 CADisplayLink 가 type 1 input source 를 쓴다. 비동기 네트워킹 코드도 type 1 을 쓸 수 있다. (다만 많은 네트워킹 라이브러리는 내부 dispatch queue 에서 blocking I/O 를 호출한 다음 main dispatch queue 로 코드를 dispatch 해 넘기기도 한다.)
  • Timer sources. Timer 같은 타이머가 이 특별한 input source 를 쓴다.
  • Main dispatch queue. main dispatch queue 에 dispatch 된 코드와 이 큐에 연결된 dispatch source 역시 하나의 input source 를 이룬다. 덕분에 오래된 코드와 Dispatch 기반 코드가 같이 상호작용할 수 있다. 다른 dispatch queue 들은 CFRunLoop 위에 구현돼 있지 않다.

input source 외에 observer 도 추가할 수 있다. run loop cycle 의 특정 지점에 도달했을 때 알림을 받는다. run loop cycle 의 각 구간은 activity 로 정의되고, observer 는 하나 또는 여러 activity 에 알림을 걸 수 있다. Apple 의 자체 프레임워크들도 run loop observer 를 광범위하게 쓴다.

앱의 run loop 가 idle 이 아닐 때는 input source 이벤트를 처리하거나 observer 에게 알림을 보내고 있는 중이다. 디버깅할 때 run loop 가 어디 있는지 쉽게 보이도록, CFRunLoop 는 모든 코드를 다음 5개의 마커 함수 중 하나를 통해 호출한다.

__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__
__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__

이 함수들은 실제로 하는 일은 없다. 스택 트레이스를 찍었을 때 run loop 의 어디에 있는지 보이게 하려고 존재한다. Xcode 프로젝트를 열고 아무 코드에나 breakpoint 를 걸어 보면, 스택 트레이스에 위 문자열 중 하나가 들어 있을 것이다.

run loop observer 를 붙이거나 이 함수들에 breakpoint 를 걸면 SwiftUI render loop 가 어떻게 동작하는지 꽤 많은 정보를 얻을 수 있다.


Core Animation 과 render server

앱이 멈춘 것 같은데 어딘가 애니메이션이 계속 도는 걸 보고 "멈춘 건 아닌가 보다" 싶었던 경험이 있을 것이다. main thread 가 멈춰 있는데도 activity indicator 가 돌고 있는 현상은 늘 당황스럽다. iOS 의 애니메이션은 메인 스레드가 바쁘거나 멈춰 있어도 계속될 수 있다. 다른 스레드에서 돌아서가 아니라, 다른 프로세스에서 돌기 때문이다.

OS 는 여러 프로세스의 그래픽을 각자의 window 에 그려 보여주기 위해 compositor 를 쓴다. iOS 에도 compositor 가 있는데, 단순히 split screen 이나 앱 스위처에 여러 window 를 동시에 그리는 데만 쓰이는 게 아니다. 앱 안의 여러 CALayer 를 그리고 애니메이션하는 데도 쓰인다. 이 프로세스가 render server 이고, Core Animation 프레임워크가 보여주는 마법의 대부분은 여기서 일어난다.

Core Animation 은 render server 에게 무엇을 그리고 애니메이션할지 말해 준다. 일반적으로 하나의 사용자 액션에 대응해 view 를 여러 번 invalidate 하는 변경을 한다. UIKit 이라면 버튼 탭에 반응해 view 의 크기 배경색을 바꾸거나, setNeedsDisplay 를 트리거하는 메서드를 여러 번 부를 수 있다. 액션 중간 단계마다 프레임을 그려 버리면 비효율적이고 glitch 도 생긴다. 어떤 변경 묶음이 "한 번의 변경" 인지 정의하려고 Core Animation 은 CATransaction 을 노출한다.

CATransaction 은 수동으로 begin/commit 할 수 있다. 수동으로 안 하고 layer 를 바꾸면 암묵적으로 CATransaction 이 시작된다. CATransaction 을 가지고 이것저것 해 보면 그 효과를 직관적으로 알 수 있다. UIKit 앱에 다음 액션을 가진 UIButton 을 만들어 보자.

@IBAction func buttonPress() {
  self.view.backgroundColor = .red
  sleep(2)
  self.view.backgroundColor = .white
}

버튼을 누르면 앱이 2초 동안 멈추는데, view 의 배경색은 그 동안 흰색으로 남아 있다. view 의 layer 에 건 변경이 sleep 전에 렌더링되지 않기 때문이다. 배경색을 바꾸는 행위가 암묵적 transaction 을 시작했지만, sleep 전에 commit 되지 않았다.

이제 첫 번째 배경색 변경 앞뒤에 두 줄을 추가해 보자.

@IBAction func buttonPress() {
  CATransaction.begin()
  self.view.backgroundColor = .red
  CATransaction.commit()
  sleep(2)
  self.view.backgroundColor = .white
}

이번에 버튼을 누르면 view 의 배경색이 빨강으로 바뀌고, 앱이 멈춘 2초 동안 빨강으로 유지되다가 흰색으로 바뀐다. 우리가 명시적으로 transaction 을 시작했기 때문에 배경색 변경이 별도의 암묵적 transaction 을 시작하지 않았다.

암묵적 transaction 은 정확히 언제 commit 되는가? 암묵적 transaction 이 시작될 때마다 현재 run loop cycle 의 끝에서 commit 하도록 예약된다. 이 예약은 Core Animation 이 main CFRunLoopbeforeWaiting activity 용으로 붙인 run loop observer 로 구현돼 있다.

CATransaction 안에 또 다른 CATransaction 을 시작할 수도 있지만, 화면 렌더링·애니메이션에 쓰이는 건 바깥쪽 transaction 뿐이다. 바깥쪽이 암묵적 transaction 일 수도 있다. 어떤 control 은 액션 핸들러를 부르기 전에 스스로 애니메이션을 돌리는데, 내부적으로는 view 배경색을 바꾸는 것과 같은 효과를 낸다. 암묵적 transaction 을 시작해 둔다는 뜻이다. 그 상태에서 layer 변경용 명시적 transaction 을 시작해 commit 해도 즉시 반영되지 않는다.

SwiftUI 앱에서 CATransaction 을 직접 쓸 일은 없지만, 프레임워크는 내부적으로 여전히 Core Animation 과 CATransaction 을 써서 그리고 애니메이션한다. render server 와 함께 Core Animation 은 iOS 의 아주 근본적인 구성 요소다.


Touch event 와 display refresh rate

custom 애니메이션이나 물리 엔진이 필요한 앱은 CADisplayLink 를 써서 그리는 코드를 디스플레이 refresh rate 에 맞출 수 있다. 이 API 가 나오기 전에는 정말 어려웠다. 특히 게임 개발자들은 NSTimer 를 써야 했고 그 한계를 피하느라 고생했다.

앱은 OS 로부터 디스플레이 refresh 와 같은 주파수로 touch 이벤트를 받는다. 말이 되는 이야기다. touch 를 view 갱신에 쓰는데, 화면에 표시할 수 있는 빈도보다 더 자주 받아 봤자 낭비다. 하지만 touch 수신 시점과 CADisplayLink 발생 시점을 비교해 보면 둘이 정확히 동기화돼 있지는 않다.

touch sampling rate 가 높은 최신 iPhone 에서는 한 refresh 주기 안에 여러 touch 이벤트가 발생할 수 있지만, 우리는 그걸 따로 받지 않는다. UIKit 에서는 UITouch 객체에서 중간 touch 를 꺼낼 수 있다.

CADisplayLink 든 touch 든, run loop input source 는 이벤트가 발생했는데 앱이 바쁠 때를 서로 다른 방식으로 다룬다. 앱이 이전 touch 에 응답하느라 바쁜 동안 여러 touch 가 들어오면 따로따로 전달되지는 않지만, 최신 touch 이벤트로부터 중간 touch 들을 복원할 수 있다. 반면 다음 refresh 가 올 때 아직 바쁜 상태라면 CADisplayLink 는 아예 알리지 않는다.


전체 그림

iOS 가 touch 같은 이벤트를 처리하고 화면에 내용을 그리는 저수준 기술들의 배경이 어느 정도 잡혔으니, 이제 SwiftUI render loop 전체 를 살펴보자. 도식으로 그려 보면 다음과 같다.

SwiftUI run loop

아무 일도 하고 있지 않을 때 SwiftUI 앱은 idle 상태의 CFRunLoop 를 돌린다. touch, 네트워크 이벤트, timer, 디스플레이 refresh 같은 input source 이벤트를 기다린다. touch 에 반응해 SwiftUI 는 Button 의 action handler 를 호출할 수 있다. 그 action handler 안에 breakpoint 를 걸어 보면 스택 트레이스 어딘가에 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ 가 보인다. touch 이벤트가 type 0 input source 로 전달되기 때문이다.

input source 이벤트에 반응해 우리가 동작을 수행하면서 view 의 @State 변수를 갱신하거나 @ObservedObject 에서 objectWillChange publisher 를 발화시키는 함수를 부르는 경우, SwiftUI view 는 invalidate 된다. body 를 다시 평가해야 한다는 뜻인데, 즉시 하는 건 비효율적이다. 같은 함수가 곧이어 다른 @State 변수를 또 바꿀 수도 있기 때문이다. 그래서 body 평가는 뒤로 미뤄진다.

view 의 body 어디에 breakpoint 를 걸어 봐도 스택 트레이스에 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ 가 보인다. 암묵적 CATransaction commit 예약과 마찬가지로, invalidate 된 view 의 body 평가도 현재 run loop cycle 의 끝에 실행되도록 예약된다. 이 또한 CFRunLoopActivity.beforeWaiting 단계에 진입할 때 발화하는 run loop observer 로 구현돼 있다. 한 run loop cycle 동안 view 가 두 번 invalidate 되더라도 body 가 두 번 평가되지는 않는다.

invalidate 된 모든 view 를 다시 평가한 뒤에도 SwiftUI 는 바로 run loop 에 제어를 넘기지 않는다. onChange, onPreferenceChange, onAppear 같은 변경 핸들러가 먼저 호출되고, 이 핸들러가 view 를 한 번 더 invalidate 할 수 있다. 두 번째 재평가 예약에는 SwiftUI 가 run loop observer 를 쓰지 않는다.

두 번째 body 평가가 또 변경 핸들러를 불러 그것이 invalidate 를 트리거하면, SwiftUI 는 무한 루프를 막기 위해 view invalidate 를 일시적으로 꺼 버린다. 그리고 이런 경고를 찍는다.

onChange(of: _) action tried to update multiple times per frame

view 를 재평가하는 동안 built-in view (즉 BodyNever 인 view) 는 앱의 CALayer 에 변경을 걸 수 있다. 앞서 봤듯 이 변경은 즉시 화면에 그려지지 않고 암묵적 CATransaction 을 시작한다. SwiftUI 도 UIKit 앱에서 우리가 손으로 썼던 바로 그 최적화를 그대로 쓰고 있는 셈이다.

암묵적 CATransaction 이 commit 되고 나서야 view 의 내용이 화면에 렌더링된다. CPU 를 쓰는 렌더링 코드도 이 시점에 호출된다. SwiftUI 가 render loop 의 이 단계에서 크래시 나면 고치기가 쉽지 않다. 어느 view 의 어느 부분이 원인인지 보기 어렵기 때문이다.

render loop 전반에 걸쳐 반복되는 패턴이 있다. 함수를 부르거나 변수를 바꾸는 행위가 업데이트를 트리거할 때, 이 업데이트를 즉시 수행하지 않고 뒤로 예약하는 패턴이다. state 가 바뀌어 view 가 invalidate 될 때, onChangeonAppear 같은 핸들러가 호출될 때, Core Animation 이 그래픽을 그려야 할 때 모두 그렇다. 어떤 건 CFRunLoop observer 를 쓰고, 어떤 건 프레임워크 내부에서 직접 처리한다.

render loop 를 이해하고 나면 앞서의 예제 코드가 왜 안전한지 알 수 있다. 첫 번째 body 평가 이후의 모든 변경은, 그게 포함된 암묵적 transaction 이 아직 commit 되지 않았으니 렌더링되지 않는다. 디버깅하거나 성능을 개선하려 할 때 SwiftUI 가 지금 뭘 하는지 아는 것도 유용하다.

SwiftUI 의 render loop 는 잘 숨겨져 있지만, 거기 쓰이는 기술들은 UIKit 앱에서 쓰던 것과 같고 문서화도 잘 되어 있다. 어떻게 돌아가는지 더 잘 들여다볼수록 우리가 쓴 코드의 side effect 를 이해할 수 있고, 더 나은 판단을 내릴 수 있다. view 의 body 를 평가한다는 의미로 "rendering" 이라는 말을 쓰기도 하지만, 때로는 그 구분을 이해하는 게 큰 도움이 된다.