SwiftUI에서 View가 점점 크고 복잡해지면 당연히 성능적으로 영향을 주기 마련이다. 특히 UIKit과 달리 SwiftUI에서는 Data Model의 State 변화에 따라 View를 재렌더링 하기 때문에 예상치 못한 또는 불필요한 Data Model의 변화로 렌더링 성능에 영향을 줄 수 있으므로 View의 구조를 정확하게 이해하고 성능을 최적화하는 것이 중요하다.
이번 포스트에서는 SwiftUI의 View 렌더링 성능 개선 방법에 대해 알아본다.
struct DogView: View {
@Environment(\.isPlayTime) private var isPlayTime
var dog: Dog
var body: some View {
Text(dog.name)
.font(nameFont)
Text(dog.breed)
.font(breedFont)
.foregroundStyle(.secondary)
ScalableDogImage(dog)
DogDetailView(dog)
LetsPlayButton()
.disabled(dog.isTired)
}
}
가령 위와 같은 심플한 형태의 SwiftUI View가 있다고 가정하자. isPlayTime은 @Environment property wrapper를 통해 종속성을 외부에서 주입받고 있는 동적 프로퍼티이고, dog 프로퍼티는 부모 View를 통해 종속성을 주입받고 있는 프로퍼티이다.
또한 DogView 하위에는 dog 프로퍼티의 종속성을 가지는 또 다른 여러 View가 계층을 이루고 있다.

DogView의 값이 변경 되어 View가 업데이트 되는 과정을 살펴보자. View가 업데이트 되는 첫번째 과정은 새로운 값을 View에 생성해 주는 것이다. 이때 새로운 값은 동적 프로퍼티와 프로퍼티를 모두 포함한다. 그 다음 SwiftUI는 View가 가지고 있는 모든 동적 프로퍼티를 업데이트하여 새로운 값으로 바꾼다. 마지막으로 업데이트된 모든 동적 프로퍼티를 이용하여 body 프로퍼티를 실행하게 된다.

여기서 중요한 점은 값이 변경되면 값의 복사본이 만들어지게 되고 하위에 해당 값의 종속성을 가진 자식 View들도 연쇄적으로 함께 업데이트 된다는 점이다. 가령, 위의 그림에서는 dog 값의 종속성을 가지는 VStack, ScalableDogImage 등의 하위 View가 값의 변화로 새로운 렌더링이 생성된다.
이러한 SwiftUI의 View 업데이트 과정을 통해 알 수 있는 렌더링 성능을 향상 시키는 방법은 View의 업데이트 수를 줄여 재렌더링 횟수를 감소시키는 것이다. 다시 말해, 값의 변경이 반드시 필요한 경우에만 View를 업데이트 해야한다. SwiftUI View에는 언제, 어떤 데이터가 변경되어 View가 업데이트 되는지 디버깅할 수 있는 함수를 제공한다.
struct ScalableDogImage: View {
@State private var scaleToFill = false
var dog: Dog
var body: some View {
let _ = Self._printChanges()
dog.image
.resizable()
.aspectRatio(contentMode: scaleToFill ? .fill : .fit)
.frame(maxHeight: scaleToFill ? 500 : nil)
.padding(.vertical, 16)
.onTapGesture {
withAnimation { scaleToFill.toggle() }
}
}
}
위의 코드와 같이 Self._printChanges()를 사용하면 View가 업데이트될 때 마다 업데이트에 영향을 미치는 동적 프로퍼티를 콘솔에 출력한다.
_printChanges()함수명 맨 앞의 underscore를 통해 알 수 있듯, 향후 릴리즈 버전에서 사라질 가능성이 있는 함수이다. 따라서 해당 코드는 디버깅 시에만 사용 후 제거해야 한다.
앞서 언급했다시피, SwiftUI에서는 값이 변경되거나 종속성에 변화가 있는 View만을 업데이트하게 된다. 따라서 성능 개선을 위해서는 불필요한 종속성을 분리하고 해당 View에 필요한 종속성만을 가지도록 하여 View 업데이트 시 불필요한 업데이트가 발생하지 않도록 하여 성능을 개선할 수 있다.
struct DogView: View {
@Environment(\.isPlayTime) private var isPlayTime
var dog: Dog
var body: some View {
DogHeader(name: dog.name, breed: dog.breed)
ScalableDogImage(dog.image)
DogDetailView(dog)
LetsPlayButton()
.disabled(dog.isTired)
}
}
이 코드는 위에서 살펴 본 코드를 리팩토링 한 코드이다. dog.name, dog.breed, dog.image 등을 통해 View에서 직접적으로 필요한 종속성만을 넘겨주도록 리팩토링 하였고, 이를 통해 각 하위 뷰는 필요한 데이터에만 의존하게 되어, 불필요한 데이터의 변경으로 발생하는 View의 업데이트를 최소화할 수 있다.
동적 프로퍼티에 상태 객체를 할당하거나 상태를 초기화할 때, 객체 인스턴스화에 많은 연산이 필요하여 시간이 걸리는 경우 View 업데이트가 느려질 수 있다.

가령, 위의 코드는 동적 프로퍼티인 model에 클래스의 인스턴스를 초기화하면서 시간이 걸리는 fetchDogs()를 호출하고 있다. 이러한 초기화 과정은 fetchDogs() 메서드가 완료될 때까지 동기적으로 수행되며, 동적 프로퍼티는 메서드가 완료될 때까지 초기화 되지 않기 때문에 View의 업데이트가 느려질 수 있다. 따라서 이러한 메서드는 .onAppear() 또는 async / await 문법을 이용하여 .task() 등의 SwiftUI View 생명주기와 연관된 함수를 통해 반응형으로 View를 업데이트하는 것이 적절하다.
View의 body 내부에서 많은 연산을 요구하는 문자열 보간이나 데이터 필터링 등의 작업을 수행하는 경우 View 업데이트가 느려질 수 있다. body 내부에는 가능한 최소한의 연산만을 수행해야 한다.
SwiftUI List View에서 Identity(식별자)는 List View의 생명주기를 관리하는데 중요한 역할을 한다. 식별자가 변경되었다는 것은 곧 View가 바뀌었다는 말이기 때문이다. 다시말해, 식별자는 List View의 성능과 연관된다는 말이기도 하다.
List {
ForEach(dogs) {
DogCell(dog: $0)
}
}
SwiftUI에서 List를 생성할 때 위의 코드와 같이 ForEach를 사용하여 반복되는 코드를 단순화하여 표현할 수 있다. 하지만 List 내부에 ForEach를 사용하면 성능적으로 주의해야 할 부분이 있다.
struct ForEach<Data, ID, Content> where Data : RandomAccessCollection, ID : Hashable
ForEach의 선언부와 함께 List가 내부적으로 어떻게 동작하는지 알아보자. ForEach는 Content 클로저를 통해 도출되는 View에 데이터를 매핑하고, 각각의 View에 명시적 식별자를 할당한다. List는 이 식별자와 Content를 합성하여 행을 생성한다.
SwiftUI에서
List의 행은 UIKit에서UITableView의UITableViewCell과 동일하게 메모리를 호율적으로 사용하기 위해 필요할 때 마다 생성한다.
List {
ForEach(dogs) { dog in
if dog.type = .retriever {
DogCell(dog: dog)
}
}
}
만약 위의 코드와 같이 조건에 해당하는 행만을 표시하고자 한다고 해보자. 하지만 성능적인 측면을 생각했을 때 위의 코드는 효율적이지 못한 코드이다. 왜냐하면 List가 표시할 행의 식별자를 가져오기 위해서는 여전히 모든 View를 다 빌드할 수 밖에 없기 때문이다.
List {
ForEach(dogs.filter{ $0.type == .retriever) { dog in
DogCell(dog: dog
}
}
위에서 언급했다시피, 이 문제를 해결하기 위해 body 내부에서 연산을 수행하는 것 또한 컬렉션 크기에 따라 성능에 영향을 줄 수 있으니 좋지 않은 방법이다. 따라서 이러한 연산은 Model 영역으로 빼서 연산하는 것이 좋다.