LODY/정리

애플 플랫폼 백과사전 / SwiftUI 에서 escaping closure 를 쓰지 말아야 하는 이유

SwiftUI 에서 escaping closure 를 쓰지 말아야 하는 이유

원문: Don't use escaping closures in SwiftUI — rensbr.eu 저자: Rens Breur 발행: 2022년 5월 1일

SwiftUI 의 stack, ScrollView 같은 container view, 그리고 Button 같은 많은 control 은 전부 @ViewBuilder 를 써서 어떤 view 를 표시할지 지정하게 되어 있다. 덕분에 view 가 계층 구조를 가질 수 있고, 다른 view 들을 조합해 새로운 view 를 정의할 수 있다.

우리도 @ViewBuilder 를 직접 쓸 수 있다. 간단한 예로, 내용을 보여주거나 숨길 수 있는 view 를 생각해 보자.

struct Collapsable<Content: View>: View {
  @State var collapsed = false
 
  init(@ViewBuilder content: () -> Content) {
    // ...
  }
 
  var body: some View {
    VStack {
      if !collapsed {
        // content...
      }
      Button(collapsed ? "↓ open" : "↑ close") {
        collapsed.toggle()
      }
    }
  }
}

이 view 는 이렇게 쓸 수 있다.

var body: some View {
  // ...
  Collapsable {
    ExtraOptions()
  }
}

Collapsable view 를 만들 때 한 가지 선택지가 생긴다. view 를 반환하는 closure 를 바로 평가해서 content 를 view 에 저장하거나, closure 를 @escaping 으로 받아서 저장해 두고 body 에서 필요할 때 평가하는 방법이다.

// Approach 1
struct Collapsable<Content: View>: View {
  let content: Content
 
  init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }
 
  // ...
}
 
// Approach 2
struct Collapsable<Content: View>: View {
  let content: () -> Content
 
  init(@ViewBuilder content: @escaping () -> Content) {
    self.content = content
  }
 
  // ...
}

많은 개발자들이 Approach 2 를 더 선호하는 것 같다. 그쪽이 더 효율적이라고 느끼기 때문이다. 겉으로는 더 효율적으로 보일 수 있다. content 가 필요할 때만 만드는 것처럼 보이니까. 하지만 따지고 보면 이쪽이 오히려 덜 효율적이고, 심지어 원치 않는 side effect 까지 생길 수 있다. 이 글의 나머지 부분에서 왜 그런지 살펴보자.


SwiftUI view 는 함수다

우리는 closure 를 함수라고 생각한다. 그런데 SwiftUI view 도 개념적으로는 함수 다. 입력은 view 안에 정의된 변수들이다. 일부는 초기화 때 설정되는 상수이고, 일부는 @State 같은 property wrapper 로 감싸져 SwiftUI 가 관리한다. 함수로 본 SwiftUI view 의 출력은 body 다. 우리가 만든 SwiftUI view 는 자기 스스로 어떤 일을 하지 않는다. 상태는 전부 SwiftUI 가 관리하고, 프레임워크가 우리 view 를 부르는 유일한 순간은 입력 중 하나가 바뀌어서 그 입력에 대응되는 출력 view 가 뭔지 알아야 할 때다.

Closure 와 SwiftUI view 의 런타임 메모리 표현을 비교해 보면 둘이 굉장히 비슷하다. 런타임의 SwiftUI view 는 초기화에 쓰인 상수·변수들의 묶음에 body 로의 참조 하나가 붙은 형태다. (사실 view 를 직접 쓰는 경우에는 body 참조조차 필요 없고, 바로 호출한다.) 런타임의 closure 도 캡처한 파라미터들의 묶음에 code block 으로 만들어진 함수의 참조가 붙은 형태다.

SwiftUI 가 closure 대신 struct 를 쓰는 이유 중 하나는 SwiftUI 가 외부에서 관리하는 변수를 선언할 수 있어야 하기 때문이다. 또 다른 이유는, struct 를 쓰면 view body 를 꼭 필요한 횟수만 평가하도록 무거운 최적화를 걸 수 있기 때문이다. 함수 관점에서 보면 이런 최적화에는 이미 이름이 붙어 있다. 예를 들어 변수가 바뀌지 않았으면 SwiftUI 는 view 의 body 를 평가하지 않는다. 파라미터가 바뀌지 않았을 때 함수를 평가하지 않는 기법을 memoization 이라고 부른다.


Closure 없이도 lazy 하다

View 와 함수의 비유가 왜 유용한지, 그리고 Approach 1 이 성능에 나쁘지 않은 이유를 이해하려면 NavigationLink 와 destination 지정 방식을 살펴보는 게 도움이 된다.

NavigationLink("Details", destination: DetailsView())

이렇게 하면 NavigationLink 가 화면에 나오는 순간에 이미 DetailsView 가 만들어진 것 아닌가? 비효율적이지 않나? 그렇지 않다. destination 은 view 니까 이미 함수다. 그 출력인 body 는 실제로 화면에 표시될 때까지 평가되지 않는다. SwiftUI view 는 굉장히 가볍다.

더 복잡한 DetailView — 예를 들어 초기화할 때 네트워크 요청을 시작하는 view model 을 가진 — 에서는 얘기가 달라지지 않을까 싶겠지만, 그래서도 안 된다. ObservableObject 는 바깥에서 주입되는 존재이고, view 가 @StateObject property wrapper 를 통해 model object 를 쓴다면 그 model object 도 view 가 실제로 표시될 때까지 만들어지지 않는다. StateObjectautoclosure 로 초기화되는 이유 중 하나가 이것이다.

NavigationLink 의 destination 이 표시될 때까지 평가되지 않는 것과 마찬가지로, Approach 1 에서 Collapsable 의 content 도 실제로 표시될 때까지 평가되지 않는다. 두 접근 모두 content 가 쓰는 변수들을 저장하고, 그 변수에 대해 어떤 body 를 계산할지 알고 있어야 한다. closure 를 저장해도 얻는 게 아무것도 없다.


보지 않는 세계도 존재하는가

Approach 2 는 content 를 closure 로 저장해 두고 body 에서 평가한다. SwiftUI 가 view 에 적용하는 최적화 때문에, 이 한 단계의 indirection 이 원치 않는 흥미로운 side effect 를 만들어 낸다.

먼저 Approach 1 으로 쓴 Collapsable 의 동작부터 확인해 보자. 처음엔 접힌 상태로 시작하도록 했다.

struct Collapsable<Content: View>: View {
  @State var collapsed: Bool = true
  let content: Content
 
  init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }
 
  var body: some View {
    VStack {
      if !collapsed {
        content
      }
      Button(collapsed ? "↓ open" : "↑ close") {
        visible.toggle()
      }
    }
  }
}

그리고 아래처럼 써 보자. 카운터 값을 접거나 펼칠 수 있고, 버튼으로 값을 증가시킬 수 있다.

struct ContentView: View {
  @State var value: Int = 0
 
  var body: some View {
    VStack {
      Collapsable {
        Text("Value: \(value.value)")
      }
      Button("Increase") { value.increase() }
    }
  }
}

모두 정상적으로 동작한다. 버튼을 누르면 state 가 갱신되고, 카운터 값을 숨기거나 보일 수 있다.

이제 같은 예제에 Approach 2 로 구현한 Collapsable 을 써 보자.

struct Collapsable<Content: View>: View {
  @State var collapsed: Bool = true
  let content: () -> Content
 
  init(@ViewBuilder content: @escaping () -> Content) {
    self.content = content
  }
 
  var body: some View {
    VStack {
      if !collapsed {
        content()
      }
      Button(collapsed ? "↓ open" : "↑ close") {
        visible.toggle()
      }
    }
  }
}

이 구현에서는 glitch 가 생긴다. 평소에는 잘 동작하는데, Collapsable 을 한 번도 펼치기 전에 카운터 값을 먼저 증가시킨 뒤 그제서야 펼치면 엉뚱하게 0 이 표시된다. 한 번 더 state 를 증가시키면 0 에서 현재 카운터 값으로 점프한다.

이 버그의 근본 원인은 또 다른 SwiftUI 최적화에 있다. body 평가에 쓰이지 않는 @State 변수는 바뀌어도 view 재평가를 트리거하지 않고, SwiftUI 내부에서 관리하는 view property 값 갱신도 트리거하지 않는다. 영리한 최적화이고 memoization 과도 잘 맞물린다. 입력 변수가 body 에서 쓰이지 않으면 그 변수가 바뀌어도 view 를 재평가할 필요가 없고, 쓰일 때만 변경됐을 때 재평가하면 되기 때문이다.

그런데 지금 이 경우엔 제대로 동작하지 않는다. body 가 처음 평가될 때 텍스트가 접혀 있으면 state 변수가 사용되지 않기 때문에 body 평가에 필요 없다고 판단된다. 그런데 펼치는 순간 갑자기 state 를 읽게 된다. ContentView 자체는 재평가되지 않았고, @State 는 제대로 준비되어 있지 않다.

content 를 closure 로 유지한 채 이 버그를 피하는 방법도 있긴 하지만, 그건 SwiftUI 의 최적화를 활용하는 게 아니라 오히려 최적화와 싸우는 셈이다.


Closure 를 비교한다는 것

Collapsable 의 body 가 어떻게 평가되는지 살펴보면, Approach 2 가 memoization 을 막는 두 번째 방식이 보인다. Collapsable 의 body 에 print 를 넣어 확인할 수 있다 (view invalidation 은 SwiftUI 내부 동작이고 아주 예상 밖으로 동작하기도 하니 주의할 것).

var body: some View {
  let _ = print("Evaluating body")
  // ...
}

ContentView 에 무관한 state 변수 하나를 추가하고, 그 state 를 표시하고 바꿀 수 있는 버튼을 붙인다.

struct ContentView: View {
  @State var value: Int = 0
  @State var unrelated: Int = 0
 
  var body: some View {
    VStack {
      Collapsable {
        Text(value: "\(value)")
      }
      Button("Increase") { value += 1 }
      Button("Unrelated state (\(unrelated))") { unrelated += 1 }
    }
  }
}

이제 ContentView 를 강제로 재평가시킬 수 있다.

Approach 1 의 Collapsable 에서는 "Increase" 버튼을 눌렀을 때만 "Evaluating body" 가 찍힌다. 하지만 Approach 2 에서는 두 번째 버튼을 눌러도 Collapsable 의 body 가 재평가된다. memoization 이 깨진 건데, 왜 그런가?

이 경우 이유는 간단하다. SwiftUI 가 memoization 을 하려면 입력을 비교할 수 있어야 한다. 그런데 closure 는 Equatable 도 아니고 다른 방식으로 비교할 수도 없다. 지난번 body 평가 이후 이 closure 가 바뀌었는지 확인할 방법이 없다.


마무리

물론 closure 를 써야 할 때도 있다. ForEach 같은 view 는 파라미터를 받아서 closure 를 저장해야 한다. 그 외에는 쓰지 말자. view 대신 view 를 반환하는 closure 를 저장하는 건 최적화가 아니다. view 자체가 함수이고, 아주 가볍다. 그리고 SwiftUI 가 최적화를 가장 효율적으로 걸 수 있게 해 준다. initializer 에서 closure 를 받는 view 는 좋은 문법과 @ViewBuilder 지원을 위해서 쓰되, initializer 안에서 바로 평가해서 view 를 저장하자.