반응형

SwiftUI Study – 무거운 연산을 메인 스레드에서 실행하지 않고 백그라운드로 안전하게 분리하는 방법 (UI 끊김·프레임 드랍 방지)

 

1. 왜 중요한가 (문제 배경)

SwiftUI는 모든 UI 렌더링을 메인 스레드(MainActor) 에서 수행합니다.

따라서 메인 스레드에서 다음과 같은 작업을 실행하면 즉시 프레임 드랍이 발생합니다.

  • 대용량 JSON 파싱
  • 이미지 리사이징/압축
  • 파일 접근(큰 파일 읽기/쓰기)
  • 복잡한 정렬·필터링
  • CPU 연산이 많은 계산 로직
  • 동기 네트워크 요청 (Data(contentsOf:) 등)

문제의 핵심은 다음과 같습니다.

“메인 스레드는 UI 전용으로 유지해야 하며,

무거운 연산은 반드시 백그라운드로 분리해야 한다.

Combine, .task, onAppear 안에서 무심코 이런 연산을 넣으면

UI가 순간적으로 멈추거나, 스크롤이 끊기고, 사용자가 “버벅인다”고 느끼게 됩니다.

 

2. 잘못된 패턴 예시

❌ 예시 1: JSON 파싱을 메인 스레드에서 직접 실행

struct WrongJSONView: View {
    @State private var items: [Item] = []

    var body: some View {
        List(items) { item in
            Text(item.title)
        }
        .task {
            // ❌ I/O + CPU 모두 메인 스레드에서 실행
            let data = try Data(contentsOf: URL(string: "https://example.com/items.json")!)
            let decoded = try JSONDecoder().decode([Item].self, from: data)
            items = decoded
        }
    }
}

문제점

  • Data(contentsOf:) 는 동기 I/O → UI 스레드를 직접 블록
  • JSONDecoder.decode 또한 대용량일수록 CPU 부하 큼
  • 네트워크 상태가 나쁘면 화면이 수초간 멈출 수도 있음

❌ 예시 2: 정렬/필터링을 View body 안에서 수행

struct WrongSortedListView: View {
    let items: [Item]

    var body: some View {
        List(items.sorted { $0.date > $1.date }) { item in   // ❌ body 호출마다 정렬
            Text(item.title)
        }
    }
}

문제점

  • body는 상태가 바뀔 때마다 반복 호출
  • 그때마다 정렬 연산이 실행됨
  • 리스트가 커질수록 렌더링이 느려지고 스크롤이 끊김

❌ 예시 3: 이미지 리사이징을 직접 UI 스레드에서 수행

struct WrongImageView: View {
    let image: UIImage

    var body: some View {
        Image(uiImage: image.resized(to: CGSize(width: 300, height: 300)))  // ❌
            .resizable()
    }
}

문제점

  • 리사이즈 연산은 픽셀 수에 비례해 비용이 커짐
  • 특히 고해상도 이미지는 UI 프레임을 심하게 끊어 먹음
  • 리스트/스크롤 안에서 사용하면 체감 성능 저하가 매우 큼

 

3. 올바른 패턴 예시

✅ 예시 1: Task.detached로 무거운 JSON 파싱을 백그라운드로 분리

struct CorrectJSONView: View {
    @State private var items: [Item] = []

    var body: some View {
        List(items) { item in
            Text(item.title)
        }
        .task {
            let decoded = try? await loadItems()
            if let decoded {
                items = decoded
            }
        }
    }

    func loadItems() async throws -> [Item] {
        try await Task.detached(priority: .userInitiated) {
            let (data, _) = try await URLSession.shared.data(from: URL(string: "https://example.com/items.json")!)
            return try JSONDecoder().decode([Item].self, from: data)
        }.value
    }
}

장점

  • 네트워크 + 디코딩이 모두 백그라운드에서 실행
  • 메인 스레드는 List 렌더링과 스크롤만 담당 → UI 끊김 최소화
  • Swift Concurrency 패턴과 자연스럽게 맞물림

✅ 예시 2: 정렬/필터링은 body 바깥에서 계산하거나 비동기 처리

struct CorrectSortedListView: View {
    let items: [Item]

    var sortedItems: [Item] {
        items.sorted { $0.date > $1.date }   // body 바깥 계산
    }

    var body: some View {
        List(sortedItems) { item in
            Text(item.title)
        }
    }
}

혹은 리스트가 매우 클 경우:

@MainActor
final class SortedViewModel: ObservableObject {
    @Published var sorted: [Item] = []

    func sort(_ items: [Item]) async {
        let result = await Task.detached {
            items.sorted { $0.date > $1.date }
        }.value

        sorted = result
    }
}

장점

  • body는 항상 “가벼운 뷰 선언”만 담당
  • 정렬 비용이 커도 UI와 분리되어 프레임 유지 가능

✅ 예시 3: 이미지 처리(리사이즈/필터)는 백그라운드에서 완료 후 UI에 반영

@MainActor
final class ImageViewModel: ObservableObject {
    @Published var resized: UIImage?

    func process(_ image: UIImage) {
        Task {
            let output = await Task.detached {
                image.resized(to: CGSize(width: 300, height: 300))
            }.value

            resized = output
        }
    }
}

View:

struct CorrectImageView: View {
    @StateObject private var vm = ImageViewModel()
    let original: UIImage

    var body: some View {
        Group {
            if let img = vm.resized {
                Image(uiImage: img)
                    .resizable()
            } else {
                ProgressView()
            }
        }
        .task {
            vm.process(original)
        }
    }
}

장점

  • 무거운 이미지 연산이 UI 스레드를 막지 않음
  • 처리 중에는 ProgressView 등으로 상태 표시 가능
  • 리스트/갤러리 등 이미지 많은 화면에서 특히 효과적

 

4. 실전 적용 팁

✔ 팁 1 – “UI가 버벅인다” 느껴지면 가장 먼저 메인 스레드 작업량을 의심

프로파일링 전이라도, 큰 연산이 어디서 실행되는지부터 점검.

✔ 팁 2 – Data(contentsOf:)는 SwiftUI에서 사용 금지에 가깝다고 생각하기

항상 URLSession + async/await 조합을 우선 고려.

✔ 팁 3 – body 안에서는 절대 정렬/필터/리사이즈/파싱하지 않기

body는 선언만, 연산은 ViewModel 또는 별도 Task에서.

✔ 팁 4 – Task.detached 사용 시 결과 업데이트는 항상 MainActor에서

상태(@State/@Published) 업데이트는 메인 스레드에서만.

✔ 팁 5 – “UI 전용 스레드”라는 개념을 팀 내 공통 규칙으로 두기

리뷰 기준에 “메인에서 무거운 연산 금지” 항목을 추가하면 품질이 크게 올라간다.

 

5. 정리

  • SwiftUI에서 메인 스레드가 막히는 순간, 앱 전체가 느려지거나 멈춘 것처럼 보인다.
  • JSON 파싱, 정렬, 이미지 처리, 동기 네트워크 등은 반드시 백그라운드에서 실행해야 한다.
  • Task.detached + MainActor.run 또는 @MainActor ViewModel + 백그라운드 연산 패턴은 실무에서 가장 안정적인 구조다.
  • 이 원칙만 지켜도 “느리고 버벅이는 SwiftUI 앱”이 “부드럽게 동작하는 앱”으로 바뀐다.
반응형
Posted by 까칠코더
,