Swift 구조체는 할당 후에도 가변 객체를 공유할 수 있습니다. 복사본을 독립적으로 유지하려면 값 타입 속성을 사용하거나 클래스 저장소를 copy-on-write 뒤에 숨기세요. 이 가이드에서는 Mac 데스크톱 미리 보기용 합성 타일 행 계산기를 만들고, 한 복사본을 편집해도 다른 복사본이 바뀌지 않는지 확인합니다.
구조체를 복사했는데도 원본이 바뀌는 이유는 무엇인가요?
데스크톱 미리 보기에서 타일 행의 너비를 계산한다고 가정해 보겠습니다. 다른 타일 너비를 시험하려고 설정을 복사하면서 원래 미리 보기는 바뀌지 않을 것으로 기대합니다. 하지만 두 설정 값에 같은 가변 객체를 가리키는 참조가 들어 있으면 그런 기대는 빗나갑니다.
Swift는 값을 다른 변수에 할당할 때 구조체의 저장된 값을 복사합니다. 클래스 속성의 경우 저장된 값은 참조이므로, 참조 대상 객체는 계속 공유됩니다. 래퍼는 여전히 구조체지만, 관찰 가능한 동작에는 공유된 변경이 포함될 수 있습니다. 이 규칙은 Swift 문서에 설명된 값 타입과 참조 타입의 차이를 따릅니다.
다음 전체 예제는 이 문제를 보여 줍니다. Swift 표준 라이브러리만 사용하므로 가져오기 선언이 필요하지 않습니다.
final class TileDimensions {
var width: Int
init(width: Int) {
self.width = width
}
}
struct SharedLayout {
let dimensions: TileDimensions
}
let original = SharedLayout(
dimensions: TileDimensions(width: 40)
)
let edited = original
edited.dimensions.width = 60
assert(original.dimensions === edited.dimensions)
assert(original.dimensions.width == 60)
print(original.dimensions.width) // 60
속성이나 래퍼를 let으로 선언해도 객체의 가변 속성은 고정되지 않습니다. 해당 참조나 구조체 값을 교체할 수 없게 할 뿐입니다. Swift의 구조체와 클래스 장은 상수 참조와 변경 불가능한 객체 상태를 명확히 구분합니다.
이 객체들을 배열에 넣어도 공유된 정체성은 유지됩니다. 배열 복사본은 요소를 독립적으로 관리하지만, 클래스 타입 요소 자체는 참조입니다. 요소를 교체하면 한 배열만 영향을 받지만, 두 배열이 모두 참조하는 객체를 수정하면 양쪽에서 그 변경을 볼 수 있습니다. Apple은 Array 문서에서 이 차이를 설명합니다.
어떤 설계가 계산에 독립적인 값을 제공하나요?
구현을 선택하기 전에 계약을 정의하세요. var edited = original 이후 레이아웃 중 하나를 지원되는 방식으로 편집해도 다른 레이아웃의 계산된 너비는 바뀌지 않아야 합니다. 이 타입을 사용하는 쪽에서 내부 저장소가 공유되는지 알 필요가 없어야 합니다.
다음 판단 자료는 이 계약을 로컬 미리 보기 모델에 적용한 것입니다. 문서에 설명된 복사 규칙에서 도출한 설계 권장 사항입니다.
| 상황 | 적합한 설계 | 독립성을 유지하기 위한 조건 |
|---|---|---|
| 설정이 숫자, 문자열 또는 값 의미론을 가진 다른 구성 요소로 이루어짐 | 해당 값을 구조체에 직접 저장 | 중첩된 구성 요소도 관찰 가능한 독립 값을 제공하는지 확인 |
| 기존 클래스 기반 저장소를 유지해야 함 | 비공개 copy-on-write 저장소 사용 | 모든 변경 전에 공유된 가변 상태를 분리해야 함 |
| 지정된 작업에서만 별도 스냅샷이 필요함 | 명시적 복사 작업 제공 | 래퍼가 독립성을 보장하지 않는 한 일반 할당은 여전히 객체를 공유한다고 문서화 |
| 프로그램의 여러 부분이 의도적으로 한 객체를 편집함 | 참조 의미론 유지 | API에서 공유 객체 동작 설명 |
정수 설정 세 개라면 구조체 속성에 직접 저장하는 것이 가장 간단합니다. 아래의 클래스 기반 구현은 기존 저장소 설계를 조정하거나 경계가 작동하는 방식을 익힐 때 유용하지만, 이 작은 계산에서 성능상 이점이 있음을 뜻하지는 않습니다.
Copy-on-write를 사용하면 한 값이 바뀔 때까지 두 값이 저장소를 공유할 수 있습니다. Apple은 클래스를 구조체로 감싸고 copy-on-write를 적용하면 별도로 할당되는 저장소와 값 의미론을 결합할 수 있다고 Swift 성능 살펴보기에서 설명합니다. 클래스를 단순히 감싸는 것만으로는 이 동작이 구현되지 않습니다.
명시적인 copy() 메서드만으로는 빈틈이 남습니다. 호출자가 이 메서드를 사용하지 않고 일반 할당을 할 수 있기 때문입니다. 할당이 값 계약의 일부라면 래퍼의 변경 경로에서 자동으로 독립성을 보장해야 합니다.
copy-on-write를 어떻게 구현하고 확인하나요?
비공개 저장소 객체 하나를 사용하고, 래퍼 내부에서 생성하며, 계산된 값과 제어된 변경 메서드만 노출하세요. 저장소를 변경하기 직전에 isKnownUniquelyReferenced을 호출합니다. 유일성이 확인되지 않으면 저장소를 독립 복사본으로 교체하세요. Apple은 이 절차를 copy-on-write 구현하기에서 구체적으로 설명합니다.
계산 예제에서는 설명을 위한 입력값을 사용합니다. 너비가 각각 40포인트인 타일 세 개와 인접한 타일 사이 간격 10포인트입니다. 간격은 두 개이므로 행 너비는 3 * 40 + 2 * 10 = 140포인트입니다. 타일 너비를 60으로 늘리면 200포인트가 됩니다.
Mac에서 전체 프로그램을 실행하려면 먼저 터미널에서 swift --version을 실행해 Swift 툴체인을 사용할 수 있는지 확인하세요. 이는 Swift 시작하기 가이드의 권장 사항입니다. 다음 코드를 Layout.swift로 저장한 다음 해당 디렉터리에서 swift Layout.swift를 실행하세요. 프로그램은 메모리에서 계산하고 결과를 출력합니다.
struct TileLayout {
private final class Storage {
let columns: Int
var tileWidth: Int
var gap: Int
init(columns: Int, tileWidth: Int, gap: Int) {
self.columns = columns
self.tileWidth = tileWidth
self.gap = gap
}
func copy() -> Storage {
Storage(
columns: columns,
tileWidth: tileWidth,
gap: gap
)
}
}
private var storage: Storage
init(columns: Int, tileWidth: Int, gap: Int) {
precondition((1...1000).contains(columns))
precondition((0...1000).contains(tileWidth))
precondition((0...1000).contains(gap))
storage = Storage(
columns: columns,
tileWidth: tileWidth,
gap: gap
)
}
var rowWidth: Int {
storage.columns * storage.tileWidth
+ (storage.columns - 1) * storage.gap
}
private mutating func ensureUniqueStorage() {
if !isKnownUniquelyReferenced(&storage) {
storage = storage.copy()
}
}
mutating func setTileWidth(_ width: Int) {
precondition((0...1000).contains(width))
ensureUniqueStorage()
storage.tileWidth = width
}
mutating func setGap(_ gap: Int) {
precondition((0...1000).contains(gap))
ensureUniqueStorage()
storage.gap = gap
}
}
var original = TileLayout(columns: 3, tileWidth: 40, gap: 10)
var edited = original
edited.setTileWidth(60)
assert(original.rowWidth == 140)
assert(edited.rowWidth == 200)
original.setGap(5)
assert(original.rowWidth == 130)
assert(edited.rowWidth == 200)
let snapshot = edited
edited.setGap(20)
assert(snapshot.rowWidth == 200)
assert(edited.rowWidth == 220)
print(original.rowWidth, snapshot.rowWidth, edited.rowWidth)
// Expected output: 130 200 220
프로그램에는 가져오기 선언이나 외부 패키지가 필요하지 않습니다. 허용 범위는 의도적으로 정한 예제 제약입니다. 열은 1개에서 1,000개까지, 너비와 간격은 0에서 1,000포인트까지 허용합니다. 이 범위는 정수 계산의 한계를 정합니다. 범위를 벗어난 인수는 사전 조건 오류를 발생시키며, 자동으로 범위 안에 맞춰지지 않습니다. 나중에 데스크톱 인터페이스에서 편집 가능한 텍스트를 이 설정기에 전달한다면, 호출 전에 입력을 검증하고 사용자가 수정할 방법을 제공하세요.
검사 항목은 관찰 가능한 요구 사항 세 가지를 다룹니다. 복사한 레이아웃을 편집해도 원본이 유지되고, 원본을 편집해도 복사본이 유지되며, 상수 스냅샷을 보관하면 이후 편집 중에도 그 결과가 유지됩니다. 특정 할당 횟수를 요구하지 않고 동작을 검사합니다. 조사 환경에서는 Swift를 사용할 수 없어 예제를 검토만 하고 실행하지는 못했습니다. 표시된 출력은 예상 결과입니다.
래퍼가 확장될 때 무엇을 지켜야 하나요?
이 예제의 독립성은 전체 API 경계에서 비롯됩니다. 저장소는 비공개이고, 외부에서 참조를 보유한 저장소 객체를 받는 초기화 메서드가 없으며, 저장소를 반환하는 게터도 없습니다. 두 변경 메서드 모두 같은 분리 작업을 거칩니다. 게터는 저장소를 변경하지 않고 결과를 계산합니다.
기능을 추가할 때는 관찰 가능한 상태를 바꿀 수 있는 각 경로를 살펴보세요. 새 설정자, 서브스크립트 또는 편집 콜백도 기존 메서드와 같은 처리가 필요합니다. 게터 자체가 읽기 전용이어도 가변 자식 객체를 반환하면 호출자가 분리를 우회할 수 있습니다.
저장소 복사본에는 값과 관련된 모든 가변 상태도 포함되어야 합니다. 여기서는 Storage.copy()이 정수 세 개를 복사하므로 충분합니다. 나중에 저장소에 가변 클래스 타입 자식이 들어간다면, 그 자식을 새 저장소에 할당하는 것만으로는 다른 공유 참조가 그대로 남습니다. 그 자식도 적절히 복사하거나, 이미 값 의미론을 제공하는 구성 요소로 대체하세요. Array의 클래스 타입 요소에 관해 문서화된 동작도 같은 경계를 보여 줍니다.
계산기를 확장할 때는 다음 순서를 사용하세요.
- 새 편집 작업을 각각 시험하기 전에 레이아웃을 복사합니다.
- 변경하지 않은 레이아웃에 이전 결과가 유지되는지 확인합니다.
- 역할을 서로 바꿔 반복합니다.
- 스냅샷을 보관하고, 이후 여러 번 편집한 뒤 다시 확인합니다.
- 래퍼 밖으로 빠져나갈 수 있는 가변 참조가 반환 속성이나 초기화 인수에 있는지 살펴봅니다.
final 저장소 클래스는 하위 클래스가 동작을 추가하지 못하게 하지만, 속성을 불변으로 만들지는 않습니다. 마찬가지로 연산을 mutating으로 표시하면 구조체를 갱신할 수 있지만, 이 예제의 독립성을 보장하는 것은 분리 로직입니다.
Foundation 객체는 어떻게 복사해야 하나요?
기존 모델에서 NSMutableString 같은 Foundation 객체를 저장한다면, 저장소 분리를 구현할 때 구체적인 타입에 문서화된 복사 동작을 사용하세요. Swift 할당은 Foundation 복사 메서드를 자동으로 호출하지 않습니다.
Foundation의 NSCopying 프로토콜은 기능적 복사를 정의하지만, 정확한 의미는 클래스에 따라 다릅니다. 해당 문서에서는 클래스와 그 내용이 모두 불변인 경우 원래 객체를 반환해도 된다고 설명합니다. 따라서 객체 정체성만으로 복사본이 값 계약을 충족하는지 보편적으로 판단할 수는 없습니다.
가변형과 불변형이 따로 있는 타입에서 가변 결과가 필요하다면 NSMutableCopying을 참고하세요. Apple은 이 용도로 mutableCopy(with:)와 편의 메서드 mutableCopy()를 설명합니다. 이런 구분이 없는 클래스는 대신 NSCopying을 채택해야 합니다.
타일 계산기에서 실무상 지켜야 할 경계는 명확합니다. 입력으로 받은 Foundation 객체가 독립적인 설정이라고 내세우는 값에 외부에서 변경 가능한 별칭으로 남아서는 안 됩니다. 입력 시 적절한 독립 스냅샷을 만들고, 편집 전에 알맞게 분리하며, 내부 가변 객체 대신 값 스냅샷을 반환하세요. 복사 작업을 신뢰하기 전에 구체적인 클래스가 중첩된 가변 내용을 어떻게 처리하는지 확인하세요.
copy-on-write를 사용하면 타입이 Sendable이 되나요?
값의 독립성과 동시성 적합성은 별도로 판단해야 합니다. Apple의 Sendable 문서에 따르면 검사되는 Sendable 구조체의 저장 프로퍼티는 Sendable이어야 합니다. 비공개 가변 저장소 클래스는 이를 감싸는 타입이 구조체라는 이유만으로 이 요구 사항을 충족하지 않습니다.
유일성 검사도 동기화가 아닙니다. Apple은 동시 접근 중 isKnownUniquelyReferenced이 true를 반환할 수 있으며, 이를 사용할 때 적절한 동기화가 필요하다고 경고합니다. 이 검사는 강한 참조를 확인하지만 약한 참조나 미소유 참조 별칭은 여전히 존재할 수 있습니다. 따라서 유일성 검사뿐 아니라 저장소를 래퍼 외부에서 접근할 수 없게 유지하는 것도 중요합니다. 표준 라이브러리 함수에 문서화된 한계를 참고하세요.
이 예제는 단일 동기 계산 문맥에서 사용하세요. 이후 버전에서 동시성 도메인 사이에 설정을 전달해야 한다면, Sendable 값 속성을 포함하는 구조체를 사용하고 검사된 Sendable 적합성을 선언하는 것이 간단한 대안입니다. @unchecked Sendable을 추가하면 정확성에 대한 책임이 구현자에게 넘어갈 뿐, 빠진 조율이 저절로 제공되지는 않습니다. Apple은 Sendable 참조 문서에서 이 요구 사항을 설명합니다.
작은 데스크톱 계산에는 작업에 적합한 경우 직접 값 속성을 사용하세요. 가변 클래스 저장소를 유지해야 한다면 비공개 소유권, 완전한 저장소 복사, 제어된 편집, 관찰 가능한 독립성 검사를 통해 계약을 지키세요. 그러면 미리 보기의 작업 설정을 변경해도 보관된 각 레이아웃 값에 자체 계산 결과가 유지되어 예측 가능한 동작을 얻을 수 있습니다.
출처
- Swift의 값 타입과 참조 타입 — Swift.org.
- 구조체와 클래스 — The Swift Programming Language, Swift 프로젝트.
- Array — Apple 개발자 문서.
- Swift 성능 살펴보기 — Apple Developer.
- isKnownUniquelyReferenced(_:) — Apple 개발자 문서.
- 시작하기 — Swift.org.
- NSCopying — Apple 개발자 문서.
- NSMutableCopying — Apple 개발자 문서.
- Sendable — Apple 개발자 문서.