Cocoa Internals - 객체 복사

객체 복사

앞에서 살펴본, 객체 인스턴스를 메모리에 만들어 만들어진 객체 인스턴스를 참조하는 경우와는 달리, 여기서는 객체 인스턴스 데이터를 새로운 객체 인스턴스로 복사해야 하는 경우 필요한 프로토콜에 대해 알아본다.

얕은 복사가 아닌 깊은 복사를 위한 아카이브 방식에 대해 알아본다.

NSCopying 계열 프로토콜

NSCopyingNSMutableCopying 프로토콜이 제공되며, 이들은 객체를 복사하기 위해 미리 구현해야 하는 복사용 메소드 목록을 요구한다.

Cocoa 객체들은 이미 NSCopying 프로토콜을 채택하고 있어 객체를 복사하기 쉽다.

객체를 복사한다는 것은 힙 영역에 별도의 메모리 공간을 할당하고 복사된 객체를 초기화하며, 복사되는 객체와 복사된 객체는 등가성을 갖게 된다는 것을 의미한다.

복사만 가능한 객체

NSCopying 프로토콜을 준수하고 이것이 요구하는 -copyWithZone: (Swift의 copy(with:)) 메소드를 구현한다.

메소드의 구현부는 객체의 속성 등을 그대로 복사하여 반환하는 코드를 가지게 될 것이다.

zone(NSZone 타입)은 더이상 사용되지 않으므로 nil을 넘겨주어도 괜찮다.

이 프로토콜을 구현하고 복사된 객체는 불변이다.

복사와 수정이 가능한 객체

NSMutableCopying 프로토콜을 준수하고 이것이 요구하는 -mutableCopyWithZone: (Swift의 mutableCopy(with:)) 메소드를 구현한다.

NSCopying과 비교했을 때 해당 프로토콜을 구현하고 복사된 객체는 변경 가능하다는 차이가 있다.

요약

애플 프레임워크에 포함된 클래스 대부분은 NSCopying 또는 NSMutableCopying 프로토콜을 구현하고 있어 객체 복사를 하기 쉽다.

커스텀 클래스도 객체 복사 기능을 지원하려면 위의 프로토콜을 준수하여 메소드를 구현할 필요가 있다.

가변 객체와 불변 객체 모두를 지원한다면 NSCopyingNSMutableCopying 프로토콜을 모두 준수해야 할 것이다.

얕은 복사 vs. 깊은 복사

NSArray처럼 내부에 다른 객체를 포함하는 경우 객체 복사시 주의해야 한다.

위의 경우 객체 자체는 복사되어 힙의 개별 공간에 위치하겠지만 그것의 요소들은 복사되지 않아 같은 메모리 주소를 가리키게 된다.

이처럼 참조하는 포인터에 있는 주소 값만 복사하는 방식을 ‘얕은 복사’라고 한다.

얕은 복사

@interface PenHolder : NSObject <NSCopying> {
NSMutableArray *_pens;
}

@end

@implementation PenHolder

- (id)copyWithZone:(NSZone *)zone {
PenHolder *copiedHolder = [[[self class] alloc] init];
copiedHolder->_pens = [_pens mutableCopy];
return copiedHolder;
}

@end

위와 같은 코드가 있다고 가정하자. Penholder 클래스의 -copyWIthZone: 메소드의 구현을 살펴보자.

copiedHolder->_pens = _pens; 처럼 작성했다면 PenHolder만 복사되고 그 내부에 있는 프로퍼티는 복사되지 않아 같은 주소를 참조하게 될 것이다.

하지만 copiedHolder->_pens = [_pens mutableCopy]; 처럼 작성하는 것으로 NSMutableArray가 복사된다.

하지만 Foundation 프레임워크에 있는 모든 클래스는 얕은 복사 형태로 구현되어 있어 NSMutableArray 객체는 복사되지만 배열의 요소는 복사되지 않아 같은 요소의 주소를 가리키게 된다.

이 상태에서 copiedHolder 인스턴스 내 _pens 집합에 있는 Pen 객체 내부의 값을 변경하면, 기존 holder 인스턴스 내 _pens 집합에 있는 동일한 Pen 객체 내용도 바뀌게 된다.

엄밀히 말하여 이것은 복사된 것이 아니다.

배열 계열 컬렉션 클래스에는 -initWithArray: copyItems:와 같은 깊은 복사를 지원하는 메소드가 있으므로 깊은 복사를 원한다면 이를 활용한다.

깊은 복사

위에서 말한 것처럼 -initWithArray: copyItems:와 같은 메소드를 사용하여 깊은 복사를 할 수 있다. NSMutableArray 객체 뿐만 아니라 그 요소들까지도 힙 영역의 별개 공간에 복사되게 된다.

깊은 복사시 복사하는 객체 내부에서 다른 객체를 참조하는 경우를 조심해야 한다.

객체 그래프를 따라 깊은 복사를 했더라도 객체의 참조 관계의 관점에서는 기존 객체와 항상 완벽하게 동일하다고 말하기 힘들다. 어떠한 객체는 중간에 여러 객체에게서 여러 번 참조될 수도 있고, 순환 참조 문제가 있을 수도 있다.

객체 관계를 완벽하게 복원하려면, 깊은 복사가 어떤 형태의 객체 그래프로 그려지는지 확인한 뒤 참조 관계에 따라서 복사 방식을 결정해야 한다.

요약

Cocoa 프레임워크의 클래스는 얕은 복사 형태로 복사되어 참조 관계를 유지한다.

컬렉션 객체나 다른 객체를 참조하는 경우에 객체를 복사해야 하는 경우 깊은 복사 방식을 고려해 보아야 한다.

객체 그래프가 복잡한 경우 깊은 복사만으로는 하위 객체들의 참조 관계를 완벽하게 복사하기 힘들다.

아카이브

객체 그래프를 탐색하여 깊은 복사를 구현하기 위한 두 가지 방법

  • Core Data
    • Core Data는 ORM처럼 활용하여 데이터베이스에 쉽게 접근하기 위한 방식으로 이해되고 있으나, 사실 이는 객체 그래프를 저장하기 위한 프레임워크다. 읽어볼만한 글
  • NSCopying 프로토콜 및 Keyed-Archive 클래스 활용하여 객체 인코딩

여기서는 NSCopying 프로토콜 및 Keyed-Archive를 활용한 아카이브로 깊은 복사를 구현하는 방식에 대해 알아본다.

객체 직렬화와 아카이브의 차이

Serialization. 객체 그래프를 따라 객체의 데이터 내용을 저장.

주로 문자열과 배열, 딕셔너리 컬렉션에 담겨 있는 계층 구조와 참조하는 객체 데이터만 직접적으로 저장한다.

여러 곳에서 하나의 객체를 다중 참조하고 있으면 참조마다 동일한 내용의 객체를 여러 개 저장한다. 그러므로 다시 객체화하면 (deserialized) 동일한 객체를 다중 참조하는 것이 아니라 각기 다른 객체가 만들어진다.

또한 데이터 값만 저장하므로 다시 객체화할 때 이것이 불변 객체인지 가변 객체인지 판단할 수 없다.

객체 직렬화의 가장 대표적인 것이 NSPropertyListSerialization 클래스인데, 이것은 NSDictionary, NSArray, NSString, NSDate, NSData, NSNumber (Dictionary, Array, String, Date, Data, Int, Float, Double) 타입으로 저장되어 있는 데이터 구조만 XML 기반 프로퍼티 리스트 파일 (.plist) 로 직렬화하여 저장한다.

NSUserDefaults (UserDefaults) 클래스는 이러한 방식을 내부적으로 사용한다.

위에서 언급한 것처럼 직렬화할 수 있는 데이터 타입이 제한적이다. 위에서 지원하는 것 이외의 데이터를 직렬화하려면 NSData로 변환하여 직렬화해야 할 것이다.

NSCoding 프로토콜

객체 관계가 복잡하거나 일정 규모 이상으로 커지는 경우 프로퍼티 리스트 방식을 사용하기에 부적합해진다. 또한 프로퍼티 리스트는 타입이 제한적이며 가변 객체나 다중 참조 관계도 원래대로 복원하지 못한다.

이러한 것들을 지원하기 위해 NSCoding 프로토콜을 사용한다. 이는 객체 인스턴스를 인코딩하거나 다시 객체로 디코딩하기 위한 메소드 두 개를 요구한다.

  • -initWithCoder: (init?(coder:)) 메소드는 인자 값으로 넘겨지는 decoder 객체에서 데이터를 찾아 새로운 객체를 초기화한다.
  • -encodeWithCoder: (encode(with:)) 메소드는 해당 객체의 인스턴스 변수를 인코딩한다.

이들 메소드가 인자로 요구하는 NSCoder 클래스는 메모리상에 있는 객체 인스턴스 변수를 다른 형태로 변환하기 위한 인터페이스를 정의한 추상 클래스이며, NSKeyedArchiver, NSKeyedUnarchiver와 같은 하위 클래스 구현체를 일반적으로 사용한다.

객체 그래프와 아카이브

NSCoder 는 뿌리 객체root object와 조건부 객체conditional object라는 개념을 사용한다.

뿌리 객체

객체 그래프에 대한 탐색을 위한 시작점. 아카이브를 시작하는 시점이 된다.

뿌리 객체부터 탐색을 시작하여 기존에 인코딩했던 객체를 다시 참조하는 경우에는 인코딩한 기존 객체를 참조한다.

NSCoder 클래스의 encodeRootObject: 메소드를 사용하여 처리한다.

조건부 객체

객체 그래프에서 반드시 아카이브하지 않아도 되는 참조 객체를 의미한다.

어느 시점에서 반드시 인코딩이 되는 특정한 객체를 참조하기 때문에 다시 인코딩할 필요가 없는 객체를 말한다.

encodeConditionalObject:forKey 메소드를 사용한다.

아카이빙하는 동안 조건부 객체만 인코딩되는 경우 다시 디코딩하여 복원할 때 해당 객체는 참조할 수 없고 nil을 반환하며, 조건 없는 객체unconditional object가 인코딩되어 있는 경우 해당 객체를 참조하는 포인터를 반환한다.

따라서 조건부 객체는 약한 참조 형태로 인코딩한다고 이해할 수 있다.

NSArchiver, NSUnarchiver 클래스는 deprecated되었다.

이름 있는 아카이브

NSKeyedArchiver 클래스를 사용하여 이름 있는 아카이브keyed archives 방식으로 객체를 인코딩한다.

이는 딕셔너리 타입처럼 변수에 대한 키 값을 이름으로 지정하여 인코딩할 수 있게 한다.

디코딩할 때는 NSKeyedUnarchiver 클래스를 사용한다.

디코딩할 때 해당 키 값에 대한 데이터가 없을 수 있다. 이 경우 객체에 대해서는 nil, 또는 NO나 0.0과 같은 기본 값을 반환한다.

아카이브 델리게이트

각 클래스에 대한 델리게이트를 지정하여 객체를 인코딩하기 직전이나 직후에, 모든 인코딩이 끝날 때 특정 처리를 할 수 있다.

아카이브 만들기

NSKeyedArchiver 클래스를 사용한다.

+archiveRootObject:toFile: 클래스 메소드나 +archiveDataWithRootObject: 클래스 메소드를 사용하여 뿌리 객체부터 아카이브를 만들 수 있다.

직접 원하는 객체를 아카이브하고 싶다면 -initForWritingWithMutableData: 메소드를 사용하여 NSKeyedArchiver 객체 인스턴스를 먼저 생성하고 초기화한 후, -encodeObject:forKey: 메소드로 개별 인코딩 작업을 진행한 후, 인코딩 작업이 끝나면 반드시 -finishEncoding 메소드를 호출해야 한다.

아카이브 해제하기

NSKeyedUnarchiver 클래스를 사용한다.

뿌리 객체로 아카이브한 경우 +unarchiveObjectWithFile: 또는 unarchiveObjectWithData: 클래스 메소드를 사용하여 다시 객체화할 수 있다.

아카이브 해제 과정을 직접 처리하려면 먼저 -initForReadingWithData: 메소드를 호출하여 인스턴스를 초기화하고, -decodeObjectForKey: 메소드로 개별 디코딩 작업을 진행한 후, 디코딩 작업이 끝나면 반드시 -finishDecoding 메소드를 호출해야 한다.

위에서 언급한 각 클래스의 클래스 메소드 및 인스턴스 메소드는 iOS 12 이후, macOS 10.14 이후에서 deprecated 되었다.

객체 인코딩과 디코딩

NSKeyedArchiverNSKeyedUnarchiver 클래스에 정의된 메소드 사용하여 NSCoding 프로토콜 요구 메소드 구현하기

NSCoding 프로토콜의 -encodeWithCoder: 메소드의 구현에 NSKeyedArchiver-encodeObject:forKey 메소드를 사용한다. 이는 객체 인스턴스 변수를 인코딩하는 역할을 해야 할 것이다.

NSCoding 프로토콜의 -initWithCoder: 메소드의 구현에 NSKeyedUnarchiver-decodeObjectForKey: 메소드를 사용한다. 이는 객체 인스턴스 변수를 디코딩하는 역할을 해야 할 것이다.

NSSecureCoding 프로토콜

기존 NSCoding 프로토콜에 보안성을 강화하여 확장한 것 (NSCoding 프로토콜로부터 상속됨)

아카이브한 파일 내부 클래스를 대체하는 공격 방식에 대체할 수 있다.

아카이브한 바이너리 데이터를 네트워크상으로 주고 받는 경우에 대한 해킹 위험을 방지한다.

XPC 방식으로 객체를 아카이브해서 주고 받을 때는 반드시 이 프로토콜을 구현해야 한다.

요약

객체 그래프를 탐색하며 깊은 복사를 구현하기 위해 객체 직렬화 방식이나 아카이브 방식을 고려할 수 있다.

객체 직렬화 방식은 간단하지만 타입 제한, 복잡한 관계를 가진 객체 그래프는 복원되지 않는다는 등의 단점이 있다.

아카이브 방식은 NSCoding 프로토콜을 구현한 객체에서 모두 깊은 복사가 가능하고, 객체 그래프도 완벽하게 복원이 된다.