2015년 1월 5일 월요일

Swift Memory Management #1 기초 개념

소프트웨어를 만들 때 메모리 관리라는 건 언어에 따라 중요성의 차이가 다르긴 하다. 하지만 왠만해서는 신경써서 작업해야 하는 것이 메모리 관리이다. 얼마나 적거나 많은 메모리를 사용하는지, 또는 어느 시점에서 메모리가 해제되면 효율적인지 등등 신경써야 할 부분이 제법 많다.

이 글은 Swift의 메모리 관리에 대한 기본적인 지식 시리즈 첫 번째로, ARC(Automatic Reference Counting)의 전제가 되는 레퍼런스 카운트(Reference Count) 개념부터 정리한다. 직접적인 Swift 에 대한 내용이 아니기 때문에 꼭 알아야 할 필요는 없을지도 모르겠다.

Swift는 자동 메모리 회수(GC, Gabage Collection) 기능이 없는 언어이다. GC가 탑재되지 않는 이유를 명확히 알지는 못 하지만, GC의 유명한 부작용 두 가지 - GC가 언제 실행될 지 코드 상에서 알 수가 없고, GC는 퍼포먼스를 제법 까 먹는다 - 를 생각해보면 일부러 GC를 탑재하지 않았다고 생각 할 수도 있다.

대신, Swift에서는 Objective-C 에서부터 사용되던 레퍼런스 카운트(Reference Count) 모델과 함께, Objective-C 2.0 시절부터 도입된 ARC(Automatic Reference Counting) 라는 메모리 관리 정책을 이용 할 수 있다.
예제코드는 사정 상 Objective-C 코드 위주가 되는데, 잘 모른다면 일단 중괄호([, ])는 메소드를 호출 할 때도 이용된다는 점을 알아두면 읽는데 도움이 될 것이다.

레퍼런스 카운트(Reference Count) 메모리 관리 방식

C 언어와 같이 저수준(비하가 아니라 low-level) 언어들은 일반적으로 alloc-free 방식 메모리 관리 정책을 이용한다. 매우 단순한데, 사용할 메모리를 할당(Allocation) 하는 것이 alloc 이고 반대로 이 할당한 메모리를 다시 OS에 돌려주는 것이 free 이다. 굉장히 직관적이지만, 실수로 free된 메모리 포인터를 참조하려고 하면 프로그램이 죽어버리는 문제가 발생하기 때문에 포인터 관리에 주의를 기울여야 했다.
void some_func(void *bufptr) {
    memcpy(bufptr, "TEST", 4);
    free(bufptr);
}

int main() {
    void *buf = malloc(sizeof(unsigned char) * 1024);
    memset(buf, 0x0, sizeof(unsigned char) * 1024);
    
    some_func(buf);

    *(buf + 5) = NULL; // Segmentation Fault

    return 0;
}
억지로 만든 예제. main() 함수에서 특정 메모리를 alloc(위의 경우 malloc 함수 사용)으로 할당받고 some_func() 함수에 이 메모리를 넘겨주는데, some_func() 함수에서 bufptr로 받은 메모리를 해제(free)하고 있다. 그래서 이 함수 실행이 끝나고 다시 main()에서 buf 라는 포인터를 엑세스 하려고 하면 잘못된 메모리 액세스 오류가 발생한다.

Objective-C는 C에서 파생된(superset) 언어이기 때문에 C의 이런 메모리관리 방식을 그대로 사용할 수 있지만, 그 보다는 좀 진보한(혹은 다른??) 메모리 관리 정책을 도입했다. 바로 레퍼런스 카운트 개념을 이용한 메모리 해제 방법이다.

레퍼런스 카운트 방식은 Retain과 Release 라는 개념을 이용한다. 메모리를 할당(alloc)하는 것은 비슷하지만, 코드에서 메모리를 참조하려 할 때 이 레퍼런스 카운트를 증가(리테인)시키고 참조가 끝나면 레퍼런스 카운트를 감소(릴리즈)시키는 방식이다. 그래서 레퍼런스 카운트가 0이 되면 메모리가 해제(free)된다.

즉 레퍼런스 카운트는 그 이름 그대로 ‘참조 갯수’ 이다. 얼마나 많은 코드에서 메모리를 참조하고 있는지를 알려주는 수치이다. 그리고 이 수치를 증가시키는 것이 리테인, 반대로 참조가 끝났으니 수치를 감소시키는 것이 릴리즈이다.
개인적으로 리테인은 '내꺼라고 침 뱉는 행위', 그리고 릴리즈는 이제 필요없으니 '뱉어놓은 침을 닦는 행위'라고 종종 이야기한다.
따라서 레퍼런스 카운트 메모리 관리 방식에서는 '리테인 하는 코드'와 '릴리즈 하는 코드'가 쌍으로 존재해야 한다. 예를 들자면 아래와 같은 식이다.
void someFunction() {
    SomeClass *obj = [[SomeClass alloc] init];  // retain (alloc)
    
    anotherFunction(obj)                         

    [obj release];                              // obj가 가리키는 포인터의 메모리가 해제된다.
}

void anotherFunction(SomeClass *obj) {
    [obj retain];

    ...

    [obj release];
}
참고로 여기서 alloc은 retain을 호출한다. 짝이 안맞다고 생각하지 말자.

위 예제에서 anotherFunction 이 실행되는 타이밍에는 절대로 obj로 넘어온 포인터가 가리키는 메모리는 해제되지 않는다. 왜냐하면 anotherFunction이 시작하자마자 리테인을 걸고 끝 날 때에만 릴리즈를 걸기 때문이다.

retain과 release가 짝이 잘 맞으면 레퍼런스 카운트 방식의 메모리 관리 방법은 잘못된 메모리 참조 오류를 일으킬 가능성이 낮아지면서도 GC에 의한 퍼포먼스 저하도 존재하지 않는 효율적인 방식이 된다. 그저 코딩이 귀찮을 뿐이다.

코딩은 귀찮지만 레퍼런스 카운트 모델의 경우 메모리 해제 자동화가 가능해진다는 장점이 있다. 대표적인 잇점으로 소개 할 수도 있을 것 같은데 아래와 같은 식이다.
UIView *customView = [[UIView alloc] initWithFrame:frame];
[self.view addSubview:customView];
[customView release];
이 코드는 iOS용 예제로 비ARC 환경에서 특정 커스텀뷰를 만들어서 자기 뷰(self.view)에 서브뷰(subview)로 추가 한다는 의도를 가지고 있다. addSubview를 통해 뷰를 추가하고 바로 추가한 뷰를 릴리즈 시켰다.

내부 동작을 잘 모른다면 이 코드에서 혼란을 느낄 수도 있다. 왜 바로 릴리즈를 시키는가이다. 혹시 죽어버리는거 아닐까?

아니다. 죽지 않고 메모리도 제때에 잘 해제된다. 왜냐하면 addSubview가 리테인을 걸기 때문이다. 그리고 UIView는 자신이 사라져야 할 시점에서 서브뷰(subviews)들을 모두 release 시킨다. self.view도 UIView 클래스 타입이기 때문에 이런 식으로 동작한다.

따라서 self가 해제되어야 할 때 self.view도 해제되어야 하고, self.view가 해제될 때 위에서 추가한 customView도 해제가 된다.

물론 이런 식의 코드에서 이제는 아마 릴리즈도 필요 없어 질 것이다. ARC 덕분에 말이다.

ARC(Automatic Reference Counting)

Objective-C 2.0 부터 도입된 특수한 메모리 관리 정책이 ARC이다. 사실 메모리 관리 정책 이라기 보다는, retain이나 release 코딩을 안해도 되도록 좀 더 편하게 해주는 기능에 가깝다.

ARC의 특징은 컴파일러에서 retain과 release 코드를 자동으로 추가한다는 점이다. 위의 someFunction() 함수를 ARC환경용 코드로 바꾸면 아래와 같다.
void someFunction() {
    SomeClass *obj = [[SomeClass alloc] init];
    AnotherFunction(obj);
}

void anotherFunction(SomeClass *obj) {
    ...
}
retain과 release를 시키는 코드가 완전히 사라졌다. 남은건 alloc 하는 코드 뿐이다.

이 코드를 ARC 환경에서 컴파일 시키면 컴파일러는 아래와 같은 식으로 몇몇 코드를 추가한 뒤 컴파일을 한다.
void someFunction() {
    SomeClass *obj = [[SomeClass alloc] init];
    [obj retain];         // ARC

    anotherFunction(obj);

    [obj release];        // ARC
}

void anotherFunction(SomeClass *obj) {
    [obj retain];         // ARC

    ...

    [obj release];        // ARC
}
ARC의 기능은 이런 식으로 컴파일 시 retain과 release를 자동으로 삽입하는 것에 중점이 있다. 위에서 ARC라고 주석을 표기한 부분이 추가된 코드이다.

즉 ARC는 포인터 변수에 값이 지정되는 단계 부터 추적을 시작해서 블럭(block) 단위로 자동으로 retain과 release를 추가한다. 물론 사용자의 코드에 직접 변조를 하지 않고 컴파일 시 임시로 코드를 추가해서 컴파일 한다.
주의: 개념상 이렇게 된다는 점이지 실제로 코드가 변하는 모양은 다를 것이다.
또 다른 예제를 하나 보자.
void foobar() {
    SomeClass *a = [[SomeClass alloc] init];
    SomeClass *b = [[SomeClass alloc] init];

    SomeClass *container = nil;

    container = a;
    container = b;
}
위 코드에서 a와 b 오브젝트가 생성되고 값이 지정되는 시점에서 ARC에 의해 retain 코드가 삽입되고, foobar 함수가 끝나는 시점에서 ARC에 의해 a와 b를 release 시키는 코드가 자동으로 추가된다는 점은 앞서 이야기 했다.

그렇다면 container에 a 혹은 b를 지정(Assign) 하는 행위는 어떻게 될까. 이 경우 ARC는 기존의 메모리 포인터를 릴리즈 시킨 후 새로운 값을 리테인 한다. 즉 아래와 같은 식이다.
[container release];  // ARC
container = a;
[container retain];   // ARC

[container release];  // ARC
container = b;
[container retain];   // ARC
만약 포인터 개념을 모른다면 container에다 retain을 하거나 release 하는 행위에 의문을 느낄지도 모르겠다. 그래서 아래와 같은 식으로 이해를 돕기 위한 코드로 재편집해 봤다. 내용상 위와 완전히 동일한 코드이다.
// container = a
[container release];
container = [a retain];

// container = b
[container release];
container = [b retain];
물론 빠진 부분이 한가지 있다. foobar 함수가 끝나는 시점에서 container를 release 시키는 코드가 추가될 것이다.
참고: nil에 retain을 하거나 release를 하는 행위는 어디선가 알아서 무시해준다. 허공에 침 뱉었는데 자기에게 떨어져봤자 그냥 찝찝함만 남겠지?

사족

Swift에 대한 이야기인데 Objective-C 코드 예제가 나오는 것이 못내 안타깝다. 하지만 Swift는 기본이 ARC를 사용하는 형태이기 때문에 retain-release 코드를 보여 줄 수가 없어서 어쩔 수 없이 Objective-C 코드를 사용하게 된다.

다음 포스팅은 좀 더 직접적인 Swift ARC에 대해 살펴보고자 한다.

[다음글] Swift Memory Management #2 ARC 기초
[관련글] Xcode ARC, 약인가 독인가
[관련글] Objective-C의 레퍼런스 카운트는 왜 필요한 건가

댓글 없음 :