[Swift] 메모리 관리와 ARC

cskim·2019년 9월 5일
0

Memory Management

Class instance나 함수 등 참조 타입의 값들은 메모리의 heap 영역에 저장됩니다. 이 영역 저장되는 값은 명시적으로 삭제하지 않는 한 메모리에 계속 남아있기 때문에 적절한 시점에 메모리에서 삭제하지 않으면 메모리의 낭비로 이어지게 됩니다. 그래서 용량이 작고 한정된 메모리 자원을 효율적으로 사용하기 위해 더 이상 사용되지 않는 값들은 메모리에서 해제시키고 그 공간을 또 다른 데이터를 저장하는데 사용하게 되는데, 이런 작업을 메모리를 관리한다고 말합니다.

메모리 관리가 제대로 이루어지지 않으면 몇 가지 문제가 발생할 수 있습니다. 사용하지 않는 데이터가 메모리 공간을 계속 차지하게 되는 메모리 누수(Memory Leak) 문제가 발생하거나, 이미 해제된 주소로 메모리에 접근하여 발생하는 고아 포인터(Dangling Pointer) 문제가 발생할 수 있습니다. 개발자가 메모리를 직접 관리하게 되면 이러한 메모리 관련 문제가 발생할 가능성이 높아지기 때문에 시스템에서 메모리를 관리해 주는 방법을 고안했는데, 그 대표적인 방법이 메모리를 참조하는 횟수를 추적하여 그 횟수가 0이 되면 더 이상 사용하지 않는 데이터로 판단하여 메모리에서 해제하는 것입니다.

ARC(Automatic Reference Counting)는 이렇게 참조 횟수를 추적하여 자동으로 메모리를 관리해 주는 방법이며, 우리는 ARC가 어떻게 동작하는지 공부해서 그 규칙에 맞게 instance가 생성될 때 참조 카운팅을 할지 말지 결정해 주게 됩니다.

GC와 RC

메모리를 관리하는 방법은 언어마다 다를 수 있는데, 크게 GC(Garbage Collection)와 RC(Reference Counting)로 나눠볼 수 있습니다. 두 방법의 차이는 참조를 계산하는 시점에 있습니다. GC는 application이 실행되는 동안 주기적으로 참조를 추적하여 사용되지 않는 instance를 그때그때 해제하지만, RC는 컴파일 할 때부터 언제 참조가 형성되고 해제되는지 결정되어 그대로 실행됩니다.

GC

  • 참조 계산 시점 : Run time
  • 장점
    • 인스턴스가 해제될 확률이 비교적 높음
    • 특별히 규칙을 신경 쓰지 않아도 됨
  • 단점
    • Run time에 참조를 계속 추적해야 하므로 추가 리소스가 필요하여 성능 저하가 발생할 수 있음
    • 개발자가 메모리 해제 시점을 예측하기 어려움

RC

  • 참조 계산 시점 : Complie time
  • 장점
    • Compile시 메모리 해제 시점이 결정되므로 개발자가 대략적으로 해제 시점을 예측할 수 있음
    • Run time에 메모리 해제를 위해 필요한 추가 리소스가 발생하지 않음
  • 단점
    • 기본적인 규칙과 동작 방식을 알아야 함
    • 개발자 실수에 의해 순환 참조 등 instance가 메모리에서 영원히 해제되지 않을 수 있음

MRR/MRC vs. ARC

Objective-C는 RC 방법으로 메모리를 관리해 왔습니다. 개발자가 메모리 할당 및 해제를 직접 관리해야 했기 때문에 MRC(Manual Reference Counting), 또는 할당(retain)과 해제(release)를 명시적으로 호출하기 때문에 MRR(Manual Retain-Release) 이라는 이름으로 불렸습니다.

Person *man = [[Person alloc] init];  // RC 1
[man retain];  // RC 2
[man doSometing];
[man release];  // RC1
[man release];  // RC0

2011년부터 Objective-C의 메모리 관리 방식은 MRC에서 ARC로 대체되었고, 2014년 발표된 Swift도 ARC를 사용한 메모리 관리 방식을 채택했습니다. ARC는 개발자가 직접 작성해야 했던 retainrelease 코드를 complie할 때 생성하여 참조를 관리합니다.

기본적으로 retainrelease는 메모리의 heap 영역에 할당/해제하는 작업이기 때문에 ARC도 class instance 및 function, closure 같은 참조 타입에만 적용됩니다.

// 작성한 코드
class Point {
  var x: Double
  var y: Double
  init(x: Double, y: Double) {
    self.x = x
    self.y = y
  }
}

let point1 = Point(x: 0, y: 0)
let point2 = point1
point2.x = 5

// 컴파일 시 생성되는 코드
class Point {
  var refCount: Int  // generated
  var x: Double
  var y: Double
	init(x: Double, y: Double) {
    self.x = x
    self.y = y
  }
}

let point1 = Point(x: 0, y: 0)
retain(point1)  // generated
let point2 = point1
point2.x = 5
release(point1)  // generated
release(point2)  // generated

ARC

컴파일러는 일정한 규칙에 의해 메모리 참조 및 해제 코드를 생성합니다. 우리는 ARC가 언제 참조 카운트를 올리고 내리는지 규칙을 알고, 적절한 시점에 코드가 생성되도록 조절할 수 있습니다.

Count Up

참조 카운트는 class instance 등의 참조 타입 값을 변수에 할당할 때 증가합니다. Class instance를 변수에 할당할 때 메모리에서는 heap 영역에 저장된 instance meta data의 주소값을 변수에 할당하는 작업이 일어납니다. 즉, 메모리의 heap 영역에 저장되어 있는 instance meta data의 주소값이 변수에 할당되는 시점이 참조 카운트가 증가하는 시점입니다.

let cskim = Person()  // RC 1
let clone = cskim     // RC 2

Count Down

참조 카운트가 감소하는 시점은 증가할 때와 반대입니다. 즉, instance를 참조하던 stack 영역의 변수들이 메모리에서 해제될 때 참조 카운트도 줄어듭니다.

Stack 영역에 할당된 변수가 메모리에서 해제되는 경우는 크게 세 경우입니다.

1. 변수의 생명 주기가 끝날 때

변수의 생명 주기는 해당 변수의 scope에 의해 결정됩니다. 실행 흐름이 변수가 선언되어 있는 블럭({ })을 벗어나면 해당 변수는 메모리에서 해제됩니다.

var sample1 = Sample1()    // Sample1 RC 1
func execute() {
  let sample2 = sample1    // Sample1 RC 2
  let sample3 = Sample3()  // Sample3 RC 1
}
execute()		// Sample1 RC 1, Sample3 RC 0(release).
/* print */
// Sample3 is deinitialized

2. nil이 할당될 때

Optional 타입 변수에 nil을 할당하면 해당 변수는 값을 갖지 않는 상태가 되어 메모리에서 해제됩니다.

var sample1: Sample? = Sample1()  // Sample1 RC 1
func execute() {
  let sample2 = sample1           // Sample1 RC 2
  let sample3 = Sample3()         // Sample3 RC 1
}
execute()      // Sample1 RC 1, Sample3 RC 0(release).
sample1 = nil  // Sample1 RC 0(release)
/* print */
// Sample3 is deinitialized
// Sample1 is deinitialized

3. 프로퍼티의 경우, 속해 있는 class instance가 메모리에서 해제될 때

프로퍼티가 속해있는 class instance가 참조 횟수가 0이 되어 메모리에서 해제되면 내부 meta data들까지 모두 메모리에서 해제됩니다. 따라서, 그 안에 프로퍼티들도 메모리에서 해제됩니다.

class Sample1 {
  var sample2: Sample2?
}
var sample1: Sample1? = Sample1()  // Sample1 RC 1
sample1?.sample2 = Sample2()       // Sample2 RC 1
sample1 = nil    // Sample1 RC 0(release) -> Sample2 RC 0(release)
/* print */
// Sample1 is deinitialized
// Sample2 is deinitialized

Strong, Weak, Unowned

기본적으로 인스턴스의 주소값이 변수에 할당되면 참조 횟수가 증가합니다. 하지만, 인스턴스를 할당할 때 마다 참조 횟수가 증가하면 문제가 발생할 수 있습니다. 서로 다른 인스턴스가 서로를 강하게 참조하고 있어서 참조 횟수를 0으로 만들지 못하고 영원히 메모리에서 해제되지 않는 순환 참조 관계가 그것입니다.

강한 참조(Strong Reference)와 강한 참조 순환(Reference Cycle)

class Sample1 {
  var ref2: Sample2?
}

class Sample2 {
  var ref1: Sample1?
}

var sample1: Sample1? = Sample1()  // Sample1 RC 1
var sample2: Sample2? = Sample2()  // Sample2 RC 1
sample1?.ref2 = sample2            // Sample2 RC 2
sample2?.ref1 = sample1            // Sample1 RC 2
sample1 = nil                      // Sample1 RC 1
sample2 = nil                      // Sample2 RC 1

위 코드에서 Sample1Sample2가 상호 타입의 instance를 property로 갖고 있습니다. sample1sample2에 각각 nil을 할당하여 참조 카운트를 감소시켰지만, ref1ref2 때문에 여전히 Sample1Sample2 class instance의 RC는 남아있습니다. Instance들을 완전히 메모리에서 해제시키려면 ref1ref2에 접근해서 직접 nil을 할당하는 등 남아있는 참조 카운트를 감소시켜야 하지만, samplesample2 변수는 이미 메모리에서 해제된 후이므로 ref1ref2에 직접 접근할 방법이 없습니다. 결국, Sample1Sample2의 instance는 더 이상 사용되지 않더라도 메모리에서 영원이 살아남게 되어 메모리 릭으로 이어집니다.

ARC는 참조 카운트가 0이 되지 않아서 발생할 수 있는 문제들을 해결하기 위해 참조 관계를 유지하지만 참조 카운트(RC)는 증가시키지 않는 약한 참조 옵션을 제공합니다.

약한(Weak) 참조

약한 참조는 instance를 참조할 때 RC를 증가시키지 않으면서 인스턴스를 참조하도록 합니다. 만약 참조하고 있던 인스턴스가 메모리에서 해제되면 자동으로 nil이 할당되어 참조를 해제하고 메모리에서 반환합니다. 프로퍼티 선언 이후 nil을 할당해야 하기 때문에 약한 참조로 선언된 프로퍼티는 항상 옵셔널 타입의 변수여야 합니다.

class Sample1 {
  var ref2: Sample2?
}

class Sample2 {
  weak var ref1: Sample1?
}

var sample1: Sample1? = Sample1()  // Sample1 RC 1
var sample2: Sample2? = Sample2()  // Sample2 RC 1
sample1?.ref2 = sample2            // Sample2 RC 2
sample2?.ref1 = sample1            // Sample1 RC 1
sample1 = nil                      // Sample1 RC 0 -> Sample2 RC 1
sample2 = nil                      // Sample2 RC 0

위의 순환 참조 문제에서 ref1weak 참조 옵션이 사용되어 sample2?.ref1sample1를 할당해도 RC가 증가하지 않게 되었습니다. sample1nil을 할당하면 Sample1 instance의 참조 카운트가 0이 되어 Sample1이 메모리에서 해제되고, 그 프로퍼티였던 ref2도 메모리에서 해제되며 Sample2의 RC를 감소시키게 됩니다. 이어서 sample2nil을 할당하면서 Sample2 instance의 참조 카운트도 0이 되어 메모리에서 해제됩니다. 이렇게 weak 또는 unowned 옵션을 사용하여 특정 변수에서 instance를 참조할 때 RC 동작을 결정할 수 있습니다.

미소유(Unowned) 참조

미소유 참조도 약한 참조와 마찬가지로 RC를 증가시키지 않으면서 인스턴스를 참조합니다. 하지만 미소유 참조는 인스턴스를 참조하는 도중에 해당 인스턴스가 메모리에서 사라질 일이 없다고 가정하기 때문에 약한 참조와 달리 암묵적으로 옵셔널을 해제(!)하여 선언합니다.

class SomeClass {
  weak var weakVariable: Int?
  unowned var unownedVariable: Int!
}

미소유 참조는 참조하고 있던 인스턴스가 먼저 메모리에서 해제될 때 nil을 할당할 수 없어 오류를 발생시키므로 위험한 방법입니다. 일반적으로 약한 참조만 사용해도 문제가 없기 때문에, 특별한 경우가 아니면 미소유 참조는 사용하지 않는 것이 좋을 것 같습니다.

약한 참조와 미소유 참조 선택

순환 참조 문제에서 약한 참조와 미소유 참조 중 어떤 것을 사용할 것인지는 크게 중요하지 않지만, 각각의 특성과 객체의 성격을 고려해서 선택해 볼 수 있습니다. 아래 코드는 Person class와 Car class가 서로를 참조하여 순환 참조 문제가 발생할 가능성이 있습니다.

class Person {
  var car: Car?
}

class Car {
  var owner: Person!
}

var cskim: Person? = Person()
var genesis: Car? = Car()
cskim?.car = genesis
genesis?.owner = cskim

사람과 자동차의 관계를 OOP 관점에서 생각해 보면, 사람은 자기 소유의 차가 있을 수도 있고 없을 수도 있지만 자동차는 일반적으로 소유주가 없으면 돌아다닐 수 없습니다. 그래서 Person이 가진 car 프로퍼티를 weak로 설정하거나 Car가 가진 owner 프로퍼티를 unowned로 설정하여 순환참조 문제를 해결할 수 있겠습니다. 단, unowned를 사용하는 경우 ownerPerson 인스턴스를 참조하는 중간에 Person 인스턴스가 메모리에서 해제되지 않아야 합니다.

비교

강한 참조, 약한 참조, 미소유 참조를 비교해 보면 다음과 같습니다.

strongweakunowned
Reference CountingOXX
Variable(var)OOO
Constant(let)OXO
OptionalOOX
Non-OptionalOXO
Memory Release명시적으로 nil 할당auto deinit
nil 할당
auto deinit
메모리 주소를 계속 갖고 있음
Expected ProblemStrong Reference Cycle
Memory Leak
인스턴스 해제 후 접근하면 nil 반환인스턴스 해제 후 접근하면 오류
Dangling Pointer
profile
iOS Developer

0개의 댓글