함께 공부하는 분께 SwiftUI를 선호하는지 UIKit를 선호하는지 여쭤본 적이 있다. 그분의 대답은 “SwiftUI보다 UIKit를 더 선호한다. SwiftUI는 데이터에 따라 뷰가 재 렌더링 되는 것을 관리하기 까다로운 것 같다” 이었다.
나는 어떨까 생각해 보니 성능적인 것보다는 뷰를 만들기 직관적이고 쉽다는 측면으로 SwiftUI를 선호했던 것 같다. 그래서 최근에 SwiftUI 뷰에 대한 최적화 방법은 무엇이 있을까 여러 방면으로 공부해 보고 테스트해 보았다. 그 와중에 보게 된 WWDC23! 뷰를 최적화하기 위해서는 일단 SwiftUI가 어떤 식으로 동작하는지 정확히 알아야 한다는 것을 다시금 깨닫게 해 주었고. 놓치기 쉬운 지점까지 다시 생각하는 기회가 되어 너무 좋았다! SwiftUI 프로젝트를 하고 있다면 보는 거 추천!
WWDC21 에서 Demystify SwiftUI를 보고 보는 것도 좋을 것 같다 ( 뷰를 그리는데 기반이 되는 identity에 대한 내용을 주로 다루는데, 뷰와 식별자, 그리고 데이터에 대한 생애주기를 중심으로 SwiftUI가 뷰를 어떻게 처리하는지 에 대한 내용이 담겨있다.)
WWDC23 Demystify SwiftUI performance 세션에서는 아래 세 가지가 어떻게 동작하는지 설명하고 어떤 식으로 코드를 개선하는 게 좋은지에 대한 인사이트를 제공한다.
Dependencies
Faster view update
Identity in List and Table
우선 이 글에서는 Dependencies에 대한 내용을 중심으로 다루고 다른 주제들도 천천히 글을 써보겠다.
📍성능에 대한 문제를 인지하고 해결해 가는 과정
우리는 아래와 같은 증상(symptom)을 통해 성능에 대한 문제를 인지한다. (네비게이션 화면 전환이 늦거나, 애니메이션이 제대로 안되거나, macOS의 경우 커서가 스피닝으로 지속되는 것에 대한 증상 )
이런 성능에 대한 문제를 파악했을 때 문제를 측정(Measure) 해서 → 원인을 식별(Identify cause) 하고 → 원인을 해결하는 최적화(Optimzation)를 통해 인식한 증상에 대한 것을 고쳐야 한다. 만약 최적화를 했음에도 제대로 해결되지 않았다면? 이러한 과정을 반복(loop)해야 하며 이런 반복과정은 일반 버그에서도 중요하지만, 특히나 성능적인 문제에서 중요하다고 한다! 그리고 문제가 해결된 걸 검증한 후에는 반복을 끝낸다(break the loop)
그냥 직감이나 육안으로 확인하는 것이 아니라 실제로 “측정”하고 “원인을 파악”하고 원인에 대한 “최적화”방안을 설계하여 해결해 나가는 게 중요한 것이다. 근데 어떻게 성능에 대한 문제를 확실히 식별할 수 있는데…?
개인적으로도 어떤 "동작"이 안 되는 것을 해결하는 건 비교적 쉽지만, "성능" 문제에 대한 원인을 파악하고 개선하는 건 훨씬 더 생각을 많이 해야 하고, 어려운 부분이라고 생각한다. 이 세션에서는 많은 해결법에 대한 도구를 알고 있으면 위에서 말하는 루프를 끝내고 문제를 해결하기 수월하며, 이 세션에서는 그런 것들을 다룬다고 말한다. 이 세션은 대체적으로 성능적인 면 중에서도 view hierarchy가 느리게 업데이트되는 것에 대한 관점이 많이 들어가 있다.
📍Dependencies
드뎌 본격적인 내용!
여기서 말하는 종속성/의존성이란 각 뷰에 들어가는 데이터 혹은 입력값이라고 이해하면 쉬울 것 같다. 왜 성능을 이야기하는데 Dependency를 말하냐! SwiftUI에서는 데이터를 기반으로 뷰가 업데이트되기 때문에 그 뷰가 어떤 데이터에 종속성을 가지는지에 따라 뷰의 업데이트 횟수가 달라진다. 그렇기 때문에 Dependency가 중요한 것!
DogView에서는 dog라는 파라미터와 isPlayTIme이라는 environment property가 있다.
→ 이 뷰에서의 의존성은 dog와 isPlayTIme인 것이다.
📍Dependency graph
DogView라는 뷰를 종속성 그래프로 표현할 수 있다. 화살표를 따라가면 각각의 body 안에 갖는 뷰를 나타내는데 모든 뷰들을 더 이상 하위뷰가 없을 때까지의 뷰(여기서는 leaf view라고 표현한다)를 이렇게 그래프로 표현할 수 있다.
“Let’s play” 버튼을 눌렀을 때 데이터가 바뀌면서 아래처럼 뷰가 업데이트되었다고 가정해 보자. 데이터의 변화가 있을 경우 Depency praph는 어떤 식으로 동작할까?
DogView에서는 그의 부모 뷰로부터도 dog라는 프로퍼티를 받고 environment로부터 도 isPlayTIme이라는 값을 받는다. 데이터들이 어디서부터 왔는지 봤다면! 이제 어느 순서로 업데이트되는지 보자! x축을 시간으로 놓고 시간 흐름별 업데이트되는 것들을 보면
1️⃣ 뷰의 새로운 값을 만든다
새로운 값이라는 것은 부모로부터 받은 dog 프로퍼티를 포함해서 뷰에 포함된 모든 저장프로퍼티가 될 수도 있다!
2️⃣ dynamic 프로퍼티들을 업데이트한다
environment 프로퍼티 같은 환경변수도 dynamic property!
3️⃣ 뷰 업데이트
이렇게 업데이트된 값들을 가지고 하위뷰들을 생성하기 위해 body를 실행한다
이런 프로세스는 UI를 업데이트하기 위해 실행되고, 위에서 본 Dependency graph에서 새 값이나 변경된 종속성이 있는 뷰만 업데이트한다. 종속성을 가진다는 건 데이터에 변경사항이 있을 경우 이렇게 복잡한 업데이트 과정을 거치게 된다는 것이다! 그러니 뷰의 성능을 위해종속성을 잘 관리해 주어야겠죠?
“Let’s play” 버튼을 누르면서 Dog 모델이 바뀌게 되고 dog에 대항 종속성이 있는 하위뷰들은 모두 업데이트될 것이고, 예를 들어 ScalableDogImage가 업데이트된다.
가장 마지막 하위뷰(leaf view)인 image에 도달하면 비로소 SwiftUI는 멈춘다
이게 바로 Dependency Graph를 보는 방법이다
그렇다면, 데이터의 변화가 있을 때에 뷰를 업데이트시켜주면서 일어나는 이런 일련의 과정들을 어떻게 개선시켜 줄 수 있을까? 그림으로 보면서 더 쉽게 눈치챘을 수도 있는데 필요할 때만 업데이트시켜줄 수 있도록 업데이트되는 요소(종속성)들을 관리하는 것이 중요하다!
여기서 팁! 뷰가 언제 업데이트되는지 뷰 업데이트 시점을 알기 위해서 사용할 수 있는 메서드가 있는데, 바로 printChanges 이다.
이 메서드를 body 안에서 실행하면 SwiftUI graph evaluator가 뷰의 body를 호출한 이유를 출력해 준다. (SwiftUI는 코드를 평가할 때 종속성을 바라보고 있다고 앞서 말했으니 → 어떤 종속성이 바뀌었을 때 뷰가 다시 그려지는지를 알려주는 메서드라고 미루어 짐작할 수 있다.) 그래서 이러한 출력을 지원하는 메서드를 사용해서 필요 없는 의존성이 있는지 확인할 수 있다
하지만 주의할 점!
printChanges is a debugging-only facility 세션에서도 “디버깅 전용 기능” 이라고 말하고 있고, 향후 릴리즈 버전에서는 제거될 수 있는 메서드이기 때문에 디버깅 용도로만 사용하고 앱스토어에는 올라가지 않도록 하라고 권고한다. 그리고 런타임에 영향을 끼칠 수도 있다고 한다.
화면에서 Favorite Treat에 대한 데이터가 Biscuit에서 → Cucumber으로 바뀐다고 했을 때 ScalableDogImage의 body 내부에서 실행한 printChange의 결과가 아래와 같다. 왜 그럴까? ScalableDogImage에서 dog라는 프로퍼티를 넘겨받는데 그 안에 treat이라는 게 바뀌었기 때문에 해당 뷰는 dog데이터에 대한 종속성을 가지고 있으므로 body가 다시 실행되는 것이다.
근데 생각해 보면 이미지에 대한 뷰인 ScalableDogImage는 treat이라는 데이터와 관련이 없는데…?
ScalableDogImage 이 뷰에서는 dog 데이터 중에서도 .image에 대한 데이터만 사용하고 있다. 그니까 dog.treat에 대한 데이터가 바뀐 것에도 뷰 업데이트가 되면 필요 없는 종속성을 가지고 있는 것!
ScalableDogImage는 Image에 대한 데이터만 바라볼 수 있도록, 즉, 종속성을 축소하는 것으로 코드를 개선할 수 있다.
📍뷰를 업데이트하는 팁
뷰에 필요하지 않은 종속성은 제거하자 ( Eliminate unnecessary dependencies )
=> 필요한 데이터에만 의존하자!
필요할 경우에는 위해 뷰를 추출하자 ( Extract views id needed )
아래에서 예시 코드로 설명할 텐데, 필요할 때만 업데이트되도록 뷰의 효율적인 리렌더링을 위해 하위뷰를 쪼개고 뷰 별로 필요한 종속성만 가지게 하는 것을 여기서도 말하고 있는 것 같다.
swift 5.9에서 나온 @Observable 사용해 보자 ( Explore using Observable)
swift 5.9에서 나온 @Observable 매크로가 종속성 범위지정(dependency scoping)에도 도움이 될 수 있다
( @Observable이 뭘까 찾아보다가 ObservableObject를 마이그레이션 할 수 있을 만큼 정말 유용하게 사용될 수 있겠다는 생각을 했는데, 나중에 실제 사용해 보고 공유하겠다!)
📍Extract views if needed
바로 위의 "뷰를 업데이트하는 팁"에서도 말한 것처럼 필요한 종속성만 가져서 뷰의 업데이트가 효율적으로 이루어질 수 있도록 하는 게 중요하다. 이렇게 하기 위해서는 하위뷰를 효율적으로 나누어 놓는 것도 도움이 되는데 이해를 돕기 위해 예시 코드를 가져와보았다.
> 하위 뷰 나누기 전
이렇게 하면 어떤 컬럼의 버튼이 눌리든 세 컬럼의 버튼들 (30개)가 모두 리로드 된다. 한 컬럼의 selectedNumber만 바뀌었음에도 모두 다시 로드된다. (background의 랜덤 컬러가 바뀌는 것으로 알 수 있다.)
var count = 0
extension Color {
static var random : Color {
get {
Color(red: .random(in: 0..<1), green: .random(in: 0..<1), blue: .random(in: 0..<1))
}
}
}
struct SwiftUIPerformanceView: View {
@State private var column1_selectedNumber : Int = 0
@State private var column2_selectedNumber : Int = 0
@State private var column3_selectedNumber : Int = 0
var body: some View {
count += 1
let _ = print("📍 body reload", count)
return HStack {
//1
VStack {
Text("column1 : \(String(column1_selectedNumber))")
ForEach(0..<10) { element in
let _ = print("column1 ForEach -> ", element)
Button {
column1_selectedNumber = element+1
} label : {
Text(String(element+1))
}
}
}
.background(Color.random)
//2
VStack {
Text("column2 : \(String(column2_selectedNumber))")
ForEach(0..<10) { element in
let _ = print("column2 ForEach -> ", element)
Button {
column2_selectedNumber = element+1
} label : {
Text(String(element+1))
}
}
}
.background(Color.random)
//3
VStack {
Text("column3 : \(String(column3_selectedNumber))")
ForEach(0..<10) { element in
let _ = print("column3 ForEach -> ", element)
Button {
column3_selectedNumber = element+1
} label : {
Text(String(element+1))
}
}
}
.background(Color.random)
}
}
}
> 하위 뷰 나눈 후
데이터 변경에 따라 필요한 뷰만 리로드 되어서 불필요한 뷰의 재렌더를 막을 수 있다.
var count = 0
extension Color {
static var random : Color {
get {
Color(red: .random(in: 0..<1), green: .random(in: 0..<1), blue: .random(in: 0..<1))
}
}
}
struct SwiftUIPerformanceView: View {
@State private var column1_selectedNumber : Int = 0
@State private var column2_selectedNumber : Int = 0
@State private var column3_selectedNumber : Int = 0
var body: some View {
count += 1
let _ = print("📍 body reload", count)
return HStack {
ButtonListColumnView(columNum : 1, selectedNumber: $column1_selectedNumber)
ButtonListColumnView(columNum : 2, selectedNumber: $column2_selectedNumber)
ButtonListColumnView(columNum : 3, selectedNumber: $column3_selectedNumber)
}
}
}
struct ButtonListColumnView : View {
var columNum : Int
@Binding var selectedNumber : Int
var body: some View {
let _ = print("📍 \(columNum) child view reload " )
let _ = Self._printChanges()
VStack {
Text("column\(columNum) : \(String(selectedNumber))")
ForEach(0..<10) { element in
let _ = print("ForEach -> ", element)
Button {
selectedNumber = element+1
} label : {
Text(String(element+1))
}
}
}
.background(Color.random)
}
}
📍요약
뷰의 업데이트 횟수는 SwiftUI의 성능에 영향을 끼친다.
뷰의 body가 업데이트되는 횟수를 줄이기 위해서는 필요한 경우에만 뷰가 업데이트될 수 있도록 뷰에 필요한 종속성(dependencies)만 가지도록 설계해야 한다.
Dependency라는 주제를 통해 각각의 뷰에서 종속성을 잘 관리해야겠구나라고 생각하게 되었고, 하위뷰를 잘 나누는 게 코드의 가독성을 위해서도 중요하지만 뷰에서 필요한 데이터에만 종속성을 가짐으로써 필요 없는 뷰의 업데이트를 줄인다는 점에서도 중요하겠구나라고 생각하게 되었다.
필요한 종속성만 하위뷰에 넘기기로 하고, 귀찮다고 모델을 통째로 넘기지 말자 ( 셀프 다짐 ) ㅎㅎ