← Swift & Foundation 로드맵 13 / 33

13 · 접근제어·ABI·매크로

package 접근 수준부터 라이브러리 진화(@inlinable/@frozen), 컴파일 타임 매크로, borrowing/consuming, ~Copyable까지 — API 경계를 설계하는 다섯 가지 도구.
진행률
0 / 0 완료

이 챕터는 "코드를 얼마나 넓게 노출할 것인가"라는 질문을 세 층위에서 다룬다. 소스 레벨에서는 접근 제어자(private~open)가 누가 무엇을 볼 수 있는지를 정하고, 바이너리 레벨에서는 라이브러리 진화(library evolution, 컴파일된 프레임워크가 소스 재컴파일 없이도 버전을 올릴 수 있게 하는 체계)가 그 경계를 ABI(Application Binary Interface, 컴파일된 바이너리끼리 맞춰야 하는 규약) 안정성으로 확장하며, 컴파일 타임 레벨에서는 매크로가 반복 코드를 자동 생성한다. 마지막 두 문항(borrowing/consuming, ~Copyable)은 "복사"라는 개념 자체를 API 계약에 끌어들인 가장 최근의 언어 확장이다. Part A(Swift 언어 자체를 다루는 구간)의 마지막 장이며, 다음 장부터는 이 위에서 동작하는 Foundation으로 넘어간다.

Q1. package(SE-0386)는 왜 필요했는가? internal/public 조합으로는 왜 부족했나?

🔑 30초 답변

iOS 앱을 Tuist/SPM으로 CoreKit·FeatureA·FeatureB처럼 여러 모듈로 쪼개면, internal은 그 모듈 하나 안에서만 보이고 public은 그 모듈을 import하는 아무에게나(외부 SDK 포함) 다 보인다. "우리 앱 패키지 안의 다른 모듈에는 보이되, 그 패키지를 가져다 쓰는 남에게는 안 보이는" 자리가 없었던 것이다. 그래서 팀들은 내부 연결 통로까지 public으로 뚫어 API 표면을 오염시키거나, 아예 모듈을 안 쪼개고 한 덩어리로 버텨 증분 빌드·병렬 컴파일 이득을 포기했다. SE-0386package-package-name 문자열이 같은 모듈끼리만 보이는 접근 수준을 추가해 정확히 이 빈 자리를 메운다.

원리

swift-book AccessControl이 정의하는 접근 수준은 제약이 강한 순서로 6단계다: private(선언 자체와 같은 파일의 확장까지) → fileprivate(같은 소스 파일) → internal(같은 모듈, 기본값) → package(같은 패키지) → public(모듈 밖 어디서나, 단 상속·오버라이드는 불가) → open(모듈 밖 + 상속·오버라이드까지 허용). 지도 원칙은 하나다 — "더 제약이 강한 것으로 정의된 개체를 그보다 넓은 것에 노출할 수 없다"(public 함수가 internal 타입을 반환 타입으로 쓸 수 없는 이유). Swift 6.0의 SE-0409import 선언에도 접근 수준을 붙여(package import 등) 의존성 자체가 실수로 밖에 노출되는 것까지 막았다.

내부 동작

컴파일러가 "같은 패키지"를 판정하는 기준은 디렉터리 구조나 git 저장소가 아니라 딱 하나 — 두 모듈이 같은 -package-name 문자열로 컴파일됐는가다. SwiftPM을 쓰면 이 플래그를 직접 넘길 필요가 없다. 하나의 Package.swift 매니페스트 안의 모든 타겟에는 그 패키지의 name이 자동으로 -package-name으로 주입된다. 실제로 두 개의 로컬 타겟(Core, Feature)을 가진 패키지를 swift build -v로 빌드해 보면 두 타겟 모두 -package-name pkgtest가 컴파일 커맨드에 박혀 나온다 — 패키지 이름 하나가 그 안의 모든 타겟을 하나의 접근 영역으로 묶는 것이다. 반대로 별도의 Package.swift를 가진 다른 패키지(예: 사내 분석 SDK)가 로컬 경로로 CoreKit을 의존성에 추가해도, 그 패키지는 자기 자신의 이름으로 -package-name을 받기 때문에 CoreKit의 package 심볼은 여전히 안 보인다.

접근 수준 6단계 — 오른쪽으로 갈수록 노출 범위가 넓어진다 private 선언+파일 확장 fileprivate 같은 파일 internal 같은 모듈(기본값) package 같은 패키지 public 모듈 밖 전체 open 밖 + 상속 허용 internal과 public 사이의 빈 자리를 package가 채운다 Tuist/SPM 멀티 모듈 iOS 앱 AppPackage — 같은 -package-name CoreKit package func retry() FeatureA import CoreKit FeatureB import CoreKit 분석 SDK (다른 -package-name) import CoreKit — OK retry() 호출 — ✗ public 심볼만 보임
package는 같은 -package-name으로 컴파일된 모듈끼리만 통하는 통로다. 디렉터리가 아니라 빌드 플래그가 "같은 패키지"를 정한다.

실험 · 도구

SwiftPM 없이 swiftc만으로도 직접 재현할 수 있다.

같은 -package-name vs 다른 -package-name — 직접 컴파일해서 비교
// CoreLib.swift
package func packageOnly() -> String { "core internals" }
public func publicAPI() -> String { packageOnly() }

// 1) CoreLib을 패키지 이름 appPkg로 빌드
swiftc -emit-module -module-name CoreLib -package-name appPkg \
  -emit-module-path /tmp/CoreLib.swiftmodule \
  -emit-library -o /tmp/libCoreLib.dylib CoreLib.swift

// 2) Client.swift(import CoreLib; packageOnly() 호출)를 같은 이름으로 타입체크 → 통과
swiftc -I /tmp -module-name Client -package-name appPkg -typecheck Client.swift
// EXIT=0

// 3) 패키지 이름만 다르게(otherPkg) 주고 다시 타입체크 → 실패
swiftc -I /tmp -module-name Client -package-name otherPkg -typecheck Client.swift
// error: cannot find 'packageOnly' in scope

실제로 위 3번 커맨드를 실행하면 컴파일러는 packageOnly라는 이름을 아예 모르는 것처럼 취급한다 — "접근 불가"가 아니라 "스코프에 없음" 에러라는 점이 흥미롭다. package 심볼은 다른 패키지 입장에서는 애초에 존재하지 않는 것과 동일하게 보인다.

프로젝트 적용

CoreKit(공용 코어) + FeatureA/FeatureB(기능 모듈) 구조에서, CoreKit이 앱 안의 다른 모듈에게만 내주고 싶은 내부 유틸(재시도 정책, 내부 DTO 변환기)을 package로 선언한다. public이 아니므로 시그니처를 자유롭게 바꿔도 외부 소비자를 깨뜨릴 걱정이 없다.

Core 타겟 — package로 노출해 public API 표면을 좁게 유지
// Sources/Core/Core.swift
package struct InternalConfig {
    package var flag: Bool
    package init(flag: Bool) { self.flag = flag }
}

package func internalHelper() -> Int { 42 }

// 진짜 외부에 내줄 것만 public
public struct PublicAPI {
    public init() {}
    public func run() -> Int {
        internalHelper() // 같은 패키지 안에서만 통하는 내부 경로
    }
}

// Sources/Feature/Feature.swift
import Core

func useConfig() -> InternalConfig {
    InternalConfig(flag: internalHelper() > 0) // 같은 패키지 → 컴파일 성공
}

사내 분석 SDK처럼 진짜 외부 패키지가 같은 심볼을 쓰려 하면 그 자리에서 막힌다 — public으로 새 게이트웨이 함수를 열어주지 않는 한 우회할 방법이 없다는 게 요점이다.

별도 Package.swift(다른 -package-name)에서 시도하면
// 별도 패키지의 Consumer.swift
import Core

func tryUse() -> Int {
    internalHelper() // 다른 -package-name으로 빌드된 패키지에서 호출
}
// error: cannot find 'internalHelper' in scope
// → public API(PublicAPI.run())를 거치라는 신호
⚠️ 흔한 오해

"package는 public과 별 차이 없다"는 틀렸다. package 심볼은 모듈 밖 그 어디에도 노출되지 않으므로 SemVer 같은 외부 계약 부담이 전혀 없고, 다음 릴리스에서 시그니처를 자유롭게 바꿔도 된다(public이었다면 외부 소비자의 빌드가 깨진다). 또 "package는 매니페스트에 폴더 구조로 선언한다"도 틀렸다 — 근거는 오직 컴파일 시점의 -package-name 문자열 일치뿐이다.

🧒 쉽게 이해하기

회사 사무실에 비유하면 이렇다. private~internal은 "내 책상 서랍", "내 옆자리 동료들만", "우리 팀 전체"이고, public/open은 "회사 밖 아무나 볼 수 있는 안내문"이다. package는 그 사이에 있는 "우리 회사 안의 다른 팀에게는 보여주지만 회사 밖에는 안 보여주는 사내 문서"에 해당한다. 비유가 깨지는 곳: 회사 안의 "다른 팀"인지는 보통 사람이 판단하지만, package 접근은 사람이 아니라 빌드할 때 넘긴 이름표(-package-name)가 같은지로 컴퓨터가 기계적으로 딱 잘라 판정한다. 이름표가 다르면 물리적으로 같은 회사 건물 안에 있어도(같은 저장소, 같은 폴더) 남이다.

꼬리 질문

SwiftPM을 쓰면 -package-name을 개발자가 직접 지정해야 하나?
아니다. 하나의 Package.swift 매니페스트 안에 있는 모든 타겟에는 그 매니페스트의 name 값이 자동으로 -package-name으로 주입된다. 실제로 두 개의 로컬 타겟(Core, Feature)이 있는 패키지를 swift build -v로 빌드해 커맨드 라인을 확인해 보면, 두 타겟의 컴파일 커맨드 모두에 동일하게 -package-name pkgtest(매니페스트의 name: "pkgtest")가 찍혀 나온다. 개발자는 그저 한 매니페스트 안에 여러 타겟을 두기만 하면 된다.
쉽게 말하면 회사 이름표를 직원마다 손으로 써 붙이는 게 아니라, 같은 회사 건물(매니페스트) 안에서 입사하면 자동으로 그 회사 이름표가 발급되는 것과 같다.
package 접근 수준을 특정 타겟에서만 막고 싶으면 어떻게 하나?
SE-0386이 도입한 매니페스트 설정 packageAccessfalse로 지정하면 그 타겟은 패키지 범위의 접근에서 제외된다(연구 노트에 근거한 SE-0386 원문 언급이며, 이 챕터에서 직접 빌드 검증은 하지 않았다). 여러 타겟이 있는 큰 패키지에서 특정 타겟만 "정말 이 모듈 안에서만" 쓰도록 잠그고 싶을 때 쓴다.
쉽게 말하면 같은 회사 이름표를 갖고 있어도 특정 부서 문(타겟)은 사내 문서 열람 자체를 거부하도록 따로 잠가두는 것이다.
package 심볼에 접근하려는 다른 패키지에서 에러 메시지가 "접근 불가"가 아니라 "스코프에 없음"으로 나오는 이유는?
Swift 컴파일러는 이름 조회(name lookup) 단계에서부터 접근 수준을 함께 검사한다. 다른 패키지 컴파일 단위 입장에서 package 심볼은 애초에 "조회 가능한 후보"에 들어오지 않으므로, "찾았는데 권한이 없다"가 아니라 "그런 이름을 찾을 수 없다"는 결과가 나온다. private/fileprivate 심볼을 모듈 밖에서 참조할 때도 동일하게 "cannot find ... in scope" 에러가 난다 — 접근 제어가 컴파일러에게는 가시성(visibility)의 문제이지, 별도의 권한 검사 단계가 아니라는 뜻이다.
쉽게 말하면 출입증이 없는 방에 들어가려 할 때 "들어갈 권한이 없습니다"라고 알려주는 게 아니라, 애초에 그 방이 지도에 표시조차 안 되는 것과 같다.

Q2. @inlinable / @usableFromInline / @frozen과 라이브러리 진화(resilience)의 관계는?

🔑 30초 답변

일반 앱 타겟에서는 컴파일러가 모듈 전체를 한 번에 보므로 struct 필드는 그냥 고정 오프셋으로 직접 읽힌다. 하지만 -enable-library-evolution으로 빌드하는 바이너리 안정 프레임워크(시스템 프레임워크, 배포용 XCFramework 등)에서는 얘기가 다르다. 클라이언트가 다시 컴파일하지 않아도 프레임워크만 새 버전으로 교체될 수 있어야 하므로, 기본적으로 struct의 레이아웃은 "불투명"하게 취급되고 필드 접근은 게터 함수 호출을 거치며 크기는 런타임에 조회된다. @frozen은 "이 타입의 레이아웃은 앞으로도 절대 안 바뀐다"는 약속이라 직접 필드 접근을 되찾지만, 그 대가로 저장 프로퍼티를 다시는 추가·삭제·재배치할 수 없다. @inlinable은 함수의 구현부 자체를 공개 인터페이스의 일부로 노출해 클라이언트가 그 본문을 그대로 가져다 인라인/특수화할 수 있게 하며, 그 본문이 참조하는 internal 심볼에는 @usableFromInline이 필요하다. 이 셋 다 일반 앱 타겟에는 적용되지 않는 얘기다 — 값을 치르는 쪽은 프레임워크 저자다.

원리

swift.org의 Library Evolution 블로그가 정리하듯, 라이브러리 진화 모드가 켜진 라이브러리는 ABI를 깨지 않고도 struct에 필드를 추가·삭제·재배치하거나 enum에 새 케이스를 추가할 수 있다 — 대신 클라이언트는 그 타입을 간접적으로(비인라인 함수 호출을 통해) 다뤄야 한다. TSPL Attributes에 따르면 @inlinable은 "함수·메서드·연산 프로퍼티·서브스크립트·convenience init·deinit 선언에 붙여 그 구현을 모듈의 공개 인터페이스 일부로 노출"하는 어트리뷰트이고, @usableFromInline은 internal 선언을 "ABI상으로는 public이지만 소스 언어상으로는 public이 아닌" 상태로 만들어 같은 모듈의 @inlinable 코드 안에서만 참조 가능하게 한다.

내부 동작

직접 두 개의 struct를 라이브러리 진화 모드로 빌드해서 클라이언트 쪽 SIL을 뽑아보면 차이가 명확하다. @frozen이 없는 ResilientPoint의 필드를 읽는 클라이언트 함수는 SIL에서 function_ref ResilientPoint.x.getter를 만들어 apply로 호출한다 — 진짜 함수 호출이다. 반면 @frozen이 붙은 FrozenPoint를 읽는 함수는 struct_extract %0, #FrozenPoint.x 한 줄로 끝난다 — 필드를 메모리에서 바로 뽑아내는 명령이지 호출이 아니다. 파라미터 전달 방식도 다르다: 크기가 바뀔 수 있는 ResilientPoint@in_guaranteed ResilientPoint(주소로 간접 전달)를 쓰고, 레이아웃이 고정된 FrozenPoint는 그냥 $FrozenPoint(값으로 직접 전달)를 쓴다. @inlinable도 같은 방식으로 확인 가능하다 — 라이브러리의 @inlinable public func inlinableDouble과 일반 public func opaqueDouble을 각각 호출하는 클라이언트를 -O로 빌드하면, opaqueDouble 호출은 여전히 function_ref+apply로 남지만 inlinableDouble 호출은 아예 사라지고 그 자리에 곱셈 연산(smul_with_overflow)이 직접 박혀 있다 — 컴파일러가 본문을 그대로 가져와 인라인한 것이다. 덧붙여, @frozen struct와 그렇지 않은 struct를 라이브러리 진화 모드로 만든 .swiftinterface를 비교하면, 동일하게 Double 두 개짜리 필드 구성이라도 @frozen 쪽에만 Swift.Sendable·Swift.BitwiseCopyable 자동 준수가 붙는다 — 레이아웃이 고정되어야만 컴파일러가 "이 타입은 바이트 단위로 안전하게 복사 가능하다"는 성질을 확정할 수 있기 때문으로 보인다.

클라이언트가 struct 필드 하나를 읽을 때 (-enable-library-evolution) ResilientPoint (frozen 아님) 파라미터: @in_guaranteed (주소로 간접 전달) function_ref Point.x.getter apply %getter(%0) — 진짜 함수 호출 크기: 런타임 메타데이터로 조회 FrozenPoint (@frozen) 파라미터: 값으로 직접 전달 struct_extract %0, #Point.x 호출 없음 — 필드 오프셋에서 바로 추출 크기: 컴파일타임 상수 대가: @frozen은 이후 필드 추가·삭제·재배치가 전부 ABI 파괴 직접 실측: swiftinterface에서 @frozen 쪽만 Sendable / BitwiseCopyable을 자동으로 얻는다 — 레이아웃이 고정돼야 그 최적화 성질을 확정할 수 있다
라이브러리 진화 모드에서 resilient 타입은 접근 시 함수 호출을 거치고, frozen 타입만 직접 오프셋으로 접근한다 — SIL로 직접 확인한 결과다.

실험 · 도구

아래 커맨드로 위 SIL 차이를 그대로 재현할 수 있다.

라이브러리 진화 모드에서 frozen 유무에 따른 SIL 차이 확인
// lib.swift
public struct ResilientPoint {
    public var x: Double, y: Double
    public init(x: Double, y: Double) { self.x = x; self.y = y }
}
@frozen
public struct FrozenPoint {
    public var x: Double, y: Double
    public init(x: Double, y: Double) { self.x = x; self.y = y }
}

swiftc -emit-module -enable-library-evolution -module-name Lib \
  -emit-module-interface-path /tmp/Lib.swiftinterface \
  -o /tmp/Lib.swiftmodule lib.swift

// client.swift
import Lib
func readR(_ p: ResilientPoint) -> Double { p.x }
func readF(_ p: FrozenPoint) -> Double { p.x }

swiftc -emit-sil -I /tmp client.swift -o client.sil
grep -A6 "func readR\|func readF" client.sil
// readR → function_ref ...Point.x.getter / apply
// readF → struct_extract %0, #FrozenPoint.x

@inlinableprivate 심볼을 참조하면 무슨 일이 나는지도 직접 볼 수 있다: @inlinable public func f() { privateHelper() }-enable-library-evolution으로 컴파일하면 error: global function 'privateHelper()' is private and cannot be referenced from an '@inlinable' function이 그대로 뜨고, 함께 딸려오는 note는 is not '@usableFromInline' or public이라고 정확히 원인을 짚어준다. private@usableFromInline internal로 바꾸면 즉시 컴파일이 통과한다.

프로젝트 적용

일반 앱 타겟이라면 이 셋을 신경 쓸 필요가 거의 없다 — -enable-library-evolution이 꺼져 있으면 @frozen은 관찰 가능한 효과가 없다. 이 어트리뷰트들은 바이너리로 배포하는 프레임워크(사내 XCFramework, 오픈소스 바이너리 배포판)를 만들 때만 실질적인 선택이 된다.

일반 앱 모듈 — 신경 쓸 필요 없음
// 앱 타겟, 라이브러리 진화 모드가 아님
public struct UserProfile {   // @frozen 없이도 필드는 항상 직접 오프셋 접근
    public var name: String
    public var age: Int
}
// -enable-library-evolution이 꺼져 있으면 컴파일러가 모듈 전체를
// 한 번에 보고 최적화하므로, 이 챕터의 구분 자체가 무의미해진다.

사내 XCFramework를 만들 때는 트레이드오프를 명시적으로 골라라. "이 타입은 영원히 지금 필드 그대로 갈 것"이라 확신할 수 있는 값 타입(좌표, 색상 등)만 @frozen을 붙이고, 앞으로 필드를 늘릴 여지가 있는 타입은 resilient 상태(기본값)로 남겨 성능보다 유연성을 택한다.

배포용 프레임워크 — frozen은 신중하게, 필요한 곳에만
// ✅ 좌표는 앞으로도 (x, y) 두 필드 이상 늘어날 일이 거의 없다
@frozen
public struct Point2D {
    public var x: Double
    public var y: Double
}

// ⚠️ 설정 struct는 다음 버전에 필드가 늘어날 가능성이 높다 — frozen 금지
public struct NetworkConfig {
    public var timeout: TimeInterval
    public var retryCount: Int
    // 다음 버전에 필드를 추가해도 ABI가 안 깨진다(resilient라서)
}

// 자주 호출되는 유틸 함수의 본문을 클라이언트에 노출해 인라인시키고 싶다면
@usableFromInline
internal func clampInternal(_ v: Double) -> Double { min(max(v, 0), 1) }

@inlinable
public func clamp01(_ v: Double) -> Double { clampInternal(v) }
⚠️ 흔한 오해

"@frozen은 성능에만 영향을 주고 API 계약과는 무관하다"는 틀렸다 — 핵심은 ABI 안정성 계약(다시는 필드를 못 바꾼다는 약속)이고, 직접 오프셋 접근이라는 성능 이점은 그 계약의 결과일 뿐이다. "@inlinable을 아무 함수에나 붙여도 안전하다"도 틀렸다 — 본문이 그대로 클라이언트 바이너리에 박혀 들어가므로, 이후 그 구현을 자유롭게 바꾸면 옛 버전 본문을 인라인해둔 클라이언트와 새 버전 라이브러리가 섞여 동작할 수 있다.

🧒 쉽게 이해하기

택배 상자에 비유해보자. 보통 상자(resilient)는 안에 뭐가 몇 개 들었는지 모르니, 물건을 꺼낼 때마다 "상자야, x 좀 꺼내줘"라고 부탁(함수 호출)해야 한다. @frozen 상자는 "이 상자는 앞으로도 영원히 정확히 이 칸에 이 물건만 들어있다"고 도장을 찍은 것이라, 부탁할 필요 없이 정해진 칸을 바로 손으로 집으면(직접 오프셋 접근) 된다. 대신 도장을 찍고 나면 그 상자 칸 배치는 절대 못 바꾼다 — 나중에 칸 하나를 추가하고 싶어도 안 된다. 비유가 깨지는 곳: 이 구분은 "상자를 만드는 공장"(라이브러리 진화 모드로 빌드하는 프레임워크)에만 해당한다. 내가 직접 쓰는 앱처럼 상자를 만들자마자 바로 쓰는 경우엔 애초에 이런 손해 볼 일이 없다.

꼬리 질문

@usableFromInline이 왜 필요한가? internal이나 public만으로는 왜 부족한가?
internal 심볼은 소스 코드 레벨에서 모듈 밖에 아예 안 보인다. 그런데 @inlinable 함수의 본문은 클라이언트 모듈 안으로 그대로 복사되어 들어가므로, 그 본문이 참조하는 internal 심볼도 클라이언트가 빌드할 때 링크 가능해야 한다 — 즉 ABI 레벨에서는 노출돼야 한다. 그렇다고 public으로 바꾸면 소스 레벨에서도 누구나 그 이름을 호출할 수 있게 돼버려 API 표면이 원치 않게 넓어진다. @usableFromInline은 "소스에서는 여전히 internal(이름으로 직접 호출 불가)이지만 ABI에서는 public처럼 링크 가능"이라는, 딱 그 중간 지점을 만들어준다.
쉽게 말하면 레시피(함수 본문)를 손님에게 그대로 적어주려면, 그 레시피 안에 쓰인 특수 재료(internal 심볼)도 손님이 구할 수 있어야 한다. 하지만 그 재료를 매장 진열대에 따로 내놓고 싶지는 않다 — @usableFromInline은 "레시피에는 쓸 수 있지만 매대에는 안 놓는" 재료로 표시하는 것이다.
@inlinable 함수 하나만 있어도 라이브러리 진화 모드를 켠 효과가 사라지나?
아니다. @inlinable은 함수·프로퍼티 단위로 선택하는 것이라, 다른 public 심볼들은 여전히 resilient(불투명 레이아웃, 함수 호출 경유)로 남는다. 실제로 위 실험에서 opaqueDouble 호출은 여전히 function_ref+apply로 남고 inlinableDouble 호출만 인라인됐다 — 같은 라이브러리 안에서 두 방식이 얼마든지 공존한다. 프레임워크 저자는 뜨거운 경로(hot path)의 작은 함수에만 선택적으로 @inlinable을 붙이는 식으로 쓴다.
쉽게 말하면 가게 메뉴판에서 몇 개 메뉴만 "레시피 공개"로 표시하고 나머지는 그대로 비공개로 둘 수 있는 것과 같다. 한 메뉴를 공개했다고 가게 전체 레시피가 다 공개되는 게 아니다.
@frozen struct에 나중에 계산 프로퍼티(computed property)를 추가하는 것도 금지되나?
아니다. @frozen이 고정하는 것은 저장 프로퍼티의 레이아웃(개수·순서·타입)이지 API 표면 전체가 아니다. 계산 프로퍼티나 메서드는 저장 공간을 차지하지 않으므로 나중에 얼마든지 추가할 수 있다. 위험한 것은 오직 "이 값이 메모리에서 몇 바이트를 어떤 순서로 차지하는가"를 바꾸는 변경 — 즉 새 저장 프로퍼티 추가, 기존 저장 프로퍼티 삭제/재배치/타입 변경이다.
쉽게 말하면 도장 찍은 택배 상자라도 상자 겉면에 설명 스티커(계산 프로퍼티, 메서드)를 새로 붙이는 건 자유다. 절대 안 되는 건 상자 안 칸막이(저장 프로퍼티) 배치를 바꾸는 것뿐이다.

Q3. 매크로(SE-0382/0389)는 컴파일 타임에 무엇을 하는가? 확장 결과는 어떻게 확인하는가?

🔑 30초 답변

Swift 매크로는 두 계열이다. #로 쓰는 freestanding 매크로(SE-0382, 식이나 선언 자리를 통째로 대체)와 @로 붙이는 attached 매크로(SE-0389, 기존 선언에 member/peer/accessor/memberAttribute/conformance 다섯 가지 역할 중 하나로 코드를 덧붙임)다. 매크로 구현체는 컴파일러 프로세스 안이 아니라 완전히 별도의 컴파일러 플러그인 프로세스로 실행되며, swift-syntax 패키지로 구문 트리를 받아 새 Swift 소스 텍스트를 돌려준다. 매크로는 오직 코드를 추가만 할 수 있고, 기존 선언을 지우거나 고치는 것은 불가능하다. 확장은 컴파일 타임에 끝나고 런타임에는 이미 확장된 평범한 코드만 남으므로, 런타임 오버헤드는 없다 — 대신 빌드할 때마다 플러그인 프로세스를 띄우고 구문 트리를 분석하는 비용이 붙는다.

원리

SE-0382는 매크로의 존재 이유를 "일반 표현식은 #file, #line 같은 몇몇 하드코딩된 예외를 빼면 자기 자신의 소스 코드를 조작할 수 없다"는 한계로 설명한다. 매크로는 이 한계를 깨되, AST(추상 구문 트리, 컴파일러 내부 구조)가 아니라 Swift 소스 코드로 확장 결과를 표현하는 설계를 택했다 — "Swift 프로그래머는 Swift 소스 코드를 이해하니, 매크로가 자기 소스에 적용된 결과도 추론할 수 있어야 한다"는 원칙이다. SE-0389는 attached 매크로의 5개 역할을 정의한다: peer(원래 선언 옆에 형제 선언 추가), member(타입 내부에 새 멤버 추가), accessor(프로퍼티에 getter/setter 추가), memberAttribute(기존 멤버들에 어트리뷰트 부착), conformance(프로토콜 준수 추가).

내부 동작

직접 swift package init --type macro로 매크로 패키지를 만들어 확인해보면, Package.swift.macro(name: "MacroDemoMacros", ...) 타겟이 @main struct ... CompilerPlugin을 구현하는 실행 파일로 빌드된다 — swift build 로그를 보면 이 타겟이 MacroDemoMacros-tool이라는 별도 실행 파일로 링크되는 것이 그대로 보인다. 클라이언트가 #stringify(a + b)를 쓰면, 컴파일러는 이 별도 프로세스를 호출해 구문 트리를 넘기고 (a + b, "a + b")라는 확장된 소스를 돌려받는다. -Xfrontend -dump-macro-expansions로 빌드하면 컴파일러가 실제로 만든 숨김 파일 이름까지 볼 수 있다 — @__swiftmacro_..._9stringifyfMf_.swift 같은 이름의 파일 안에 정확히 (a + b, "a + b")가 들어있다. 이 파일은 디스크에 남는 실제 소스이고, 그 다음 단계에서 다시 정상적으로 타입 체크된다 — SE-0382가 말한 "2단계 확장"(먼저 인자를 타입체크 → 매크로가 트리를 변환 → 결과를 다시 타입체크)이 그대로 관찰된다.

매크로 확장 파이프라인 (컴파일 타임에만 일어난다) 소스 코드 #stringify(a + b) 별도 플러그인 프로세스 MacroDemoMacros-tool swift-syntax로 트리 변환 확장된 소스 (a + b, "a + b") 숨김 .swift 파일로 저장 확장 결과가 다시 정상 타입 체크된다 (2단계 확장) 규칙: 매크로는 코드를 '추가'만 한다 — 기존 선언을 지우거나 바꿀 수 없다
매크로 호출은 별도 프로세스가 만든 실제 Swift 소스로 확장되고, 그 소스가 다시 타입 체크를 거친다. 실행 파일에는 이미 확장이 끝난 코드만 남는다.

실험 · 도구

실제로 매크로 패키지를 만들어 확장 결과를 눈으로 확인할 수 있다.

스캐폴딩부터 실행까지 — swift package init이 만들어주는 #stringify 예제
swift package init --type macro --name MacroDemo
cd MacroDemo
swift run MacroDemoClient
// 출력: The value 42 was produced by the code "a + b"

// 확장된 실제 소스를 텍스트로 덤프
swift build --target MacroDemoClient \
  -Xswiftc -Xfrontend -Xswiftc -dump-macro-expansions
// @__swiftmacro_...stringifyfMf_.swift
// ------------------------------
// (a + b, "a + b")
// ------------------------------

Xcode에서는 더 간단하다 — 매크로 호출부(#stringify(...)@Observable 같은 것)를 우클릭 → "Expand Macro"를 선택하면 확장된 실제 소스가 인라인으로 펼쳐져 보인다(WWDC23 "Expand on Swift macros" 세션에서 시연). 매크로 구현 자체도 일반 Swift 코드이므로 SwiftSyntaxMacrosTestSupport로 유닛 테스트할 수 있다.

프로젝트 적용

@Observable(Q3와 별개로 27장에서 다시 다룬다), #Predicate 같은 표준 매크로를 쓸 때도 "이게 뭘 생성했는지" 궁금하면 주저 말고 Expand Macro를 눌러라 — 디버깅 시간을 크게 줄여준다.

직접 만든 초소형 freestanding 매크로 정의 (Package.swift의 .macro 타겟)
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxMacros

// #stringify(x + y) → (x + y, "x + y")
public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let argument = node.arguments.first?.expression else {
            fatalError("컴파일러 버그: 인자가 없다")
        }
        return "(\(argument), \(literal: argument.description))"
    }
}

@main
struct MacroDemoPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [StringifyMacro.self]
}

매크로를 직접 도입할지 말지는 빌드 시간 대비 반복 코드 절감 효과로 저울질하라. 이 데모처럼 아주 작은 매크로 하나는 사전 빌드된 swift-syntax 지원 바이너리 덕에 swift build가 5초 안에 끝났지만(툴체인 버전이 정확히 맞을 때 얘기다), 프로젝트가 커지고 매크로 타겟이 여러 개로 늘어나거나 툴체인 버전이 어긋나 swift-syntax를 소스부터 새로 빌드해야 하는 상황에서는 빌드 시간이 눈에 띄게 늘어난다.

매크로 도입 여부를 가르는 기준 — 반복 보일러플레이트가 많을 때만
// ❌ 한두 곳뿐인 반복이면 매크로 도입 비용이 더 크다
struct Point { var x: Double; var y: Double } // 그냥 손으로 써도 충분

// ✅ 수십 개 타입에 걸쳐 같은 패턴(예: 로깅 코드 자동 삽입, Observable화)이
//    반복될 때 매크로가 빌드 시간 이상의 유지보수 이득을 준다
@Observable
final class ViewModel {
    var items: [String] = []   // 매크로가 추적 코드를 자동으로 덧붙인다
}
⚠️ 흔한 오해

"매크로는 런타임에 코드를 생성한다"는 완전히 틀렸다 — 확장은 오직 컴파일 타임에만 일어나고, 실행 파일 안에는 이미 확장이 끝난 평범한 Swift 코드만 남는다(런타임에 swift-syntax가 로드되지도 않는다). "매크로는 컴파일러 내부를 특별히 건드리는 코드"라는 것도 틀렸다 — 매크로 구현은 swift-syntax를 쓰는 별도의 평범한 Swift 실행 파일(컴파일러 플러그인)일 뿐이라 일반 프로그램처럼 디버깅·유닛 테스트가 가능하다.

🧒 쉽게 이해하기

숙제 대행 도우미에 비유하면 쉽다. 내가 #stringify(a + b)라고 짧게 적어 보내면, 그 즉시 다른 방(별도 프로세스)에 있는 도우미가 그걸 받아서 "(a + b, "a + b")"라는 완성된 답안을 손글씨(진짜 Swift 소스 코드)로 다시 써서 돌려준다. 선생님(컴파일러)은 그 완성된 답안지만 채점(타입 체크)한다. 도우미는 답안지에 내용을 더 적어줄 수만 있지, 내가 이미 적어 낸 다른 답을 지우거나 고칠 수는 없다. 비유가 깨지는 곳: 이 도우미는 시험(런타임)이 시작되기 전, 미리 다 끝내고 퇴근한다 — 실제 시험 시간에는 도우미가 아예 존재하지 않고, 내가 미리 받아 적어둔 완성된 답안지만 남는다.

꼬리 질문

freestanding 매크로와 attached 매크로는 각각 어떤 문제를 푸는가?
freestanding 매크로(#)는 식이나 선언이 놓일 자리 자체를 대체한다 — #stringify(x)처럼 "이 자리에 새로운 표현식/선언을 만들어 넣어달라"는 요청이다. attached 매크로(@)는 이미 존재하는 선언(struct, 프로퍼티 등)에 붙어서 그 선언을 확장한다 — 원본은 그대로 두고 옆에 형제를 추가하거나(peer), 안에 멤버를 추가하거나(member), 프로퍼티에 접근자를 붙이는(accessor) 식이다. 전자는 "새로운 조각을 만들어달라", 후자는 "이미 있는 것에 무언가를 덧붙여달라"는 요청이라고 구분하면 된다.
쉽게 말하면 freestanding은 빈 종이에 새 그림을 그려달라는 부탁이고, attached는 내가 이미 그린 그림 옆이나 위에 스티커를 붙여달라는 부탁이다.
매크로가 "코드를 추가만 할 수 있다"는 제약은 왜 중요한가?
이 제약 덕분에 매크로가 붙지 않은 코드만 읽어도(매크로 확장 없이도) 원본 선언의 의미가 그대로 보장된다 — 매크로가 몰래 기존 함수의 동작을 바꾸거나 지워버리는 사고가 원천적으로 불가능하다. 또한 여러 매크로가 같은 선언에 동시에 붙어도 서로 다른 매크로가 만든 결과를 지우는 충돌이 생기지 않는다(각자 코드를 더할 뿐이므로). 결과적으로 "이 코드가 뭘 하는지"를 추론할 때 매크로 확장 결과를 몰라도 원본 소스만으로 최소한의 신뢰를 가질 수 있다.
쉽게 말하면 스티커(매크로)는 내 그림 위에 덧붙일 수는 있어도 내가 이미 그린 선을 지우개로 지울 수는 없다. 그래서 원본 그림만 봐도 "적어도 이 부분은 확실히 이렇게 그려져 있겠구나"를 믿을 수 있다.
매크로 확장이 컴파일러 내부 AST가 아니라 소스 텍스트로 표현되는 설계는 왜 중요한가?
컴파일러의 내부 AST 구조는 버전마다 자유롭게 바뀔 수 있는 구현 디테일이다. 만약 매크로가 그 내부 구조를 직접 조작해야 했다면, 매크로 구현체가 컴파일러 버전에 강하게 결합되고, 개발자가 확장 결과를 이해하려면 컴파일러 내부를 알아야 했을 것이다. 매크로가 대신 "Swift 소스 텍스트"를 만들어내도록 설계했기 때문에, 개발자는 평소 읽던 방식 그대로(Expand Macro로 펼쳐보거나 -dump-macro-expansions로 덤프해서) 확장 결과를 이해할 수 있고, 매크로 구현체 자체도 컴파일러 내부 API가 아니라 안정적인 swift-syntax 패키지에만 의존한다.
쉽게 말하면 도우미가 나에게 답을 줄 때, 자기만 아는 암호(컴파일러 내부 구조)가 아니라 내가 평소 쓰는 글씨(진짜 Swift 코드)로 써주는 것과 같다. 그래야 나도 그 답을 그대로 읽고 이해할 수 있다.

Q4. borrowing / consuming(SE-0377)은 무엇을 바꾸는가? 언제 명시해야 하는가?

🔑 30초 답변

SE-0377은 함수가 파라미터를 받을 때의 소유권 컨벤션을 명시적으로 고를 수 있게 하는 두 수식어를 도입했다. borrowing은 "이 함수는 값을 잠깐 빌려 읽기만 하고 소유권을 가져가지 않는다"는 뜻이고, consuming은 "이 함수가 값의 소유권을 넘겨받아 해제 책임을 진다"는 뜻이다. 일반 Copyable 타입에서는 이게 대체로 최적화 힌트에 가깝다 — 실제로 SIL을 뽑아보면 클래스 파라미터를 그냥 읽기만 하는 일반 함수도 -Onone부터 이미 @guaranteed(= borrowing과 동일한 호출 규약)로 컴파일된다. 반면 ~Copyable 타입에서는 "복사"라는 대안 자체가 없으므로 borrowing/consuming 구분이 최적화가 아니라 API가 성립하기 위한 필수 조건이 된다.

원리

SE-0377 이전에는 컴파일러가 암묵적으로 컨벤션을 골랐다(예: 이니셜라이저나 세터는 보통 파라미터를 소비하는 편이 유리). borrowing/consuming 파라미터 안에서는 값이 암묵적으로 복사되지 않는다 — 복사가 필요하면 copy x 연산자로 명시해야 한다. 이 제안은 리뷰 과정에서 이름이 몇 차례 바뀌었다(borrow/takeconsume/borrow → 최종 동명사형 borrowing/consuming) — 이는 이 기능이 "명령"이 아니라 "그 함수가 파라미터와 맺는 관계"를 서술한다는 설계 의도를 반영한다.

내부 동작

직접 세 함수 — 수식어 없는 기본, borrowing, consuming — 를 클래스 파라미터로 만들어 -emit-sil로 비교하면, 흥미로운 결과가 나온다. -Onone(최적화 없이)에서조차 수식어 없는 기본 함수와 명시적 borrowing 함수의 SIL 시그니처가 완전히 동일하다 — 둘 다 @convention(thin) (@guaranteed Box) -> Int다. @guaranteed는 "이 파라미터의 생존을 호출자가 보장하니 콜리는 retain/release를 안 해도 된다"는 뜻으로, 정확히 borrowing이 의미하는 바다. 즉 클래스를 단순히 읽기만 하는 파라미터는 수식어를 안 붙여도 이미 borrowing과 동일하게 컴파일된다 — 연구 노트가 말한 "기본값이 이미 대부분의 파라미터에서 borrowing"이라는 설명이 SIL 레벨에서 그대로 확인된다. 반면 consuming 함수는 @convention(thin) (@owned Box) -> Int로, @owned(호출자가 소유권을 완전히 넘긴다)를 쓰며 SIL 어트리뷰트에 copy/destroy 효과가 명시된다 — 실제로 해제 책임이 콜리로 넘어갔다는 뜻이다.

-emit-sil로 직접 비교한 세 파라미터 컨벤션 func f(_ b: Box) @guaranteed Box 수식어 없음 = 이미 borrowing과 동일 func f(_ b: borrowing Box) @guaranteed Box SIL 시그니처가 위와 완전히 동일 func f(_ b: consuming Box) @owned Box 소유권 이동, 콜리가 해제 책임을 짐 Copyable 타입: 대체로 최적화 힌트 (컴파일러가 이미 잘 고른다) ~Copyable 타입: 복사라는 대안이 아예 없으므로 이 구분이 의미적으로 필수 — 컴파일러의 유일한 안전장치가 된다
Copyable 타입에서는 수식어 없는 기본 파라미터가 이미 borrowing과 동일하게 컴파일된다 — 실측으로 확인한 결과다.

실험 · 도구

직접 SIL을 뽑아 세 시그니처를 비교해보라.

기본 vs borrowing vs consuming — SIL 시그니처 직접 비교
// borrow_test.swift
final class Box { var v: Int = 0 }
func useDefault(_ b: Box) -> Int { b.v }
func useBorrowing(_ b: borrowing Box) -> Int { b.v }
func useConsuming(_ c: consuming Box) -> Int { c.v }

swiftc -emit-sil -Onone borrow_test.swift | grep "sil hidden.*use"
// useDefault   : (@guaranteed Box) -> Int
// useBorrowing : (@guaranteed Box) -> Int   ← 기본과 동일
// useConsuming : (@owned Box) -> Int         ← 소유권 이동

noncopyable 타입에서는 이 구분이 컴파일 에러로 강제된다는 것을 Q5의 실험에서 바로 확인할 수 있다.

프로젝트 적용

대부분의 앱 코드는 이 수식어를 안 붙이는 게 맞다. 컴파일러가 이미 대부분의 읽기 전용 파라미터를 borrowing처럼 최적화하므로, 수식어를 뿌리는 것은 가독성만 떨어뜨리고 실이익은 미미하다.

일반 앱 코드 — 대부분은 수식어 없이 그냥 두는 게 맞다
// ❌ 과잉 최적화 — 이미 컴파일러가 알아서 처리한다
func process(_ items: borrowing [Item]) -> Int { items.count }

// ✅ 그냥 쓴다. 컴파일러가 읽기 전용 사용 패턴을 보고 스스로 골라준다
func process(_ items: [Item]) -> Int { items.count }

예외는 두 가지다 — noncopyable 타입을 다루는 API를 설계할 때(Q5), 그리고 대량의 대형 값 타입을 반복적으로 넘기는 뜨거운 경로에서 프로파일링으로 ARC 트래픽이 실제 병목임을 확인했을 때다. "확인 없이 미리 붙이지 말라"가 원칙이다.

예외 — noncopyable 타입 API에서는 명시가 필수다
struct FileHandle: ~Copyable {
    private let fd: Int32
    // consuming: 이 함수가 값을 소비(해제)한다는 걸 시그니처로 드러낸다
    consuming func close() { /* ... */ }
    // borrowing: 값을 읽기만 하고 계속 살려둔다는 걸 드러낸다
    borrowing func isValid() -> Bool { fd >= 0 }
}
⚠️ 흔한 오해

"borrowing/consuming은 그냥 성능 힌트일 뿐 의미(semantics)에 영향이 없다"는 Copyable 타입에서만 대체로 맞는 말이다. 실측했듯 일반 클래스 파라미터는 수식어 없이도 이미 @guaranteed로 컴파일되므로 borrowing을 붙여도 SIL이 안 바뀐다. 하지만 ~Copyable 타입에서는 이 구분이 진짜 의미론이다 — 복사해서 임시로 넘기는 길 자체가 없으므로, borrowing/consuming 선택이 "이 함수가 값을 죽이는가 살려두는가"를 결정하는 유일한 신호가 된다.

🧒 쉽게 이해하기

친구에게 책을 건네는 상황에 비유하면 된다. borrowing은 "잠깐 보고 돌려줘"이고 consuming은 "이제 이 책은 네 거야, 나는 신경 안 써"다. 보통 책(Copyable 타입, 얼마든지 복사 가능)을 빌려줄 때는 굳이 "잠깐 보고 돌려줘"라고 말 안 해도 다들 알아서 그렇게 한다 — 컴파일러도 마찬가지로 대부분 알아서 판단한다. 하지만 세상에 단 한 권뿐인 책(~Copyable, Q5 참고)이라면 얘기가 다르다. "잠깐 보고 돌려줘"인지 "이제 네가 가져"인지를 반드시 분명히 말해야 한다 — 안 그러면 그 책이 누구 손에 있는지 아무도 모르게 된다. 비유가 깨지는 곳: 일반 책은 실제로 복사(카피)해서 두 권을 만들 수 있지만, 컴파일러가 다루는 "복사"는 눈에 안 보이는 참조 카운트 증감 같은 것이라 우리가 체감하기 어렵다.

꼬리 질문

모든 파라미터에 borrowing/consuming을 붙이면 항상 더 빠른가?
아니다. 실측에서 봤듯 단순 읽기 전용 클래스 파라미터는 수식어 없이도 이미 @guaranteed로 컴파일되므로 borrowing을 붙여도 SIL이 바뀌지 않는다. 오히려 consuming을 잘못된 곳에 붙이면 불필요하게 소유권을 가져가느라 호출부에서 추가 복사가 생길 수 있다. 수식어는 "컴파일러가 잘못 판단하고 있다는 증거(프로파일링)가 있을 때"에만 넣는 교정 도구이지, 기본값으로 뿌리는 습관이 아니다.
쉽게 말하면 이미 알아서 잘 하고 있는 친구에게 굳이 매번 "이렇게 해, 저렇게 해"라고 지시하면 오히려 헷갈리게 만들 수 있다.
consuming 함수 안에서 파라미터를 다시 복사해서 쓰면 무슨 일이 일어나나?
Copyable 타입이라면 copy 연산자 없이도 컴파일러가 필요하면 알아서 복사해 쓸 수 있어 문제가 없다(단, 명시적으로 정확한 소유권 이전 의도를 드러내려 했다면 그 의도가 흐려질 뿐 에러는 아니다). 하지만 ~Copyable 타입에서 consuming 파라미터를 함수 안에서 두 번 이상 "소비"하려 하면(예: 두 개의 다른 consuming 메서드를 순서대로 호출) 컴파일러가 "값이 이미 소비된 뒤 다시 쓰였다"는 에러를 낸다 — Q5의 "consumed more than once" 에러가 정확히 이 상황이다.
쉽게 말하면 세상에 하나뿐인 물건을 이미 남에게 넘겨줬는데, 마치 아직도 내 것인 양 또 넘기려 하면 안 되는 것과 같다.
consuming 파라미터를 뜻하는 @owned와 borrowing의 @guaranteed의 실질적 차이는 호출자 입장에서 무엇인가?
@guaranteed 호출에서는 호출자가 인자의 retain count를 그대로 유지한 채 함수에 넘기고, 함수가 끝나면 호출자가 계속 그 값을 정상적으로 쓸 수 있다(콜리가 retain/release를 아예 안 건드리므로 그만큼 명령어가 줄어든다). @owned 호출에서는 호출자가 인자에 대한 소유권(하나의 retain)을 콜리에게 넘기므로, 만약 호출자가 그 값을 계속 쓰고 싶다면 호출 전에 스스로 retain(=copy)을 하나 더 만들어둬야 한다 — 그렇지 않으면 호출자 쪽 참조가 유효하지 않게 된다.
쉽게 말하면 @guaranteed는 "잠깐만 보여줄게, 다 보면 나한테 다시 줘"이고 @owned는 "이제 이건 네 거야, 나는 이제 이거 안 갖고 있어" — 그래서 원래 주인이 계속 갖고 싶으면 미리 복사본을 하나 더 만들어놔야 한다.

Q5. ~Copyable(SE-0390/0427)은 어떤 문제를 푸는가? class는 왜 noncopyable이 될 수 없는가?

🔑 30초 답변

파일 디스크립터, 락, 유일 버퍼처럼 "정확히 한 번만 존재해야 하고 실수로 복사되면 버그가 되는" 자원이 있다. Swift의 모든 타입은 기본적으로 복사 가능하다는 전제 위에 서 있었는데, SE-0390~Copyable로 이 전제를 깨는 struct/enum을 처음 도입했고, SE-0427은 이를 제네릭 시스템(<T: ~Copyable>)까지 확장했다. noncopyable 값은 대입해도 복사가 아니라 이동(move)이 일어나고, 이미 이동시킨 값을 다시 쓰려 하면 컴파일 타임에 막힌다. class는 ~Copyable이 될 수 없다 — 클래스 "참조" 자체(포인터 값)는 언제나 복사 가능하고, ARC(자동 참조 카운팅)의 모델 전체가 "참조를 복사하고 카운트를 올린다"는 전제 위에 있기 때문이다. 실측해보면 컴파일러도 class NotAllowed: ~Copyable을 곧바로 "classes cannot be '~Copyable'"이라는 에러로 거부한다.

원리

SE-0390은 문제를 이렇게 정의한다 — "지금까지 Swift의 모든 타입은 copyable하다, 즉 어떤 값이든 동일하고 상호교환 가능한 여러 표현을 만들 수 있다"는 전제가 있었다. 이 전제가 항상 맞는 건 아니다: 파일 핸들을 복사해서 두 곳에서 각각 close()를 부르면 이중 해제가 되고, 락을 복사하면 상호 배제가 깨진다. noncopyable 타입은 유일 소유권(unique ownership)을 표현해 이런 자원을 타입 시스템이 직접 지킨다. noncopyable 타입은 deinit을 가질 수 있고, consuming 메서드 안에서 discard self를 쓰면 그 인스턴스의 deinit 실행 자체를 억제할 수 있다(예: "닫는" 메서드가 스스로 정리를 다 했으니 자동 정리는 필요 없다고 선언). SE-0427은 최초 SE-0390의 강한 제약(제네릭 타입 인자 불가, Optional/Array/튜플에 못 담음)을 풀어, 표준 라이브러리에 Copyable 프로토콜을 실재하는 것으로 도입했다 — 이제 <T>는 사실 <T: Copyable>과 같고, noncopyable 타입을 받으려면 <T: ~Copyable>로 명시해야 한다.

내부 동작

noncopyable 위반을 잡는 검사는 타입 체크가 아니라 SIL 단계에서 일어난다는 게 직접 실험에서 드러나는 흥미로운 지점이다. let a = FileDescriptor(fd: 3); let b = a; _ = a 같은 코드를 swiftc -typecheck로만 돌리면 에러가 안 난다. 하지만 swiftc -emit-sil로 실제 컴파일을 진행하면 error: 'a' used after consume가 뜬다 — note: consumed herelet b = a 줄을, note: used here가 그 다음 _ = a 줄을 정확히 가리킨다. 이는 이동/소비 추적이 SSA 형태의 SIL 위에서 도는 소유권 검사기(ownership checker)의 일이기 때문이다. consuming func close()discard self 없이 정의하고 직접 실행해보면, close()가 끝나자마자 deinit이 자동으로 한 번 더 실행되는 것이 출력 순서로 보인다 — consuming이라는 것만으로는 자동 정리를 막지 못하고, discard self를 추가해야 비로소 deinit이 억제된다는 것을 직접 실행 결과로 확인할 수 있다.

let a = FileDescriptor(...); let b = a; _ = a a — 유일 소유자 let b = a (이동) b — 유일 소유자 _ = a → error: 'a' used after consume 대조: class 참조 let x = obj let y = x (포인터 값 복사 — OK) consuming func close() { discard self } class가 ~Copyable이 될 수 없는 이유 참조(포인터) 자체는 항상 복사 가능 — ARC 전체가 "참조를 복사하고 카운트를 올린다"는 전제 위에 있다
noncopyable 값은 대입 시 이동만 허용되고 재사용은 컴파일 에러가 된다. class는 참조 자체가 복사 가능한 값이라 이 규칙 밖에 있다.

실험 · 도구

직접 컴파일해보면 타입 체크와 SIL 단계의 차이, 그리고 discard self의 효과까지 눈으로 확인할 수 있다.

used-after-consume 에러는 -typecheck가 아니라 실제 컴파일에서만 잡힌다
struct FileDescriptor: ~Copyable {
    private let fd: Int32
    init(fd: Int32) { self.fd = fd }
    deinit { print("closing fd \(fd)") }
    consuming func close() { print("explicit close \(fd)") }
}
func test() {
    let a = FileDescriptor(fd: 3)
    let b = a   // 이동
    _ = a       // 이미 이동한 값을 다시 사용
    _ = b
}

swiftc -typecheck test.swift   // EXIT=0 — 여기선 안 잡힌다
swiftc -emit-sil test.swift -o /dev/null
// error: 'a' used after consume
//   note: consumed here      (let b = a)
//   note: used here          (_ = a)
discard self 유무에 따른 deinit 실행 순서 차이 — 직접 실행해서 확인
struct FileDescriptor: ~Copyable {
    private let fd: Int32
    init(fd: Int32) { self.fd = fd; print("open fd \(fd)") }
    deinit { print("deinit: closing fd \(fd) (자동)") }
    consuming func close() {
        print("explicit close fd \(fd)")
        // discard self 없음
    }
}
let f = FileDescriptor(fd: 7)
f.close()
// 실행 결과:
// open fd 7
// explicit close fd 7
// deinit: closing fd 7 (자동)   ← discard self가 없으면 close() 직후 deinit이 또 돈다!

// discard self를 추가하면:
// open fd 7
// explicit close fd 7
// (deinit 로그가 안 찍힌다 — discard self가 억제했다)

프로젝트 적용

유일해야 하는 시스템 자원(파일 핸들, 소켓, 락)을 감싸는 래퍼 타입에 ~Copyable을 써서 "실수로 복사해 이중 해제하는 버그"를 컴파일 타임에 원천 차단한다.

FileDescriptor 래퍼 — 복사 불가로 이중 close 버그를 컴파일 타임에 차단
struct FileDescriptor: ~Copyable {
    private let raw: Int32
    init(raw: Int32) { self.raw = raw }

    deinit {
        // close(raw) 같은 실제 시스템 콜 — 아무도 안 닫으면 여기서 정리
    }

    consuming func close() {
        // 명시적으로 닫는다 — 자동 정리가 또 도는 걸 막는다
        discard self
        // 실제로는 여기서 close(raw) 시스템 콜을 호출
    }

    borrowing func read(into buffer: inout [UInt8]) -> Int {
        // 소유권은 안 가져가고 읽기만
        0 // 실제로는 read(raw, ...) 호출
    }
}

func handleUpload(_ file: consuming FileDescriptor) {
    // ...
    file.close() // 여기서 소유권이 소비된다
    // file.close() 를 한 번 더 부르면 컴파일 에러 — 'consumed more than once'
}

class는 이 용도로 쓸 수 없다는 걸 명확히 하라 — "참조 하나만 갖고 있으면 안전하겠지"라는 생각으로 class를 쓰면, 누군가 그 참조를 배열에 저장하거나 클로저로 캡처하는 순간 "유일해야 한다"는 불변식이 조용히 깨진다. noncopyable struct는 애초에 그런 복사 자체를 언어가 막아준다.

class로 흉내내면 컴파일러가 지켜주지 않는다 — 실측: class는 애초에 ~Copyable 선언 자체가 거부된다
// ❌ class NotAllowed: ~Copyable { var x = 1 }
// error: classes cannot be '~Copyable'
//   → 참조(포인터) 자체는 항상 복사 가능하고, ARC 전체가
//     "참조를 복사하고 retain count를 올린다"는 전제 위에 서 있기 때문

// class로 '유일함'을 흉내내면 컴파일러가 못 막아준다
final class FileHandleBox {
    let raw: Int32
    init(raw: Int32) { self.raw = raw }
}
let box1 = FileHandleBox(raw: 3)
let box2 = box1          // 참조 복사 — 컴파일러가 막을 방법이 없다
// box1.raw와 box2.raw가 같은 fd를 가리키는 걸 아무도 못 막는다
⚠️ 흔한 오해

"~Copyable은 Swift 5.9(SE-0390) 시점부터 제네릭·Array·Optional에 자유롭게 쓸 수 있었다"는 틀렸다. SE-0390의 최초 버전은 noncopyable 타입을 제네릭 타입 인자로 쓰거나 Optional/Array/튜플에 담는 것 자체를 금지했다. 이 제약이 풀린 건 SE-0427(Swift 6.0)부터다. 또 "class 대신 struct + ~Copyable을 쓰면 항상 참조 카운팅 오버헤드가 완전히 사라진다"도 100%는 아니다 — 힙에 올려야 하는 상황(예: UniqueBox류 스마트 포인터)에서는 여전히 할당 자체는 발생한다. 다만 참조 카운팅과 공유 소유권에 따르는 오버헤드는 없다.

🧒 쉽게 이해하기

세상에 딱 한 장뿐인 콘서트 티켓에 비유하면 된다. 보통 물건(Copyable 타입)은 사진을 찍듯 복사본을 얼마든지 만들 수 있다. 하지만 이 티켓은 딱 한 장이라 "복사"라는 행위 자체가 말이 안 된다 — 친구에게 건네주면(대입) 그 순간부터 나는 더 이상 티켓이 없다(이동). 만약 내가 이미 건네준 티켓을 다시 쓰려고 하면("어, 나 아직 티켓 있는데?") 그건 명백히 잘못된 행동이고, Swift는 이걸 컴파일 타임에 딱 잡아낸다. 비유가 깨지는 곳: class(참조 타입)는 "티켓이 들어있는 사진(포인터)"을 나눠 갖는 것과 비슷하다 — 사진(참조)은 얼마든지 복사해서 여러 명이 나눠 가질 수 있지만, 그 사진들이 결국 같은 진짜 티켓(같은 인스턴스)을 가리킨다는 사실 자체는 아무도 못 막는다. 그래서 class로는 "물리적으로 하나만 존재함"을 강제할 수 없다.

꼬리 질문

noncopyable 타입을 클래스 프로퍼티로 저장할 수 있는가?
클래스는 참조 타입이라 인스턴스 자체가 여러 곳에서 공유될 수 있는데, 만약 그 인스턴스 안에 noncopyable 저장 프로퍼티를 두면 "이 클래스 인스턴스를 참조하는 여러 코드가 동시에 그 noncopyable 값에 접근할 수 있다"는 뜻이 되어 유일 소유권이라는 전제와 정면으로 충돌한다. 실제로 Swift는 오랫동안 noncopyable 타입을 클래스의 저장 프로퍼티로 두는 것을 제한적으로만 허용해왔고, 이 영역은 최근에도 계속 발전 중이다(연구 노트 기준 UniqueBox/Ref류 타입이 "noncopyable 값을 힙에 안전하게 올리는" 방법으로 별도 제안되고 있다). 확실한 건, class 참조 자체는 여전히 자유롭게 복사되므로 "그 참조를 통해 접근하는 noncopyable 값이 유일하다"는 보장을 class 하나만으로는 만들 수 없다는 점이다.
쉽게 말하면 여러 명이 같은 사진(class 참조)을 나눠 가질 수 있는데, 그 사진 안에 세상에 하나뿐인 티켓(noncopyable 값)을 그대로 넣어두면 "누가 진짜 그 티켓의 주인인가"가 애매해진다.
~Copyable 타입에 Equatable 같은 일반 프로토콜을 채택시킬 수 있는가?
그 프로토콜 자체가 ~Copyable로 선언돼 있지 않으면 안 된다. SE-0427에서 Copyable이 표준 라이브러리의 실재하는 프로토콜이 된 뒤로, 일반 프로토콜 선언(예: Equatable)은 암묵적으로 Self: Copyable을 요구하는 것과 같다 — noncopyable 타입은 그 요구를 만족할 수 없으므로 채택이 막힌다. noncopyable 타입이 준수할 수 있으려면 그 프로토콜 선언 자체가 protocol Foo: ~Copyable { ... } 처럼 명시적으로 Copyable 요구를 벗어놓았어야 한다.
쉽게 말하면 "복사해서 비교해보는" 것을 전제로 만들어진 규칙(Equatable)에, 애초에 복사가 불가능한 물건을 끼워 맞추려면 그 규칙 자체가 처음부터 "복사 없이도 참여 가능"이라고 미리 선언해뒀어야 한다.
discard self는 아무 noncopyable 타입에나 쓸 수 있는가?
아니다. 연구 노트에 따르면 discard self는 도입 초기에 POD(Plain Old Data, 별도의 정리 로직 없이 단순 값들로만 구성된) 타입에 한정되는 등 제약이 있었다 — deinit이 실행되지 않고 그냥 사라져도 메모리 안전에 문제가 없는 경우로 범위를 좁힌 것이다. 이 챕터에서 실제로 검증한 것은 "discard self가 있고 없고에 따라 close() 직후 deinit이 또 도는지 여부가 실행 결과로 갈린다"는 사실이며, 어떤 타입 구성까지 discard self가 허용되는지의 정확한 최신 경계는 이 챕터의 실험 범위를 벗어난다.
쉽게 말하면 "나 이미 다 정리했으니까 자동 청소는 하지 마"라고 말할 수 있는 자격은, 애초에 자동 청소가 없어도 안전한 단순한 물건에만 주어진다.

여기까지가 Swift 언어 자체의 마지막 장이다. 다음 장(14 · Foundation이란 무엇인가)부터는 이 접근 제어·ABI 규칙 위에서 실제로 배포되는 Foundation 프레임워크로 넘어간다 — NS 접두사의 기원부터 swift-foundation 재작성까지, 우리가 매일 import하는 그 라이브러리의 정체를 다룬다.

출처 · 참고자료