← iOS 개발자 CS 로드맵 33 / 35

33 · 런타임과 ABI

라이브러리 진화가 struct 레이아웃을 불투명하게 만드는 이유와 @frozen·@inlinable, throws의 전용 에러 레지스터와 실제 남는 명령, 비가상화가 성립하는 세 조건, 프로토콜 확장의 정적 바인딩 함정, package 접근 수준, swift_once와 lazy var의 보장 차이.
진행률
0 / 0 완료

32장이 타입 시스템이 무엇을 보장하는가였다면, 이 챕터는 그 아래층 — 컴파일러가 실제로 무엇을 생성하고 런타임이 무엇을 보장하는가다.

여기서 다루는 것들은 대부분 평소에는 안 보이다가 특정 조건에서 갑자기 문제가 된다. 프레임워크로 분리하는 순간 성능이 떨어지고, 프로토콜 타입으로 호출하는 순간 다른 구현이 돌고, 동시에 접근하는 순간 초기화가 여러 번 일어난다.

P0 14장에서 컴파일·링크·dyld 로드의 흐름을, P0 05장에서 Swift 런타임의 값·참조 표현을 봤다. 이 챕터는 그 둘이 만나는 자리 — ABI 경계를 다룬다.

Q1. 라이브러리 진화는 무엇을 고정하고 무엇을 대가로 치르는가?

🔑 30초 답변

클라이언트 재컴파일 없이 프레임워크를 교체할 수 있게 하는 대신, 필드 접근이 함수 호출이 된다. 일반 앱 타깃은 컴파일러가 모듈 전체를 한 번에 보므로 struct 필드가 고정 오프셋으로 직접 읽힌다. 그런데 -enable-library-evolution으로 빌드한 바이너리 안정 프레임워크는 레이아웃이 기본적으로 불투명하게 취급되어 게터 함수 호출을 거친다. SIL로 확인하면 명확하다 — @frozen이 없으면 function_ref Point.x.getter + apply(진짜 호출)이고, @frozen이 붙으면 struct_extract 한 줄로 끝난다. @frozen의 핵심은 성능이 아니라 ABI 계약이고, 직접 접근은 그 결과다.

CS 원리

ABI 안정성은 "컴파일 시점에 알던 것"과 "실행 시점에 실제인 것"이 달라질 수 있음을 전제로 한다.

일반적인 컴파일에서는 이 둘이 같다. 컴파일러가 struct Point { var x, y: Int }를 보면 x는 오프셋 0, y는 오프셋 8이라고 확정하고, 그 숫자를 기계어에 박는다.

그런데 프레임워크가 나중에 교체될 수 있다면 그 가정이 깨진다. 새 버전이 필드를 추가하거나 순서를 바꾸면 박아 둔 오프셋이 틀린 곳을 가리킨다. 그래서 라이브러리 진화 모드는 오프셋을 박지 않고 물어보게 만든다.

이것이 추상화의 대가다 — 유연성을 얻는 대신 간접 계층이 생긴다. 31장 Q7·32장 Q2에서 본 "이미 배포된 소비자가 제약을 만든다"는 원리가 여기서는 기계어 수준으로 나타난다.

탈출구가 명시적 약속이다.

둘 다 구현 세부를 계약으로 승격시키는 행위다. 성능을 얻는 대신 바꿀 자유를 판다.

클라이언트를 다시 컴파일하지 않아도 교체 가능해야 한다면 일반 앱 타깃 오프셋을 기계어에 박는다 struct_extract %0, #Point.x — 한 줄 라이브러리 진화 레이아웃이 불투명하다 function_ref …getter + apply — 진짜 호출 탈출구는 명시적 약속이고, 약속에는 대가가 있다 @frozen "레이아웃이 절대 안 바뀐다" 얻는 것 — 직접 필드 접근 잃는 것 — 프로퍼티 추가·재배치 불가 @inlinable 구현부를 공개 인터페이스로 얻는 것 — 인라인·특수화 잃는 것 — 구현을 바꿀 자유 일반 앱 타깃에서는 이 구분 자체가 무의미하다
둘 다 구현 세부를 계약으로 승격시킨다 — 성능을 사고 바꿀 자유를 판다.

iOS에서는

대부분의 앱 개발자에게 이 설정은 꺼져 있다. 문제가 되는 것은 프레임워크를 만들 때다.

// 프레임워크 안 — 라이브러리 진화가 켜져 있다면
public struct Point {
    public var x: Int
    public var y: Int
}
//   클라이언트에서 p.x 는 게터 호출이 된다

@frozen public struct FastPoint {
    public var x: Int
    public var y: Int
}
//   직접 필드 접근으로 돌아온다. 대신 이후 필드 추가가 ABI 파괴다.

@inlinable본문을 공개하므로 참조 대상도 함께 공개돼야 한다.

@inlinable
public func scaled(_ p: Point, by f: Int) -> Point {
    Point(x: p.x * f, y: p.y * f)
}

// 본문이 internal 심볼을 참조하면 @usableFromInline 이 필요하다
@usableFromInline
internal func clamp(_ v: Int) -> Int { max(0, v) }

@inlinable
public func safeScaled(_ p: Point, by f: Int) -> Point {
    Point(x: clamp(p.x * f), y: clamp(p.y * f))   // clamp 가 인라인 본문에 등장한다
}

언제 켜지는지를 알아야 판단할 수 있다.

실험 · 도구

SIL로 직접 비교하면 차이가 눈에 보인다.

cat > /tmp/lib.swift <<'SWIFT'
public struct Point { public var x: Int; public var y: Int }
@frozen public struct FastPoint { public var x: Int; public var y: Int }
public func readA(_ p: Point) -> Int { p.x }
public func readB(_ p: FastPoint) -> Int { p.x }
SWIFT

# 라이브러리 진화 켜고 SIL 확인
swiftc -emit-sil -enable-library-evolution /tmp/lib.swift 2>/dev/null \
  | grep -A6 "sil.*readA\|sil.*readB"
#   readA — function_ref @…Point.x.getter 와 apply 가 보인다
#   readB — struct_extract %0 : $FastPoint, #FastPoint.x 한 줄

# 끄고 비교하면 둘 다 struct_extract 다
swiftc -emit-sil /tmp/lib.swift 2>/dev/null | grep -A4 "sil.*readA"

@inlinable의 효과도 확인할 수 있다.

# -O 에서 인라인되면 function_ref 가 사라지고 연산이 직접 박힌다
swiftc -O -emit-sil /tmp/inline.swift 2>/dev/null | grep -c "function_ref.*scaled"
#   0 이면 인라인된 것이다

프로젝트 설정을 확인한다.

# 라이브러리 진화가 켜진 타깃 찾기
grep -rn "BUILD_LIBRARY_FOR_DISTRIBUTION" *.xcodeproj/project.pbxproj

# 이미 @frozen·@inlinable 을 쓰는 곳 — ABI 계약을 진 자리
grep -rn "@frozen\|@inlinable\|@usableFromInline" --include=*.swift .

프로젝트 적용

  1. 앱 타깃에서는 신경 쓰지 않는다. 이 설정이 꺼져 있으면 @frozen·@inlinable의미가 없다.
  2. 바이너리 프레임워크를 배포한다면 성능 영향을 측정한다. 뜨거운 경로의 struct 접근이 게터 호출이 된다.
  3. @frozen은 정말 안 바뀔 타입에만 붙인다. 좌표·색상처럼 수학적으로 확정된 구조가 후보다.
  4. @inlinable은 작고 안정적인 함수에만 붙인다. 본문이 영원히 계약이 되므로 버그 수정도 어려워진다.
  5. SPM 소스 배포를 우선 고려한다. 소스로 배포하면 이 문제 자체가 없다 — P1 26장 Q3의 동적 프레임워크 비용과 함께 저울질한다.
⚠️ 흔한 오해

"@frozen은 성능에만 영향을 주고 API 계약과 무관하다"는 틀렸다핵심은 ABI 안정성 계약이고 직접 오프셋 접근은 그 결과일 뿐이다. "모든 Swift 코드에서 struct 필드 접근은 직접 접근이다"도 틀렸다 — 라이브러리 진화 모드에서는 게터 호출이다. "@inlinable은 그냥 최적화 힌트"도 틀렸다 — 본문이 공개 인터페이스가 되어 바꾸면 ABI가 깨진다. "일반 앱에서도 @frozen을 붙이면 빨라진다"도 틀렸다 — 이미 직접 접근이라 변화가 없다.

🧒 쉽게 이해하기

서랍장에서 세 번째 칸에 양말이 있다고 하자. 매번 "세 번째 칸"이라고 외워 두면 바로 손이 간다. 빠르다.

그런데 이 서랍장이 나중에 통째로 새것으로 바뀔 수 있다면? 새 서랍장은 칸이 하나 더 늘어서 양말이 네 번째에 있을 수도 있다. 그럼 외워 둔 번호가 엉뚱한 칸을 가리킨다.

그래서 이런 경우엔 번호를 외우지 않고 매번 물어본다. "양말 어디 있어요?" 하고. 안전하지만 물어보는 시간이 든다.

물어보기 싫으면 "이 서랍장은 절대 안 바꾸겠습니다"라고 약속하면 된다. 그러면 다시 번호를 외워도 된다. 대신 진짜로 못 바꾼다 — 나중에 칸을 하나 늘리고 싶어도 안 된다.

중요한 건 이게 속도 얘기가 아니라 약속 얘기라는 점이다. 빨라지는 건 약속의 결과일 뿐이다.

꼬리 질문

SPM 패키지를 소스로 배포할 때와 바이너리로 배포할 때 무엇이 다른가?

소스 배포는 이 챕터의 문제가 전부 사라진다. 클라이언트와 함께 컴파일되므로 컴파일러가 모든 것을 볼 수 있다.

소스 배포바이너리 (XCFramework)
레이아웃직접 접근불투명 (@frozen 제외)
비가상화WMO 로 가능 (Q3)모듈 경계에서 제한
제네릭 특수화가능@inlinable 필요
빌드 시간길다 — 매번 컴파일짧다
소스 노출노출됨감춰짐

그래서 선택 기준이 명확하다.

  • 사내 모듈 — 소스 배포가 대체로 낫다. 성능 손실이 없고 디버깅이 쉽다. 빌드 시간이 문제면 캐싱으로 해결한다.
  • 외부 배포·소스 비공개 — 바이너리가 필요하다. 이때 @frozen·@inlinable의식적으로 설계한다.

P1 26장 Q3에서 본 동적 프레임워크의 이중 비용도 함께 고려한다 — 바이너리 배포는 대개 동적 프레임워크가 되어 시작 시간에도 영향을 준다.

쉽게 말하면 레시피를 그대로 주는 것완성된 음식을 주는 것의 차이다. 레시피를 주면 받는 쪽이 자기 주방에 맞게 최적화할 수 있다. 음식을 주면 빠르게 받지만 안을 못 고친다.
Apple 시스템 프레임워크의 struct는 왜 느리지 않은가?

많은 것이 이미 @frozen이기 때문이다. 표준 라이브러리와 시스템 프레임워크는 성능이 중요한 타입을 의도적으로 고정했다.

// 표준 라이브러리의 핵심 타입들은 대부분 @frozen 이다
// Int, Double, Bool, Optional, Array 의 표현 등
//   그래서 앱 코드에서 이들을 다룰 때 게터 호출이 끼지 않는다

또한 @inlinable이 광범위하게 쓰인다. 표준 라이브러리 함수 상당수가 인라인 가능해 클라이언트 쪽에서 특수화·인라인된다. 이것이 Swift가 ABI 안정성을 지키면서도 성능을 유지하는 방식이다.

대가도 실재한다 — 표준 라이브러리는 그 결정들을 되돌릴 수 없다. @frozen으로 고정한 타입에는 필드를 추가할 수 없고, @inlinable로 공개한 함수는 구현을 바꾸면 이미 인라인된 클라이언트와 동작이 갈릴 수 있다.

그래서 Swift Evolution에서 @frozen·@inlinable 추가는 신중하게 논의된다 — 한 번 붙이면 영구적이기 때문이다.

쉽게 말하면 애플은 자주 쓰는 서랍장에만 "절대 안 바꾸겠습니다" 약속을 걸어 뒀다. 그래서 우리가 그 서랍은 번호를 외워서 바로 열 수 있다. 대신 애플도 그 서랍은 영영 못 고친다.
모듈을 나눌수록 성능이 떨어지는가?

배포 방식에 따라 다르고, 소스 배포라면 대체로 영향이 작다.

영향을 주는 요인을 분리하면 이렇다.

  • 라이브러리 진화 — 켜져 있으면 레이아웃·디스패치에 영향. SPM 소스 배포는 기본적으로 꺼짐이라 해당 없다.
  • WMO 범위 — 최적화는 모듈 단위로 일어난다. 모듈을 잘게 쪼개면 모듈 간 인라인·비가상화 기회가 줄어든다(Q3). 이건 소스 배포에서도 해당된다.
  • 동적 프레임워크 수 — 시작 시간에 직접 영향을 준다(P1 26장 Q3).

그래서 모듈 분할의 진짜 대가는 라이브러리 진화보다 최적화 범위 축소동적 링크 비용이다.

다만 모듈 분할의 이득도 크다 — 빌드 시간(변경된 모듈만 재빌드), 의존 관계 강제, 팀 경계. P0 17장의 결합도 관리가 빌드 시스템 수준으로 올라온 것이다.

실무 판단은 측정이다. 모듈을 나눈 뒤 콜드 스타트와 뜨거운 경로를 재고, 문제가 되면 합치거나 정적 링크로 바꾼다.

쉽게 말하면 작업장을 여러 방으로 나누면 정리는 잘 되는데, 방 사이를 오가는 시간이 생긴다. 그리고 각 방에서만 정리를 잘하다 보니 방 전체를 아우르는 개선 기회는 놓친다. 나누는 게 대체로 좋지만 공짜는 아니다.

Q2. throws는 정말 공짜인가?

🔑 30초 답변

C++ 예외와 다르지만 완전히 공짜는 아니다. Swift의 에러 전파는 테이블 기반 스택 언와인딩을 쓰지 않는다. 콜링 컨벤션이 전용 에러 레지스터(ARM64 x21, x86-64 r12, 둘 다 callee-saved, LLVM swifterror 어트리뷰트)를 예약해, throws 함수는 정상 반환값과 함께 그 레지스터에 에러 포인터를 실어 돌아온다. 실측(Swift 6.2.1, arm64 -O)하면 호출 전 mov x21, #0으로 초기화하고 호출 후 cbz x21로 검사해 분기한다. "zero-cost"는 "언와인딩 테이블 룩업·프레임 등록이 없다"는 뜻이지 명령이 0개라는 뜻이 아니다 — 정상 경로에도 초기화 1개 + 분기 1개가 매 호출마다 남는다.

CS 원리

에러 전파를 구현하는 방식은 크게 둘이다.

테이블 기반 언와인딩반환 채널
대표C++ 예외Swift throws, Go의 error
정상 경로비용 0에 가깝다초기화 + 분기가 남는다
에러 경로매우 비싸다 — 테이블 탐색·프레임 해제평범한 분기
바이너리 크기언와인딩 테이블만큼 증가거의 없음
예측 가능성낮다높다

C++ 방식은 "예외는 예외적이다"라는 전제 위에 있다 — 정상 경로를 최대한 빠르게 하고 에러 경로의 비용은 감수한다. 대신 에러가 자주 나면 급격히 느려지고, 실시간 시스템처럼 최악 실행 시간이 중요한 곳에서는 쓰기 어렵다.

Swift는 반대를 골랐다 — 정상 경로에 작은 고정 비용을 두고 에러 경로를 평범한 분기로 만든다. 그래서 예측 가능하고, 에러를 제어 흐름의 일부로 써도 성능이 무너지지 않는다.

구현의 핵심은 숨겨진 반환 채널이다. 함수는 값과 에러를 동시에 돌려줄 수 있어야 하는데, 반환값 자리는 하나다. 그래서 레지스터 하나를 예약해 두 번째 채널로 쓴다. callee-saved로 지정한 이유는 중간 함수들이 값을 보존해야 하기 때문이다.

다만 throw 자체는 힙 할당을 한다(swift_allocError). 그래서 에러를 초당 수만 번 던지는 설계는 여전히 비싸다 — 32장 Q2의 Typed Throws가 실존 박싱을 피하는 것과는 별개 문제다.

에러는 숨겨진 반환 채널로 돌아온다 — 예외 언와인딩이 아니다 호출부 호출 throws 함수 x0 — 정상 반환값 x21 — 에러 포인터 ARM64 x21 · x86-64 r12 둘 다 callee-saved 정상 경로에 실제로 남는 명령 (arm64 -O 실측) mov x21, #0 — 호출 전 초기화 cbz x21 — 호출 후 검사·분기 "zero-cost" 의 진짜 뜻 언와인딩 테이블 룩업·프레임 등록이 없다 명령이 0개라는 뜻이 아니다 그리고 throw 자체는 swift_allocError 로 힙 할당을 한다 에러가 뜨거운 경로에 있으면 이 비용이 드러난다
Swift 팀 문서 스스로 "완전히 공짜"라는 표현을 부인한다.

iOS에서는

실무 판단은 "에러가 예외적인가, 흔한가"다.

// ✅ 에러가 예외적 — throws 가 적합하다
func loadConfig() throws -> Config { … }        // 실패는 드물다

// ⚠️ 에러가 흔한 흐름 — 반환 타입으로 표현하는 편이 나을 수 있다
func parseInt(_ s: String) throws -> Int { … }   // 실패가 자주 일어난다면?
func parseInt2(_ s: String) -> Int? { Int(s) }   // 옵셔널이 더 자연스럽다

옵셔널과 throws의 선택 기준이 여기서 나온다.

에러 타입의 크기도 영향을 준다.

// 연관값이 큰 에러는 할당 비용이 는다
enum HeavyError: Error {
    case failed(request: URLRequest, response: HTTPURLResponse, body: Data)   // 크다
}

// 진단 정보가 필요하면 참조로 감싸거나 지연 생성한다
enum LeanError: Error {
    case failed(context: () -> String)      // 필요할 때만 문자열을 만든다
}

다만 대부분의 앱에서 이 수준은 최적화 대상이 아니다. P0 12장의 원칙대로 측정 후 판단한다.

실험 · 도구

어셈블리로 직접 확인할 수 있다.

cat > /tmp/err.swift <<'SWIFT'
enum E: Error { case bad }
@inline(never) func mayFail(_ x: Int) throws -> Int {
    if x < 0 { throw E.bad }
    return x * 2
}
@inline(never) func caller(_ x: Int) -> Int {
    (try? mayFail(x)) ?? -1
}
SWIFT

# arm64 어셈블리에서 에러 레지스터 사용을 확인한다
swiftc -O -emit-assembly -target arm64-apple-macos13 /tmp/err.swift 2>/dev/null \
  | grep -E "x21|cbz|swift_allocError" | head -20
#   mov x21, #0      ← 호출 전 초기화
#   cbz x21, …       ← 호출 후 검사
#   bl _swift_allocError  ← throw 경로의 힙 할당

비용을 재 보고 싶다면 정상 경로에러 경로를 나눠 측정한다.

import Foundation

let clock = ContinuousClock()
let n = 1_000_000

// 정상 경로 — 초기화 + 분기만 남는다
let ok = clock.measure { for i in 0..<n { _ = try? mayFail(i) } }

// 에러 경로 — 매번 swift_allocError
let fail = clock.measure { for i in 0..<n { _ = try? mayFail(-i) } }

// 비교군 — 옵셔널 반환
let opt = clock.measure { for i in 0..<n { _ = i < 0 ? nil : i * 2 } }

print("정상: \(ok)\n에러: \(fail)\n옵셔널: \(opt)")
//   에러 경로가 눈에 띄게 느리면 그 경로가 뜨거운지 검토한다

프로젝트 적용

  1. 에러가 예외적이면 throws를 그대로 쓴다. 대부분의 경우가 여기 해당한다.
  2. 실패가 흔하고 이유가 자명하면 옵셔널을 고려한다. Int(_:)·Dictionary 조회가 옵셔널인 이유다.
  3. 뜨거운 루프에서 에러를 던지지 않는다. swift_allocError가 반복된다 — 루프 밖에서 검증하거나 옵셔널로 바꾼다.
  4. "throws가 느리다"는 이유로 피하지 않는다. 정상 경로 비용은 명령 두 개 수준이다.
  5. 에러 연관값을 가볍게 유지한다. 큰 데이터를 담으면 할당 비용이 는다 — 필요하면 지연 생성한다.
⚠️ 흔한 오해

"throws는 완전히 공짜다"는 Swift 팀 문서 스스로 부인한다 — 정상 경로에도 초기화 1개 + 분기 1개가 남는다. "throws는 내부적으로 예외를 던진다"도 틀렸다숨겨진 리턴 채널을 체크하는 평범한 제어 흐름이고 스택 언와인딩이 없다. "그래서 throws는 C++ 예외보다 항상 빠르다"도 단순화다 — 정상 경로는 C++이 유리하고 에러 경로는 Swift가 유리하다. "에러를 많이 던져도 상관없다"도 틀렸다 — throw마다 힙 할당이 있다.

🧒 쉽게 이해하기

일하다 문제가 생겼을 때 알리는 방법이 두 가지 있다.

비상벨을 누르는 방식(C++ 예외) — 평소에는 아무 비용이 없다. 그런데 실제로 누르면 온 건물이 대피 절차를 밟느라 아주 오래 걸린다.

보고서에 칸을 하나 더 두는 방식(Swift) — 모든 보고서에 "문제 있었나요?" 칸이 하나 더 있다. 평소에도 그 칸에 "없음"이라고 적고, 받는 쪽은 그 칸을 확인해야 한다. 아주 작은 수고지만 매번 든다.

대신 실제로 문제가 생겼을 때는 그냥 그 칸에 적으면 끝이라 빠르다.

그래서 "Swift의 방식은 공짜다"라는 말은 정확하지 않다. "대피 절차가 없다"는 뜻이지 "아무 일도 안 한다"는 뜻이 아니다. 칸 하나 채우고 확인하는 일은 매번 일어난다.

한 가지 더 — 문제 내용을 적을 종이를 새로 꺼내는 일이 있다. 그래서 문제가 아주 자주 생기면 종이 꺼내는 시간이 쌓인다.

꼬리 질문

옵셔널과 throws 중 무엇을 반환해야 하는가?

호출자가 "왜"를 알아야 하는가로 정한다.

상황선택이유
실패 이유가 하나옵셔널Int("abc") — 왜인지 자명하다
이유에 따라 대응이 다름throws네트워크 실패 vs 인증 만료
실패가 정상 흐름옵셔널딕셔너리 조회 — 없는 게 정상
진단 정보가 필요throws파싱 실패 위치(31장 Q4)

애매할 때 유용한 신호가 하나 있다 — 호출부가 catch에서 무엇을 하는가를 상상해 본다. 전부 똑같이 처리한다면 이유를 안 쓰는 것이므로 옵셔널로 충분하다.

// 호출부가 이유를 안 쓴다면 throws 가 과하다
guard let v = try? parse(s) else { return defaultValue }

// 이유에 따라 다르게 행동한다면 throws 가 맞다
do { try submit(order) }
catch NetworkError.offline { showOfflineBanner() }
catch NetworkError.server { scheduleRetry() }
catch { showGenericError() }

둘 다 제공하는 것도 방법이다 — 표준 라이브러리가 Int(_:)(옵셔널)와 Int(_:radix:)를 함께 두듯이, 편의 옵셔널 래퍼를 얹을 수 있다.

쉽게 말하면 "안 됐어요"만 알면 되는 일에는 고개만 저으면 된다. "왜 안 됐는지에 따라 다르게 할 일"이면 이유를 적어 줘야 한다. 받는 사람이 이유를 안 볼 거면 적을 필요가 없다.
rethrows는 무엇이 다른가?

"인자로 받은 클로저가 던질 때만 나도 던진다"는 조건부 선언이다. 호출부의 try 필요 여부가 인자에 따라 달라진다.

extension Sequence {
    func myMap<T>(_ transform: (Element) throws -> T) rethrows -> [T] { … }
}

let a = [1, 2].myMap { $0 * 2 }              // ✅ try 불필요 — 클로저가 안 던진다
let b = try [1, 2].myMap { try risky($0) }   // try 필요 — 클로저가 던진다

값어치는 호출부의 부담을 없애는 것이다. rethrows가 없으면 안 던지는 클로저를 넘겨도 호출부가 try를 써야 한다.

32장 Q2의 Typed Throws가 이 영역을 더 정밀하게 만든다 — 제네릭 에러 타입으로 어떤 에러가 전파되는지까지 표현할 수 있다.

// Typed Throws 를 쓰면 에러 타입까지 전파된다
func measured<T, E: Error>(_ body: () throws(E) -> T) throws(E) -> T { try body() }
//   rethrows 보다 정밀하다 — 어떤 에러인지도 유지된다

주의할 점은 rethrows 함수가 스스로 던질 수 없다는 것이다. 자체 에러를 던져야 하면 일반 throws여야 한다.

쉽게 말하면 "내가 문제를 만들지는 않지만, 네가 준 일이 문제를 만들면 그건 그대로 전해 줄게"라는 약속이다. 그래서 문제 안 만드는 일을 주면 받는 쪽도 대비할 필요가 없다.
에러를 던지는 대신 로깅하고 넘어가야 할 때는 언제인가?

호출자가 할 수 있는 일이 없을 때다. 이 판단이 API 설계의 핵심 중 하나다.

기준을 나누면 이렇다.

  • 던진다 — 호출자가 다르게 행동할 수 있을 때. 재시도, 대체 경로, 사용자 안내.
  • 로깅하고 계속 — 부수적 작업이 실패했고 주 작업은 계속돼야 할 때. 분석 이벤트 전송 실패, 캐시 쓰기 실패.
  • 크래시프로그래머 실수이고 계속하면 더 나빠질 때. precondition·fatalError. 사용자 데이터가 걸린 곳에서는 신중해야 한다(P1 19장).
// 부수적 실패 — 던지지 않고 기록한다
func trackEvent(_ e: Event) {
    do { try analytics.send(e) }
    catch {
        logger.notice("분석 전송 실패: \(error.localizedDescription, privacy: .public)")
        metrics.increment("analytics.send_failure")     // 지표로 추세를 본다
    }
}
//   호출자가 할 수 있는 일이 없으므로 던지지 않는다.
//   대신 30장 Q5 꼬리질문처럼 빈도를 관측한다.

조용히 삼키지 않는 것이 핵심이다. try?로 무시하면 실패가 일어나는지조차 모른다 — P1 21장 Q6의 페일 사일런트다.

쉽게 말하면 문제를 위에 알릴지는 "위에서 뭘 할 수 있나"에 달렸다. 할 수 있는 게 없으면 알려 봐야 소용없다 — 대신 일지에는 꼭 적어 둔다. 아무 데도 안 적으면 나중에 "요즘 왜 이러지?"에 답할 수가 없다.

Q3. 비가상화는 언제 일어나는가?

🔑 30초 답변

"이 호출 지점에서 가능한 구현이 정확히 하나뿐"임을 컴파일러가 증명할 수 있을 때만 vtable 간접 호출이 직접 호출로 치환된다. 증명은 세 갈래로 얻어진다 — final(오버라이드를 언어 차원에서 금지), Whole Module Optimization(모듈 전체를 한 번에 봐서 오버라이드 후보 부재를 스스로 증명), private/fileprivate(오버라이드 가능 범위를 좁혀 증명을 쉽게 함). WMO는 프로젝트에 따라 최대 2~5배 속도 향상을 낼 수 있다고 swift.org가 보고한다. dynamic은 이 추론을 전부 무효화해 항상 objc_msgSend 경로를 강제한다.

CS 원리

동적 디스패치는 "어느 구현을 부를지 실행 시점에 정한다"는 것이고, 그 대가로 간접 호출 + 인라인 불가가 따라온다. 인라인이 막히는 것이 더 큰 손실인 경우가 많다 — 인라인은 다른 최적화의 관문이기 때문이다.

비가상화는 정적 분석으로 동적 성질을 제거하는 최적화다. 핵심은 증명 가능성이고, 그것은 컴파일러가 얼마나 넓게 볼 수 있는가에 달렸다.

증명 수단원리범위
final언어가 오버라이드를 금지선언만으로 성립
WMO모듈 전체를 보고 후보 부재를 확인모듈 내부에 갇힌 타입
private오버라이드 가능 범위를 축소파일·타입 내부

여기서 모듈 경계가 결정적이다. public·open 클래스는 모듈 밖에서 서브클래싱될 수 있으므로 WMO만으로는 비가상화를 기대할 수 없다. Q1에서 본 ABI 경계가 최적화에도 같은 방식으로 작용한다.

dynamic은 이 모든 것을 무효화한다 — 런타임 교체 가능성을 명시적으로 요구하므로 컴파일러가 증명을 포기한다. 메서드 스위즐링이나 KVO를 쓰려면 필요하지만(34장), 성능은 확실히 포기하는 선택이다.

"가능한 구현이 하나뿐"임을 증명할 수 있을 때만 final 언어가 오버라이드를 금지 선언만으로 성립 WMO 모듈 전체를 보고 후보 부재 확인 최대 2~5배 향상 보고 private / fileprivate 오버라이드 범위를 좁힌다 증명을 쉽게 만든다 증명이 깨지는 자리 public · open 클래스 모듈 밖에서 서브클래싱 가능 WMO 만으로는 부족하다 dynamic 런타임 교체 가능성을 요구 objc_msgSend 강제 · 원천 차단 비가상화는 인라인의 전제조건일 뿐 — 실제 인라인은 별도 휴리스틱이 정한다 Q1 의 ABI 경계가 최적화에도 같은 방식으로 작용한다
모듈 경계가 레이아웃뿐 아니라 디스패치 최적화에도 같은 제약을 만든다.

iOS에서는

SIL에서 class_methodfunction_ref로 바뀌는지로 확인한다.

class Base { func work() -> Int { 1 } }
final class Sealed { func work() -> Int { 2 } }

func callBase(_ b: Base) -> Int { b.work() }       // 모듈 내부라면 WMO 로 비가상화 가능
func callSealed(_ s: Sealed) -> Int { s.work() }   // final — 항상 직접 호출

open class Exposed { open func work() -> Int { 3 } }
public func callExposed(_ e: Exposed) -> Int { e.work() }
//   모듈 밖에서 서브클래싱될 수 있어 비가상화되지 않는다

실무에서 효과가 큰 조치는 순서가 있다.

  1. WMO가 켜져 있는지 확인한다. 릴리스 빌드의 기본값이지만 설정이 바뀌어 있을 수 있다.
  2. 모듈 밖에 공개할 이유가 없는 클래스를 internal 둔다. public이 기본값처럼 붙어 있으면 최적화 기회가 사라진다.
  3. 확장 가능성이 없는 클래스에 final을 붙인다. 의도도 드러나고 최적화도 확실해진다.
// 기본값을 final 로 두고 필요할 때만 연다
final class ViewModel { … }          // ✅ 상속 계획이 없으면
class BaseCoordinator { … }          // 상속이 설계의 일부일 때만

// 모듈 경계도 좁힌다
internal final class Cache { … }     // ✅ 밖에 낼 이유가 없다
public final class PublicAPI { … }   // public 이어도 final 이면 비가상화된다

dynamic이 필요한 경우는 제한적이다 — KVO, 메서드 스위즐링, 일부 테스트 대역. 이들은 성능을 포기하는 대가로 런타임 유연성을 산다(34장).

실험 · 도구

SIL로 비가상화 여부를 직접 확인한다.

cat > /tmp/dv.swift <<'SWIFT'
class Base { func work() -> Int { 1 } }
final class Sealed { func work() -> Int { 2 } }
public func a(_ b: Base) -> Int { b.work() }
public func b(_ s: Sealed) -> Int { s.work() }
SWIFT

# WMO + 최적화로 SIL 확인
swiftc -O -wmo -emit-sil /tmp/dv.swift 2>/dev/null | grep -E "class_method|function_ref.*work"
#   class_method 가 남아 있으면 가상 호출, function_ref 면 비가상화됐다

# WMO 를 끄면 결과가 달라지는지 비교
swiftc -O -emit-sil /tmp/dv.swift 2>/dev/null | grep -c "class_method"

빌드 설정을 확인한다.

# WMO 설정 확인 — 릴리스에서 wholemodule 이어야 한다
xcodebuild -showBuildSettings -configuration Release \
  | grep -E "SWIFT_COMPILATION_MODE|SWIFT_OPTIMIZATION_LEVEL"
#   SWIFT_COMPILATION_MODE = wholemodule
#   SWIFT_OPTIMIZATION_LEVEL = -O

# final 이 아닌 internal 클래스 — final 후보
grep -rnE "^ *(internal )?class [A-Z]" --include=*.swift . | grep -v final | head -20

# dynamic 사용처 — 성능을 포기한 자리
grep -rn "dynamic func\|@objc dynamic" --include=*.swift .

실제 효과는 Time Profiler로 확인한다 — 디스패치 자체보다 인라인이 막혀 생기는 손실이 크므로, 스택에서 작은 함수가 반복 등장하면 신호다(P0 15장 Q6).

프로젝트 적용

  1. 릴리스 빌드의 WMO를 확인한다. 꺼져 있으면 가장 큰 최적화를 놓치고 있다.
  2. final을 기본으로 삼는다. 상속이 설계의 일부일 때만 열어 둔다. 의도가 드러나는 부수 효과도 크다.
  3. 접근 수준을 필요 이상으로 넓히지 않는다. public이 습관적으로 붙어 있으면 최적화가 막힌다(Q5의 package가 대안이다).
  4. dynamic은 정말 필요할 때만 쓴다. KVO·스위즐링이 아니면 대개 불필요하다.
  5. 측정 없이 final을 남발하지 않는다. 성능보다 설계 의도 표현이 주된 이유여야 한다.
⚠️ 흔한 오해

"final을 안 쓰면 무조건 vtable 호출"은 틀렸다 — WMO가 켜져 있고 그 클래스가 모듈 밖에 공개되지 않았다면 final 없이도 비가상화될 수 있다. "private으로 좁히면 자동으로 인라인까지 보장된다"도 틀렸다 — 비가상화는 인라인의 전제조건 중 하나일 뿐이고, 실제 인라인 여부는 함수 크기 등 별도 휴리스틱이 정한다. "public이어도 WMO면 최적화된다"도 틀렸다 — 모듈 밖 서브클래싱 가능성이 증명을 막는다. "struct를 쓰면 디스패치 문제가 없다"도 부분적으로만 맞다 — 프로토콜 요구사항 호출은 witness table을 탄다(Q4).

🧒 쉽게 이해하기

"이 일은 누가 할까?"를 미리 정해 둘 수 있으면 빠르다. 그때그때 물어봐야 하면 느리다.

미리 정해 두려면 확실히 알아야 한다. 방법이 세 가지다.

"이 일은 오직 이 사람만 한다"고 못 박기 — 그러면 물어볼 필요가 없다.
작업장 전체를 둘러보고 확인하기 — "이 일을 할 수 있는 사람이 이 사람뿐이네" 하고 스스로 확인한다.
일을 작은 방 안으로 제한하기 — 방 안에 사람이 몇 없으면 확인이 쉽다.

그런데 작업장 밖에서 누가 들어올 수 있다면 아무리 둘러봐도 소용없다. 밖에서 새 사람이 와서 그 일을 대신할 수도 있으니까. 그래서 밖에 공개된 것은 최적화가 안 된다.

그리고 "이 일은 언제든 다른 사람으로 바꿀 수 있게 해 주세요"라고 요청하면(dynamic), 컴퓨터는 아예 미리 정하기를 포기한다. 필요할 때가 있지만 속도는 확실히 포기하는 것이다.

꼬리 질문

WMO를 켜면 빌드가 느려지지 않는가?

느려진다. 그래서 디버그는 incremental, 릴리스는 wholemodule이 기본 구성이다.

incrementalwholemodule (WMO)
단위파일모듈 전체
변경 시바뀐 파일만모듈 전체 재컴파일
최적화파일 범위모듈 범위 — 비가상화·특수화
적합개발 중릴리스

이것이 모듈 분할의 실질적 이득 중 하나다 — 모듈이 작으면 WMO 재컴파일 비용도 작아진다. 다만 Q1 꼬리질문에서 본 대로 최적화 범위도 함께 작아진다.

그래서 균형점이 있다. 너무 큰 모듈은 빌드가 느리고, 너무 잘게 쪼갠 모듈은 최적화 기회를 잃는다. 실무에서는 기능 단위로 나누고 뜨거운 코드는 같은 모듈에 두는 편이 무난하다.

디버그 빌드에서 성능을 재면 안 되는 이유도 여기 있다 — P0 12장 Q4에서 본 대로, 디버그는 최적화가 꺼져 있어 실제 성능과 크게 다르다.

쉽게 말하면 작업장 전체를 둘러보면 더 좋은 방법을 찾을 수 있지만 둘러보는 데 시간이 걸린다. 그래서 매일 일할 때는 대충 하고, 납품할 물건을 만들 때만 꼼꼼히 둘러본다.
프로토콜 기반 코드도 비가상화되는가?

제네릭 특수화를 거치면 가능하고, 실존 타입(any)이면 어렵다.

세 경우로 갈린다.

  • 제네릭 <T: P> — 컴파일러가 구체 타입별로 특수화하면 witness table 호출이 직접 호출이 된다. WMO 범위 안에서 잘 일어난다.
  • some P — 구체 타입이 하나로 고정되므로 특수화가 잘 된다.
  • any P — 런타임에 타입이 바뀔 수 있어 witness table 간접 호출이 남는다.
protocol Shape { func area() -> Double }
struct Circle: Shape { let r: Double; func area() -> Double { .pi * r * r } }

func totalGeneric<S: Shape>(_ items: [S]) -> Double { items.reduce(0) { $0 + $1.area() } }
//   Circle 로 특수화되면 area() 가 인라인될 수 있다

func totalExistential(_ items: [any Shape]) -> Double { items.reduce(0) { $0 + $1.area() } }
//   원소마다 타입이 다를 수 있어 witness table 을 탄다

다만 P1 26장 Q2에서 본 대로 특수화는 코드 크기를 늘린다. 구체 타입이 아주 많으면 바이너리가 커지고 오히려 캐시 효율이 떨어질 수 있다.

그래서 실무 판단은 "뜨거운 경로인가"다 — 뜨거우면 제네릭·some, 아니면 any로 코드 크기를 아끼는 것이 합리적이다.

쉽게 말하면 "어떤 도형이든 넓이를 구해라"라고 할 때, "이건 항상 원이야"라고 알려주면 컴퓨터가 원 전용 코드를 만들어 빠르게 한다. "뭐가 올지 몰라"라고 하면 매번 물어봐야 한다.
final을 붙였는데도 느리면 무엇을 봐야 하는가?

비가상화는 인라인의 전제일 뿐이므로, 실제 병목은 다른 곳일 가능성이 높다.

확인 순서다.

(1) 정말 그 함수가 병목인가. Time Profiler로 확인한다(P0 15장 Q6). 대개는 아니다.

(2) 인라인이 됐는가. 함수가 크면 비가상화돼도 인라인되지 않는다. @inline(__always)최후 수단이고, 대개는 함수를 작게 쪼개는 편이 낫다.

(3) 진짜 비용이 함수 호출인가. ARC 트래픽, 힙 할당, 복사가 더 클 수 있다.

# ARC 트래픽 확인 — retain/release 가 뜨거운 경로에 있는지
swiftc -O -emit-sil MyFile.swift | grep -c "strong_retain\|strong_release"

# 할당 확인
swiftc -O -emit-sil MyFile.swift | grep -c "alloc_ref\|alloc_box"

(4) 알고리즘·자료구조 문제인가. P0 06장·12장에서 본 대로, 상수 배 개선보다 복잡도 개선이 훨씬 크다. 디스패치 최적화로 얻는 것은 대개 상수 배다.

일반 원칙은 "측정이 가리키는 곳을 고친다"이다. final설계 의도 표현으로서 가치가 크고, 성능은 부수적 이득으로 보는 편이 정직하다.

쉽게 말하면 문을 바로 열 수 있게 만들어도, 방 안에서 하는 일이 오래 걸리면 전체 시간은 그대로다. 문 여는 시간을 줄이기 전에 안에서 뭘 하고 있는지부터 봐야 한다.

Q4. 프로토콜 확장의 디스패치 함정은 무엇인가?

🔑 30초 답변

요구사항이면 동적, 확장 전용이면 정적으로 바인딩된다. 프로토콜 요구사항으로 선언된 메서드는 conforming 타입마다 witness table 슬롯을 가져, 어떤 정적 타입으로 부르든 항상 그 타입의 구현이 실행된다. 반면 확장에만 정의되고 요구사항 목록에 없는 메서드는 슬롯 자체가 없어 컴파일러가 호출부의 정적 타입만 보고 컴파일 타임에 결정한다. 그래서 같은 인스턴스라도 구체 타입 변수로 부르면 그 타입의 구현이, 프로토콜 타입 변수로 부르면 확장의 기본 구현이 실행되는 불일치가 생긴다. 해법은 하나뿐이다 — 나중에 다르게 구현될 가능성이 있으면 요구사항으로 선언한다.

CS 원리

이 함정의 뿌리는 "오버라이드처럼 보이지만 오버라이드가 아니다"라는 데 있다.

클래스 상속에서 메서드를 재정의하면 vtable 슬롯이 교체된다. 그래서 부모 타입 변수로 불러도 자식 구현이 돈다. 이것이 다형성이다.

프로토콜에서는 요구사항일 때만 같은 일이 일어난다. 요구사항은 witness table에 슬롯을 만들고, conforming 타입이 그 슬롯을 채운다. 슬롯이 있으므로 실행 시점에 어느 구현인지 조회할 수 있다.

확장 전용 메서드는 다르다. 슬롯이 없으므로 조회할 것이 없다. 컴파일러는 호출부에서 보이는 정적 타입으로 어느 함수를 부를지 정하고 그것을 박는다. conforming 타입이 같은 이름의 메서드를 정의해도, 그것은 오버라이드가 아니라 이름이 같은 별개의 정적 함수를 하나 더 만든 것에 가깝다.

중요한 점은 제네릭 안에서도 똑같이 재현된다는 것이다. <T: P>에서 T가 무엇으로 특수화되든, 확장 전용 메서드 호출은 제약 타입(P)의 정적 타입으로 결정된다.

같은 인스턴스인데 부르는 변수의 타입에 따라 다른 구현이 돈다 요구사항 구체 타입으로 호출 프로토콜 타입으로 호출 둘 다 그 타입의 구현 witness table 슬롯이 있다 확장 전용 구체 타입으로 호출 프로토콜 타입으로 호출 그 타입의 구현 확장의 기본 구현 슬롯이 없어 정적 타입으로 컴파일 타임에 결정된다 제네릭 <T: P> 안에서도 똑같이 재현된다
conforming 타입의 같은 이름 메서드는 오버라이드가 아니라 별개의 정적 함수다.

iOS에서는

재현이 간단하다.

protocol Greeter {
    func required() -> String        // 요구사항
}

extension Greeter {
    func required() -> String { "기본 required" }
    func extensionOnly() -> String { "기본 extensionOnly" }   // 요구사항이 아니다
}

struct Korean: Greeter {
    func required() -> String { "한국어 required" }
    func extensionOnly() -> String { "한국어 extensionOnly" }
}

let k = Korean()
let g: Greeter = k                    // 같은 값, 다른 정적 타입

k.required()          // "한국어 required"
g.required()          // "한국어 required"        ✅ 일관적

k.extensionOnly()     // "한국어 extensionOnly"
g.extensionOnly()     // "기본 extensionOnly"     ❌ 불일치

제네릭에서도 같다.

func callAll<T: Greeter>(_ v: T) {
    print(v.required())        // "한국어 required"
    print(v.extensionOnly())   // "기본 extensionOnly"  ← T 가 Korean 이어도 그렇다
}
callAll(Korean())

해법은 요구사항으로 올리는 것 하나뿐이다.

protocol Greeter2 {
    func required() -> String
    func wasExtensionOnly() -> String     // ✅ 요구사항으로 선언
}

extension Greeter2 {
    func wasExtensionOnly() -> String { "기본" }   // 기본 구현은 확장에 그대로 둔다
}
//   이제 conforming 타입이 재정의하면 프로토콜 타입으로 불러도 그 구현이 돈다

실무에서 이 함정이 드러나는 자리는 이렇다.

실험 · 도구

어느 쪽인지 확인하는 방법이 있다.

// (1) 프로토콜 선언을 직접 확인한다 — 요구사항 목록에 있는가
//     라이브러리라면 인터페이스 파일을 본다
# 모듈 인터페이스에서 프로토콜 요구사항 목록 확인
swiftc -emit-module-interface -enable-library-evolution MyLib.swift
grep -A20 "protocol Greeter" MyLib.swiftinterface
#   protocol 블록 안에 있으면 요구사항, extension 블록 안이면 확장 전용이다

# SIL 로 witness table 슬롯을 확인한다
swiftc -emit-sil /tmp/proto.swift 2>/dev/null | grep -A10 "sil_witness_table"
#   요구사항만 슬롯으로 나타난다

불일치를 테스트로 잡을 수도 있다.

import Testing

@Test func 프로토콜_타입으로_불러도_같은_구현이_돈다() {
    let concrete = Korean()
    let asProtocol: Greeter = concrete

    #expect(concrete.extensionOnly() == asProtocol.extensionOnly(),
            "확장 전용 메서드라 정적 바인딩됐다 — 요구사항으로 올려야 한다")
}
//   이 테스트가 실패하면 그 메서드는 요구사항이 아니다

프로젝트 적용

  1. 재정의될 가능성이 있으면 요구사항으로 선언한다. "기본 구현만 쓸 것"이라고 확신할 때만 확장 전용으로 둔다.
  2. 테스트 대역을 쓰는 프로토콜은 특히 확인한다. 대역이 무시되는 버그는 테스트가 통과하는데 프로덕션이 다르게 동작하는 형태로 나타난다.
  3. 확장 전용 메서드는 이름을 구별한다. 요구사항과 같은 이름을 피하면 착각 자체가 줄어든다.
  4. 라이브러리 프로토콜을 커스터마이징할 때 인터페이스를 확인한다. 요구사항이 아니면 재정의해도 반영되지 않는다.
  5. 불일치 테스트를 핵심 프로토콜에 둔다. 리팩터링 중 요구사항이 확장으로 밀려나는 것을 잡는다.
⚠️ 흔한 오해

"요구사항이 아닌 확장 메서드도 conforming 타입에서 재정의하면 다형적으로 호출된다"는 틀렸다 — 프로토콜 타입으로 호출하면 정적 바인딩된다. "구조체는 상속이 없으니 확장 메서드도 항상 정적"이라는 것도 부분적으로만 맞다프로토콜 요구사항 메서드는 구조체여도 witness table을 통한 동적 디스패치를 쓴다. "제네릭 제약을 쓰면 구체 타입 구현이 불린다"도 틀렸다T가 무엇이든 확장 전용이면 기본 구현이다. "컴파일러가 경고해 준다"도 아니다 — 조용히 다른 구현이 돈다.

🧒 쉽게 이해하기

동아리에 규칙이 있다고 하자. "회원은 인사말을 할 수 있어야 한다."

규칙에 적혀 있는 일회원 명부에 각자 칸이 생긴다. 그래서 "인사해 봐"라고 하면 명부를 보고 그 사람 방식으로 인사한다. 누구를 통해 부르든 결과가 같다.

그런데 규칙에 안 적혀 있고 안내문에만 있는 일은 명부에 칸이 없다. 그래서 "동아리 회원으로서" 부르면 안내문의 기본 방식이 나오고, "김 아무개로서" 부르면 그 사람 방식이 나온다.

같은 사람인데 어떻게 부르느냐에 따라 다른 답이 나오는 것이다.

더 헷갈리는 건, 그 사람이 "저는 이렇게 인사할게요"라고 적어 뒀어도 소용이 없다는 점이다. 규칙에 없는 일이라 명부에 반영되지 않았기 때문이다. 그건 고쳐 쓴 게 아니라 같은 이름의 다른 걸 하나 더 만든 셈이다.

해결은 하나다 — 나중에 사람마다 다르게 할 것 같으면, 처음부터 규칙에 적어 둔다.

꼬리 질문

테스트 대역이 무시되는 버그는 어떻게 발견하는가?

테스트는 통과하는데 프로덕션 동작이 다른 형태라 발견이 늦다. 세 가지로 잡는다.

(1) 대역이 호출됐는지 검증한다. 스파이를 두어 실제로 불렸는지 확인하면 무시된 것이 드러난다.

final class SpyLoader: Loader {
    private(set) var loadCallCount = 0
    func load() -> Data { loadCallCount += 1; return Data() }
}

@Test func 대역이_실제로_호출된다() {
    let spy = SpyLoader()
    let sut = Service(loader: spy)
    _ = sut.run()
    #expect(spy.loadCallCount == 1, "대역이 호출되지 않았다 — 확장 전용 메서드일 수 있다")
}

(2) 프로토콜 선언을 검토한다. 주입 대상 프로토콜의 모든 메서드가 요구사항인지 확인한다.

(3) 기본 구현을 지워 본다. 확장의 기본 구현을 임시로 제거하면, 요구사항이 아닌 메서드는 컴파일 에러가 난다 — 어느 것이 확장 전용인지 즉시 드러난다.

근본 대책은 주입용 프로토콜에 기본 구현을 두지 않는 것이다. 편의 기본값이 필요하면 별도 프로토콜로 분리한다.

쉽게 말하면 대역 배우를 세웠는데 원래 배우가 계속 나오는 상황이다. 확인하는 방법은 대역에게 "몇 번 나갔는지 세어 보라"고 하는 것이다. 0이면 안 나간 것이다.
기본 구현을 제공하면서도 재정의를 보장하려면?

요구사항으로 선언하고 확장에 기본 구현을 둔다 — 이 조합이 정답이다.

protocol Cache {
    func get(_ key: String) -> Data?
    func clear()                        // ✅ 요구사항
}

extension Cache {
    func clear() { /* 아무것도 안 함 */ }   // 기본 구현
}
//   요구사항이므로 witness table 슬롯이 생기고,
//   재정의하면 프로토콜 타입으로 불러도 그 구현이 돈다

이 패턴의 이점이 둘이다.

  • 선택적 구현 — conforming 타입이 필요 없으면 안 써도 된다.
  • 다형성 유지 — 재정의하면 확실히 반영된다.

Objective-C의 @optional 프로토콜 메서드를 Swift에서 흉내 내는 표준 방법이기도 하다. @objc optionalObjC 런타임에 의존하고 값 타입에 쓸 수 없어, 순수 Swift에서는 이 패턴이 낫다.

주의할 점은 기본 구현이 조용히 아무것도 안 하면 구현을 빠뜨려도 모른다는 것이다. 중요한 메서드라면 기본 구현에서 로깅하거나 assertionFailure를 두어 개발 중에 드러나게 하는 방법도 있다.

쉽게 말하면 규칙에는 적어 두되 "특별히 정한 게 없으면 이렇게 하세요"라는 기본 안내도 함께 준다. 그러면 안 정한 사람은 기본대로 하고, 정한 사람은 자기 방식이 확실히 반영된다.
이 함정이 성능과도 관계가 있는가?

있다 — 확장 전용이 오히려 빠르다. 정적 바인딩이므로 witness table 조회가 없고 인라인 여지가 크다.

그래서 트레이드오프가 성립한다.

요구사항확장 전용
디스패치witness table 간접직접 호출
인라인특수화되면 가능쉽게 가능
다형성보장됨없음

다만 성능을 이유로 확장 전용을 고르는 것은 위험하다 — 다형성이 필요해지는 순간 조용한 버그가 된다. 순서는 이렇다.

  1. 기본은 요구사항으로 선언한다. 안전이 우선이다.
  2. 측정으로 그 호출이 병목임이 확인되면, 그때 확장 전용으로 내리거나 제네릭 특수화를 유도한다(Q3 꼬리질문).
  3. 내렸다면 재정의 금지를 문서화한다.

대부분의 경우 제네릭·some으로 특수화를 유도하는 편이 낫다 — 다형성을 유지하면서 성능도 얻는다.

쉽게 말하면 명부를 안 보고 바로 부르는 게 빠르긴 하다. 그런데 사람마다 다르게 해야 할 일을 그렇게 처리하면, 나중에 누가 자기 방식을 정해도 반영이 안 된다. 빠른 것보다 맞는 것이 먼저다.

Q5. package 접근 수준은 무엇을 푸는가?

🔑 30초 답변

internal(같은 모듈)과 public(누구나) 사이의 빈자리를 메운다. SPM·Tuist로 앱을 여러 모듈로 쪼개면 "우리 패키지 안 다른 모듈에는 보이되 그 패키지를 가져다 쓰는 외부에는 안 보이는" 자리가 없어, 팀들이 내부 통로까지 public으로 뚫게 된다. SE-0386의 package가 그 자리다. 결정적으로, 컴파일러가 "같은 패키지"를 판정하는 기준은 디렉터리 구조가 아니라 오직 동일한 -package-name 컴파일 플래그다. SwiftPM을 쓰면 한 Package.swift 안의 모든 타깃에 매니페스트의 name이 자동 주입된다.

CS 원리

접근 제어의 목적은 변경 가능한 표면적을 줄이는 것이다. 공개된 것은 바꾸면 남이 깨지므로 사실상 계약이 된다 — 31장 Q7·32장 Q2·33장 Q1에서 반복된 원리다.

문제는 모듈 분할이 접근 제어의 단위와 어긋난다는 데 있다. 하나의 논리적 제품이 여러 모듈로 나뉘면, 제품 내부인데 모듈은 다른 상황이 생긴다.

수준보이는 범위계약 부담
private선언 스코프없음
internal같은 모듈없음
package같은 패키지의 다른 모듈없음
public누구나SemVer 부담

package의 값어치는 SemVer 부담이 없다는 점이다. 모듈 밖 어디에도 노출되지 않으므로 시그니처를 자유롭게 바꿀 수 있다. public으로 뚫으면 그 순간부터 남이 쓸 수 있고, 바꾸면 깨진다.

부수 효과로 최적화 여지도 지킨다 — Q3에서 본 대로 public 클래스는 모듈 밖 서브클래싱 가능성 때문에 비가상화가 막힌다. package는 패키지 밖으로 나가지 않으므로 그 제약이 덜하다.

재미있는 점은 접근 제어가 컴파일러에게는 가시성 문제이지 권한 검사가 아니라는 것이다. 접근 불가 시 에러가 "권한 없음"이 아니라 "스코프에 없음"으로 뜬다.

모듈을 쪼개면 생기는 빈자리를 메운다 우리 패키지 CoreKit package func helper() FeatureA helper() 보인다 ✅ FeatureB helper() 보인다 ✅ 이 패키지를 가져다 쓰는 외부 앱 helper() 안 보인다 — SemVer 부담이 없다 "같은 패키지"의 판정 기준 ❌ 디렉터리 구조가 아니다 ✅ 동일한 -package-name 플래그
SwiftPM은 한 Package.swift 안 모든 타깃에 매니페스트 name을 자동 주입한다.

iOS에서는

선언은 다른 접근 수준과 같은 방식이다.

// CoreKit 모듈
package struct InternalConfig {          // 패키지 안에서만 보인다
    package let endpoint: URL
    package init(endpoint: URL) { self.endpoint = endpoint }
}

package func makeClient(_ c: InternalConfig) -> Client { … }

// FeatureA 모듈 — 같은 패키지라 보인다
import CoreKit
let client = makeClient(InternalConfig(endpoint: url))   // ✅

// 외부 앱 — 안 보인다
import CoreKit
// makeClient(...)      // ❌ cannot find 'makeClient' in scope

SwiftPM에서는 자동으로 동작한다.

// Package.swift — name 이 곧 package-name 이 된다
let package = Package(
    name: "MyApp",                    // ← 이 이름이 모든 타깃에 -package-name 으로 주입된다
    targets: [
        .target(name: "CoreKit"),
        .target(name: "FeatureA", dependencies: ["CoreKit"]),
    ]
)

수동 빌드에서는 플래그를 직접 맞춰야 한다.

# 같은 -package-name 이면 package 심볼이 보인다
swiftc -emit-module -module-name CoreKit -package-name MyApp CoreKit.swift
swiftc -emit-module -module-name FeatureA -package-name MyApp -I . FeatureA.swift   # ✅

# 다르면 "cannot find in scope" 로 실패한다
swiftc -emit-module -module-name FeatureB -package-name Other -I . FeatureB.swift   # ❌

Swift 6.0의 SE-0409import 선언에도 접근 수준을 붙일 수 있게 해 의존성 자체가 새는 것도 막는다.

package import CoreKit      // CoreKit 의존이 이 모듈 밖으로 전파되지 않는다
internal import Logging     // 이 모듈 내부에서만 쓴다
//   공개 API 시그니처에 그 타입이 등장하면 컴파일 에러로 막힌다

실험 · 도구

판정 기준이 정말 플래그인지 직접 확인할 수 있다.

mkdir -p /tmp/pkg && cd /tmp/pkg

cat > Core.swift <<'SWIFT'
package func secret() -> Int { 42 }
SWIFT

cat > User.swift <<'SWIFT'
import Core
public func use() -> Int { secret() }
SWIFT

# 같은 package-name — 성공
swiftc -emit-module -module-name Core -package-name MyPkg Core.swift
swiftc -typecheck -module-name User -package-name MyPkg -I . User.swift && echo "✅ 같은 패키지"

# 다른 package-name — 실패
swiftc -typecheck -module-name User -package-name Other -I . User.swift \
  || echo "❌ 다른 패키지 — cannot find 'secret' in scope"

기존 코드에서 publicpackage로 내릴 후보를 찾는다.

# public 인데 외부에서 안 쓰이는 심볼 찾기 (근사)
grep -rhoE "public (func|struct|class|enum|var|let) +[A-Za-z_]+" \
     --include=*.swift Sources/ | awk '{print $3}' | sort -u > /tmp/publics.txt

# 앱 타깃에서 실제로 참조되는지 대조
while read sym; do
  n=$(grep -rc "\b$sym\b" App/ 2>/dev/null | awk -F: '{s+=$2} END {print s+0}')
  [ "$n" = "0" ] && echo "package 후보: $sym"
done < /tmp/publics.txt | head -20

프로젝트 적용

  1. 모듈 간 통로를 package로 내린다. public으로 뚫린 내부 API가 가장 흔한 개선 대상이다.
  2. public은 진짜 외부 계약에만 남긴다. 그래야 SemVer 관리 범위가 명확해진다.
  3. package import로 의존 전파를 막는다. 내부 로깅 라이브러리가 외부까지 노출되는 문제를 끊는다.
  4. 수동 빌드 시스템을 쓴다면 -package-name을 통일한다. 디렉터리 구조가 아니라 플래그가 기준이다.
  5. 내리는 작업은 점진적으로 한다. publicpackage외부 사용처가 없을 때만 안전하다.
⚠️ 흔한 오해

"packagepublic과 별 차이 없다"는 틀렸다package 심볼은 모듈 밖 어디에도 노출되지 않아 SemVer 부담이 없고 시그니처를 자유롭게 바꿀 수 있다. "같은 폴더에 있으면 같은 패키지"도 틀렸다 — 기준은 -package-name 플래그다. "접근 제어 위반은 권한 에러로 뜬다"도 틀렸다 — "스코프에 없음"으로 뜬다. 접근 제어가 컴파일러에게는 가시성 문제이지 별도 권한 검사가 아니기 때문이다. "package를 쓰면 빌드가 느려진다"도 근거가 없다.

🧒 쉽게 이해하기

회사에 여러 부서가 있다고 하자. 문서를 공개하는 범위가 예전에는 두 가지뿐이었다.

"우리 부서만" 아니면 "전 세계 누구나". 중간이 없었다.

그래서 옆 부서에 자료를 주려면 어쩔 수 없이 전 세계 공개로 올려야 했다. 그런데 한번 전 세계에 공개하면 바깥 사람들도 그걸 보고 일을 하기 시작한다. 그러면 우리가 그 문서를 고칠 때마다 바깥 사람들이 곤란해진다.

새로 생긴 게 "우리 회사 안에서만"이다. 옆 부서는 볼 수 있는데 바깥에는 안 보인다. 그러니 마음대로 고쳐도 아무도 안 다친다.

여기서 재미있는 게 하나 있다. "같은 회사"인지 판정하는 기준이 건물 위치가 아니라 서류에 적힌 회사 이름이다. 같은 이름을 적어 내면 같은 회사고, 다른 이름이면 다른 회사다.

꼬리 질문

모듈을 몇 개로 나누는 것이 적절한가?

정답은 없지만 판단 축이 셋 있다.

(1) 빌드 시간. 자주 바뀌는 코드와 안 바뀌는 코드를 분리하면 재빌드 범위가 줄어든다. 이것이 가장 실질적인 이득인 경우가 많다.

(2) 의존 방향 강제. 모듈 경계는 컴파일러가 강제하는 의존 규칙이다. 같은 모듈 안에서는 "이 계층이 저 계층을 참조하면 안 된다"를 사람이 지켜야 하지만, 모듈로 나누면 빌드가 막는다. P0 17장의 의존 규칙이 도구로 내려온 것이다.

(3) 최적화 범위. Q3에서 본 대로 WMO는 모듈 단위다. 잘게 쪼개면 모듈 간 인라인·비가상화 기회를 잃는다.

실무 지침으로 정리하면 이렇다.

  • 기능 단위로 나눈다 — 팀 경계와 정렬되면 더 좋다.
  • 뜨거운 코드는 같은 모듈에 둔다.
  • 공통 기반은 하나로 — 여러 모듈이 조금씩 나눠 가지면 순환 의존이 생긴다.
  • 측정하고 조정한다. 나눈 뒤 빌드 시간과 콜드 스타트를 재 본다.
쉽게 말하면 작업장을 방으로 나누면 정리와 규칙 지키기가 쉬워지고 한 방만 고치면 되니 빠르다. 대신 방을 오가는 시간전체를 아우르는 개선 기회를 잃는다. 너무 크지도 잘지도 않게가 답이다.
public을 package로 내릴 때 무엇을 확인해야 하는가?

외부 사용처가 정말 없는지를 확인하는 것이 전부인데, 이게 생각보다 까다롭다.

확인할 곳들이다.

  • 앱 타깃 — 가장 흔한 소비자.
  • 테스트 타깃@testable importinternal까지 보므로 문제없지만, 일반 import로 쓰고 있으면 깨진다.
  • 앱 익스텐션 — 별도 타깃이라 놓치기 쉽다(P1 24장).
  • 다른 패키지 — 같은 저장소 안이어도 Package.swift가 다르면 다른 패키지다.
  • 외부 배포 — 이 패키지를 남이 쓰고 있다면 깨는 변경이다.

안전한 절차는 이렇다.

  1. 내려 보고 컴파일한다. 가장 확실한 검증이다 — 쓰이는 곳이 있으면 빌드가 실패한다.
  2. 모든 타깃을 빌드한다. 익스텐션·테스트를 포함해야 한다.
  3. 외부 배포 여부를 확인한다. 배포 중이면 deprecated 기간을 두는 편이 안전하다.
# 모든 타깃을 한 번에 빌드해 확인
xcodebuild build -scheme AppAllTargets -destination 'generic/platform=iOS'
swift build --build-tests
쉽게 말하면 공개하던 문서를 회사 안으로 내리기 전에, 밖에서 그걸 보고 일하는 사람이 없는지 확인해야 한다. 가장 확실한 방법은 내려 보고 누가 항의하는지 보는 것이다 — 여기서는 빌드 실패가 항의다.
package import는 구체적으로 무엇을 막는가?

의존성이 공개 API를 타고 새어 나가는 것을 막는다.

문제 상황은 이렇다. 우리 모듈이 내부적으로 로깅 라이브러리를 쓰는데, 공개 API 시그니처에 그 타입이 등장하면 우리를 쓰는 쪽도 그 라이브러리를 알아야 한다.

import Logging          // 일반 import

public struct Service {
    public func run(logger: Logger) { … }    // ⚠️ Logger 가 공개 API 에 노출된다
}
//   우리 모듈을 쓰는 쪽이 Logging 을 import 해야 한다 — 의존이 전파됐다
internal import Logging      // ✅ 이 모듈 내부에서만 쓴다

public struct Service {
    private let logger: Logger           // ✅ 내부 사용은 문제없다
    public func run() { logger.info("…") }
    // public func run(logger: Logger)   // ❌ 컴파일 에러 — 공개 API 에 못 쓴다
}

이것이 중요한 이유는 의존 전파가 연쇄되기 때문이다. A가 B를 노출하고 B가 C를 노출하면, A를 쓰는 쪽이 C까지 알아야 한다. P0 17장의 결합도 관리모듈 의존 그래프 수준에서 나타나는 문제다.

부수 효과로 빌드 시간도 개선된다 — 전파되지 않는 의존은 하위 모듈 재빌드를 유발하지 않는다.

쉽게 말하면 우리 부서가 어떤 도구를 쓰는지는 우리 사정이다. 그런데 옆 부서에 주는 서류에 그 도구 이름이 적혀 있으면, 옆 부서도 그 도구를 알아야 한다. 그러면 도구를 바꿀 때마다 옆 부서까지 영향을 받는다. 서류에는 도구 이름을 안 적는 게 맞다.

Q6. 전역 초기화는 언제 일어나며 무엇을 보장하는가?

🔑 30초 답변

static let·전역 letswift_once로 보호되고 lazy var는 아무 보장이 없다. 컴파일러는 타입 저장 프로퍼티와 전역의 최초 접근에 1회성 초기화 런타임 호출을 자동으로 심는다(SIL에서 builtin "once"). 이는 Objective-C dispatch_once동일한 계약이다 — 여러 스레드가 동시에 최초 접근해도 초기화는 정확히 한 번이다. 반면 lazy var는 이 보장이 전혀 없다. 실측으로 동시 접근 50회를 걸면 lazy var 초기화 클로저가 실행마다 14회·11회·11회로 다르게 실행됐지만, static let은 5회 반복 모두 정확히 1회였다. 그리고 static var는 최초 초기화만 보호되고 이후 변경은 무방비다.

CS 원리

지연 초기화는 "필요할 때 한 번만"이라는 요구를 푸는 것이고, 동시성이 들어오면 "한 번만"이 어려워진다.

순진한 구현은 이렇다 — "값이 없으면 만들고 저장한다". 그런데 두 스레드가 동시에 "없다"고 판단하면 둘 다 만든다. 전형적인 check-then-act 경쟁이다(P0 02장 Q3).

이를 막으려면 검사와 생성이 원자적이어야 한다. swift_once는 이 계약을 제공한다 — 여러 스레드가 진입해도 초기화 블록은 정확히 한 번 실행되고, 나머지는 완료를 기다렸다가 같은 값을 본다.

여기서 세 가지가 서로 다른 개념임을 구분해야 한다.

개념보장하는 것Swift에서
1회 초기화초기화가 정확히 한 번static let·전역 — 보장됨
원자성연산이 쪼개지지 않음static var 변경 — 보장 안 됨
스레드 세이프티동시 접근이 안전별도 동기화 필요

이 구분이 실무에서 자주 뭉개진다. static var count를 만들고 count += 1을 여러 스레드에서 하면, 초기화는 안전하지만 증가는 안전하지 않다. 실측으로 10,000번 동시 실행하면 기대값 10000 대신 2607·2551·2492 같은 값이 나온다.

lazy var가 보장을 주지 않는 이유는 인스턴스 프로퍼티이기 때문이다. 전역과 달리 인스턴스마다 존재하므로, 모든 인스턴스에 once 토큰을 두면 비용이 크다. Swift는 성능을 택하고 보장을 포기했고, TSPL도 이를 명시적으로 경고한다.

동시 접근 50회를 걸었을 때 실측 결과 static let · 전역 let swift_once (SIL: builtin "once") 5회 반복 모두 정확히 1회 dispatch_once 와 동일한 계약 lazy var 보장이 전혀 없다 (TSPL 이 명시적으로 경고) 실행마다 14회 · 11회 · 11회 인스턴스마다 토큰을 두는 비용을 피한 선택 그리고 1회 초기화 ≠ 원자성 ≠ 스레드 세이프티 static var count = 0 최초 초기화는 보호된다 이후 변경은 무방비 count += 1 을 10,000번 동시에 기대 10000 → 실측 2607 · 2551 · 2492 읽기-수정-쓰기가 쪼개진다 P0 02장 Q3 의 데이터 경쟁이 전역 상태에서 나타난 형태다
같은 static이라도 letvar의 보장이 전혀 다르다.

iOS에서는

싱글턴 패턴이 안전한 이유가 swift_once다.

final class Analytics {
    static let shared = Analytics()      // ✅ 정확히 한 번 초기화된다
    private init() { /* 무거운 준비 작업 */ }
}
//   여러 스레드가 동시에 Analytics.shared 를 처음 만져도 init 은 한 번만 돈다

lazy var는 다르다.

final class ViewModel {
    lazy var formatter = makeExpensiveFormatter()   // ⚠️ 동시 접근 시 여러 번 실행될 수 있다
}

// 안전하게 하려면 액터에 가두거나
actor SafeModel {
    lazy var formatter = makeExpensiveFormatter()   // 액터 격리로 직렬화된다
}

// 메인 액터에 두거나
@MainActor final class UIModel {
    lazy var formatter = makeExpensiveFormatter()   // 메인에서만 접근되므로 안전
}

static var의 함정은 더 자주 사고를 낸다.

// ❌ 초기화만 안전하고 변경은 경쟁한다
enum Counter { static var count = 0 }
// 여러 스레드에서 Counter.count += 1 → 값이 유실된다

// ✅ 동기화를 명시한다
import Synchronization
enum SafeCounter {
    static let value = Atomic<Int>(0)          // Swift 6 Synchronization (35장)
}
SafeCounter.value.add(1, ordering: .relaxed)

// ✅ 또는 액터로 감싼다
actor CounterActor { private(set) var count = 0; func inc() { count += 1 } }

Swift 6의 엄격 동시성은 이 문제를 컴파일 타임에 잡는다 — 전역 가변 상태에 대해 동시성 안전을 증명하라고 요구한다(35장).

실험 · 도구

보장 차이를 직접 재현해 보자.

import Foundation

final class LazyBox {
    var initCount = 0
    lazy var value: Int = { initCount += 1; Thread.sleep(forTimeInterval: 0.001); return 42 }()
}

// lazy var — 동시 접근 시 여러 번 실행된다
for trial in 1...3 {
    let box = LazyBox()
    DispatchQueue.concurrentPerform(iterations: 50) { _ in _ = box.value }
    print("lazy var 시도 \(trial): 초기화 \(box.initCount)회")
}
// lazy var 시도 1: 초기화 14회
// lazy var 시도 2: 초기화 11회
// lazy var 시도 3: 초기화 11회

// static let — 항상 1회
enum OnceBox {
    nonisolated(unsafe) static var initCount = 0
    static let value: Int = { initCount += 1; return 42 }()
}
DispatchQueue.concurrentPerform(iterations: 50) { _ in _ = OnceBox.value }
print("static let: 초기화 \(OnceBox.initCount)회")     // 항상 1회

static var의 원자성 부재도 확인된다.

enum Racy { nonisolated(unsafe) static var count = 0 }
DispatchQueue.concurrentPerform(iterations: 10_000) { _ in Racy.count += 1 }
print(Racy.count)      // 10000 이 아니다 — 2607 / 2551 / 2492 같은 값
# SIL 에서 once 호출을 확인한다
swiftc -emit-sil /tmp/once.swift 2>/dev/null | grep -B2 -A2 'builtin "once"'

# Thread Sanitizer 로 실제 경쟁을 잡는다 (P0 15장 Q6)
xcodebuild test -scheme App -enableThreadSanitizer YES

프로젝트 적용

  1. 지연 초기화가 필요하면 static let을 쓴다. lazy var단일 스레드 접근이 확실할 때만.
  2. lazy var를 쓸 거면 격리를 명시한다. @MainActor나 액터 안에 두면 안전해진다.
  3. static var를 전역 가변 상태로 쓰지 않는다. 초기화만 안전할 뿐 변경은 무방비다.
  4. Swift 6 엄격 동시성을 켠다. 이 문제군을 컴파일 타임에 잡아 준다(35장).
  5. Thread Sanitizer를 CI에 넣는다. 이 계열 경쟁은 재현이 드물어 수동 테스트로는 안 잡힌다.
⚠️ 흔한 오해

"lazy varstatic let처럼 한 번만 초기화된다"는 틀렸다보장이 전혀 없고 TSPL이 명시적으로 경고한다. "static이면 스레드 안전하다"도 틀렸다초기화만 보호되고 static var이후 변경은 무방비다. "1회 초기화가 보장되면 원자적"이라는 것도 다른 개념이다. "싱글턴은 원래 위험하다"도 부정확하다 — 초기화는 안전하고, 위험한 것은 그 안의 가변 상태다. "lazy var가 여러 번 실행돼도 값은 같으니 괜찮다"도 틀렸다 — 초기화에 부수 효과가 있으면 그것이 여러 번 일어난다.

🧒 쉽게 이해하기

"필요할 때 한 번만 만들기"라는 규칙이 있다. 그런데 여러 사람이 동시에 필요해지면 문제가 생긴다.

둘이 동시에 "어? 아직 없네" 하고 판단하면 둘 다 만든다. 하나만 있어야 하는데 둘이 생긴다.

Swift에는 이걸 제대로 막아 주는 방식이 있다 — static let이다. 여러 명이 동시에 몰려와도 한 명만 만들고 나머지는 기다렸다가 그걸 받는다. 실제로 50명을 동시에 보내도 딱 한 번만 만들어진다.

그런데 lazy var그런 보호가 없다. 같은 실험을 하면 11번, 14번 이렇게 제각각 만들어진다.

더 헷갈리는 게 하나 더 있다. static var숫자를 세는 경우다. 만드는 건 한 번만 하는데, 그 뒤에 하나씩 더하는 건 보호가 안 된다. 만 번을 동시에 더하면 2500번쯤밖에 안 세어진다. 서로 덮어쓰기 때문이다.

그러니 "한 번만 만드는 것"과 "안전하게 고치는 것"은 다른 문제다.

꼬리 질문

전역 초기화가 앱 시작 시간에 영향을 주는가?

주지만, 지연 초기화라 첫 접근 시점에 분산된다는 점이 중요하다.

Swift의 전역·static let프로그램 시작 시가 아니라 최초 접근 시 초기화된다. 그래서 Objective-C의 +load나 C++ 정적 초기화와 달리 시작 시간에 몰리지 않는다.

다만 주의할 점이 있다.

  • 앱 시작 직후 바로 접근하면 결국 시작 경로에 들어온다. 싱글턴을 application(_:didFinishLaunching…)에서 다 만지면 지연의 이점이 사라진다.
  • +load·__attribute__((constructor))는 다르다 — 이들은 진짜 시작 시점에 실행되어 P1 26장에서 본 pre-main 구간을 늘린다.
  • 초기화가 무거우면 첫 접근이 메인스레드 행이 될 수 있다(P1 20장).
// ⚠️ 시작 직후 전부 만지면 지연의 이점이 사라진다
func application(_ app: UIApplication, didFinishLaunchingWithOptions o: [...]) -> Bool {
    _ = Analytics.shared
    _ = Database.shared
    _ = ImageCache.shared      // 전부 여기서 초기화된다
    return true
}

// ✅ 정말 필요한 것만 시작 시에, 나머지는 첫 사용 시점에
func application(...) -> Bool {
    Analytics.shared.start()   // 크래시 수집은 이른 시점이 맞다 (P1 18장)
    return true
}

측정은 Instruments App Launch로 한다(P1 22장 Q7).

쉽게 말하면 "필요할 때 만들기"는 시작을 가볍게 해 준다. 그런데 문 열자마자 전부 한 번씩 만져 버리면 결국 시작할 때 다 만드는 것과 같아진다.
Swift 6에서 전역 가변 상태는 어떻게 다루어야 하는가?

컴파일러가 안전을 증명하라고 요구하므로, 셋 중 하나를 골라야 한다.

(1) 불변으로 만든다. 가장 좋다. let이고 Sendable이면 아무 문제가 없다.

enum Config { static let apiBase = URL(string: "https://api.example.com")! }   // ✅

(2) 격리한다. 전역 액터에 붙이면 접근이 직렬화된다.

@MainActor final class AppState { static let shared = AppState(); var count = 0 }
//   메인 액터 격리로 동시 접근이 막힌다

(3) 동기화 기본 요소를 쓴다. Swift 6의 Synchronization 모듈이 Mutex·Atomic을 제공한다(35장).

import Synchronization
enum Metrics {
    static let counter = Atomic<Int>(0)
}
Metrics.counter.add(1, ordering: .relaxed)     // ✅ 원자적

피해야 할 것nonisolated(unsafe)다 — 컴파일러 검사를 끄는 탈출구일 뿐이고, 실제 동기화가 없으면 거짓 약속이다(P0 17장의 @unchecked Sendable과 같은 성질).

마이그레이션 순서는 불변 → 격리 → 동기화다. 대부분은 첫 단계에서 해결된다 — 사실 상수였던 것var로 선언돼 있는 경우가 많다.

쉽게 말하면 여럿이 함께 쓰는 물건이 있으면, 아예 안 바뀌게 만들거나, 한 사람만 만지게 정하거나, 차례를 지키는 장치를 달아야 한다. "알아서 조심할게요"는 규칙이 아니다.
lazy var를 안전하게 쓰는 방법은 없는가?

접근을 단일 스레드로 제한하면 안전하다. 그것이 유일하고 확실한 방법이다.

실무 패턴이 셋이다.

(1) @MainActor 격리. UI 관련 객체는 대개 여기 해당한다.

@MainActor final class ViewModel {
    lazy var formatter = makeFormatter()      // 메인에서만 접근되므로 안전
}

(2) 액터 안에 둔다. 액터 격리가 접근을 직렬화한다.

(3) static let으로 올린다. 인스턴스마다 다를 필요가 없으면 이쪽이 가장 단순하다.

// 인스턴스마다 필요 없으면 static let 이 낫다
enum Formatters { static let iso = ISO8601DateFormatter() }

직접 락을 거는 방법도 있지만 권장하지 않는다lazy의 편의를 잃고 코드만 복잡해진다. 차라리 명시적 계산 프로퍼티 + 저장소로 쓰는 편이 읽기 쉽다.

// 직접 관리할 거면 lazy 를 쓰지 않는다
private var _cache: Formatter?
private let lock = NSLock()
var formatter: Formatter {
    lock.lock(); defer { lock.unlock() }
    if let c = _cache { return c }
    let f = makeFormatter(); _cache = f; return f
}
//   동작하지만, 대개는 @MainActor 나 static let 이 더 낫다
쉽게 말하면 lazy var혼자 쓸 때만 안전하다. 여럿이 쓸 거면 한 사람만 만지게 정하거나, 모두가 공유하는 하나로 올려 버리는 게 낫다.

출처 · 참고자료