SwiftUI View Body instrument 사용해보기

피터·2025년 11월 28일

Instruments

목록 보기
3/3
post-thumbnail

https://developer.apple.com/tutorials/instruments/reducing-main-thread-work-by-doing-less
위 튜토리얼을 공부하며 정리한 내용입니다.

SwiftUI에서 View는 구조체(struct)입니다. 뷰를 힙 메모리에 무겁게 유지하는 대신, 스택에 가볍게 올리고 상태 변경에 따라 매번 다시 렌더링하는 방식을 취하죠. 그렇다면 이 body는 과연 몇 번이나 호출될까요?

지난 글에서 ThumbnailView.body의 잦은 호출이 앱이 버벅이는 현상, 즉 Hang의 원인임을 확인했습니다.

body 프로퍼티는 뷰 계층 구조를 만드는 핵심입니다. 이 body가 얼마나 자주, 얼마나 오래 실행되는지 확인하려면 SwiftUI View Body Instruments를 사용하면 됩니다. 이 도구는 뷰의 body 실행 빈도와 상세 타이밍을 아주 구체적으로 알려줍니다.

View Body 실행 횟수 측정하기

Instruments에서 Swift를 검색해 봅니다. (Xcode 버전에 따라 템플릿 이름이 다를 수 있습니다. 여기서는 튜토리얼과 동일하게 View Body(Legacy)를 사용했습니다.)

측정 결과는 다음과 같습니다.

데이터가 너무 많아 복잡해 보이니, 우리가 관심 있는 Hang 구간을 선택하고 Set Inspection Range and Zoom 기능을 이용해 자세히 들여다봅시다.

성능을 확인해 보니 두 가지 문제가 보입니다.
1. ThumbnailView의 평균 실행 시간이 45.31ms입니다. 쾌적한 화면을 위한 기준인 1ms를 훌쩍 넘기므로 백그라운드 작업으로 옮겨야 합니다.
2. 실행 횟수가 129번으로 너무 많습니다. 화면 크기를 고려하면 30~40개의 썸네일만 보여줘도 충분할 텐데 말이죠.

1. LazyHStack으로 변경

먼저 실행 횟수를 줄이기 위해 코드를 살펴보니 일반 HStack을 사용하고 있었습니다.

HStack 설명에 LazyHStack에 대한 언급이 있네요. 역시 공식 문서에는 다 이유가 있습니다. LazyHStack으로 변경 후 다시 프로파일링을 해보았습니다.

호출 횟수가 39번으로 확실히 줄었고, Hang 지속 시간도 1.98초로 감소하여 심각(Severe) 단계에서 벗어났습니다.

하지만 Lazy 로딩에도 장단점은 있습니다.
성능은 좋아지지만, 뷰가 화면에 나타나는 순간 렌더링을 시작하기 때문에 스크롤을 빠르게 하면 이미지가 늦게 뜨거나 미세한 버벅임이 발생할 수 있습니다.

실제로 가로 스크롤을 빠르게 해 보면 약간의 끊김이 느껴지는데, 이를 Hitch(히치)라고 합니다.
Hang과 비슷하지만, 스크롤 같은 유동적인 움직임에서 발생하는 끊김 현상을 말합니다. 사용자는 버튼 반응이 0.5초 늦는 것보다 스크롤이 0.5초 버벅이는 것에 훨씬 민감하게 반응한다고 하네요.

Hitch는 보통 250ms를 넘지 않으면 Hang으로 기록되지 않지만, Instruments에서 따로 감지할 수 있습니다.

물론 이 임계값(Threshold)은 설정에서 조절할 수 있습니다. 너무 낮게 잡으면 모든 것을 문제로 인식해 오히려 최적화가 어려워질 수 있으니 적절히 조절하는 것이 좋습니다.

2. 백그라운드 작업으로 전환

다시 돌아와서, 썸네일 생성 코드를 살펴봅시다.

    @MainActor
    var imageView: Image {
        guard let thumbnail = imageFile.makeThumbnail(displayScale: displayScale) else {
            return Image(systemName: "x.square")
        }
        return Image(uiImage: thumbnail)
    }

이 작업이 1ms를 넘겨 메인 스레드를 막고 있으니 백그라운드로 옮겨야 합니다.

@State를 이용해 이미지를 옵셔널로 선언하고, imageView@ViewBuilder를 통해 뷰를 반환하도록 수정합니다.

그리고 .task 수식어를 사용합니다.
.task는 뷰가 생성될 때 비동기 작업을 시작하고, 뷰가 사라지면 자동으로 작업을 취소해 주는 아주 편리한 기능입니다.

여기서 주의할 점이 하나 있습니다.
기존에는 displayScalebody 내부에서 직접 사용되었기 때문에 값이 바뀌면 SwiftUI가 알아서 뷰를 다시 그렸습니다. 하지만 .task 내부로 로직이 이동하면서 SwiftUI가 displayScale의 변화를 감지하지 못하게 됩니다.

이럴 때는 .task(id: ...)를 사용하면 됩니다.

.task(id: displayScale) { ... }

이렇게 하면 displayScale 값이 변경될 때마다 작업이 다시 실행됩니다. (실제로 displayScale이 바뀌는 일은 드물지만, 아이패드의 스플릿 뷰나 맥 앱 환경 등을 고려하면 챙겨두는 것이 좋은 습관입니다.)

결과 확인 및 비동기 행(Asynchronous Hang)

이제 얼마나 개선되었는지 확인해 볼까요?

Hitch는 많이 줄었지만, 여전히 Hang이 존재합니다. 왜 그럴까요?

이것이 바로 비동기 행(Asynchronous Hang)입니다.

무거운 썸네일 생성 작업은 백그라운드에서 잘 처리되었습니다. ThumbnailView 생성 시간도 평균 1.85µs로 아주 빨라졌죠.

하지만 문제는 백그라운드 작업이 끝난 후입니다.
수많은 비동기 작업들이 완료되고 UI를 업데이트하기 위해 메인 스레드로 한꺼번에 복귀하면서, 메인 스레드의 대기열이 순간적으로 꽉 막혀버리는 것입니다.

.task 사용 후 호출 스택을 보면 completeTaskWithClosure() 같은 함수들이 보이는데, 이는 비동기 작업 완료 후 처리를 의미합니다. 이 자잘한 업데이트들이 누적되어 250ms 이상 메인 스레드를 점유하면 결국 앱이 멈추는 Hang이 발생하게 됩니다.

그럼 이 문제는 어떻게 해결해야 할까요? 이건 다음 글에서 계속 정리해 보겠습니다.

profile
iOS 개발자입니다.

0개의 댓글