원문: Behind the scenes of UI: Part 1 - UIKit by Vitaly Batrakov (2024.02.19)

들어가며
iOS 개발자로서 우리는 사용자 인터페이스를 매일 다룹니다. UIKit과 SwiftUI를 효과적으로 사용하려면 lifecycle이나 layout 같은 UI의 다양한 측면을 이해하는 것이 중요합니다.
개발자가 UI의 내부 동작 원리를 반드시 알아야 할까요, 아니면 제공된 API에만 의존해도 될까요? 저는 프레임워크의 내부 동작 원리를 이해하면 성능 저하, 간헐적인 결함, 예상치 못한 애니메이션, 레이아웃 버그 등을 피하는 데 크게 도움이 된다고 생각합니다.
이 글의 목적은 UIKit이 내부적으로 어떻게 동작하는지 포괄적인 개요를 제공하는 것입니다.
UIKit
UIKit 애플리케이션에서 자주 볼 수 있는 예시를 살펴보겠습니다. 앱의 특정 시점에서 view controller의 view 안에 subview를 추가하고 싶을 때입니다.

간단해 보이죠? 하지만 실제로는 어떻게 동작할까요?
큰 "어떻게"를 작은 질문들로 나눠 봅시다:
viewDidLoad에서 UI를 업데이트하기 전에 앱에서는 무슨 일이 일어날까요?- UI 변경은 어떻게 적용될까요? 새로운 subview는 어떻게 화면에 나타날까요?

Xcode에서 빈 UIKit Swift 프로젝트를 만들고 viewDidLoad에 breakpoint를 설정해 보겠습니다.

Debug navigator의 우측 하단 버튼을 탭하면 call stack에서 훨씬 더 많은 세부 정보를 볼 수 있습니다.

UI 관련 코드가 main thread에서 실행되는 것을 확인할 수 있습니다. UIKit은 thread-safe하지 않기 때문에 main thread에서만 사용해야 합니다.
UIApplicationMain
call stack 하단을 살펴봅시다:

여기서 볼 수 있는 것은 main 함수입니다. 이 함수는 앱이 실행될 때 운영체제의 runtime 환경에 의해 호출됩니다. main 함수는 앱 실행의 entry point로 볼 수 있습니다.
예를 들어, Xcode에서 Objective-C 프로젝트를 만들면 다음과 같은 코드를 볼 수 있습니다:

여러분의 앱에서 일어나는 모든 일들이 main 함수 안에서 발생합니다. 여러분의 전체 앱은 결국 main 함수 하나의 거대한 호출에 불과합니다.
하지만 main 함수 자체는 많은 일을 하지 않습니다. 주요 목적은 UIApplicationMain() 함수를 호출하는 것입니다.
UIApplicationMain은 iOS 앱을 설정하기 위해 다양한 작업을 수행합니다:
UIApplicationshared instance와 application delegate를 생성합니다- scene, scene delegate 등의 생성을 트리거하는
UIApplication을 실행합니다 - [지금 우리에게 가장 중요한 부분]
Runloop.run()을 호출하여 main event loop를 실행합니다

Runloop
간단히 말해서, Runloop는 앱에서 발생하는 이벤트의 무한한 사이클입니다.
Runloop는 main()과 UIApplicationMain() 호출이 실제로 절대 반환되지 않는 이유입니다.
매 iteration마다 Runloop는 세 가지 주요 작업을 수행합니다:
- 이벤트를 처리합니다
- runloop의 상태 변화에 대해 observer들에게 알립니다
- 할 일이 없을 때 thread를 sleep 상태로 전환합니다


참고 1: Runloop(NSRunloop)는 CFRunloop의 Foundation 측 프록시(proxy)입니다. 이벤트 소스, observer, 새로운 runloop 모드 추가 등 CFRunloop가 가진 일부 인터페이스를 숨기기 위해 만들어졌습니다.
참고 2: CFRunloop는 thread-safe하지만, Runloop(NSRunloop)는 thread-safe하지 않습니다.
참고 3: Runloop의 빈도는 고정되어 있지 않으며, 화면 refresh rate에도 종속되지 않습니다. Runloop 빈도와 화면 refresh rate는 독립적으로 작동합니다.
Runloop 이벤트
Runloop는 여러 가지 다른 이벤트 타입을 처리합니다:
- GCD main queue의 Block
- Timer
- Source (버전 0과 1)
- Observer
앱의 거의 모든 코드 줄에 breakpoint를 설정하면, call stack 하단에서 아래의 함수들 중 하나를 발견할 가능성이 높습니다. RunLoop는 모든 main thread 코드가 이 함수들 중 하나를 통해 실행되도록 보장합니다.
Runloop Source
runloop source는 앱의 다른 부분이나 다른 프로세스에서 어떤 이벤트가 발생했음을 runloop에게 알리고, 관련 callback을 호출하도록 처리해야 한다고 알려주는 방법입니다.
source에는 source0과 source1의 두 가지 타입이 있습니다. context의 version 필드가 0이거나 1이기 때문에 이렇게 불립니다.
- Version 0 source: 앱 내부 통신용
- Version 1 source: 프로세스 간 통신(IPC)용
Version 0 Source (앱 내부 통신)
애플리케이션에 의해 관리됩니다. source가 실행될 준비가 되면, 다른 부분의 코드가 CFRunLoopSourceSignal 함수를 호출하여 runloop에게 알려야 합니다. Source가 처리되려면 CFRunLoopWakeUp()으로 RunLoop를 깨워야 합니다.
Version 0 source의 예시:
- NSObject의
performSelector(onMainThread:with:waitUntilDone:) - SFSocket
- UIKit의 touch 이벤트 fetching. UIButton의 tap selector에 breakpoint를 설정하면 이 액션을 트리거한 UIEvent가 version 0 source를 사용하여 fetch되었음을 알 수 있습니다.

Version 1 Source (IPC)
Version 1 source는 커널에 의해 관리됩니다. source의 Mach port에 메시지가 도착하면 커널에 의해 자동으로 신호를 받습니다.
화면 refresh rate에 코드를 동기화하는 데 사용할 수 있는 CADisplayLink는 내부적으로 type 1 source를 사용합니다.
Run Loop Observer
CFRunLoopObserver API는 CFRunLoop의 동작을 관찰하고 활동에 대한 알림을 받을 수 있게 해줍니다.
다음 CFRunLoopActivity 이벤트와 연결하여 run loop observer를 설정할 수 있습니다:
- Run loop의 진입 (
kCFRunLoopEntry) - Run loop가 timer를 처리하려고 할 때 (
kCFRunLoopBeforeTimers) - Run loop가 input source를 처리하려고 할 때 (
kCFRunLoopBeforeSources) - Run loop가 수면 상태로 전환되려고 할 때 (
kCFRunLoopBeforeWaiting) - Run loop가 깨어났지만 이벤트를 처리하기 전 (
kCFRunLoopAfterWaiting) - Run loop의 종료 (
kCFRunLoopExit)
앱에서 runloop observer를 직접 만들 필요는 거의 없겠지만, 존재와 목적을 인식하면 UIKit이 어떻게 동작하는지 이해하는 데 도움이 됩니다. UIKit 뒤에는 UI, 애니메이션, 렌더링의 수많은 측면을 담당하는 저수준 프레임워크인 Core Animation이 자리하고 있습니다.
Core Animation

UIKit은 Apple의 잘 알려진 프레임워크로, 앱에서 UI를 만드는 데 매일 사용합니다. 하지만 놀랍게도, UIKit은 애니메이션, 레이아웃, 렌더링 같은 UI 관련 작업의 상당 부분을 내부적으로 Core Animation에 위임합니다.

모든 UIView 뒤에는 CALayer가 숨어 있습니다. UIView가 생성될 때 내부적으로 CALayer를 만들고 자기 자신을 CALayerDelegate로 설정합니다. UIView의 프로퍼티가 기본적으로 애니메이션 가능하지 않은 이유(CALayer와 반대)는 UIView가 CALayerDelegate로서 기본 애니메이션을 비활성화하기 때문입니다. frame, bounds, backgroundColor, isHidden은 실제로 CALayer의 프록시(proxy) 프로퍼티입니다.

UIKit이 진정으로 처리하는 것은 터치 및 제스처 같은 사용자 상호작용과 접근성(accessibility)입니다.
화면의 뷰들은 함께 view hierarchy(뷰 계층)를 형성합니다. 마찬가지로, CALayer는 뷰와 병렬로 layer hierarchy를 형성합니다. Core Animation은 하나의 layer tree만 처리하는 것이 아니라 세 가지를 처리합니다:
- Model layer tree
- Presentation layer tree
- Render layer tree (앱에서 접근 불가)

CALayer의 model()과 presentation() 메서드로 model tree와 presentation node에 접근할 수 있습니다.
Model layer tree (또는 단순히 "layer tree")는 앱이 가장 많이 상호작용하는 트리입니다. 애니메이션의 목표(최종 상태) 값을 저장하는 모델 객체들입니다.
Presentation tree는 실행 중인 애니메이션의 현재(in-flight) 값을 포함합니다. 현재 화면에 표시되는 현재 값을 반영합니다. 이 트리의 객체들은 절대 직접 수정하면 안 됩니다.
Render tree는 실제 애니메이션을 수행하며 Core Animation에서 private합니다. 실제 화면 상태에 가장 가까운 layer tree입니다.

기억하세요: Presentation layer는 원본 layer가 아니라 일시적인 '유령' layer입니다. render tree layer의 근사(approximated) 상태입니다.

UI 변경 사항이 변경될 때마다 즉시 화면에 업데이트되지는 않습니다. Core Animation은 layer tree에 대한 변경 사항을 CATransaction으로 그룹화합니다.
CATransaction
layer를 수정하고 여러 프로퍼티를 변경해야 할 때, 변경 사항을 하나씩 적용하는 것은 효율적이지 않습니다. Core Animation은 이러한 업데이트를 render tree로 전송되는 단일 transaction 업데이트로 통합합니다.


Core Animation은 CFRunLoopActivity.kCFRunLoopBeforeWaiting 이벤트를 관찰합니다. 이 이벤트가 매 runloop iteration의 끝에서 발생할 때, model layer tree에 변경 사항이 있으면 Core Animation은 변경 사항들을 하나의 transaction(implicit CATransaction)으로 그룹화하고 render tree에 한꺼번에 commit합니다.

뷰의 layoutSubviews() 메서드에 breakpoint를 설정하면 call stack에서 다음 함수를 발견할 수 있습니다: __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__()


CATransaction 예제
background color가 처음에 .red로 설정된 버튼이 있다고 가정해 봅시다:
button.backgroundColor = .red
...
@objc func buttonTapped() {
button.backgroundColor = .yellow // implicit transaction이 여기서 시작됨
sleep(3) // main thread를 3초 동안 블록
button.backgroundColor = .green
}버튼을 탭하면:
backgroundColor를.yellow로 변경sleep()함수로 main thread를 3초 동안 블록backgroundColor를.green으로 변경

노란색이 보이지 않는 이유가 즉시 명확하지 않죠? 이는 노란색을 설정한 후 main thread를 블록했기 때문입니다. Implicit CATransaction은 3초가 경과할 때까지 commit되지 않았고, 3초 직후 색상을 .green으로 변경했으므로 노란색은 결코 보이지 않게 됩니다.
Explicit Transaction
코드에서 직접 transaction을 생성하는 explicit transaction도 있습니다:
CATransaction.begin()
button.backgroundColor = .yellow
// 원하는 다른 변경 사항 추가
CATransaction.commit()CATransaction.begin()과 CATransaction.commit() 사이의 모든 변경 사항은 함께 그룹화되어 원자적으로 commit됩니다.
참고 1: Explicit transaction과 implicit transaction은 동일한 CATransaction입니다. 차이점은 우리가 직접 begin과 commit을 호출하는지의 여부입니다.
참고 2: Implicit transaction은 현재 runloop iteration 중 처음으로 layer tree를 변경할 때 CoreAnimation에 의해 시작됩니다.
kCFRunLoopBeforeWaiting이 발생하는 현재 runloop iteration의 끝에서 implicit transaction이 commit됩니다.
@objc func buttonTapped() {
CATransaction.begin()
button.backgroundColor = .yellow
CATransaction.commit()
sleep(3) // main thread를 3초 동안 블록
button.backgroundColor = .green // implicit transaction이 여기서 시작됨
}이제 노란색 상태가 보입니다! main thread를 블록하기 직전에 explicit transaction으로 commit했으므로 탭 직후 즉시 변경되었습니다.

중첩 Transaction
explicit transaction을 다른 explicit transaction 안에 넣어보겠습니다:
@objc func buttonTapped() {
CATransaction.begin()
CATransaction.begin()
button.backgroundColor = .yellow
CATransaction.commit()
sleep(3)
button.backgroundColor = .green
CATransaction.commit()
}첫 번째 예제와 동일하게 노란색 상태가 나타나지 않습니다. Transaction이 중첩되면, "내부"(자식) transaction의 commit은 부모가 commit될 때만 적용됩니다. Transaction은 LIFO(후입선출) stack으로 관리됩니다.


Implicit transaction은 layer tree를 explicit transaction 외부에서 변경할 때 시작되므로 explicit transaction 안에 중첩될 수 없습니다.
Implicit Transaction 동작 방식
@objc func buttonTapped() {
button.backgroundColor = .white // implicit transaction이 여기서 시작됨
CATransaction.begin()
button.backgroundColor = .yellow
CATransaction.commit()
sleep(3)
button.backgroundColor = .green
}먼저 button.backgroundColor = .white로 model layer tree를 변경하면 implicit transaction이 시작됩니다. 따라서 explicit transaction이 implicit transaction 안에 중첩되어 나중에 implicit transaction이 commit될 때까지 render tree로 전송되지 않습니다. 결과적으로 3초 후에 초록색 상태만 보이게 됩니다.

Transaction에는
flush메서드도 있습니다. 현재 존재하는 implicit transaction을 즉시 commit합니다. 하지만flush를 명시적으로 호출하는 것은 피해야 합니다. Runloop 중에flush가 자동 실행되도록 하면 더 나은 성능과 원자적 화면 업데이트가 보장됩니다.
Transaction의 단계 (Phase)
CATransaction commit 메서드는 네 가지 단계로 구분됩니다:
- Layout phase
- Display phase
- Prepare phase
- Commit phase
모두 CA::Transaction::commit() 메서드 내에서 발생합니다.


Layout 단계
Core Animation은 DFS(Depth-First Search, 깊이 우선 탐색)를 사용하여 최상위 superview(UIWindow)에서 시작하여 최하위 leaf layer까지 layer tree를 탐색합니다. 레이어의 해당 플래그(needsLayout() / needsDisplay())가 활성화되어 있으면, 각각 layoutSublayers() / display() 메서드가 호출됩니다.


UIKit에서 자주 사용되는 layoutIfNeeded, setNeedsLayout, layoutSubviews 같은 layout 메서드들은 본질적으로 CALayer의 해당 메서드들의 프록시입니다.

UIView는 layoutSublayers(of:) 메서드를 구현하며 UIView의 layoutSubviews() 메서드를 호출합니다:
extension UIView: CALayerDelegate {
func layoutSublayers(of layer: CALayer) {
layoutSubviews()
}
}layoutSubviews()는 UIKit이 Auto Layout을 지원하도록 돕기 위한 것으로, layout 단계에 Constraints pass라는 추가 단계를 더합니다. 뷰 트리를 leaf subview에서 root view까지 bottom-up 방식으로 탐색하고, needsUpdateConstraints()가 true를 반환하면 뷰의 updateConstraints() 메서드를 호출합니다.

Constraints pass는 layoutSublayers 탐색과 반대 방향(bottom-up)으로 진행됩니다.
Display 단계

Display 단계 중에 Core Animation은 각 layer의 display() 메서드를 상단에서 하단으로 호출합니다.
- Layer의
display()의 기본 구현은 delegate 객체의display(_ layer:)메서드를 호출합니다. - 그렇지 않으면 backing store(픽셀 비트맵)를 생성하고 CALayer의
draw(in ctx: CGContext)를 호출합니다. - UIView는
display(_ layer:)를 구현하지 않지만draw(layer, in ctx: CGContext)는 구현하며, 거기서 UIView의draw(rect:)메서드를 호출합니다. - UIView의
draw(rect:)기본 구현은 아무것도 하지 않지만 서브클래스에서 커스텀 드로잉을 구현하기 위해 오버라이드할 수 있습니다.

100개의 타원을 그려 토러스(torus) 모양을 만드는 예제:

override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.setLineWidth(1.0)
context.setStrokeColor(UIColor.orange.cgColor)
context.translateBy(x: frame.size.width / 2.0, y: frame.size.height / 2.0)
let numberOfEllipses = 100
let amount = Double.pi * 2 / Double(numberOfEllipses)
for _ in 1...numberOfEllipses {
context.rotate(by: CGFloat(amount))
let rect = CGRect(x: -(frame.size.width * 0.25),
y: -(frame.size.height / 2.0),
width: frame.size.width * 0.5,
height: frame.size.height)
context.addEllipse(in: rect)
}
context.strokePath()
}UILabel은 display 단계와 draw(rect:)를 사용하여 텍스트를 그립니다. label에 대한 draw(rect:) 메서드를 오버라이드하면 텍스트가 그려지지 않는 것을 볼 수 있습니다:
class CustomLabel: UILabel {
override func draw(_ rect: CGRect) {
// super.draw(rect) - 주석 처리 시 텍스트가 사라짐
}
}Core Graphics 드로잉은 CPU에 의해 수행됩니다. Core Graphics는 CALayer backing store에 그린 후 나중에 GPU로 전송됩니다.

CATransaction에는 두 가지 단계가 더 있습니다:
- Prepare 단계: 이미지 디코딩(JPG, PNG) 및 이미지 변환 같은 추가적인 Core Animation 작업을 수행합니다.
- Commit 단계: layer tree를 패키징하여 transaction을 render tree에 적용하기 위해
Render server로 전송합니다.

Render Server
Transaction이 commit되면 IPC(프로세스 간 통신)를 통해 Render server 프로세스로 전송됩니다.
Render server는 간단히 Core Animation 백엔드로 이해할 수 있습니다.
애플리케이션을 디버깅할 때 앱이 일시 정지되어 있어도 progress indicator가 계속 회전하는 것을 본 적 있나요? 이제 그 이유를 알 수 있습니다 — 다른 프로세스인 Render Server에서 돌아가고 있기 때문입니다.
Render server가 transaction을 수신하면 render tree에 업데이트를 적용하기 위해 이를 디코딩합니다. 모든 디스플레이 업데이트 iteration에서 render server는:
- 관련 애니메이션이 진행 중인 경우 render tree의 layer 프로퍼티에 대한 중간 값을 계산합니다.
- Metal 명령을 사용하여 layer tree 렌더링을 수행합니다.
GPU에서 렌더링의 결과물은 화면에 표시될 픽셀 비트맵입니다.

정리

UIApplicationMain
main()은 앱 실행 시 운영체제 runtime 환경에 의해 호출되는 앱의 entry point입니다.UIApplicationMain()은 앱 설정을 관리하고 main event loop — Runloop를 실행합니다.
Runloop
- 앱 이벤트를 관리하는 무한 사이클
- 이벤트 처리를 관리합니다: GCD main queue block, Timer, Source (version 0과 1), Observer
CoreAnimation
- UIKit은 내부적으로 UI 관련 작업을 Core Animation에 위임합니다.
- UIView는 CALayer에 의해 지원되며, CALayer가 렌더링, 레이아웃, 애니메이션을 처리합니다.
- CoreAnimation은 병렬로 세 가지 layer tree를 유지합니다: model tree, presentation tree, render tree.
kCFRunLoopBeforeWaiting이벤트를 관찰하고 layer tree가 변경된 경우 implicitCATransaction을 시작합니다.
CATransaction
- 여러 layer-tree 작업을 render tree에 대한 원자적 업데이트로 결합합니다.
- Explicit transaction은
begin과commit메서드로 수동으로 생성할 수 있습니다. - Transaction은 중첩될 수 있으며, 중첩된 transaction의 변경 사항은 부모 transaction이 commit될 때만 적용됩니다.
- Transaction은 네 가지 단계로 구성됩니다: Layout, Display, Prepare, Commit.
Render Server
- Render server는 render tree를 관리하며 애니메이션과 렌더링을 담당합니다.
- Metal 명령을 사용하여 render tree 렌더링을 수행합니다.

- GPU 렌더링의 출력은 화면에 표시될 픽셀 비트맵입니다.
보너스
전체 흐름의 완전한 다이어그램:

UIKit에 관한 최고의 책은... 사실 Core Animation에 관한 책입니다 🤣
iOS Core Animation: Advanced Techniques by Nick Lockwood — 2013년에 출판되었지만 UIKit을 마스터하고 싶다면 여전히 관련성이 높습니다.