-
보이는 객체 수명에 속지 말기 ! - WWDC ARC 1swift 2025. 5. 7. 00:57
WWDC21 - ARC Swift
Swift 에서는 class 보단 struct, 즉 값 타입을 우선적으로 사용하라고 합니다.
저 역시 class는 데이터가 여기저기서 접근되기 쉬워서 조심히 사용해야한다. 정도의 개념으로만 이해하고 struct를 사용해왔는데요.
문득
👉데이터의 참조 타입은 언제 위험해지는 걸까 ?
이 의문에 파고드니 이를 이해하려면 ARC(Automatic Reference Counting) 을 정확히 이해야한다는 것을 알게되었습니다.
그렇게 지금까진 그저 넘겼던 참조타입이 왜 위험한지, ARC 중심으로 WWDC 의 ARC를 해석하며 알아보겠습니다.
- Swift의 class는 참조 타입이며, 메모리는 ARC로 관리된다.
- 값 타입(Struct, enum)을 우선 사용하고, 참조타입은 신중히 사용할 것
- ARC를 잘 이해해야, Swift 에서 안정적인 앱을 만들 수 있음
인스턴스의 수명과 ARC
- 인스턴스 수명은 init -> 마지막 사용 시점 까지 (life time)
- ARC 는 retain(참조 증가), release(참조 감소) 연산으로 관리됨
- 참조 카운트가 0이 되면 메모리에서 해제 (deallocate)
class Traveler { var name: String var destination: String? } let traveler1 = Traveler(name: "Lily")
위 코드를 표시하면 다음과 같습니다. Heap에 Traveler 인스턴스가 저장되고 traveler1은 그 주소를 참조하는 방식입니다.
이를 도식화 한다면 다음과 같이 표현할 수 있습니다.
traveler1 은 heap 의 주소를 참조한다 -> Reference Count + 1
let traveler2 = traveler1
이렇게 같은 주소를 참조하고 있기 때문에 RC의 값은 1 증가하게 됩니다.
살아있는 인스턴스가 해제 될때 RC가 release 되면서 RC = 0이 되면서 인스턴스의 생명주기가 행해집니다.
그럼 인스턴스의 사용이 끝날때마다 traveler1 = nil 이런식으로 인스턴스를 해제 해주어야 할까요?
Swift 컴파일러는 마지막으로 사용된 시점을 기준으로 RC 를 줄여주는 release 를 삽입합니다.
이렇게 RC를 자동으로 관리해주는 시스템이 ARC 입니다.
- traveler1 을 통해서 객체 생성 RC = 1
- traveler2 로 retain RC = 2
- traveler1 이 더이상 쓰이는 시점이 없음 → release 를 통해서 RC Down → RC = 1
- traveler2 도 쓰이는 시점 더이상 없음 → release → RC = 0
그럼 이렇게 자동으로 생명주기를 관리해주는데 어떤 위험성이 있기에 Swift 에서는 참조타입을 사용할 때 주의하라고 할까요?
Observed lifetime에 의존하지 말고 Guaranteed lifetime 에 의존하자
Guaranteed Lifetime : 컴파일러가 반드시 객체를 살려둘 것이 보장된 최소 수명. 마지막 사용 이후까지만 보장됨
Observed Lifetime: 디버깅하거나 deinit, weak, unowned 등에서 실제로 관측되는 객체 수명. 상황 따라 길어질 수도 짧아질 수도 있음
무슨말일까... 제 뜻대로 해석해보았습니다.
식당 테이블 예약
Guaranteed Lifetime
-> 손님이 식사 중일 때까지는 반드시 테이블이 예약되어 있음.
= 식사를 마치고 나가면 테이블은 정리되어도 OK (객체 해제 = release).
Observed Lifetime (관찰 기반 수명)
직원( 외부 코드 or 시스템)이 “저 손님 아직 앉아있겠지?” 하고 접시를 가져다 줬는데… 이미 떠남 -> 손님이 다시 와서 음식을 찾음
= 버그 발생
손님(객체)의 존재를 외부에서 추측하지 말고, 언제까지 존재할지를 확실하게 보장된 시점에서만 테이블을 정리한다(release)
왜 Observed Lifetime에 의존하면 위험할까요?
Swift의 ARC는 컴파일러가 자동으로 retain과 release를 삽입하여 객체의 생명 주기를 관리합니다.
하지만 이 삽입 위치는 컴파일러의 버전이나 최적화 수준에 따라 언제든지 달라질 수 있습니다.
즉, 지금은 잘 작동하는 코드라도,
나중에 Xcode 업데이트나 리팩터링, 컴파일러의 ARC 최적화가 달라지면
객체가 예기치 않게 조기 해제되어 크래시 또는 로직 버그가 발생할 수 있습니다.
이를 요약하면
- Swift에는 관찰 가능한 수명(Observed Lifetime) 이라는 개념이 존재합니다.
- 이는 deinit, weak, unowned 등을 통해 외부에서 객체의 수명을 관찰할 수 있지만,
- 보장된 수명(Guranteed Lifetime) 과는 다를 수 있습니다.
- 컴파일러는 최적화(ARC Optimization) 를 통해 release를 더 일찍 삽입할 수도 있기 때문에, weak!나 unowned처럼 위험한 접근은 나중에 버그로 되돌아올 수 있습니다.
객체 수명은 “보장된 시점” 안에서만 안전하게 사용해야 하며, 외부에서 “살아있을 것이라고 추측”하는 코드는 위험합니다.
메모리 릭 예시
class Traveler { var name: String var account: Account? func printSummary() { if let account = account { print("\(name) has \(account.points) points") } } } class Account { var traveler: Traveler var points: Int } func test() { let traveler = Traveler(name: "Lily") // (1) let account = Account(traveler: traveler, points: 1000) //(2) traveler.account = account //(3) traveler.printSummary() //(4) }
코드 실행 흐름
이제 test() 함수를 통해 객체가 생성되고 ARC가 어떤 방식으로 메모리를 관리하는지 살펴보겠습니다.
1. Traveler 객체 생성
- 힙에 객체가 생성되고, 참조 카운트는 1이 됩니다.
2. Account 객체 생성
- 마찬가지로 힙에 객체가 생성되고, 참조 카운트는 1이 됩니다.
3. Account가 Traveler를 참조
- 이로 인해 Traveler의 참조 카운트는 2로 증가한다.
4. Traveler가 다시 Account를 참조
- Account의 참조 카운트 역시 2로 증가합니다
5. 이제 변수 account는 더이상 사용되지 않음
- ARC는 account 변수의 마지막 사용 직후 release를 삽입하여, 참조 카운트를 1로 줄입니다.
6. printSummary() 함수 호출로 Traveler 정보 출력
- 이 시점이 traveler 변수의 마지막 사용입니다.
- 마지막 참조 카운트는 1이 됩니다.
단계 객체 참조 카운트 변화 설명 1 Traveler 1 생성됨 2 Account 1 생성됨 3 Traveler.account = account Account -> 2 traveler 가 account 를 참조 4 Account.traveler = traveler Traveler -> 2 account 가 traveler 를 참조 5 scope 종료 Traveler -> 1, Account -> 1 지역 변수 해제, 그러나 서로 참조 6 메모리 누수 발생 Rc 1씩 남음 두 객체 모두 참조 카운트가 0이 아님 모든 지역 변수에서 참조가 끝났는데도, 두 객체는 서로를 참조하고 있기 때문에 참조 카운트가 0으로 떨어지지 않습니다.
👉 결과적으로 두 객체 모두 메모리에서 해제되지 않고 메모리 누수(Memory Leak) 가 발생합니다.
해결 방법: weak 또는 unowned 키워드로 한쪽 참조를 약하게 만들면, 순환 참조가 깨져 메모리 해제 가능.
class Traveler { var name: String var account: Account? func printSummary() { if let account = account { print("\(name) has \(account.points) points") } } } class Account { weak var traveler: Traveler? var points: Int } func test() { let traveler = Traveler(name: "Lily") let account = Account(traveler: traveler, points: 1000) traveler.account = account traveler.printSummary() }
이렇게 Account의 traveler 참조를 weak으로 선언하면, 해당 참조는 ARC의 참조 카운트에 영향을 주지 않기 때문에 Traveler 객체가 해제 가능해지고, 그에 따라 Account 객체도 함께 해제됩니다.
단계 객체 참조 카운트 변화 설명 1 Traveler 1 생성됨 2 Account 1 생성됨 3 Traveler.account = account Account → 2 traveler가 account를 참조 4 Account.traveler = traveler Traveler → 1 (변화 없음) weak이므로 RC 증가 안함 5 스코프 종료 Traveler → 0, Account → 1 → 0 지역 변수 해제 → Traveler 먼저 해제됨 → account 내부 weak 참조는 nil 처리됨 6 🎉 정상 해제 둘 다 deinit 메모리 정상 해제 완료 핵심 차이점: weak 참조는 참조 카운트를 증가시키지 않음 → 참조 사이클을 방지할 수 있음
그렇다면 정말 weak을 쓰기만 하면 메모리 릭은 완벽히 막을 수 있는 걸까요?
그렇지 않습니다. weak 자체는 순환 참조를 끊어줄 수 있는 강력한 도구이지만,
이 약한 참조를 언제, 어떻게 접근하는지에 따라 또 다른 위험이 있습니다.
특히 Swift의 ARC 최적화와 관련된 객체 수명(Observed Lifetime) 이슈와 맞물리면…
“앱이 크래시를 일으키는 그 순간”, 우리는 weak만 믿고 있었던 걸 후회하게 될 수 있습니다.
다음 포스트에서는
👉 Observed Lifetime에 의존했을 때 발생할 수 있는 진짜 사례들과,
이를 방지하는 안전한 설계 방법들을 자세히 살펴보겠습니다.
[참고 문헌]
https://developer.apple.com/kr/videos/play/wwdc2021/10216/
'swift' 카테고리의 다른 글
TCA Tutorial 1, 2 - 상태관리와 비동기 처리 TCA (0) 2025.05.14 메모리 릭 방지 weak 가 정답일까 ? - WWDC ARC 2 (0) 2025.05.07 StackView를 통한 iOS 스토리보드 레이아웃 (0) 2023.08.25 다중 스레드와 동기화: 성능과 안정성을 위한 최적의 방법 (0) 2023.05.21 [Swift-UIkit] 명상 컨텐츠 리스트 - UICollectionView을 활용한 명상 컨텐츠 목록 구현 방법 , 버튼 클릭시 타이틀 변경 , 셀에 곡선 (0) 2023.05.20