Objective-C · 기본
Objective-C 문법과 Cocoa 기초
초기화와 메모리 - alloc init 과 ARC
alloc·init 두 단계, 지정 초기화 메서드와 self = [super init], 편의 생성자, ARC 의 참조 카운트, 순환 참조와 weak, dealloc
개발자KR · 원고 갱신
이 장에서 배우는 것
객체를 쓰려면 먼저 만들어야 하고, 다 쓴 객체는 메모리에서 치워야 한다. 앞 장에서는 프로퍼티로 객체의 값을 읽고 쓰는 방법을 다뤘다. 이 장은 그 앞뒤를 다룬다. 객체가 어떻게 태어나 값을 갖추는지, 언제 사라지는지를 본다. 사내 도서 대출 관리 도구의 도서(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 를 붙여 컴파일러에게도 알린다.
지정 초기화 메서드의 본문은 정해진 모양을 따른다.
- (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)로 바꾸는 것이다. 약한 참조는 카운트를 올리지 않는다. 가리키는 객체가 해제되면 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 |
| dealloc | ARC 가 모르는 정리만 적는다 | [super dealloc] 금지 |
다음 장에서는 이 장에서 계속 써 온 문자열과 숫자를 객체로 다루는 NSString 과 NSNumber 를 본다. 참고 자료로 Apple 의 Programming with Objective-C 와 clang 의 ARC 문서가 있다.
연습 문제
- 대출 가능 권수가 1 보다 작으면 1 로 고치는 검사를
initWithTitle:쪽에 적으면 어떤 문제가 생기는지 설명하라. - 아래 코드가 출력하는 내용을 순서대로 적어라.
Member는 이 장의 클래스이다.Member *m = [[Member alloc] initWithName:@"박둘"]; Member *n = m; m = nil; printf("1\n"); n = nil; printf("2\n"); - 완성 코드에서
Loan의member프로퍼티를strong으로 바꾸면 실행 결과에서 어떤 줄이 달라지는지, 그 이유와 함께 적어라. Member에 편의 생성자+memberWithName:을 추가하는 코드를 작성하라.
정답과 해설
- 다른 초기화 경로에서는 검사를 거치지 않게 된다. 예를 들어
initWithTitle:copies:를 직접 부르는 코드는 음수 권수를 그대로 저장한다. 값을 검증하는 코드는 모든 경로가 지나는 지정 초기화 메서드 한 곳에 둔다. - 출력은 다음 세 줄이다.
1 Member dealloc: 박둘 2m = nil은 강한 참조 하나만 없애서n이 남아 있다.n = nil에서 카운트가 0 이 되어dealloc이 실행되고, 그 뒤에 "2" 가 출력된다. - "Member dealloc: 김하나" 줄이 사라지고, "범위 밖: watcher" 와 "범위 밖: loan.member" 줄이 "살아 있음" 이 된다. 회원이
loans로 기록을 강하게 들고 기록도 회원을 강하게 들어 순환 참조가 생기기 때문이다. 안쪽 범위가 끝나도 회원의 카운트가 0 이 되지 않는다. 또keep = nil뒤에도 "Loan dealloc" 줄이 나오지 않는다. 회원이loans로 그 기록을 붙들고 있어서다. - 클래스 메서드에서
[self alloc]을 쓰고 지정 초기화 메서드에 맡긴다.
헤더(+ (instancetype)memberWithName:(NSString *)name { return [[self alloc] initWithName:name]; }@interface)에+ (instancetype)memberWithName:(NSString *)name;선언도 함께 추가한다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.