SwiftUI 앱이 느리다면: 렌더링 병목 진단부터 메모리 누수 잡는 실전 가이드

모바일

SwiftUISwiftiOS성능 최적화Swift Concurrency

이 글은 누구를 위한 것인가

  • SwiftUI 앱에서 스크롤이 버벅거리거나 UI가 느리게 반응하는 문제를 겪는 개발자
  • @Observable 매크로와 기존 @ObservableObject의 차이가 궁금한 분
  • Swift 6 strict concurrency 오류 메시지에 당황한 iOS 개발자

들어가며

SwiftUI는 선언적이고 직관적이다. 코드가 뭘 그려야 하는지 선언하면, SwiftUI가 알아서 렌더링한다. 그런데 이 "알아서"가 생각보다 자주 재렌더링을 일으킨다. 아무것도 바뀌지 않은 것처럼 보이는데 View body가 수백 번 호출되기도 한다.

SwiftUI 성능 문제의 80%는 불필요한 뷰 재렌더링에서 온다. 나머지는 메모리 누수, 느린 데이터 로딩, 이미지 처리 등이다.


1. SwiftUI 렌더링 원리: 왜 재렌더링이 일어나는가

SwiftUI는 상태(State)가 바뀌면 해당 상태를 사용하는 View의 body를 다시 실행한다.

struct ParentView: View {
    @State private var count = 0
    @State private var name = "홍길동"

    var body: some View {
        VStack {
            Text("카운트: \(count)")
            ChildView(name: name)   // name이 바뀌지 않아도...
            Button("증가") { count += 1 }
        }
    }
}

struct ChildView: View {
    let name: String

    var body: some View {
        Text("이름: \(name)")
        // count가 바뀔 때도 이 body가 실행됨
        // 실제 출력 결과는 같아도!
    }
}

count가 바뀌면 ParentView body 전체가 다시 실행된다. ChildViewname은 바뀌지 않았는데도 ChildView body가 실행된다.

다행히 SwiftUI는 실제 DOM(UI 트리)을 비교해서 변경사항만 적용한다. 하지만 body 계산 자체가 빈번하면 CPU를 낭비하고, 복잡한 뷰에서 성능 문제로 이어진다.


2. Instruments로 SwiftUI 성능 프로파일링

먼저 어디서 성능 문제가 생기는지 측정해야 한다.

SwiftUI Profiling 단계

  1. Xcode에서 Product → Profile (⌘+I)
  2. SwiftUI 템플릿 선택
  3. 앱 실행 후 느린 부분 재현
  4. View Body 트랙에서 어떤 뷰가 얼마나 자주 렌더링되는지 확인
측정할 지표:
- View body 호출 횟수: 너무 많으면 불필요한 재렌더링
- body 실행 시간: 오래 걸리면 비싼 계산이 있음
- 프레임 드롭: 16.67ms(60fps) 이상 걸리는 프레임

빠른 확인 방법: Xcode 디버그 빌드에서 _printChanges() 사용

struct MyView: View {
    var body: some View {
        let _ = Self._printChanges()  // 콘솔에 변경 이유 출력
        // ...
    }
}
// 출력: "@self changed." 또는 "@ObservedObject changed." 등

3. @Observable 매크로: 더 정밀한 재렌더링

iOS 17에서 도입된 @Observable 매크로는 기존 @ObservableObject보다 훨씬 정밀하게 재렌더링을 제어한다.

// 기존 ObservableObject 방식
class UserStore: ObservableObject {
    @Published var name = "홍길동"
    @Published var score = 0
    @Published var achievements: [String] = []
}

// name만 바뀌어도 score, achievements를 사용하는 뷰까지 모두 재렌더링!

// 새로운 @Observable 방식
@Observable
class UserStore {
    var name = "홍길동"
    var score = 0
    var achievements: [String] = []
}

// name이 바뀌면 name을 사용하는 뷰만 재렌더링!
// score를 사용하는 뷰는 score가 바뀔 때만 재렌더링

@Observable은 프로퍼티 단위로 의존성을 추적한다. 이것 하나만 적용해도 불필요한 재렌더링이 크게 줄어드는 경우가 많다.

마이그레이션 방법

// Before
class ViewModel: ObservableObject {
    @Published var items: [Item] = []
}

struct MyView: View {
    @StateObject var viewModel = ViewModel()
    // 또는
    @ObservedObject var viewModel: ViewModel
}

// After
@Observable
class ViewModel {
    var items: [Item] = []  // @Published 불필요
}

struct MyView: View {
    @State var viewModel = ViewModel()  // @StateObject 대신 @State
    // 또는
    var viewModel: ViewModel  // @ObservedObject 불필요
}

4. 불필요한 재렌더링을 막는 패턴들

뷰를 작게 분리하기

큰 뷰 하나보다 작은 뷰 여러 개가 재렌더링 범위를 줄인다.

// 나쁜 예: 하나의 큰 뷰
struct ProductListView: View {
    @State var products: [Product] = []
    @State var searchText = ""
    @State var isLoading = false

    var body: some View {
        // searchText가 바뀔 때마다 isLoading 표시 부분까지 재렌더링
        VStack {
            SearchBar(text: $searchText)
            if isLoading { ProgressView() }
            ForEach(products) { product in
                ProductRow(product: product)
            }
        }
    }
}

// 좋은 예: 역할별로 분리
struct ProductListView: View {
    @State var searchText = ""

    var body: some View {
        VStack {
            SearchBar(text: $searchText)
            ProductListContent(searchText: searchText)
        }
    }
}

struct ProductListContent: View {
    let searchText: String
    @State var products: [Product] = []
    @State var isLoading = false
    // searchText가 바뀌면 이 뷰만 재렌더링
}

Equatable 활용

struct ProductRow: View, Equatable {
    let product: Product

    static func == (lhs: ProductRow, rhs: ProductRow) -> Bool {
        lhs.product.id == rhs.product.id &&
        lhs.product.price == rhs.product.price
    }

    var body: some View { ... }
}

// SwiftUI가 같다고 판단하면 body 실행 자체를 건너뜀

5. Swift 6 Strict Concurrency 적응

Xcode 16에서 Swift 6를 사용하면 동시성 관련 컴파일 오류가 쏟아질 수 있다. 이는 기존에 숨어있던 데이터 경쟁 조건(Data Race)을 컴파일 타임에 잡아주는 것이다.

흔한 오류와 해결 방법

// 오류: "Sending 'self' may cause a data race"
class ViewModel: ObservableObject {
    func loadData() {
        Task {
            let data = await fetchFromAPI()
            self.items = data  // ⚠️ 백그라운드에서 메인 스레드 프로퍼티 수정
        }
    }
}

// 해결 1: @MainActor 지정
@MainActor
class ViewModel: ObservableObject {
    func loadData() {
        Task {
            let data = await fetchFromAPI()
            self.items = data  // ✅ 클래스 전체가 메인 스레드에서 실행
        }
    }
}

// 해결 2: MainActor.run 사용
func loadData() {
    Task {
        let data = await fetchFromAPI()
        await MainActor.run {
            self.items = data  // ✅ 메인 스레드로 명시적 전환
        }
    }
}

Swift 6 마이그레이션은 한 번에 다 할 필요 없다. 모듈 단위로 swiftLanguageVersion = 6을 적용하면서 점진적으로 진행할 수 있다.


6. 메모리 누수 잡기

SwiftUI에서 가장 흔한 메모리 누수 패턴이 **강한 참조 사이클(Strong Reference Cycle)**이다.

// 누수 발생: ViewModel이 자기 자신을 강하게 참조
class ViewModel: ObservableObject {
    var onCompletion: (() -> Void)?

    func startProcess() {
        onCompletion = {
            self.processResult()  // ⚠️ self를 강하게 캡처 → 누수
        }
    }
}

// 해결: [weak self] 캡처
func startProcess() {
    onCompletion = { [weak self] in
        self?.processResult()  // ✅ 약한 참조
    }
}

Instruments Memory Graph로 누수 확인

  1. Instruments → Leaks 또는 Allocations
  2. 화면을 반복해서 열고 닫기
  3. 객체가 해제되지 않고 계속 쌓이는지 확인

7. iOS 18 새 API 성능 관련 변경사항

iOS 18에서 주목할 성능 관련 변경사항들이다.

  • scrollPosition(id:): 목록에서 특정 아이템으로 스크롤하는 API 개선. 이전 workaround 코드 불필요.
  • @Previewable 매크로: Preview에서 State 변수를 쉽게 테스트. 별도 wrapper 뷰 불필요.
  • Swift 6 concurrency: UIKit, SwiftUI API가 대부분 @MainActor로 표시됨. 명시적 dispatch 코드 줄어듦.
  • SwiftData 퍼포먼스: 대용량 데이터 페이지네이션 지원 개선.

맺으며

SwiftUI 성능 최적화의 첫 번째 규칙은 측정부터다. 직관으로 "여기가 느릴 것 같다"고 짐작하지 말고, Instruments로 실제 병목을 찾아야 한다.

@Observable 마이그레이션은 큰 코드 변경 없이 체감 성능을 눈에 띄게 개선하는 가성비 최고의 작업이다. Swift 6 strict concurrency는 처음엔 오류가 많아 당황스럽지만, 기존 코드의 잠재적 버그를 찾아준다는 관점으로 보면 오히려 환영할 일이다.