Devin.KR

초기화와 메모리 - alloc init 과 ARC

개발자KR 조회 0

이 장에서 배우는 것

객체를 쓰려면 먼저 만들어야 하고, 다 쓴 객체는 메모리에서 치워야 한다. 앞 장에서는 프로퍼티로 객체의 값을 읽고 쓰는 방법을 다뤘다. 이 장은 그 앞뒤를 다룬다. 객체가 어떻게 태어나 값을 갖추는지, 언제 사라지는지를 본다. 사내 도서 대출 관리 도구의 도서(Book), 회원(Member), 대출 기록(Loan) 세 클래스를 만들면서 확인한다.

  • alloc 과 init 이 각각 하는 일을 구분해 설명한다.
  • 지정 초기화 메서드와 self = [super init] 관용구를 올바르게 쓴다.
  • 편의 생성자와 편의 초기화 메서드를 지정 초기화 메서드로 연결한다.
  • ARC 의 참조 카운트 개념으로 객체가 해제되는 시점을 예측한다.
  • 순환 참조가 생기는 구조를 알아보고 weak 로 끊으며, dealloc 에서 할 일과 하지 말 일을 구분한다.

문제 상황

도서 대출 도구에서 대출 기록을 만든다고 하자. 기록은 도서와 회원을 알아야 하므로 Loan 객체가 Book 과 Member 를 가리킨다. 회원은 자신의 대출 목록을 보여 줘야 하니 Member 도 Loan 을 가리킨다. 서로 가리키는 구조다.

이 구조에서 실무자가 흔히 겪는 문제가 세 가지 있다. 첫째, 제목이 없는 Book 이 만들어져 화면에 빈 줄이 나온다. 초기화 경로가 여러 갈래인데 어느 한 곳에서 값을 채우지 않았기 때문이다. 둘째, 회원 변수를 비웠는데도 회원 객체가 메모리에 남는다. 대출 기록이 회원을 되가리키고 있기 때문이다. 셋째, 객체가 언제 사라지는지 알 수 없어 정리 코드를 어디에 둘지 모른다. 이 장은 이 세 문제를 차례로 푼다.

객체가 만들어지는 과정

alloc 과 init 두 단계

Objective-C 는 객체 생성을 두 단계로 나눈다. [Book alloc] 은 객체가 들어갈 메모리를 확보하고, 모든 인스턴스 변수를 0(포인터는 nil)으로 채운 객체를 돌려준다. 이 시점의 객체는 쓸 수 있는 상태가 아니다. 이어서 init 계열 메서드가 필요한 초기값을 채운다. 그래서 두 호출을 겹쳐 쓴다.

Book *a = [[Book alloc] initWithTitle:@"C 프로그래밍" copies:2];

안쪽 alloc 이 먼저 실행되고, 그 결과에 바깥쪽 initWithTitle:copies: 가 실행된다. alloc 만 부르고 init 을 부르지 않거나, init 의 반환값을 버리고 alloc 이 돌려준 객체를 계속 쓰는 코드는 쓰지 않는다. init 이 원래 객체가 아닌 다른 객체를 돌려줄 수 있기 때문이다.

지정 초기화 메서드와 self = [super init]

클래스에는 초기화 메서드가 여러 개 있을 수 있다. 그중 모든 인스턴스 변수를 빠짐없이 채우는 메서드 하나를 지정 초기화 메서드(designated initializer)라 한다. 나머지 초기화 메서드는 편의 초기화 메서드(convenience initializer)가 되어 결국 지정 초기화 메서드를 부른다. 이렇게 모든 경로가 한 곳을 지나면 값을 채우는 코드가 한 군데에만 있으므로 빠뜨릴 일이 없다. 지정 초기화 메서드는 선언 끝에 NS_DESIGNATED_INITIALIZER 를 붙여 컴파일러에게도 알린다.

모든 편의 초기화 메서드는 지정 초기화 메서드를 거쳐 부모의 init 에 도달한다.

지정 초기화 메서드의 본문은 정해진 모양을 따른다.

- (instancetype)initWithTitle:(NSString *)title copies:(NSInteger)copies {
    self = [super init];
    if (self) {
        _title = [title copy];
        _copies = copies < 1 ? 1 : copies;
    }
    return self;
}

self = [super init] 은 부모 클래스의 초기화를 먼저 끝내라는 뜻이다. 부모의 init 은 실패하면 nil 을 돌려줄 수 있고, 다른 객체를 돌려줄 수도 있다. 그 결과를 self 에 받아 두었다가 nil 이 아닐 때만 인스턴스 변수를 채운다. 마지막에는 self 를 돌려준다. 반환 타입 instancetype 은 "이 메서드를 부른 클래스의 객체"라는 뜻이어서, 하위 클래스에서 불러도 타입이 맞는다.

초기화 메서드 안에서는 프로퍼티 접근자 대신 인스턴스 변수(_title)에 직접 대입하는 것이 관례다. 접근자는 하위 클래스가 덮어쓸 수 있는데, 초기화가 끝나지 않은 객체에서 덮어쓴 접근자가 실행되면 예상 밖의 동작이 일어나기 때문이다. copy 프로퍼티의 값은 대입할 때도 직접 copy 를 보내야 한다. 인스턴스 변수에 직접 대입하면 접근자의 복사 동작이 실행되지 않는다.

편의 생성자

매번 [[Book alloc] initWithTitle:...] 처럼 두 단계를 적는 일은 번거롭다. 클래스 메서드 하나로 묶어 둔 것이 편의 생성자(convenience constructor)다. 관례상 bookWithTitle: 처럼 클래스 이름을 소문자로 시작해 붙인다.

+ (instancetype)bookWithTitle:(NSString *)title {
    return [[self alloc] initWithTitle:title];
}

클래스 메서드 안의 self 는 클래스 자신을 가리킨다. [Book alloc] 대신 [self alloc] 으로 써야 하위 클래스가 상속해도 올바른 클래스의 객체가 만들어진다.

세 가지 초기화 도구가 하는 일
도구예하는 일부르는 대상
지정 초기화 메서드initWithTitle:copies:모든 인스턴스 변수를 채운다[super init]
편의 초기화 메서드initWithTitle:기본값을 정해 넘긴다[self 지정 초기화]
편의 생성자+bookWithTitle:alloc 과 init 을 묶는다[[self alloc] init...]

ARC 와 참조 카운트

참조 카운트로 수명 정하기

객체는 힙(heap)이라는 메모리 영역에 놓이고, 변수는 그 주소만 들고 있다. 누군가 객체를 쓰는 동안은 살아 있어야 하고, 아무도 쓰지 않으면 치워야 한다. Objective-C 는 객체마다 "나를 소유한 참조가 몇 개인가"를 세는 참조 카운트(reference count)로 이를 판단한다. 소유하는 참조가 하나 생기면 카운트가 1 늘고, 사라지면 1 줄며, 0 이 되는 순간 객체가 해제된다.

ARC(Automatic Reference Counting)는 이 증감 코드를 컴파일러가 소스에 자동으로 끼워 넣는 기능이다. 이 책의 컴파일 명령에 붙은 -fobjc-arc 옵션이 ARC 를 켠다. 개발자가 retain·release 를 직접 쓰지 않는다. 다음 흐름은 컴파일러가 하는 일을 주석으로 풀어 쓴 것이다.

Member *m = [[Member alloc] initWithName:@"박둘"];  // 카운트 1
Member *n = m;     // n 도 강한 참조: 카운트 2
m = nil;           // 카운트 1
n = nil;           // 카운트 0, 이 순간 dealloc 이 실행된다

변수는 기본적으로 강한 참조(strong)다. 강한 참조가 하나라도 남아 있으면 객체는 해제되지 않는다. 카운트 값 자체를 출력해 보려는 시도는 권하지 않는다. ARC 는 최적화 과정에서 증감을 생략하거나 앞당길 수 있어서 값이 직관과 맞지 않는 때가 많다. 이 장은 숫자 대신 dealloc 이 실행되는 시점을 관찰한다.

순환 참조와 weak

두 객체가 서로를 강하게 가리키면 카운트가 0 이 될 수 없다. 이것을 순환 참조(retain cycle)라 한다. 회원이 대출 기록 목록을 강하게 들고 있고, 대출 기록이 회원을 다시 강하게 들고 있으면 바깥 변수를 모두 비워도 둘은 서로의 카운트를 1 로 유지한다. 해제되지 않은 채 메모리에 남는 이 상태를 메모리 누수(leak)라 한다.

양쪽이 모두 강한 참조면 서로를 붙잡지만, 한쪽을 weak 로 하면 순환이 끊긴다.

해법은 한쪽을 약한 참조(weak)로 바꾸는 것이다. 약한 참조는 카운트를 올리지 않는다. 가리키는 객체가 해제되면 ARC 가 그 변수를 자동으로 nil 로 바꿔 주므로 해제된 메모리를 가리키는 위험한 포인터가 남지 않는다. 소유하는 쪽(Member → Loan)은 strong, 소유당하는 쪽이 되돌아 가리킬 때(Loan → Member)는 weak 로 둔다. 이 방향 구분이 핵심이다.

참조 종류에 따른 카운트와 해제 후 동작
키워드카운트 증가대상이 해제되면쓰는 곳
strong증가한다해제 자체가 막힌다소유하는 관계 대부분
weak증가하지 않는다자동으로 nil 이 된다되돌아 가리키는 참조
assign (객체)증가하지 않는다잘못된 주소가 남는다객체에는 쓰지 않는다

dealloc

카운트가 0 이 된 객체는 해제되기 직전에 dealloc 메서드를 한 번 실행한다. 직접 부르는 메서드가 아니라 런타임이 부르는 메서드다. ARC 에서는 인스턴스 변수가 가리키던 객체의 해제를 컴파일러가 알아서 하므로, dealloc 에는 ARC 가 모르는 정리만 적는다. 열어 둔 파일 닫기, 타이머 중지, 알림 구독 취소가 그런 예다. ARC 에서는 [super dealloc] 도 컴파일러가 마지막에 넣어 주므로 직접 적으면 오류가 난다. 이 장은 호출 시점을 눈으로 보려고 dealloc 에서 문자열만 출력한다.

여기서 출력 방식 하나를 정하자. Foundation 의 NSLog 는 시각과 프로세스 정보를 앞에 붙이고 표준 오류(stderr)로 내보낸다. 실행할 때마다 앞부분이 달라지고, 결과를 파일로 받거나 비교할 때 표준 출력에 나오지 않는다. 그래서 이 책의 결과 출력은 C 의 printf 에 UTF8String 을 넘기는 방식으로 한다. 출력이 표준 출력으로 나가고 내용이 매번 같다.

완성 코드

한 파일 main.m 에 세 클래스와 시험 코드를 담았다. 지정 초기화 메서드, 편의 초기화 메서드, 편의 생성자, weak 프로퍼티, dealloc 이 모두 들어 있다.

#import <Foundation/Foundation.h>

@interface Book : NSObject
@property (nonatomic, copy, readonly) NSString *title;
@property (nonatomic, assign, readonly) NSInteger copies;

- (instancetype)initWithTitle:(NSString *)title copies:(NSInteger)copies NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithTitle:(NSString *)title;
- (instancetype)init;
+ (instancetype)bookWithTitle:(NSString *)title;
- (NSString *)summary;
@end

@implementation Book

- (instancetype)initWithTitle:(NSString *)title copies:(NSInteger)copies {
    self = [super init];
    if (self) {
        _title = [title copy];
        _copies = copies < 1 ? 1 : copies;
    }
    return self;
}

- (instancetype)initWithTitle:(NSString *)title {
    return [self initWithTitle:title copies:1];
}

- (instancetype)init {
    return [self initWithTitle:@"(제목 없음)"];
}

+ (instancetype)bookWithTitle:(NSString *)title {
    return [[self alloc] initWithTitle:title];
}

- (NSString *)summary {
    return [NSString stringWithFormat:@"%@ (%ld권)", self.title, (long)self.copies];
}

@end

@class Member;

@interface Loan : NSObject
@property (nonatomic, strong, readonly) Book *book;
@property (nonatomic, weak, readonly) Member *member;

- (instancetype)initWithBook:(Book *)book member:(Member *)member NS_DESIGNATED_INITIALIZER;
- (instancetype)init NS_UNAVAILABLE;
@end

@interface Member : NSObject
@property (nonatomic, copy, readonly) NSString *name;
@property (nonatomic, strong, readonly) NSMutableArray *loans;

- (instancetype)initWithName:(NSString *)name NS_DESIGNATED_INITIALIZER;
- (instancetype)init NS_UNAVAILABLE;
- (Loan *)lend:(Book *)book;
@end

@implementation Loan

- (instancetype)initWithBook:(Book *)book member:(Member *)member {
    self = [super init];
    if (self) {
        _book = book;
        _member = member;
    }
    return self;
}

- (void)dealloc {
    printf("Loan dealloc: %s\n", [_book.title UTF8String]);
}

@end

@implementation Member

- (instancetype)initWithName:(NSString *)name {
    self = [super init];
    if (self) {
        _name = [name copy];
        _loans = [NSMutableArray array];
    }
    return self;
}

- (Loan *)lend:(Book *)book {
    Loan *loan = [[Loan alloc] initWithBook:book member:self];
    [self.loans addObject:loan];
    return loan;
}

- (void)dealloc {
    printf("Member dealloc: %s\n", [_name UTF8String]);
}

@end

int main(void) {
    @autoreleasepool {
        Book *a = [[Book alloc] initWithTitle:@"C 프로그래밍" copies:2];
        Book *b = [Book bookWithTitle:@"Cocoa 입문"];
        Book *c = [[Book alloc] init];
        printf("%s\n", [[a summary] UTF8String]);
        printf("%s\n", [[b summary] UTF8String]);
        printf("%s\n", [[c summary] UTF8String]);

        Loan *keep = nil;
        __weak Member *watcher = nil;
        @autoreleasepool {
            Member *kim = [[Member alloc] initWithName:@"김하나"];
            watcher = kim;
            keep = [kim lend:a];
            printf("범위 안: watcher %s\n", watcher != nil ? "살아 있음" : "nil");
            printf("범위 안: %s 대출 %lu건\n", [kim.name UTF8String],
                   (unsigned long)kim.loans.count);
        }
        printf("범위 밖: watcher %s\n", watcher != nil ? "살아 있음" : "nil");
        printf("범위 밖: loan.member %s\n", keep.member != nil ? "살아 있음" : "nil");
        printf("범위 밖: loan.book %s\n", [keep.book.title UTF8String]);
        keep = nil;
        printf("끝\n");
    }
    return 0;
}

줄별 해설

Book 의 선언. title 과 copies 는 readonly 라서 만든 뒤 바꿀 수 없다. 값은 초기화 메서드에서만 정해진다. initWithTitle:copies: 에만 NS_DESIGNATED_INITIALIZER 가 붙어 있다. 컴파일러는 이 표시를 보고, 지정이 아닌 초기화 메서드가 지정 초기화 메서드를 거치지 않으면 경고를 낸다.

Book 의 구현. 지정 초기화 메서드는 앞에서 본 관용구를 따른다. 대출 가능 권수가 1 보다 작으면 1 로 고치는 검사도 이 메서드 한 곳에만 있다. initWithTitle: 은 권수 1 을 넘기며 지정 초기화 메서드를 부른다. init 은 기본 제목을 넘기며 initWithTitle: 을 부르므로 결국 같은 곳에 닿는다. [[Book alloc] init] 으로 만든 책이 "(제목 없음) (1권)" 이 되는 것은 이 연쇄 덕분이다. bookWithTitle: 은 [self alloc] 과 initWithTitle: 을 묶는다.

Loan 과 Member 의 선언. Loan 이 Member 를 쓰는데 Member 의 정의는 아래에 있어서 @class Member; 로 "이런 클래스가 있다"고 먼저 알린다. Loan 의 member 만 weak 이고, Member 의 loans 는 strong 이다. init NS_UNAVAILABLE 은 인자 없는 init 을 부르면 컴파일 오류가 나게 한다. 회원 이름이나 도서 없이 만든 기록은 의미가 없기 때문이다.

Loan 과 Member 의 구현. Member 의 lend: 는 Loan 을 만들면서 member:self 로 자신을 넘기고, 자신의 loans 에 넣은 뒤 그 기록을 돌려준다. 두 dealloc 은 해제 시점을 알리는 출력만 한다. Loan 의 dealloc 에서 도서 제목을 읽을 수 있는 것은 인스턴스 변수가 dealloc 이 끝난 뒤에 해제되기 때문이다.

main 의 앞부분. 세 가지 방식으로 책을 만들어 요약을 출력한다. 각각 지정 초기화 메서드, 편의 생성자, 인자 없는 init 을 거친다. 바깥 @autoreleasepool 은 Foundation 프로그램의 기본 틀이다. 메서드가 돌려준 임시 객체를 모아 두었다가 블록이 끝나면 한꺼번에 놓아 준다.

main 의 안쪽 범위. kim 은 이 범위의 지역 변수이며 강한 참조다. watcher 는 __weak 변수로, 회원을 가리키지만 카운트를 올리지 않는다. 대출 기록은 keep 이 강하게 붙잡아 범위 밖에서도 산다. 범위의 마지막 줄이 실행되면 kim 이 사라지고 회원의 카운트는 0 이 된다. 회원이 해제될 때 loans 배열도 함께 해제되지만 그 안의 Loan 은 keep 이 잡고 있어 남는다. 범위 안 첫 출력은 watcher 를 확인하고 마지막 줄에서 kim 을 쓰게 순서를 짠 것이다. 변수의 마지막 사용 이후에는 ARC 가 일찍 놓아 줄 수 있어서, 시험이 분명하도록 한 장치다.

main 의 뒷부분. 범위 밖에서 watcher 와 keep.member 가 모두 nil 이 된 것을 확인한다. 약한 참조의 자동 nil 처리다. keep.book 은 여전히 읽힌다. Loan 이 도서를 강하게 들고 있기 때문이다. keep = nil 로 마지막 참조를 놓으면 Loan dealloc 이 출력된다. 책 세 권은 dealloc 출력이 없어서 프로그램 끝에 해제되는 순서가 출력에 나타나지 않는다.

실행 결과

$ clang -fobjc-arc -Wall -framework Foundation *.m -o app
$ ./app
C 프로그래밍 (2권)
Cocoa 입문 (1권)
(제목 없음) (1권)
범위 안: watcher 살아 있음
범위 안: 김하나 대출 1건
Member dealloc: 김하나
범위 밖: watcher nil
범위 밖: loan.member nil
범위 밖: loan.book C 프로그래밍
Loan dealloc: C 프로그래밍
끝

"Member dealloc" 줄이 "범위 밖" 줄보다 앞에 나온 점을 보자. 안쪽 범위가 끝나는 순간 회원이 해제된 것이다. 만약 Loan 의 member 가 strong 이었다면 이 줄은 나오지 않는다.

실무에서 자주 틀리는 것

self = [super init] 을 빼고 인스턴스 변수를 채운다

틀린 코드는 부모의 초기화를 부르지 않거나, 결과를 self 에 받지 않는다.

- (instancetype)initWithName:(NSString *)name {
    [super init];            // 결과를 버린다
    _name = [name copy];
    return self;
}

부모의 init 이 다른 객체나 nil 을 돌려주면 그 결과가 반영되지 않는다. 고친 코드는 관용구 그대로다.

- (instancetype)initWithName:(NSString *)name {
    self = [super init];
    if (self) {
        _name = [name copy];
    }
    return self;
}

편의 초기화 메서드가 지정 초기화 메서드를 건너뛴다

- (instancetype)initWithTitle:(NSString *)title {
    self = [super init];     // 지정 초기화 메서드를 건너뛴다
    if (self) {
        _title = [title copy];   // copies 는 0 으로 남는다
    }
    return self;
}

이렇게 쓰면 copies 가 0 이어서 1 이상으로 맞추는 검사도 거치지 않는다. 편의 초기화 메서드는 [super init] 이 아니라 [self initWithTitle:title copies:1] 로 지정 초기화 메서드에 맡긴다.

되돌아 가리키는 참조를 strong 으로 둔다

@property (nonatomic, strong, readonly) Member *member;   // Loan 안

회원은 loans 로 기록을 소유하는데 기록도 회원을 소유하면 순환 참조다. 회원이 지역 변수에서 사라져도 Member dealloc 은 출력되지 않는다. weak 로 고친다.

@property (nonatomic, weak, readonly) Member *member;     // Loan 안

ARC 에서 dealloc 에 [super dealloc] 을 적는다

- (void)dealloc {
    printf("정리\n");
    [super dealloc];         // ARC 에서는 컴파일 오류
}

ARC 가 부모의 dealloc 호출을 자동으로 넣는다. 고친 코드는 정리 작업만 적는다.

- (void)dealloc {
    printf("정리\n");
}

한눈에 보기

이 장의 핵심 정리
주제핵심예
alloc / init메모리 확보와 값 채우기를 나눈다[[Book alloc] init...]
지정 초기화 메서드모든 값을 채우고 [super init] 을 부른다NS_DESIGNATED_INITIALIZER
self = [super init]결과를 self 에 받고 nil 이면 건너뛴다if (self) { ... }
편의 생성자클래스 메서드로 생성을 묶는다[[self alloc] init...]
참조 카운트강한 참조가 0 개가 되면 해제된다m = nil; n = nil;
순환 참조서로 strong 이면 해제되지 않는다Member ⇄ Loan
weak카운트를 올리지 않고 자동으로 nil 이 된다Loan 의 member
deallocARC 가 모르는 정리만 적는다[super dealloc] 금지

다음 장에서는 이 장에서 계속 써 온 문자열과 숫자를 객체로 다루는 NSString 과 NSNumber 를 본다. 참고 자료로 Apple 의 Programming with Objective-C 와 clang 의 ARC 문서가 있다.

연습 문제

  1. 대출 가능 권수가 1 보다 작으면 1 로 고치는 검사를 initWithTitle: 쪽에 적으면 어떤 문제가 생기는지 설명하라.
  2. 아래 코드가 출력하는 내용을 순서대로 적어라. Member 는 이 장의 클래스이다.
    Member *m = [[Member alloc] initWithName:@"박둘"];
    Member *n = m;
    m = nil;
    printf("1\n");
    n = nil;
    printf("2\n");
  3. 완성 코드에서 Loan 의 member 프로퍼티를 strong 으로 바꾸면 실행 결과에서 어떤 줄이 달라지는지, 그 이유와 함께 적어라.
  4. Member 에 편의 생성자 +memberWithName: 을 추가하는 코드를 작성하라.

정답과 해설

  1. 다른 초기화 경로에서는 검사를 거치지 않게 된다. 예를 들어 initWithTitle:copies: 를 직접 부르는 코드는 음수 권수를 그대로 저장한다. 값을 검증하는 코드는 모든 경로가 지나는 지정 초기화 메서드 한 곳에 둔다.
  2. 출력은 다음 세 줄이다.
    1
    Member dealloc: 박둘
    2
    m = nil 은 강한 참조 하나만 없애서 n 이 남아 있다. n = nil 에서 카운트가 0 이 되어 dealloc 이 실행되고, 그 뒤에 "2" 가 출력된다.
  3. "Member dealloc: 김하나" 줄이 사라지고, "범위 밖: watcher" 와 "범위 밖: loan.member" 줄이 "살아 있음" 이 된다. 회원이 loans 로 기록을 강하게 들고 기록도 회원을 강하게 들어 순환 참조가 생기기 때문이다. 안쪽 범위가 끝나도 회원의 카운트가 0 이 되지 않는다. 또 keep = nil 뒤에도 "Loan dealloc" 줄이 나오지 않는다. 회원이 loans 로 그 기록을 붙들고 있어서다.
  4. 클래스 메서드에서 [self alloc] 을 쓰고 지정 초기화 메서드에 맡긴다.
    + (instancetype)memberWithName:(NSString *)name {
        return [[self alloc] initWithName:name];
    }
    헤더(@interface)에 + (instancetype)memberWithName:(NSString *)name; 선언도 함께 추가한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.