32장이 타입 시스템이 무엇을 보장하는가였다면, 이 챕터는 그 아래층 — 컴파일러가 실제로 무엇을 생성하고 런타임이 무엇을 보장하는가다.
여기서 다루는 것들은 대부분 평소에는 안 보이다가 특정 조건에서 갑자기 문제가 된다. 프레임워크로 분리하는 순간 성능이 떨어지고, 프로토콜 타입으로 호출하는 순간 다른 구현이 돌고, 동시에 접근하는 순간 초기화가 여러 번 일어난다.
P0 14장에서 컴파일·링크·dyld 로드의 흐름을, P0 05장에서 Swift 런타임의 값·참조 표현을 봤다. 이 챕터는 그 둘이 만나는 자리 — ABI 경계를 다룬다.
Q1. 라이브러리 진화는 무엇을 고정하고 무엇을 대가로 치르는가?
클라이언트 재컴파일 없이 프레임워크를 교체할 수 있게 하는 대신, 필드 접근이 함수 호출이 된다. 일반 앱 타깃은 컴파일러가 모듈 전체를 한 번에 보므로 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에서 본 "이미 배포된 소비자가 제약을 만든다"는 원리가 여기서는 기계어 수준으로 나타난다.
탈출구가 명시적 약속이다.
@frozen— "레이아웃이 앞으로도 절대 안 바뀐다"는 약속. 직접 필드 접근을 되찾는 대신 이후 저장 프로퍼티 추가·삭제·재배치가 전부 ABI 파괴가 된다.@inlinable— 함수 구현부 자체를 공개 인터페이스로 노출해 클라이언트가 인라인·특수화할 수 있게 한다. 그 본문이 참조하는 internal 심볼에는@usableFromInline이 필요하다.
둘 다 구현 세부를 계약으로 승격시키는 행위다. 성능을 얻는 대신 바꿀 자유를 판다.
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 가 인라인 본문에 등장한다
}
언제 켜지는지를 알아야 판단할 수 있다.
- 일반 앱 타깃 — 꺼짐. 이 챕터의 구분이 무의미하다.
- SPM 로컬 패키지 — 기본적으로 꺼짐. 소스에서 함께 빌드된다.
- XCFramework로 배포하는 바이너리 프레임워크 — 켜야 한다(
BUILD_LIBRARY_FOR_DISTRIBUTION = YES). - Apple 시스템 프레임워크 — 켜져 있다. 그래서 32장 Q7의
@unknown default규칙이 적용된다.
실험 · 도구
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 .
프로젝트 적용
- 앱 타깃에서는 신경 쓰지 않는다. 이 설정이 꺼져 있으면
@frozen·@inlinable은 의미가 없다. - 바이너리 프레임워크를 배포한다면 성능 영향을 측정한다. 뜨거운 경로의
struct접근이 게터 호출이 된다. @frozen은 정말 안 바뀔 타입에만 붙인다. 좌표·색상처럼 수학적으로 확정된 구조가 후보다.@inlinable은 작고 안정적인 함수에만 붙인다. 본문이 영원히 계약이 되므로 버그 수정도 어려워진다.- 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는 정말 공짜인가?
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가 실존 박싱을 피하는 것과는 별개 문제다.
iOS에서는
실무 판단은 "에러가 예외적인가, 흔한가"다.
// ✅ 에러가 예외적 — throws 가 적합하다
func loadConfig() throws -> Config { … } // 실패는 드물다
// ⚠️ 에러가 흔한 흐름 — 반환 타입으로 표현하는 편이 나을 수 있다
func parseInt(_ s: String) throws -> Int { … } // 실패가 자주 일어난다면?
func parseInt2(_ s: String) -> Int? { Int(s) } // 옵셔널이 더 자연스럽다
옵셔널과 throws의 선택 기준이 여기서 나온다.
- 실패 이유가 하나뿐이고 자명하면 — 옵셔널.
Int("abc")가nil인 이유는 명백하다. - 실패 이유를 알려야 하면 —
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)")
// 에러 경로가 눈에 띄게 느리면 그 경로가 뜨거운지 검토한다
프로젝트 적용
- 에러가 예외적이면
throws를 그대로 쓴다. 대부분의 경우가 여기 해당한다. - 실패가 흔하고 이유가 자명하면 옵셔널을 고려한다.
Int(_:)·Dictionary조회가 옵셔널인 이유다. - 뜨거운 루프에서 에러를 던지지 않는다.
swift_allocError가 반복된다 — 루프 밖에서 검증하거나 옵셔널로 바꾼다. - "
throws가 느리다"는 이유로 피하지 않는다. 정상 경로 비용은 명령 두 개 수준이다. - 에러 연관값을 가볍게 유지한다. 큰 데이터를 담으면 할당 비용이 는다 — 필요하면 지연 생성한다.
"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. 비가상화는 언제 일어나는가?
"이 호출 지점에서 가능한 구현이 정확히 하나뿐"임을 컴파일러가 증명할 수 있을 때만 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장), 성능은 확실히 포기하는 선택이다.
iOS에서는
SIL에서 class_method가 function_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() }
// 모듈 밖에서 서브클래싱될 수 있어 비가상화되지 않는다
실무에서 효과가 큰 조치는 순서가 있다.
- WMO가 켜져 있는지 확인한다. 릴리스 빌드의 기본값이지만 설정이 바뀌어 있을 수 있다.
- 모듈 밖에 공개할 이유가 없는 클래스를
internal로 둔다.public이 기본값처럼 붙어 있으면 최적화 기회가 사라진다. - 확장 가능성이 없는 클래스에
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).
프로젝트 적용
- 릴리스 빌드의 WMO를 확인한다. 꺼져 있으면 가장 큰 최적화를 놓치고 있다.
final을 기본으로 삼는다. 상속이 설계의 일부일 때만 열어 둔다. 의도가 드러나는 부수 효과도 크다.- 접근 수준을 필요 이상으로 넓히지 않는다.
public이 습관적으로 붙어 있으면 최적화가 막힌다(Q5의package가 대안이다). dynamic은 정말 필요할 때만 쓴다. KVO·스위즐링이 아니면 대개 불필요하다.- 측정 없이
final을 남발하지 않는다. 성능보다 설계 의도 표현이 주된 이유여야 한다.
"final을 안 쓰면 무조건 vtable 호출"은 틀렸다 — WMO가 켜져 있고 그 클래스가 모듈 밖에 공개되지 않았다면 final 없이도 비가상화될 수 있다. "private으로 좁히면 자동으로 인라인까지 보장된다"도 틀렸다 — 비가상화는 인라인의 전제조건 중 하나일 뿐이고, 실제 인라인 여부는 함수 크기 등 별도 휴리스틱이 정한다. "public이어도 WMO면 최적화된다"도 틀렸다 — 모듈 밖 서브클래싱 가능성이 증명을 막는다. "struct를 쓰면 디스패치 문제가 없다"도 부분적으로만 맞다 — 프로토콜 요구사항 호출은 witness table을 탄다(Q4).
"이 일은 누가 할까?"를 미리 정해 둘 수 있으면 빠르다. 그때그때 물어봐야 하면 느리다.
미리 정해 두려면 확실히 알아야 한다. 방법이 세 가지다.
"이 일은 오직 이 사람만 한다"고 못 박기 — 그러면 물어볼 필요가 없다.
작업장 전체를 둘러보고 확인하기 — "이 일을 할 수 있는 사람이 이 사람뿐이네" 하고 스스로 확인한다.
일을 작은 방 안으로 제한하기 — 방 안에 사람이 몇 없으면 확인이 쉽다.
그런데 작업장 밖에서 누가 들어올 수 있다면 아무리 둘러봐도 소용없다. 밖에서 새 사람이 와서 그 일을 대신할 수도 있으니까. 그래서 밖에 공개된 것은 최적화가 안 된다.
그리고 "이 일은 언제든 다른 사람으로 바꿀 수 있게 해 주세요"라고 요청하면(dynamic), 컴퓨터는 아예 미리 정하기를 포기한다. 필요할 때가 있지만 속도는 확실히 포기하는 것이다.
꼬리 질문
WMO를 켜면 빌드가 느려지지 않는가?
느려진다. 그래서 디버그는 incremental, 릴리스는 wholemodule이 기본 구성이다.
| incremental | wholemodule (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. 프로토콜 확장의 디스패치 함정은 무엇인가?
요구사항이면 동적, 확장 전용이면 정적으로 바인딩된다. 프로토콜 요구사항으로 선언된 메서드는 conforming 타입마다 witness table 슬롯을 가져, 어떤 정적 타입으로 부르든 항상 그 타입의 구현이 실행된다. 반면 확장에만 정의되고 요구사항 목록에 없는 메서드는 슬롯 자체가 없어 컴파일러가 호출부의 정적 타입만 보고 컴파일 타임에 결정한다. 그래서 같은 인스턴스라도 구체 타입 변수로 부르면 그 타입의 구현이, 프로토콜 타입 변수로 부르면 확장의 기본 구현이 실행되는 불일치가 생긴다. 해법은 하나뿐이다 — 나중에 다르게 구현될 가능성이 있으면 요구사항으로 선언한다.
CS 원리
이 함정의 뿌리는 "오버라이드처럼 보이지만 오버라이드가 아니다"라는 데 있다.
클래스 상속에서 메서드를 재정의하면 vtable 슬롯이 교체된다. 그래서 부모 타입 변수로 불러도 자식 구현이 돈다. 이것이 다형성이다.
프로토콜에서는 요구사항일 때만 같은 일이 일어난다. 요구사항은 witness table에 슬롯을 만들고, conforming 타입이 그 슬롯을 채운다. 슬롯이 있으므로 실행 시점에 어느 구현인지 조회할 수 있다.
확장 전용 메서드는 다르다. 슬롯이 없으므로 조회할 것이 없다. 컴파일러는 호출부에서 보이는 정적 타입으로 어느 함수를 부를지 정하고 그것을 박는다. conforming 타입이 같은 이름의 메서드를 정의해도, 그것은 오버라이드가 아니라 이름이 같은 별개의 정적 함수를 하나 더 만든 것에 가깝다.
중요한 점은 제네릭 안에서도 똑같이 재현된다는 것이다. <T: P>에서 T가 무엇으로 특수화되든, 확장 전용 메서드 호출은 제약 타입(P)의 정적 타입으로 결정된다.
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(),
"확장 전용 메서드라 정적 바인딩됐다 — 요구사항으로 올려야 한다")
}
// 이 테스트가 실패하면 그 메서드는 요구사항이 아니다
프로젝트 적용
- 재정의될 가능성이 있으면 요구사항으로 선언한다. "기본 구현만 쓸 것"이라고 확신할 때만 확장 전용으로 둔다.
- 테스트 대역을 쓰는 프로토콜은 특히 확인한다. 대역이 무시되는 버그는 테스트가 통과하는데 프로덕션이 다르게 동작하는 형태로 나타난다.
- 확장 전용 메서드는 이름을 구별한다. 요구사항과 같은 이름을 피하면 착각 자체가 줄어든다.
- 라이브러리 프로토콜을 커스터마이징할 때 인터페이스를 확인한다. 요구사항이 아니면 재정의해도 반영되지 않는다.
- 불일치 테스트를 핵심 프로토콜에 둔다. 리팩터링 중 요구사항이 확장으로 밀려나는 것을 잡는다.
"요구사항이 아닌 확장 메서드도 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) 기본 구현을 지워 본다. 확장의 기본 구현을 임시로 제거하면, 요구사항이 아닌 메서드는 컴파일 에러가 난다 — 어느 것이 확장 전용인지 즉시 드러난다.
근본 대책은 주입용 프로토콜에 기본 구현을 두지 않는 것이다. 편의 기본값이 필요하면 별도 프로토콜로 분리한다.
기본 구현을 제공하면서도 재정의를 보장하려면?
요구사항으로 선언하고 확장에 기본 구현을 둔다 — 이 조합이 정답이다.
protocol Cache {
func get(_ key: String) -> Data?
func clear() // ✅ 요구사항
}
extension Cache {
func clear() { /* 아무것도 안 함 */ } // 기본 구현
}
// 요구사항이므로 witness table 슬롯이 생기고,
// 재정의하면 프로토콜 타입으로 불러도 그 구현이 돈다이 패턴의 이점이 둘이다.
- 선택적 구현 — conforming 타입이 필요 없으면 안 써도 된다.
- 다형성 유지 — 재정의하면 확실히 반영된다.
Objective-C의 @optional 프로토콜 메서드를 Swift에서 흉내 내는 표준 방법이기도 하다. @objc optional은 ObjC 런타임에 의존하고 값 타입에 쓸 수 없어, 순수 Swift에서는 이 패턴이 낫다.
주의할 점은 기본 구현이 조용히 아무것도 안 하면 구현을 빠뜨려도 모른다는 것이다. 중요한 메서드라면 기본 구현에서 로깅하거나 assertionFailure를 두어 개발 중에 드러나게 하는 방법도 있다.
이 함정이 성능과도 관계가 있는가?
있다 — 확장 전용이 오히려 빠르다. 정적 바인딩이므로 witness table 조회가 없고 인라인 여지가 크다.
그래서 트레이드오프가 성립한다.
| 요구사항 | 확장 전용 | |
|---|---|---|
| 디스패치 | witness table 간접 | 직접 호출 |
| 인라인 | 특수화되면 가능 | 쉽게 가능 |
| 다형성 | 보장됨 | 없음 |
다만 성능을 이유로 확장 전용을 고르는 것은 위험하다 — 다형성이 필요해지는 순간 조용한 버그가 된다. 순서는 이렇다.
- 기본은 요구사항으로 선언한다. 안전이 우선이다.
- 측정으로 그 호출이 병목임이 확인되면, 그때 확장 전용으로 내리거나 제네릭 특수화를 유도한다(Q3 꼬리질문).
- 내렸다면 재정의 금지를 문서화한다.
대부분의 경우 제네릭·some으로 특수화를 유도하는 편이 낫다 — 다형성을 유지하면서 성능도 얻는다.
Q5. package 접근 수준은 무엇을 푸는가?
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는 패키지 밖으로 나가지 않으므로 그 제약이 덜하다.
재미있는 점은 접근 제어가 컴파일러에게는 가시성 문제이지 권한 검사가 아니라는 것이다. 접근 불가 시 에러가 "권한 없음"이 아니라 "스코프에 없음"으로 뜬다.
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-0409는 import 선언에도 접근 수준을 붙일 수 있게 해 의존성 자체가 새는 것도 막는다.
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"
기존 코드에서 public을 package로 내릴 후보를 찾는다.
# 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
프로젝트 적용
- 모듈 간 통로를
package로 내린다.public으로 뚫린 내부 API가 가장 흔한 개선 대상이다. public은 진짜 외부 계약에만 남긴다. 그래야 SemVer 관리 범위가 명확해진다.package import로 의존 전파를 막는다. 내부 로깅 라이브러리가 외부까지 노출되는 문제를 끊는다.- 수동 빌드 시스템을 쓴다면
-package-name을 통일한다. 디렉터리 구조가 아니라 플래그가 기준이다. - 내리는 작업은 점진적으로 한다.
public→package는 외부 사용처가 없을 때만 안전하다.
"package는 public과 별 차이 없다"는 틀렸다 — package 심볼은 모듈 밖 어디에도 노출되지 않아 SemVer 부담이 없고 시그니처를 자유롭게 바꿀 수 있다. "같은 폴더에 있으면 같은 패키지"도 틀렸다 — 기준은 -package-name 플래그다. "접근 제어 위반은 권한 에러로 뜬다"도 틀렸다 — "스코프에 없음"으로 뜬다. 접근 제어가 컴파일러에게는 가시성 문제이지 별도 권한 검사가 아니기 때문이다. "package를 쓰면 빌드가 느려진다"도 근거가 없다.
회사에 여러 부서가 있다고 하자. 문서를 공개하는 범위가 예전에는 두 가지뿐이었다.
"우리 부서만" 아니면 "전 세계 누구나". 중간이 없었다.
그래서 옆 부서에 자료를 주려면 어쩔 수 없이 전 세계 공개로 올려야 했다. 그런데 한번 전 세계에 공개하면 바깥 사람들도 그걸 보고 일을 하기 시작한다. 그러면 우리가 그 문서를 고칠 때마다 바깥 사람들이 곤란해진다.
새로 생긴 게 "우리 회사 안에서만"이다. 옆 부서는 볼 수 있는데 바깥에는 안 보인다. 그러니 마음대로 고쳐도 아무도 안 다친다.
여기서 재미있는 게 하나 있다. "같은 회사"인지 판정하는 기준이 건물 위치가 아니라 서류에 적힌 회사 이름이다. 같은 이름을 적어 내면 같은 회사고, 다른 이름이면 다른 회사다.
꼬리 질문
모듈을 몇 개로 나누는 것이 적절한가?
정답은 없지만 판단 축이 셋 있다.
(1) 빌드 시간. 자주 바뀌는 코드와 안 바뀌는 코드를 분리하면 재빌드 범위가 줄어든다. 이것이 가장 실질적인 이득인 경우가 많다.
(2) 의존 방향 강제. 모듈 경계는 컴파일러가 강제하는 의존 규칙이다. 같은 모듈 안에서는 "이 계층이 저 계층을 참조하면 안 된다"를 사람이 지켜야 하지만, 모듈로 나누면 빌드가 막는다. P0 17장의 의존 규칙이 도구로 내려온 것이다.
(3) 최적화 범위. Q3에서 본 대로 WMO는 모듈 단위다. 잘게 쪼개면 모듈 간 인라인·비가상화 기회를 잃는다.
실무 지침으로 정리하면 이렇다.
- 기능 단위로 나눈다 — 팀 경계와 정렬되면 더 좋다.
- 뜨거운 코드는 같은 모듈에 둔다.
- 공통 기반은 하나로 — 여러 모듈이 조금씩 나눠 가지면 순환 의존이 생긴다.
- 측정하고 조정한다. 나눈 뒤 빌드 시간과 콜드 스타트를 재 본다.
public을 package로 내릴 때 무엇을 확인해야 하는가?
외부 사용처가 정말 없는지를 확인하는 것이 전부인데, 이게 생각보다 까다롭다.
확인할 곳들이다.
- 앱 타깃 — 가장 흔한 소비자.
- 테스트 타깃 —
@testable import는internal까지 보므로 문제없지만, 일반import로 쓰고 있으면 깨진다. - 앱 익스텐션 — 별도 타깃이라 놓치기 쉽다(P1 24장).
- 다른 패키지 — 같은 저장소 안이어도
Package.swift가 다르면 다른 패키지다. - 외부 배포 — 이 패키지를 남이 쓰고 있다면 깨는 변경이다.
안전한 절차는 이렇다.
- 내려 보고 컴파일한다. 가장 확실한 검증이다 — 쓰이는 곳이 있으면 빌드가 실패한다.
- 모든 타깃을 빌드한다. 익스텐션·테스트를 포함해야 한다.
- 외부 배포 여부를 확인한다. 배포 중이면 deprecated 기간을 두는 편이 안전하다.
# 모든 타깃을 한 번에 빌드해 확인
xcodebuild build -scheme AppAllTargets -destination 'generic/platform=iOS'
swift build --build-testspackage 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. 전역 초기화는 언제 일어나며 무엇을 보장하는가?
static let·전역 let은 swift_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도 이를 명시적으로 경고한다.
static이라도 let과 var의 보장이 전혀 다르다.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
프로젝트 적용
- 지연 초기화가 필요하면
static let을 쓴다.lazy var는 단일 스레드 접근이 확실할 때만. lazy var를 쓸 거면 격리를 명시한다.@MainActor나 액터 안에 두면 안전해진다.static var를 전역 가변 상태로 쓰지 않는다. 초기화만 안전할 뿐 변경은 무방비다.- Swift 6 엄격 동시성을 켠다. 이 문제군을 컴파일 타임에 잡아 준다(35장).
- Thread Sanitizer를 CI에 넣는다. 이 계열 경쟁은 재현이 드물어 수동 테스트로는 안 잡힌다.
"lazy var도 static 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는 혼자 쓸 때만 안전하다. 여럿이 쓸 거면 한 사람만 만지게 정하거나, 모두가 공유하는 하나로 올려 버리는 게 낫다.