반응형

Swift에서 ARC 최적화: weak vs unowned

 

iOS 앱에서 메모리 누수와 크래시는 대부분 소유권(ownership) 을 잘못 설계할 때 발생합니다. Swift의 ARC는 자동으로 메모리를 관리하지만, 순환 참조(retain cycle) 를 개발자가 끊어줘야 합니다. 이 문서는 weak  unowned 를 언제, 왜, 어떻게 써야 하는지 안전성·성능·실무 패턴까지 자세히 정리합니다.

 

1. 핵심 요약

항목 weak unowned
소유권 비소유(non‑owning) 비소유(non‑owning)
옵셔널 여부 항상 Optional (자동 nil세팅) Non‑Optional 가능 (nil 아님 가정)
해제 시 동작 대상이 해제되면 자동으로 nil 대상이 해제되면 댕글링 포인터 → 접근 시 크래시
런타임 비용 제로잉(Zeroing) 테이블 관리 비용 존재 비교적 낮음 (제로잉 없음)
안전성 가장 안전 가장 빠르지만 위험
권장 용도 delegate, back‑reference, 캡처리스트 기본값 대상의 수명이 더 길거나 정확히 동등임을 증명할 수 있을 때만

 

2. ARC 기본 동작 간단 복습

  • strong: 참조 카운트(retain) 증가 → 소유권 보유
  • weak: retain 증가하지 않음, 대상 해제 시 자동 nil
  • unowned: retain 증가하지 않음, 대상 해제 시 nil 아님(접근하면 크래시)
final class Owner {
    var child: Child?          // strong
}
final class Child {
    weak var owner: Owner?     // back‑reference는 weak가 기본
}

원칙: 트리 구조는 부모→자식 strong, 자식→부모 weak 로 순환을 끊습니다.

 

3. weak  unowned 의 선택 기준


3.1 실무 체크리스트

  • 대상 수명이 나보다 길다 또는 항상 함께 산다 증명 가능? → unowned 고려
  • 그 외에는 기본 weak. 특히 비동기, 타이머, 콜백, UI 이벤트는 수명 추적이 어려우므로 weak이 안전

3.2 대표 사례

  • delegate: weak (Apple 프레임워크 관례)
  • 클로저 캡처리스트: 기본 [weak self]; 짧고 동기적이며 self가 반드시 살아있음이 명백하면 [unowned self] 가능
  • 뷰컨↔뷰모델(양방향): 한쪽은 weak
  • 오래 사는 매니저 → 일시적인 소유자: 역참조는 weak

 

4. 캡처리스트와 retain cycle


4.1 순환 참조 발생 예

final class VC: UIViewController {
    var work: (() -> Void)?

    override func viewDidLoad() {
        super.viewDidLoad()
        work = {
            // self 캡처 → VC가 work을 strong으로 잡고, work가 self를 잡아 cycle
            print(self.view.bounds)
        }
    }
}

4.2 해결: [weak self] 패턴

work = { [weak self] in
    guard let self else { return }
    print(self.view.bounds)
}

4.3 [unowned self] 는 언제?

work = { [unowned self] in
    // 이 클로저가 VC보다 **항상 짧게** 존재한다는 게 논리적으로 보장될 때만
    print(view.bounds)
}
  • 예: 즉시 실행되는 동기 클로저, 라이프사이클상 클로저가 VC 프로퍼티로 오래 저장되지 않는 경우
  • 비동기 지연/재시도/타이머/네트워크 콜백 → 위험. self 해제 후 실행되면 크래시

 

5. Delegate/Owner 패턴 베스트 프랙티스

protocol PlayerDelegate: AnyObject {       // AnyObject로 class‑only
    func didFinish(_ player: Player)
}

final class Player {
    weak var delegate: PlayerDelegate?     // weak
    func finish() { delegate?.didFinish(self) }
}

final class Screen: PlayerDelegate {
    private let player = Player()          // strong
    init() { player.delegate = self }      // back‑reference weak로 cycle 방지
    func didFinish(_ player: Player) { /* ... */ }
}
  • delegate  항상 weak. 그래야 Screen 이 사라질 때 cycle 없이 내려감.

 

6. Timer / Notification / Combine / async 패턴


6.1 Timer (Foundation)

timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
    self?.tick()
}

6.2 NotificationCenter

let token = NotificationCenter.default.addObserver(
    forName: .something, object: nil, queue: .main
) { [weak self] _ in
    self?.handle()
}
// deinit 또는 viewWillDisappear에서 removeObserver(token)

6.3 Combine

cancellable = publisher
    .sink { [weak self] value in self?.render(value) }

6.4 Swift Concurrency

task = Task { [weak self] in
    guard let self else { return }
    await self.load()
}

장수(長壽) 리소스의 콜백은 무조건 약하게(weak) 잡는 걸 기본값으로.

 

7. weak vs unowned 성능 노트

  • weak  제로잉 weak 테이블 관리로 약간의 오버헤드가 있습니다.
  • unowned 는 제로잉 없음 → 접근 비용은 더 낮음.
  • 그러나 대부분의 앱에서 차이는 미미합니다. 안전성 우선 → 기본 weak.
  • 진성 핫패스에서 측정으로 병목이 확인될 때만 unowned 로 전환을 검토하세요.

성능 판단은 반드시 Release + 최적화 빌드에서 측정(Instruments Time Profiler).

 

8. 크래시를 만드는 unowned 반례

final class Store {
    var handler: (() -> Void)?
}
final class VC {
    let store = Store()
    func bind() {
        store.handler = { [unowned self] in   // ⚠️ VC 해제 후 호출되면 크래시
            print(self)
        }
    }
}
  • 네트워크 재시도/백그라운드 재개/지연 타이머 등에서 unowned  언젠가 터집니다.
  • 이런 경우는 항상 [weak self] + 존재 확인(guard let/self?)이 정석.

 

9. 컬렉션/캐시와 약한 참조


9.1 약한 참조 래퍼

final class WeakBox<T: AnyObject> {
    weak var value: T?
    init(_ value: T) { self.value = value }
}

var listeners = [WeakBox<AnyObject>]()
func addListener(_ l: AnyObject) { listeners.append(WeakBox(l)) }
func compactListeners() { listeners.removeAll { $0.value == nil } }

9.2 NSMapTable / NSPointerArray (Obj‑C 기반)

  • NSMapTable.weakToStrongObjects()  weak 키/값 구성이 가능
  • 캐시/리스너 목록에서 순환 방지에 유용

 

10. deinit 로 수명 확인하기

final class Foo {
    deinit { print("Foo deinit") }
}
  • 디버깅 중 사라져야 할 객체가 안 사라지면 retain cycle 의심
  • Xcode Memory Graph / Allocations / Leaks 도구로 추적

 

11. 구조적 소유권 설계 (디자인 규칙)

  • 단방향 강한 소유권 + 역방향 약한 참조 (Owner→Child strong, Child→Owner weak)
  • 뷰 계층/코디네이터/라우팅에도 동일 원칙 적용
  • 비즈니스 그래프가 복잡하면 “핵심 데이터는 Value Type(Struct)”, UI/브리지는 Class로 구분하여 참조 그래프 축소

 

12. 예제: ViewController ↔ ViewModel

final class ViewModel {
    weak var output: ViewModelOutput?
    func load() { output?.didLoad("Hello") }
}

protocol ViewModelOutput: AnyObject { func didLoad(_ text: String) }

final class VC: UIViewController, ViewModelOutput {
    private let vm = ViewModel()
    override func viewDidLoad() {
        super.viewDidLoad()
        vm.output = self           // back‑reference weak
        vm.load()
    }
    func didLoad(_ text: String) { /* update UI */ }
}

 

13. 예제: 클로저 보관 프로퍼티와 캡처

final class Loader {
    var onComplete: ((Data) -> Void)?
    func start() { /* async ... */ }
}

final class Screen {
    private let loader = Loader()

    func show() {
        loader.onComplete = { [weak self] data in  // 기본은 weak
            self?.render(data)
        }
        loader.start()
    }

    private func render(_ data: Data) { /* ... */ }
}

 

14. 흔한 질문(FAQ)

Q1. weak는 항상 Optional이어야 하나요?

A. 네. 해제 시 ARC가 자동으로 nil을 넣어야 하므로 weak var  Optional 이어야 합니다.

Q2. unowned Optional은 안 되나요?

A. 불가합니다. unowned 는 “nil이 될 수 없다”는 전제가 전부입니다. (필요하면 weak 사용)

Q3. 성능 때문에 전부 unowned로 바꾸면 안 되나요?

A. 권장하지 않습니다. 성능 차이는 대부분 미미하며, 크래시 리스크가 큽니다. 측정 기반으로 특정 지점만 고려하세요.

Q4. struct/enum 같은 값 타입에도 weak/unowned를 쓸 수 있나요?

A. 아니오. 약한 참조는 클래스 인스턴스(참조 타입)에만 의미가 있습니다.

Q5. 클로저에서 [weak self] 후 강제 언래핑(!)을 써도 되나요?

A. 지양하세요. guard let self else { return } 또는 if let self = self { ... } 로 처리하세요.

 

15. 선택 가이드 (요약 표)

상황 권장 선택 근거
delegate/back‑reference weak 관례 + 자동 nil
비동기 콜백/타이머/Notification/Combine weak 수명 예측 어려움
즉시 실행 동기 클로저(수명 확실) unowned 가능 빠르고 간단
Owner→Child strong 소유권 명확화
Child→Owner weak cycle 제거
극한 성능 핫패스 측정 후 unowned검토 오버헤드 감소

 

16. 점검 체크리스트

  •  캡처리스트에서 기본값은 [weak self]
  •  Owner→Child strong / Child→Owner weak 설계
  •  비동기/지연 콜백에서 unowned 금지
  •  의도적으로 unowned 를 썼다면 수명 관계를 코드/주석으로 명확히
  •  Instruments(Time Profiler, Leaks, Memory Graph)로 주기적 점검

 

17.  샘플 코드 (디버그용)

final class A { deinit { print("A deinit") } }
final class B {
    weak var a: A?   // unowned로 바꿔가며 비교
    init(a: A) { self.a = a }
}

func demo() {
    var a: A? = A()
    var b: B? = B(a: a!)
    a = nil          // weak이면 b?.a == nil, unowned면 이후 접근 시 크래시
    print(b?.a as Any)
    b = nil
}
demo()

 

18. 결론

  • weak  안전이 최우선인 기본값, unowned  증명된 수명 관계에서만 제한적으로 사용하세요.
  • 성능 최적화는 측정 기반으로, 크래시 리스크를 감수하면서까지 unowned 를 남용하지 마세요.
  • 소유권 그래프를 단방향 strong + 역방향 weak 으로 설계하면 대부분의 순환 참조 문제를 예방할 수 있습니다.
반응형
Posted by 까칠코더
,