티스토리 뷰
개발을 하다 보면 앱 성능과 안정성을 좌우하는 핵심 요소 중 하나가 바로 메모리 관리라는 사실을 알게된다.
아무리 기능이 뛰어난 코드라도 메모리 누수가 발생하면, 결국 앱의 속도 저하나 예기치 않은 종료로 이어질 수 있다. 특히 Swift나 Objective-C와 같이 참조 타입이 존재하는 언어에서는 객체의 생명주기와 메모리 해제 시점을 명확히 이해하고 관리하는 것이 필수인 것 같다.
이번 글에서는 참조 타입과 Heap, ARC의 개념, 다양한 참조 방식(strong/weak/unowned), 그리고 ARC와 GC의 차이점까지, 메모리 관리의 기본기를 차근차근 정리해보려 한다!!
📝 목차
1. 참조 타입과 Heap
2. ARC란?
3. strong/weak/unowend 참조
4. ARC와 GC의 차이점
1. 참조 타입과 Heap
ARC에 대해 알아보기 전, 참조와 Heap에 대해서 알아야하는 이유는
ARC가 메모리 영역 중 힙 영역을 관리하기 때문이다.
클래스 인스턴스, 클로저 같은 참조 타입은 자동으로 힙에 할당된다.
class Human {
var name: String?
var age: Int?
init(name: String?, age: Int?) {
self.name = name
self.age = age
}
}
func createHuman() {
let gildong = Human(name: "gildong", age: 35)
}
Human이란 클래스가 있고, gildong이라는 인스턴스를 생성하고 값을 초기화했다.
이제 메모리에 어떻게 저장되는지 확인해보면,
- 지역 변수 gildong은 스택에 저장
- 실제 Human 인스턴스는 힙에 할당
스택에 있는 gildong은 힙 영역에 있는 인스턴스(Human)를 참조하고 있고, gildong 안에 힙에 할당된 인스턴스의 주소 값이 들어있다.
let gildong = Human(name: "gildong", age: 35)
let clone = gildong
이렇게 하면 gildong과 clone은 같은 객체를 가리킨다.
🧐 이렇게 clone이란걸 만들면 인스턴스가 복사될까??
=> 클래스는 참조 타입이기 때문에 인스턴스가 복사되는 것이 아니라, 같은 힙 영역의 인스턴스를 가지게 된다.
여기까지 알아봤을때 참조 타입과 heap에 대해 좀 알게 되었는데 중요한 점은
힙 영역은 꼭 사용하고 난 후에는 메모리 해제를 해줘야 한다!!
🧐 왜 매모리 해제를 해야할까??
=> 메모리 누수(스택 영역은 함수가 끝나면 자동으로 메모리 해제되지만, 힙은 개발자가 명시적으로 해제하지 않으면 메모리가 남아있다)
🧐 스택은 함수가 종료가 되면 자동으로 저장된 메모리도 해제되는데 heap은??
=> swift를 사용하면서 heap에 저장된 메모리를 해제하는 코드를 쓴 적이 없었던 것 같았는데, 그 이유가 ARC 때문이다.
ARC가 자동으로 heap 영역에 있는 인스턴스를 해제해주기 때문!!
2. ARC란?
ARC는 swift에서 객체의 메모리 생명 주기를 자동으로 관리하는 시스템이다.
공식 문서에서 "ARC는 클래스 인스턴스가 더 이상 필요하지 않을 때 메모리를 자동으로 해제한다" 라고 되어있다.
📌 ARC의 메모리 관리 기법
ARC에서 RC는 Reference Count로 메모리의 참조 횟수를 뜻한다.
즉 ARC는 참조 카운트(RC)가 0이되면 해당 객체는 더 이상 필요없다고 판단되어 메모리에서 해제되도록 한다.
만약 참조 횟수가 10이라면, 해당 인스턴스가 10군데에서 참조하고 있다는 뜻이다.
참조 횟수가 0이라면, 해당 인스턴스가 아무데서도 참조하지 않고 있기 때문에 메모리에서 해제되도록 한다.
=> 모든 인스턴스는 자신의 RC 값을 가지고 있다고 할 수 있다.
📌 참조 횟수가 증가하는 경우는? (+)
1️⃣ 인스턴스가 주소 값을 변수에 할당할 때
let gildong = Human(name: "gildong", age: 35)
이렇게 Human이라는 인스턴스가 gildong이라는 변수에 할당 되면 참조 횟수가 증가하게 된다.
2️⃣ 기존 인스턴스를 다른 변수에 대입할 때
let clone = gildong
기존에는 gildong만 인스턴스를 참조하고 있었는데 clone도 참조하게 되어서 참조 횟수가 증가하게 된다.
📌 참조 횟수가 감소하는 경우는? (-)
1️⃣ 인스턴스를 가리키던 변수가 메모리에서 해제되었을 때
func makeClone(_ origin: Human) {
let clone = origin // ② Instance RC : 2
}
let gildong = Human(name: "gildong", age: 35) // ① Instance RC : 1
makeClone(gildong)
// ③ Instance RC : 1
- gildong이 Human 인스턴스를 참조 (+1)
- clone이 Human 인스턴스를 참조(+1)
- 함수가 종료되고, clone 참조를 해제(-1)
함수가 종료되면 참조를 해제해서 참조 횟수가 감소한다.
2️⃣ nil이 지정된 경우
var gildong: Human? = .init(name: "gildong", age: 35) // ① Instance RC : 1
gildong = nil // ② Instance RC : 0 (메모리 해제)
gildong이 nil 값으로 바뀌면 메모리에서 해제되어서 참조 횟수가 감소한다.
3️⃣ 변수에 다른 값을 대입한 경우
var gildong: Human? = .init(name: "gildong", age: 35) // ① gildong Instance RC : 1
var clone: Human? = .init(name: "gildong2", age: 35) // ② clone Instance RC : 1
gildong = clone // ③ clone Instance RC : 2, gildong Instance RC : 0 (메모리 해제)
gildong 인스턴스와 clone 인스턴스가 각각 만들어지고, 모두 RC가 1이다.
gildong = clone 에서 clone 인스턴스는 RC = 2로 바뀌고, gildong 인스턴스는 RC = 0으로 감소한다.
3. strong/weak/unowned 참조
여기까지 ARC에 대해 알아봤는데 ARC가 자동으로 메모리를 관리해주지만, 순환 참조 문제는 자동으로 해결되지 않는다.
따라서 개발자는 다음과 같은 상황을 신경을 써야한다.
- 객체 간에 서로를 참조하는 구조(특히 부모-자식, delegate 패턴, 클로저 캡처 등)에서 순환 참조가 발생하지 않도록 weak/unowned 참조를 적절히 사용해야 한다.
- 객체의 생명 주기와 참조 관계를 명확히 이해하고 설계해야 한다.
1️⃣ 강한 참조
강한 참조는 기본 참조로, 객체의 소유권을 가지며 참조 카운트를 증가시킨다.
class Human {
var name: String?
var age: Int?
init(name: String?, age: Int?) {
self.name = name
self.age = age
}
}
func createHuman() {
let gildong = Human(name: "gildong", age: 35)
}
이렇게 Human이라는 인스턴스가 gildong이라는 변수에 할당 되면 참조 횟수가 증가하게 된다고 했다.
이 경우가 바로 강한 참조인데,
=> 인스턴스의 주소값이 변수에 할당될 때, RC가 증가하면 강한 참조다!
기본값이 strong이기 때문에 어떤 처리를 하지 않으면 강한 참조가 되어버린다.
class Person {
var name: String
var pet: Pet? // 기본적으로 강한 참조
init(name: String) { self.name = name }
}
class Pet {
var type: String
var owner: Person? // 기본적으로 강한 참조
init(type: String) { self.type = type }
}
let gildong = Person(name: "gildong")
let cat = Pet(type: "cat")
이렇게 heap에 각각의 인스턴스를 생성하는 경우에는 문제가 없지만, 다음과 같은 경우에는 문제가 발생한다.
gildong.pet = cat
cat.owner = gildong
gildong과 cat이 서로를 강하게 참조해 순환 참조가 발생하게 된다.
🧐 순환 참조란??
순환 참조란 두 객체가 서로를 강하게 참조(소유)해서, 둘 다 메모리에서 해제되지 못하는 상황을 말한다.
1. gildong이라는 Person 객체가 생성
=> gildong RC = 1
2. cat이라는 Pet 객체가 생성
=> cat RC = 1
3. gildong.pet = cat
=> cat RC = 2(gildong이 cat을 강하게 참조)
4. cat.owner = gildong
=> gildong RC = 2(cat이 gildong을 강하게 참조)
이렇게 되면 gildong과 cat에 대한 외부 변수(예: let gildong, let cat)가 모두 해제되어도,
gildong => pet(cat), cat => owner(gildong)으로 강하게 참조하고 있어서 서로의 RC가 1씩 남아있게 된다.
즉, 참조 카운트가 0이 되지 않아 ARC가 메모리를 해제할 수 없게된다.
이것이 바로 순환 참조(Strong Reference Cycle)!!!
순환 참조를 해결하기 위해서는 weak와 unowned를 사용하면 된다.
2️⃣ 약한 참조
약한 참조(weak)의 특징은 다음과 같다.
- 인스턴스를 참조할 때, RC를 증가하지 않는다.
- 참조하던 인스턴스가 메모리에서 해제된 경우, 자동으로 nil이 할당되어 메모리가 해제된다.
인스턴스가 메모리 해제된 경우, 자동으로 nil이 할당되기 때문에 옵셔널 타입을 가진다.
약한 참조는 순환 참조를 일으키는 프로퍼티 앞에 weak를 붙여주면 된다.
class Person {
var name: String
weak var pet: Pet? // 약한 참조
init(name: String) { self.name = name }
}
class Pet {
var type: String
var owner: Person?
init(type: String) { self.type = type }
}
let gildong = Person(name: "gildong")
let cat = Pet(type: "cat")
gildong.pet = cat // 순환 참조 방지
cat.owner = gildong
week는 인스턴스를 참조할 때, RC를 증가하지 않으므로 Pet 인스턴스는 RC가 1이 된다.
Pet이 해제될 때, Person의 pet은 자동으로 nil이 되어서 순환참조를 방지할 수 있다.
3️⃣ 미소유 참조
미소유 참조(unowned)의 특징은 다음과 같다.
- 인스턴스를 참조할 때, RC를 증가하지 않는다.
- 항상 값이 있다고 가정, 변수 타입이 옵셔널이 아니고, nil이 될 수 없다.
- 참조하는 객체가 자신보다 오래 살아있거나, 적어도 동시에 해제된다는 것이 확실할 때만 사용해야 한다.
예를 들어, 신용카드는 반드시 고객이 있을 때만 존재하고, 만약 고객이 먼저 해제되면 신용카드도 무의미해진다.
미소유 참조는 순환 참조를 일으키는 프로퍼티 앞에 unowned를 붙여주면 된다.
class Person {
let name: String
var card: CreditCard?
init(name: String) { self.name = name }
}
class CreditCard {
let number: UInt
unowned let owner: Person // 미소유 참조
init(number: UInt, owner: Person) {
self.number = number
self.owner = owner
}
}
let jee: Person? = Person(name: "Jee")
if let person = jee {
person.card = CreditCard(number: 1004, owner: person)
}
CreditCard의 owner는 unowned로 선언되어, 고객이 해제되면 신용카드도 더 이상 의미가 없다.
만약 고객이 먼저 해제되고 신용카드에서 owner에 접근하면 런타임 에러가 발생한다.
참고로 Swift 5.0 이후,
unowned 참조도 옵셔널 타입(unowned var delegate: SomeDelegate?)으로 선언할 수 있다고 한다!!
이 경우, 참조 대상이 해제되면 자동으로 nil이 할당된다.
4. ARC와 GC의 차이점
📌 GC란?
GC(Garbage Collection)는 주로 Android, Java에서 사용하며, ARC와 가장 큰 차이점은 순환 참조를 자동으로 관리한다는 점이다. GC는 런타임에 주기적으로 모든 객체 그래프를 스캔해서 더 이상 사용되지 않는(참조되지 않는) 객체를 찾아내어 메모리에서 해제한다.
이 과정에서 순환 참조로 연결된 객체들도 외부에서 더 이상 참조되지 않으면 자동으로 해제되기 때문에, 개발자가 순환 참조 문제를 신경 쓸 필요가 없다.
하지만 GC는 객체 그래프를 주기적으로 스캔하고 mark-and-sweep 알고리즘을 실행해서 추가적인 메모리(RAM)와 CPU 자원을 소모한다. 성능을 확보하려면 실제 필요한 메모리의 2~5배까지 메모리를 확보해야 하며, 그렇지 않으면 프로그램이 일시적으로 멈추는(stall, pause) 현상이 발생할 수 있다.
(예를 들어, 프로그램에 자체 객체에 100MB의 RAM이 필요한 경우 GC는 200~300MB의 공간을 할당해야 한다.)
=> Android 기기들이 iOS 기기보다 더 많은 RAM을 탑재하는 이유 중 하나가 GC 때문인 것 같다...?
ARC는 객체의 참조 카운트를 컴파일 타임에 자동으로 관리하고, 참조 카운트가 0이 되는 즉시 메모리에서 해제한다. 순환 참조가 발생하면 참조 카운트가 0이 되지 않아 메모리 누수가 생길 수 있기 때문에, 개발자가 weak, unowned 같은 수정자를 사용해 직접 순환 참조를 방지해야 한다. ARC는 컴파일 타임에 해제 코드를 자동 삽입하므로 런타임 오버헤드가 없고, 메모리 해제 시점이 예측 가능하며, 주요 성능 문제는 발생하지 않는다.
📌 GC vs ARC
정리하면,
- GC는 순환 참조를 자동으로 해결하지만 더 많은 메모리와 CPU를 필요로 하고, 일시적 성능 저하가 있을 수 있다.
- ARC는 개발자가 순환 참조를 직접 관리해야 하지만, 메모리 사용량이 적고 성능 저하가 거의 없다.
구분 | ARC (iOS) | GC (Android 등) |
동작 시점 | 컴파일 타임에 retain/release 코드 삽입, 참조 카운트 기반 | 런타임에 주기적으로 참조 없는 객체를 탐색 후 해제 (Mark & Sweep 등) |
순환 참조 처리 | 개발자가 weak/unowned 등으로 직접 관리 | 순환 참조도 자동 탐지 및 해제 가능 |
예측성 | 객체 해제 시점이 명확, 예측 가능 | GC 동작 시점 예측 어려움, 앱 멈춤(pause) 가능성 |
성능 | 오버헤드 적음, 실시간 성능 우수 | GC 동작 중 일시적 성능 저하 가능 |
메모리 사용 | 메모리 사용량 적음 | 상대적으로 메모리 사용량 많음 |
iOS에서 ARC를 선택한 이유도 GC보다 성능과 메모리 효율이 뛰어나기 때문에, 개발자가 순환 참조를 직접 관리해야 하는 단점이 있지만, 이러한 점을 감수하고서라도 ARC의 이점을 택했다고 생각한다.
출처
'iOS' 카테고리의 다른 글
[iOS] - Firebase Fuctions를 사용해서 푸시 알림 구현 (2) | 2025.07.13 |
---|---|
[iOS] - 프로세스와 스레드 관리 방법 (3) | 2025.06.24 |
[iOS] - 앱 실행 원리와 성능에 영향을 주는 요소에 대해 알아보기 (1) | 2025.05.24 |
[Swift] - API & Socket 요청 리팩토링 및 인증 처리 개선 (1) | 2025.02.16 |
[Swift] - fastlane + GitHub Actions CI 구축(3) (0) | 2025.01.20 |