SwiftUI 성능 최적화 — 부드러운 UI, 재계산 감소

발행: (2025년 12월 3일 오전 11:36 GMT+9)
7 min read
원문: Dev.to

Source: Dev.to

SwiftUI는 빠릅니다 — 하지만 올바르게 사용할 때만 그렇습니다.

시간이 지나면 다음과 같은 문제를 겪게 됩니다:

  • 끊기는 스크롤 성능
  • 끊긴 애니메이션
  • 느린 리스트
  • 뷰가 너무 자주 새로고침
  • 불필요한 재계산
  • 비동기 작업이 UI를 차단
  • 이미지로 인한 메모리 급증
  • 120 Hz 애니메이션에서 프레임 손실

이 가이드는 실제 현장에서 적용하는 성능 규칙을 보여줍니다. 이 규칙들은 이론이 아니라 전체 SwiftUI 앱을 만들 때 실제로 마주치는 문제들을 해결합니다. UI를 버터처럼 부드럽게 만들어 봅시다. 🧈⚡

1. 무거운 뷰를 작은 서브뷰로 나누기

SwiftUI는 @State/@ObservedObject가 변경될 때 전체 뷰를 다시 렌더링합니다.

잘못된 예:

VStack {
    Header()
    ExpensiveList(items: items)   // ← 거대한 뷰
}
.onChange(of: searchQuery) {
    // 전체 뷰가 재계산됨
}

개선된 예:

VStack {
    Header()
    ExpensiveList(items: items)   // 격리됨
}

최선의 예:

struct BigScreen: View {
    var body: some View {
        Header()
        BodyContent()   // 격리된 서브뷰가 추가 재계산을 방지
    }
}

작고 재사용 가능한 컴포넌트가 큰 성능 향상을 가져옵니다.

2. @StateObject & @observable을 올바르게 사용하기

@StateObject재초기화되지 않아야 하는 객체에 사용합니다:

@StateObject var viewModel = HomeViewModel()

경량 모델에는 @observable 을 사용합니다:

@Observable
class HomeViewModel {  }

단순 값에는 @State를 사용하고 (복잡한 구조체는 피함) 무거운 객체를 @State에 절대 저장하지 마세요.

3. 비동기 작업을 메인 스레드 밖에서 수행하기

잘못된 예:

func load() async {
    let data = try? API.fetch()   // 느림
    self.items = data             // UI가 멈춤
}

올바른 예:

func load() async {
    let data = try? await Task.detached { await API.fetch() }.value
    await MainActor.run { self.items = data }
}

메인 스레드를 절대 차단하지 마세요 — SwiftUI는 메인 스레드에 의존합니다.

4. 무거운 그리기에 .drawingGroup() 사용하기

그라디언트, 블러 레이어, 큰 심볼, 복잡한 마스크 등을 렌더링하면 금방 비용이 커집니다.

MyComplexShape()
    .drawingGroup()

GPU 렌더 패스를 강제하게 되어 → 훨씬 빠릅니다.

5. 이미지 최적화 (가장 흔한 지연 원인)

리사이즈된 썸네일을 사용하세요:

Image(uiImage: image.resized(to: 200))

ScrollView 안에서 큰 이미지를 로드하는 것을 피합니다. 대신:

  • .resizable().scaledToFit()
  • .interpolation(.medium)
  • 비동기 로딩 + 캐싱

원격 이미지는 URLCache 혹은 Nuke 같은 라이브러리를 사용합니다.

6. ScrollView 안에서 무거운 레이아웃 작업 피하기

흔히 하는 실수:

ScrollView {
    ForEach(items) { item in
        ExpensiveLayout(item: item)
    }
}

스크롤 중에 비용이 큰 레이아웃을 수행하면 끊김이 발생합니다.

해결책:

  • 수정자를 최소화
  • 계산된 값을 캐시
  • 자식 뷰를 격리된 컴포넌트로 분리

7. 필요할 때 LazyVGrid / LazyVStackList보다 선호하기

List는 훌륭합니다 — 하지만 그렇지 않을 때도 있습니다.

LazyVStack을 사용할 상황:

  • 커스텀 애니메이션이 필요할 때
  • 큰 컴포지션 레이아웃이 있을 때
  • 행에 복잡한 컨테이너가 포함될 때

List를 사용할 상황:

  • 행이 단순할 때
  • 네이티브 셀 최적화를 활용하고 싶을 때

8. .id(UUID()) 로 뷰 재계산 피하기

MyView()
    .id(UUID())   // BAD – 정체성을 파괴함

매 프레임마다 전체 뷰를 다시 로드하게 됩니다. .id(...)는 제어된 리셋이 필요할 때만 사용하세요.

9. 계산된 프로퍼티는 빠르게 만들기

잘못된 예:

var filtered: [Item] {
    hugeArray.filter {  }   // 비용이 큼
}

렌더링마다 재계산됩니다.

개선된 예:

@State private var filtered: [Item] = []

필요할 때만 업데이트:

.onChange(of: searchQuery) { _ in
    filtered = hugeArray.filter {  }
}

10. Transaction 으로 애니메이션 비용 제어하기

기본 애니메이션은 끊길 수 있습니다. 부드럽게 만들려면:

withTransaction(Transaction(animation: .snappy)) {
    isOpen.toggle()
}

맞춤형 애니메이션 트랜잭션은 레이아웃 점프를 줄여줍니다.

11. 대량 업데이트 시 애니메이션 끄기

withAnimation(.none) {
    items = newItems
}

큰 리스트 작업 중에 발생하는 지연을 방지합니다.

12. Instruments 사용하기 (예, SwiftUI에서도 동작합니다)

다음 도구로 프로파일링하세요:

  • SwiftUI “Dirty Views”
  • Memory Graph
  • Time Profiler
  • Allocation Tracking
  • FPS drops

지연의 90 %는 다음에서 비롯됩니다:

  • 거대한 뷰
  • 비용이 큰 초기화
  • 이미지
  • 메인 스레드 차단

✔️ 최종 성능 체크리스트

출시 전, 다음을 확인하세요:

  • 메인 스레드 API 호출 없음
  • 비용이 큰 계산 프로퍼티 없음
  • ScrollView 내부 레이아웃 스러시 없음
  • 이미지가 리사이즈되었거나 캐시됨
  • 무거운 뷰가 컴포넌트로 분리됨
  • ViewModel이 @StateObject 혹은 @observable 사용
  • 애니메이션이 .snappy 혹은 .spring 이고 격리됨
  • 리스트는 필요 시 lazy 컨테이너 사용
Back to Blog

관련 글

더 보기 »

Bf-트리: 페이지 장벽을 깨다

안녕하세요, 저는 Maneshwar입니다. 저는 FreeDevTools – 온라인 오픈‑소스 허브를 개발하고 있습니다. 이 허브는 dev tools, cheat codes, 그리고 TLDRs를 한 곳에 모아 쉽게 이용할 수 있게 합니다.

Hello Developer: 2025년 12월

이번 호: 2025 App Store Award 수상자를 만나보세요. 새해에 새로운 디자인 및 Liquid Glass 활동에 등록하세요. 최신 추가 항목을 확인하세요.