Swift로 개발하다 보면 [weak self]나 unowned를 코드 곳곳에서 마주치게 됩니다. 이것들이 왜 필요한지, 언제 써야 하는지 명확하게 이해하려면 ARC를 알아야 합니다. 이번 포스팅에서는 ARC가 무엇인지부터 실제 문제가 발생하는 상황과 해결 방법까지 정리를 해보려고 합니다.
ARC란?
ARC(Automatic Reference Counting)는 Swift가 메모리를 자동으로 관리하는 방식입니다.
클래스 인스턴스가 생성되면 Swift는 해당 인스턴스에 대한 참조 카운트(Reference Count)를 1로 설정합니다. 이후 어딘가에서 이 인스턴스를 참조할 때마다 카운트가 1씩 증가하고, 참조가 끊길 때마다 1씩 감소합니다. 카운트가 0이 되는 순간 메모리에서 해제됩니다.
class UserSession {
let userID: String
init(userID: String) {
self.userID = userID
print("UserSession 생성 — \(userID)")
}
deinit {
print("UserSession 해제 — \(userID)")
}
}
var session1: UserSession? = UserSession(userID: "user_001") // RC = 1
var session2 = session1 // RC = 2
session1 = nil // RC = 1
session2 = nil // RC = 0, "UserSession 해제" 출력
```
deinit은 인스턴스가 메모리에서 해제될 때 호출되는 메서드입니다. 위 코드에서는 session2까지 nil로 만드는 순간 RC가 0이 되어 deinit이 호출되는 것을 확인할 수 있습니다.
참고: ARC는 클래스(class) 인스턴스에만 적용됩니다.
struct나 enum 같은 값 타입은 스택에 복사되므로 ARC의 대상이 아닙니다.
순환 참조 - ARC가 풀지 못하는 문제
ARC는 대부분의 상황에서 잘 동작하지만, 두 인스턴스가 서로를 강하게 참조하는 경우 문제가 생깁니다. 이를 순환 참조(Retain Cycle)라고 합니다.
class HomeViewModel {
var title: String
var viewController: HomeViewController? // ViewController를 강하게 참조
init(title: String) { self.title = title }
deinit { print("HomeViewModel 해제") }
}
class HomeViewController {
var viewModel: HomeViewModel?
init() { }
deinit { print("HomeViewController 해제") }
}
var vc: HomeViewController? = HomeViewController() // VC RC = 1
var vm: HomeViewModel? = HomeViewModel(title: "홈") // VM RC = 1
vc?.viewModel = vm // ViewModel RC = 2
vm?.viewController = vc // ViewController RC = 2
vc = nil // ViewController RC = 1 — 해제 안 됨
vm = nil // ViewModel RC = 1 — 해제 안 됨
// deinit이 출력되지 않음 -> 메모리 누수 발생
vc와 vm을 모두 nil로 만들었지만 deinit이 호출되지 않습니다. 그 이유는 두 인스턴스가 서로를 강하게 참조하고 있어서 RC가 0이 되지 않기 때문입니다.
weak - 순환 참조 해결
weak를 사용하면 참조 카운트를 증가시키지 않고 인스턴스를 참조할 수 있습니다. 참조 대상이 해제되면 자동으로 nil이 되므로 반드시 optional로 선언해야 합니다.(안 그러면 크래시 발생)
class HomeViewModel {
var title: String
weak var viewController: HomeViewController? // weak — RC 증가 안 함
init(title: String) { self.title = title }
deinit { print("HomeViewModel 해제") }
}
var vc: HomeViewController? = HomeViewController() // VC RC = 1
var vm: HomeViewModel? = HomeViewModel(title: "홈") // VM RC = 1
vc?.viewModel = vm // ViewModel RC = 2
vm?.viewController = vc // ViewController RC = 1 (weak이라 증가 안 함)
vc = nil // ViewController RC = 0 → "HomeViewController 해제" 출력
// vm?.viewController는 자동으로 nil
vm = nil // ViewModel RC = 0 -> "HomeViewModel 해제" 출력
vm?.viewController를 weak로 선언하면 vc를 nil로 만드는 순간 RC가 0이 되어 정상적으로 해제됩니다.
unowend
unowend도 RC를 증가시키지 않는다는 점에서 weak와 같습니다. 차이점은 참조 대상이 해제되어도 nil이 되지 않고 그대로 남아 있다는 것입니다. 참조 대상이 자신보다 반드시 오래 살아 있다고 확신할 때만 사용합니다.
네트워크 계층에서 APIRequest가 NetworkSession을 참조하는 경우로 살펴보겠습니다.
Request는 session없이 존재할 수 없으므로 Session이 Request보다 반드시 오래 삽니다.
class NetworkSession {
let baseURL: String
init(baseURL: String) { self.baseURL = baseURL }
deinit { print("NetworkSession 해제") }
}
class APIRequest {
let endpoint: String
unowned let session: NetworkSession // session이 request보다 반드시 오래 삼
init(endpoint: String, session: NetworkSession) {
self.endpoint = endpoint
self.session = session
}
deinit { print("APIRequest 해제") }
}
```
클로저에서도 동일하게 사용할 수 있습니다. lazy var로 선언된 클로저는 인스턴스와 생명주기가 같으므로 [unowned self]가 안전합니다.
class SearchViewModel {
let keyword: String
lazy var logSearch: () -> Void = { [unowned self] in
AnalyticsService.shared.log(event: "search", value: self.keyword)
}
init(keyword: String) { self.keyword = keyword }
}
주의점: 이미 해제된 인스턴스에 unowned로 접근하면 런타임 크래시가 발생합니다. 확인이 없다면 weak를 사용하는 것이 안전합니다.
클로저에서의 순환 참조
순환 참조는 두 클래스 인스턴스 사이에서만 생기는 게 아닙니다. 클로저를 프로퍼티로 저장할 때도 발생합니다.
ViewController가 ViewModel의 콜백 클로저에서 self를 캡처하는 상황이 실무에서 가장 자주 마주치는 패턴입니다.
class ProductViewController: UIViewController {
let viewModel = ProductViewModel()
override func viewDidLoad() {
super.viewDidLoad()
// self → viewModel → onFetchCompleted(클로저) → self : 순환 참조 발생
viewModel.onFetchCompleted = {
self.tableView.reloadData()
}
viewModel.fetchProducts()
}
deinit { print("ProductViewController 해제") }
}
class ProductViewModel {
var onFetchCompleted: (() -> Void)? // 클로저를 강하게 참조
func fetchProducts() {
// 네트워크 요청 후
onFetchCompleted?()
}
deinit { print("ProductViewModel 해제") }
}
클로저는 캡처한 self를 강하게 참조하고, self는 viewModel을, viewModel은 onFetchCompleted 클로저를 강하게 참조합니다. 이를 캡처 리스트로 해결합니다.
viewModel.onFetchCompleted = { [weak self] in
guard let self else { return }
self.tableView.reloadData()
}
[weak self]로 캡처하면 클로저가 self를 약하게 참조하므로 순환 참조가 사라집니다. guard let self로 언래핑한 이후에는 self. 없이 프로퍼티에 접근할 수 있습니다.
weak vs unowned 선택 기준
| weak | unowend | |
| Optional 여부 | var, Optional | let 가능, Non-optional |
| 대상 해제 시 | 자동으로 nil | 크래시 |
| 사용 시기 | 대상이 먼저 해제될 수 있을 때 | 대상이 반드시 더 오래 살 때 |
정리
- Swift는 ARC로 클래스 인스턴스의 메모리를 자동 관리한다
- 참조 카운트가 0이 되는 순간 deinit이 호출되고 메모리가 해제된다
- 두 인스턴스가 서로를 강하게 참조하면 순환 참조가 생겨 메모리 누수가 발생한다
- weak은 RC를 증가시키지 않고 대상 해제 시 nil이 된다
- unowned는 RC를 증가시키지 않고 대상 해제 시 크래시가 발생한다
- 클로저에서 self를 캡처할 때 [weak self]로 순환 참조를 방지한다
감사합니다.
잘못된 내용이 있거나 더 좋은 내용 피드백은 언제나 환영합니다!
궁금하신 부분은 댓글로 질문 부탁드립니다!
'Apple > Swift' 카테고리의 다른 글
| [Swift] 왜 Class는 Initializer를 자동으로 만들어주지 않을까? (1) | 2026.04.21 |
|---|---|
| [Swift] SwiftData 상속과 스키마 마이그레이션 알아보기 (3) | 2026.03.18 |
| [Swift] Swift 6로 마이그레이션 하기 (3) | 2026.03.04 |
| [Swift] 싱글톤 패턴(Singleton Pattern) 알아보기 (4) | 2026.02.23 |
| [Swift] Swift 동시성 사용하기 (0) | 2026.02.09 |
