SwiftUI 메모리 관리 및 순환 참조 함정 (프로덕션 가이드)

발행: (2026년 1월 6일 오전 03:45 GMT+9)
7 min read
원문: Dev.to

Source: Dev.to

Introduction

SwiftUI는 많은 메모리 복잡성을 숨깁니다—하지만 어느 순간에는 드러납니다. 대규모에서는 팀이 다음과 같은 문제를 겪습니다:

  • 절대로 해제되지 않는 ViewModel
  • 영원히 실행되는 Task
  • 메모리를 누수하는 Navigation 스택
  • EnvironmentObject가 전체 그래프를 유지함
  • 클로저에 의해 발생하는 미묘한 순환 참조
  • 오랜 사용 후 성능 저하

이 가이드는 SwiftUI에서 메모리가 실제로 어떻게 동작하는지, 누수가 발생하는 원인, 그리고 누수 없이 프로덕션에 안전한 아키텍처를 설계하는 방법을 설명합니다.

뷰 vs. 객체

  • 는 값 타입입니다.
  • 객체(class 인스턴스)는 참조 타입입니다.
  • SwiftUI는 뷰를 지속적으로 재생성하므로, 메모리 문제는 거의 항상 뷰 자체가 아니라 여러분이 소유한 객체에서 발생합니다.
struct Screen: View {
    @StateObject var vm = ViewModel()
}

Screen이 이제 ViewModel을 소유합니다. 화면이 계층 구조를 떠나지 않으면 ViewModel은 해제되지 않습니다.

@StateObject private var vm = ViewModel()

소유권이 명시적이며 범위가 한정됩니다.

네비게이션 스택

네비게이션 스택은 다음을 유지합니다:

  1. 해당 ViewModel
  2. 캡처된 모든 의존성

ViewModel이 네비게이션 상태, 전역 서비스, 혹은 self를 캡처하는 클로저를 참조한다면, 절대 해제되지 않을 수 있습니다.

규칙: 네비게이션 경로에 남아 있으면, 계속 살아 있습니다.

클래식 순환 참조

class ViewModel {
    var onUpdate: (() -> Void)?

    func bind() {
        onUpdate = {
            self.doSomething()
        }
    }
}

클로저가 self를 강하게 캡처 → 순환 참조 발생.

수정

onUpdate = { [weak self] in
    self?.doSomething()
}

SwiftUI는 이 패턴으로부터 여러분을 보호하지 않습니다.

Tasks

뷰에 연결된 장기 실행 작업이 누수의 일반적인 원인입니다:

.task {
    await loadData()
}

loadData()가 반복하거나, 오래 지속되는 작업을 await 하거나, 절대 완료되지 않으면 해당 작업이 뷰 모델을 유지하게 됩니다.

Proper Cancellation

.task {
    await loadData()
}
.onDisappear {
    cancelTasks()
}

또는

Task { [weak self] in
    await self?.loadData()
}

EnvironmentObjects

EnvironmentObject는 전역 강한 참조입니다:

.environmentObject(AppState())

AppState가 서비스, 캐시, 클로저 또는 옵저버를 보유하고 있다면, 하위에 있는 모든 것이 계속 살아 있게 됩니다.

잘못된 패턴

class AppState {
    let featureA = FeatureAViewModel()
    let featureB = FeatureBViewModel()
}

아무것도 해제되지 않습니다.

더 나은 패턴

  • AppState는 식별자와 플래그만 보유합니다.
  • 각 기능이 자체 뷰 모델을 소유합니다.
  • 네비게이션이 기능 상태를 생성하고 파괴합니다.

Subscriptions

Long‑lived Combine pipelines can leak:

publisher
    .sink { value in
        self.handle(value)
    }
    .store(in: &cancellables)

If self never deallocates, the pipeline never cancels.

Rules

  • Cancel on onDisappear.
  • Scope subscriptions tightly to the view model’s lifecycle.
  • Prefer short‑lived pipelines.

디이니셜라이제이션 디버깅

모든 뷰 모델에 deinit 로그를 추가하세요:

deinit {
    print("Deinit:", Self.self)
}

로그가 보이지 않으면, 아직 객체를 소유하고 있는 것이 있습니다 (네비게이션 경로, 작업, 환경, 클로저).

Windows 및 멀티‑윈도우 앱

각 창:

  • 각 창은 자체 뷰 트리와 탐색 상태를 소유합니다.
  • 뷰 모델을 중복할 수 있습니다.

의도하지 않는 한 창 간에 뷰 모델을 공유하지 마세요; 이렇게 하면 메모리 누수가 빠르게 증가할 수 있습니다.

Common Leak Sources to Avoid

  • 뷰 모델에 대한 전역 싱글톤
  • 객체 안에 뷰를 저장하는 것
  • 취소 없이 장시간 실행되는 작업
  • 무거운 EnvironmentObjects
  • self를 강하게 캡처하는 클로저
  • 네비게이션과 상태 소유권을 혼합하는 것

안전한 소유권 체크리스트

  1. 이 객체의 소유자는 누구인가?
  2. 언제 해제되어야 하는가?
  3. 무엇이 이를 캡처하는가?
  4. 무엇이 이를 유지하는가?
  5. 네비게이션이 이를 살아 있게 하는가?
  6. 작업이 이를 살아 있게 하는가?

이 질문들에 답할 수 없다면, 메모리 누수가 있을 가능성이 높습니다.

결론

SwiftUI가 제공하는 것:

  • 예측 가능한 라이프사이클
  • 명확한 소유권 경계
  • 강력한 추상화

하지만 여전히 소유권을 올바르게 설계해야 합니다. 메모리가 깨끗할 때:

  • 성능이 안정화됩니다
  • 버그가 사라집니다
  • 네비게이션이 기대대로 동작합니다
  • 긴 세션이 원활하게 유지됩니다
Back to Blog

관련 글

더 보기 »

SwiftUI View Diffing 및 Reconciliation

SwiftUI는 화면을 “다시 그리”는 것이 아닙니다. 뷰 트리를 차이(diff)합니다. SwiftUI가 무엇이 변경되고 무엇이 동일하게 유지되는지를 어떻게 결정하는지 이해하지 못한다면, 불필요한 …

SwiftUI #21: 그룹

Group이란? Group은 여러 뷰를 함께 묶어 Stack이 가지고 있는 최대 10개의 서브 뷰 제한을 피하고 여러 뷰에 스타일을 적용할 수 있게 합니다.

SwiftUI #20: 우선순위

소개 SwiftUI에서 Stack은 뷰 사이의 공간을 균등하게 나눕니다. 뷰가 들어가지 않으면 Image에 고정 크기를 할당하고 축소합니다.