스위프트를 공부하기 위해 책을 펴자마자 모르는 단어가 보여 공부를 시작하게 되었습니다. 이제 시작 단계에 있다보니 책을 다음장으로 넘기는 것도 쉽지 않은 것 같습니다.
ARC(automatic reference counting)이란?
Swift에서 앱의 메모리 사용을 관리하기 위해 사용하는 기법으로 인스턴스의 메모리 참조 횟수를 자동으로 관리해 더 이상 사용되지 않는 인스턴스를 해제해주는 역할을 합니다.
만약 하나의 person이라는 객체를 instance1에 할당한 후(ex: var instance1 = Person(data)), intance2에도 할당하게 되면(var instance2 = instance1) 실제로 person은 2개의 참조(instance1 and instance2)를 가지게 된다는 것입니다. 이렇게 메모리에 올라간 인스턴스에 연결된 변수의 갯수를 개발자가 수동으로 관리하는 것이 아니라 Swift에서 자동으로 관리해주는 것을 의미하는게 ARC입니다. 이렇게 올라간 참조 횟수는 인스턴스들(instance1 and instance2)이 nil이 되면서(더 이상 메모리 주소를 연결하지 않게 되면서) 줄어들게 됩니다. 그렇게 참조 횟수가 0이 된 인스턴스는 더 이상 사용되지 않는 것으로 간주되어 메모리 상에서 내려가게 됩니다.
자동으로 메모리를 관리해주는 개념은 자바의 garbage collection을 떠오르게 합니다. 둘 다 개발자가 수동으로 메모리에 올라간 데이터들을 관리하지 않게 하기 위해 만들어진 것이라는 공통점을 지니고 있습니다. 그렇다면 자바의 garbage collection과 비교하면서 ARC에 대해 알아보겠습니다.
ARC(swift) vs Garbage Collection(java)
먼저 두 기법의 가장 큰 차이점은 참조 시점입니다. 먼저 ARC의 경우에는 컴파일을 시도하는 시점에 이미 인스턴스의 해제 시기가 정해집니다. 이는 컴파일시에 참조 카운트를 검사해 다시 0이 되는 위치에 해제를 위한 코드를 심어놓는다고 판단할 수 있습니다. 이렇게 컴파일 시에 인스턴스의 해제를 지정하게 되면 앱을 실행하고 있는 도중에 시스템 자원을 추가로 소모해서 메모리 관리를 위한 판단을 따로 할 필요가 없습니다.(이미 모든 참조 횟수를 컴파일시에 카운팅 했고, 그 수치들이 올바르게 0으로 돌아가 해제되었다고 가정하기 때문에) 또한, 컴파일이 끝나는 시점에 이미 모든 인스턴스의 해제 시점이 지정되는 것이기 때문에 언제 인스턴스가 메모리 상에서 제거될 지에 대한 예측이 가능해집니다. 하지만, 컴파일시에 이미 모든 인스턴스의 해제 시점을 정해놓기 때문에 개발자가 ARC에 대해 제대로 이해하지 못해 컴파일시 미처 자동으로 해제되지 못한 인스턴스가 있다면 그 인스턴스는 사라지지 않고 앱의 러닝타임중에 따로 파악하는 기능이 존재하지 않아 메모리 릭을 야기시키는 존재가 될 수 있습니다.
반대로 Garbage Collection의 경우에는 러닝 타임 시점에 메모리를 관리하기 시작합니다. 이렇게 러닝 타임동안 계속해서 메모리를 확인하고 GC에 지정된 조건에 따라 인스턴스를 해제하게 되면 ARC에서 봤던 단점과는 반대로 인스턴스가 특정 상황 때문에 계속해서 참조 중이라고 가정하더라도 영원히 살아남지 않고 해제가 되게 됨으로서 결국 인스턴스가 해제될 가능성을 높여줍니다. 또한, 러닝 타임중에 알아서 메모리를 관리하기 때문에 개발자가 코드를 작성할 때 메모리 해제 관련 부분에서 크게 신경쓸 부분이 줄어듭니다. 사실 GC는 따로 GC를 튜닝하지 않는 이상, 대부분의 경우 원리를 알게 되더라도 코드 상에서 그 부분을 유도하기는 쉽지 않기 때문에 개발자들이 따로 건들 수 있는 부분은 거의 없습니다.(물론 System.gc()를 통해 GC를 직접 호출할 수도 있지만, 이 코드가 실행된다고 해서 100% GC가 호출되는 것도 아닐 뿐더러 강제로 호출시키는 경우 GC의 특성상 자원을 크게 잡아먹기 때문에 앱의 성능이 크게 저하될 수 있습니다.) 그러나 러닝 타임 중에 메모리를 관찰하고 관리한다는 것은 러닝 타임 내내 계속해서 GC에 자원을 추가적으로 할당해야한다는 것을 의미하므로 동일한 코드로 ARC가 적용된 코드보다 훨씬 느려지거나 더 많은 자원을 소모하게 됩니다.
강한 참조 vs 약한 참조
ARC의 규칙에는 참조 횟수가 올라가는 강한 참조와 참조 횟수를 카운팅하지 않는 약한 참조가 존재합니다. 우리가 생각하는 가장 기본적인 참조는 변수 선언 이후 변수에 데이터를 할당하게 되는 일련의 과정이고, 이는 참조 횟수를 카운팅하는 강한 참조의 형태를 띄게 됩니다. 그러니까 코드를 작성할 때 아무런 장치 없이 사용하게 되는 경우에는 ARC가 인스턴스가 참조되었다고 판단하는 것입니다.
reference1 = Person(name: "John Appleseed")
// Prints "John Appleseed is being initialized"
reference2 = reference1
reference3 = reference1
위의 코드는 스위프트 가이드 문서에서 제공하는 예제로, 이때 Person(name: "John Appleseed")의 참조 횟수는 총 3(reference1, reference2, reference3)이 됨을 알 수 있습니다.
reference1 = nil
reference2 = nil
또한 여기에 nil을 할당하게 되면 reference1과 reference2가 더이상 Person을 참조하지 않게 되면서 참조 횟수가 줄어듭니다. 이때 총 3개의 참조 중 2개가 nil이 되었기 때문에(더 이상 변수가 가리키고 있는 메모리 주소값이 존재하지 않아 Person의 주소를 참조하고 있는 변수가 2개 줄어들게 됩니다.) 참조 횟수는 3-2를 통해 1로 변경되게 됩니다. 이런식으로 기존에 할당했던 메모리를 해제하는 과정을 통해 참조 횟수를 조절할 수 있습니다.
그러나 이러한 참조에는 순환 문제가 존재합니다. GC를 공부할때도 자주 등장하는 것인데요. 바로 A 인스턴스와 B 인스턴스가 서로를 참조하게 되면 일어나는 문제로, A 객체의 변수가 B 인스턴스를 참조하고, B 객체의 변수가 A 인스턴스를 참조하게 될 때 발생하는 문제입니다.
스위프트 공식 문서에서 제공하는 그림으로 강한 참조 순환 문제에 대해서 설명하는 그림입니다.
위의 사진을 보시면 john이라는 변수에 Person 인스턴스가 하나, unit4A 변수에 Apartment 인스턴스가 하나 매칭되어 있는 것을 확인할 수 있습니다. 그런데 john의 apartment 변수가 init4A 인스턴스를 참조하고 있고, unit4A의 변수 tennant가 john을 참조하고 있는 것을 볼 수 있습니다.
이러한 경우에, john과 unit4A의 변수의 참조가 사라져도 두 인스턴스는 각각을 1번씩 참조하고 있기 때문에 참조 횟수가 0이 되지 않아 메모리에서 해제가 되지 않는 문제가 발생합니다. 이러한 순환이 여러군데에서 일어나게 되면 OOM이 발생하게 될 가능성이 생기고, 실제로 사용하지 않는 인스턴스가 계속해서 메모리 한 켠을 차지하게 됩니다. 더욱이 GC처럼 오랫동안 쓰이지 않으면 자동으로 해제해주지도 않기 때문에 많은 문제를 야기할 수 있습니다.
물론 이 문제를 해결하기 위해서는 각각의 apartment, tennant 변수를 john, unit4A 변수를 초기화하기 전에 초기화해주는 것으로 막을 수 있습니다. 하지만 너무 많은 변수가 묶여있거나 실수로 인해 해제하지 않은 경우 이러한 문제는 충분히 야기될 수 있습니다.
그래서 이렇게 순환 문제가 발생할 것이라고 예상되는 곳에는 weak var 으로 선언하는 약한 참조를 사용할 수 있습니다.
약한 참조(weak reference)와 미소유 참조(unowned reference)
약한 참조와 미소유 참조는 모두 인스턴스의 참조횟수를 증가시키지 않으면서 인스턴스를 사용하는 참조를 의미합니다. 참조 횟수에 관여하지 않기 때문에 인스턴스의 메모리 할당 및 해제에 영향을 주지 않는 변수가 되는데요. 이로 인해 약한 참조의 경우, 참조하는 인스턴스가 nil이 될 수 있기 때문에 상수가 아닌, 옵셔널 타입이 강제되는 형태가 됩니다.
위와 같이 weak 형태의 참조를 하게 되는 경우 참조 횟수가 증가하지 않아 Person instance의 참조 횟수는 그대로 1(john)이기 때문에 john이 인스턴스 할당을 해제하는 순간 Person 인스턴스는 사라지게 됩니다. 그런 이후 tenant 변수를 사용하고자 한다면 tenant 변수는 nil이기 때문에 에러를 일으킬 수 있습니다.
미소유 참조의 경우, 단순히 약한 참조에서 인스턴스를 참조하는 변수가 nil이 아님을 가정하기 때문에 옵셔널한 변수를 가지지는 않는 것이 약한 참조와의 차이점입니다. 참조 횟수와 관련된 사항을 둘 모두 공통적으로 작동하게 됩니다.
이렇게 두 개로 나뉜 참조의 경우, 약한 참조는 약한 참조를 사용하는 인스턴스 그 자체로도 어딘가에 할당되어 사용되는 경우(위와 같은 상황에서 unit4A라는 변수가 Apartment 인스턴스를 가지고 있기 때문에 Person 인스턴스가 해제되어도 Apartment 인스턴스는 사용될 수 있음), 약한 참조를 이용해 강한 참조 순환 문제를 해결하는 것이고(위의 경우 weak을 tenant에 걸어뒀지만 apartment에 걸어둬도 상관은 없습니다. 코딩하는 방식이나 사용하는 변수의 형태에 따라 다르게 작성해야 합니다.), 아예 하나의 인스턴스가 다른 인스턴스에 종속되는 경우(CreditCard 인스턴스의 경우 따로 할당된 변수가 Customer에 밖에 존재하지 않아 Customer 인스턴스에서만 사용되므로 CreditCard 인스턴스는 Customer가 해제되기 전까지는 항상 존재하며, 해제되는 경우 같이 해제됩니다. 그렇기 때문에 customer 변수는 항상 nil이 아니라고 가정할 수 있습니다.), 미소유 참조를 통해 연결되어 있는 인스턴스의 메모리 할당/해제에 맞춰 같이 작동하게 하면 메모리 문제 발생 가능성을 이론상 없애줍니다.
이렇게 미리 참조 횟수를 알 수 있으면 실행 시간에 따로 메모리를 관리하지 않아도 참조 횟수를 잘 계산했다면 낭비되는 메모리 없이 잘 사용할 수 있습니다. 하지만 그만큼 개발자가 더 신경써서 개발을 하지 않으면 한 번 낭비된 메모리는 그 코드를 다시 수정하지 않는 한 계속 이루어진다는 점은 분명 위험한 점입니다. 그만큼 고숙련도를 요구한다는 것이겠죠.
Swift의 메모리 관리 방식에 대해 알아봤는데, 자바보다 더 복잡하면서 깔끔하게 되어있다는 것을 알 수 있었습니다. 하지만 역시 애플의 '상정한 범위 내에서의 깔끔함' 문제는 어딜가지 않는 모양입니다. 물론 저런 예외사항을 하나하나 다 잡아주다보면 결국 자바처럼 계속해서 파악해주는 형태가 될 테니, 애플이 선택한 방향성이 느껴지는 것 같습니다.
'IOS' 카테고리의 다른 글
SwiftUI vs UIKit (0) | 2022.02.13 |
---|---|
swift를 공부하기 전에(2) (0) | 2022.01.19 |
swift를 공부하기 전에(1) (0) | 2022.01.16 |
댓글