Cocoa Internals - 메모리 관리

메모리 관리

메모리 사용을 최적화하는 과정은 CPU 사용률에 직간접적 영향을 주고, 결과적으로 배터리 소모량에도 영향을 미친다.

효율적으로 메모리를 관리하는 것은 성능에 영향을 주는 아주 중요한 사항이다.

메모리와 객체

이론적으로 64bit 시스템에서는 264의 크기를 갖는 가상 주소 공간에 접근할 수 있다.

물리 메모리는 위의 프로세스 주소 공간보다 작으므로 가상 메모리 방식을 사용한다. CPU와 메모리 관리 유닛에서 일정한 크기를 가진 페이지 단위로 나누어 메모리를 관리한다.

기본적으로 페이지의 크기는 4KB이다.

가상 메모리 페이지 중에 읽을 데이터가 없어 Page Fault가 발생하면 4KB 단위씩 새로운 페이지를 읽어나가므로 성능에 좋지 않다.

객체 인스턴스 생성

Pen *pen = [[Pen alloc] init];

클래스 객체에 +alloc 메세지를 보내면 생성할 객체 메타 클래스에 명시된 속성 데이터 타입 크기를 확인하여 힙 메모리를 할당한다.

메모리 할당의 최소 단위는 16Byte이다. (ex. 24Byte 요청시 32Byte 할당)

힙 공간에 생성된 객체 인스턴스에 -init 메세지를 보내면 생성자를 호출하여 필요한 초기 값을 설정한다.

객체 인스턴스가 메모리에 할당된 이후, 생성자 메소드가 호출되는 시점부터 객체의 생명주기가 시작된다.

메모리 할당 단위

일반적으로 최신 macOS와 iOS에서는 다음의 메모리 할당 단위를 갖는다.

단위 명칭 메모리 할당 범위 할당 단위 비고
NANO 1~255Byte 16Byte macOS에만 해당
TINY 256~992Byte 16Byte
SMALL 993Byte~127KB 512Byte
LARGE 128KB~ 4KB

각 단위의 메모리 조각들이 구분 없이 만들어지면 파편화 현상이 발생하게 된다. 그러므로 실제로는 힙 영역에 각 단위에 대한 영역을 만들고 구분하여 할당된다.

메모리 영역과 가상메모리

magazine_malloc 이라는 개념의 magazine 영역은 멀티스레드 환경에서 메모리 할당 영역에 대한 오버헤드를 줄이기 위해 스레드별로 메모리를 관리하는 단위다.

이 때 TINY 단위는 스레드와 상관 없이 최상위 수준에서 생성되고 각 스레드에 할당되는 구조로 동작한다. Objective-C 객체 인스턴스는 TINY의 크기보다 작을 것이라고 가정했기 때문이다.

따라서 객체를 설계할 때 1KB보다 작게 설계하는 것이 권장된다.

힙 공간에는 객체 인스턴스만 존재하는 것이 아니다. 비트맵 이미지나 데이터베이스가 캐싱되기도 한다.

UIImage 클래스의 +imageNamed: (init?(named:)) 메소드를 사용하는 경우 시스템이 내부에 이미지를 캐싱하므로 주의하여 사용해야 한다.

메모리 지역zone

같은 zone의 메모리 또는 같은 가상 메모리 페이지에 객체를 할당하면 지역성locality가 좋아져 성능이 향상되곤 했으나 Objective-C 최신 런타임에서는 이러한 것을 관리할 필요가 없어졌다.

현재 zone 관련 메소드는 deprecated되었다.

객체 인스턴스 소멸

Objective-C에서는 객체 인스턴스에 -release 메세지를 보내면 해당 객체의 -dealloc 메소드를 호출하여 하위 객체를 해제할 수 있도록 도와준다.

Swift의 클래스에도 -deinit 메소드가 있으나 ARC를 사용하므로 소멸 시점을 지정할 수 없다.

요약

객체 인스턴스를 메모리에 생성해서 소멸할 때까지의 과정을 메모리 관리 측면에서 이해하면 효율적인 프로그램을 작성할 수 있을 것이다.

이미지나 데이터베이스 캐시를 위해 내부적으로 할당하는 메모리 공간에 대해서도 고려해야 한다.

참조 계산

MRC에 대한 내용이다.

특정 객체가 다른 객체를 참조하는 경우, 참조할 객체의 메모리 존재 여부를 판단할 필요가 있다.

이를 위해 애플은 참조 계산reference counting 방식을 제공한다.

Cocoa 프레임워크가 제공하는 모든 객체는 참조 카운터 공간을 갖는다. 여기에 해당 객체의 참조 횟수를 계산한 값이 기록된다.

객체가 만들어질 때 참조 횟수는 초기 값 1로 설정되고, 그 객체를 참조하는 다른 객체가 있을 때마다 참조 횟수가 1 증가한다. 참조하던 객체가 더 이상 참조하지 않으면 1 감소한다.

macOS의 옛 버전에서 가비지 컬렉션 방식을 지원했었으나 지금은 지원하지 않는다. iOS는 가바지 컬렉션 방식을 지원하지 않는다.

객체 소유권

다른 객체를 참조한다는 것은, 다른 객체의 힙 메모리 주소를 포인터 변수에 담아 갖고 있다는 것을 의미한다.

Pen *aPen = [[Pen alloc] init];
Pen *bPen = aPen;
[aPen release];
bPen.color = [UIColor yellowColor];

위의 코드는 오류를 발생시킨다. bPen이 가리키고 있는 힙 영역이 메모리에서 해제되었기 때문이다.

ARC를 지원하지 않을 때, 객체 소유권을 명시적으로 관리할 필요가 있다.

객체 소유권 규칙

  • 특정 객체를 새로 만드는 경우 소유권을 갖는다.
  • 다른 객체가 생성한 객체를 참조하기 전 소유권을 요청하여 받아야 한다.
  • 소유권을 얻는 객체를 더 이상 참조하지 않으면 소유권을 반환한다.
  • 소유권을 갖고 있지 않는 객체를 반환하면 안 된다.

‘소유권을 갖는다’는 말은 참조 횟수를 1 증가시킨다 / retain한다는 의미다.

‘소유권을 반환한다’는 말은 참조 횟수를 1 감소시킨다 / release한다는 의미다.

위의 네 규칙을 Cocoa 프레임워크의 메소드와 관련해서 다시 쓰면 다음과 같다.

  • alloc, new, copy, mutableCopy 계열의 메소드로 특정 객체를 생성하거나 복사하여 새로운 객체 인스턴스를 만들면 참조 횟수를 1로 설정하고 소유권을 갖는다.
  • 다른 객체가 이미 만들어 놓은 객체 인스턴스를 참조하는 경우 retain 메소드를 사용하여 객체 소유권을 요청하며, 참조 횟수를 1 증가시키고 소유권을 갖게 된다.
  • 위에서 소유권을 얻는 객체를 더 이상 참조하지 않는 경우 release 또는 autorelease 메소드를 사용하여 객체 소유권을 반환하며, 참조 횟수를 1 감소시킨다.
  • 소유권을 요청한 적이 없거나 이미 소유권을 반환한 경우라면 release 또는 autorelease 메세지를 해당 객체에 보내면 안 된다.
// `aPen` 객체 생성
// `aPen`이 가리키는 객체의 참조 횟수 초기값 설정 -> 1
Pen *aPen = [[Pen alloc] init];
// 참조를 바로 할당하는 대신 `retain` 메소드로 소유권 요청
// `aPen`과 `bPen`은 같은 힙 영역 메모리를 가리킴
// `bPen`이 가리키는 객체의 참조 횟수 증가 -> 2
Pen *bPen = [aPen retain];
// `bPen` 객체 복사. `bPen`이 가리키는 힙 영역과는 다른 곳을 가리키게 됨
// `cPen`이 가리키는 객체의 참조 횟수 초기값 설정 -> 1
Pen *cPen = [bPen copy];
// `aPen`의 소유권 반환
// `aPen`이 가리키는 객체의 참조 횟수 감소 -> 1
[aPen release];
bPen.color = [UIColor yellowColor];
cPen.color = [UIColor redColor];
// `cPen`의 소유권 반환
// `cPen`이 가리키는 객체의 참조 횟수 감소 -> 0 -> 해제
[cPen release];
// `bPen`의 소유권 반환
// `bPen`이 가리키는 객체의 참조 횟수 감소 -> 0 -> 해제
[bPen release];

자동 반환 목록

객체가 생성되고, 소유권이 없는 상태에서 다른 객체가 사용할 때까지 일정 시간 동안 메모리를 반환하지 않고 남겨두어야 할 경우가 있다. 이 때를 위해 자동 반환 목록을 사용한다.

자동 반환 목록은 일정 시간 뒤에 반환할 객체 목록을 만들어서 관리해준다.

함수 범위나 문법적으로 특정 범위가 정해진 변수들은 자동 변수로서 스택에 생겼다가 사라진다. autorelease 메세지를 받은 객체도 이와 비슷한 동작을 한다.

NSAutoreleasePool *autoreleasePool = [[NSAutoreleasePool alloc] init];
Pen *temp = [[Pen alloc] init];
[temp autorelease];
// do something
[autoreleasePool drain];

Objective-C의 NSObject 클래스는 NSObject 프로토콜을 준수하며, NSObject 프로토콜이 -autorelease 메소드를 가지고 있다.

Objective-C의 NSObject 프로토콜은 Swift에 NSObjectProtocol의 이름으로 import된다.

객체에 autorelease 메세지를 보내면 자동 반환 목록에 객체가 등록되며 autoreleasePool 객체가 소유권을 넘겨받게 된다.

autoreleasePooldrain 메소드를 보내면 자동 반환 대상 객체를 차례대로 release한다.

간편한 메소드와 자동 반환 대상

Objective-C의 간편 메소드convenience method의 경우 객체가 생성 및 초기화된 후 자동 반환 목록에 등록된다.

AutoreleasePool 클래스 객체를 스레드마다 하나씩 생성하여 사용하고 스레드가 끝날 때 함께 소멸되도록 하는 것이 권장된다.

main() 함수 내부에 AutoreleasePool 객체를 만드는 코드가 이미 있어서 앱이 동작하는 메인 스레드에 대해서는 따로 만들지 않아도 된다.

자동 반환 목록 사용 시 주의 사항

객체를 반복하여 생성하는 경우 자동 반환 목록에 객체가 너무 많이 쌓이는 경우가 있을 수 있다. 이러한 경우 반복문 안에 명시적으로 AutoreleasePool 객체를 만들어 사용하면 좋다.

객체 그래프

최상위 객체부터 소유권을 갖거나 직접 참조 관계가 있는 객체를 하위 트리 구조로 그려볼 수 있으며, 이를 객체 그래프라고 한다.

예를 들어 배열의 경우 각 요소를 하위 트리로 만들어 가리키는 그래프를 그려볼 수 있을 것이다.

순환 참조 문제

각각의 객체가 서로를 참조하거나, 세 개 이상의 객체가 서로를 가리켜 참조 사이클을 형성하는 등의 경우 순환 참조 문제가 발생하여 객체가 메모리에서 해제되지 않는 현상이 발생할 수 있다.

약한 참조를 통해 순환 참조를 해결할 수 있다.

요약

메모리 관리를 위해 참조 계산을 실수 없이 처리하려면 객체 소유권 개념을 명확하게 이해해야 한다.

자동 반환 목록을 관리하는 AutoreleasePool을 사용하는 것이 권장된다.

객체 초기화

객체 인스턴스를 메모리에 할당한 직후, 객체 내부 변수를 초기 값으로 지정하기 위해 초기화 메소드를 사용한다.

Objective-C는 타입 메소드 또는 인스턴스 메소드의 형태로 초기화 메소드를 제공한다. Swift의 이니셜라이저의 개념이 없다.

Swift는 이니셜라이저가 객체를 초기화하는 역할을 담당한다.

여러 초기화 메소드

NSObject를 상속받는 경우 해당 클래스의 기본 초기화 메소드 -init을 재정의할 수 있다.

- (instancetype)init {
self = [super init];
if (self != nil) {
// 인스턴스 변수 초기화
}
return self;
}

id 타입은 Objective-C에서 모든 객체를 표현할 수 있는 동적 타입이므로, 타입 정보가 부족하다.

이를 해결하기 위해 instancetype이 등장했으며, 이는 해당 클래스에 대한 인스턴스를 반환한다는 것을 컴파일러에게 알릴 수 있다.

초기화 메소드 구현하기

  1. 상속받은 슈퍼 클래스의 초기화 메소드를 먼저 호출
  2. 슈퍼 클래스의 초기화 메소드의 리턴 값을 확인하여 nil이면 그래도 nil을 리턴
  3. 내부 리소스를 초기화하면서 객체는 copyretain 메소드를 호출하여 객체 소유권을 획득
  4. 인스턴스 변수들을 초기화하고 나면 self를 리턴
  5. 인스턴스 변수의 초기화 과정에서 에러가 발생한 경우 selfrelease하고 nil을 리턴
  6. self가 아닌 객체 인스턴스를 리턴하는 경우에도 selfrelease해야 한다.

Swift는 실패 가능한 이니셜라이저와 실패하지 않는 이니셜라이저를 모두 제공하며 위와 같은 작업을 할 필요가 없다.

객체 초기화 관련 문제

  • -init 계열 메소드로 객체 인스턴스를 초기화한 후 다시 -init 계열 메소드를 호출하면 안 된다.
  • 초기화하는 객체가 +alloc 메소드를 통해 정상적으로 메모리에 생성한 객체가 아닌 경우를 주의해야 한다.
  • 초기화 메소드가 실패하는 경우를 대비해야 한다.

요약

객체 초기화 메소드는 안전한 객체를 만드는 첫 관문이다.

객체 인스턴스가 메모리에 제대로 할당되었는지 확인하고, 내부 리소스가 준비되지 않았다면 메모리를 반환해야 한다.