이번 포스팅은 Class와 Struct에서 참조 카운트에 대해 알아보겠습니다.
지난 포스팅
Class vs Struct (1)
Class의 메모리 관리는 힙 영역에서 ARC에 의해 이루어지고, Struct의 메모리 관리는 스택 영역에서 LIFO 방식으로 알아서 된다고 알고 있습니다.
ARC에 대한 자세한 내용은 길기 때문에 ARC에 대한 포스팅을 따로 하고, 이번 포스팅에서는 참조 카운트 계산과 메모리 영역에서 어떻게 이루어지는지 간단하게 시각화하여 나타낼 예정입니다.
또한, 클래스 프로퍼티를 가지고 있는 클래스와 구조체를 비교하여 참조가 어떻게 이루어지는지도 알아볼 예정입니다.
먼저, 지난 포스팅에서 설명했던 클래스는 참조 타입이고 구조체는 값 타입이라고 했었습니다.
이를 비교하기 위한 아주 기본적인(다들 알고 계신) 코드는 이렇습니다.
func address(_ object: AnyObject) {
print(Unmanaged.passUnretained(object).toOpaque())
}
class ExamplaryClass {
var a: Int
init(a: Int) {
self.a = a
}
func updateA(_ a: Int) {
self.a = a
}
}
let exampleClass = ExamplaryClass(a: 10)
let reference = exampleClass
print(exampleClass.a) // 10
print(reference.a) // 10
두 값 모두 10이 출력됩니다.
exampleClass.updateA(20)
print(exampleClass.a) // 20
print(reference.a) // 20
exampleClass의 a를 20으로 업데이트하면, 두 값 모두 20이 출력됩니다.
reference는 exampleClass와 같은 클래스 인스턴스에 대한 참조를 가지고 있기 때문입니다.
reference.updateA(30)
print(exampleClass.a) // 30
print(reference.a) // 30
반대로 reference의 a를 30으로 업데이트해도 두 값 모두 30이 출력됩니다.
address(exampleClass) // 0x0000600001801000
address(reference) // 0x0000600001801000
둘의 참조된 주소값을 확인하면 동일하게 나옵니다.
이것이 참조 타입인 클래스의 특징입니다.
메모리에선 최종적으로 다음과 같이 할당되어 있습니다.
struct ExamplaryStruct {
var a: Int
mutating func updateA(_ a: Int) {
self.a = a
}
}
var exampleStruct = ExamplaryStruct(a: 10)
var copy = exampleStruct
클래스와 다른 점이 두 가지가 있습니다.
첫 번째는 updateA() 메서드의 앞에 붙은 mutating
키워드 입니다.
Swift에서 값 타입은 기본적으로 내부에서 인스턴스를 변경할 수 없기 때문에, mutating 키워드를 통해 변경할 수 있도록 해주어야 합니다.
두 번째는 let
이 아닌 var
입니다.
클래스는 참조 타입이므로, let으로 선언해도 인스턴스의 내부 프로퍼티를 변경할 수 있습니다. (프로퍼티가 var인 경우)
let은 인스턴스에 대한 참조가 변하지 않는다는 표현일 뿐, 인스턴스의 프로퍼티는 수정할 수 있다는 것을 의미합니다.
반면에 구조체는 값 타입이므로, let으로 선언하면 인스턴스 뿐만 아니라 내부 프로퍼티도 모두 수정할 수 없습니다.
exampleStruct.updateA(20)
print(exampleStruct.a) // 20
print(copy.a) // 10
exampleStruct의 a값을 20으로 변경하면, exampleStruct의 a만 변경됩니다.
구조체는 클래스와 다르게 값 타입이므로, copy는 exampleStruct와 동일한 인스턴스를 가리키는 참조 값이 아닌 새로운 복사본이기 때문입니다.
copy.updateA(30)
print(exampleStruct.a) // 20
print(copy.a) // 30
copy의 a값을 변경하면, 위와 같은 이유로 copy의 a값만 변경됩니다.
address(exampleStruct) // Error
address(copy) // Error
구조체는 클래스와 다르게 값 타입이기 때문에, 특정 인스턴스를 가리키는 참조(포인터)가 없어 해당 코드를 실행할 수 없습니다.
그렇기 때문에 뒤에 나올 CFGetRetainCount() 또한 실행할 수 없습니다.
메모리에선 최종적으로 다음과 같이 할당되어 있습니다.
ARC가 힙 영역에서 참조 카운트를 계산해서 카운트가 0이 되면 인스턴스를 메모리 상에서 해제합니다.
코드와 함께 참조 카운트가 어떻게 계산되는지 알아보도록 하겠습니다.
참조 카운트를 확인하기 위해서는 CFGetRetainCount() 라는 함수를 사용해주면 됩니다.
해당 함수에 대한 정의는 공식문서에서 다음과 같이 나와있습니다.
Core Foundation 객체의 참조 카운트를 반환합니다.
특정 객체의 현재 참조 카운트가 몇인지 나타내줍니다.
하지만, 클래스 하나를 생성하고 바로 참조 카운트를 해당 함수를 통해 출력하면 2가 나옵니다.
함수 호출 시, 일시적으로 1이 증가하여 출력되어서 그런 것 같습니다.
클래스가 아닌 구조체는 힙 영역에 존재하지 않기 때문에 사용할 수 없습니다.
class AClass {
var bClass: BClass? = nil
init() {
print("AClass 메모리 할당")
}
deinit {
print("AClass 메모리 해제")
}
}
class BClass {
var aClass: AClass? = nil
init() {
print("BClass 메모리 할당")
}
deinit {
print("BClass 메모리 해제")
}
}
var a: AClass? = AClass() // AClass 메모리 할당
var b: BClass? = BClass() // BClass 메모리 할당
생성자를 통해 메모리 할당 시점을 출력하도록 했기 때문에, a와 b를 생성하면 각 클래스의 생성자가 호출됩니다.
print(CFGetRetainCount(a)) // 2
print(CFGetRetainCount(b)) // 2
위에서 설명했듯이, AClass는 a, BClass는 b가 각각 하나씩 참조하고 있어 참조 카운트가 1입니다.
CFGetRetainCount()를 통해 호출했기 때문에 2로 출력됩니다.
address(a!) // 0x00006000023100c0
address(b!) // 0x00006000023106a0
현재 시점의 메모리 할당을 시각적으로 표현하면 이런 식으로 되어있겠네요.
그럼 각 클래스의 프로퍼티에 다음과 같이 서로의 클래스를 넣어주면 어떻게 될까요?
a?.bClass = b
b?.aClass = a
print(CFGetRetainCount(a)) // 3
print(CFGetRetainCount(b)) // 3
각각 3이 출력됩니다.
기존의 참조 카운트 값에서 서로를 힙 영역에서 다시 참조하기 때문에 1씩 증가합니다.
address(a!.bClass!) // 0x00006000023106a0
address(b!.aClass!) // 0x00006000023100c0
메모리 주소를 보면 서로를 참조하고 있는 것을 알 수 있습니다.
다음과 같이 말이죠.
a = nil
b = nil
print(CFGetRetainCount(a)) // Error
print(CFGetRetainCount(b)) // Error
a와 b를 메모리 상에서 해제하였기 때문에 CFGetRetainCount()를 해주면 포인터가 존재하지 않아 에러가 발생합니다.
또한, 이 상태에서 a와 b를 nil로 하여 메모리 해제를 시켜주려 해도 완전히 해제되지 않습니다.
AClass의 bClass는 BClass를, BClass의 aClass는 AClass를 아직 참조하고 있기 때문입니다.
정상적으로 모두 메모리에서 해제가 되었다면 nil을 대입하였을 때, deinit에서 구현한 텍스트가 출력되어야 하지만 출력되지 않는 것을 보면 알 수 있죠.
메모리 상에선 다음과 같습니다. 아직 서로를 참조하고 있어 참조 카운트가 1씩 존재하기 때문에, 0이 되어야 메모리에서 완전히 해제해주는 ARC는 이들을 가만히 내비둡니다.
따라서, 메모리 상에서 완전히 이들을 해제해주고 싶다면 다음과 같은 과정을 거쳐야 합니다.
a?.bClass = nil
b?.aClass = nil
print(CFGetRetainCount(a)) // 2
print(CFGetRetainCount(b)) // 2
a = nil // AClass 메모리 해제
b = nil // BClass 메모리 해제
이를 강한 참조(Strong Reference)라고 합니다.
각 프로퍼티를 먼저 메모리에서 해제를 해 준 다음 자신의 메모리를 해제해 주어야 완전하게 참조 카운트가 0이 됩니다.
이를 피하기 위해 weak var 또는 unowned var를 통해 약한 참조를 해주어야 합니다만,
강한 참조, 약한 참조 등은 ARC에 대한 포스팅을 할 때 다시 다뤄볼 예정입니다.
이번 포스팅에서는 그냥 참조 카운트가 이런 식으로 이루어진다 라고 생각하면 될 것 같습니다.
그럼 조금 더 복잡한 구조로 예시를 확인해보겠습니다.
class DummyClass { }
class ExamplaryClass {
let dummyClass1 = DummyClass()
let dummyClass2 = DummyClass()
let dummyClass3 = DummyClass()
}
struct ExamplaryStruct {
let dummyClass1 = DummyClass()
let dummyClass2 = DummyClass()
let dummyClass3 = DummyClass()
}
다음과 같이 특정 클래스가 있고, 해당 클래스를 프로퍼티로 여러개 가진 클래스와 구조체가 있습니다.
여기서 여러 객체를 생성하고 각 참조 카운트와 주소를 확인해 보면 어떻게 될까요??
let examplaryClass = ExamplaryClass()
let reference1 = examplaryClass
let reference2 = examplaryClass
let reference3 = examplaryClass
ExamplaryClass를 참조하는 examplaryClass 하나를 생성하고 이를 같이 참조하는 3개의 참조를 생성했습니다.
print(CFGetRetainCount(examplaryClass)) // 5
print(CFGetRetainCount(examplaryClass.dummyClass1)) // 2
print(CFGetRetainCount(examplaryClass.dummyClass2)) // 2
print(CFGetRetainCount(examplaryClass.dummyClass3)) // 2
참조 카운트는 다음과 같습니다. 1씩 빼면 4, 1, 1, 1인 것이죠.
examplaryClass의 경우, ExamplaryClass 인스턴스를 생성할 때 1 증가. -> 0 + 1 = 1
reference1, 2, 3에서 참조 할 때 각각 1씩 증가. -> 1 + 3 = 4
이렇게 참조 카운트는 4가 됩니다.
각 dummyClass의 경우, ExamplaryClass 인스턴스를 생성할 때 이미 새로운 서로 다른 DummyClass 인스턴스를 3개 생성하기 때문에 각 참조 카운트는 1입니다.
그럼, 주소값을 확인 해볼까요?
address(examplaryClass) // 0x0000600001568510
address(reference1) // 0x0000600001568510
address(reference2) // 0x0000600001568510
address(reference3) // 0x0000600001568510
address(examplaryClass.dummyClass1) // 0x00006000026cc040
address(reference2.dummyClass1) // 0x00006000026cc040
address(reference1.dummyClass2) // 0x00006000026cc050
address(reference3.dummyClass2) // 0x00006000026cc050
examplaryClass, reference모두 같은 인스턴스를 참조하고 있죠.
dummyClass들에 대한 참조 또한 같은 더미 클래스라면 같은 주소값을 가지고 있습니다.
같은 인스턴스를 참조하고, 해당 인스턴스가 가진 더미 클래스들도 그 더미 클래스의 인스턴스를 참조하고 있기 때문입니다.
말로 설명하기 보다는 그림으로 이해하는게 더 빠를 것 같네요.
메모리 상에서는 이렇게 되어있습니다.
let examplaryStruct = ExamplaryStruct()
let copy1 = examplaryStruct
let copy2 = examplaryStruct
let copy3 = examplaryStruct
ExamplaryStruct 값을 가진 examplaryStruct 하나를 생성하고 이의 복사본 3개를 생성했습니다.
print(CFGetRetainCount(examplaryStruct)) // Error
print(CFGetRetainCount(examplaryStruct.dummyClass1)) // 5
print(CFGetRetainCount(examplaryStruct.dummyClass2)) // 5
print(CFGetRetainCount(examplaryStruct.dummyClass3)) // 5
첫 번째 출력문은 실행할 수 없습니다. CFGetRetainCount()에 대한 정의를 설명할 때, 구조체는 사용할 수 없다고 써놓았죠?
여기서는 클래스와 확연하게 다른 점이 보입니다.
각 더미클래스의 참조 카운트가 4라는 점인데요. 왜 이렇게 되는지 알아보도록 합시다.
examplaryStruct를 생성할 때, dummyClass1, 2, 3을 가진 구조체를 하나 생성합니다.
더미 클래스들은 서로 다른 객체이기 때문에, 각각 다른 DummyClass에 대한 참조를 가지고 있습니다.
즉, ExamplaryStruct는 3개의 DummyClass 인스턴스에 대한 참조를 가진 구조체로써 스택 영역에 저장되는 것입니다.
copy 1, 2, 3은 구조체 이므로 값 타입이며, examplaryStruct의 복사본입니다.
스택 영역에 examplaryStruct와 동일한 구조체를 복사하여 저장됩니다.
이 구조체이 가지고 있는 더미 클래스들 또한 원본과 같은 참조를 가지고 있습니다.
따라서, examplaryStruct, copy1, 2, 3에 있는 각각의 더미 클래스들은 모두 3개의 더미클래스에 대한 참조인 것이죠. 그렇기 때문에 참조 카운트가 4인 것입니다.
메모리 상에는 다음과 같이 나타날 것입니다.(조금 눈아프지만 이렇게밖에 표현 못해서 죄송합니다..)
실제 개발에서는 이보다 더 복잡한 상황도 많을 것이라 예상됩니다.
따라서, 클래스 및 클로저 등을 사용하는 경우, 참조를 적절하게 활용하고 계산하여 메모리 누수를 최대한 방지해야겠죠?
이번 포스팅은 저도 처음 공부할 때, 참조 카운트 증가와 해제가 헷갈렸습니다.
특히, 구조체 속 클래스와 클래스 속 클래스에서 머리가 좀 아팠습니다.
하지만 직접 그림을 그려가며 공부해보니 이해가 잘 되더라구요.
다음에 앱을 개발하며 메모리 누수를 생각해야 할 때 그림을 그려가며 메모리 방지를 위해 노력해야겠다 생각했습니다 ㅎㅎ..
Class vs Struct에 대한 포스팅은 아직 끝나지 않았습니다..
3번째는 클래스와 구조체가 어떤 상황에서 조금 더 빠른지 몇 가지 실험을 할 예정입니다.
https://ios-development.tistory.com/1464
https://stackoverflow.com/questions/24058906/printing-a-variable-memory-address-in-swift
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/methods/
https://developer.apple.com/documentation/corefoundation/1521288-cfgetretaincount?language=objc