Devin.KR

Objective-C · 기본

Objective-C 문법과 Cocoa 기초

프로퍼티 - 속성 키워드의 의미

@property 와 자동 합성, strong·weak·copy·assign, nonatomic, readonly, 점 문법과 메시지 문법, NSString 에 copy 를 쓰는 이유

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 클래스를 헤더(.h)와 구현(.m)으로 나누고 인스턴스 변수와 메서드를 선언하는 방법을 다뤘다. 이 장에서는 객체가 가진 값을 바깥에 열어 주는 표준 방법인 프로퍼티(property)를 다룬다. 프로퍼티는 한 줄 선언으로 값을 읽고 쓰는 메서드와 값을 담는 저장 공간을 함께 만들어 준다. 선언에 붙이는 속성 키워드는 그 값을 누가 소유하는지, 바깥에서 바꿀 수 있는지를 정한다. 키워드를 잘못 고르면 컴파일은 되지만 실행 중에 값이 뜻밖에 바뀌거나 사라진다.

  • @property 한 줄이 접근자 메서드와 인스턴스 변수를 만들어 주는 자동 합성(auto-synthesis)의 규칙을 설명할 수 있다.
  • strong, weak, copy, assign의 차이를 대상이 사라질 때의 동작까지 포함해 구분한다.
  • NSString 프로퍼티에 copy를 쓰는 이유를 변경 가능한 문자열을 넣는 예로 보인다.
  • nonatomic, readonly, getter=가 무엇을 바꾸는지 안다.
  • 점 문법과 메시지 문법이 같은 호출임을 알고, 두 문법을 섞어 쓸 때의 규칙을 지킨다.

문제 상황

사내 도서 대출 관리 도구에서 Book 클래스를 만들고 있다고 하자. 앞 장의 방식대로 하면 값 하나마다 인스턴스 변수를 선언하고, 읽는 메서드와 쓰는 메서드를 직접 적는다. 제목 하나를 다루는 코드는 대략 이렇다.

@interface Book : NSObject {
    NSString *_title;
}
- (NSString *)title;
- (void)setTitle:(NSString *)title;
@end

@implementation Book
- (NSString *)title {
    return _title;
}
- (void)setTitle:(NSString *)title {
    _title = title;
}
@end

필드가 제목, 저자, 대출 일수, 대출 회원으로 늘면 이 모양이 필드 수만큼 반복된다. 반복보다 더 큰 문제는 이 코드가 의미를 담지 못한다는 점이다. _title = title; 은 문자열의 주소만 저장한다. 등록 화면이 입력 버퍼로 NSMutableString 하나를 재사용하는 상황을 생각해 보자. 첫 번째 책의 제목으로 버퍼를 넘기고, 이어서 같은 버퍼에 다음 책 제목을 쓰면 이미 등록한 첫 번째 책의 제목도 함께 바뀐다. 코드에는 오류가 없고 컴파일 경고도 없다.

대출 회원을 가리키는 필드에도 비슷한 문제가 있다. Book 이 회원 객체를 붙잡고 있으면 회원이 목록에서 빠진 뒤에도 객체가 메모리에 남는다. 아무것도 붙잡지 않으면 이미 사라진 객체를 가리킬 위험이 생긴다. 프로퍼티의 속성 키워드는 이런 선택을 선언 한 줄에 적어 두는 장치다.

프로퍼티 선언과 자동 합성

한 줄이 만들어 주는 것

위의 보일러플레이트(매번 되풀이되는 틀에 박힌 코드)는 다음 한 줄로 바뀐다.

@property (nonatomic, copy) NSString *title;

컴파일러(clang)는 이 선언을 보고 구현부에 세 가지를 보충한다. 이 보충을 자동 합성이라고 한다.

  • 값을 읽는 메서드 - (NSString *)title
  • 값을 쓰는 메서드 - (void)setTitle:(NSString *)title (readonly이면 만들지 않는다)
  • 값을 담는 인스턴스 변수 _title. 프로퍼티 이름 앞에 밑줄을 붙인 이름이다.

쓰는 메서드의 이름은 set 다음에 프로퍼티 이름의 첫 글자를 대문자로 바꿔 붙인 형태다. 읽는 메서드는 프로퍼티 이름 그대로다. 이 이름 규칙은 코드 전체가 믿고 쓰는 약속이다. 구현부에 아무것도 쓰지 않아도 되고, 접근자를 직접 구현하면 그 구현이 우선한다.

접근자와 인스턴스 변수

클래스 안에서 값을 다루는 길은 두 가지다. self.title처럼 접근자를 거치는 길과 _title처럼 인스턴스 변수를 바로 만지는 길이다. 접근자를 거치면 copy 같은 키워드의 동작이 적용된다. 인스턴스 변수를 바로 만지면 그 동작이 적용되지 않으므로 직접 [title copy]처럼 써야 한다. 일반 메서드에서는 접근자를 쓰고, 접근자 자신과 초기화 메서드 안에서만 _title을 쓰는 것이 관례다. 이유는 뒤의 '실무에서 자주 틀리는 것'에서 다시 본다. 초기화 메서드의 자세한 규칙은 다음 장에서 다룬다.

속성 키워드의 의미

소유권을 정하는 네 키워드

객체를 가리키는 변수는 그 객체를 붙잡아 둘 수도 있고, 위치만 알고 있을 수도 있다. ARC(자동 참조 계산)는 객체를 붙잡고 있는 참조가 하나도 없으면 그 객체를 메모리에서 치운다. ARC 자체는 다음 장에서 자세히 다루고, 여기서는 이 규칙만 전제로 한다. 속성 키워드는 프로퍼티가 값을 어떤 방식으로 쥘지 정한다.

소유권 키워드는 값을 붙잡는 방식과 대상이 사라졌을 때의 동작이 다르다
키워드값을 쥐는 방식대상이 사라지면주로 쓰는 곳
strong객체를 붙잡는다이 참조가 붙잡고 있어 사라지지 않는다소유하는 객체. ARC 에서 객체 프로퍼티의 기본값
weak붙잡지 않고 가리키기만 한다자동으로 nil 이 된다소유하지 않는 관계, 되돌아가는 참조
copy복사본을 만들어 그것을 붙잡는다복사본이라 원본과 무관하다NSString 처럼 변경 가능한 형제 클래스가 있는 값
assign값을 그대로 대입한다해당 없음(C 값이므로)NSInteger, BOOL, double 같은 C 타입. 기본값

assign은 C 타입 전용으로 생각하면 된다. 정수나 불리언(BOOL)에는 붙잡을 객체가 없으므로 값만 복사한다. 키워드를 생략하면 C 타입에는 assign이, ARC 아래의 객체 포인터에는 strong이 적용된다. 그래도 의도를 드러내려고 적어 주는 편이 읽기 좋다.

NSString 에 copy 를 쓰는 이유

NSString은 내용을 바꿀 수 없는 문자열이고, 그 하위 클래스인 NSMutableString은 내용을 바꿀 수 있다. 하위 클래스의 객체는 NSString 변수에도 들어간다. 그래서 NSString * 형 프로퍼티에 받은 인수가 실제로는 변경 가능한 문자열일 수 있다. 프로퍼티가 strong이면 그 객체를 그대로 붙잡으므로, 호출한 쪽이 나중에 문자열을 고치면 프로퍼티 값도 같이 바뀐다. copy이면 설정하는 순간의 내용을 복사해 두므로 이후 변경과 무관하다.

strong 은 원본 문자열을 함께 가리켜 변경이 따라오고, copy 는 복사본을 가져 설정 시점의 값을 유지한다.

원본이 NSString(변경 불가)이면 copy는 보통 같은 객체를 그대로 돌려주므로 비용이 거의 들지 않는다. 복사는 변경 가능한 문자열이 들어왔을 때만 일어난다. 변경할 수 없는 값을 복사하는 것은 낭비가 아니고, 필요할 때만 복사가 일어나는 구조다. 이 이유로 NSString 프로퍼티에는 copy를 기본으로 쓴다. 배열과 딕셔너리처럼 변경 가능한 형제 클래스가 있는 타입도 같은 이유로 copy를 쓴다. 이들은 7장 이후에 다룬다.

같은 이유로 copy는 변경 가능한 타입 자체에는 어울리지 않는다. copy로 만든 복사본은 변경 불가한 형태이므로, 프로퍼티 타입이 NSMutableString이면 나중에 수정하는 메시지가 실행 중에 오류가 된다.

weak 와 자동 nil

대출 기록에서 Book 은 지금 빌려 간 회원을 알고 싶지만, 회원 객체의 수명을 책임지고 싶지는 않다. 이럴 때 weak를 쓴다. weak 프로퍼티는 객체를 붙잡지 않는다. 다른 곳의 strong 참조가 모두 사라져 객체가 치워지면, 실행 시스템이 weak 참조를 자동으로 nil 로 바꿔 준다. nil 에 메시지를 보내도 오류가 나지 않는다(앞에서 다룬 메시지 문법의 성질이다). 그래서 사라진 객체를 가리키는 위험 포인터가 남지 않는다.

weak 프로퍼티는 객체를 붙잡지 않으므로 strong 참조가 사라지면 nil 이 된다.

weak를 쓰는 것은 소유하지 않아도 되는 관계일 때다. 어떤 객체를 이 프로퍼티만 쥐고 있다면 weak로 선언한 순간 객체가 곧바로 사라진다. 이 함정은 뒤에서 코드로 본다. 두 객체가 서로를 strong으로 쥐면 둘 다 치워지지 않는 순환 참조가 생긴다. 한쪽을 weak로 두는 것이 이를 막는 일반적인 방법이다.

nonatomic

속성 키워드를 생략하면 atomic이 적용된다. atomic은 값을 읽고 쓰는 동작 하나가 중간 상태 없이 끝나도록 보장하는 잠금을 접근자에 건다. nonatomic은 그 잠금을 걸지 않는다. 한 프로퍼티를 읽고 쓰는 동작만 보호하므로, 여러 프로퍼티를 함께 바꾸는 코드의 안전은 atomic으로 해결되지 않는다. 그래서 대부분의 코드는 nonatomic을 적는다. 이 책의 예제는 모두 한 스레드에서 실행되므로 nonatomic을 쓴다.

readonly 와 getter=

readonly는 쓰는 메서드를 만들지 않는다. 바깥에서 값을 읽을 수만 있고 바꿀 수는 없다. 도서 번호처럼 한번 정해지면 바뀌면 안 되는 값에 쓴다. 클래스 안에서는 초기화 메서드가 _bookID에 직접 값을 넣는다. 바깥에서 book.bookID = 5;를 쓰면 컴파일 오류가 난다.

getter=는 읽는 메서드의 이름을 바꾼다. 불리언 프로퍼티는 읽을 때 isOnLoan처럼 묻는 형태가 자연스럽다.

@property (nonatomic, readonly, getter=isOnLoan) BOOL onLoan;

이 선언에서 프로퍼티 이름은 onLoan이고 읽는 메서드 이름은 isOnLoan이다. 점 문법으로는 book.onLoan이라고 쓰며 실제로는 isOnLoan이 호출된다. 읽는 메서드를 구현부에서 직접 쓰면, 값을 저장하는 인스턴스 변수 없이 다른 프로퍼티에서 계산한 값을 돌려줄 수 있다. 이 장의 예제는 borrower가 nil 이 아닌지로 대출 여부를 계산한다.

점 문법과 메시지 문법

프로퍼티 값은 두 가지 문법으로 접근할 수 있다. 점 문법은 대입 연산자의 왼쪽에 놓이면 쓰는 메서드를, 그 외에는 읽는 메서드를 부르는 문법적 편의다. 다른 동작을 하는 것이 아니고, 컴파일러가 메시지 문법으로 바꿔서 번역한다.

점 문법은 같은 접근자 호출을 줄여 쓴 것이다
점 문법메시지 문법호출되는 메서드
x = book.loanDays;x = [book loanDays];읽는 메서드
book.loanDays = 21;[book setLoanDays:21];쓰는 메서드
book.loanDays += 7;[book setLoanDays:[book loanDays] + 7];읽기 후 쓰기
book.onLoan[book isOnLoan]getter=로 정한 이름

점 문법은 값처럼 읽히는 접근에 쓰고, 일을 시키는 동작(예를 들어 대출 처리)은 메시지 문법으로 쓰는 것이 관례다. 이렇게 하면 코드를 읽는 사람이 값 접근과 동작을 구분할 수 있다. 점 문법은 객체 포인터에서만 쓰며, C 구조체의 멤버를 가리키는 점과는 문법이 같아 보이지만 별개의 기능이다. 이 점은 자주 헷갈리므로 기억해 두자.

완성 코드

아래 프로그램은 이 장의 내용을 한 파일에 담은 것이다. 결과는 printf로 표준 출력에 낸다. NSLog는 시각과 프로세스 정보를 앞에 붙여 표준 오류로 내보내므로 실행할 때마다 출력이 달라지고, 결과를 파이프로 넘기거나 파일에 저장해 비교하기에 맞지 않는다. 그래서 결과 출력에는 쓰지 않는다.

main.m

#import <Foundation/Foundation.h>

static void say(NSString *text) {
    printf("%s\n", [text UTF8String]);
}

@interface Member : NSObject
@property (nonatomic, copy) NSString *name;
@end

@implementation Member
@end

@interface BookDraft : NSObject
@property (nonatomic, strong) NSString *title;
@end

@implementation BookDraft
@end

@interface Book : NSObject
@property (nonatomic, copy) NSString *title;
@property (nonatomic, readonly) NSInteger bookID;
@property (nonatomic, assign) NSInteger loanDays;
@property (nonatomic, weak) Member *borrower;
@property (nonatomic, readonly, getter=isOnLoan) BOOL onLoan;
- (instancetype)initWithID:(NSInteger)bookID title:(NSString *)title;
- (NSString *)summary;
@end

@implementation Book

- (instancetype)initWithID:(NSInteger)bookID title:(NSString *)title {
    self = [super init];
    if (self) {
        _bookID = bookID;
        _title = [title copy];
        _loanDays = 14;
    }
    return self;
}

- (BOOL)isOnLoan {
    return self.borrower != nil;
}

- (NSString *)summary {
    return [NSString stringWithFormat:@"#%ld %@ (%ld일)",
            (long)self.bookID, self.title, (long)self.loanDays];
}

@end

int main(void) {
    @autoreleasepool {
        Book *book = [[Book alloc] initWithID:101 title:@"Refactoring"];
        say([book summary]);
        say([NSString stringWithFormat:@"bookID: %ld (읽기 전용)", (long)book.bookID]);

        book.loanDays = 21;
        [book setLoanDays:[book loanDays] + 7];
        say([NSString stringWithFormat:@"loanDays: %ld", (long)book.loanDays]);

        NSMutableString *buffer = [NSMutableString stringWithString:@"Clean Code"];
        BookDraft *draft = [[BookDraft alloc] init];
        book.title = buffer;
        draft.title = buffer;
        [buffer appendString:@" (draft)"];
        say([NSString stringWithFormat:@"copy 속성: %@", book.title]);
        say([NSString stringWithFormat:@"strong 속성: %@", draft.title]);

        {
            Member *temp = [[Member alloc] init];
            temp.name = @"김서연";
            book.borrower = temp;
            say([NSString stringWithFormat:@"대출 중(블록 안): %@, %@",
                 book.onLoan ? @"예" : @"아니오", temp.name]);
        }
        say([NSString stringWithFormat:@"대출 중(블록 밖): %@",
             book.onLoan ? @"예" : @"아니오"]);
    }
    return 0;
}

줄별 해설

  • static void say(NSString *text): NSString을 받아 UTF-8 바이트열로 바꿔 printf로 출력하는 도우미 함수다. C 언어의 함수이므로 클래스 밖에 둔다. static은 이 파일 안에서만 쓰겠다는 표시다.
  • Member: 프로퍼티 하나만 가진 가장 작은 클래스다. 구현부가 비어 있어도 접근자와 _name이 자동으로 만들어진다.
  • BookDraft: 비교용 클래스다. 제목을 strong으로 선언해 copy와의 차이를 보여 준다. 실제 코드에서는 이렇게 쓰지 않는다.
  • @property (nonatomic, readonly) NSInteger bookID;: 읽기 전용이다. 쓰는 메서드는 없고 _bookID는 있다.
  • @property (nonatomic, readonly, getter=isOnLoan) BOOL onLoan;: 읽는 메서드 이름을 isOnLoan으로 바꾼다. 구현부에서 이 메서드를 직접 썼으므로 저장 공간은 만들어지지 않는다.
  • initWithID:title:: 인스턴스 변수에 직접 값을 넣는다. _title = [title copy];는 접근자를 거치지 않으므로 copy를 직접 호출해야 같은 효과를 낸다.
  • - (BOOL)isOnLoan: self.borrower != nil로 대출 여부를 계산한다. 회원 객체가 사라지면 borrower가 nil 이 되므로 이 값도 함께 바뀐다.
  • summary: %ld는 long용 서식이다. NSInteger는 플랫폼에 따라 크기가 다르므로 (long)으로 변환해 넘겨야 경고가 나지 않는다.
  • book.loanDays = 21;과 [book setLoanDays:[book loanDays] + 7];: 두 문법을 이어 쓴다. 21에 7을 더해 28이 된다.
  • book.title = buffer;와 draft.title = buffer;: 같은 변경 가능한 문자열을 두 객체에 넣는다. 그다음 appendString:으로 버퍼를 바꾼다. copy인 Book 은 변하지 않고 strong인 BookDraft 는 바뀐다.
  • 중괄호 블록: 지역 변수 temp의 유효 범위를 좁힌다. 블록이 끝나면 temp의 참조가 사라져 Member 객체가 치워지고, weak인 borrower는 nil 이 된다. 블록 안에서 값을 읽는 마지막 위치에 temp.name을 쓴 것은 temp가 그 시점까지 살아 있음을 분명히 하기 위해서다.
  • @autoreleasepool: 이 구조는 6장에서 설명한다. 지금은 main 본문을 감싸는 틀로 받아들이면 된다.

실행 결과

$ clang -fobjc-arc -Wall -framework Foundation *.m -o app
$ ./app
#101 Refactoring (14일)
bookID: 101 (읽기 전용)
loanDays: 28
copy 속성: Clean Code
strong 속성: Clean Code (draft)
대출 중(블록 안): 예, 김서연
대출 중(블록 밖): 아니오

실무에서 자주 틀리는 것

NSString 프로퍼티를 strong 으로 둔다

틀린 코드다.

@property (nonatomic, strong) NSString *title;

호출하는 쪽에서 변경 가능한 문자열을 넘기고 나중에 고치면 이미 설정한 제목이 바뀐다. 컴파일 경고도 없고 입력 버퍼를 재사용하는 코드에서만 가끔 드러나 찾기 어렵다. 고친 코드다.

@property (nonatomic, copy) NSString *title;

weak 프로퍼티에 방금 만든 객체를 넣는다

틀린 코드다.

book.borrower = [[Member alloc] init];
// 이 줄이 끝나면 Member 객체는 이미 치워졌고 borrower 는 nil 이다

새로 만든 객체를 붙잡는 곳이 weak 프로퍼티뿐이므로 대입 직후에 객체가 사라진다. clang 이 경고를 내기도 한다. 객체를 소유하는 쪽을 따로 정해야 한다. 고친 코드다.

Member *member = [[Member alloc] init];
book.borrower = member;   // member 변수가 살아 있는 동안 유효하다

readonly 프로퍼티에 대입한다

틀린 코드다.

book.bookID = 5;   // 오류: readonly 프로퍼티에는 대입할 수 없다

도서 번호는 만든 뒤 바뀌면 안 되는 값으로 설계했으니 이 오류는 의도된 보호다. 값을 정하는 시점은 초기화 메서드 안이다. 고친 코드다.

- (instancetype)initWithID:(NSInteger)bookID title:(NSString *)title {
    self = [super init];
    if (self) {
        _bookID = bookID;   // 초기화 안에서는 인스턴스 변수에 직접 넣는다
    }
    return self;
}

쓰는 메서드 안에서 self.프로퍼티에 대입한다

틀린 코드다.

- (void)setTitle:(NSString *)title {
    self.title = title;   // 점 문법은 setTitle: 을 다시 부른다. 끝없이 되풀이된다
}

점 문법은 쓰는 메서드를 호출하는 문법이다. 쓰는 메서드 안에서 쓰면 자기 자신을 다시 부르다가 스택이 가득 차 프로그램이 죽는다. 접근자 안에서는 인스턴스 변수에 직접 넣어야 한다. 고친 코드다.

- (void)setTitle:(NSString *)title {
    _title = [title copy];
}

한눈에 보기

속성 키워드와 문법을 한 표로 정리하면 고르는 기준이 보인다
항목의미고르는 기준예
strong객체를 붙잡는다이 객체의 수명을 책임질 때소유하는 하위 객체
weak붙잡지 않고 사라지면 nil소유하지 않는 관계, 순환 방지대출 중인 회원
copy복사본을 붙잡는다변경 가능한 형제 클래스가 있을 때제목, 이름 같은 NSString
assign값을 그대로 대입한다C 타입NSInteger, BOOL
nonatomic접근자에 잠금을 걸지 않는다대부분의 경우이 책의 모든 예제
readonly쓰는 메서드가 없다바깥에서 바꾸면 안 되는 값도서 번호
getter=읽는 메서드 이름을 바꾼다BOOL 을 묻는 형태로 읽고 싶을 때isOnLoan
점 문법접근자 호출의 줄임 표기값처럼 읽히는 접근book.loanDays

프로퍼티와 접근자 설계에 관한 공식 설명은 Programming with Objective-C의 Encapsulating Data에서 확인할 수 있다.

연습 문제

  1. Book 클래스에 다음 세 프로퍼티를 추가하려 한다. 각각 어떤 소유권 키워드가 알맞은지 고르고 이유를 한 줄로 적어라. (가) 저자 이름을 담는 NSString *author (나) 쪽 수를 담는 NSInteger pageCount (다) 책을 마지막으로 빌린 Member *lastBorrower. 이 책이 회원 객체의 수명을 책임지지 않는다고 가정한다.
  2. book.loanDays += 7;을 점 문법 없이 메시지 문법만으로 다시 써라.
  3. 다음 코드는 무엇을 출력하는가. 그리고 name이 strong이었다면 무엇이 출력되는가. 단, say는 문자열을 출력하는 앞의 함수이고, Member의 name은 copy다.
    NSMutableString *s = [NSMutableString stringWithString:@"A"];
    Member *m = [[Member alloc] init];
    m.name = s;
    [s appendString:@"B"];
    say(m.name);
  4. Member 클래스에 한번 정하면 바뀌지 않는 회원 번호 memberID(NSInteger)와, 활동 여부를 나타내며 isActive라는 이름으로 읽는 BOOL 프로퍼티 active를 선언하라. 둘 다 nonatomic을 쓰고, 회원 번호는 바깥에서 대입할 수 없어야 한다.

정답과 해설

  1. (가) copy. 문자열이어서 변경 가능한 문자열이 들어올 수 있다. (나) assign. C 타입이어서 붙잡을 객체가 없다. (다) weak. 회원 객체의 수명을 책임지지 않으므로 붙잡지 않고, 회원이 사라지면 nil 이 되는 편이 안전하다. 세 프로퍼티 모두 nonatomic을 함께 쓴다.
  2. [book setLoanDays:[book loanDays] + 7]; 읽는 메서드로 현재 값을 얻고, 7을 더한 값을 쓰는 메서드에 넘긴다.
  3. copy이면 A가 출력된다. 대입 시점의 내용을 복사해 두었기 때문이다. strong이면 같은 객체를 붙잡고 있으므로 AB가 출력된다.
  4. 다음과 같이 선언한다.
    @property (nonatomic, readonly) NSInteger memberID;
    @property (nonatomic, assign, getter=isActive) BOOL active;
    readonly이므로 member.memberID = 3;은 컴파일 오류가 된다. 값은 초기화 메서드에서 _memberID에 넣는다. active는 member.active로 쓰지만 실제로 호출되는 읽는 메서드는 isActive다. 쓰는 메서드 이름은 getter=와 무관하게 setActive:다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

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

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