← Swift & Foundation 로드맵 29 / 33

29 · 배타적 접근과 Unsafe 메모리

같은 변수를 두 번 빌리면 왜 컴파일이 막히고, 어떤 위반은 왜 릴리스 빌드에서 SIGABRT로 죽는가. swift_beginAccess가 실제로 남기는 로그부터, 클로저 밖으로 새어나간 포인터가 exit 0으로 조용히 통과하는 가장 위험한 경우, withUnsafeTemporaryAllocation, 그리고 -strict-memory-safety가 강제하는 unsafe 표기까지 직접 컴파일해 확인한다.
진행률
0 / 0 완료

여기서부터 Part C다. Part A가 언어의 의미론, Part B가 Foundation의 API였다면, Part C는 그 아래 — 메모리를 직접 만지는 층과 최근 두 릴리스가 새로 얹은 도구들이다. 이 챕터는 그중 가장 오래된 규칙에서 시작한다. Swift에는 Arrayclass도 아닌, 언어 전체를 관통하는 메모리 규칙이 하나 있다. 어떤 메모리를 수정하는 동안에는 아무도 그 메모리에 접근할 수 없다는 것. 이 규칙(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)이란 무엇이며, 컴파일러는 언제 정적으로 잡고 언제 런타임으로 미루는가?

🔑 30초 답변

SE-0176이 정한 규칙은 하나다 — 어떤 변수를 수정(modify)하려고 접근하는 동안, 그 변수에 대한 다른 어떤 접근도 겹칠 수 없다. 읽기끼리는 겹쳐도 되지만 쓰기가 하나라도 끼면 안 된다. Swift는 이 규칙을 두 군데서 강제한다. 대상이 지역 변수·inout 인자처럼 컴파일러가 접근 범위를 전부 볼 수 있으면 컴파일 에러로 막고, 전역 변수·클래스 프로퍼티·static 프로퍼티처럼 정적으로 증명할 수 없으면 런타임 검사(swift_beginAccess/swift_endAccess)로 미룬다. 후자는 위반 시 SIGABRT로 프로세스를 끝낸다.

원리

SE-0176이 제안한 규칙 — 원문
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)"로 표현하고, 범위가 겹치는지 검사한다.

접근 대상이 무엇인가 지역 변수 · inout 인자 접근 범위를 전부 볼 수 있다 전역 · static · 클래스 프로퍼티 다른 경로가 있을 수 있다 정적 검사 (컴파일 타임) SILGen 단계에서 범위 비교 동적 검사 (런타임) swift_beginAccess 컴파일 에러 배포 자체가 불가 — 가장 안전 SIGABRT · exit 134 런타임에 프로세스 종료 Unsafe 포인터 계열 — 어느 검사에도 걸리지 않는다 컴파일도 되고 실행도 되고 exit 0. 보장이 없다는 사실만 남는다 (Q3)
같은 "겹친 접근"이라도 대상이 무엇인지에 따라 컴파일 에러 · 런타임 중단 · 무검사로 갈린다. 세 번째 줄이 이 챕터에서 가장 중요한 칸이다.

동적 검사는 공짜가 아니다. 접근 시작 시 swift_beginAccess가 스레드별 접근 집합(AccessSet)에 이 접근을 등록하고, 끝날 때 swift_endAccess가 제거한다. 등록하는 순간 이미 겹치는 접근이 있으면 그 자리에서 진단 메시지를 찍고 중단한다. 그래서 크래시 로그에 swift::runtime::AccessSet::insert가 등장한다 — 뒤의 Q2에서 실제 로그로 확인한다.

실험 · 도구

먼저 정적으로 걸리는 쪽. 같은 변수를 두 inout 인자로 넘기면 컴파일러가 두 개의 서로 다른 진단을 낸다.

ex1.swift — 같은 변수를 두 inout 인자로 (컴파일 실패)
func swapTwo(_ a: inout Int, _ b: inout Int) { let t = a; a = b; b = t }
var x = 1
swapTwo(&x, &x)   // 같은 변수를 두 inout 인자로
swiftc -O ex1.swift — 실제 출력
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 프로퍼티를 통과하므로 범위가 겹친다.

cases.swift — 무엇이 걸리고 무엇이 안 걸리는지 (네 경우를 한 번에 컴파일)
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]) }            // ❌ 에러
}
swiftc -O -c cases.swift — 실제 출력 (에러는 두 건뿐이다)
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도 똑같이 읽기끼리는 겹쳐도 된다. 금지되는 건 "누군가 고치는 중에 다른 접근이 끼는" 경우뿐이다.

꼬리 질문

읽기끼리는 왜 겹쳐도 되는가?
배타적 접근이 막으려는 것은 "한쪽의 수정이 다른 쪽이 본 값을 무효로 만드는 상황"이다. 둘 다 읽기만 하면 메모리 내용이 변하지 않으므로 누가 먼저 읽든 같은 값을 보고, 무효화될 것이 없다. 그래서 규칙의 정확한 이름은 "배타적 수정"에 가깝다 — 수정 접근만 배타성을 요구하고, 읽기 접근은 다른 읽기와 공유(shared)될 수 있다.
쉽게 말하면 게시판에 붙은 공고문을 여러 사람이 동시에 읽는 건 아무 문제가 없지만, 누군가 그 공고문을 고쳐 쓰는 중에 읽으면 반쯤 고쳐진 글을 보게 되는 것과 같다.
왜 지역 변수는 정적으로 잡고 전역 변수는 런타임으로 미루는가?
지역 변수는 그 함수 안에서만 이름을 가지므로, 컴파일러가 그 변수에 접근하는 모든 코드를 한 함수 본문 안에서 전부 볼 수 있다. 그러면 접근 범위가 겹치는지를 컴파일 시점에 완전하게 증명할 수 있다. 반면 전역·static·클래스 프로퍼티는 다른 파일, 다른 모듈, 심지어 아직 컴파일되지 않은 코드에서도 접근할 수 있어서 "모든 접근"을 볼 방법이 없다. 증명이 불가능하면 남는 선택은 실행 중에 확인하는 것뿐이다.
쉽게 말하면 내 방에 있는 물건은 누가 언제 만지는지 내가 다 알 수 있지만, 거실 공용 물건은 가족 누구든 언제든 만질 수 있어서 "지금 아무도 안 만지는지" 그때그때 확인해야 하는 것과 같다.
배타적 접근과 Swift 6의 데이터 레이스 안전성(Sendable)은 같은 것인가?
다른 층이다. 배타적 접근은 한 스레드 안에서도 성립해야 하는 메모리 접근 규칙이고, 검사 단위는 "이 변수에 대한 접근 범위"다. Sendable/actor 격리는 여러 스레드 사이의 공유를 다루고, 검사 단위는 "이 값이 격리 경계를 넘어도 되는가"다. 실제로 배타적 접근 위반은 완전히 단일 스레드인 프로그램에서도 재현되며(Q2), 반대로 Sendable을 다 지켜도 재진입으로 배타성을 깰 수 있다. 두 규칙은 서로를 대체하지 않는다. Foundation 타입의 Sendable 대응은 Part B 17장에서 다뤘다.
쉽게 말하면 "한 사람이 두 손으로 같은 물건을 반대로 잡아당기는 문제"(배타적 접근)와 "여러 사람이 같은 물건을 동시에 잡는 문제"(동시성)는 둘 다 문제지만 원인이 다르고, 한쪽을 막아도 다른 쪽은 그대로 남는다.

Q2. 릴리스(-O) 빌드에서도 배타적 접근 검사가 살아 있는가? 왜 어떤 위반은 조용히 통과하는가?

🔑 30초 답변

살아 있다. 동적 배타성 검사는 디버그 전용이 아니라 -O 릴리스 빌드에서도 기본으로 켜져 있고, 위반하면 Simultaneous accesses to 0x...를 찍고 SIGABRT(종료 코드 134)로 죽는다. 실측으로 -Onone-O 양쪽에서 동일하게 트랩하는 것을 확인했다. 다만 같은 위반이 조용히 통과하는 경우가 있다 — 최적화기가 "이 inout 인자를 아무도 쓰지 않는다"고 판단해 접근 자체를 제거하면, 제거된 접근은 검사 대상도 아니게 된다. 즉 통과했다는 것이 안전하다는 뜻이 아니라, 그 코드 경로가 이번 빌드에서 사라졌다는 뜻일 뿐이다.

원리

Swift 컴파일러에는 -enforce-exclusivity=<enforcement> 옵션이 있다(swiftc -hEnforce law of exclusivity로 문서화돼 있다). 기본값은 checked — 정적으로 증명 못 한 접근에 런타임 검사를 삽입한다. 실측으로 확인한 것은 이것이다 — 이 툴체인(6.2.1)에서 -Onone-O 양쪽 모두 검사가 삽입되고 위반 시 중단한다(아래 실험). 즉 "릴리스니까 검사가 빠진다"는 가정은 성립하지 않으며, 이것이 배타적 접근 위반이 프로덕션 크래시로 보고되는 이유다.

📝 출처 구분

"동적 검사가 어느 Swift 버전부터 릴리스 빌드에 들어갔는가"는 이 강의가 확인하지 않은 역사적 사실이다. SE-0176 본문은 빌드 구성별 활성화 여부를 규정하지 않는다. 여기서 근거로 삼는 것은 이 툴체인에서 직접 측정한 현재 동작뿐이다.

내부 동작

런타임 검사의 실체는 스레드 로컬 접근 집합이다. swift_beginAccess(주소, 버퍼, 플래그, PC)가 호출되면 그 스레드의 AccessSet{주소, 종류}를 삽입하고, 삽입하려는 접근이 기존 항목과 충돌하면(둘 중 하나라도 modification이면) 진단을 찍고 중단한다. 그래서 로그의 스택 트레이스 최상단이 항상 AccessSet::insertswift_beginAccess 순서다.

핵심은 검사는 "삽입되는 접근"이 있어야 존재한다는 것이다. 최적화기가 접근을 지우면 검사도 함께 사라진다. 아래 실험에서 이 차이가 그대로 드러난다.

실험 · 도구

먼저 통과하는 버전. inout 인자 a를 함수 본문에서 한 번도 쓰지 않는다.

ex2.swift — inout 인자를 쓰지 않는 버전
var arr = [1, 2, 3]
func mutate(_ a: inout [Int]) { arr.append(99) }   // a 를 쓰지 않는다
mutate(&arr)
print(arr)
swiftc -O ex2.swift 결과 — 트랩하지 않는다
[1, 2, 3, 99]
exit code = 0

이제 단 한 줄을 더한다. a를 실제로 사용해, 접근이 제거되지 않게 만든다.

ex4.swift — inout 인자를 실제로 사용하는 버전
var arr = [1, 2, 3]
func mutate(_ a: inout [Int]) {
    arr.append(99)          // 빌린 대상을 전역 경로로 또 건드린다
    print("inside, a =", a) // a 를 실제로 써서 접근이 지워지지 않게 한다
}
mutate(&arr)
print("after:", arr)
swiftc -O ex4.swift 실행 결과 — 실제 크래시 로그 (주소는 실행마다 다름)
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번 프레임이 실제 앱 코드

② 앞의 두 버전을 비교하면 실무 규칙이 하나 나온다. "릴리스에서 안 죽으니 괜찮다"는 결론을 내리면 안 된다. 통과 여부가 최적화기의 판단에 달려 있으므로, 코드를 한 줄 고치거나 툴체인을 올리는 것만으로 조용했던 위반이 크래시로 바뀔 수 있다.

전역·static을 inout으로 넘기는 대신 — 읽고, 계산하고, 쓴다
// ❌ 전역을 직접 빌려주고, 그 안에서 전역을 또 만진다
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)"는 뜻이다. 단일 스레드에서도 함수 호출이 중첩되면 바깥 접근이 아직 끝나지 않은 상태에서 안쪽 접근이 시작되므로 범위가 겹친다. 실제로 컴파일 타임 진단은 같은 상황을 overlapping accesses라는 더 정확한 단어로 표현한다 — Q1의 실측 출력에서 두 표현이 나란히 나온 이유다.
쉽게 말하면 한 사람이 전화를 받으면서 같은 전화기로 다른 번호에 전화를 걸려는 것과 같다. 사람은 한 명뿐이지만 두 통화가 시간적으로 겹치니 안 되는 것이다.

Q3. withUnsafeBufferPointer의 포인터를 클로저 밖으로 빼면 정확히 무엇이 깨지는가?

🔑 30초 답변

withUnsafe* 계열이 클로저에 넘겨주는 포인터는 그 클로저가 실행되는 동안에만 유효하다는 계약을 갖는다. 밖으로 빼내 나중에 쓰면 정의되지 않은 동작(UB)이며, 컴파일러는 이것을 막아주지 않는다. 실측하면 컴파일도 통과하고, 실행도 되고, 심지어 그럴듯한 값(1)이 출력되면서 exit 0으로 정상 종료한다. 즉 이 버그는 크래시가 아니라 "조용히 틀린 값"으로 나타나며, 이것이 Q2에서 본 SIGABRT보다 훨씬 위험한 이유다. 30장의 Span이 정확히 이 구멍을 컴파일 타임에 막기 위해 도입됐다.

원리

withUnsafeBufferPointer(_:)의 계약은 "이 메서드는 배열의 연속 저장 공간에 대한 포인터를 클로저에 넘긴다. 이 포인터는 클로저 실행 범위를 벗어나면 유효하지 않다"는 것이다. 이 계약이 필요한 이유는 배열의 버퍼가 언제든 이동하거나 해제될 수 있기 때문이다. Part A 01장에서 본 CoW가 새 버퍼로 갈아타면 원래 주소는 남의 땅이 되고, 배열 자체의 수명이 끝나면 그 힙 블록은 해제되어 다른 할당에 재사용된다.

여기서 중요한 것은 UB는 "크래시한다"가 아니라 "어떤 일이 일어날지 언어가 아무 약속도 하지 않는다"라는 점이다. 운이 좋으면(=해제된 페이지가 아직 반환되지 않았고 내용이 남아 있으면) 원래 값이 그대로 읽힌다. 그래서 UB는 테스트를 통과한다.

내부 동작

왜 컴파일러가 막지 못하는가. UnsafePointer<T>그냥 정수 주소를 감싼 값 타입이고, 타입 시스템에 "이 주소가 누구의 수명에 묶여 있는지"를 표현할 자리가 없다. 그래서 클로저 밖의 변수에 대입하는 것이 문법적으로 완전히 합법이다. 컴파일러가 아는 것은 "UnsafePointer<Int> 값을 UnsafePointer<Int>? 변수에 넣는다"뿐이다.

시간 → 클로저 실행 중 포인터 유효 — 계약이 보장하는 구간 배열 a 아직 생존 주소는 아직 살아 있다 a 수명 종료 후 접근 보장 없음 — UB leaked = buf.baseAddress leaked!.pointee 실측 결과 — 아무것도 막지 않는다 컴파일: 경고 0건 · 출력: 탈출한 포인터로 읽기: 1 · exit code: 0 "그럴듯한 정답"이 나오기 때문에 테스트로는 절대 잡히지 않는다
포인터가 유효한 구간은 클로저 안뿐이다. 그 밖에서의 접근은 UB지만, 값이 남아 있으면 정답처럼 보인다.

실험 · 도구

esc.swift — 포인터를 클로저 밖으로 빼낸다 (하지 말아야 하는 코드)
var leaked: UnsafePointer<Int>? = nil
do {
    let a = [1, 2, 3]
    a.withUnsafeBufferPointer { buf in
        leaked = buf.baseAddress          // 클로저 밖으로 탈출 — 컴파일 통과
    }
}
// 원본 배열의 수명이 끝난 뒤 접근
print("탈출한 포인터로 읽기:", leaked!.pointee)   // 정의되지 않은 동작
swiftc -O esc.swift 결과 — 가장 위험한 형태
컴파일 경고/에러: 없음
탈출한 포인터로 읽기: 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/ArrayC 호출이 끝날 때까지 살려두는 구조다.

🔴 함정 — &array 축약도 같은 계약이다

c_process(&array, count)처럼 배열에 &를 붙여 C 포인터로 넘기는 축약 문법도 그 함수 호출 동안에만 유효하다. 이 형태는 withUnsafeBufferPointer보다 더 짧아서 "그냥 주소를 넘긴다"고 착각하기 쉽지만, 계약은 완전히 같다. 호출이 반환된 뒤 그 포인터가 어딘가에 남아 있으면 동일한 UB다.

⚠️ 흔한 오해

"UB면 크래시가 나니까 테스트에서 걸린다"는 가장 위험한 오해다. 실측이 보여준 것처럼 정답이 나오면서 exit 0일 수 있다. 또 "let으로 선언한 배열은 안 바뀌니 포인터도 계속 유효하다"도 틀렸다 — let은 값의 불변성이지 버퍼의 주소나 수명에 대한 약속이 아니다. 배열이 스코프를 벗어나면 버퍼는 해제된다. 마지막으로 "옵티마이저를 끄면(-Onone) 안전하다"도 틀렸다 — 최적화 수준은 UB를 정의된 동작으로 만들어주지 않고, 단지 증상이 나타나는 시점을 바꾼다.

🧒 쉽게 이해하기

호텔 방 번호를 적어두는 것과 같다. 내가 묶는 동안(클로저 안)에는 "302호"라는 쪽지가 정확히 내 방을 가리킨다. 그런데 체크아웃한 뒤에도 그 쪽지를 들고 와서 "302호에 내 짐 있어"라고 하면? 운 좋으면 아직 청소를 안 해서 짐이 그대로 있고, 운 나쁘면 다른 손님이 들어와 있다. 쪽지에는 "언제까지 유효한지"가 안 적혀 있다는 것이 문제의 핵심이다. 비유가 깨지는 곳: 호텔은 다른 손님이 있으면 눈에 보이지만, 메모리는 남의 데이터가 들어와 있어도 겉보기에 똑같은 숫자라서 알아챌 수 없다.

꼬리 질문

실측에서 값 1이 정확히 나왔는데, 왜 이것을 "틀렸다"고 하는가?
출력된 값이 우연히 맞았을 뿐, 언어가 그 값을 보장하지 않기 때문이다. 프로그램의 정확성은 "이번 실행에서 맞는 값이 나왔는가"가 아니라 "언어 규칙에 따라 항상 그 값이 나오는가"로 판단한다. 이 코드는 후자를 만족하지 못하므로, 다음 빌드·다른 기기·주변 코드 한 줄 변경으로 값이 바뀔 수 있다. 실제로 UB의 가장 흔한 실패 방식은 "개발 중에는 늘 맞다가 특정 사용자 기기에서만 틀리는" 형태다.
쉽게 말하면 신호를 무시하고 건넜는데 이번엔 차가 안 와서 무사했다고, 그 건널목이 안전하다고 말할 수는 없는 것과 같다.
왜 Swift는 UnsafePointer에 수명 정보를 넣지 않았는가?
넣을 자리가 없었기 때문이다. 타입 시스템이 "이 값은 저 값보다 오래 살 수 없다"는 관계를 표현하려면 수명 의존성(lifetime dependency)이라는 개념이 필요하고, 이것은 ~Escapable이라는 새 타입 성질과 함께 도입돼야 했다. UnsafePointer는 그런 장치가 없던 Swift 1 시절부터 C 상호운용을 위해 존재해온 타입이라, 뒤늦게 성질을 바꾸면 기존 코드가 전부 깨진다. 그래서 애플의 선택은 기존 타입을 고치는 대신 수명이 타입에 새겨진 새 타입을 추가하는 것이었다 — 30장의 Span이다.
쉽게 말하면 유효기간이 안 적힌 옛날 쿠폰들을 전부 회수할 수는 없으니, 유효기간이 인쇄된 새 쿠폰을 따로 만들어 앞으로는 그걸 쓰게 한 것이다.
클로저 안에서 async 함수를 await 하면 포인터는 여전히 유효한가?
애초에 그렇게 쓸 수 없고, 쓸 수 있게 만들면 위험하다. withUnsafeBufferPointer의 클로저는 동기이므로 그 안에서 await할 수 없다. 억지로 포인터를 Task 안으로 넘기면 클로저는 즉시 반환되고 포인터는 그 순간 무효가 되므로, Task가 실제로 실행될 때는 이미 유효 구간이 끝나 있다. 비동기 경계를 넘겨야 하는 데이터라면 포인터가 아니라 소유권을 가진 값(Data, Array, [UInt8])을 복사해 넘기는 것이 유일하게 안전한 방법이다.
쉽게 말하면 "나중에 처리해줘"라고 부탁하면서 곧 없어질 방 번호를 알려주는 것과 같다. 나중에 갔을 때 그 방은 남의 방이다. 짐을 직접 챙겨 건네야 한다.

Q4. withUnsafeTemporaryAllocation은 무슨 문제를 푸는가?

🔑 30초 답변

"짧게 쓰고 버릴 작업용 버퍼가 필요한데, 그걸 위해 힙 할당(malloc)과 해제를 하는 게 아까운" 상황을 푼다. withUnsafeTemporaryAllocation(of:capacity:_:)충분히 작으면 스택에 잡고, 크면 힙으로 넘기는 판단을 런타임에 맡긴다. 반환된 버퍼는 초기화되지 않은 상태로 오므로 직접 initializeElement(at:to:)로 채우고 deinitialize()로 정리해야 하며, Q3와 똑같이 포인터를 클로저 밖으로 내보내면 안 된다. 실측에서 4개 Int 버퍼에 값을 채워 [0, 1, 4, 9]를 정상 출력하는 것을 확인했다.

원리

표준 라이브러리의 실제 선언은 SDK에 동봉된 .swiftinterface에서 직접 확인할 수 있다. 내부 구현은 Builtin.RawPointer를 다루는 _withUnsafeTemporaryAllocation으로 내려가고, @_alwaysEmitIntoClient @_transparent가 붙어 있다.

SDK의 Swift.swiftinterface — 내부 구현 선언 (실제 인용)
@_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 : ~CopyableR : ~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가 해제를 못 함

실험 · 도구

ex3.swift — 임시 버퍼를 잡아 채우고 정리한다
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()
}
swiftc -O ex3.swift 실행 결과
temp buf: [0, 1, 4, 9] | baseAddress non-nil: true

IntBitwiseCopyable이라 deinitialize()가 실질적으로 아무 일도 하지 않지만, 그래도 넣어두는 게 좋다. 나중에 원소 타입이 참조를 품은 타입으로 바뀌면 그 순간부터 deinitialize()가 반드시 필요해지는데, 처음부터 있으면 그 변경이 안전해진다.

프로젝트 적용

① 쓸 자리는 크기를 실행 시점에만 아는 짧은 작업 버퍼다. 대표적으로 인코딩 변환, 정렬용 임시 배열, C API가 요구하는 출력 버퍼다.

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를 사용자 입력이나 네트워크 응답에서 그대로 받으면 안 된다. 큰 값이 오면 스택을 넘겨 스택 오버플로로 죽을 수 있고, 이건 크래시 리포트에서 원인을 찾기 어렵다. 상한을 두고 그 위는 ArrayData로 처리한다.

상한을 두고 갈라 쓴다
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장)안전인라인(힙 없음)크기가 컴파일 타임에 고정
withUnsafeTemporaryAllocationunsafe — 계약 준수 필요스택 가능크기를 런타임에 알고, 짧게 쓰고 버림
UnsafeMutablePointer.allocateunsafe — 해제까지 직접수명이 함수 밖으로 나가야 할 때
⚠️ 흔한 오해

"withUnsafeTemporaryAllocation은 스택 할당이므로 항상 힙보다 빠르다"는 틀렸다 — 스택에 잡힐지는 보장이 아니고, 클로저·초기화·정리 비용을 합치면 작은 크기에서는 Array와 차이가 없거나 더 느릴 수도 있다. 또 "임시니까 정리(deinitialize)는 생략해도 된다"도 틀렸다 — 원소가 참조를 품고 있으면 그 참조들이 해제되지 않아 릭이 된다. 마지막으로 "버퍼가 0으로 초기화돼 온다"는 근거 없는 가정이다. 초기화되지 않은 메모리이므로 읽기 전에 반드시 써야 한다.

🧒 쉽게 이해하기

요리할 때 재료를 잠깐 올려둘 접시가 필요한 상황이다. 매번 찬장에서 새 접시를 꺼내 씻어 넣는 게(힙 할당·해제) 번거로우니, 그냥 조리대 한쪽을 잠깐 비워 쓰는 것(스택)이 이 API다. 대신 세 가지 규칙이 있다. 조리대 자리는 원래 뭐가 묻어 있을 수 있으니 쓰기 전에 닦아야 하고(초기화), 다 쓰면 치워야 하고(deinitialize), 요리가 끝나면 그 자리는 없어지므로 거기 둔 걸 나중에 찾으러 오면 안 된다(탈출 금지). 비유가 깨지는 곳: 재료가 아주 많으면 조리대에 안 들어가서 결국 찬장 접시를 꺼내게 되는데, 그 판단을 요리사가 아니라 주방이 알아서 한다는 점이 다르다.

꼬리 질문

@_transparent가 붙어야 스택 할당이 가능한가?
스택 공간은 함수의 프레임에 잡힌다. 만약 withUnsafeTemporaryAllocation이 자기 프레임을 갖는 별도의 함수 호출로 남아 있으면, 그 안에서 잡은 스택 공간은 그 함수가 반환하는 순간 사라진다 — 클로저가 실행되는 동안 유효해야 하는데 그럴 수 없다. @_transparent는 이 함수를 호출 지점으로 무조건 인라인해 프레임을 없애므로, 잡히는 공간이 호출한 쪽 함수의 프레임이 되어 클로저 실행 내내 살아 있다. 즉 이 애트리뷰트는 최적화가 아니라 정확성 요건이다.
쉽게 말하면 "옆 방에 자리를 잡아줄게"라고 하면 그 방에서 나오는 순간 자리가 없어지니, 아예 지금 이 방 안에 자리를 만들어야 하는 것과 같다.
buf[i] = xbuf.initializeElement(at: i, to: x)는 왜 다른가?
대입(buf[i] = x)의 의미는 "그 자리에 이미 있던 유효한 값을 해제하고 새 값을 넣는다"다. 초기화되지 않은 메모리에는 해제할 유효한 값이 없으므로, 대입을 하면 쓰레기 비트를 유효한 값으로 착각해 해제 절차를 밟는다. 원소가 클래스 참조라면 임의의 주소에 release를 보내는 셈이라 즉시 메모리 손상이다. initializeElement(at:to:)는 "여기엔 아직 아무것도 없다"를 전제로 해제 없이 값을 심는 연산이라 이 상황에 맞는다. Int처럼 BitwiseCopyable인 타입에서는 결과가 같아서 차이가 안 보이지만, 그래서 더 위험하다 — 원소 타입만 바꿨을 때 조용히 깨진다.
쉽게 말하면 "이 칸에 있던 사람을 내보내고 새 사람을 넣어라"와 "빈 칸에 사람을 넣어라"의 차이다. 빈 칸에 첫 번째 명령을 쓰면 없는 사람을 내보내려다 사고가 난다.
capacity에 아주 큰 값을 넣으면 어떻게 되는가?
문서가 크기 임계값을 규정하지 않으므로 결과도 구현에 달려 있다. 실무적으로 예상되는 결과는 둘이다 — 구현이 힙으로 넘겨 정상 동작하거나, 스택에 잡으려다 스택 한계를 넘어 프로세스가 죽는 것이다. 후자의 크래시는 스택 오버플로로 나타나 원인 지점이 로그에 잘 안 남는다. 그래서 이 API를 쓸 때는 capacity가 어디서 오는지를 반드시 확인하고, 외부 입력(파일 헤더의 길이 필드, 네트워크 응답, 사용자 입력)에서 오는 값은 반드시 상한으로 자른 뒤 넘겨야 한다. 상한을 넘는 경우는 Array/Data로 우회한다.
쉽게 말하면 "조리대를 좀 비워줘"라고 했는데 트럭 한 대 분량을 요구하면, 주방이 감당하지 못해 무너지는 것과 같다. 얼마까지 부탁할지는 내가 미리 정해둬야 한다.

Q5. -strict-memory-safety(SE-0458)는 무엇을 강제하며 unsafe는 어떻게 쓰는가?

🔑 30초 답변

SE-0458unsafe한 구조를 쓰는 것을 금지하지 않고, 눈에 보이게 만든다. -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하다"는 수준까지 짚어준다.

sms.swift — 아무 표시 없이 포인터를 쓰는 코드
import Foundation
func f(_ p: UnsafePointer<Int>) -> Int { p.pointee }
let a = [1, 2, 3]
print(a.withUnsafeBufferPointer { f($0.baseAddress!) })
swiftc -strict-memory-safety sms.swift — 실제 진단 (발췌)
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를 붙인다.

safe.swift — 각 지점을 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)
swiftc -strict-memory-safety safe.swift — 경고 0건, 정상 실행
(컴파일 출력 없음)
r = 1

unsafe표현식 앞에 붙는 키워드다. try/await와 같은 자리에 오며, 실행 의미는 전혀 바꾸지 않는다. 반대 방향의 애트리뷰트도 있다 — 30장에서 볼 Span의 선언에 붙은 @safe가 그것으로, "포인터를 내부에 품고 있지만 이 타입의 공개 API는 안전하다"를 선언한다.

실험 · 도구

기존 코드베이스에 켤 때는 migrate 변형이 있다. swiftc -h에 두 형태가 함께 문서화돼 있다.

swiftc -h 발췌 — 실제 출력
-strict-memory-safety:migrate
-strict-memory-safety   Enable strict memory safety checking
-enforce-exclusivity=<enforcement>
                        Enforce law of exclusivity

Q3의 탈출 예제에 이 플래그를 걸어보면, 아무 신호도 주지 않았던 그 코드가 컴파일 타임에 지적된다. Q3 표에서 "잡는다"로 표시한 근거가 이것이다.

프로젝트 적용

① 도입 전략은 Swift 6 동시성 마이그레이션과 같다. 전체를 한 번에 켜지 말고, 모듈 단위로 켠다. Foundation·C 래핑이 몰려 있는 하위 모듈은 뒤로 미루고, 순수 로직 모듈부터 켜면 경고 수가 감당 가능하다.

Package.swift — 모듈별로 켠다
.target(
    name: "AppLogic",                        // 순수 로직 — 먼저 켠다
    swiftSettings: [.unsafeFlags(["-strict-memory-safety"])]
),
.target(
    name: "CBridge",                         // C 래핑 — 나중에
    dependencies: []
)

② unsafe를 붙이는 것은 "이 코드가 안전하다"는 선언이 아니라 "내가 이 지점의 계약을 확인했다"는 서명이다. 그래서 붙일 때 계약을 주석으로 남기는 규율이 실전에서 값을 만든다. 무엇이 그 포인터의 수명을 보장하는지가 리뷰의 대상이 되어야 한다.

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 -rn
🟣 한 걸음 더 — 이 챕터의 네 위반을 한 표로 실측 종합

Q1~Q5에서 재현한 것을 위험도 순으로 정리하면, 왜 Span 같은 새 타입이 필요했는지가 한눈에 보인다.

위반걸리는 지점실측 결과고칠 가능성
같은 변수 두 inout컴파일 타임error 2건100% — 배포가 안 된다
전역 재진입 수정런타임exit 134 · SIGABRT높음 — 크래시 리포트로 올라온다
같은 위반 + inout 미사용어디에도 안 걸림exit 0낮음 — 다음 빌드에 터질 수 있다
포인터 클로저 밖 탈출어디에도 안 걸림exit 0 · 정답처럼 보이는 값가장 낮음 — 신호가 없다

아래 두 줄이 -strict-memory-safetySpan이 겨냥한 표적이다. 전자는 그 지점을 보이게 만들고, 후자는 컴파일 에러로 만든다.

⚠️ 흔한 오해

"-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하면 어떻게 되는가?
진단은 컴파일되는 모듈 기준으로 발생한다. 즉 내 모듈에 플래그를 켜면, 다른 모듈에서 가져온 API를 쓰는 표현식이라도 그것이 unsafe한 타입을 노출하면 내 쪽에서 경고가 난다. 반대로 내가 켜지 않았다면 켜진 모듈의 API를 써도 내 코드에는 경고가 없다. 실무적 함의는 두 가지다. 첫째, 점진적 도입이 가능하다 — 모듈 단위로 켜 나가면 되고, 전부 켤 필요가 없다. 둘째, 라이브러리 저자가 @safe로 안전한 표면을 만들어주면 하위 사용자 전체가 이득을 본다 — 그래서 표준 라이브러리가 Span@safe를 붙인 것이 중요하다.
쉽게 말하면 우리 팀만 안전모를 쓰기로 정할 수 있고, 그 규칙은 우리 팀 작업에만 적용된다. 다만 부품을 만들어주는 협력업체가 애초에 안전한 부품을 주면 모든 팀이 편해지는 것과 같다.

출처 · 참고자료