여기서부터 Part C다. Part A가 언어의 의미론, Part B가 Foundation의 API였다면, Part C는 그 아래 — 메모리를 직접 만지는 층과 최근 두 릴리스가 새로 얹은 도구들이다. 이 챕터는 그중 가장 오래된 규칙에서 시작한다. Swift에는 Array도 class도 아닌, 언어 전체를 관통하는 메모리 규칙이 하나 있다. 어떤 메모리를 수정하는 동안에는 아무도 그 메모리에 접근할 수 없다는 것. 이 규칙(law of exclusivity)은 Swift 4부터 있었지만, 대부분의 개발자는 Simultaneous accesses to 0x... 크래시를 처음 만나는 날에야 그 존재를 알게 된다.
이 챕터의 결론을 먼저 말하면 이렇다. 같은 위반이 어디서 걸리느냐에 따라 위험도가 완전히 다르다. 컴파일 타임에 걸리면 가장 안전하고, 런타임에 SIGABRT로 죽으면 그다음이고, 아무 일도 없이 exit 0으로 통과하는 게 가장 위험하다. 이 세 가지를 실제로 재현해 종료 코드까지 비교한다.
본문의 컴파일 진단문·크래시 로그·수치는 모두 Swift 6.2.1 (swiftlang-6.2.1.4.8) / macOS 26.6.1 / arm64에서 직접 컴파일·실행해 얻은 실측값이다. 크래시 로그의 주소값은 실행마다 달라진다.
Q1. 메모리 배타적 접근(law of exclusivity)이란 무엇이며, 컴파일러는 언제 정적으로 잡고 언제 런타임으로 미루는가?
SE-0176이 정한 규칙은 하나다 — 어떤 변수를 수정(modify)하려고 접근하는 동안, 그 변수에 대한 다른 어떤 접근도 겹칠 수 없다. 읽기끼리는 겹쳐도 되지만 쓰기가 하나라도 끼면 안 된다. Swift는 이 규칙을 두 군데서 강제한다. 대상이 지역 변수·inout 인자처럼 컴파일러가 접근 범위를 전부 볼 수 있으면 컴파일 에러로 막고, 전역 변수·클래스 프로퍼티·static 프로퍼티처럼 정적으로 증명할 수 없으면 런타임 검사(swift_beginAccess/swift_endAccess)로 미룬다. 후자는 위반 시 SIGABRT로 프로세스를 끝낸다.
원리
We propose that Swift should instead enforce a general rule that
potential modifications of variables must be exclusive with
any other access to that variable.문장에서 배타성을 요구하는 주체가 modifications임에 주목한다. "모든 접근이 배타적"이 아니라 "수정은 다른 어떤 접근과도 겹칠 수 없다"이므로, 읽기끼리는 겹쳐도 된다. 실측으로도 같은 변수를 두 인자로 읽기만 하는 호출은 정상 컴파일·실행된다 — 이 규칙의 정확한 이름은 "배타적 수정"에 가깝다.
왜 이런 규칙이 필요한가. inout은 겉보기엔 "참조로 넘긴다"처럼 보이지만 Swift의 공식 모델은 copy-in / copy-out, 정확히는 빌림(borrow)이다. 함수가 inout 인자를 들고 있는 동안 그 변수는 "함수에게 대여 중"이며, 대여 기간에 원래 자리로 다른 경로가 끼어들면 두 개의 수정이 서로를 덮어쓴다. SE-0176은 이 상태를 언어 차원에서 금지해, 값 타입이 값처럼 동작한다는 약속을 지킨다.
이 규칙은 성능 보장이기도 하다. 배타성이 보장되면 컴파일러는 "이 함수가 도는 동안 이 메모리는 아무도 안 건드린다"고 가정할 수 있어, 값을 레지스터에 올려두고 매번 다시 읽지 않아도 된다. 배타적 접근은 안전성 규칙이면서 최적화의 전제다. 이것이 Part A 13장에서 본 borrowing/consuming(SE-0377)이 서 있는 토대이기도 하다.
내부 동작
접근에는 기간(duration)이 있다. 순간적으로 읽고 끝나는 접근(instantaneous)과, 함수 호출이 끝날 때까지 이어지는 접근(long-term)이 다르다. inout 인자와 mutating 메서드의 self가 후자다. 컴파일러는 이 기간을 "접근 범위(access scope)"로 표현하고, 범위가 겹치는지 검사한다.
동적 검사는 공짜가 아니다. 접근 시작 시 swift_beginAccess가 스레드별 접근 집합(AccessSet)에 이 접근을 등록하고, 끝날 때 swift_endAccess가 제거한다. 등록하는 순간 이미 겹치는 접근이 있으면 그 자리에서 진단 메시지를 찍고 중단한다. 그래서 크래시 로그에 swift::runtime::AccessSet::insert가 등장한다 — 뒤의 Q2에서 실제 로그로 확인한다.
실험 · 도구
먼저 정적으로 걸리는 쪽. 같은 변수를 두 inout 인자로 넘기면 컴파일러가 두 개의 서로 다른 진단을 낸다.
func swapTwo(_ a: inout Int, _ b: inout Int) { let t = a; a = b; b = t }
var x = 1
swapTwo(&x, &x) // 같은 변수를 두 inout 인자로ex1.swift:3:13: error: inout arguments are not allowed to alias each other
ex1.swift:3:9: error: overlapping accesses to 'x', but modification requires
exclusive access; consider copying to a local variable두 진단이 함께 나오는 게 포인트다. 앞의 것은 "inout 인자끼리 같은 메모리를 가리키면 안 된다"는 좁은 규칙이고, 뒤의 것이 배타적 접근이라는 일반 규칙이다. 뒤쪽 문구에 해결책까지 붙어 있다 — consider copying to a local variable.
프로젝트 적용
① 정적 위반은 대부분 "지역 복사본을 만들라"로 해결된다. 진단문이 시키는 그대로다.
var x = 1
var tmp = x // 지역 복사본 — 접근 범위가 분리된다
swapTwo(&x, &tmp)
x = tmp // 필요하면 되돌려 쓴다② 더 흔한 실무 형태는 같은 배열의 두 원소를 동시에 inout으로 넘기는 경우다. 여기서 이 규칙의 가장 중요한 성질이 드러난다 — 배타성은 원소 단위가 아니라 프로퍼티(저장 위치) 단위로 강제된다. 인덱스가 서로 달라도 두 접근이 모두 같은 cells 프로퍼티를 통과하므로 범위가 겹친다.
func modify(_ a: inout [Int], at i: Int) { a[i] += 1 }
func two(_ a: inout Int, _ b: inout Int) { a += b }
struct S {
var cells = [1, 2, 3]
var n = 0
mutating func caseA() { modify(&cells, at: cells.count - 1) } // ✅ 합법
mutating func caseB() { two(&n, &n) } // ❌ 에러
mutating func caseC() { two(&n, &cells[0]) } // ✅ 합법
mutating func caseD() { two(&cells[0], &cells[1]) } // ❌ 에러
}cases.swift:9:33: error: overlapping accesses to 'self.n', but modification
requires exclusive access; consider copying to a local variable
cases.swift:11:33: error: overlapping accesses to 'self.cells', but modification
requires exclusive access; consider copying to a local variable네 경우의 차이가 규칙의 경계를 정확히 그려준다.
| 경우 | 결과 | 왜 |
|---|---|---|
modify(&cells, at: cells.count - 1) | 합법 | cells.count 읽기가 수정 접근이 시작되기 전에 끝난다 — 인자 평가가 먼저다 |
two(&n, &cells[0]) | 합법 | 서로 다른 저장 프로퍼티라 범위가 겹치지 않는다 |
two(&n, &n) | 에러 | 같은 프로퍼티를 두 번 장기 대여 |
two(&cells[0], &cells[1]) | 에러 | 인덱스가 달라도 두 접근이 같은 cells를 통과한다 |
마지막 줄이 실무에서 사람을 놀라게 하는 대목이다. "0번과 1번은 다른 원소인데 왜?"라는 질문의 답은, 컴파일러가 보는 단위가 cells[0]이 아니라 cells라는 것이다. 배열 첨자에 대한 inout 접근은 Array의 _modify 접근자(Part A 05장)를 통과하며, 그 접근자는 버퍼 전체를 대여한다. 그래서 원소 두 개를 맞바꾸려면 swapAt(_:_:)처럼 한 번의 접근 안에서 처리하는 API를 써야 한다.
func two(_ a: inout Int, _ b: inout Int) { a += b }
// ❌ 두 대여가 같은 cells 프로퍼티에서 겹친다 — 컴파일 에러
// var x = [1, 2, 3]; two(&x[0], &x[1])
// ✅ 해법 1 — 교환이 목적이면 한 번의 접근으로 끝내는 표준 API를 쓴다
var p = [1, 2, 3]
p.swapAt(0, 1)
print("swapAt:", p) // [2, 1, 3]
// ✅ 해법 2 — 임의의 inout 함수를 써야 하면 지역 값으로 꺼낸다
var q = [1, 2, 3]
var a = q[0], b = q[1] // 읽기 접근이 여기서 끝난다
two(&a, &b) // 지역 변수끼리는 겹치지 않는다
q[0] = a; q[1] = b // 쓰기 접근을 각각 따로 시작한다
print("지역 분리:", q) // [3, 2, 3] — a = 1 + 2 = 3실무에서 이 규칙을 가장 자주 위반하는 자리는 셋이다 — 전역 캐시·싱글턴을 inout으로 넘기는 코드, static var 카운터를 여러 경로에서 갱신하는 코드, 그리고 클로저가 캡처한 변수를 캡처 밖에서도 수정하는 코드다. 앞의 둘은 런타임 검사로 넘어가므로 테스트에서 안 걸리고 프로덕션에서 터질 수 있다.
"배타적 접근은 inout에만 적용되는 규칙"이라는 말은 틀렸다. mutating 메서드의 self, 프로퍼티의 _modify 접근자(Part A 05장), 첨자 대입 등 수정 의미를 갖는 모든 접근이 대상이다. 또 "동시성 문제라 단일 스레드면 상관없다"도 틀렸다 — 이 규칙은 한 스레드 안에서 재진입으로도 위반된다. Q2의 실측이 정확히 단일 스레드 예제다.
도서관에서 책을 한 권 빌리는 것과 같다. 내가 그 책을 빌려 간 동안에는, 서가에 있는 "그 책"을 다른 사람이 가져갈 수도 없고 내용을 고칠 수도 없다. Swift가 inout으로 변수를 넘기는 건 "이 함수가 이 변수를 빌려 갔다"는 뜻이고, 빌려 간 동안 원래 자리를 누가 또 건드리면 규칙 위반이다. 비유가 깨지는 곳: 도서관은 두 사람이 같이 읽는 건 허용하는데, Swift도 똑같이 읽기끼리는 겹쳐도 된다. 금지되는 건 "누군가 고치는 중에 다른 접근이 끼는" 경우뿐이다.
꼬리 질문
읽기끼리는 왜 겹쳐도 되는가?
왜 지역 변수는 정적으로 잡고 전역 변수는 런타임으로 미루는가?
static·클래스 프로퍼티는 다른 파일, 다른 모듈, 심지어 아직 컴파일되지 않은 코드에서도 접근할 수 있어서 "모든 접근"을 볼 방법이 없다. 증명이 불가능하면 남는 선택은 실행 중에 확인하는 것뿐이다.배타적 접근과 Swift 6의 데이터 레이스 안전성(Sendable)은 같은 것인가?
Sendable/actor 격리는 여러 스레드 사이의 공유를 다루고, 검사 단위는 "이 값이 격리 경계를 넘어도 되는가"다. 실제로 배타적 접근 위반은 완전히 단일 스레드인 프로그램에서도 재현되며(Q2), 반대로 Sendable을 다 지켜도 재진입으로 배타성을 깰 수 있다. 두 규칙은 서로를 대체하지 않는다. Foundation 타입의 Sendable 대응은 Part B 17장에서 다뤘다.Q2. 릴리스(-O) 빌드에서도 배타적 접근 검사가 살아 있는가? 왜 어떤 위반은 조용히 통과하는가?
살아 있다. 동적 배타성 검사는 디버그 전용이 아니라 -O 릴리스 빌드에서도 기본으로 켜져 있고, 위반하면 Simultaneous accesses to 0x...를 찍고 SIGABRT(종료 코드 134)로 죽는다. 실측으로 -Onone과 -O 양쪽에서 동일하게 트랩하는 것을 확인했다. 다만 같은 위반이 조용히 통과하는 경우가 있다 — 최적화기가 "이 inout 인자를 아무도 쓰지 않는다"고 판단해 접근 자체를 제거하면, 제거된 접근은 검사 대상도 아니게 된다. 즉 통과했다는 것이 안전하다는 뜻이 아니라, 그 코드 경로가 이번 빌드에서 사라졌다는 뜻일 뿐이다.
원리
Swift 컴파일러에는 -enforce-exclusivity=<enforcement> 옵션이 있다(swiftc -h에 Enforce law of exclusivity로 문서화돼 있다). 기본값은 checked — 정적으로 증명 못 한 접근에 런타임 검사를 삽입한다. 실측으로 확인한 것은 이것이다 — 이 툴체인(6.2.1)에서 -Onone과 -O 양쪽 모두 검사가 삽입되고 위반 시 중단한다(아래 실험). 즉 "릴리스니까 검사가 빠진다"는 가정은 성립하지 않으며, 이것이 배타적 접근 위반이 프로덕션 크래시로 보고되는 이유다.
"동적 검사가 어느 Swift 버전부터 릴리스 빌드에 들어갔는가"는 이 강의가 확인하지 않은 역사적 사실이다. SE-0176 본문은 빌드 구성별 활성화 여부를 규정하지 않는다. 여기서 근거로 삼는 것은 이 툴체인에서 직접 측정한 현재 동작뿐이다.
내부 동작
런타임 검사의 실체는 스레드 로컬 접근 집합이다. swift_beginAccess(주소, 버퍼, 플래그, PC)가 호출되면 그 스레드의 AccessSet에 {주소, 종류}를 삽입하고, 삽입하려는 접근이 기존 항목과 충돌하면(둘 중 하나라도 modification이면) 진단을 찍고 중단한다. 그래서 로그의 스택 트레이스 최상단이 항상 AccessSet::insert → swift_beginAccess 순서다.
핵심은 검사는 "삽입되는 접근"이 있어야 존재한다는 것이다. 최적화기가 접근을 지우면 검사도 함께 사라진다. 아래 실험에서 이 차이가 그대로 드러난다.
실험 · 도구
먼저 통과하는 버전. inout 인자 a를 함수 본문에서 한 번도 쓰지 않는다.
var arr = [1, 2, 3]
func mutate(_ a: inout [Int]) { arr.append(99) } // a 를 쓰지 않는다
mutate(&arr)
print(arr)[1, 2, 3, 99]
exit code = 0이제 단 한 줄을 더한다. a를 실제로 사용해, 접근이 제거되지 않게 만든다.
var arr = [1, 2, 3]
func mutate(_ a: inout [Int]) {
arr.append(99) // 빌린 대상을 전역 경로로 또 건드린다
print("inside, a =", a) // a 를 실제로 써서 접근이 지워지지 않게 한다
}
mutate(&arr)
print("after:", arr)Simultaneous accesses to 0x1003d8018, but modification requires exclusive access.
Previous access (a modification) started at ex4_O`main + 56 (0x1003d0700).
Current access (a modification) started at:
0 libswiftCore.dylib 0x... swift::runtime::AccessSet::insert(swift::runtime::Access*,
void*, void*, swift::ExclusivityFlags) + 476
1 libswiftCore.dylib 0x... swift_beginAccess + 84
2 ex4_O 0x... mutate(_:) + 56
3 ex4_O 0x... main + 64
4 dyld 0x... start + 6992
exit code = 134 # 128 + 6 (SIGABRT)-Onone으로 빌드해도 같은 메시지와 같은 스택 모양이 나온다. 검사가 최적화 수준과 무관하게 살아 있다는 직접 증거다. 이 예제는 스레드를 하나도 만들지 않는다 — 배타적 접근이 동시성 문제가 아니라는 Q1의 주장을 그대로 증명한다.
프로젝트 적용
① 진단문을 읽는 법이 실무에서 가장 쓸모 있다. 로그의 세 줄이 각각 다른 정보를 준다.
| 로그 줄 | 알려주는 것 | 어떻게 쓰는가 |
|---|---|---|
Simultaneous accesses to 0x… | 충돌한 메모리 주소 | 같은 주소면 같은 변수 — 여러 변수 중 어느 것인지 가른다 |
Previous access … started at … | 먼저 시작된 접근의 코드 위치 | 보통 여기가 진짜 범인 — 대여를 시작한 지점 |
Current access … started at: + 스택 | 나중에 끼어든 접근 | 스택 3~4번 프레임이 실제 앱 코드 |
② 앞의 두 버전을 비교하면 실무 규칙이 하나 나온다. "릴리스에서 안 죽으니 괜찮다"는 결론을 내리면 안 된다. 통과 여부가 최적화기의 판단에 달려 있으므로, 코드를 한 줄 고치거나 툴체인을 올리는 것만으로 조용했던 위반이 크래시로 바뀔 수 있다.
// ❌ 전역을 직접 빌려주고, 그 안에서 전역을 또 만진다
var cache: [String: Int] = [:]
func risky(_ c: inout [String: Int]) { cache["extra"] = 1; c["x"] = 2 }
// ✅ 지역으로 꺼내 작업하고, 마지막에 한 번만 되돌려 쓴다
func safe() {
var local = cache // 읽기 접근이 여기서 끝난다
local["x"] = 2
local["extra"] = 1
cache = local // 쓰기 접근이 여기서 시작하고 끝난다
}③ -enforce-exclusivity를 끄는 선택은 사실상 없다. 성능 때문에 끄고 싶어지는 자리라면, 그 자리는 애초에 inout 대여를 반복하는 설계이거나 Q4의 withUnsafeTemporaryAllocation·30장의 Span으로 다시 써야 하는 자리다.
세 가지 메모리 규칙 위반의 종료 코드를 나란히 재현하면 위험도의 순서가 그대로 보인다. Int.max + 1 오버플로는 exit 133(SIGTRAP), 배타적 접근 위반은 exit 134(SIGABRT), 그리고 Q3에서 볼 "클로저 밖으로 새어나간 포인터로 읽기"는 exit 0이다. 앞의 둘은 크래시 리포트로 올라와 반드시 고쳐지고, 마지막은 아무 신호도 남기지 않은 채 잘못된 값을 계산해 다음 로직으로 흘려보낸다. 메모리 안전성에서는 시끄럽게 죽는 것이 조용히 사는 것보다 낫다.
"동적 배타성 검사는 디버그 빌드에만 들어가니 릴리스 성능에는 영향이 없다"는 틀렸다 — Swift 5부터 릴리스에도 기본 삽입된다. 반대로 "그래서 모든 접근마다 런타임 비용이 든다"도 과장이다. 컴파일러가 정적으로 증명한 접근에는 검사가 삽입되지 않으므로, 비용은 증명하지 못한 전역·프로퍼티 접근에만 붙는다. 또 "테스트가 다 통과했으니 배타성 위반이 없다"는 것도 근거가 되지 못한다 — 위반 경로가 실행되지 않았거나, 최적화기가 그 접근을 지웠을 수 있다.
도서관에 "대출 장부"가 있다고 하자. swift_beginAccess는 "지금 이 책 빌려 갑니다"를 장부에 적는 일이고, 적으려는 순간 그 책이 이미 대출 중으로 적혀 있으면 사서가 막아선다. 그런데 만약 내가 책을 빌려서 한 번도 펴보지 않았다면, 똑똑한 사서가 "어차피 안 읽을 거니 장부에 적지도 말자"고 생략해버릴 수 있다. 그러면 규칙을 어겼는데도 아무도 막지 않는다. 비유가 깨지는 곳: 사서는 친절해서 생략하는 게 아니라 속도를 위해 생략하며, 나중에 내가 책을 한 줄이라도 읽도록 코드를 고치면 그때부터 다시 장부에 적히고 곧바로 막힌다.
꼬리 질문
ex2와 ex4의 차이는 단 한 줄인데, 왜 결과가 통과와 크래시로 갈리는가?
inout 인자가 함수 본문에서 전혀 사용되지 않는다"를 확인하면 그 대여 자체를 없앨 수 있다. 대여가 없어지면 swift_beginAccess 호출도 삽입되지 않고, 삽입되지 않은 접근은 충돌 검사에 참여하지 않는다. ex4에서 print("inside, a =", a) 한 줄을 넣으면 a가 실제로 읽히므로 대여를 지울 수 없고, 그 결과 beginAccess가 살아남아 전역 arr.append와 충돌한다. 결과가 갈린 것은 위반 여부가 달라졌기 때문이 아니라, 위반을 감지할 코드가 남았는지가 달라졌기 때문이다.SIGABRT(134)와 SIGTRAP(133)은 왜 다른 신호를 쓰는가?
SIGTRAP(128+5=133)으로 보인다. 배타적 접근 위반은 런타임 라이브러리 함수(swift_beginAccess)가 진단 메시지를 먼저 출력한 뒤 정상적인 중단 경로를 타므로 abort() 계열, 즉 SIGABRT(128+6=134)가 된다. 실무적으로는 이 차이가 로그 판독에 쓸모가 있다 — 134에 Simultaneous accesses 문구가 함께 있으면 배타성 위반이 거의 확실하다.단일 스레드인데 "Simultaneous"(동시)라는 단어를 쓰는 건 부정확하지 않은가?
overlapping accesses라는 더 정확한 단어로 표현한다 — Q1의 실측 출력에서 두 표현이 나란히 나온 이유다.Q3. withUnsafeBufferPointer의 포인터를 클로저 밖으로 빼면 정확히 무엇이 깨지는가?
withUnsafe* 계열이 클로저에 넘겨주는 포인터는 그 클로저가 실행되는 동안에만 유효하다는 계약을 갖는다. 밖으로 빼내 나중에 쓰면 정의되지 않은 동작(UB)이며, 컴파일러는 이것을 막아주지 않는다. 실측하면 컴파일도 통과하고, 실행도 되고, 심지어 그럴듯한 값(1)이 출력되면서 exit 0으로 정상 종료한다. 즉 이 버그는 크래시가 아니라 "조용히 틀린 값"으로 나타나며, 이것이 Q2에서 본 SIGABRT보다 훨씬 위험한 이유다. 30장의 Span이 정확히 이 구멍을 컴파일 타임에 막기 위해 도입됐다.
원리
withUnsafeBufferPointer(_:)의 계약은 "이 메서드는 배열의 연속 저장 공간에 대한 포인터를 클로저에 넘긴다. 이 포인터는 클로저 실행 범위를 벗어나면 유효하지 않다"는 것이다. 이 계약이 필요한 이유는 배열의 버퍼가 언제든 이동하거나 해제될 수 있기 때문이다. Part A 01장에서 본 CoW가 새 버퍼로 갈아타면 원래 주소는 남의 땅이 되고, 배열 자체의 수명이 끝나면 그 힙 블록은 해제되어 다른 할당에 재사용된다.
여기서 중요한 것은 UB는 "크래시한다"가 아니라 "어떤 일이 일어날지 언어가 아무 약속도 하지 않는다"라는 점이다. 운이 좋으면(=해제된 페이지가 아직 반환되지 않았고 내용이 남아 있으면) 원래 값이 그대로 읽힌다. 그래서 UB는 테스트를 통과한다.
내부 동작
왜 컴파일러가 막지 못하는가. UnsafePointer<T>는 그냥 정수 주소를 감싼 값 타입이고, 타입 시스템에 "이 주소가 누구의 수명에 묶여 있는지"를 표현할 자리가 없다. 그래서 클로저 밖의 변수에 대입하는 것이 문법적으로 완전히 합법이다. 컴파일러가 아는 것은 "UnsafePointer<Int> 값을 UnsafePointer<Int>? 변수에 넣는다"뿐이다.
실험 · 도구
var leaked: UnsafePointer<Int>? = nil
do {
let a = [1, 2, 3]
a.withUnsafeBufferPointer { buf in
leaked = buf.baseAddress // 클로저 밖으로 탈출 — 컴파일 통과
}
}
// 원본 배열의 수명이 끝난 뒤 접근
print("탈출한 포인터로 읽기:", leaked!.pointee) // 정의되지 않은 동작컴파일 경고/에러: 없음
탈출한 포인터로 읽기: 1
exit code = 0정답처럼 보이는 1이 나왔다. 이 값이 맞은 것은 순전히 해제된 메모리가 아직 덮어써지지 않았기 때문이며, 배열 크기·최적화 수준·주변 할당이 조금만 달라지면 쓰레기 값이나 크래시로 바뀐다. 같은 코드가 어떤 빌드에서는 통과하고 어떤 빌드에서는 실패하는 종류의 버그다.
그럼 무엇이 이걸 잡아주는가. 세 가지 도구가 각각 다른 층에서 잡는다.
| 도구 | 언제 잡는가 | 이 예제를 잡는가 |
|---|---|---|
-strict-memory-safety (Q5) | 컴파일 타임 | 잡는다 — unsafe 구조 사용을 경고 |
| Address Sanitizer | 런타임 (실행해야 함) | 잡는다 — use-after-free로 보고 |
Span (30장) | 컴파일 타임 | 잡는다 — 탈출 자체가 에러 |
| 단위 테스트 | 런타임 | 못 잡는다 — 값이 맞게 나온다 |
프로젝트 적용
① 규칙은 단순하다. withUnsafe* 클로저 안에서 시작한 일은 그 안에서 끝낸다. 포인터를 내보내는 대신, 필요한 결과를 계산해서 내보낸다.
let a = [1, 2, 3]
// ❌ 포인터를 내보낸다
var p: UnsafePointer<Int>?
a.withUnsafeBufferPointer { p = $0.baseAddress }
// ✅ 값을 내보낸다 — 클로저는 값을 반환할 수 있다
let total = a.withUnsafeBufferPointer { buf -> Int in
var t = 0
for i in 0..<buf.count { t += buf[i] }
return t
}
print(total) // 6② 반환값으로도 새어나갈 수 있다는 점을 놓치기 쉽다. 클로저가 포인터를 return하면 대입과 똑같이 탈출한다. 그래서 "포인터 타입을 반환하지 않는다"를 코드 리뷰 규칙으로 두는 편이 실전에서 잘 통한다.
// ❌ 반환 타입이 포인터면 그것도 탈출이다
func badBase(_ a: [Int]) -> UnsafePointer<Int> {
a.withUnsafeBufferPointer { $0.baseAddress! } // 반환되는 순간 무효
}
// ✅ C API에 넘겨야 한다면 호출까지 클로저 안에서 끝낸다
func callCAPI(_ a: [Int]) -> Int32 {
a.withUnsafeBufferPointer { buf in
c_process(buf.baseAddress, Int32(buf.count)) // 여기서 다 끝낸다
}
}③ C 라이브러리와 붙이는 코드가 이 함정의 주 무대다. C 함수가 포인터를 보관하는 API라면(콜백 등록, 비동기 처리) 클로저 안에서 호출하는 것만으로는 부족하다. 그때는 직접 힙에 할당해 수명을 명시적으로 관리해야 한다 — UnsafeMutablePointer.allocate(capacity:)와 deallocate() 쌍, 혹은 그 데이터를 소유하는 Data/Array를 C 호출이 끝날 때까지 살려두는 구조다.
&array 축약도 같은 계약이다c_process(&array, count)처럼 배열에 &를 붙여 C 포인터로 넘기는 축약 문법도 그 함수 호출 동안에만 유효하다. 이 형태는 withUnsafeBufferPointer보다 더 짧아서 "그냥 주소를 넘긴다"고 착각하기 쉽지만, 계약은 완전히 같다. 호출이 반환된 뒤 그 포인터가 어딘가에 남아 있으면 동일한 UB다.
"UB면 크래시가 나니까 테스트에서 걸린다"는 가장 위험한 오해다. 실측이 보여준 것처럼 정답이 나오면서 exit 0일 수 있다. 또 "let으로 선언한 배열은 안 바뀌니 포인터도 계속 유효하다"도 틀렸다 — let은 값의 불변성이지 버퍼의 주소나 수명에 대한 약속이 아니다. 배열이 스코프를 벗어나면 버퍼는 해제된다. 마지막으로 "옵티마이저를 끄면(-Onone) 안전하다"도 틀렸다 — 최적화 수준은 UB를 정의된 동작으로 만들어주지 않고, 단지 증상이 나타나는 시점을 바꾼다.
호텔 방 번호를 적어두는 것과 같다. 내가 묶는 동안(클로저 안)에는 "302호"라는 쪽지가 정확히 내 방을 가리킨다. 그런데 체크아웃한 뒤에도 그 쪽지를 들고 와서 "302호에 내 짐 있어"라고 하면? 운 좋으면 아직 청소를 안 해서 짐이 그대로 있고, 운 나쁘면 다른 손님이 들어와 있다. 쪽지에는 "언제까지 유효한지"가 안 적혀 있다는 것이 문제의 핵심이다. 비유가 깨지는 곳: 호텔은 다른 손님이 있으면 눈에 보이지만, 메모리는 남의 데이터가 들어와 있어도 겉보기에 똑같은 숫자라서 알아챌 수 없다.
꼬리 질문
실측에서 값 1이 정확히 나왔는데, 왜 이것을 "틀렸다"고 하는가?
왜 Swift는 UnsafePointer에 수명 정보를 넣지 않았는가?
~Escapable이라는 새 타입 성질과 함께 도입돼야 했다. UnsafePointer는 그런 장치가 없던 Swift 1 시절부터 C 상호운용을 위해 존재해온 타입이라, 뒤늦게 성질을 바꾸면 기존 코드가 전부 깨진다. 그래서 애플의 선택은 기존 타입을 고치는 대신 수명이 타입에 새겨진 새 타입을 추가하는 것이었다 — 30장의 Span이다.클로저 안에서 async 함수를 await 하면 포인터는 여전히 유효한가?
withUnsafeBufferPointer의 클로저는 동기이므로 그 안에서 await할 수 없다. 억지로 포인터를 Task 안으로 넘기면 클로저는 즉시 반환되고 포인터는 그 순간 무효가 되므로, Task가 실제로 실행될 때는 이미 유효 구간이 끝나 있다. 비동기 경계를 넘겨야 하는 데이터라면 포인터가 아니라 소유권을 가진 값(Data, Array, [UInt8])을 복사해 넘기는 것이 유일하게 안전한 방법이다.Q4. withUnsafeTemporaryAllocation은 무슨 문제를 푸는가?
"짧게 쓰고 버릴 작업용 버퍼가 필요한데, 그걸 위해 힙 할당(malloc)과 해제를 하는 게 아까운" 상황을 푼다. withUnsafeTemporaryAllocation(of:capacity:_:)은 충분히 작으면 스택에 잡고, 크면 힙으로 넘기는 판단을 런타임에 맡긴다. 반환된 버퍼는 초기화되지 않은 상태로 오므로 직접 initializeElement(at:to:)로 채우고 deinitialize()로 정리해야 하며, Q3와 똑같이 포인터를 클로저 밖으로 내보내면 안 된다. 실측에서 4개 Int 버퍼에 값을 채워 [0, 1, 4, 9]를 정상 출력하는 것을 확인했다.
원리
표준 라이브러리의 실제 선언은 SDK에 동봉된 .swiftinterface에서 직접 확인할 수 있다. 내부 구현은 Builtin.RawPointer를 다루는 _withUnsafeTemporaryAllocation으로 내려가고, @_alwaysEmitIntoClient @_transparent가 붙어 있다.
@_alwaysEmitIntoClient @_transparent
internal func _withUnsafeTemporaryAllocation<T, R>(
of type: T.Type, capacity: Swift.Int, alignment: Swift.Int,
_ body: (Builtin.RawPointer) throws -> R
) rethrows -> R where T : ~Copyable, R : ~Copyable@_transparent가 붙은 이유가 이 API의 핵심이다. 스택 할당은 호출한 함수의 프레임에 공간을 잡아야 하므로, 이 함수가 별도 프레임을 만드는 진짜 호출로 남아 있으면 성립하지 않는다. 그래서 항상 호출 지점으로 인라인되어 사라진다. T : ~Copyable과 R : ~Copyable은 Part A 13장의 noncopyable 타입도 이 버퍼에 담고 결과로 돌려줄 수 있다는 뜻이다.
내부 동작
"작으면 스택"의 판단 기준은 API 계약에 명시되지 않았다. 문서가 보장하는 것은 "충분히 작으면 스택에 할당될 수 있다"는 가능성이며, 크기 임계값이나 판단 규칙은 구현 세부사항이다. 따라서 "이 API를 쓰면 힙 할당이 없다"고 가정한 코드를 써서는 안 된다 — 성능이 중요하면 실제로 프로파일링해 확인해야 한다. 이것은 Part B 28장에서 본 NSCache.countLimit이 "엄격한 제한이 아니다"와 같은 종류의 계약이다.
버퍼가 초기화되지 않은 채 온다는 점도 중요하다. 넘어오는 것은 UnsafeMutableBufferPointer<T>이지만, 그 안의 메모리에는 아무 유효한 T 값도 없다. 그래서 buf[i] = x처럼 대입하면 안 되고(대입은 "기존 값을 해제하고 새 값을 쓴다"는 의미다) initializeElement(at:to:)로 처음 값을 심어야 한다. 참조 카운트가 있는 타입이라면 이 구분이 곧바로 메모리 오류로 이어진다.
| 단계 | 해야 하는 일 | 빠뜨리면 |
|---|---|---|
| 진입 | initializeElement(at:to:)로 채운다 | 초기화 안 된 메모리를 읽음 — UB |
| 사용 | 클로저 안에서만 쓴다 | Q3와 동일한 탈출 UB |
| 이탈 | deinitialize()로 정리한다 | 참조 타입이면 릭 — ARC가 해제를 못 함 |
실험 · 도구
let n = 4
withUnsafeTemporaryAllocation(of: Int.self, capacity: n) { buf in
for i in 0..<n { buf.initializeElement(at: i, to: i * i) }
print("temp buf:", Array(buf[0..<n]), "| baseAddress non-nil:", buf.baseAddress != nil)
buf.deinitialize()
}temp buf: [0, 1, 4, 9] | baseAddress non-nil: trueInt는 BitwiseCopyable이라 deinitialize()가 실질적으로 아무 일도 하지 않지만, 그래도 넣어두는 게 좋다. 나중에 원소 타입이 참조를 품은 타입으로 바뀌면 그 순간부터 deinitialize()가 반드시 필요해지는데, 처음부터 있으면 그 변경이 안전해진다.
프로젝트 적용
① 쓸 자리는 크기를 실행 시점에만 아는 짧은 작업 버퍼다. 대표적으로 인코딩 변환, 정렬용 임시 배열, C API가 요구하는 출력 버퍼다.
func readInto(count: Int) -> [UInt8] {
withUnsafeTemporaryAllocation(of: UInt8.self, capacity: count) { buf in
for i in 0..<count { buf.initializeElement(at: i, to: 0) }
let written = c_fill(buf.baseAddress, Int32(count)) // 클로저 안에서 호출
defer { buf.deinitialize() }
return Array(buf[0..<Int(written)]) // 값으로 복사해 내보낸다
}
}② 쓰지 말아야 할 자리가 더 중요하다. capacity를 사용자 입력이나 네트워크 응답에서 그대로 받으면 안 된다. 큰 값이 오면 스택을 넘겨 스택 오버플로로 죽을 수 있고, 이건 크래시 리포트에서 원인을 찾기 어렵다. 상한을 두고 그 위는 Array나 Data로 처리한다.
let maxTemp = 1024 // 프로젝트가 정한 상한
func process(count: Int) -> Int {
if count <= maxTemp {
return withUnsafeTemporaryAllocation(of: Int.self, capacity: count) { buf in
for i in 0..<count { buf.initializeElement(at: i, to: i) }
defer { buf.deinitialize() }
return buf[0..<count].reduce(0, +)
}
} else {
let heap = Array(0..<count) // 큰 건 힙으로
return heap.reduce(0, +)
}
}③ 대안과의 관계를 정리하면 선택이 쉬워진다. 안전한 코드를 먼저 쓰고, 프로파일링이 지목한 자리만 아래로 내려간다.
| 수단 | 안전성 | 할당 | 언제 |
|---|---|---|---|
Array + reserveCapacity | 완전히 안전 | 힙 | 기본 선택 — 대부분 여기서 끝난다 |
InlineArray (30장) | 안전 | 인라인(힙 없음) | 크기가 컴파일 타임에 고정 |
withUnsafeTemporaryAllocation | unsafe — 계약 준수 필요 | 스택 가능 | 크기를 런타임에 알고, 짧게 쓰고 버림 |
UnsafeMutablePointer.allocate | unsafe — 해제까지 직접 | 힙 | 수명이 함수 밖으로 나가야 할 때 |
"withUnsafeTemporaryAllocation은 스택 할당이므로 항상 힙보다 빠르다"는 틀렸다 — 스택에 잡힐지는 보장이 아니고, 클로저·초기화·정리 비용을 합치면 작은 크기에서는 Array와 차이가 없거나 더 느릴 수도 있다. 또 "임시니까 정리(deinitialize)는 생략해도 된다"도 틀렸다 — 원소가 참조를 품고 있으면 그 참조들이 해제되지 않아 릭이 된다. 마지막으로 "버퍼가 0으로 초기화돼 온다"는 근거 없는 가정이다. 초기화되지 않은 메모리이므로 읽기 전에 반드시 써야 한다.
요리할 때 재료를 잠깐 올려둘 접시가 필요한 상황이다. 매번 찬장에서 새 접시를 꺼내 씻어 넣는 게(힙 할당·해제) 번거로우니, 그냥 조리대 한쪽을 잠깐 비워 쓰는 것(스택)이 이 API다. 대신 세 가지 규칙이 있다. 조리대 자리는 원래 뭐가 묻어 있을 수 있으니 쓰기 전에 닦아야 하고(초기화), 다 쓰면 치워야 하고(deinitialize), 요리가 끝나면 그 자리는 없어지므로 거기 둔 걸 나중에 찾으러 오면 안 된다(탈출 금지). 비유가 깨지는 곳: 재료가 아주 많으면 조리대에 안 들어가서 결국 찬장 접시를 꺼내게 되는데, 그 판단을 요리사가 아니라 주방이 알아서 한다는 점이 다르다.
꼬리 질문
왜 @_transparent가 붙어야 스택 할당이 가능한가?
withUnsafeTemporaryAllocation이 자기 프레임을 갖는 별도의 함수 호출로 남아 있으면, 그 안에서 잡은 스택 공간은 그 함수가 반환하는 순간 사라진다 — 클로저가 실행되는 동안 유효해야 하는데 그럴 수 없다. @_transparent는 이 함수를 호출 지점으로 무조건 인라인해 프레임을 없애므로, 잡히는 공간이 호출한 쪽 함수의 프레임이 되어 클로저 실행 내내 살아 있다. 즉 이 애트리뷰트는 최적화가 아니라 정확성 요건이다.buf[i] = x와 buf.initializeElement(at: i, to: x)는 왜 다른가?
buf[i] = x)의 의미는 "그 자리에 이미 있던 유효한 값을 해제하고 새 값을 넣는다"다. 초기화되지 않은 메모리에는 해제할 유효한 값이 없으므로, 대입을 하면 쓰레기 비트를 유효한 값으로 착각해 해제 절차를 밟는다. 원소가 클래스 참조라면 임의의 주소에 release를 보내는 셈이라 즉시 메모리 손상이다. initializeElement(at:to:)는 "여기엔 아직 아무것도 없다"를 전제로 해제 없이 값을 심는 연산이라 이 상황에 맞는다. Int처럼 BitwiseCopyable인 타입에서는 결과가 같아서 차이가 안 보이지만, 그래서 더 위험하다 — 원소 타입만 바꿨을 때 조용히 깨진다.capacity에 아주 큰 값을 넣으면 어떻게 되는가?
Array/Data로 우회한다.Q5. -strict-memory-safety(SE-0458)는 무엇을 강제하며 unsafe는 어떻게 쓰는가?
SE-0458은 unsafe한 구조를 쓰는 것을 금지하지 않고, 눈에 보이게 만든다. -strict-memory-safety를 켜면 포인터 타입을 다루는 표현식마다 [#StrictMemorySafety] 경고가 붙고, 개발자가 그 표현식에 unsafe 키워드를 명시하면 경고가 사라진다. 즉 목적은 제거가 아니라 감사 가능성(auditability) — 코드베이스에서 unsafe한 지점이 어디인지 unsafe 문자열 검색만으로 전부 드러나게 하는 것이다. Swift 6.2에 탑재됐고, 실측으로 6.2.1에서 경고 발생과 unsafe 표기로 인한 소거를 모두 확인했다.
원리
Swift는 처음부터 "기본적으로 안전하되 필요하면 내려갈 수 있는" 언어를 지향했다. 문제는 내려간 지점이 표시되지 않는다는 것이었다. UnsafePointer라는 타입 이름에 unsafe가 들어 있어도, 그것을 쓰는 표현식은 평범해 보인다. Q3의 leaked = buf.baseAddress가 그 예다 — 이 한 줄만 보면 위험 신호가 없다.
SE-0458의 접근은 Swift 6 동시성 검사와 같은 모양이다. 옵트인 모드를 켜면 컴파일러가 위험 지점을 전부 지적하고, 개발자는 각 지점을 명시적으로 승인한다. 승인 표시가 곧 문서가 되고, 코드 리뷰의 단위가 된다.
내부 동작
진단 그룹 이름이 StrictMemorySafety다. 경고는 표현식 단위로 발생하며, 어떤 부분이 왜 unsafe한지 note로 지목한다. 실측 출력을 보면 컴파일러가 "이 인자의 타입이 unsafe하다"는 수준까지 짚어준다.
import Foundation
func f(_ p: UnsafePointer<Int>) -> Int { p.pointee }
let a = [1, 2, 3]
print(a.withUnsafeBufferPointer { f($0.baseAddress!) })sms.swift:4:1: warning: expression uses unsafe constructs but is not marked
with 'unsafe' [#StrictMemorySafety]
note: argument #0 in call to instance method 'withUnsafeBufferPointer'
has unsafe type '(UnsafeBufferPointer<Int>) -> Int'
sms.swift:4:35: warning: expression uses unsafe constructs but is not marked
with 'unsafe' [#StrictMemorySafety]
note: argument #0 in call to global function 'f'
has unsafe type 'UnsafePointer<Int>'경고가 두 개 나온 것에 주의한다. 한 줄 안에 unsafe한 지점이 두 곳(클로저 전달, f 호출)이라 각각 별도로 지적된다. 이제 각 지점에 unsafe를 붙인다.
func f(_ p: UnsafePointer<Int>) -> Int { unsafe p.pointee }
let a = [1, 2, 3]
let r = unsafe a.withUnsafeBufferPointer { unsafe f($0.baseAddress!) }
print("r =", r)(컴파일 출력 없음)
r = 1unsafe는 표현식 앞에 붙는 키워드다. try/await와 같은 자리에 오며, 실행 의미는 전혀 바꾸지 않는다. 반대 방향의 애트리뷰트도 있다 — 30장에서 볼 Span의 선언에 붙은 @safe가 그것으로, "포인터를 내부에 품고 있지만 이 타입의 공개 API는 안전하다"를 선언한다.
실험 · 도구
기존 코드베이스에 켤 때는 migrate 변형이 있다. swiftc -h에 두 형태가 함께 문서화돼 있다.
-strict-memory-safety:migrate
-strict-memory-safety Enable strict memory safety checking
-enforce-exclusivity=<enforcement>
Enforce law of exclusivityQ3의 탈출 예제에 이 플래그를 걸어보면, 아무 신호도 주지 않았던 그 코드가 컴파일 타임에 지적된다. Q3 표에서 "잡는다"로 표시한 근거가 이것이다.
프로젝트 적용
① 도입 전략은 Swift 6 동시성 마이그레이션과 같다. 전체를 한 번에 켜지 말고, 모듈 단위로 켠다. Foundation·C 래핑이 몰려 있는 하위 모듈은 뒤로 미루고, 순수 로직 모듈부터 켜면 경고 수가 감당 가능하다.
.target(
name: "AppLogic", // 순수 로직 — 먼저 켠다
swiftSettings: [.unsafeFlags(["-strict-memory-safety"])]
),
.target(
name: "CBridge", // C 래핑 — 나중에
dependencies: []
)② unsafe를 붙이는 것은 "이 코드가 안전하다"는 선언이 아니라 "내가 이 지점의 계약을 확인했다"는 서명이다. 그래서 붙일 때 계약을 주석으로 남기는 규율이 실전에서 값을 만든다. 무엇이 그 포인터의 수명을 보장하는지가 리뷰의 대상이 되어야 한다.
// 수명 근거: buf 는 withUnsafeBufferPointer 클로저 안에서만 쓰이고,
// c_process 는 포인터를 보관하지 않는다(문서 확인: 동기 API).
let n = unsafe a.withUnsafeBufferPointer { buf in
unsafe c_process(buf.baseAddress, Int32(buf.count))
}③ 가장 큰 실무 효과는 검색 가능성이다. unsafe가 붙은 지점을 세는 것만으로 코드베이스의 위험 표면을 정량화할 수 있고, 30장의 Span·InlineArray로 옮길 우선순위를 그 수치로 정할 수 있다. Part C의 다음 챕터가 정확히 그 이야기다.
grep -rn '\bunsafe\b' Sources/ --include='*.swift' | wc -l
grep -rn 'Unsafe[A-Za-z]*Pointer' Sources/ --include='*.swift' | cut -d: -f1 | sort | uniq -c | sort -rnQ1~Q5에서 재현한 것을 위험도 순으로 정리하면, 왜 Span 같은 새 타입이 필요했는지가 한눈에 보인다.
| 위반 | 걸리는 지점 | 실측 결과 | 고칠 가능성 |
|---|---|---|---|
같은 변수 두 inout | 컴파일 타임 | error 2건 | 100% — 배포가 안 된다 |
| 전역 재진입 수정 | 런타임 | exit 134 · SIGABRT | 높음 — 크래시 리포트로 올라온다 |
같은 위반 + inout 미사용 | 어디에도 안 걸림 | exit 0 | 낮음 — 다음 빌드에 터질 수 있다 |
| 포인터 클로저 밖 탈출 | 어디에도 안 걸림 | exit 0 · 정답처럼 보이는 값 | 가장 낮음 — 신호가 없다 |
아래 두 줄이 -strict-memory-safety와 Span이 겨냥한 표적이다. 전자는 그 지점을 보이게 만들고, 후자는 컴파일 에러로 만든다.
"-strict-memory-safety를 켜면 포인터를 못 쓴다"는 틀렸다 — unsafe를 붙이면 그대로 쓸 수 있고, 실행 의미도 전혀 바뀌지 않는다. 또 "unsafe를 붙였으니 이제 안전하다"는 정반대의 오해다. 키워드는 위험을 표시할 뿐 제거하지 않는다 — Q3의 탈출 코드에 unsafe를 붙여도 UB는 그대로 UB다. 마지막으로 "런타임 비용이 생긴다"도 틀렸다. 이것은 순수하게 컴파일 타임 진단이라 생성되는 코드에는 아무 영향이 없다.
공장에서 "여기 위험구역"이라고 바닥에 노란 선을 그어두는 것과 같다. 선을 그었다고 그 구역이 안전해지는 건 아니고, 들어가는 걸 금지하는 것도 아니다. 다만 들어갈 때마다 "나 지금 위험구역 들어갑니다"라고 말하게 만든다. 그러면 나중에 사고를 조사할 때 "누가 언제 어디로 들어갔는지"가 기록으로 남고, 위험구역을 줄이는 공사를 어디부터 할지도 정할 수 있다. 비유가 깨지는 곳: 공장 노란 선은 사람이 그리지만, 이 선은 컴파일러가 자동으로 위험한 곳을 전부 찾아 그려준다는 점이 훨씬 강력하다.
꼬리 질문
unsafe 키워드가 실행 코드를 전혀 바꾸지 않는다면 무슨 값이 있는가?
unsafe가 붙은 줄만 봐도 메모리 계약을 확인해야 할 지점을 전부 찾을 수 있다. 둘째, 정량화가 가능해진다 — unsafe 개수를 세면 코드베이스의 위험 표면 크기를 추적할 수 있고, 리팩터링의 효과를 숫자로 보여줄 수 있다. 셋째, 새로 유입되는 위험을 막는다 — 플래그가 켜진 모듈에서는 누군가 무심코 포인터를 쓰면 경고가 나므로, 의식적인 결정 없이 unsafe가 늘어나지 않는다. 이는 Swift 6의 @unchecked Sendable이 하는 역할(Part B 17장)과 정확히 같은 종류의 값이다.@safe는 언제 붙이며 unsafe와 어떤 관계인가?
unsafe는 사용하는 쪽이 "여기서 위험한 걸 쓴다"고 표시하는 것이고, @safe는 제공하는 쪽이 "이 타입/API는 내부적으로 포인터를 쓰지만 공개 인터페이스는 안전하다"고 선언하는 것이다. 후자가 있어야 안전한 추상화를 만들 수 있다 — 그렇지 않으면 포인터를 감싼 모든 라이브러리 타입의 사용자가 영원히 unsafe를 붙여야 한다. 실제로 표준 라이브러리의 Span 선언이 @frozen @safe public struct Span<Element>이고, 그 내부 저장 프로퍼티는 internal let _pointer: UnsafeRawPointer?다. 내부는 포인터, 외부는 안전 — 이것이 @safe가 표현하는 계약이며 30장에서 자세히 본다.@safe는 그 보증서이고, unsafe는 뚜껑을 열고 직접 만질 때 쓰는 표시다.이 모드를 켠 모듈이 안 켠 모듈을 import하면 어떻게 되는가?
@safe로 안전한 표면을 만들어주면 하위 사용자 전체가 이득을 본다 — 그래서 표준 라이브러리가 Span에 @safe를 붙인 것이 중요하다.