이 챕터는 Swift 언어를 메모리와 런타임 관점에서 다룬다. 값/참조 타입, COW, 클로저 캡처와 ARC, Optional의 비트 표현, 그리고 제네릭·existential·opaque 타입이 컴파일러와 런타임에서 각각 어떻게 구현되는지를 면접에서 설명할 수 있는 수준으로 파고든다.
Q1. Struct와 Class는 의미와 메모리 관리에서 어떻게 다른가?
struct는 값 타입이라 대입·전달 시 값 자체가 복사되어 서로 독립적이고, class는 참조 타입이라 포인터(참조)만 복사되어 하나의 인스턴스를 공유한다. class 인스턴스는 원칙적으로 힙에 할당되어(탈출하지 않는 객체는 최적화 단계에서 스택으로 승격되기도 한다) ARC(참조 카운팅)로 수명이 관리되며 isa·refcount 헤더를 갖는다. struct는 선언된 자리에 인라인으로 놓여 그 자체는 ARC로 수명 관리되지 않고 retain cycle도 없지만(단 String·Array·클래스 프로퍼티 등 참조 필드를 품으면 값 복사·소멸 시 그 필드에 대한 retain/release는 여전히 발생), 클로저 캡처나 클래스 프로퍼티로 들어가면 힙에 올라갈 수 있다. class만 상속·deinit·식별자(===)를 가진다. 그래서 기본은 struct, 공유 가변 상태·정체성·상속이 필요할 때 class를 쓴다.
CS 원리
값 타입은 변수 안에 값 그 자체가 들어 있다. 대입하면 값이 통째로 복사되고, 이후 한쪽을 바꿔도 다른 쪽은 그대로다(독립성). 참조 타입은 변수 안에 힙에 있는 객체를 가리키는 주소가 들어 있다. 대입하면 주소만 복사되므로 두 변수가 같은 객체를 공유하고, 한쪽에서 바꾼 변화가 다른 쪽에도 보인다.
참조 타입은 여러 곳에서 같은 객체를 가리키므로 "언제 해제할지"를 판단할 장치가 필요하다. Swift는 그 객체를 가리키는 강한 참조의 수를 세는 참조 카운팅을 쓴다. 카운트가 0이 되면 해제한다. 값 타입은 소유자가 명확(스코프가 끝나면 사라짐)해서 이런 카운팅이 필요 없다.
iOS에서는
class 인스턴스는 ARC가 관리한다. 64비트에서 객체 앞에는 최소 16바이트 헤더가 붙는데, metadata(isa) 포인터와 refcount 필드다. Swift 런타임의 swift_allocObject가 힙에 할당하고 swift_retain/swift_release가 카운트를 조정한다. @objc/NSObject 계열은 Objective-C 런타임과 브리징되어 isa가 Objective-C 클래스 객체를 가리킨다.
중요한 건 "struct=스택, class=힙"이 언어 차원의 보장이 아니라는 점이다. Swift는 값의 저장 위치를 스펙으로 못박지 않는다. 지역 struct는 대개 스택 프레임에, 배열 원소인 struct는 배열 버퍼에 인라인으로 놓이지만, escaping 클로저에 캡처되거나 크기가 큰 값은 컴파일러가 힙에 박싱할 수 있다. SwiftUI의 View는 struct(값)이고, UIKit의 UIView/UIViewController는 class다.
실험 · 도구
MemoryLayout으로 크기를, 카운트는 관찰용으로 CFGetRetainCount(또는 Instruments Allocations)로 볼 수 있다. 값 복사의 독립성은 코드로 바로 재현된다.
struct SPoint { var x = 0 }
final class CPoint { var x = 0 }
var sa = SPoint(); var sb = sa // 값 복사
sb.x = 99
print(sa.x, sb.x) // 0 99 (독립)
let ca = CPoint(); let cb = ca // 참조 복사(같은 객체)
cb.x = 99
print(ca.x, cb.x) // 99 99 (공유)
print(ca === cb) // true (같은 식별자)
// MemoryLayout은 헤더를 제외한 값의 저장 크기만 잰다
print(MemoryLayout<SPoint>.size) // 8 (Int 하나)
print(class_getInstanceSize(CPoint.self)) // 24 (16B 헤더 + Int 8)Instruments의 Allocations로 class 생성 시 힙 할당이 찍히는지, struct는 안 찍히는지 비교하면 저장 위치 차이를 눈으로 확인할 수 있다.
프로젝트 적용
① 기본값은 struct. 모델·값 객체·상태는 값 타입으로 두면 스레드 안전성과 예측 가능성이 좋고 ARC·retain cycle 비용이 없다.
import Foundation
// 값 타입은 ARC도, 순환 참조도, 스레드 경합도 구조적으로 없다.
// "왜 class여야 하는가"에 답할 수 없으면 struct가 맞다.
// ✅ 모델·값 객체·상태 — 전부 값 타입
struct User: Sendable, Equatable, Codable {
let id: UUID
var displayName: String
var email: String
}
struct CartItem: Sendable, Equatable {
let sku: String
var quantity: Int
var unitPrice: Decimal
var subtotal: Decimal { unitPrice * Decimal(quantity) }
}
struct Cart: Sendable, Equatable {
var items: [CartItem] = []
var total: Decimal { items.reduce(0) { $0 + $1.subtotal } }
}
// 값 타입이라 이런 게 공짜로 따라온다
func valueTypeBenefits() {
var a = Cart(items: [CartItem(sku: "X", quantity: 1, unitPrice: 100)])
let snapshot = a // ① 스냅샷이 그냥 대입이다
a.items[0].quantity = 5
assert(snapshot.items[0].quantity == 1) // ② 원본이 안 바뀐다
// ③ Equatable·Codable·Hashable이 자동 합성된다
// ④ 다른 스레드로 넘겨도 안전하다(Sendable)
}
// UI 상태도 값으로 두면 변경 추적과 되돌리기가 단순해진다
struct EditorState: Equatable {
var text: String = ""
var selection: Range<Int>? = nil
}
var undoStack: [EditorState] = []② class는 공유 가변 상태, 식별자(===), 상속, deinit, Objective-C 상호운용이 필요할 때만 선택한다.
import Foundation
import UIKit
// class를 고르는 정당한 이유는 다섯 가지뿐이다.
// ① 공유 가변 상태 — 여러 곳이 '같은 것'을 봐야 한다
final class SessionStore {
private(set) var currentUser: User?
func signIn(_ u: User) { currentUser = u }
}
// ② 식별자(identity) — 값이 같아도 다른 개체여야 한다
final class Connection {
let id = UUID()
}
func identityMatters(_ a: Connection, _ b: Connection) -> Bool {
a === b // 값이 아니라 '그 개체인가'를 묻는다
}
// ③ deinit — 결정적 시점에 자원을 반납해야 한다
final class FileWriter {
private let handle: FileHandle
init(_ h: FileHandle) { handle = h }
deinit { try? handle.close() }
}
// ④ 상속 — 프레임워크가 요구한다
final class FeedViewController: UIViewController {} // UIKit이 class를 요구
// ⑤ Objective-C 상호운용 — @objc·KVO·NSCoding
final class LegacyBridge: NSObject {
@objc dynamic var progress: Double = 0 // KVO 대상
}
// ✅ 상속을 안 쓸 거면 final을 붙인다 — 동적 디스패치가 정적으로 바뀐다
// (WMO가 켜지면 컴파일러가 추론하기도 하지만, 명시가 확실하다)
final class Cache {} // ✅
class OpenCache {} // ❌ 이유 없이 열어 두면 최적화를 막는다
struct User { let id: UUID }③ 하지 말 것: 상속을 안 쓰는 class는 final을 붙여 동적 디스패치를 줄인다. 아주 큰 struct를 hot path에서 마구 복사하지 말 것(단 Array/String 등은 COW로 완화됨).
import Foundation
// 값 타입은 복사가 기본이다. 대부분은 문제가 안 되지만,
// 큰 struct를 초당 수만 번 넘기면 memcpy 비용이 실제로 보인다.
// 128바이트짜리 struct (Double 16개) — 반환·대입마다 통째로 복사된다
struct Matrix4x4 {
var m: (Double, Double, Double, Double,
Double, Double, Double, Double,
Double, Double, Double, Double,
Double, Double, Double, Double)
}
// ❌ 반복문에서 값으로 주고받기
func transformBad(_ m: Matrix4x4, by t: Matrix4x4) -> Matrix4x4 {
m // 인자는 주소로 빌려 오지만(기본이 borrowing) 반환값은 통째로 복사된다
}
// ✅ ① 제자리 수정 — inout은 복사 없이 참조로 넘긴다
func transform(_ m: inout Matrix4x4, by t: Matrix4x4) {
// m을 직접 수정
}
// ✅ ② 참조 필드를 품은 큰 struct라면 borrowing으로 ARC 트래픽을 생략시킨다
// (Matrix4x4처럼 참조가 없는 trivial 타입은 이미 기본이 borrowing이라 효과가 없다)
func determinant(of m: borrowing Matrix4x4) -> Double { 0 }
// ✅ ③ 내부가 Array라면 COW가 알아서 완화해 준다 — 이 경우엔 걱정할 필요가 적다
struct Mesh {
var vertices: [Double] // 복사해도 버퍼 참조만 복사된다(O(1))
}
// 크기를 직접 확인해 감을 잡는다
func sizes() {
print(MemoryLayout<Matrix4x4>.size) // 128
print(MemoryLayout<Mesh>.size) // 8 (버퍼 참조 하나)
}
// ⚠️ "값 타입이라 복사가 비싸다"는 일반화는 틀렸다.
// Array·String·Data 같은 표준 컬렉션은 COW라 대입 비용이 O(1)이다.
// 비싼 건 '고정 크기 페이로드가 큰' struct뿐이다."struct는 항상 스택, class는 항상 힙"은 틀렸다. class 인스턴스는 항상 힙이 맞지만, struct는 escaping 클로저 캡처·클래스 프로퍼티·큰 값 등으로 힙에 올라갈 수 있고 Swift는 저장 위치를 언어 차원에서 보장하지 않는다.
struct를 남한테 넘기는 건 사진을 한 장 더 인화해서 손에 쥐여 주는 것에 가깝다. 친구가 그 사진에 낙서를 해도 내 사진은 멀쩡하다. class를 넘기는 건 창고 열쇠를 하나 더 복사해 주는 것이다. 열쇠는 두 개가 됐지만 창고는 여전히 하나뿐이라, 친구가 안에서 물건을 옮기면 내가 열었을 때도 옮겨져 있다. ARC(자동 참조 카운팅, 열쇠가 지금 몇 개 나가 있는지 세어 두는 장치)는 바로 이 열쇠 개수를 센다. 마지막 열쇠까지 반납돼서 0이 되면 그제서야 창고를 비운다. 그래서 class 쪽에는 "아직 누가 쓰고 있나"를 세는 잔업이 늘 붙어 다니고, struct 쪽에는 그게 없다.
비유가 깨지는 자리도 짚어 두자. struct라고 열쇠가 아예 없는 건 아니다. struct 안에 String이나 Array, class 프로퍼티가 들어 있으면 그건 사진에 그려 넣은 열쇠다. 사진을 복사해도 그림 속 열쇠는 똑같은 창고를 연다. 그래서 struct를 복사하거나 버릴 때도 그 필드에 대한 retain/release는 여전히 일어난다.
꼬리 질문
struct가 class 프로퍼티를 갖고 있으면 그 struct를 값 복사할 때 내부 객체는 어떻게 되나?
struct는 필드 단위로 복사되지만, class 프로퍼티는 참조(포인터)라서 복사되는 건 포인터뿐이고 가리키는 힙 객체는 그대로 공유된다. 이때 swift_retain으로 그 객체의 refcount만 1 올라간다. 결과적으로 두 struct 복사본이 같은 class 인스턴스를 보므로, 한쪽에서 그 객체의 내부 상태를 바꾸면 다른 쪽에도 보인다 — 이 부분에서 value semantics가 깨진다. 참조 자체를 다른 객체로 재대입해야 비로소 두 복사본이 갈라진다.
struct를 escaping 클로저가 캡처하면 그 값은 어디에 저장되나?
지역 변수를 escaping 클로저가 캡처하면, 그 변수의 저장소는 스택 프레임을 넘어 살아남아야 하므로 컴파일러가 힙에 박스(box)를 할당해 승격시킨다(closure context object). 기본 캡처는 변수 자체를 참조로 잡으므로 박스를 공유하고, 캡처 리스트 [x]를 쓰면 그 시점의 값을 박스로 복사해 넣는다. 즉 struct라도 escaping 캡처 경로에서는 힙에 올라가며, 이 박스는 ARC로 수명이 관리된다.
[x]를 쓰면 메모를 통째로 들고 가는 게 아니라 그 순간 내용만 베껴서 box에 넣는다.final class의 메서드와 struct의 메서드는 디스패치 방식이 어떻게 다른가?
둘 다 오버라이드가 불가능하므로 원칙적으로 정적 디스패치(direct call, 인라이닝 가능)로 같다. struct는 상속 자체가 없어 vtable이 없고, final class는 서브클래싱이 막혀 컴파일러가 호출 대상을 하나로 확정할 수 있기 때문이다. 다만 final 멤버라도 @objc dynamic이 붙으면 objc_msgSend를 타는 동적 디스패치가 되고, non-final class는 vtable 기반 동적 디스패치를 쓴다(프로토콜 요구사항 호출은 대신 witness table을 탄다). 또한 class 메서드 호출에는 인스턴스에 대한 ARC 트래픽이 얹힐 수 있다는 차이도 있다.
final class는 상속을 막았으니 컴파일러가 번호를 미리 적어 둘 수 있다 — 이게 정적 디스패치다. 다만 @objc dynamic을 붙이면 일부러 매번 번호부를 뒤지게 만드는 셈이라 이 이득이 도로 사라진다.Q2. Value semantics와 Reference semantics는 무엇인가?
Value semantics는 "한 변수를 통해 값을 바꿔도 다른 변수·복사본에는 영향이 없다"는 독립성 보장이고, reference semantics는 "여러 참조가 같은 인스턴스를 가리켜 한쪽 변경이 모두에게 보이는" 공유이다. 핵심은 이게 저장 위치나 복사 시점이 아니라 관찰 가능한 동작에 대한 정의라는 점이다. 그래서 Array·String처럼 COW로 복사를 지연시켜도 value semantics는 유지된다. 주의할 함정은 struct 안에 참조 타입(예: NSMutableArray)을 넣으면 그 부분은 공유되어 value semantics가 깨진다는 것이다.
CS 원리
value semantics를 정의하는 기준은 "복사본을 만든 뒤 원본과 복사본이 서로에게 영향을 주지 않는가"이다. 이는 구현이 아니라 계약이다. 실제로 매번 즉시 복사하든(eager), mutation 순간에만 복사하든(COW, lazy) 관찰되는 동작이 독립적이면 value semantics다.
reference semantics는 값이 아니라 정체성(identity)을 다룬다. 두 참조가 같은 객체인지(===)가 의미를 가지며, 어느 참조로 상태를 바꿔도 같은 객체를 보는 모두가 그 변화를 관찰한다.
Array·String처럼 COW로 실제 복사를 미뤄도 독립성은 지켜지고, 반대로 struct라도 참조 타입 프로퍼티를 품으면 그 부분은 공유되어 깨진다.iOS에서는
Swift에서 struct·enum·tuple은 value semantics, class·closure는 reference semantics를 갖는다. 표준 라이브러리 컬렉션(Array·Dictionary·Set·String)은 COW로 value semantics를 제공한다. Foundation 브리징에서 NSArray(참조)와 Array(값)의 차이가 대표적이다. SwiftUI는 value semantics에 크게 의존한다. @State 값이 바뀌면 SwiftUI가 그 뷰를 무효화해 body를 다시 계산하고, 그렇게 나온 뷰 값들을 이전 것과 비교해 갱신 범위를 좁히는데, 이 비교가 성립하려면 뷰가 독립적으로 복사·비교되는 값이어야 한다.
실험 · 도구
struct Box { // struct지만…
var items = NSMutableArray() // 참조 타입 프로퍼티
}
var a = Box()
var b = a // Box 자체는 값 복사
b.items.add("x") // 그러나 items는 같은 객체를 공유!
print(a.items.count) // 1 ← value semantics가 깨졌다
// 순수 값 타입이면
struct Pure { var items: [String] = [] } // Array는 COW 값 타입
var c = Pure(); var d = c
d.items.append("x")
print(c.items.count) // 0 ← 독립적 (value semantics 유지)===로 정체성을 확인하고, withUnsafePointer나 buffer 주소를 찍어 "공유 중인지"를 관찰하면 두 semantics의 차이를 정량적으로 볼 수 있다.
프로젝트 적용
① 도메인/모델 타입은 value 타입으로 설계해 예측 가능성과 스레드 안전성을 확보한다.
import Foundation
// 값 의미론은 "이 값을 넘겨도 내 것이 안 바뀐다"는 보장이다.
// 그 보장이 있으면 추론 범위가 함수 안으로 줄어든다.
struct Order: Sendable, Equatable {
let id: UUID
var status: Status
var items: [Item]
enum Status: Sendable, Equatable { case draft, placed, shipped, cancelled }
struct Item: Sendable, Equatable {
let sku: String
var quantity: Int
}
}
// ✅ 변환은 '새 값을 만드는' 함수로 — 원본이 절대 안 바뀐다
extension Order {
func placing() -> Order {
var copy = self
copy.status = .placed
return copy
}
}
// ✅ 호출자는 자기 값이 안전하다는 걸 시그니처만 보고 안다
func validate(_ order: Order) -> [String] {
// order를 아무리 만져도(로컬 복사) 호출자 것은 그대로다
var errors: [String] = []
if order.items.isEmpty { errors.append("빈 주문") }
return errors
}
// ✅ 테스트가 단순해진다 — 목이 필요 없고 값만 비교하면 된다
func test_주문_확정() {
let draft = Order(id: UUID(), status: .draft, items: [])
let placed = draft.placing()
assert(draft.status == .draft) // 원본 불변
assert(placed.status == .placed)
}
// ✅ 동시성도 공짜로 따라온다 — 복사본을 넘기므로 공유가 없다
func process(_ orders: [Order]) async -> [Order] {
await withTaskGroup(of: Order.self) { group in
for o in orders { group.addTask { o.placing() } }
var out: [Order] = []
for await r in group { out.append(r) }
return out
}
}② value 타입 안에 가변 참조 타입을 숨기지 말 것. 넣어야 한다면 방어적 복사(deep copy)로 독립성을 복원하거나, 그 공유를 문서화한다.
import Foundation
// struct라도 안에 참조 타입이 있으면 그 부분은 공유된다.
// 호출자는 값처럼 믿는데 실제로는 아니므로, 가장 찾기 어려운 버그가 된다.
// ❌ 겉은 struct, 속은 공유
struct BadDocument {
var title: String
var pages = NSMutableArray() // 참조 타입
}
func demonstrateBug() {
var a = BadDocument(title: "A")
var b = a // "복사"했다고 믿는다
b.pages.add("새 페이지")
print(a.pages.count) // 1 — 원본까지 바뀌었다 💥
}
// ✅ ① 순수 값 타입으로 바꾼다 (최선)
struct Document {
var title: String
var pages: [String] = [] // Array는 COW 값 타입
}
// ✅ ② 참조를 꼭 담아야 하면 방어적 복사로 독립성을 복원한다
struct ImageDocument {
private var storage: NSMutableData
init(data: NSMutableData) {
storage = data.mutableCopy() as! NSMutableData // 들어올 때 복사
}
var data: Data { storage as Data } // 나갈 때는 불변으로
mutating func append(_ chunk: Data) {
if !isKnownUniquelyReferenced(&storage) { // COW를 직접 구현
storage = storage.mutableCopy() as! NSMutableData
}
storage.append(chunk)
}
}
// ✅ ③ 공유가 의도라면 문서화하고 이름으로 드러낸다
struct ViewModelRef {
/// ⚠️ 공유 참조입니다. 복사해도 같은 인스턴스를 가리킵니다.
let sharedCoordinator: Coordinator
}
final class Coordinator {}③ class로 value semantics를 흉내내려면 불변(immutable)으로 만들거나 복사 메서드를 명시적으로 제공한다.
import Foundation
// class를 써야 하는데 값처럼 다루고 싶다면, 방법은 '불변'뿐이다.
// ✅ ① 전부 let — 만들어진 뒤 아무도 못 바꾼다 → 공유해도 안전
final class Configuration: Sendable {
let baseURL: URL
let timeout: TimeInterval
let headers: [String: String]
init(baseURL: URL, timeout: TimeInterval, headers: [String: String] = [:]) {
self.baseURL = baseURL
self.timeout = timeout
self.headers = headers
}
// ✅ 변경은 '새 인스턴스를 만드는' 메서드로 (값 타입의 mutating에 대응)
func with(timeout: TimeInterval) -> Configuration {
Configuration(baseURL: baseURL, timeout: timeout, headers: headers)
}
func adding(header key: String, _ value: String) -> Configuration {
var h = headers; h[key] = value
return Configuration(baseURL: baseURL, timeout: timeout, headers: h)
}
}
// ✅ ② 불변으로 못 만들면 복사 메서드를 '명시적으로' 제공한다
final class MutableBuffer {
private(set) var bytes: [UInt8]
init(bytes: [UInt8]) { self.bytes = bytes }
/// 호출자가 독립 사본을 원한다는 걸 코드에 드러낸다
func copy() -> MutableBuffer { MutableBuffer(bytes: bytes) }
func append(_ b: UInt8) { bytes.append(b) }
}
func useCopy(_ original: MutableBuffer) {
let mine = original.copy() // ✅ 의도가 호출부에 보인다
mine.append(0)
}
// ⚠️ NSCopying 관례를 따를 수도 있지만, Swift에서는 명시적 copy() 메서드나
// 애초에 struct로 가는 편이 더 명확하다."value semantics = 항상 복사한다 / 스택에 있다"는 오해다. value semantics는 동작(독립성)의 정의이지 저장 위치나 복사 전략이 아니다. COW는 복사를 미뤄도 value semantics를 지킨다. 또 "struct면 무조건 value semantics"도 틀렸다 — 참조 타입 프로퍼티를 품으면 그 부분은 공유된다.
value semantics는 "무엇으로 만들었나"가 아니라 "겪어 보면 어떤가"로 판정하는 약속이다. 도서관 대출대에 "빌려 가실 땐 복사본을 드린다"라고 붙어 있다고 하자. 사서가 언제 복사기를 돌리는지는 손님이 알 바가 아니다. 미리 복사해 두든, 손님이 밑줄을 그으려는 순간 후다닥 복사해서 새 책을 건네든, 손님 입장에서 "내가 그은 밑줄이 남의 책에는 안 보인다"는 결과만 같으면 약속은 지켜진 것이다. 뒤쪽 방식이 바로 COW다.
약속이 깨지는 자리는 이렇다. 겉표지는 "복사본 드린다"인데, 뒤에 꽂힌 부록 CD 한 장만은 원본에 있던 걸 그대로 같이 쓴다면? 그 CD에서 파일을 지우는 순간 다른 손님 책에서도 사라진다. struct 안에 NSMutableArray 같은 가변 참조 타입을 심어 넣는 게 딱 그 부록 CD다. struct라는 간판을 달았다고 value semantics가 저절로 보장되지 않는 이유가 여기 있다.
꼬리 질문
Swift Array도 내부 버퍼를 참조로 공유하는데, 왜 NSMutableArray를 품은 struct와 달리 복사본이 독립적으로(value semantics) 유지되나?
복사 순간에 버퍼(또는 객체) 참조만 공유된다는 점은 둘 다 똑같다. 갈리는 건 변경이 닿는 순간의 방어막이다. Array의 버퍼는 COW로 감싸져 있어, 원소를 바꾸려는 그 순간 isKnownUniquelyReferenced로 버퍼가 공유 중인지 보고 공유라면 새 버퍼로 복제한 뒤 그 사본에만 write한다 — 그래서 다른 복사본의 버퍼는 절대 건드려지지 않는다. 반면 NSMutableArray에는 그런 write barrier가 없어 add(_:) 같은 변경이 공유 객체를 곧바로 제자리에서 고치고, 두 struct가 그 변화를 함께 관찰한다. 결국 value semantics를 결정하는 건 '버퍼를 참조로 공유하느냐'가 아니라 '변경 지점에 복사 방어막이 끼어드느냐'이고, Array는 그것을 갖췄지만 struct에 박아 넣은 가변 참조 타입은 그렇지 않다.
Array는 "어, 이거 둘이 같이 보는 공책이네" 하고 자기 공책에 통째로 베낀 뒤 거기에다 쓴다. NSMutableArray는 그런 확인 없이 공용 공책에 바로 적어 버리니 옆 사람 것까지 바뀐다. 공유하느냐가 아니라 쓰기 직전에 확인하는 절차가 있느냐가 갈림길이다.class로 value semantics를 흉내내는 방법 두 가지는?
첫째, 불변(immutable)으로 만든다 — 모든 저장 프로퍼티를 let으로 두고 변경 메서드를 없애면, 공유되더라도 아무도 바꿀 수 없으므로 관찰상 독립성이 유지된다(문자열 상수 공유와 같은 원리). 둘째, 명시적 복사 의미를 부여한다 — NSCopying/copy()로 방어적 복사를 제공하거나, class를 struct로 감싸고 isKnownUniquelyReferenced로 직접 COW를 구현해 변경 직전에 복제한다.
actor는 value인가 reference인가? 그 이유는?
actor는 참조 타입이다. class처럼 힙에 할당되고 ARC로 관리되며 정체성(identity)을 가진다 — 전달하면 같은 인스턴스를 공유한다. class와의 차이는 값/참조 구분이 아니라 격리(isolation)에 있다: 가변 상태에 대한 접근이 actor의 executor를 통해 직렬화되어 데이터 레이스가 방지된다. 그래서 actor 참조는 Sendable이지만 그 내부 상태는 격리로 보호된다.
Q3. Swift Array는 어떤 자료구조이며 Copy-on-Write는 어떻게 동작하는가?
Swift Array는 원소를 연속된 힙 버퍼에 담는 동적 배열이다. 임의 접근은 O(1), append는 용량을 기하급수적으로(대략 2배) 늘리며 분할상환 O(1)이다. Array는 struct(값 타입)지만 내부적으로 버퍼(클래스)를 가리키는 참조 하나를 들고 있어서, 복사하면 버퍼 참조만 O(1)로 복사되고 refcount만 올라간다. 이후 어느 한쪽을 변경하려는 순간 isKnownUniquelyReferenced로 버퍼가 공유 중인지 확인해, 공유 중이면 새 버퍼로 복사한 뒤 수정하고(그래서 다른 복사본은 안전), 단독 소유면 제자리에서 수정한다. 이것이 COW이다.
CS 원리
Array는 C++의 vector나 Java ArrayList와 같은 계열의 growable contiguous array다. 용량(capacity)이 차면 더 큰 버퍼를 새로 잡아 원소를 옮긴다. 증가율이 상수배(기하급수적)이므로 N번 append의 총비용이 O(N), 즉 append 하나당 분할상환 O(1)이 된다.
COW는 "값 타입의 독립성"과 "복사 비용 회피"를 동시에 얻는 기법이다. 값을 공유하는 동안은 복사를 하지 않다가, 누군가 실제로 변경(write)을 시도하고 그 버퍼가 둘 이상에게 공유되고 있을 때에만 복사한다. 공유 여부는 버퍼의 참조 카운트가 1인지로 판단한다.
iOS에서는
Swift stdlib의 Array·ContiguousArray·Dictionary·Set·String이 모두 COW다. COW의 핵심 프리미티브는 isKnownUniquelyReferenced(_:)로, 인자로 받은 클래스 참조의 카운트가 정확히 1이면 true를 준다. 버퍼는 클래스 인스턴스라 힙에 있다. ObjC의 NSArray에서 브리징된 Array는 native 버퍼가 아닐 수 있는데, ContiguousArray는 브리징 없이 항상 연속 native 저장을 보장한다.
실험 · 도구
var a = [1, 2, 3]
var b = a
a.withUnsafeBufferPointer { print($0.baseAddress!) } // 같은 주소
b.withUnsafeBufferPointer { print($0.baseAddress!) } // 같은 주소 (공유)
b.append(4)
b.withUnsafeBufferPointer { print($0.baseAddress!) } // 다른 주소 → 복사됨
// 직접 COW: 변경 직전에만 버퍼를 복제
final class Ref<T> { var value: T; init(_ v: T) { value = v } }
struct COWBox<T> {
private var ref: Ref<T>
init(_ v: T) { ref = Ref(v) }
var value: T { ref.value }
mutating func set(_ v: T) {
if !isKnownUniquelyReferenced(&ref) { ref = Ref(ref.value) } // 공유 중이면 복제
ref.value = v
}
}Instruments Allocations로 append가 실제 힙 재할당을 유발하는 순간을 확인하고, reserveCapacity를 넣기 전후로 재할당 횟수가 줄어드는지 비교한다.
프로젝트 적용
① 루프에서 크기를 아는 배열은 reserveCapacity로 재할당을 없앤다.
import Foundation
// Array.append는 amortized O(1)이지만, 용량이 찰 때마다 새 버퍼를 잡고
// 전체를 복사한다. 최종 크기를 알면 그 복사를 전부 없앨 수 있다.
// ❌ 재할당이 log n 번 일어난다 (2→4→8→16→… 실제 값은 malloc 블록에 맞춰 반올림된다)
func buildBad(_ source: [Row]) -> [ViewModel] {
var out: [ViewModel] = []
for r in source { out.append(ViewModel(r)) }
return out
}
// ✅ 한 번만 할당한다
func build(_ source: [Row]) -> [ViewModel] {
var out: [ViewModel] = []
out.reserveCapacity(source.count)
for r in source { out.append(ViewModel(r)) }
return out
}
// ✅ 더 간단히 — map은 내부적으로 용량을 미리 잡는다
func buildMap(_ source: [Row]) -> [ViewModel] {
source.map(ViewModel.init)
}
// 용량 증가를 직접 관찰해 보면 감이 온다
func observeGrowth() {
var a: [Int] = []
var lastCapacity = a.capacity
for i in 0..<1000 {
a.append(i)
if a.capacity != lastCapacity {
print("count \(a.count) → capacity \(a.capacity)") // 재할당 시점
lastCapacity = a.capacity
}
}
}
// ✅ Dictionary·Set도 마찬가지 — 재해시를 없앤다
func buildIndex(_ rows: [Row]) -> [String: Row] {
var index = [String: Row](minimumCapacity: rows.count)
for r in rows { index[r.id] = r }
return index
}
struct Row { let id: String }
struct ViewModel { init(_ r: Row) {} }② hot loop에서 배열을 함수 인자로 넘겨 그 안에서 변경하면 의도치 않은 COW가 터질 수 있다 — 제자리 수정이 필요하면 inout을 쓴다.
import Foundation
// 배열을 값으로 넘기면 호출된 쪽에서 변경할 때 COW가 발동해 전체가 복사된다.
// 제자리 수정이 목적이라면 inout이 그 복사를 없앤다.
// ❌ 안에서 바꾸면 사본이 생긴다 (호출자 배열과 공유 상태였으므로)
func normalizeBad(_ values: [Double]) -> [Double] {
var copy = values // 참조만 복사
for i in copy.indices { copy[i] /= 255 } // 첫 쓰기에서 전체 복사 발생
return copy
}
// ✅ inout — 넘기는 것 자체로는 복사가 없다
// (다만 호출자 배열이 다른 변수와 버퍼를 공유 중이면 첫 쓰기에서 COW가 1회 일어난다)
func normalize(_ values: inout [Double]) {
for i in values.indices { values[i] /= 255 }
}
// 사용
func useInout() {
var samples = [Double](repeating: 128, count: 1_000_000)
normalize(&samples) // 복사 0회
}
// ✅ 더 빠른 경로 — 경계 검사까지 줄인다
func normalizeFast(_ values: inout [Double]) {
values.withUnsafeMutableBufferPointer { buf in
for i in buf.indices { buf[i] /= 255 }
}
}
// 복사가 실제로 일어났는지 버퍼 주소로 확인할 수 있다
func detectCopy() {
var a = [1.0, 2.0, 3.0]
let before = a.withUnsafeBufferPointer { $0.baseAddress }
normalize(&a)
let after = a.withUnsafeBufferPointer { $0.baseAddress }
print(before == after ? "복사 없음 ✅" : "복사 발생 ❌")
}③ 배열을 클로저가 캡처하면 참조가 하나 더 생겨 이후 변경 때 COW가 발생할 수 있으니 주의.
import Foundation
// 배열을 클로저가 캡처하면 참조가 하나 더 생긴다.
// 그 뒤에 배열을 변경하면 "혼자가 아니므로" COW가 발동한다.
final class Renderer {
private var buffer = [Float](repeating: 0, count: 1_000_000)
private var onSnapshot: (() -> Void)?
// ❌ 클로저가 buffer를 캡처 → 캡처 이후 '첫' 쓰기에서 전체 복사가 터진다
// (그 뒤엔 다시 단독 소유라 제자리 수정이다)
func setupBad() {
onSnapshot = { [buffer] in // 값 캡처지만 버퍼 참조는 공유된다
print(buffer.count)
}
}
func write() {
buffer[0] = 1 // setupBad 이후엔 여기서 4MB 복사
}
// ✅ 필요한 것만 캡처한다
func setup() {
let count = buffer.count // 스칼라만 복사
onSnapshot = { print(count) } // 버퍼를 붙들지 않는다
}
// ✅ 스냅샷이 정말 필요하면 '그때' 명시적으로 뜬다
func snapshotNow() -> [Float] {
buffer // 이 시점에만 공유가 생긴다
}
}
// 같은 함정: 배열을 프로퍼티에 대입해 두고 원본을 계속 쓰는 경우
final class Cache {
var lastSnapshot: [Float] = []
var working = [Float](repeating: 0, count: 1_000_000)
func snapshot() { lastSnapshot = working } // 공유 시작
func mutate() { working[0] = 1 } // 여기서 복사 발생 (의도된 것이면 OK)
}
// 판별법: isKnownUniquelyReferenced는 클래스에만 쓸 수 있으므로,
// 배열은 baseAddress 비교로 복사 여부를 관찰한다(디버깅 용도).④ ObjC 브리징이 없고 성능이 중요하면 ContiguousArray를 고려한다.
import Foundation
// Array는 NSArray와 브리징될 가능성 때문에 요소 접근에 약간의 검사가 들어간다.
// 클래스나 @objc 프로토콜 요소를 담을 때 특히 그렇다.
// ContiguousArray는 항상 연속된 네이티브 버퍼를 보장해 그 검사를 없앤다.
// ✅ 수치 연산·버퍼 처리처럼 성능이 중요한 경로
var samples = ContiguousArray<Float>(repeating: 0, count: 1_000_000)
func process(_ buffer: inout ContiguousArray<Float>) {
buffer.withUnsafeMutableBufferPointer { buf in
for i in buf.indices { buf[i] *= 0.5 }
}
}
// ⚠️ 값 타입(Int·Float·struct)만 담는 Array는 이미 네이티브 버퍼를 쓴다.
// 그래서 이 경우 차이가 거의 없다 — 실제 이득은 '클래스 요소'를 담을 때다.
final class Node {}
var nodes = ContiguousArray<Node>() // ✅ 브리징 검사 제거 효과가 있다
// ⚠️ 대가: NSArray로 브리징되지 않는다. Objective-C API에 넘겨야 하면 못 쓴다.
func needsBridging(_ array: [Node]) {
let nsArray = array as NSArray // ContiguousArray는 이게 안 된다
_ = nsArray
}
// 판단 순서
// ① 먼저 측정한다 — 대부분의 코드에서 차이가 없다
// ② 프로파일러가 배열 접근을 핫스팟으로 지목했고
// ③ 요소가 클래스/existential이며
// ④ ObjC 브리징이 필요 없을 때
// → 그때 ContiguousArray로 바꾼다"var b = a는 배열 전체를 즉시 복사한다"는 오해다. 복사는 참조만 O(1)로 이뤄지고, 실제 원소 복제는 공유 중 변경이 일어나는 순간으로 지연된다. 반대로 "COW라 절대 복사 안 한다"도 틀렸다 — 공유 상태에서 write하면 반드시 복사한다.
Array를 신발장이라고 보자. 칸(capacity)이 정해져 있고, 꽉 차면 더 큰 신발장으로 통째 이사해야 한다. 만약 한 칸씩만 늘려 준다면 신발 한 켤레 넣을 때마다 이사를 해야 하니 끔찍하다. 그래서 Swift는 꽉 찰 때마다 칸 수를 두 배로 키운다. 신발 100켤레를 넣는 동안 이사는 일곱 번쯤이면 끝난다. 이사 한 번은 분명 비싸지만 그 비용을 그동안 넣은 신발 수로 나눠 보면 한 켤레당 거의 공짜다 — 이걸 분할상환 O(1)이라고 부른다.
COW는 그 신발장 문에 붙은 이름표라고 생각하면 된다. 이름표가 내 것 하나뿐이면 신발을 마음대로 넣고 뺀다. 이름표가 둘이면 남이랑 같이 쓰는 신발장이니, 손대기 직전에 내 신발을 전부 새 신발장으로 옮긴 다음 거기서 정리한다. isKnownUniquelyReferenced가 바로 "이름표 한 장 맞아?"를 물어보는 함수다. 여기서 헷갈리기 쉬운 건 검사 시점인데, 복사하는 순간이 아니라 고치려는 순간에 물어본다. 그래서 읽기만 하는 동안에는 몇 명이 같이 쓰든 복사가 한 번도 안 일어난다.
꼬리 질문
배열을 딕셔너리 값으로 넣은 뒤 그 배열을 변경하면 COW는 몇 번 일어나나?
var arr = [...]; dict["k"] = arr를 하면 버퍼 참조만 복사되어 refcount가 2(변수 arr + 딕셔너리 저장분)가 된다. 이후 바깥 변수 arr를 변경하면 버퍼가 공유 중이므로 COW가 한 번 일어나 arr가 자기 버퍼를 갖고, 딕셔너리 안의 배열은 옛 버퍼로 남는다. 반대로 dict["k"]?.append(...)처럼 딕셔너리 안의 배열을 제자리에서 바꾸면, Swift의 subscript _modify 접근자가 임시 참조를 만들지 않고 in-place로 처리해 불필요한 복사를 막는다(딕셔너리 자체가 유일 소유이면 COW 없음). 핵심은 "공유된 버퍼에 write가 처음 닿는 그 순간 1회"라는 것이다.
arr를 고치면 arr가 새 공책을 받아 나가고 딕셔너리 안 것은 옛 공책에 남는다. 반대로 딕셔너리 속 배열을 그 자리에서 고치는 경로는 중간에 사본이 안 생기도록 컴파일러가 길을 터 준다.isKnownUniquelyReferenced로 직접 COW 타입을 만들 때 흔히 빠지는 함정은?
가장 흔한 실수는 인자를 inout(&ref)으로 넘겨야 한다는 점이다. Apple 플랫폼에서는 런타임이 objc_isUniquelyReferenced로 위임해 NSObject 계열에도 동작하지만, 커스텀 retain/release를 가진 클래스나 태그드 포인터에는 보수적으로 false를 돌려주므로 순수 Swift class에 쓰는 편이 안전하다. 또 검사 직전에 그 참조를 다른 프로퍼티·클로저·연산 프로퍼티가 잠깐이라도 붙잡고 있으면 카운트가 1을 넘어 불필요한 복사가 터진다. 마지막으로 이 검사는 스레드 안전하지 않으므로 동시 접근 상황에서 유일성 판단을 신뢰하면 안 된다.
&ref로 넘겨야 하고, ObjC 브리징 클래스에는 아예 쓰면 안 된다.reserveCapacity가 append 성능에 주는 영향을 분할상환 관점에서 설명하라.
reserveCapacity(n)로 미리 용량을 잡으면 그 범위 안의 append는 재할당·원소 이동 없이 순수 O(1)이 된다. 없으면 배열은 기하급수적(약 2배) 임계점마다 재할당하는데, 재할당 1회 비용은 현재 크기에 비례(O(k))하고 N개 넣는 동안 약 log N번 일어난다. 총합은 여전히 분할상환 O(1)로 같지만, reserveCapacity는 그 재할당들을 아예 없애 상수 배수와 지연 스파이크를 제거한다. 즉 점근 복잡도를 바꾸는 게 아니라 실측 상수와 tail latency를 줄이는 최적화다.
Q4. Closure는 값을 어떻게 캡처하며 Retain cycle은 왜 발생하는가?
클로저는 참조 타입이며, 기본적으로 주변 스코프의 변수를 값 스냅샷이 아니라 그 변수의 저장 공간(box) 참조로 캡처한다. 그래서 캡처한 변수를 바깥에서 바꾸면 클로저 안에서도 바뀐 값이 보인다. 캡처 대상이 class 인스턴스면 클로저가 그것을 strong으로 붙잡는다. 이때 그 인스턴스(self)가 다시 클로저를 프로퍼티로 strong 보관하면 서로를 붙잡아 refcount가 0이 되지 못하는 retain cycle(순환 참조)이 생겨 메모리가 새어나간다. ARC는 카운팅만 하고 사이클을 못 걷어내므로, 캡처 리스트 [weak self]/[unowned self]로 한쪽을 약하게 끊어준다.
CS 원리
클로저는 코드 + 캡처한 환경(closed-over environment)을 함께 들고 다니는 값이다. Swift는 기본적으로 변수 자체를 캡처한다(변수의 값을 그 시점에 복사하는 게 아니라). 그래서 캡처한 변수가 나중에 바뀌면 그 변화가 반영된다. 반면 캡처 리스트 [x]를 쓰면 클로저 생성 시점의 x 값을 복사해 고정한다.
retain cycle은 참조 카운팅 방식의 근본적 한계다. A가 B를 strong으로, B가 A를 strong으로 참조하면 둘 다 카운트가 최소 1로 유지되어 스코프를 벗어나도 해제되지 않는다. GC(추적형 가비지 컬렉터)는 도달 불가능한 사이클을 수거하지만, ARC는 순수 카운팅이라 사이클을 감지하지 못한다.
iOS에서는
실무 사이클의 단골은 저장되는 클로저다 — completion handler 프로퍼티, Combine sink, RxSwift 구독, Timer·NotificationCenter 블록. 방어는 캡처 리스트로 한다. [weak self]는 self를 optional로 약하게 잡아 self가 죽으면 nil이 되고, [unowned self]는 nil 검사를 생략하지만 self가 이미 죽었으면 crash한다. 델리게이트를 weak var delegate로 선언하는 것도 같은 원리다.
실험 · 도구
final class Downloader {
var onDone: (() -> Void)?
func start() {
onDone = { self.finish() } // self ↔ onDone 사이클
}
func startSafe() { // 끊기
onDone = { [weak self] in
guard let self else { return }
self.finish()
}
}
func finish() {}
deinit { print("deinit") } // 사이클이면 절대 안 찍힘
}Xcode의 Memory Graph Debugger(디버그 세션 중 메모리 아이콘)로 보라색 순환 화살표를 찾거나, Instruments Leaks로 누수 인스턴스를 잡는다. deinit에 로그를 심어 해제가 실제로 일어나는지 확인하는 게 가장 빠르다.
프로젝트 적용
① 저장·장수(long-lived) escaping 클로저에서 self를 캡처하면 기본적으로 [weak self] + guard let self.
import Foundation
final class FeedLoader {
private var completion: (([Item]) -> Void)?
private var items: [Item] = []
// ❌ self가 completion을 보유 + completion이 self를 캡처 = 순환
func loadBad() {
completion = { newItems in
self.items = newItems // 💥 영구 누수
}
}
// ✅ 저장되고 나중에 실행되는 클로저 → weak + guard
func load() {
completion = { [weak self] newItems in
guard let self else { return } // 이미 해제됐으면 조용히 종료
self.items = newItems
self.notify()
}
}
// ✅ 여러 번 쓰는 self는 guard let으로 한 번만 풀어 쓴다
// (self?.a, self?.b 를 반복하면 그 사이에 nil이 될 수도 있다)
func loadDetailed() {
api.fetch { [weak self] result in
guard let self else { return }
switch result {
case .success(let items):
self.items = items
self.notify() // ✅ 여기까지 self가 살아 있음이 보장된다
case .failure(let error):
self.handle(error)
}
}
}
private func notify() {}
private func handle(_ e: Error) {}
private let api = API()
}
struct Item {}
struct API {
func fetch(_ done: @escaping (Result<[Item], Error>) -> Void) {}
}② unowned는 self의 수명이 클로저보다 엄격히 길다고 보장될 때만. 아니면 crash 위험.
import Foundation
// unowned는 언랩 비용이 없고 의도(“얘는 나보다 오래 산다”)를 코드로 드러낸다.
// 대신 그 가정이 깨지면 그 자리에서 crash다.
// ✅ 정당한 경우 — 소유 방향이 한쪽으로 고정돼 있다
final class Player {
private(set) var tracks: [Track] = []
func add(_ title: String) { tracks.append(Track(title: title, player: self)) }
}
final class Track {
let title: String
unowned let player: Player // ✅ Player가 Track을 소유한다. 역은 불가능.
init(title: String, player: Player) {
self.title = title
self.player = player
}
}
// ✅ self가 소유하고 self보다 오래 못 사는 클로저
final class Timerish {
private var tick: (() -> Void)?
func start() {
tick = { [unowned self] in // tick은 self의 프로퍼티 → self보다 오래 못 산다
self.onTick()
}
}
private func onTick() {}
}
// ❌ 정당하지 않은 경우 — 수명이 외부 요인에 달려 있다
final class Screen {
func setupCrashy(_ session: Session) {
session.onExpire = { [unowned self] in // 💥 Screen이 먼저 닫힐 수 있다
self.reload()
}
}
func setupSafe(_ session: Session) {
session.onExpire = { [weak self] in // ✅
self?.reload()
}
}
private func reload() {}
}
final class Session { var onExpire: (() -> Void)? }
// 자문할 것: "이 참조가 쓰이는 시점에 대상이 해제됐을 시나리오를 하나라도 그릴 수 있나?"
// 하나라도 그려지면 weak다.③ 남용 금지: 즉시 실행되고 저장되지 않는 non-escaping 클로저(map·filter·동기 블록)는 self를 잡아도 사이클이 안 생기고, 굳이 weak로 잡으면 필요한 순간 self가 nil일 수 있다.
import Foundation
final class Report {
private var rows: [Int] = []
private var total = 0
// ❌ 동기·비저장 클로저에 weak를 붙이면 얻는 것 없이 잃기만 한다
func computeBad() {
rows.forEach { [weak self] row in
self?.total += row // 옵셔널 체이닝만 늘어난다
}
}
// ✅ map·filter·forEach·sorted 등은 non-escaping이라 사이클이 불가능하다
func compute() {
rows.forEach { row in
self.total += row // 안전하다
}
let sorted = rows.sorted { self.weight($0) < self.weight($1) }
_ = sorted
}
// ❌ 더 나쁜 경우: 반드시 실행돼야 하는 작업에 weak를 걸면
// self가 마지막 참조일 때 작업이 통째로 건너뛰어진다
func saveBad(on queue: DispatchQueue) {
queue.async { [weak self] in
self?.flushToDisk() // self가 nil이면 저장이 사라진다
}
}
// ✅ 이 작업이 끝날 때까지 살아 있어야 한다면 strong 캡처가 '옳은 선택'이다
func save(on queue: DispatchQueue) {
queue.async {
self.flushToDisk() // 의도적으로 수명을 연장한다
}
}
private func weight(_ i: Int) -> Int { i }
private func flushToDisk() {}
}
// 판단 기준은 "weak가 안전해 보인다"가 아니라
// "self가 이 클로저를 보유해서 순환이 생기는가"다.④ completion을 한 번 호출하고 나면 onDone = nil로 비워 참조를 끊는 것도 방법.
import Foundation
final class Uploader {
private var completion: ((Result<URL, Error>) -> Void)?
private var progress: ((Double) -> Void)?
func upload(_ data: Data,
onProgress: @escaping (Double) -> Void,
completion: @escaping (Result<URL, Error>) -> Void) {
self.progress = onProgress
self.completion = completion
start(data)
}
private func finish(_ result: Result<URL, Error>) {
// ✅ 먼저 지역 변수로 옮기고 프로퍼티를 비운다
// (콜백 안에서 다시 upload를 부를 수 있으므로 순서가 중요하다)
let done = completion
completion = nil
progress = nil
done?(result) // ✅ 클로저가 캡처한 것들이 여기서 해제된다
}
// ❌ 비우지 않으면 클로저가 캡처한 객체들이 Uploader 수명 내내 살아 있다
private func finishLeaky(_ result: Result<URL, Error>) {
completion?(result)
// completion이 그대로 남아 캡처한 뷰컨트롤러·이미지가 계속 붙들린다
}
private func start(_ d: Data) {}
}
// ✅ 애초에 async/await로 바꾸면 저장할 클로저 자체가 없어진다
final class ModernUploader {
func upload(_ data: Data) async throws -> URL {
try await perform(data) // 완료 후 아무것도 남지 않는다
}
private func perform(_ d: Data) async throws -> URL { URL(fileURLWithPath: "/") }
}
// ⚠️ 진행률처럼 여러 번 부르는 콜백은 '완료 시점'에 비운다.
// 한 번만 부르는 completion과 수명 관리가 다르다."클로저에는 항상 [weak self]를 붙여야 한다"는 과잉 규칙이다. 사이클은 클로저가 self에 저장되어 오래 사는 경우에만 문제다. non-escaping/동기 실행 클로저는 호출 직후 해제되어 사이클이 없고, 무분별한 weak는 오히려 로직 버그(원하는 시점에 nil)를 만든다.
클로저가 바깥 변수를 캡처한다는 건, 그 순간의 숫자를 종이에 받아 적어 가는 게 아니다. 칠판을 가리키는 손가락을 들고 가는 쪽에 가깝다. 나중에 누가 칠판 숫자를 고쳐 쓰면 클로저가 보는 값도 같이 바뀐다. 반대로 캡처 리스트 [x]를 쓰면 그 순간 칠판을 사진으로 찍어 주머니에 넣는 것이라, 뒤에 칠판이 바뀌어도 사진 속 숫자는 그대로다.
retain cycle(순환 참조, 서로 붙잡고 안 놔줘서 아무도 못 떠나는 상태)은 이렇게 생긴다. self가 클로저를 프로퍼티에 넣어 붙잡고, 그 클로저는 안에서 self를 쓰느라 self를 붙잡는다. 두 사람이 서로 상대 손목을 쥐고 "네가 먼저 놔"라며 버티는 꼴이라 둘 다 방을 못 나간다. ARC는 "지금 몇 명이 잡고 있나"만 셀 뿐, 원을 그리며 서로 잡고 있다는 사실을 밖에서 내려다보지 못한다. 그래서 사람 손으로 한쪽을 [weak self]로 느슨하게 풀어 줘야 한다. 다만 이건 클로저가 self 안에 저장돼 오래 사는 경우 얘기다. map처럼 부르고 곧바로 버려지는 클로저는 손목을 잡았다 금방 놓으므로 원이 만들어지지 않는다.
꼬리 질문
weak와 unowned는 각각 언제 쓰고 trade-off는 무엇인가?
weak는 참조 대상이 클로저 수명 도중 사라질 수 있을 때 쓴다 — optional로 저장되고 대상이 해제되면 ARC가 side table을 통해 자동으로 nil로 만들어 안전하지만, zeroing 추적과 옵셔널 언래핑 비용이 있다. unowned는 대상이 클로저보다 반드시 오래 산다고 보장할 때 쓴다 — non-optional이라 언래핑이 없고 더 싸지만, 이미 해제된 대상을 접근하면 unowned(safe)는 trap(크래시), unowned(unsafe)는 정의되지 않은 동작이 된다. 요약하면 수명이 독립적이면 weak, 포함관계가 확실하면 unowned다.
weak는 상대가 사라지면 손이 저절로 풀리고 nil이 되는 안전 고리다. 대신 쓸 때마다 "아직 있나?"를 확인하는 값을 치른다. unowned는 "얘는 나보다 절대 먼저 안 죽어"라고 확신하고 그 확인을 생략하는 것이라 더 싸지만, 확신이 틀리면 앱이 그 자리에서 죽는다. 두 수명이 서로 상관없으면 weak, 부모-자식처럼 한쪽이 다른 쪽을 품고 있으면 unowned.캡처 리스트 없이 지역 var를 캡처하고 바깥에서 그 값을 바꾸면 클로저 안에서는 어떤 값이 보이나?
캡처 시점의 값이 아니라 가장 최근에 바뀐 값이 보인다. 기본 캡처는 값 스냅샷이 아니라 그 변수의 저장소(box)를 참조로 잡기 때문에, 바깥에서 변경한 것이 클로저 안에 반영되고 반대로 클로저 안에서 바꾼 것도 바깥에 반영된다. 캡처 시점 값으로 고정하고 싶으면 캡처 리스트 [x]로 복사해야 한다.
[x]로 사진을 찍어 두는 수밖에 없다.클로저 없이 두 class가 서로 strong으로 참조하면 사이클인가? 어떻게 끊나?
사이클이 맞다. retain cycle은 클로저에 국한된 현상이 아니라 strong 참조가 원을 이루는 모든 경우에 생긴다 — A→B, B→A가 서로 strong이면 둘의 refcount가 최소 1로 유지되어 스코프를 벗어나도 해제되지 않는다. 한쪽을 weak 또는 unowned로 바꿔 끊는다(예: 부모→자식은 strong, 자식→부모는 weak). 델리게이트를 weak var delegate로 두는 관례가 바로 이 패턴이다.
weak) 바꾸면 풀린다. 부모는 자식을 꽉, 자식은 부모를 느슨하게 잡는 게 관례고 weak var delegate가 딱 그 모양이다.Q5. Escaping closure는 수명과 메모리 할당에 어떤 영향을 주는가?
non-escaping 클로저는 함수가 반환하기 전에 반드시 실행(또는 폐기)된다고 보장되므로, 컴파일러가 캡처 컨텍스트를 스택에 두고 불필요한 retain을 생략할 수 있다. @escaping 클로저는 함수가 끝난 뒤에도 저장되거나 나중에 호출될 수 있어(비동기 콜백, 프로퍼티 저장) 캡처 컨텍스트를 힙에 박싱하고 캡처한 참조들을 클로저 수명 동안 strong으로 붙잡는다. 즉 escaping은 캡처 대상의 수명을 늘리고 힙 할당·ARC 비용을 유발한다. 바로 이 "수명이 길어진다"는 성질 때문에 retain cycle과 [weak self] 문제가 주로 escaping에서 생긴다. Swift에서 클로저 파라미터는 기본이 non-escaping이라, 저장·비동기가 필요할 때만 @escaping을 붙인다.
CS 원리
"escaping"은 클로저가 자신을 만든 함수의 호출 수명을 벗어나(escape) 살아남을 수 있는가의 문제다. 벗어나지 않는다면(non-escaping) 컴파일러는 캡처 저장소의 수명이 함수 호출과 함께 끝난다는 걸 알기에 스택 할당이 가능하고 참조 카운트 조작을 줄일 수 있다. 벗어난다면 캡처된 상태가 함수 반환 이후에도 유효해야 하므로 힙에 올려 참조 카운팅으로 관리해야 한다.
iOS에서는
Swift 3부터 클로저 파라미터의 기본은 non-escaping이고, 저장하거나 비동기로 넘기려면 @escaping을 명시해야 한다. URLSession.dataTask(completionHandler:), DispatchQueue.async, Combine의 sink 등이 escaping이다. 임시로 escaping 제약을 우회해야 할 땐 withoutActuallyEscaping(_:do:)를 쓴다. 최근에는 async/await가 많은 escaping completion 패턴을 대체해, 콜백을 저장하지 않고 suspension point로 수명을 다룬다.
실험 · 도구
var handlers: [() -> Void] = []
func nonEscaping(_ f: () -> Void) {
handlers.append(f) // ❌ 컴파일 에러: non-escaping 파라미터는 저장 불가
f() // ✅ 즉시 호출만 가능
}
func escaping(_ f: @escaping () -> Void) {
handlers.append(f) // ✅ 저장 가능 → f가 캡처한 것들이 계속 살아있음
}escaping이 캡처한 self의 수명을 늘리는지는 deinit 로그로 확인한다: escaping 클로저를 배열에 저장한 채로 두면 원래 스코프가 끝나도 self가 살아있고, 배열을 비우면 그제서야 deinit이 찍힌다. 힙 할당 자체는 Instruments Allocations에서 클로저 컨텍스트 객체로 관찰된다.
프로젝트 적용
① @escaping은 정말 필요할 때만 붙인다. 호출자에게 제약(캡처한 것들의 수명 관리)을 강제하고 힙 할당·ARC 비용을 만들기 때문.
import Foundation
// @escaping은 호출자에게 "캡처한 것들의 수명을 관리하라"는 제약을 강제하고,
// 컴파일러에게는 힙 박싱·ARC를 요구한다. 기본값(non-escaping)이 더 싸다.
// ✅ 즉시 실행하고 끝 — @escaping 불필요 (기본값)
func withTransaction<T>(_ body: () throws -> T) rethrows -> T {
begin()
defer { commit() }
return try body() // 함수가 끝나기 전에 반드시 실행된다
}
// ✅ 버퍼 접근처럼 스코프가 명확한 것도 non-escaping
func withBuffer<T>(count: Int,
_ body: (inout UnsafeMutableBufferPointer<UInt8>) -> T) -> T {
var storage = [UInt8](repeating: 0, count: count)
return storage.withUnsafeMutableBufferPointer(body) // 클로저가 inout을 받는다
}
// ❌ 습관적으로 @escaping을 붙이면 최적화 기회를 잃는다
func withTransactionBad<T>(_ body: @escaping () -> T) -> T {
body() // 저장하지도 않으면서 힙 박싱 비용만 진다
}
// ✅ 정말 필요한 경우: 저장하거나, 비동기로 나중에 부르거나
final class EventBus {
private var handlers: [(Event) -> Void] = []
func subscribe(_ handler: @escaping (Event) -> Void) { // ✅ 저장한다
handlers.append(handler)
}
}
func loadLater(_ completion: @escaping (Data) -> Void) { // ✅ 나중에 부른다
DispatchQueue.global().async { completion(Data()) }
}
// 판별: 클로저를 (1) 프로퍼티/배열에 저장하거나 (2) 다른 escaping 클로저로 넘기거나
// (3) 함수 반환 후에 부른다 → 셋 중 하나면 @escaping. 아니면 붙이지 않는다.
struct Event {}
func begin() {}; func commit() {}② 저장된 escaping 클로저는 발화 후 nil로 비워 참조를 끊고, self 캡처엔 [weak self].
import Foundation
final class DownloadTask {
private var onComplete: ((Data) -> Void)?
private var onProgress: ((Double) -> Void)?
func start(onProgress: @escaping (Double) -> Void,
onComplete: @escaping (Data) -> Void) {
self.onProgress = onProgress
self.onComplete = onComplete
}
private func complete(_ data: Data) {
let handler = onComplete
clearHandlers() // ✅ 먼저 비우고
handler?(data) // ✅ 그다음 호출 (재진입 안전)
}
private func clearHandlers() {
onComplete = nil
onProgress = nil
}
deinit { clearHandlers() }
}
// 호출부 — self 캡처는 weak로
final class DownloadScreen {
private let task = DownloadTask()
private var received: Data?
func begin() {
task.start(
onProgress: { [weak self] p in self?.updateBar(p) },
onComplete: { [weak self] data in
guard let self else { return }
self.received = data
self.render()
}
)
}
private func updateBar(_ p: Double) {}
private func render() {}
}
// ⚠️ 두 방향을 모두 챙겨야 한다:
// · 호출부: 클로저가 self를 붙들지 않게 (weak)
// · 소유자: 클로저가 캡처한 것을 오래 붙들지 않게 (발화 후 nil)③ 가능하면 async/await로 바꿔 completion 저장·수명 관리를 없앤다.
import Foundation
// completion 저장·nil 처리·[weak self]·순환 검토가 전부 사라진다.
// ❌ 콜백 기반 — 중첩되면 에러 처리가 흩어지고 수명 관리가 복잡해진다
final class OldFlow {
func load(completion: @escaping (Result<Profile, Error>) -> Void) {
api.token { [weak self] tokenResult in
guard let self else { return }
switch tokenResult {
case .failure(let e): completion(.failure(e))
case .success(let token):
self.api.profile(token) { profileResult in
completion(profileResult)
}
}
}
}
private let api = API()
}
// ✅ async/await — 저장할 클로저가 없으니 순환도, 비우기도 없다
final class Flow {
private let api = API()
func load() async throws -> Profile {
let token = try await api.token()
return try await api.profile(token)
}
}
// ✅ 취소도 구조적으로 따라온다
@MainActor
final class ProfileScreen {
private var loadTask: Task<Void, Never>?
private let flow = Flow()
func onAppear() {
loadTask = Task {
do { render(try await flow.load()) }
catch is CancellationError { }
catch { showError(error) }
}
}
func onDisappear() { loadTask?.cancel() } // 화면을 닫으면 요청도 끝난다
private func render(_ p: Profile) {}
private func showError(_ e: Error) {}
}
struct Profile {}
struct API {
func token(_ done: @escaping (Result<String, Error>) -> Void) {}
func profile(_ t: String, _ done: @escaping (Result<Profile, Error>) -> Void) {}
func token() async throws -> String { "" }
func profile(_ t: String) async throws -> Profile { Profile() }
}"@escaping은 성능에 영향이 없다" 혹은 "escaping이면 무조건 메모리 누수"는 둘 다 틀렸다. escaping은 힙 박싱·참조 카운팅이라는 비용을 유발할 뿐 그 자체가 누수는 아니다 — 누수는 self↔클로저 사이클이 있을 때만 생긴다. escaping은 "수명을 늘린다"는 것이 본질이다.
non-escaping과 escaping의 차이는 "가게에서 먹고 가느냐, 포장해 가느냐"다. 함수 안에서 클로저를 바로 실행하고 끝난다면 그릇은 그냥 테이블에 두면 된다. 손님이 일어날 때 같이 치우면 되니까, 컴파일러도 캡처한 걸 스택에 얹어 두고 값비싼 뒷정리(retain/release)를 상당 부분 건너뛴다. 그런데 @escaping은 포장 주문이다. 함수가 끝난 뒤 언제 열어볼지 모르니 밖에 들고 나가도 안 새는 용기, 즉 힙 박스에 담아야 한다.
여기서 오해가 자주 생긴다. 포장했다고 음식이 상하는 건 아니다. escaping은 그 자체로 누수가 아니라 "수명이 길어진다"는 뜻일 뿐이다. 다만 도시락이 냉장고에 남아 있는 동안에는 그 안에 든 재료(캡처한 self)도 같이 버릴 수 없다. 진짜 누수는 그 도시락이 자기를 넣어 둔 냉장고를 거꾸로 붙잡고 있을 때, 즉 앞 문항의 retain cycle이 겹칠 때만 생긴다. 그래서 다 먹은 도시락은 onDone = nil로 버려 주는 게 좋다.
꼬리 질문
withoutActuallyEscaping(_:do:)은 언제, 왜 쓰나?
실제로는 escape하지 않을 non-escaping 클로저를, 형식상 @escaping을 요구하는 API에 넘겨야 할 때 쓴다. 대표적으로 @escaping 파라미터를 받지만 동기적으로 즉시 호출만 하는 함수나, 넘긴 클로저를 저장하기 때문에 형식상 @escaping을 요구하는 API(예: array.lazy.filter는 predicate를 저장하므로 @escaping이 필요)에, 내가 가진 non-escaping 클로저를 넘겨야 할 때다. 이 함수는 클로저에 do 블록 동안만 escaping 타입을 부여하고, 블록이 끝날 때 실제로 escape하지 않았는지 런타임에 검사한다. 덕분에 원래 파라미터를 굳이 @escaping으로 승격시키지 않아도 된다.
do 블록 동안만 임시로 포장 딱지를 붙여 주고, 블록이 끝날 때 "진짜로 들고 나가진 않았지?"를 런타임에 확인한다. 그래서 내 함수 시그니처까지 @escaping으로 물들이지 않아도 된다.컴파일러가 non-escaping 클로저를 스택에 둘 수 있는 근거는?
non-escaping은 클로저가 함수 호출 수명을 넘어 살아남지 않는다고 타입 시스템이 보장하기 때문이다. 즉 캡처 컨텍스트의 수명이 스택 프레임의 수명 안에 완전히 갇힌다는 걸 컴파일러가 알 수 있어서, 힙에 박스를 잡을 필요 없이 스택에 두고 캡처한 참조에 대한 retain/release도 생략하거나 줄일 수 있다. escaping이면 반환 이후에도 캡처 상태가 유효해야 하므로 이 근거가 사라져 힙 박싱 + ARC가 필요하다.
async/await로 바꾸면 escaping completion 대비 캡처 수명 관리가 어떻게 달라지나?
escaping completion은 콜백을 프로퍼티에 저장해 두는 구조라 self를 오래 붙잡고, 그래서 [weak self]와 발화 후 nil 비우기가 필요했다. async/await에서는 저장하는 콜백이 없고, 캡처 상태가 suspension point를 넘어 유지되긴 하지만 그 수명이 task의 구조적 수명에 묶인다 — self는 await하는 동안만 잡히고 호출이 끝나면 풀린다. 따라서 self↔클로저 사이클을 [weak self]로 끊을 필요가 대개 사라진다(단 Task를 프로퍼티에 저장하면 그 Task가 self를 붙잡을 수 있으니 예외).
async/await에는 그 서랍이 없다. await로 멈춰 있는 동안만 self가 잡혀 있다가 함수가 끝나면 놓인다. 그래서 [weak self]가 대개 필요 없어진다. 예외는 Task를 프로퍼티에 저장할 때인데, 그 순간 서랍을 다시 하나 만든 셈이다.Q6. Optional은 어떤 문제를 해결하며 메모리에서 어떻게 표현될 수 있는가?
Optional은 "값이 없을 수 있음(null)"을 타입 시스템에 명시해 암묵적 null 역참조로 인한 크래시를 컴파일 타임에 막는다. T?는 사실 enum Optional<Wrapped> { case none; case some(Wrapped) }이고, 컴파일러가 언래핑을 강제한다. 메모리 표현은 순진하게 보면 태그 1바이트 + 페이로드지만, Swift는 extra inhabitant(그 타입에서 쓰이지 않는 비트 패턴)를 태그로 재활용한다. 예를 들어 클래스 참조는 all-zero(널) 패턴이 유효한 값이 아니므로 그 패턴을 .none으로 써서 크기가 전혀 늘지 않고(MemoryLayout<C?>.size == MemoryLayout<C>.size), Bool?도 1바이트다. 반대로 Int처럼 모든 비트 패턴을 다 쓰는 타입은 여분이 없어 태그 바이트가 추가되어 Int?가 9바이트(stride 16)가 된다.
CS 원리
null 참조는 토니 호어가 "10억 달러짜리 실수"라 부른 문제다. 어떤 참조든 널일 수 있으면 모든 역참조가 잠재적 크래시가 된다. Optional은 "널 가능성"을 별도 타입으로 승격시켜, 값을 쓰기 전에 반드시 존재를 확인(언래핑)하도록 컴파일러가 강제한다. 널 가능성이 타입에 드러나 컴파일 타임에 검증된다는 게 핵심이다.
메모리 최적화의 열쇠는 extra inhabitants다. 어떤 타입 T의 저장 표현에는 실제 값으로 쓰이지 않는 비트 패턴들이 있을 수 있다(예: 포인터의 0x0, Bool의 2~255). 컴파일러는 이 여분 패턴을 Optional의 .none 같은 케이스 태그로 재활용해서 추가 저장 공간 없이 enum을 표현한다.
iOS에서는
Objective-C 상호운용에서 포인터의 nil은 클래스 타입의 Optional.none과 맞물린다. NS_ASSUME_NONNULL_BEGIN과 _Nullable/_Nonnull 애노테이션이 Swift의 T?/T로 매핑된다. T!(IUO, Implicitly Unwrapped Optional)도 내부적으로는 Optional이지만 접근 시 자동 언래핑되며, 2단계 초기화가 필요한 @IBOutlet 등에 쓰인다. 크기는 MemoryLayout으로 직접 검증할 수 있다.
실험 · 도구
MemoryLayout<Int>.size // 8
MemoryLayout<Int?>.size // 9 (여분 없음 → 태그 1B 추가)
MemoryLayout<Int?>.stride // 16 (정렬 때문)
MemoryLayout<UInt8>.size // 1
MemoryLayout<UInt8?>.size // 2 (256패턴 다 씀 → 태그 필요)
MemoryLayout<Bool>.size // 1
MemoryLayout<Bool?>.size // 1 (Bool은 2~255가 여분 → 태그 재활용)
MemoryLayout<Bool??>.size // 1 (중첩해도 여분이 남아 안 커짐)
class C {}
MemoryLayout<C>.size // 8
MemoryLayout<C?>.size // 8 (널 포인터 0x0을 .none으로 재활용)이 숫자들을 직접 출력해 보면 "Optional은 항상 커진다/박싱된다"가 왜 틀렸는지 바로 드러난다. Int?만 커지고 클래스·Bool은 안 커진다.
프로젝트 적용
① Optional은 진짜 부재를 모델링할 때만 쓴다. 에러 전달 채널로 남용하지 말 것(그건 throws/Result).
import Foundation
// Optional은 "값이 없을 수 있다"를 뜻한다. "실패했다"가 아니다.
// 실패를 nil로 뭉개면 호출자가 원인을 알 수 없고, 로깅도 불가능해진다.
// ❌ 실패 원인이 전부 사라진다
func parseBad(_ json: Data) -> User? {
try? JSONDecoder().decode(User.self, from: json)
}
// 호출자: 네트워크 문제인지, 스키마 불일치인지, 빈 데이터인지 알 수 없다
// ✅ 실패는 throws로 — 원인이 타입으로 전달된다
func parse(_ json: Data) throws -> User {
try JSONDecoder().decode(User.self, from: json)
}
// ✅ 진짜 부재만 Optional
struct User: Decodable {
let id: UUID
let email: String
let nickname: String? // ✅ 닉네임은 '설정 안 했을' 수 있다 — 진짜 부재
let deletedAt: Date? // ✅ 삭제되지 않았으면 없는 게 맞다
}
// ✅ 부재와 실패가 함께 있으면 둘을 구분해 표현한다
enum FetchResult {
case found(User)
case notFound // 부재 (404) — 정상 흐름
}
func fetch(id: UUID) async throws -> FetchResult {
// 네트워크·파싱 실패는 throw, "그런 사용자 없음"은 .notFound
.notFound
}
// ✅ 컬렉션에서는 빈 컬렉션이 nil보다 낫다
struct Feed {
var items: [Item] = [] // ✅ 없으면 빈 배열
// var items: [Item]? // ❌ nil과 []를 구분할 이유가 대부분 없다
}
struct Item {}② 강제 언래핑 !은 불변식으로 non-nil이 보장될 때만, 그리고 이유를 주석/precondition으로 남긴다.
import Foundation
import UIKit
// !는 "여기서 nil이면 프로그래머 오류다"라는 선언이다.
// 그 판단의 근거를 코드에 남겨야 나중에 검증할 수 있다.
// ✅ ① 근거가 명확한 경우 — 앱 번들에 반드시 있는 리소스
func loadSchema() -> Data {
let url = Bundle.main.url(forResource: "schema", withExtension: "json")!
// 근거: 빌드 페이즈에 포함된 리소스. 없으면 빌드 구성 오류이므로 즉시 실패가 옳다.
return try! Data(contentsOf: url)
}
// ✅ ② precondition으로 이유를 명시하면 실패 시 원인이 바로 보인다
func configure(_ tableView: UITableView?) {
precondition(tableView != nil, "IB 아웃렛 연결 누락: tableView")
tableView!.dataSource = nil
}
// ✅ ③ 컴파일 타임 상수처럼 명백한 경우
let apiBase = URL(string: "https://api.example.com")! // 리터럴이라 실패 불가
// ❌ 근거 없는 강제 언래핑 — 런타임에 사용자 데이터에 따라 nil이 될 수 있다
func avatarBad(_ user: User) -> URL {
URL(string: user.avatarURLString)! // 💥 서버가 빈 문자열을 주면 크래시
}
// ✅ 외부 입력은 반드시 안전하게 처리한다
func avatar(_ user: User) -> URL? {
URL(string: user.avatarURLString)
}
// ✅ 기본값이 있으면 nil 병합이 가장 읽기 쉽다
func avatarOrPlaceholder(_ user: User) -> URL {
URL(string: user.avatarURLString) ?? placeholderURL
}
struct User { let avatarURLString: String }
let placeholderURL = URL(string: "https://example.com/placeholder.png")!③ IUO(T!)는 수명·초기화 제약(IBOutlet, 2단계 init) 말고는 지양한다.
import UIKit
// IUO는 "선언은 옵셔널이지만 쓸 때는 아닌 척한다"는 타협이다.
// 정당한 이유는 사실상 둘뿐이다.
// ✅ ① IBOutlet — 뷰 로드 전에는 nil, 로드 후에는 항상 존재
final class ProfileViewController: UIViewController {
@IBOutlet private var nameLabel: UILabel! // 프레임워크 계약
@IBOutlet private var avatarView: UIImageView!
}
// ✅ ② 2단계 초기화 — 자기 참조가 필요해 init에서 못 채우는 경우
final class Coordinator {
private var router: Router! // init 후에 채운다
init() {
router = Router(coordinator: self) // self를 넘겨야 해서 여기서
}
}
final class Router {
unowned let coordinator: Coordinator
init(coordinator: Coordinator) { self.coordinator = coordinator }
}
// ❌ 그 외에는 지양 — "나중에 채울게"는 대부분 설계로 해결된다
final class BadViewModel {
var user: User! // 언제 채워지는지 타입이 말해주지 않는다
func render() { print(user.name) } // 💥 순서가 어긋나면 크래시
}
// ✅ 생성자 주입으로 바꾸면 컴파일러가 순서를 보장한다
final class ViewModel {
let user: User // 항상 존재한다
init(user: User) { self.user = user }
func render() { print(user.name) }
}
// ✅ 정말 나중에 오는 값이면 진짜 Optional로 두고 명시적으로 다룬다
final class HonestViewModel {
private(set) var user: User?
func render() {
guard let user else { return showPlaceholder() }
print(user.name)
}
private func showPlaceholder() {}
}
struct User { let name: String }④ 의도치 않은 Optional<Optional<T>> 중첩을 조심(예: 딕셔너리 lookup 결과를 또 optional로 감쌀 때).
import Foundation
// Optional<Optional<T>>는 "없음"이 두 층이라 조건 판정이 어긋나기 쉽다.
// ① 가장 흔한 발생 지점: 값이 Optional인 딕셔너리
let settings: [String: String?] = ["nickname": nil, "theme": "dark"]
func nestedExample() {
let a = settings["nickname"] // String?? — 키는 있고 값이 nil
let b = settings["missing"] // String?? — 키 자체가 없음
// 둘 다 "nil처럼" 보이지만 의미가 다르다
print(a == nil) // false — 키가 있으므로 바깥은 non-nil
print(b == nil) // true
// ✅ 한 겹으로 평탄화한다
let flat = settings["nickname"] ?? nil // String?
_ = flat
}
// ② try?가 Optional을 반환하는 함수를 감쌀 때
func find(_ id: String) throws -> User? { nil }
func tryExample() throws {
let nested = try? find("x") // User? — SE-0230(Swift 5)부터 try?는 겹을 더 씌우지 않는다
// 대신 throw든 '못 찾음'이든 똑같이 nil이라 원인 구분이 불가능해진다
let flat = try? find("x") ?? nil // ✅ User?
_ = (nested, flat)
}
// ③ map/flatMap 혼동
func mapExample() {
let raw: String? = "42"
let mapped = raw.map { Int($0) } // Int?? — map은 한 겹 더 씌운다
let flatMapped = raw.flatMap { Int($0) } // ✅ Int?
_ = (mapped, flatMapped)
}
// ✅ 애초에 값에 Optional을 넣지 않는 설계가 가장 깔끔하다
enum Setting {
case unset // 명시적으로 "설정 안 함"
case value(String)
}
let betterSettings: [String: Setting] = ["nickname": .unset]
struct User {}"Optional은 항상 크기가 커지고 힙에 박싱된다"는 오해다. Optional은 별도 힙 박스가 아니라 값 자체의 인라인 표현이며, extra inhabitant 덕분에 클래스 참조·Bool 등은 크기가 전혀 늘지 않는다. "nil은 곧 포인터 0"이라는 설명도 포인터/클래스 타입에 한해 맞는 이야기다.
Optional을 메모리에 어떻게 적느냐는 OMR 답안지 문제와 똑같다. "답 없음"을 어디에 표시할까? Bool은 1바이트짜리 마킹 칸을 받아서 256가지를 칠할 수 있는데 실제로 쓰는 건 0번과 1번 딱 둘뿐이다. 노는 칸이 254개나 남으니 "2번에 칠하면 nil"이라고 약속만 하면 종이를 한 장도 더 안 써도 된다. 클래스 참조도 사정이 같다. 주소 0번지에는 어떤 객체도 살 수 없어서 그 칸이 늘 비어 있고, 그래서 0을 nil 표시로 쓴다. 이렇게 놀고 있는 비트 패턴을 extra inhabitant(그 타입이 값으로는 절대 쓰지 않는 여분 패턴)라고 부른다.
Int가 유별난 이유도 여기서 나온다. Int는 8바이트로 만들 수 있는 패턴을 하나도 남김없이 진짜 숫자로 써 버린다. 노는 칸이 없으니 "답 없음"을 적을 데가 없고, 결국 옆에 표시용 칸을 하나 더 붙여서 9바이트가 된다. 여기서 헷갈리기 쉬운 게 있는데, Optional이 커진다는 건 힙 어딘가에 상자를 새로 만든다는 뜻이 아니다. 그냥 답안지 종이가 한 칸 길어진 것뿐이다.
꼬리 질문
MemoryLayout<Int?>.size와 MemoryLayout<ClassRef?>.size가 다른 이유를 extra inhabitant로 설명하라.
Int는 264가지 비트 패턴을 전부 유효한 값으로 쓰므로 여분 패턴(extra inhabitant)이 없다 → .none을 표시할 곳이 없어 별도 태그 바이트가 붙어 Int?는 9바이트가 된다. 반면 class 참조는 0x0(널)이 결코 유효한 인스턴스 주소가 아니어서 그 패턴이 여분이다 → 컴파일러가 .none을 all-zero로 인코딩해 크기가 전혀 늘지 않고 ClassRef?도 8바이트를 유지한다.
Int는 8바이트로 만들 수 있는 조합을 하나도 안 남기고 전부 진짜 숫자로 써 버려서, "여긴 값이 없음"이라고 적을 빈칸이 없다. 그래서 옆에 칸을 하나 더 붙여 9바이트가 된다. 반대로 class 참조는 0번지에 객체가 살 수 없으니 그 조합이 늘 놀고 있고, 그 노는 자리를 nil 표시로 빌려 쓰니 크기가 그대로다.Optional<Optional<Bool>>가 여전히 1바이트인 이유는?
Bool은 0과 1만 값으로 쓰고 2~255(254개)는 여분 패턴이다. Optional<Bool>은 그중 하나(예: 2)를 .none으로 재활용해 여전히 1바이트다. 한 번 더 감싼 바깥 .none은 남아 있는 또 다른 여분 패턴(예: 3)을 쓰면 되고, 1바이트 안에는 아직 여분이 넉넉히 남아 있으므로 크기가 커지지 않는다. 여분 패턴이 고갈될 만큼 깊이 중첩해야 비로소 태그 바이트가 추가된다.
Bool은 1바이트 안에 칸이 256개 있는데 실제로 쓰는 건 0번과 1번, 딱 두 칸이다. 남는 254칸 중 한 칸을 "안쪽 nil", 또 한 칸을 "바깥쪽 nil"로 약속해 두면 그만이라 두 번 감싸도 종이가 안 늘어난다. 남는 칸을 전부 써 버릴 만큼 겹겹이 싸야 비로소 태그 바이트가 붙는다.IUO(T!)는 일반 Optional과 무엇이 같고 무엇이 다른가?
같은 점: IUO는 내부적으로 진짜 Optional<T>다. 저장 표현이 동일하고 nil을 담을 수 있으며 if let·?로 안전하게 다룰 수도 있다. 다른 점: 사용처에서 컴파일러가 자동으로 강제 언래핑을 삽입하므로, 값이 nil인데 접근하면 크래시한다. "초기화가 늦지만 결국 non-nil이 보장되는" 상황(@IBOutlet, 2단계 초기화)을 위한 편의 장치이며, 명시적으로 T? 타입에 대입되는 문맥에서는 일반 optional로 강등된다.
@IBOutlet처럼 "처음엔 비어 있어도 화면 뜰 때쯤엔 반드시 채워진다"고 믿을 수 있을 때만 쓴다.Q7. Generic과 Protocol 기반 다형성은 어떻게 다른가?
제네릭은 parametric polymorphism으로, 하나의 알고리즘을 여러 타입에 적용하되 각 사용처의 구체 타입 정보를 컴파일 타임에 유지한다(T는 각 인스턴스화에서 특정 구체 타입). 최적화 시 specialization으로 정적 디스패치·인라이닝이 가능하고 박싱이 없다. 반면 프로토콜을 타입으로 쓰는 existential(any P)은 구체 타입을 지우고 박스(existential container)에 담아, protocol witness table을 통한 동적 디스패치로 동작한다. 그래서 이종(heterogeneous) 컬렉션을 담을 수 있지만 런타임 비용과 (값이 크면) 힙 할당이 따른다. 요약하면 제네릭 = 컴파일 타임·타입 보존·저비용, existential = 런타임·타입 소거·유연하지만 비용.
CS 원리
다형성에는 여러 종류가 있다. 제네릭은 "타입을 매개변수로 받는" parametric polymorphism이다. func f<T: P>(_ x: T)에서 T는 호출마다 하나의 구체 타입으로 결정되고, 그 구체 타입 관계가 함수 전체에 걸쳐 유지된다(반환 타입에도, associated type에도). existential은 "이 프로토콜을 따르는 어떤 타입이든"을 하나의 값으로 다루는 것으로, 구체 타입을 런타임 박스 뒤로 숨긴다.
구현 관점에서 비특수화 제네릭과 existential은 둘 다 witness table로 동작한다. protocol witness table(PWT)은 프로토콜 요구사항의 실제 구현 주소표, value witness table(VWT)은 그 값을 복사·소멸·이동하는 방법의 표다. 제네릭은 이 표를 숨은 인자로 넘겨받고, existential은 박스 안에 함께 담는다는 차이가 있다.
iOS에서는
WWDC16 "Understanding Swift Performance"에 따르면 existential container는 대략 5 word — 3 word 인라인 value buffer + metadata 포인터 + protocol witness table 포인터 — 이고, 값이 3 word를 넘으면 힙에 박싱한다. MemoryLayout<any P>.size는 값 타입도 담을 수 있는 프로토콜 1개 기준 40바이트이고, AnyObject로 제약된 클래스 전용 프로토콜은 인스턴스 포인터 + witness table 2워드라 16바이트다. 제네릭 specialization은 최적화(예: -O + Whole Module Optimization, 또는 @inlinable)로 구체 타입이 보일 때 일어나며 보장된 동작은 아니다.
실험 · 도구
protocol Shape { func area() -> Double }
// 제네릭: 컴파일 타임에 T 확정, 특수화 시 정적 디스패치·박싱 없음
func totalG<S: Shape>(_ shapes: [S]) -> Double { shapes.reduce(0) { $0 + $1.area() } }
// existential: 이종 컬렉션 가능, 동적 디스패치 + 박싱
func totalE(_ shapes: [any Shape]) -> Double { shapes.reduce(0) { $0 + $1.area() } }
print(MemoryLayout<any Shape>.size) // 40 (5 word)swiftc -O -emit-sil로 SIL을 뜯어보면 제네릭 호출이 특수화되어 witness_method 대신 직접 호출로 바뀌는지 확인할 수 있다. 큰 값을 any에 담는 코드는 Instruments Allocations에서 박싱 힙 할당으로 관찰된다.
프로젝트 적용
① 성능이 중요하고 타입 관계를 보존해야 하는 API는 제네릭. 이종 값을 한 컬렉션/변수에 담아야 하면 any.
import Foundation
protocol Shape {
func area() -> Double
func scaled(by f: Double) -> Self // Self 반환 — any Shape로도 호출은 되지만(SE-0309)
// 결과가 any Shape로 지워져 구체 타입이 사라진다
}
struct Circle: Shape {
var radius: Double
func area() -> Double { .pi * radius * radius }
func scaled(by f: Double) -> Circle { Circle(radius: radius * f) }
}
// ✅ 제네릭 — 구체 타입 정보가 컴파일 타임에 유지된다
func totalArea<S: Shape>(_ shapes: [S]) -> Double {
shapes.reduce(0) { $0 + $1.area() }
// 특수화되면 area()가 정적 디스패치·인라인된다. 박싱도 없다.
}
// ✅ Self 요구가 있는 API는 제네릭이라야 성립한다
func scaleAll<S: Shape>(_ shapes: [S], by f: Double) -> [S] {
shapes.map { $0.scaled(by: f) } // 반환 타입이 S로 보존된다
}
// func scaleAllAny(_ s: [any Shape]) -> [any Shape] ← Self 정보가 사라져 불편해진다
// ✅ 타입 관계를 API에 드러낼 수 있다
func firstMatching<S: Shape>(_ shapes: [S], where p: (S) -> Bool) -> S? {
shapes.first(where: p) // 반환도 S — 호출부가 구체 타입을 그대로 받는다
}
// 호출부에서 타입이 보존되는 걸 확인
func usage() {
let circles = [Circle(radius: 1), Circle(radius: 2)]
let bigger: [Circle] = scaleAll(circles, by: 2) // ✅ [Circle]로 돌아온다
_ = bigger
}
// ⚠️ 제네릭의 한계: 하나의 배열에 서로 다른 구체 타입을 담을 수 없다.
// [S]는 전부 같은 S여야 한다. 이종이 필요하면 any로 간다.② 제네릭 특수화는 코드가 타입마다 복제되어 바이너리 크기를 키울 수 있다 — 필요하면 existential로 경계를 두어 코드 폭발을 억제한다.
import Foundation
// 제네릭 특수화는 사용된 타입마다 기계어를 복제한다. 런타임은 빨라지지만
// 바이너리가 커지고, 커진 __TEXT는 콜드 스타트 페이지 폴트로 돌아온다.
protocol Codec { func encode() -> Data }
// ❌ 큰 함수를 제네릭으로 두면 타입 수만큼 복제된다
func processBad<C: Codec>(_ items: [C]) -> Data {
var out = Data()
// … 수백 줄의 로직 …
for i in items { out.append(i.encode()) }
return out
}
// Codec 구현이 20개면 이 수백 줄이 20벌 생길 수 있다
// ✅ 얇은 제네릭 껍데기 + 두꺼운 비제네릭 코어
func process<C: Codec>(_ items: [C]) -> Data {
processCore(items.map { $0.encode() }) // 껍데기만 복제된다
}
private func processCore(_ encoded: [Data]) -> Data {
var out = Data()
// … 수백 줄의 로직 … (한 벌만 존재)
for d in encoded { out.append(d) }
return out
}
// ✅ 호출 빈도가 낮은 넓은 API는 일부러 existential로 경계를 둔다
func configure(_ codec: any Codec) { // 코드 1벌
_ = codec.encode()
}
// ✅ 특수화를 명시적으로 막을 수도 있다(라이브러리 경계에서)
// ⚠️ @inline(never)는 인라이닝만 막을 뿐 특수화는 못 막는다 — SIL에 specialized 심볼이 그대로 생긴다.
// 코드 복제를 줄이려면 위처럼 '얇은 껍데기 + 비제네릭 코어'로 쪼개거나 파라미터를 any로 받는다.
@inline(never)
func rarelyCalled<C: Codec>(_ c: C) -> Data { c.encode() }
/* 확인 방법 — 바이너리에서 특수화된 심볼을 센다
nm -gU MyApp | xcrun swift demangle | grep 'specialized' | wc -l
또는 Xcode → Product → Perform Action → Assemble 로 확인
판단: 런치 시간·바이너리 크기가 문제로 잡히기 전까지는
제네릭을 쓰는 게 맞다. 이건 '문제가 생겼을 때의 처방'이다. */③ any는 동적 디스패치·박싱 비용이 있으니 hot path에서는 피한다.
import Foundation
protocol Renderer {
func render(_ item: Item) -> Layer
}
struct Item {}; struct Layer {}
// existential 비용 세 가지
// ① witness table을 통한 동적 디스패치 (인라인 불가)
// ② 페이로드가 3워드 초과면 힙 박싱 + ARC
// ③ 위 둘 때문에 컴파일러 최적화가 대부분 막힌다
// ❌ 프레임마다 도는 경로에서 any
func renderFrameBad(_ items: [Item], with renderer: any Renderer) -> [Layer] {
items.map { renderer.render($0) } // 매 호출이 동적 디스패치
}
// ✅ 제네릭으로 바꾸면 특수화 후 정적 디스패치·인라인이 가능해진다
func renderFrame<R: Renderer>(_ items: [Item], with renderer: R) -> [Layer] {
items.map { renderer.render($0) }
}
// ✅ some 파라미터는 위와 같은 의미의 축약형이다
func renderFrameShort(_ items: [Item], with renderer: some Renderer) -> [Layer] {
items.map { renderer.render($0) }
}
// ✅ 이종이 꼭 필요하면 '루프 밖'에서 한 번만 existential을 푼다
func renderMixed(_ items: [Item], renderers: [any Renderer]) -> [Layer] {
renderers.flatMap { renderer in
renderFrame(items, with: renderer) // 여기서 제네릭으로 넘어간다
}
}
// existential 크기를 직접 확인 — 3워드 인라인 버퍼 + 메타데이터 + witness table
func layout() {
print(MemoryLayout<any Renderer>.size) // 40 = 5워드
}
// 판단: 초당 수천 번 이상 도는 경로만 신경 쓴다.
// 설정·초기화처럼 드물게 도는 곳에서는 any가 훨씬 읽기 쉽다."프로토콜 타입(any)을 써도 제네릭과 성능이 같다"는 오해다. existential은 동적 디스패치 + (값이 크면) 힙 박싱 비용이 있다. 반대로 "제네릭은 런타임에 타입별로 코드가 무조건 복제된다"도 틀렸다 — specialization은 최적화일 뿐 보장이 아니고, 비특수화 제네릭은 witness table로 동작한다.
witness table을 "사용설명서"라고 부르면 그림이 잡힌다. area()를 부르려면 이게 원인지 사각형인지에 맞는 실제 코드 주소를 알아야 하는데, 그 주소들을 모아 둔 표가 protocol witness table이다. 값을 어떻게 복사하고 어떻게 버리는지 적어 둔 표는 value witness table이다.
any Shape는 물건을 규격 상자에 담고 설명서를 상자에 같이 붙여 보내는 방식이다. 받는 쪽은 안에 뭐가 들었는지 몰라도 설명서만 보면 다룰 수 있으니, 원이든 사각형이든 한 상자 규격에 섞어 담을 수 있다. 대신 부를 때마다 설명서를 펼쳐 봐야 하고(동적 디스패치), 물건이 상자보다 크면 창고에 따로 두고 상자에는 창고 주소만 적는다(힙 박싱). 제네릭은 반대다. 컴파일러가 "여기 오는 건 언제나 Circle이네" 하고 알아채면 상자도 설명서도 걷어내고 Circle 전용 코드를 아예 새로 찍어 낸다 — 이게 specialization이다.
- 비유가 깨지는 지점: 제네릭이라고 항상 설명서를 버리는 건 아니다. 컴파일러가 구체 타입을 못 보면 제네릭도 설명서를 손에 따로 들고 함께 다닌다(숨은 인자). 상자에 넣느냐 손에 드느냐가 다를 뿐 표를 참조하는 건 같다.
꼬리 질문
associated type이 있는 프로토콜을 any로 쓸 때의 제약(예: any Collection)은?
과거에는 associated type이 있는 프로토콜(PAT)을 existential로 아예 쓸 수 없었다. Swift 5.7(SE-0309)부터 any Collection이 허용되지만, associated type은 값 뒤에 숨겨진 채라 공변(covariant) 위치에서만 안전하게 노출된다. 즉 associated type이나 Self를 반환하는 멤버는 상위 타입(any P)으로 지워진 채 쓸 수 있지만, 그것들을 파라미터로 받는 멤버(예: contains(_: Element), ==)처럼 비공변(non-covariant) 위치에 노출하는 멤버는 쓸 수 없다. 그런 연산이 필요하면 existential을 열거나(open existential) some/제네릭으로 가야 한다.
any로 담으면, 그 숨은 타입은 밖으로 내보내는 자리에서만 안전하게 꺼내 볼 수 있다. any Collection에서 원소를 꺼내는 건 되지만, 특정 원소를 넣어 "이거 들어 있어?"라고 물어보는 건 막힌다 — 넣는 게 타입이 맞는지 컴파일러가 보장 못 하기 때문. 그런 게 필요하면 some이나 제네릭으로 가야 한다.existential container가 담을 값이 3 word를 넘으면 메모리에서 어떻게 처리되나?
인라인 value buffer(3 word)에 값이 들어가지 못하면 Swift는 힙에 박스를 할당해 실제 값을 거기 저장하고, 컨테이너의 buffer에는 그 박스를 가리키는 포인터 하나만 둔다. 이 박스의 할당·복사·소멸은 value witness table(VWT)이 담당한다. 결과적으로 큰 값을 any에 담을 때마다 힙 할당과 참조 카운팅 성격의 관리 비용이 붙는다.
any에 담을 때마다 창고를 새로 빌리는 값(힙 할당)을 치른다. 작아서 칸에 바로 들어가면 그 비용이 없다.제네릭 specialization이 실제로 일어나는 조건은 무엇인가?
컴파일러가 호출 지점의 구체 타입과 제네릭 함수의 본문을 동시에 볼 수 있어야 한다. 그래서 최적화가 켜져 있고(-O), 정의가 같은 모듈에 있거나, 모듈을 넘는 경우엔 @inlinable/@usableFromInline 또는 whole-module/cross-module 최적화가 켜져 있어야 한다. 이 조건이 안 맞으면 witness table 기반 비특수화 제네릭으로 폴백한다 — specialization은 보장이 아니라 최적화다.
-O 켜지고 같은 모듈이거나 @inlinable) 그 타입 맞춤 코드를 새로 만들어 준다. 하나라도 안 보이면 포기하고 설명서(witness table) 방식으로 돌아간다. 그래서 이건 "되면 좋은" 최적화지 늘 되는 건 아니다.Q8. any와 some은 무엇이 다른가?
some P는 opaque type으로, 구현부가 고른 하나의 고정된 구체 타입이지만 그 이름을 호출자에게는 감춘다("역방향 제네릭"). 타입이 하나로 고정되므로 타입 관계가 보존되고, 컴파일러가 구체 타입을 볼 수 있으면 특수화되어 정적 디스패치·박싱 없음이 된다(특수화가 안 되면 일반 제네릭과 마찬가지로 witness table을 탄다). any P는 existential로, 런타임에 서로 다른 구체 타입을 담을 수 있는 박스이며 타입을 지우고 동적 디스패치·박싱을 쓴다. 한마디로 some은 "정체는 하나인데 이름만 가림", any는 "여러 타입을 담는 상자". SwiftUI의 some View가 대표 예로, body가 반환하는 거대한 단일 구체 타입을 감춰 프레임워크가 안정적 타입으로 diffing하게 해준다.
CS 원리
some P(opaque type, SE-0244)는 "타입이 하나로 정해져 있지만 그게 무엇인지는 API 경계에서 숨긴다"는 개념이다. 호출자는 구체 타입을 모르지만 컴파일러는 안다. 그래서 두 번 호출한 some Equatable이 같은 타입임을 컴파일러가 보장하고, 타입 관계를 유지한 채 최적화(정적 디스패치)를 한다.
any P(existential, SE-0335)는 "이 프로토콜을 따르는 어떤 타입이든" 담는 상자다. 실행 중에 담긴 타입이 바뀔 수 있고(컬렉션에 여러 타입 혼재 가능), 그 유연성의 대가로 타입 소거·동적 디스패치·박싱이 따른다. 파라미터 위치의 some P(SE-0341)는 <T: P> 제네릭의 축약이다.
| 구분 | some P (opaque) | any P (existential) |
|---|---|---|
| 구체 타입 | 하나로 고정(감춰짐) | 런타임에 여러 타입 가능 |
| 타입 관계 보존 | 유지 | 소거 |
| 디스패치 | 정적(특수화 가능) | 동적(witness table) |
| 박싱/비용 | 없음(제로 코스트) | existential 박스·힙 가능 |
| 이종 컬렉션 | 불가 | 가능([any P]) |
iOS에서는
SwiftUI의 var body: some View가 핵심 사례다. body가 만드는 타입은 VStack<TupleView<...>>처럼 거대한 단일 구체 타입인데, opaque로 감추면 이름을 안 적어도 되고 프레임워크는 안정적 구체 타입을 얻어 뷰 diffing과 identity 추적을 효율적으로 한다. 여기에 any View를 쓰면 매 뷰가 박싱되고 타입 안정성이 사라져 성능·정확성이 나빠진다. WWDC22 "Embrace Swift generics"가 이 선택을 자세히 다룬다.
실험 · 도구
protocol Animal { func sound() -> String }
struct Dog: Animal { func sound() -> String { "woof" } }
struct Cat: Animal { func sound() -> String { "meow" } }
// some: 반환 타입이 하나로 고정되어야 함
func makeOne() -> some Animal { Dog() } // OK
// func broken(_ f: Bool) -> some Animal { // ❌ 두 구체 타입 반환 불가
// f ? Dog() : Cat()
// }
// any: 서로 다른 타입을 한 배열에
let zoo: [any Animal] = [Dog(), Cat()] // OK (이종)
print(zoo.map { $0.sound() }) // ["woof", "meow"]
print(MemoryLayout<any Animal>.size) // 40 (existential 박스)조건에 따라 다른 타입을 some으로 반환하려 하면 컴파일 에러가 나는 걸 직접 확인하라 — 이게 "some은 하나의 타입"이라는 성질의 증거다. @ViewBuilder는 이 제약을 _ConditionalContent라는 단일 타입으로 감싸 우회한다.
프로젝트 적용
① 기본은 some(파라미터·반환 모두). 제로 코스트이고 타입 관계를 지킨다.
import Foundation
protocol Store {
associatedtype Value
func load() -> Value
}
struct MemoryStore: Store {
func load() -> Int { 42 }
}
// ✅ 파라미터 위치의 some — 제네릭의 문법 설탕이다
// (의미는 같지만 호출부에서 타입 인자를 명시적으로 지정할 수 없고, 가변 인자 위치에는 못 쓴다)
func read(from store: some Store) {
_ = store.load()
}
// 위는 func read<S: Store>(from store: S) 와 같다
// ✅ associatedtype이 있는 프로토콜은 any로 쓰기가 까다롭다 — some이 자연스럽다
// func readAny(from store: any Store) { _ = store.load() } // Value 타입을 알 수 없다
// ✅ 반환 위치의 some — 구현부가 타입을 정하고 이름만 감춘다
func makeStore() -> some Store {
MemoryStore() // 호출자는 구체 타입을 모르지만 컴파일러는 안다
}
func usage() {
let s = makeStore()
let v = s.load() // ✅ Int로 정확히 추론된다 (타입 관계 보존)
_ = v
}
// ✅ SwiftUI가 이 패턴의 대표 사례다
// body의 실제 타입은 VStack<TupleView<(Text, Divider)>> 같은 거대한 타입이지만
// 프레임워크는 그걸 알 필요가 없고, 안정적인 단일 타입이라 diffing이 가능하다
// 비용: 특수화되면 정적 디스패치이고 박싱이 없다(특수화는 최적화일 뿐 보장은 아니다).② any는 진짜로 이종 타입을 담아야 할 때(한 변수·컬렉션에 여러 구체 타입)만 쓴다.
import Foundation
protocol Plugin {
var name: String { get }
func run()
}
struct AnalyticsPlugin: Plugin {
let name = "analytics"
func run() {}
}
struct CrashPlugin: Plugin {
let name = "crash"
func run() {}
}
// ✅ 서로 다른 구체 타입을 한 컬렉션에 담아야 한다 → any
let plugins: [any Plugin] = [AnalyticsPlugin(), CrashPlugin()]
func runAll() {
for p in plugins { p.run() }
}
// ✅ 런타임에 타입이 결정되는 팩토리 반환도 any
func makePlugin(named name: String) -> any Plugin {
name == "analytics" ? AnalyticsPlugin() : CrashPlugin()
// some으로는 불가능하다 — 반환 타입이 하나로 고정되지 않는다
}
// ✅ 저장 프로퍼티로 여러 구현을 갈아 끼워야 할 때
final class PluginHost {
var active: (any Plugin)? // 런타임에 교체된다
}
// ❌ 구현이 하나뿐인데 any를 쓰는 것 — 비용만 내고 얻는 게 없다
struct OnlyOne: Plugin { let name = "only"; func run() {} }
func badUsage(_ p: any Plugin) {} // 구현이 하나면 구체 타입이 낫다
func goodUsage(_ p: OnlyOne) {}
// ✅ any를 받되 내부에서 제네릭으로 넘겨 비용을 한 번만 치르는 패턴
func process(_ plugins: [any Plugin]) {
for p in plugins { processOne(p) }
}
private func processOne(_ p: some Plugin) { // 박스를 매번 열지는 않지만, 구체 타입이 런타임에만 정해져 특수화는 안 되고 호출은 여전히 witness table을 탄다
p.run()
}③ SwiftUI body는 항상 some View. hot path의 추상화도 some을 우선.
import SwiftUI
// SwiftUI의 body는 반드시 some View다 — 이유가 성능과 직결된다.
// 프레임워크가 '안정적인 단일 타입'을 알아야 diffing이 가능하기 때문이다.
struct ProfileView: View {
let user: User
// ✅ some View — 구체 타입은 거대하지만 하나로 고정된다
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(user.name).font(.headline)
Text(user.email).foregroundStyle(.secondary)
Divider()
}
}
}
// ❌ AnyView로 감싸면 타입이 지워져 (참고: var body: any View는 애초에 컴파일되지 않는다) diffing이 무력해지고
// 매 갱신마다 뷰를 새로 만들게 된다
struct BadView: View {
var body: some View {
AnyView(Text("느림")) // 꼭 필요할 때만
}
}
// ✅ 조건 분기는 AnyView 대신 @ViewBuilder로 — 타입이 보존된다
struct ConditionalView: View {
let isLoggedIn: Bool
var body: some View {
if isLoggedIn {
Text("환영합니다")
} else {
Button("로그인") {}
}
// @ViewBuilder가 _ConditionalContent<Text, Button>으로 묶어 준다
}
}
// ✅ 재사용 컴포넌트도 some으로 반환한다
@ViewBuilder
func badge(_ count: Int) -> some View {
if count > 0 {
Text("\(count)").padding(4).background(.red).clipShape(Circle())
}
}
// AnyView가 정당한 경우: 런타임에 완전히 다른 뷰 트리를 골라야 하고
// 그 종류를 컴파일 타임에 열거할 수 없을 때(플러그인 시스템 등)
struct User { let name: String; let email: String }"some과 any는 문법 설탕 차이일 뿐"은 틀렸다. some은 정적·단일 타입·제로 코스트, any는 동적·이종·박싱으로 런타임 동작과 비용이 다르다. 또 "반환 위치의 some은 제네릭과 동일"도 아니다 — 반환 some은 구현부가 타입을 정하지만, 제네릭 파라미터는 호출자가 타입을 정하므로 서로 대체 불가다.
some과 any의 진짜 차이는 "타입을 누가 정하느냐"다. 반환 자리의 some View는 오늘의 메뉴다. 주방장이 뭘 낼지 이미 정해 뒀고 손님은 그 이름을 모르지만, 몇 번을 시켜도 나오는 건 언제나 똑같은 그 한 가지다. 이름만 가려져 있을 뿐 정체가 하나로 고정돼 있으니 주방은 그 요리에 맞춘 준비를 미리 해 둘 수 있다. 그래서 공짜다.
any Animal은 뷔페 접시 쪽이다. 접시 규격은 하나지만 그 위에 올라오는 게 그때그때 다르다. 개도 올리고 고양이도 올릴 수 있어 유연한 대신, 뭐가 올라와 있는지 매번 확인하는 값을 치른다. 그리고 여기서 제네릭과의 헷갈림이 풀린다. <T: Animal>은 오늘의 메뉴가 아니라 주문서다 — 손님이 "저는 Dog로 주세요"라고 고르는 쪽이다. 정하는 사람이 정반대라서, 반환 자리의 some을 제네릭으로 바꿔 쓸 수 없다.
꼬리 질문
반환 위치의 some P를 왜 제네릭 파라미터 <T: P>로 대체할 수 없나?
타입을 누가 고르느냐가 정반대이기 때문이다. 반환 some P는 구현부가 하나의 구체 타입을 정하고 호출자는 그걸 고를 수 없다("역방향 제네릭"). 반면 <T: P>(...) -> T는 호출자가 T를 정한다 — 함수는 호출자가 요구하는 아무 타입이나 만들어 반환해야 하는데, 고정된 타입을 생산하는 구현부로선 불가능하다. 그래서 둘은 서로 대체될 수 없다.
some P는 함수 만든 쪽이 "난 늘 Dog만 낼게" 하고 정하는 것. 제네릭 <T: P> -> T는 부르는 쪽이 "Cat으로 주세요" 하고 정하는 것. 부르는 쪽이 아무 타입이나 요구할 수 있는데 함수는 딱 한 가지만 만들 줄 아니, 그 주문을 맞출 수가 없다.SwiftUI body가 some View가 아니라 any View라면 무슨 문제가 생기나?
any View는 모든 뷰를 existential 박스로 감싸 구체 타입을 지운다. SwiftUI는 안정적인 정적 뷰 타입에 기대어 구조적 identity를 추적하고 효율적으로 diffing하는데, 타입 소거는 이를 무너뜨려 identity 손실(상태 리셋·애니메이션 깨짐), 동적 디스패치·박싱 오버헤드를 부른다. 또 @ViewBuilder(result builder)가 만들어내는 정밀한 제네릭 타입 구조도 깨지고 컴파일 타임 보장이 사라진다.
any View로 싸면 SwiftUI가 각 뷰의 진짜 타입을 못 보게 된다. SwiftUI는 뷰의 타입 모양을 지문처럼 보고 "아, 이건 아까 그 뷰네" 하며 상태와 애니메이션을 이어 주는데, 지문을 지워 버리면 같은 뷰인 줄 몰라 입력값이 리셋되고 애니메이션이 튄다. 게다가 뷰마다 상자에 싸느라 느려진다. 그래서 body는 some View여야 한다.한 함수에서 조건에 따라 다른 뷰 타입을 반환하면서도 some View를 유지하려면 어떻게 하나?
분기를 하나의 타입으로 합쳐야 한다. 가장 관용적인 방법은 @ViewBuilder를 붙이는 것으로, if/else를 _ConditionalContent<A, B>라는 단일 타입으로 감싸준다. 대안으로 Group { ... }으로 묶거나, 정적 타입을 포기하는 대신 AnyView로 지워 반환 타입을 하나로 만들 수도 있다. 실무 정답은 대개 @ViewBuilder다.
some View는 "늘 같은 한 타입"이라 if/else로 서로 다른 뷰를 내면 규칙을 어긴다. 해법은 두 갈래를 한 타입으로 묶는 것. @ViewBuilder를 붙이면 컴파일러가 if/else를 _ConditionalContent라는 한 상자에 알아서 담아 준다. 그래서 SwiftUI에선 그냥 @ViewBuilder를 쓰면 된다. 마지막 수단인 AnyView는 앞 문항에서 본 손해를 그대로 떠안는다.