초기화는 인스턴스가 세상에 나오기 전 마지막 검문소이고, deinit은 생명주기 끝에서 실행되는 되돌릴 수 없는 마지막 기회다. 이 챕터는 Swift가 왜 클래스 초기화를 굳이 2단계로 쪼개 놓았는지, static let이 스레드에 안전한 진짜 이유는 무엇인지, 그리고 인스턴스가 사라지는 순간 손대면 안 되는 것이 무엇인지를 직접 컴파일해서 얻은 진짜 진단 메시지와 실행 로그로 확인한다.
Q1. 2단계 초기화(two-phase init)의 안전 규칙 4가지는 각각 무엇을 막는가?
Swift 클래스 초기화는 두 단계로 나뉜다. Phase 1은 자신과 조상 클래스가 도입한 저장 프로퍼티 전부에 최초값을 확정하는 단계이고, Phase 2는 그렇게 완전히 초기화된 인스턴스를 self로 참조하며 다듬는 단계다. 네 가지 안전 규칙은 전부 "phase 1이 끝나기 전에 인스턴스가 관찰되는 것"을 막기 위한 장치이고, 넷 다 실제 컴파일 에러로 강제된다. 규칙을 하나씩 깨보면 컴파일러가 내놓는 진단 문구 자체가 그 규칙이 정확히 뭘 지키려는 건지 알려준다.
원리
TSPL Initialization이 정의하는 두 단계는 이렇다 — "클래스 초기화는 2단계 과정이다. 첫 단계에서는 그 프로퍼티를 도입한 클래스가 각 저장 프로퍼티에 초깃값을 대입한다. 모든 저장 프로퍼티의 초기 상태가 확정되면 두 번째 단계가 시작되고, 각 클래스는 새 인스턴스가 준비된 것으로 간주되기 전에 저장 프로퍼티를 추가로 다듬을 기회를 얻는다." 목적은 명확하다 — 초기화되지 않은 프로퍼티 값이 읽히는 것을 막고, 한 초기화자가 세팅한 값이 다른 초기화자에 의해 예기치 않게 덮어써지는 것을 막는다.
이 목적을 지키려고 컴파일러가 강제하는 네 가지 안전 규칙:
- 규칙1 — designated init은 자신의 클래스가 도입한 프로퍼티를 전부 초기화한 뒤에야 슈퍼클래스 초기화자로 위임할 수 있다.
- 규칙2 — designated init은 슈퍼클래스로 위임을 마친 뒤에야 상속받은 프로퍼티에 값을 대입할 수 있다. 그러지 않으면 방금 대입한 값이 슈퍼클래스 초기화 과정에서 덮어써진다.
- 규칙3 — convenience init은 다른 초기화자로 위임을 마친 뒤에야(같은 클래스가 정의한 프로퍼티라도) 어떤 프로퍼티든 대입할 수 있다.
- 규칙4 — 어떤 초기화자든 phase 1이 끝나기 전에는 인스턴스 메서드를 호출하거나, 인스턴스 프로퍼티를 읽거나, self를 값으로 참조할 수 없다.
내부 동작
이 네 규칙은 타입 체커가 아니라 SIL(Swift Intermediate Language) 단계의 Definite Initialization(DI) 진단 패스가 강제한다. swiftlang/swift 저장소엔 이 패스가 lib/SILOptimizer/Mandatory/DefiniteInitialization.cpp라는 이름으로 "Mandatory"(항상 실행되는) 패스 디렉터리에 실제로 존재하고, 각 진단 문구는 include/swift/AST/DiagnosticsSIL.def에 정의돼 있다. 직접 확인해보면 재미있는 사실이 하나 나온다 — 아래 다섯 개 위반 코드를 swiftc -typecheck로만 돌리면 전부 에러 없이 통과하고, SILGen까지 진행하는 일반 컴파일(swiftc file.swift -o out)에서야 비로소 진단이 뜬다. DI가 이름 그대로 SIL을 만든 다음에 도는 패스이기 때문이다.
class Vehicle {
var wheels: Int
init(wheels: Int) { self.wheels = wheels }
}
class Car: Vehicle {
var brand: String
init(brand b: String) {
super.init(wheels: 4) // ❌ brand를 아직 초기화하지 않은 채 위로 위임
self.brand = b
}
}
// 실제 컴파일 에러 (swiftc 전체 빌드, DiagnosticsSIL.def의
// ivar_not_initialized_at_superinit):
// error: property 'self.brand' not initialized at super.init callclass Vehicle {
var wheels: Int
init() { self.wheels = 4 }
}
class Car: Vehicle {
var brand: String
init(brand: String) {
self.brand = brand
self.wheels = 6 // ❌ 아직 super.init을 안 불렀는데 상속 프로퍼티를 건드림
super.init()
}
}
// 실제 컴파일 에러 (self_use_before_fully_init):
// error: 'self' used in property access 'wheels' before 'super.init' callclass Car {
var brand: String
init(brand: String) { self.brand = brand }
convenience init() {
self.brand = "Unknown" // ❌ self.init으로 위임하기 전에 프로퍼티 대입
self.init(brand: "Tesla")
}
}
// 실제 컴파일 에러 (self_before_selfinit_value_type):
// error: 'self' used before 'self.init' call or assignment to 'self'// (a) 아직 저장 프로퍼티가 다 안 채워졌는데 인스턴스 메서드 호출
class Car {
var brand: String
init(brand: String) {
self.describe() // ❌ brand 초기화 전에 메서드 호출
self.brand = brand
}
func describe() { print("brand =", brand) }
}
// error: 'self' used in method call 'describe' before all stored
// properties are initialized
// (b) super.init 위임 전에 self를 값으로 참조
class Vehicle2 {
var wheels: Int
init(wheels: Int) { self.wheels = wheels }
}
class Car2: Vehicle2 {
var brand: String
init(brand: String) {
print(self) // ❌ 위로 위임하기 전에 self를 값으로 씀
self.brand = brand
super.init(wheels: 4)
}
}
// error: 'self' used before 'super.init' call실험 · 도구
위 다섯 스니펫은 전부 실제로 손으로 컴파일해서 나온 진단이다. 직접 재현하려면 아래처럼 두 단계로 비교해보라 — -typecheck는 통과하는데 전체 빌드에서만 에러가 뜨는 걸 직접 눈으로 봐야 이 규칙이 "SIL 단계"에서 강제된다는 말이 체감된다.
swiftc -typecheck rule1.swift
# 종료 코드 0 — 에러 없음 (SIL이 아직 안 만들어졌으니 DI 패스도 안 돎)
swiftc rule1.swift -o /tmp/rule1
# error: property 'self.brand' not initialized at super.init call프로젝트 적용
① 순서를 "자기 프로퍼티부터, 그다음 위임"으로 고정하면 네 규칙을 전부 자연스럽게 지키게 된다.
class Car: Vehicle {
var brand: String
init(brand: String) {
self.brand = brand // ✅ 먼저 내 프로퍼티부터 (규칙1)
super.init(wheels: 4) // ✅ 그 다음 위로 위임 (규칙2)
}
}② "에디터에서는 빨간 줄이 안 뜨는데 ⌘B를 누르면 갑자기 에러가 난다"는 흔한 경험은 위 실험과 맥이 닿아 있다. 타입 체크 결과만 빠르게 보여주는 경로에서는 이 네 가지 진단이 SIL이 만들어지기 전이라 늦게 나타날 수 있다는 걸 감안하고, 초기화자 순서는 항상 안전 규칙대로 짜는 습관을 들이는 편이 좋다.
"two-phase init 규칙은 스타일 가이드 수준이라 우회할 방법이 있을 것"이라는 생각은 틀렸다 — 넷 다 하드 컴파일 에러이고 정상적인 방법으로는 우회가 안 된다. 반대로 "규칙 위반은 항상 즉시 에디터에 빨간 줄로 뜬다"도 항상 맞는 건 아니다 — 위에서 보듯 -typecheck만으로는 안 잡힌다.
집을 지을 때를 생각해보자. 골조(내 프로퍼티)부터 다 세우고 나서야 옆집(슈퍼클래스)에 "저 이제 골조 다 세웠어요"라고 알릴 수 있다(규칙1). 옆집도 자기 골조를 다 세우기 전엔 내가 빌린 방(상속 프로퍼티)에 가구를 놓을 수 없다(규칙2). 그리고 집 전체 골조가 다 세워지기 전엔 그 집 주소(self)로 아무도 초대할 수 없다(규칙4). 비유가 깨지는 곳: 현실에서 골조 순서를 안 지켜도 어찌어찌 집은 지어지지만, Swift는 순서를 안 지키면 애초에 집이 안 지어진다 — 컴파일 자체가 실패하니 훨씬 엄격하다.
꼬리 질문
구조체(값 타입)에도 이 4가지 안전 규칙이 똑같이 적용되는가?
self.init으로 다른 초기화자를 부를 순 있지만 그건 그냥 하나의 초기화 흐름일 뿐, 위로 위임하는 클래스 계층 자체가 없어서 규칙1·2·4가 적용될 대상이 없다(규칙3의 "convenience"라는 구분도 값 타입엔 없다). 그래서 Q2에서 다루듯 struct extension init은 조건부로 self를 직접 건드릴 수도 있다.슈퍼클래스의 designated init이 async라면 이 안전 규칙이 어떻게 달라지나?
super.init() 호출에 await을 명시적으로 붙여야 한다 — TSPL도 "동기, 0개 인자 designated init"에 한해서만 암묵적 호출 생략을 허용한다고 못박는다. 안전 규칙 자체(1~4)는 그대로 적용되고, await 지점에서 suspend되는 동안 다른 태스크가 self를 관찰할 위험도 없다 — phase 1이 끝나기 전엔 애초에 self가 노출될 방법이 없기 때문이다.왜 규칙3은 "같은 클래스가 정의한 프로퍼티라도"라고 굳이 강조하나?
Q2. 클래스 extension에는 왜 convenience init만 추가할 수 있는가?
Extension은 새 저장 프로퍼티를 추가할 수 없다 — 이것이 유일한 진짜 이유다. designated init은 phase 1에서 자기 클래스가 도입한 저장 프로퍼티 전부를 책임지고 초기화해야 하는데, extension은 그 저장소를 새로 늘릴 방법이 없는 별도 구문 단위다. 그래서 extension에 쓸 수 있는 초기화자는 "이미 존재하는 designated init에 위임만 하는" convenience init뿐이다. 값 타입(struct/enum)은 사정이 달라서, 같은 모듈 안이라면 extension에서도 저장 프로퍼티를 직접 대입하는 init을 쓸 수 있다.
원리
TSPL Extensions는 이렇게 못박는다 — "Extensions can add new convenience initializers to a class, but they can't add new designated initializers or deinitializers to a class. Designated initializers and deinitializers must always be provided by the original class implementation." 그리고 바로 앞 문단엔 이런 조건이 있다 — "Extensions can add new computed properties, but they can't add stored properties." 이 두 문장을 이으면 답이 나온다. designated init은 저장 프로퍼티 초기화를 책임지는데, extension은 애초에 저장 프로퍼티를 추가할 방법이 없으니 그 책임을 질 수가 없다.
내부 동작
class Car {
var brand: String
init(brand: String) { self.brand = brand }
}
extension Car {
init() { // ❌ extension에서 designated init 선언 시도
self.brand = "Unknown"
}
}
// 실제 컴파일 에러:
// error: designated initializer cannot be declared in an extension of
// 'Car'; did you mean this to be a convenience initializer?class Car {
var brand: String
init(brand: String) { self.brand = brand }
}
extension Car {
var year: Int = 2024 // ❌ 저장 프로퍼티를 extension에 추가하려는 시도
}
// 실제 컴파일 에러:
// error: extensions must not contain stored properties흥미로운 사실 하나 — 이 제약은 "지금 그 클래스에 프로퍼티가 있는지"를 보는 동적 검사가 아니라 무조건적인 구조적 규칙이다. 저장 프로퍼티가 하나도 없는 빈 클래스를 확장해도 결과는 같다.
class Empty {}
extension Empty {
init(dummy: Int) { // ❌ 프로퍼티가 없어도 여전히 에러
print("dummy init", dummy)
}
}
// error: designated initializer cannot be declared in an extension of
// 'Empty'; did you mean this to be a convenience initializer?값 타입은 사정이 다르다. 같은 모듈 안에서라면 struct extension init이 저장 프로퍼티를 직접 대입해도 되고, 그러면서도 원본의 memberwise init이 사라지지 않는다(원본 정의에 커스텀 init을 직접 추가하면 memberwise init을 잃지만, extension에 추가하면 그대로 남는다).
struct PointB {
var x: Int
var y: Int
}
extension PointB {
init(diagonal: Int) { self.x = diagonal; self.y = diagonal } // ✅ 직접 대입
}
let b1 = PointB(x: 1, y: 2) // memberwise init 여전히 사용 가능
let b2 = PointB(diagonal: 5) // extension init도 함께 사용 가능
print(b1, b2)
// 실행 결과: PointB(x: 1, y: 2) PointB(x: 5, y: 5)단, PointB가 다른 모듈에서 정의된 타입이라면 얘기가 달라진다. TSPL은 이 경우를 따로 짚는다 — "If you use an extension to add an initializer to a structure that was declared in another module, the new initializer can't access self until it calls an initializer from the defining module." 모듈 경계 밖에서는 저장 레이아웃이 앞으로도 그대로일 거라 보장할 수 없기 때문이다.
| 확장 대상 | 추가 가능한 init | 이유 |
|---|---|---|
| class | convenience init만 | designated init은 저장 프로퍼티 phase 1 책임을 지므로 원본 정의 안에서만 가능 |
| struct/enum (같은 모듈) | 저장 프로퍼티를 직접 대입하는 init도 가능 | 값 타입엔 designated/convenience 구분 자체가 없음 — memberwise init도 그대로 유지 |
| struct(다른 모듈에서 정의) | self 직접 대입 불가 — 원본 모듈의 init에 먼저 위임 | 모듈 경계 밖에선 저장 레이아웃을 안전하게 보장할 수 없음 |
실험 · 도구
위 스니펫은 전부 swiftc file.swift -o /tmp/out으로 그대로 재현된다. 이번엔 Q1과 달리 -typecheck만으로도 똑같이 잡힌다 — 초기화 순서를 흐름 분석해야 하는 DI 패스와 달리, 이건 "이 선언이 이 위치에 있어도 되는가"라는 정적 선언 검사라 SIL을 만들 필요가 없다.
프로젝트 적용
① 프로덕션 designated init과 테스트/프리뷰 전용 convenience init을 파일 단위로 분리하면 코드 리뷰에서 "이 이니셜라이저가 실제 서비스 경로인가"를 한눈에 구분할 수 있다.
// Car.swift — 프로덕션 designated init만
final class Car {
let brand: String
let year: Int
init(brand: String, year: Int) {
self.brand = brand
self.year = year
}
}
// Car+Preview.swift — SwiftUI 프리뷰 전용 convenience init을 별도 extension으로 분리
#if DEBUG
extension Car {
convenience init(mock: Bool = true) {
self.init(brand: "미리보기용", year: 2024)
}
}
#endif② 다른 모듈(예: Foundation)의 값 타입을 확장할 땐 self를 직접 대입하려다 컴파일 에러를 만나는 대신, 처음부터 원본 init에 위임하는 패턴으로 짠다.
extension CGPoint {
init(unitSquareCorner corner: Int) {
// ❌ self.x = ... 직접 대입은 다른 모듈 타입에선 불가능
// ✅ 반드시 원본이 제공하는 init에 먼저 위임한다
self.init(x: CGFloat(corner % 2), y: CGFloat(corner / 2))
}
}"extension은 아무 init이나 자유롭게 추가할 수 있다"는 클래스 기준으론 틀렸다 — convenience init만 가능하다. 반대로 "struct/enum extension init은 아무 제약이 없다"도 절반만 맞다 — 같은 모듈이면 맞지만, 다른 모듈에서 정의된 타입을 확장할 땐 반드시 원본 init에 위임부터 해야 한다.
레고 설명서(원본 클래스 정의)에는 "처음부터 새로 조립하는 법"(designated init)이 적혀 있고, 나중에 산 확장팩 설명서(extension)에는 "이미 조립된 것에 장식을 더하는 법"(convenience init)만 적을 수 있다. 확장팩만 보고는 애초에 블록이 몇 개 필요한지(저장 프로퍼티 목록)조차 알 수 없기 때문이다. 비유가 깨지는 곳: 레고는 그래도 억지로 확장팩 설명서에 처음부터 조립하는 법을 끼워 넣을 수 있지만, Swift는 그 자체를 컴파일 에러로 원천 봉쇄한다.
꼬리 질문
프로토콜 extension에도 이 제약이 똑같이 적용되나?
같은 파일 안에서 원본 클래스 정의와 extension을 나눠 써도 designated init을 extension 쪽에 넣을 수 있나?
class Car { }와 extension Car { }를 나란히 써도, designated init은 반드시 class Car { } 블록 자체에 있어야 하고 extension Car { } 블록엔 들어갈 수 없다.원본 struct 정의에 커스텀 init을 추가하는 것과 extension에 추가하는 것, 왜 memberwise init의 운명이 갈리나?
Q3. designated / convenience / required / failable init은 각각 언제 쓰는가?
넷은 서로 다른 축의 개념이다. designated는 그 클래스가 도입한 프로퍼티 전부를 완전히 책임지는 정식 경로이며 위로만 위임한다. convenience는 같은 클래스의 다른 init에 위임해 결국 designated에 도달하는 지름길이다. required는 위임 방향과 무관하게 "서브클래스가 반드시 이 초기화 경로를 제공해야 한다"는 계약이고, 주로 프로토콜 준수·팩토리 패턴·NSCoder 기반 UIKit 타입에서 강제된다. failable(init?)은 입력이 실패할 수 있는 초기화이며, 실패 시 크래시 대신 nil을 돌려준다.
원리
TSPL의 위임 규칙은 간결하다 — "Designated initializers must always delegate up. Convenience initializers must always delegate across." 그리고 자동 상속 규칙 둘도 함께 알아야 실무에서 겪는 "왜 아무것도 안 썼는데 초기화자가 그냥 되지"가 이해된다.
- Rule 1 — 서브클래스가 designated init을 하나도 정의하지 않으면, 슈퍼클래스의 designated init을 전부 자동으로 상속한다.
- Rule 2 — 서브클래스가 슈퍼클래스의 designated init 전부를(Rule 1로 상속했든 직접 구현했든) 갖추고 있으면, 슈퍼클래스의 convenience init까지 전부 자동으로 상속한다.
| 종류 | 위임 방향 | 언제 쓰나 |
|---|---|---|
| designated | ↑ 위로 (그 클래스엔 반드시 1개 이상 필요) | 그 클래스가 도입한 프로퍼티 전부를 완전히 책임지는 정식 경로 |
| convenience | → 가로 (같은 클래스의 다른 init에 위임, 결국 designated 도달) | 자주 쓰는 기본값 조합을 짧게 제공(파라미터 없는 버전 등) |
| required | 위임 방향과 무관 — "서브클래스는 반드시 구현/상속" 강제 | 프로토콜 준수, 팩토리 베이스 클래스, NSCoder 기반 UIKit 타입 |
failable(init?) | 위임 방향과 무관 — 실패 시 nil | 입력 검증이 필요한 파싱/변환(raw value, 문자열 → 모델 등) |
내부 동작
class Base {
required init(x: Int) { print(x) }
}
class Sub: Base {
var y: Int = 0
init(y: Int) { self.y = y; super.init(x: 0) }
// required init(x:)를 재구현하지 않음
}
// 실제 컴파일 에러:
// error: 'required' initializer 'init(x:)' must be provided by
// subclass of 'Base'
// note: 'required' initializer is declared in superclass hereclass Base {
required init(x: Int) { print("Base", x) }
}
class Sub: Base {
// 새 저장 프로퍼티도, 새 designated init도 없음 → Rule 1로 자동 상속
}
let s = Sub(x: 10)
// 실행 결과: Base 10 ← 아무것도 안 썼는데 required init이 그대로 동작실험 · 도구
위 두 스니펫을 swiftc file.swift -o /tmp/out으로 나란히 돌려보면 "서브클래스가 슈퍼클래스 designated init을 하나라도 새로 만들면 자동 상속이 끊기고, 그 즉시 required 요건을 직접 채워야 한다"는 경계가 명확히 드러난다.
프로젝트 적용
① 의존성 주입은 designated로, 자주 쓰는 기본값은 convenience로 감싼다.
final class NetworkClient {
let session: URLSession
let baseURL: URL
init(session: URLSession, baseURL: URL) { // designated — 테스트에서 목(mock) 세션 주입
self.session = session
self.baseURL = baseURL
}
convenience init(baseURL: URL) { // convenience — 실무 기본값
self.init(session: .shared, baseURL: baseURL)
}
}② UIView/UIViewController 서브클래스에서 required init?(coder:)를 빠뜨리면 스토리보드/XIB 경로가 즉시 컴파일 에러를 낸다 — 실무에서 가장 자주 마주치는 required init 사례다.
final class CustomView: UIView {
let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) { // ✅ 스토리보드/XIB 경로도 반드시 구현
super.init(coder: coder)
setup()
}
private func setup() { addSubview(titleLabel) }
}③ 입력 검증이 필요한 초기화는 failable init으로 만들어 크래시 대신 옵셔널로 실패를 표현한다.
struct Rating {
let stars: Int
init?(stars: Int) {
guard (0...5).contains(stars) else { return nil } // ✅ 크래시 대신 nil
self.stars = stars
}
}
let ok = Rating(stars: 4) // Optional(Rating(stars: 4))
let bad = Rating(stars: 9) // nil — 크래시 없이 실패를 표현"required는 서브클래스가 그 init을 반드시 오버라이드해야 한다는 뜻"은 정확하지 않다 — 정확히는 "서브클래스가 어떤 방식으로든(직접 구현이든, 자동 상속이든) 그 초기화 경로를 제공해야 한다"는 뜻이다. 위 대조 실험처럼 서브클래스가 새 저장 프로퍼티나 새 designated init을 안 만들면, 아무것도 안 써도 자동 상속으로 요건이 채워진다.
convenience init은 카페의 "아메리카노 사이즈업 세트"다 — 결국 계산대(designated init)에서 정식으로 값을 다 치러야 나온다. required init은 프랜차이즈 본사가 "이 레시피는 지점마다 반드시 있어야 한다"고 정한 필수 메뉴다. failable init은 "재료가 없으면 주문 자체를 안 받는다"(nil)는 규칙이다. 비유가 깨지는 곳: 카페 세트 메뉴는 매장마다 다르게 만들어도 손님이 눈치 못 채지만, Swift는 convenience init이 정말로 designated init에 도달하는지 컴파일 타임에 끝까지 검증한다.
꼬리 질문
convenience init에서 다른 convenience init을 호출해도 되나, 아니면 반드시 designated로 바로 가야 하나?
같은 파라미터 시그니처로 failable init과 non-failable init을 동시에 정의할 수 있나?
init? 하나로 통일해야 한다.required convenience init도 가능한가?
required convenience init()을 선언하고 서브클래스가 슈퍼클래스의 designated init을 전부 오버라이드해 갖추고 있으면(자동 상속 Rule 2), 서브클래스 쪽엔 아무 선언 없이도 그 required convenience init이 그대로 상속되어 동작한다. "required"는 위임 종류(designated/convenience)와는 독립적인 "서브클래스 필수 제공" 표시일 뿐이다.Q4. static let은 스레드 세이프한데 lazy var는 왜 아닌가? — swift_once 메커니즘
static let(그리고 전역 let, 타입의 저장 프로퍼티)은 컴파일러가 "1회성 초기화 토큰 + swift_once(개념적으로 Objective-C의 dispatch_once와 동일한 계약) 런타임 호출"을 자동으로 심어준다 — 예전에 수동으로 짜던 dispatch_once 싱글턴 패턴을 언어가 대신해주는 것이다. 그래서 여러 스레드가 동시에 최초 접근해도 초기화 블록은 정확히 한 번만 실행되고 모든 스레드가 같은 결과를 본다. 반대로 lazy var는 TSPL이 "여러 스레드가 동시에 접근하고 아직 초기화 전이라면, 딱 한 번만 초기화된다는 보장이 없다"고 명시한다 — 실제로는 백킹 저장소가 nil이면 계산해서 채우고 아니면 그대로 반환하는 단순 분기라 그 자체가 원자적이지 않기 때문이다. static var는 그 중간이다 — 최초 초기화는 swift_once로 원자적이지만, 그 이후의 변경(mutation)은 전혀 보호되지 않는다.
원리
TSPL Properties는 타입 저장 프로퍼티에 대해 이렇게 보장한다 — "Stored type properties are lazily initialized on their first access. They're guaranteed to be initialized only once, even when accessed by multiple threads simultaneously, and they don't need to be marked with the lazy modifier." 반면 인스턴스의 lazy var에는 정확히 반대되는 경고를 붙인다 — "If a property marked with the lazy modifier is accessed by multiple threads simultaneously and the property hasn't yet been initialized, there's no guarantee that the property will be initialized only once."
여기서 꼭 구분해야 할 개념이 하나 있다 — 원자성(atomicity)은 스레드 세이프티(thread-safety)와 다르다. 원자성은 "값이 반쯤 쓰인 채로 관찰되지 않는다"만 보장한다. static let이 완전히 안전한 이유는 "초기화가 원자적"이라는 것과 "그 뒤로 값이 절대 안 바뀐다(let)"는 것, 두 조건이 함께 있기 때문이다. static var는 앞의 조건만 있고 뒤의 조건이 없어서 반쪽짜리 안전만 갖는다.
내부 동작
SIL을 직접 뽑아보면 이 차이가 코드 레벨에서 그대로 드러난다. static let의 접근자에는 builtin "once" 명령이 들어간다 — 이게 바로 swift_once 런타임 호출이 SIL에 남기는 흔적이다.
// one-time initialization token for shared
sil_global private @$s...C6shared_Wz : $Builtin.Word
// one-time initialization function for shared
sil private [global_init_once_fn] @$s...C6shared_WZ
: $@convention(c) (Builtin.RawPointer) -> () {
bb0(%0 : $Builtin.RawPointer):
alloc_global @$s...C6sharedACvpZ
%2 = global_addr @$s...C6sharedACvpZ : $*Service
%3 = metatype $@thick Service.Type
%4 = function_ref @$s...init...
%5 = apply %4(%3) : $@convention(method) (@thick Service.Type) -> @owned Service
store %5 to %2
return
}
// Service.shared.unsafeMutableAddressor
sil hidden [global_init] @$s...C6sharedACvau : $@convention(thin) () -> Builtin.RawPointer {
bb0:
%0 = global_addr @$s...C6shared_Wz : $*Builtin.Word
%1 = address_to_pointer %0 to $Builtin.RawPointer
%2 = function_ref @$s...C6shared_WZ
%3 = builtin "once"(%1, %2) : $Builtin.SILToken // ← swift_once 호출이 바로 이 줄
%4 = global_addr @$s...C6sharedACvpZ : $*Service depends_on %3
return
}같은 실험을 lazy var로 해보면 builtin "once"가 아예 없다. 백킹 저장소(옵셔널)를 읽어서 switch_enum으로 nil인지 검사하고, nil이면 계산해서 store하는 게 전부다 — 락도, 토큰도 없다.
sil hidden [lazy_getter] [noinline] @$s...C5valueSivg
: $@convention(method) (@guaranteed Box) -> Int {
bb0(%0 : $Box):
%2 = ref_element_addr %0, #Box.$__lazy_storage_$_value
%3 = begin_access [read] [dynamic] %2
%4 = load %3
end_access %3
switch_enum %4, case #Optional.some!enumelt: bb1, case #Optional.none!enumelt: bb2
bb1(%7 : $Int):
br bb3(%7) // 이미 값이 있으면 그냥 반환
bb2: // 값이 없으면(nil) — 락 없이 곧바로 계산
%9 = function_ref @$s...closure...
%10 = apply %9() : $@convention(thin) () -> Int
%11 = enum $Optional<Int>, #Optional.some!enumelt, %10
%12 = ref_element_addr %0, #Box.$__lazy_storage_$_value
%13 = begin_access [modify] [dynamic] %12
store %11 to %13 // 계산 결과를 저장 — 두 스레드가 동시에 여기 도달하면 경쟁한다
end_access %13
br bb3(%10)
bb3(%17 : $Int):
return %17
}실험 · 도구
말로만 설명하지 않고 직접 돌려서 확인했다. DispatchQueue.concurrentPerform으로 50개 동시 작업이 처음으로 lazy var에 접근하게 하면, 초기화 클로저가 매번 다른 횟수만큼 실행된다.
import Dispatch
final class Box {
lazy var value: Int = {
print("C", terminator: "") // 계산될 때마다 한 글자씩 찍는다
return 42
}()
}
let box = Box()
DispatchQueue.concurrentPerform(iterations: 50) { _ in
_ = box.value
}
print()
// 실행 결과 (같은 코드를 3번 반복, 매번 다르다):
// CCCCCCCCCCCCCC (14번 계산됨)
// CCCCCCCCCCC (11번 계산됨)
// CCCCCCCCCCC (11번 계산됨)
// 50번 다 캐시가 안 먹은 것도, 그렇다고 매번 50번인 것도 아니다 — 순수한 레이스 컨디션이다.import Dispatch
enum Holder {
static let value: Int = {
print("C", terminator: "")
return 42
}()
}
DispatchQueue.concurrentPerform(iterations: 50) { _ in _ = Holder.value }
print()
// 실행 결과: 5회 반복 모두 "C" 딱 한 번만 찍힌다import Dispatch
enum Counter {
static var count = 0 // 최초 초기화(0)는 swift_once로 안전
}
DispatchQueue.concurrentPerform(iterations: 10_000) { _ in
Counter.count += 1 // read-modify-write, 원자적이지 않다
}
print("최종 count =", Counter.count, "(기대값 10000)")
// 실행 결과 (3회 반복):
// 최종 count = 2607 (기대값 10000)
// 최종 count = 2551 (기대값 10000)
// 최종 count = 2492 (기대값 10000)
// 초기화 자체(count = 0)는 정확히 1번 됐지만, += 1이 서로의 결과를 덮어쓰며
// 절반 넘게 유실됐다 — 이게 "atomic ≠ thread-safe"의 실제 증거다.이 마지막 레이스는 Thread Sanitizer로도 형식적으로 잡히는 종류다 — swiftc -sanitize=thread staticvarrace.swift -o race && ./race로 빌드하면 두 스레드가 Counter.count의 같은 메모리 주소를 락 없이 read/write한다는 data race 리포트가 뜬다(이 문서를 쓴 환경에서는 TSan 런타임 자체가 dyld 로딩 단계에서 죽어 리포트를 그대로 옮겨 담지는 못했지만, 위 카운터 손실 결과 자체가 이미 레이스의 물증이다 — 기대값 10000과의 차이가 매번 다르다는 것 자체가 "비결정적 동시 접근"의 정의다).
프로젝트 적용
① 싱글턴은 무조건 static let으로 만든다. 수동 락은 이미 컴파일러가 해주는 일을 다시 손으로 짜는 것뿐이다.
final class ImageDownloader {
static let shared = ImageDownloader() // ✅ swift_once가 알아서 스레드 세이프하게 처리
private init() {}
}
// ❌ 이렇게 굳이 다시 만들 필요 없다 — 컴파일러가 이미 해준다
// private static var _shared: ImageDownloader?
// private static let lock = NSLock()
// static var shared: ImageDownloader { lock.lock(); defer { lock.unlock() }; ... }② 여러 스레드/큐에서 건드릴 가능성이 있는 인스턴스 프로퍼티는 lazy var 대신 actor로 격리해 "동시 접근" 자체를 원천 차단한다.
// ❌ 백그라운드 큐에서도 접근 가능한 인스턴스에 lazy var를 두면 레이스 위험
final class ReportBuilder {
lazy var formatter = makeExpensiveFormatter()
}
// ✅ actor로 격리 — 한 번에 한 태스크만 접근 가능해 레이스 자체가 없다
actor ReportBuilder2 {
private var cached: DateFormatter?
var formatter: DateFormatter {
if let f = cached { return f }
let f = makeExpensiveFormatter()
cached = f
return f
}
}
func makeExpensiveFormatter() -> DateFormatter { DateFormatter() }③ static var로 공유 가변 상태(카운터, 캐시 등)를 둬야 한다면 값을 직접 흩뿌리지 말고 actor 메서드를 통해서만 바뀌도록 강제한다.
actor CounterActor {
private(set) var count = 0
func increment() { count += 1 }
}
// Counter.count += 1을 코드 곳곳에 흩뿌리는 대신,
// 반드시 counterActor.increment() 호출을 통해서만 값이 바뀌게 한다"static이니까 무조건 스레드 세이프하다"는 절반만 맞다 — static let은 맞지만 static var는 "최초 초기화만" 원자적이다. 그리고 "원자적(atomic)이면 스레드 세이프(thread-safe)하다"도 틀렸다 — 원자성은 "값이 반쯤 쓰인 채로 관찰되지 않는다"만 보장하고, 스레드 세이프는 그 이후의 모든 동시 접근 순서에도 결과가 일관됨을 보장해야 한다.
화장실 문에 자동 잠금장치(swift_once)가 달려 있다고 생각해보자. 첫 번째 사람이 들어가면 문이 잠기고, 그 사람이 볼일을 다 볼 때까지 다른 사람은 밖에서 기다린다 — 그리고 나오면 그 다음부턴 그냥 "이미 비었음" 팻말만 보고 들어간다(재실행 없음). lazy var는 이 잠금장치가 아예 없는 화장실이다 — 문에 "비어있음" 팻말만 걸려 있고, 두 사람이 동시에 팻말을 보고 동시에 들어가 버릴 수 있다. 비유가 깨지는 곳: 화장실은 두 사람이 부딪히면 바로 알아채지만, lazy var의 레이스는 겉으로 티가 안 나고 계산이 조용히 여러 번 실행되거나 값이 미묘하게 어긋나는 식으로만 드러난다.
꼬리 질문
Swift 3 이전엔 dispatch_once를 직접 썼는데 지금은 왜 쓰면 안 되나?
dispatch_once 토큰 기반 API 자체가 사실상 폐기됐다 — 언어가 static let/전역 let에 이미 동일한 swift_once 보장을 자동으로 심어주기 때문에, 수동 dispatch_once 싱글턴 패턴은 그냥 static let shared = Foo() 한 줄로 대체된다. 굳이 예전 API를 쓰면 컴파일러가 대신해주는 안전장치를 손으로 다시 짜는 셈이라 실수 여지만 늘어난다.actor의 프로퍼티는 lazy var라도 스레드 세이프한가?
lazy var는 두 태스크가 동시에 그 getter에 진입할 수 없다. 다만 await 지점을 지나는 동안 재진입(reentrancy)이 가능하다는 점은 별개 주의사항이다.왜 언어는 lazy var에도 그냥 static처럼 동기화를 넣어주지 않았나?
lazy var마다 동기화 원시 연산(락/once 토큰)을 심으면, 애초에 스레드 경합이 없는 대다수 인스턴스에서도 모든 접근마다 동기화 비용을 치르게 된다. 인스턴스는 static/전역과 달리 무한히 만들어질 수 있어 인스턴스마다 once 토큰을 따로 들고 다니는 비용도 무시할 수 없다. 그래서 언어는 "대부분 단일 스레드에서 쓰인다"는 흔한 경우를 빠르게 만들고, 여러 스레드에서 쓰는 드문 경우엔 개발자가 직접 락/actor로 보호하도록 책임을 넘겼다.Q5. deinit은 정확히 언제 호출되며, 그 안에서 하면 안 되는 일은?
deinit은 인스턴스의 마지막 강한 참조가 사라져 참조 카운트가 0이 되는 순간, 메모리가 실제로 회수되기 직전에 자동 호출된다 — 직접 호출은 불가능하고 throws도 붙일 수 없다. 클래스 계층에서는 서브클래스의 deinit이 먼저 실행되고, 그 끝에서 자동으로 슈퍼클래스의 deinit이 실행된다(슈퍼클래스가 deinit을 안 만들어도 상속되어 항상 실행된다). 안에서 하면 안 되는 일은 크게 둘이다 — (1) self를 어딘가에 몰래 남겨 "부활"시키려는 시도(deinit이 끝나면 메모리는 무조건 회수되므로 이후 접근은 use-after-free), (2) 정리해야 할 타이머·옵저버가 오히려 self를 강하게 붙잡고 있어서 참조 카운트가 영원히 0이 안 되는 경우(이러면 deinit 자체가 안 불린다).
원리
TSPL Deinitialization은 순서를 명확히 못박는다 — "Superclass deinitializers are inherited by their subclasses, and the superclass deinitializer is called automatically at the end of a subclass deinitializer implementation. Superclass deinitializers are always called, even if a subclass doesn't provide its own deinitializer." 그리고 deinit 안에서 프로퍼티에 접근해도 되는 이유도 함께 설명한다 — "Because an instance isn't deallocated until after its deinitializer is called, a deinitializer can access all properties of the instance it's called on."
내부 동작
이 순서를 실제 클래스 계층으로 확인하면 그대로 나온다.
class Animal {
let name: String
init(name: String) { self.name = name }
deinit { print("Animal(\(name)) deinit") }
}
class Dog: Animal {
let breed: String
init(name: String, breed: String) {
self.breed = breed
super.init(name: name)
}
deinit { print("Dog(\(breed)) deinit") }
}
var d: Dog? = Dog(name: "바둑이", breed: "진돗개")
d = nil
// 실행 결과:
// Dog(진돗개) deinit
// Animal(바둑이) deinit더 위험한 건 "부활" 시도다. deinit이 끝나는 순간 메모리는 무조건 회수 대상이 되므로, deinit 안에서 self를 몰래 다른 곳에 남겨두면 그 뒤의 접근은 use-after-free다. 그리고 use-after-free는 정의되지 않은 동작(UB)이라, 증상이 일정하지 않다 — 아래 코드를 같은 기계에서 빌드 방식만 바꿔 돌리면 결과가 셋으로 갈린다.
final class Zombie {
let tag: Int
init(tag: Int) { self.tag = tag }
deinit {
print("deinit 시작, tag=\(tag)")
// ❌ deinit 안에서 self를 전역에 몰래 남겨 "부활"을 시도
Graveyard.leaked = Unmanaged.passUnretained(self).toOpaque()
print("deinit 끝 — 이 시점부터 메모리는 회수 대상")
}
}
enum Graveyard { static var leaked: UnsafeMutableRawPointer? }
do {
let z = Zombie(tag: 7)
_ = z
} // 스코프 종료 — 여기서 deinit이 이미 실행됨
if let ptr = Graveyard.leaked {
let revived = Unmanaged<Zombie>.fromOpaque(ptr).takeUnretainedValue()
print("부활 시도: tag =", revived.tag) // 정의되지 않은 동작
}
// ── 같은 코드, 같은 기계(Swift 6.2.1 / arm64 / macOS 26). 빌드만 바꿔 실행한 결과 ──
//
// ① swift zombie.swift (인터프리터, 5회 반복)
// → 부활 시도: tag = 0 크래시 없음, 종료 코드 0
//
// ② swiftc zombie.swift (디버그 빌드)
// → tag 줄을 출력하지도 못하고 크래시 종료 코드 133 / 139
//
// ③ swiftc -O zombie.swift (릴리스 빌드, 3회 반복)
// → 부활 시도: tag = 7 원래 값이 멀쩡히 보인다. 크래시 없음, 종료 코드 0
//
// ③이 가장 위험하다. "돌려봤더니 잘 되던데요"가 나오는 경로이기 때문이다.
// UB는 '반드시 터진다'는 뜻이 아니라 '무엇이든 일어날 수 있다'는 뜻이다 —
// 값이 멀쩡해 보이는 것도 그 '무엇이든'에 포함된다.실험 · 도구
위 다이어그램이 실제로 벌어지는지, Timer를 직접 만들어서 확인했다.
final class Ticker {
var timer: Timer?
let name: String
init(name: String) {
self.name = name
// ❌ 클로저가 self를 강하게 캡처 + self가 timer를 강하게 들고 있음 → 사이클
self.timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in
print("\(self.name) tick")
}
}
deinit { print("Ticker(\(name)) deinit 호출됨") }
}
var ticker: Ticker? = Ticker(name: "A")
RunLoop.current.add(ticker!.timer!, forMode: .common)
ticker = nil // 마지막 강한 참조를 없앴다
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
// 실행 결과: "Ticker(A) deinit 호출됨"이 단 한 번도 안 찍히고
// "A tick"만 계속 찍힌다 — 사이클 때문에 deinit이 영원히 안 불림final class Ticker {
var timer: Timer?
let name: String
init(name: String) {
self.name = name
// ✅ weak self로 캡처 — 사이클이 끊긴다
self.timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in
guard let self else { return }
print("\(self.name) tick")
}
}
deinit {
print("Ticker(\(name)) deinit 호출됨")
timer?.invalidate() // ✅ deinit에서도 한 번 더 확실히 무효화
}
}
var ticker: Ticker? = Ticker(name: "B")
RunLoop.current.add(ticker!.timer!, forMode: .common)
ticker = nil
// 실행 결과: "Ticker(B) deinit 호출됨"이 ticker = nil 하는 즉시 찍힌다프로젝트 적용
① 반복 타이머는 [weak self]만으로는 부족하다 — RunLoop가 타이머 자체를 계속 붙들고 있으므로 deinit에서도 반드시 invalidate()를 호출해 이중으로 정리한다(위 "고친 버전" 패턴을 그대로 쓴다).
② NotificationCenter 옵저버 토큰도 deinit에서 명시적으로 제거한다. 자세한 비교(블록 기반 옵저버, iOS 26의 타입 메시지 API)는 27장 옵저버에서 더 깊이 다룬다.
final class FeedViewController {
private var token: NSObjectProtocol?
init() {
token = NotificationCenter.default.addObserver(
forName: .someEvent, object: nil, queue: .main
) { _ in /* ... */ }
}
deinit {
if let token { NotificationCenter.default.removeObserver(token) }
}
}③ deinit에서 self를 캡처하는 비동기 작업을 새로 던지지 않는다 — 그 작업이 실행될 시점엔 self가 이미(혹은 곧) 해제된 메모리이기 때문이다. 지금 필요한 값만 미리 꺼내서 넘긴다.
final class Uploader {
var lastResult: String?
deinit {
// ❌ deinit에서 self를 캡처하는 Task를 새로 던지면
// 실행 시점엔 self가 이미 해제된 메모리일 수 있다
// Task { await self.reportDeallocation() }
// ✅ self가 아니라 '지금 필요한 값만' 미리 꺼내서 넘긴다
let snapshot = lastResult
Task.detached { AnalyticsQueue.log(snapshot) }
}
}
enum AnalyticsQueue { static func log(_ v: String?) {} }"deinit이 안 불리면 항상 리테인 사이클 때문이다"는 성급한 일반화다 — 진짜 원인은 "이제 아무도 안 써야 할 텐데도 어딘가 강한 참조가 남아있다"는 것이고, 그 "어딘가"가 사이클일 수도, 그냥 실수로 오래 들고 있는 배열/캐시일 수도 있다. 또 "deinit 안에서 self를 어딘가 저장해두면 그 순간부터 다시 살아난다"도 틀렸다 — 위 좀비 실험처럼 deinit이 끝나는 순간 메모리는 무조건 회수 대상이 되고, 그 뒤의 접근은 정의되지 않은 동작(크래시하거나 쓰레기값)일 뿐이다.
장례식(deinit)을 다 치르고 화장(메모리 회수)까지 끝났는데, 누군가 몰래 고인의 옷(포인터)을 챙겨 계속 입고 다니려는 것과 같다. 옷은 이미 없어졌으니 다음에 무슨 일이 생길지 아무도 모른다 — 우연히 같은 자리에 다른 옷이 걸려 있을 수도(쓰레기값), 아예 옷걸이가 뽑혀서 넘어질 수도 있다(크래시). 비유가 깨지는 곳: 현실의 장례식은 몰래 옷을 챙겨도 딱히 위험하지 않지만, Swift에서는 이게 "정의되지 않은 동작"이라 실행할 때마다 결과가 달라질 수 있는 훨씬 위험한 일이다.
꼬리 질문
deinit에 throws를 붙여서 정리 실패를 알릴 수 있나?
deinit throws { }는 아예 문법 에러다(직접 확인: "error: expected '{' for deinitializer"). deinit은 항상 성공한다고 가정되고, 실패할 수 있는 정리 작업(파일 닫기 등)은 deinit 밖에서 명시적인 close()/invalidate() 메서드로 미리 끝내두고, deinit은 그게 안 됐을 때의 마지막 안전망 정도로만 써야 한다.구조체(값 타입)에도 deinit이 있나?
~Copyable struct/enum은 예외로, Swift 5.9(SE-0390)부터 deinit을 가질 수 있게 됐다. 유일 소유권이 전제이므로 클래스의 "마지막 참조가 사라지면"과는 의미가 다르고, "소유자가 스코프를 벗어나는 순간"에 결정적으로 호출된다.Timer 사이클은 왜 [weak self]만으로는 100% 안전하지 않고 invalidate()도 같이 해야 하나?
[weak self]는 클로저가 self를 강하게 붙잡지 않게 해서 Ticker의 deinit이 제때 불리게는 해준다. 하지만 반복 타이머 자체는 RunLoop가 별도로 강하게 들고 있으므로, self가 해제된 뒤에도 타이머는 계속 살아서 매 간격마다 클로저를 실행한다(그 안에서 guard let self else { return }로 조용히 빠져나갈 뿐이다). 즉 invalidate()를 안 하면 "메모리 누수"는 피해도 "쓸모없는 타이머가 계속 돌아가는" 낭비는 남는다.[weak self]는 알람이 울려도 퇴사한 직원을 안 부르게 할 뿐이고, 알람 자체를 끄는 건 invalidate()다.