P0 02·09장에서 액터와 Sendable을, 10장에서 락과 우선순위 역전을 다뤘다. 거기까지가 "동시성의 도구를 아는" 단계였다.
이 챕터는 그다음이다. 컴파일러가 어떻게 안전을 증명하는가, 그 증명이 어디서 실패하고 왜 실패하는가, 그리고 언어가 어느 방향으로 움직이고 있는가.
이 장의 주장은 대부분 실제 컴파일과 실행으로 확인했다(Swift 6.2.1, arm64-apple-macos). 진단 메시지와 수치는 재현한 결과 그대로다.
Q1. 컴파일러는 "이 값은 넘겨도 안전하다"를 어떻게 아는가?
Region-Based Isolation(SE-0414)이 값들을 "서로 참조로 얽힌 덩어리(region)"로 묶고, 그 덩어리 전체가 넘어간 뒤에도 원래 쪽에서 쓰이는지를 본다. 그래서 Sendable이 아닌 타입이어도 만든 곳에서만 쓰고 넘긴 뒤 손대지 않으면 통과한다. 결정적으로 이 분석은 타입 체크가 아니라 SIL(중간 표현) 단계에서 돈다 — 직접 확인해 보면 swiftc -typecheck는 통과하는 코드가 swiftc -c에서는 에러가 난다. 그리고 별칭(let alias = b)을 통한 접근도 같은 region으로 잡아낸다.
CS 원리
문제는 이렇게 요약된다. "이 값을 다른 실행 도메인에 넘겨도 되는가?"
가장 단순한 답은 타입으로 판정하는 것이다 — Sendable이면 되고 아니면 안 된다. 이 방식은 건전(sound)하지만 지나치게 보수적이다. 방금 만들어서 아무도 안 보고 있는 객체까지 막기 때문이다.
Region-Based Isolation은 타입 대신 흐름을 본다. 이것은 컴파일러 이론의 포인터 별칭 분석(alias analysis)과 선형 타입(linear types) 계열 아이디어다.
핵심 개념이 둘이다.
- Region — 서로 참조로 도달 가능한 값들의 묶음.
let a = Box(); let b = [a]이면a와b는 같은 region이다. - Disconnected — 어떤 액터에도, 어떤 태스크에도 아직 묶이지 않은 region. 이 상태의 region만 넘길 수 있다.
넘기는 순간 그 region은 consumed로 표시된다. 이후 원래 쪽에서 그 region의 어떤 값이든 접근하면 에러다 — 넘긴 변수 자체가 아니어도 잡힌다.
이 분석이 왜 SIL 단계에서 도는가가 실무적으로 중요하다. 타입 검사는 선언만 보지만, region 분석은 제어 흐름과 값의 이동을 봐야 한다. 그 정보는 SIL로 낮춘 뒤에야 있다. 33장 Q1에서 SIL을 읽어 struct_extract와 apply를 구분했던 것과 같은 층위다.
결과는 개발 경험에 직접 영향을 준다. 에디터의 실시간 진단(타입 체크 기반)에는 안 보이다가 빌드해야 나타나는 에러가 존재한다.
-typecheck는 통과하고 -c는 실패한다.iOS에서는
가장 흔한 이득은 "만들어서 넘기는" 패턴이 그냥 통과한다는 것이다.
final class ImageBuffer { var pixels: [UInt8] = [] } // Sendable 아님
actor Cache {
var stored: [ImageBuffer] = []
func store(_ b: ImageBuffer) { stored.append(b) }
}
func load(_ cache: Cache) async {
let buf = ImageBuffer() // 여기서 만들고
buf.pixels = decode() // 여기서만 채우고
await cache.store(buf) // 넘긴다 — 이후 안 쓴다
}
// Sendable 이 아닌데도 통과한다. region 이 disconnected 이기 때문이다.
실패하는 형태는 넘긴 뒤에 계속 쓰는 코드다.
func loadBad(_ cache: Cache) async -> Int {
let buf = ImageBuffer()
await cache.store(buf)
return buf.pixels.count // ❌ 넘긴 뒤 접근
}
// error: sending 'buf' risks causing data races
// note: sending 'buf' to actor-isolated instance method 'store' risks causing
// data races between actor-isolated and local nonisolated uses
// note: access can happen concurrently
별칭도 같은 region으로 추적된다는 것이 중요하다. "변수 이름만 다르면 되겠지"는 통하지 않는다.
let buf = ImageBuffer()
let sameThing = buf // 이름만 다르지 같은 객체 = 같은 region
await cache.store(buf)
sameThing.pixels = [] // ❌ 역시 잡힌다
고치는 방향은 셋이다. (1) 넘긴 뒤 안 쓰도록 순서를 바꾼다. (2) 필요한 값을 넘기기 전에 꺼내 둔다. (3) 타입을 진짜 Sendable로 만든다.
// (2) 넘기기 전에 꺼낸다 — 가장 간단한 해법인 경우가 많다
let count = buf.pixels.count
await cache.store(buf)
return count
실험 · 도구
타입 체크와 실제 빌드가 다르다는 것을 직접 확인해 보자. 이것을 모르면 CI에서만 깨지는 이유를 못 찾는다.
// region.swift
final class Box { var v = 0 }
actor Store { var boxes: [Box] = []; func take(_ b: Box) { boxes.append(b) } }
func caseB(_ s: Store) async {
let b = Box()
await s.take(b)
b.v = 99 // 넘긴 뒤 사용
}
$ swiftc -swift-version 6 -typecheck region.swift ; echo "EXIT=$?"
EXIT=0 # 통과한다
$ swiftc -swift-version 6 -c region.swift -o /dev/null
region.swift:6:13: error: sending 'b' risks causing data races
|- error: sending 'b' risks causing data races [#SendingRisksDataRace]
`- note: sending 'b' to actor-isolated instance method 'take' risks causing
data races between actor-isolated and local nonisolated uses
b.v = 99
`- note: access can happen concurrently
진단 코드 [#SendingRisksDataRace]가 붙어 있어 문서로 바로 갈 수 있다. Swift 6는 진단마다 이런 식별자를 준다.
# 프로젝트에서 이 계열 에러만 뽑아 본다
xcodebuild build 2>&1 | grep -E "SendingRisksDataRace|risks causing data races"
# 어떤 진단이 활성인지 확인 — 언어 모드와 업커밍 기능
swiftc -swift-version 6 -Xfrontend -debug-diagnostic-names -typecheck file.swift 2>&1 | head
프로젝트 적용
- "만들고 → 채우고 → 넘긴다" 순서로 쓴다. region 분석이 가장 잘 통과하는 형태다.
- 넘긴 뒤 필요한 값은 미리 꺼낸다.
Sendable을 억지로 붙이는 것보다 안전하다. - 에디터 진단만 믿지 않는다. 이 계열 에러는 실제 빌드에서만 나온다.
@unchecked Sendable로 도망가지 않는다. P0 17장에서 본 대로 그것은 검사를 끄는 것이지 안전을 만드는 것이 아니다.- 별칭을 만들어 우회하려 하지 않는다. 같은 region으로 추적되므로 통하지 않고, 통했다면 그건 진짜 경쟁이다.
"Sendable이 아니면 액터에 못 넘긴다"는 이제 틀렸다 — region이 disconnected면 넘어간다. "region 분석은 타입 검사다"도 틀렸다 — SIL 단계에서 돌고, 그래서 -typecheck는 통과한다. "변수를 다른 이름으로 받으면 피할 수 있다"도 틀렸다 — 별칭은 같은 region이다. "이 에러가 나면 코드가 실제로 경쟁한다"는 반은 맞다 — 컴파일러는 보수적이라 안전한 코드도 막을 수 있다. 다만 그때도 대개 표현을 바꾸면 통과한다.
물건을 남에게 건네주는 상황을 생각하자.
예전 규칙은 "물건 종류"로만 판단했다. "이건 넘겨도 되는 물건, 저건 안 되는 물건." 그래서 방금 내가 만들어서 아무도 안 본 물건까지 못 넘기게 막았다.
새 규칙은 물건이 어떻게 움직이는지를 본다. 내가 만들어서 건네준 다음 다시 안 만지면 괜찮다. 문제는 건네주고도 계속 만지는 경우다 — 그러면 둘이 동시에 만지게 된다.
재미있는 건, 같은 물건을 다른 이름으로 불러도 소용없다는 것이다. "이건 상자, 이건 내 상자"라고 이름을 둘로 만들어도 물건은 하나다.
그리고 언제 걸리느냐가 헷갈린다. 이 검사는 제출 서류만 보는 단계가 아니라 실제로 짐을 싸 보는 단계에서 한다. 그래서 서류상으로는 문제없다가 짐 쌀 때 걸리는 일이 생긴다.
꼬리 질문
region 분석이 안전한 코드를 막을 때는 어떻게 하는가?
보수적 정적 분석의 숙명이다. 컴파일러는 증명할 수 있는 것만 허용하므로, 안전하지만 증명이 안 되는 코드가 막힌다.
대응은 세 단계로 올라간다 — 위험이 낮은 것부터.
(1) 표현을 바꾼다. 대부분 여기서 해결된다. 값을 만드는 위치를 옮기거나, 필요한 값을 먼저 꺼내거나, 함수를 쪼갠다.
(2) sending을 쓴다. 함수 경계에서 소유권 이전을 명시하면 호출자와 피호출자 양쪽에서 검증이 이어진다(Q2).
func handoff(_ b: sending Box, to s: Store) async { await s.take(b) }(3) 마지막에야 @unchecked Sendable. 이건 "내가 책임진다"는 선언이므로, 그 타입이 어떻게 안전한지 주석으로 남기고 리뷰를 거쳐야 한다. P0 17장의 기준을 그대로 적용한다.
순서를 지키는 이유는 되돌리기 비용이다. (1)은 국소적이고, (3)은 그 타입을 쓰는 모든 곳의 검사를 끈다.
이전 방식(Sendable만으로 판정)과 비교하면 무엇이 좋아졌는가?
거짓 양성(안전한데 막히는 경우)이 크게 줄었다.
| 상황 | Sendable 판정만 | Region 분석 |
|---|---|---|
| 새로 만들어 바로 넘김 | ❌ 막힘 | ✅ 통과 |
| 넘긴 뒤 계속 사용 | ❌ 막힘 | ❌ 막힘 (정당) |
| 배열에 담아 통째로 넘김 | ❌ 막힘 | ✅ 통과 |
| 공유 중인 객체를 넘김 | ❌ 막힘 | ❌ 막힘 (정당) |
실무적 의미는 모델 타입을 억지로 Sendable로 만들 필요가 줄었다는 것이다. 예전에는 final class 모델을 액터에 넘기려고 불변으로 다시 설계하거나 @unchecked를 붙였는데, 이제는 사용 방식만 맞으면 통과한다.
다만 공짜는 아니다. 분석이 흐름을 보므로 진단 메시지가 길고, 왜 막혔는지 이해하려면 note까지 읽어야 한다. 위 예제의 access can happen concurrently note가 범인을 가리키는 부분이다.
이 분석은 클로저와 Task 안에서도 동작하는가?
동작한다. 오히려 클로저 캡처가 region을 합치는 주된 경로다.
let b = Box()
Task { b.v = 1 } // 클로저가 b 를 캡처 → b 의 region 이 이 태스크에 묶인다
b.v = 2 // ❌ 바깥에서 또 쓴다규칙은 같다. region이 어딘가로 묶이면(캡처든 인자 전달이든) 원래 쪽은 손을 뗀다.
Task.detached와 Task의 차이도 여기에서 드러난다. Task는 현재 격리를 물려받고 Task.detached는 물려받지 않으므로, 캡처한 값의 판정이 달라진다.
@MainActor func f() {
let b = Box()
Task { b.v = 1 } // MainActor 를 물려받는다 — 같은 도메인
Task.detached { b.v = 2 } // 물려받지 않는다 — 경계를 넘는다
}실무에서는 Task.detached를 기본으로 쓰지 않는 것이 좋다. 격리와 우선순위를 둘 다 잃어서 예상 못 한 진단과 성능 문제가 생긴다(P0 10장의 우선순위 역전과 이어진다).
Q2. sending은 무엇을 약속하는가?
"이 인자의 region을 호출자가 포기하고 피호출자에게 넘긴다"는 함수 경계의 계약(SE-0430)이다. Q1의 region 분석은 한 함수 안에서만 볼 수 있어서, 함수 인자로 받은 값은 호출자가 계속 쓸지 알 수 없어 넘기지 못한다. sending이 그 정보를 시그니처에 적어 양쪽에서 검증하게 만든다. 확인해 보면 명확하다 — 같은 본문인데 func plain(_ b: Box, …)는 에러, func withSending(_ b: sending Box, …)는 통과한다. Swift 6.2의 SE-0461은 여기서 한 걸음 더 나가, nonisolated async 함수가 호출자의 실행자에서 돌게 바꿔 경계 자체를 없앤다.
CS 원리
정적 분석에는 범위의 벽이 있다. Q1의 region 분석은 함수 하나의 본문을 본다. 그런데 함수는 인자가 어디서 왔는지 모르고, 호출자는 함수가 인자로 뭘 할지 모른다.
해결책은 시그니처를 계약으로 쓰는 것이다. 이것이 모듈러 검증(modular verification)의 표준 기법이다 — 각자 자기 몫만 검사하고, 경계에서는 선언된 계약을 믿는다.
sending이 만드는 계약은 양방향이다.
| 호출자가 지켜야 할 것 | 피호출자가 얻는 것 | |
|---|---|---|
sending 인자 | 넘긴 뒤 그 region을 안 쓴다 | 자유롭게 다른 도메인으로 보낼 수 있다 |
sending 반환 | 받은 값을 마음대로 쓸 수 있다 | 반환한 값을 더 안 쓴다고 약속 |
이 구조는 33장 Q1의 Library Evolution과 같은 발상이다 — 경계에 무엇을 적어 두느냐로 양쪽이 독립적으로 컴파일된다.
기본값은 보수적인 쪽이다. sending을 안 붙이면 "호출자가 계속 쓸 수 있다"고 가정하므로 넘기지 못한다. 이것이 건전성 우선 설계다.
SE-0461(Swift 6.2)은 다른 각도의 해법이다. nonisolated async 함수가 기본적으로 호출자의 실행자에서 돌게 바꾸면(nonisolated(nonsending)), 애초에 격리 경계를 넘지 않으므로 전달 검사가 필요 없어진다. 확인해 보면 같은 코드가 이 기능을 켜는 것만으로 에러에서 통과로 바뀐다.
iOS에서는
확인한 대비가 그대로 실무 규칙이 된다.
final class Box { var v = 0 }
actor Store { var boxes: [Box] = []; func take(_ b: Box) { boxes.append(b) } }
// ❌ 같은 본문인데 에러가 난다
func plain(_ b: Box, to s: Store) async { await s.take(b) }
// error: sending 'b' risks causing data races
// note: sending task-isolated 'b' to actor-isolated instance method 'take'
// risks causing data races between actor-isolated and task-isolated uses
// ✅ sending 을 붙이면 통과
func withSending(_ b: sending Box, to s: Store) async { await s.take(b) }
반환값에도 쓴다. 팩토리 함수가 대표적이다.
// "만들어 주지만 나는 안 갖고 있는다" — 호출자가 어디로든 보낼 수 있다
func makeBuffer() -> sending ImageBuffer {
ImageBuffer()
}
let b = makeBuffer()
await cache.store(b) // 통과한다
sending을 붙이면 함수 안에서의 제약도 생긴다. 인자를 전역이나 프로퍼티에 저장해 두면 약속을 어기는 것이라 막힌다.
var kept: Box?
func bad(_ b: sending Box) {
kept = b // 밖으로 새어 나간다 — 계약 위반
}
SE-0461은 프로젝트 단위 스위치다. 켜면 nonisolated async 함수의 의미가 바뀐다.
@MainActor func caller() async {
let b = makeOnMain()
await work(b) // work 는 nonisolated async
print(b.v)
}
// 기본(6.2): error: sending 'b' risks causing data races
// -enable-upcoming-feature NonisolatedNonsendingByDefault → 통과
실험 · 도구
두 시그니처의 차이를 한 파일에서 재현한다.
// sending.swift
final class Box { var v = 0 }
actor Store { var boxes: [Box] = []; func take(_ b: Box) { boxes.append(b) } }
func plain(_ b: Box, to s: Store) async { await s.take(b) }
func withSending(_ b: sending Box, to s: Store) async { await s.take(b) }
$ swiftc -swift-version 6 -c sending.swift -o /dev/null
sending.swift:5:51: error: sending 'b' risks causing data races
`- note: sending task-isolated 'b' to actor-isolated instance method 'take'
risks causing data races between actor-isolated and task-isolated uses
# 6번 줄(withSending)에 대한 에러는 없다 — 통과했다는 뜻이다
SE-0461 스위치의 효과를 확인한다.
# 끈 상태 — 에러
$ swiftc -swift-version 6 -c iso.swift -o /dev/null
iso.swift:10:11: error: sending 'b' risks causing data races
# 켠 상태 — 조용하다
$ swiftc -swift-version 6 \
-enable-upcoming-feature NonisolatedNonsendingByDefault \
-c iso.swift -o /dev/null
$ echo $?
0
# 프로젝트에서 sending 사용 현황
grep -rn ": sending \|-> sending " --include=*.swift . | wc -l
# 아직 @unchecked Sendable 로 우회 중인 곳 — sending 으로 옮길 후보
grep -rn "@unchecked Sendable" --include=*.swift .
프로젝트 적용
- "넘겨받아 어딘가로 보내는" 함수에
sending을 붙인다. 큐잉·캐시 저장·태스크 시작이 전형이다. - 팩토리 반환에
sending을 쓴다. 호출자가 자유롭게 쓸 수 있게 된다. @unchecked Sendable을sending으로 대체할 수 있는지 먼저 본다. 검사를 끄는 대신 계약을 적는 쪽이다.- SE-0461은 모듈 단위로 켠다. 의미가 바뀌므로 한 모듈씩 적용하고 회귀를 확인한다.
- 진단의 note를 끝까지 읽는다.
task-isolated인지local nonisolated인지에 따라 고치는 방법이 다르다.
"sending은 Sendable의 줄임말"이라는 것은 틀렸다 — Sendable은 타입의 성질이고 sending은 인자·반환의 전달 방식이다. "sending을 붙이면 아무 값이나 넘길 수 있다"도 틀렸다 — 호출자 쪽에서 이후 사용이 없어야 한다. "sending은 성능에 영향을 준다"도 틀렸다 — 컴파일 타임 검증일 뿐 런타임 동작이 아니다. "SE-0461을 켜면 동시성이 느려진다"도 단정할 수 없다 — 실행자 전환이 줄어드는 방향이라 오히려 유리한 경우가 많다. 다만 의미가 바뀌므로 측정 없이 켜지는 않는다.
Q1의 검사관은 방 하나 안에서만 물건이 어떻게 움직이는지 볼 수 있다. 그런데 다른 방에서 들어온 물건은 곤란하다 — 그 방 주인이 이 물건을 계속 쓸 건지 알 수가 없다.
그래서 문에 표시를 붙이기로 했다. "이 문으로 들어오는 물건은 완전히 넘겨받은 것입니다." 그러면 보내는 쪽은 "넘긴 뒤 안 만진다"를 지켜야 하고, 받는 쪽은 마음껏 처리할 수 있다.
그런데 더 재밌는 해법이 하나 더 나왔다. "애초에 문을 지나가지 않게 하면 되잖아?" 옆방으로 옮겨가서 일하는 대신, 그 자리에서 그냥 일하게 만드는 것이다. 방을 안 옮기니 검사할 일도 없다.
같은 코드가 이 방식을 켜는 것만으로 막히던 게 통과한다.
꼬리 질문
sending 파라미터 안에서 하면 안 되는 일은 무엇인가?
그 값을 함수 밖으로 새어 나가게 하는 모든 행위다. sending은 "받아서 처리하고 끝낸다"는 약속이기 때문이다.
- 전역·프로퍼티에 저장 — 함수가 끝나도 남는다.
nonsending인자로 다시 전달 — 계약이 끊긴다.- 비-
sending반환 — 호출자가 계속 쓰게 된다.
actor Keeper { var kept: Box? }
extension Keeper {
// ✅ 액터 안에 저장하는 것은 괜찮다 — 격리 도메인 안이다
func keep(_ b: sending Box) { kept = b }
}
// ❌ 격리 없는 전역에 저장하면 어디서든 접근 가능해진다
nonisolated(unsafe) var leaked: Box?
func bad(_ b: sending Box) { leaked = b }구분 기준은 "저장한 곳이 격리돼 있는가"다. 액터 프로퍼티는 그 액터 안에서만 접근되므로 문제없고, 격리 없는 전역은 아무나 접근하므로 문제다.
이 규칙이 선형 타입의 사용-한-번 규칙과 닮았다는 점이 흥미롭다 — 다만 Swift는 "한 번만 쓴다"가 아니라 "한 도메인에만 있는다"를 강제한다.
진단에 나오는 task-isolated와 local nonisolated는 무엇이 다른가?
그 값이 지금 어디에 묶여 있는지를 가리키고, 고치는 방법이 달라진다.
| 표현 | 의미 | 흔한 해법 |
|---|---|---|
local nonisolated | 함수 안에서 방금 만든 값 | 넘긴 뒤 안 쓰게 순서 조정 |
task-isolated | 인자로 받았거나 태스크에 묶인 값 | sending을 시그니처에 추가 |
actor-isolated | 어떤 액터에 이미 속한 값 | 액터 안에서 복사해 넘기기 |
앞의 두 실험이 정확히 이 차이를 보여준다.
// caseB — 함수 안에서 만든 값
// note: sending 'b' … between actor-isolated and local nonisolated uses
// → 순서를 바꾸면 해결된다
// plain — 인자로 받은 값
// note: sending task-isolated 'b' … between actor-isolated and task-isolated uses
// → sending 을 붙여야 해결된다그래서 note를 읽는 것이 진짜 진단이다. 에러 첫 줄(sending 'b' risks causing data races)은 둘 다 똑같아서 구분이 안 된다.
P1 20장 Q7의 원칙이 여기도 적용된다 — 증상이 같아도 원인이 다르면 처방이 다르다.
SE-0461을 켜는 것이 안전한가?
의미가 바뀌므로 모듈 단위로 점진 적용하는 것이 원칙이다.
바뀌는 것은 nonisolated async 함수가 어디서 도는가다.
| 지금까지 | SE-0461 이후 | |
|---|---|---|
| 실행 위치 | 공용 동시 실행자 | 호출자의 실행자 |
| 실행자 전환 | 호출마다 발생 | 발생하지 않음 |
| 전달 검사 | 필요 | 불필요 |
이득은 명확하다. 진단이 줄고, 실행자 전환 비용이 사라진다.
주의할 점은 메인 액터에서 호출하는 무거운 async 함수다. 예전에는 자동으로 배경으로 빠졌지만 이제는 메인에서 그대로 돈다 — 화면이 멈출 수 있다(P0 07장, P1 25장의 hitch).
// 무거운 작업은 명시적으로 밖으로 보낸다
nonisolated func heavy() async -> Data {
await Task.detached(priority: .utility) { expensiveWork() }.value
}
// 또는 전용 액터/실행자에 격리시킨다적용 순서는 말단 모듈부터다. 의존이 적은 곳에서 켜 보고, 메인 스레드 점유가 늘지 않는지 P1 25장의 방법으로 측정한 뒤 넓힌다.
Q3. 격리를 값으로 다루면 무엇이 가능해지는가?
@isolated(any)(SE-0431)는 클로저가 "어느 액터에 격리돼 있는지"를 .isolation 프로퍼티로 런타임에 읽을 수 있게 만든다. 타입은 (any Actor)?다. 실행해 보면 값이 정확히 셋으로 갈린다 — 격리 없는 클로저는 nil, { @MainActor in }은 Optional(Swift.MainActor), 직접 만든 글로벌 액터는 Optional(모듈명.DataActor). 이게 왜 필요한가 하면, 스케줄러가 작업을 "제자리에서" 실행하려면 어디로 가야 하는지 먼저 알아야 하기 때문이다. Task와 TaskGroup이 불필요한 실행자 전환을 피하는 근거가 바로 이것이다.
CS 원리
여기서 다루는 것은 일급 값(first-class value)이라는 개념이다. 어떤 것이 변수에 담기고, 인자로 넘어가고, 검사될 수 있으면 일급이다.
동시성에서 격리(isolation)는 오랫동안 타입 시스템에만 존재했다. @MainActor는 컴파일러가 검사하는 표시였고, 실행 중에 물어볼 수는 없었다.
문제는 스케줄링을 직접 구현할 때 드러난다. 작업을 받아 나중에 실행하는 코드는 이런 딜레마에 빠진다.
- 작업이 어느 액터에 속하는지 모르면, 일단 아무 데서나 시작한 뒤
await로 넘어가야 한다 → 불필요한 전환 한 번. - 더 나쁘게는 순서 보장이 깨진다 — 같은 액터로 갈 작업 둘이 중간에서 뒤바뀔 수 있다.
@isolated(any)는 격리를 값으로 승격해 이 문제를 푼다. 스케줄러가 .isolation을 읽어 처음부터 올바른 실행자에서 시작할 수 있다.
이것은 33장 Q5의 package와 비슷한 성격의 변화다 — 컴파일러만 알던 정보를 프로그램이 쓸 수 있게 만든 것이다. 다만 결과는 훨씬 크다. Task.init·TaskGroup.addTask가 이 기능 위에 재구현되어, 표준 라이브러리 자체가 전환을 줄인다.
글로벌 액터도 같은 맥락이다. @globalActor는 프로그램 전체에서 유일한 액터 인스턴스를 만드는 장치이고, @MainActor는 그중 하나일 뿐이다. 직접 만들면 메인이 아닌 전용 도메인을 가질 수 있다.
iOS에서는
직접 만드는 전용 글로벌 액터부터 보자. 메인이 아닌 단일 도메인이 필요할 때 쓴다.
@globalActor
actor DatabaseActor {
static let shared = DatabaseActor()
}
@DatabaseActor
final class LocalStore { // 이 타입 전체가 DatabaseActor 격리
private var cache: [String: Data] = []
func read(_ k: String) -> Data? { cache[k] }
func write(_ k: String, _ v: Data) { cache[k] = v }
}
// DB 접근이 한 도메인에서 직렬화된다 — 락 없이도 순서가 보장된다(P0 10장 대비)
@isolated(any)는 스케줄러·큐를 직접 만들 때 쓴다.
func schedule(_ op: @isolated(any) @Sendable () async -> Void) async {
print("이 작업의 격리 대상:", op.isolation as Any)
await op() // 올바른 실행자에서 바로 시작된다
}
await schedule { } // isolation = nil
await schedule { @MainActor in } // isolation = Optional(Swift.MainActor)
await schedule { @DatabaseActor in } // isolation = Optional(DatabaseActor)
실무에서 더 자주 쓰는 것은 isolated 파라미터다. 함수를 호출자의 액터에 격리시킨다.
// 어느 액터에서 불리든 그 액터 안에서 실행된다 — await 가 필요 없다
func inspect(_ store: isolated LocalStore) {
print(store.read("key") as Any) // await 없이 접근 가능
}
주의할 것이 있다. @Sendable 클로저는 주변 액터 격리를 물려받지 않는다. 액터 메서드 안에서 @Sendable 클로저를 만들어 self의 격리 상태를 쓰려 하면 막힌다.
actor Worker {
func ping() { }
func run(_ f: @isolated(any) @Sendable () async -> Void) async { await f() }
func broken() async {
await run { self.ping() }
// error: actor-isolated instance method 'ping()' cannot be called
// from outside of the actor
}
}
// @Sendable 클로저는 격리 밖이다 — 명시적으로 @DataActor in 같은 표시를 하거나
// isolated 파라미터를 쓴다
실험 · 도구
격리 값이 실제로 어떻게 찍히는지 직접 확인한다.
// iso.swift
@globalActor actor DataActor { static let shared = DataActor() }
func schedule(_ op: @isolated(any) @Sendable () async -> Void) async {
print(" isolation =", op.isolation as Any)
await op()
}
@main struct M {
static func main() async {
print("1) 격리 없는 클로저"); await schedule { }
print("2) @MainActor 클로저"); await schedule { @MainActor in }
print("3) @DataActor 클로저"); await schedule { @DataActor in }
}
}
$ swiftc -swift-version 6 -parse-as-library iso.swift -o iso && ./iso
1) 격리 없는 클로저
isolation = nil
2) @MainActor 클로저
isolation = Optional(Swift.MainActor)
3) @DataActor 클로저
isolation = Optional(iso.DataActor)
3번이 iso.DataActor로 모듈명이 붙어 찍히는 것에 주목하자 — 33장 Q1에서 본 맹글링된 이름이 그대로 드러난다.
# 프로젝트의 글로벌 액터 목록
grep -rn "@globalActor" --include=*.swift .
# MainActor 남용 점검 — 배경에서 돌아도 되는데 메인에 묶은 곳
grep -rn "@MainActor" --include=*.swift . | grep -iE "repository|store|service|manager"
# isolated 파라미터 사용처
grep -rn ": isolated \|isolated(any)" --include=*.swift .
프로젝트 적용
- 메인이 아닌 직렬 도메인이 필요하면 글로벌 액터를 만든다. DB·파일·로그가 대표적이다.
- 모든 것을
@MainActor로 밀지 않는다. 편하지만 메인 스레드 점유가 늘어난다(P1 25장). @isolated(any)는 스케줄러를 직접 만들 때만 쓴다. 앱 코드에서 직접 쓸 일은 드물다.- 호출자 액터에서 실행하고 싶으면
isolated파라미터를 쓴다.await가 사라져 코드가 단순해진다. @Sendable클로저가 격리를 안 물려받는다는 것을 기억한다. 액터 안에서 만든 클로저라도 밖이다.
"@MainActor는 특별한 문법"이라는 것은 틀렸다 — 글로벌 액터 중 하나일 뿐이고 똑같이 만들 수 있다. "격리는 컴파일 타임에만 존재한다"도 이제 틀렸다 — @isolated(any)로 런타임 값이 된다. "액터 메서드 안의 클로저는 그 액터에 격리된다"는 @Sendable이면 틀렸다 — 격리 밖이라 self 접근이 막힌다. "글로벌 액터를 만들면 스레드가 하나 생긴다"도 틀렸다 — 액터는 스레드가 아니라 직렬화 도메인이고, 실행 스레드는 협력 풀에서 온다(P0 09장).
택배를 부치는데 상자에 주소가 안 적혀 있다고 하자. 어떻게 될까? 일단 아무 집하장으로 보낸 뒤, 거기서 열어 보고 다시 옮겨야 한다. 한 번 더 움직이는 셈이다.
게다가 순서가 뒤바뀔 수 있다. 같은 동네로 갈 상자 둘이 서로 다른 집하장을 거치면 도착 순서가 달라진다.
새로 생긴 기능은 상자에 주소를 적어 두는 것이다. 그러면 처음부터 맞는 곳으로 보낸다. 한 번 덜 움직이고, 보낸 순서대로 도착한다.
확인해 보면 주소가 세 종류로 찍힌다. "주소 없음", "메인 창구", "내가 만든 전용 창구". 세 번째가 재밌는데, 메인 창구가 특별한 게 아니라 그냥 미리 만들어진 창구 하나였을 뿐이라는 걸 알 수 있다.
꼬리 질문
글로벌 액터와 그냥 액터 인스턴스는 언제 갈리는가?
"도메인이 하나여야 하는가"가 기준이다.
| 글로벌 액터 | 액터 인스턴스 | |
|---|---|---|
| 개수 | 프로그램에 하나 | 필요한 만큼 여러 개 |
| 표시 방법 | 타입·함수에 어노테이션 | 인스턴스를 들고 다님 |
| 적합한 대상 | UI, 단일 DB 연결, 전역 로그 | 세션·요청·문서 단위 상태 |
| 남용 시 문제 | 병목 — 전부 한 줄로 선다 | 인스턴스 관리 부담 |
// 글로벌 액터 — 파일 시스템 접근을 앱 전체에서 직렬화
@globalActor actor FileActor { static let shared = FileActor() }
// 액터 인스턴스 — 문서마다 독립적으로 직렬화
actor Document { private var text = "" }
let a = Document(), b = Document() // 서로 간섭하지 않는다실수하기 쉬운 지점은 글로벌 액터를 남발하는 것이다. 편의상 @MainActor를 여기저기 붙이면 모든 작업이 메인에 줄을 서서 화면이 끊긴다.
P0 09장의 원칙이 그대로다 — 격리 단위는 "함께 일관돼야 하는 상태"의 단위여야 한다. 편의가 아니라.
isolated 파라미터는 언제 유용한가?
액터의 상태를 다루는 로직을 액터 밖으로 빼고 싶을 때다.
액터가 커지면 메서드를 나누고 싶어지는데, 밖으로 빼면 await가 붙고 중간에 다른 작업이 끼어들 수 있는 지점이 생긴다. isolated 파라미터는 그 문제를 없앤다.
actor Ledger {
var entries: [Entry] = []
var balance: Int = 0
}
// ❌ 밖으로 빼면 await 마다 틈이 생긴다
func auditBad(_ l: Ledger) async {
let e = await l.entries // ← 여기서 중단
let b = await l.balance // ← 그 사이 값이 바뀔 수 있다
check(e, b) // 일관되지 않은 조합일 수 있다
}
// ✅ isolated 파라미터 — 액터 안에서 통째로 실행된다
func audit(_ l: isolated Ledger) {
check(l.entries, l.balance) // 중단 지점이 없다 = 원자적으로 본다
}이것이 액터 재진입(reentrancy) 문제의 실용적 해법 중 하나다 — P0 09장에서 다룬 "await 사이에 상태가 바뀐다"는 함정을, await를 없애서 피한다.
Swift 6.2에는 #isolation 매크로도 있어, 현재 격리를 기본값으로 넘길 수 있다.
func helper(isolation: isolated (any Actor)? = #isolation) async { … }isolated 파라미터는 확인하는 사람을 방 안으로 불러들이는 것이다. 나갈 일이 없으니 바뀔 틈도 없다.커스텀 실행자(Custom Executor)는 무엇이 다른가?
액터가 "어디서" 실행되는지를 직접 정하는 기능(SE-0392)이다. 격리 도메인은 그대로 두고 실행 자원만 바꾼다.
final class QueueExecutor: SerialExecutor {
let queue = DispatchQueue(label: "db")
func enqueue(_ job: consuming ExecutorJob) {
let j = UnownedJob(job)
queue.async { j.runSynchronously(on: self.asUnownedSerialExecutor()) }
}
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
actor DBActor {
private let exec = QueueExecutor()
nonisolated var unownedExecutor: UnownedSerialExecutor { exec.asUnownedSerialExecutor() }
}쓸 만한 상황은 제한적이다.
- 기존
DispatchQueue기반 코드와 통합 — 점진 이전 중에 순서를 맞춰야 할 때. - C 라이브러리가 특정 스레드를 요구할 때(일부 오디오·그래픽 API).
- 실행 순서를 직접 통제해야 하는 특수 스케줄링.
대부분의 앱 코드에는 필요 없다. 기본 협력 풀이 스레드 폭발을 막고 우선순위를 전파하도록 이미 설계돼 있어(P0 10장), 직접 만들면 그 이점을 잃기 쉽다.
판단 기준은 단순하다 — "실행 자원을 바꿔야만 하는 외부 제약이 있는가?" 없으면 쓰지 않는다.
Q4. AsyncStream에서 요소는 어디로 사라지는가?
두 곳에서 사라진다 — 버퍼 정책과 소비자 수. 1000개를 밀어 넣고 세어 봤을 때, .unbounded(기본값)는 1000개 전부 도착하지만 .bufferingNewest(1)은 1개만 도착하고(그것도 첫 요소가 아닌 1000번째), .bufferingOldest(1)은 1번째 1개만, .bufferingNewest(10)은 991~1000번 10개만 남는다. 더 위험한 함정은 소비자 쪽이다 — AsyncStream은 브로드캐스트가 아니다. 소비자 둘이 붙으면 요소가 복제되지 않고 나눠진다: 실측 52+48, 58+42, 51+49 — 합계는 항상 100이고 비율은 매번 다르다.
CS 원리
AsyncSequence는 비동기 반복자 프로토콜이다. 동기 Sequence가 next() -> Element?를 주듯, AsyncIteratorProtocol은 next() async throws -> Element?를 준다.
여기서 생산자와 소비자의 속도 차라는 고전적 문제가 등장한다. 생산자가 빠르면 어딘가에 쌓여야 하고, 쌓을 곳은 유한하다. 선택지는 셋뿐이다.
| 전략 | 동작 | 대가 |
|---|---|---|
| 무제한 버퍼 | 다 쌓는다 | 메모리가 증가한다 — 최악엔 OOM |
| 드롭 | 넘치면 버린다 | 데이터가 유실된다 |
| 배압(backpressure) | 생산자를 멈춘다 | 생산자가 기다려야 한다 |
AsyncStream은 앞의 두 가지만 제공한다. yield가 async가 아니어서 생산자를 멈출 수단이 없기 때문이다 — 이것이 설계상의 한계이지 버그가 아니다.
그리고 기본값이 .unbounded라는 것이 중요하다. 아무 생각 없이 쓰면 무제한 버퍼가 된다. P1 19장 Q5의 메모리 증가 진단에서 이런 스트림이 원인인 경우가 있다.
소비자 쪽 성질은 더 자주 오해된다. AsyncStream은 단일 소비자(unicast) 설계다. 요소는 먼저 next()를 부른 쪽이 가져간다 — Combine의 Publisher가 브로드캐스트(multicast)인 것과 정반대다.
이 차이를 모르면 "화면 두 곳에서 같은 스트림을 구독"했다가 각자 절반씩만 받는 버그를 만든다. 그리고 비율이 매번 달라서 재현이 어렵다.
iOS에서는
버퍼 정책은 데이터의 성격으로 정한다.
// 센서 값 — 최신만 의미 있다. 밀린 값은 버린다.
let sensor = AsyncStream<Reading>(bufferingPolicy: .bufferingNewest(1)) { c in
manager.onUpdate = { c.yield($0) }
}
// 사용자 이벤트 — 하나도 놓치면 안 된다. 대신 상한을 둔다.
let taps = AsyncStream<Tap>(bufferingPolicy: .bufferingOldest(256)) { c in … }
// ⚠️ 기본값은 .unbounded — 명시하지 않으면 무제한으로 쌓인다
let risky = AsyncStream<Log> { c in … }
여러 곳에서 봐야 하면 스트림을 나눠 만든다. 하나를 공유하면 나뉘어 버린다.
// ❌ 하나를 둘이 본다 — 각자 절반씩만 받는다
for await v in shared { updateChart(v) } // 화면 A
for await v in shared { updateList(v) } // 화면 B
// ✅ 소비자마다 별도 스트림을 만들어 준다
actor Broadcaster {
private var conts: [UUID: AsyncStream<Value>.Continuation] = [:]
func subscribe() -> AsyncStream<Value> {
let id = UUID()
return AsyncStream(bufferingPolicy: .bufferingNewest(64)) { c in
conts[id] = c
c.onTermination = { _ in Task { await self.remove(id) } }
}
}
func send(_ v: Value) { for c in conts.values { c.yield(v) } }
private func remove(_ id: UUID) { conts[id] = nil }
}
onTermination을 반드시 설정한다. 소비자가 사라졌는데 생산자가 계속 밀어 넣으면 자원이 새어 나간다.
let s = AsyncStream<Data> { c in
let token = start()
c.onTermination = { reason in
stop(token) // .cancelled / .finished 둘 다 여기로 온다
}
}
취소 전파도 확인해야 한다. for await를 도는 Task가 취소되면 순회가 끝나고 onTermination이 .cancelled로 불린다.
실험 · 도구
버퍼 정책별 유실을 직접 센다.
func drain(_ policy: AsyncStream<Int>.Continuation.BufferingPolicy, _ name: String) async {
let s = AsyncStream<Int>(bufferingPolicy: policy) { c in
for i in 1...1000 { c.yield(i) } // 소비 전에 한꺼번에 밀어넣는다
c.finish()
}
var got = 0, first = 0, last = 0
for await v in s { if got == 0 { first = v }; last = v; got += 1 }
print("\(name): 1000개 중 \(got)개 도착 (첫 \(first), 끝 \(last))")
}
$ ./buffer
.unbounded : 1000개 중 1000개 도착 (첫 1, 끝 1000)
.bufferingNewest(1) : 1000개 중 1개 도착 (첫 1000, 끝 1000)
.bufferingOldest(1) : 1000개 중 1개 도착 (첫 1, 끝 1)
.bufferingNewest(10): 1000개 중 10개 도착 (첫 991, 끝 1000)
소비자 분배도 세어 본다. 세 번 돌려 보는 것이 중요하다 — 한 번만 보면 우연으로 착각한다.
let (s, c) = AsyncStream<Int>.makeStream()
Task.detached { for i in 1...100 { c.yield(i) }; c.finish() }
async let a: Int = { var n = 0; for await _ in s { n += 1 }; return n }()
async let b: Int = { var n = 0; for await _ in s { n += 1 }; return n }()
let (x, y) = await (a, b)
print("소비자A \(x)개, 소비자B \(y)개, 합계 \(x+y)")
$ ./split; ./split; ./split
소비자A 52개, 소비자B 48개, 합계 100 (보낸 건 100개)
소비자A 58개, 소비자B 42개, 합계 100 (보낸 건 100개)
소비자A 51개, 소비자B 49개, 합계 100 (보낸 건 100개)
# 버퍼 정책을 지정하지 않은 스트림 — 전부 무제한이다
grep -rn "AsyncStream" --include=*.swift . | grep -v "bufferingPolicy"
# onTermination 없는 스트림 — 자원 누수 후보
grep -rn -A5 "AsyncStream" --include=*.swift . | grep -c "onTermination"
프로젝트 적용
- 버퍼 정책을 항상 명시한다. 기본값
.unbounded는 선택이 아니라 방치다. - 최신만 의미 있으면
.bufferingNewest(1), 하나도 놓치면 안 되면 상한 있는.bufferingOldest(N)를 쓴다. - 여러 소비자가 필요하면 소비자마다 스트림을 만든다. 하나를 공유하면 나뉜다.
onTermination을 반드시 설정한다. 생산 자원 정리가 여기 있다.- 메모리가 계속 느는 화면에서 스트림을 의심한다. P1 19장 Q5의 phys_footprint 증가 진단과 함께 본다.
"AsyncStream은 Publisher의 async 버전"이라는 것은 틀렸다 — Publisher는 브로드캐스트, AsyncStream은 단일 소비자다. "소비자가 둘이면 둘 다 받는다"도 실측과 다르다 — 나눠 갖는다(52+48). "버퍼가 넘치면 생산자가 기다린다"도 틀렸다 — yield는 async가 아니라 기다릴 수 없고, 대신 버린다. ".bufferingNewest(1)이면 첫 값이 온다"도 틀렸다 — 가장 최근 값(1000번)이 온다. "기본값이 안전하다"도 틀렸다 — 기본이 무제한이라 가장 위험하다.
공장에서 물건을 컨베이어 벨트로 보낸다고 하자. 만드는 속도가 가져가는 속도보다 빠르면 물건이 쌓인다.
방법은 셋인데, 이 도구는 둘만 준다. "창고를 무한정 늘린다"(그러다 창고가 터진다) 또는 "넘치면 버린다". "만드는 걸 잠깐 멈춘다"는 안 된다 — 만드는 쪽이 기다릴 수 있는 구조가 아니다.
그런데 기본 설정이 "무한 창고"다. 아무 생각 없이 쓰면 계속 쌓인다.
더 놀라운 건 받는 사람이 둘일 때다. 라디오처럼 둘 다 똑같이 듣는 게 아니다. 벨트 위 물건을 먼저 집는 사람이 가져간다. 100개를 보내면 52개 / 48개로 나뉜다. 그리고 매번 비율이 달라서 "왜 절반만 오지?"를 알아내기가 아주 어렵다.
꼬리 질문
배압이 정말 필요하면 어떻게 만드는가?
생산자가 기다릴 수 있게 만들어야 한다. AsyncStream으로는 안 되고, 직접 구현하거나 다른 도구를 쓴다.
(1) 액터로 유한 버퍼를 만든다. 가장 직접적이다.
actor BoundedQueue<T: Sendable> {
private var items: [T] = []
private var waiters: [CheckedContinuation<Void, Never>] = []
private let limit: Int
init(limit: Int) { self.limit = limit }
func push(_ v: T) async {
while items.count >= limit { // 가득 차면 생산자가 기다린다
await withCheckedContinuation { waiters.append($0) }
}
items.append(v)
}
func pop() -> T? {
guard !items.isEmpty else { return nil }
let v = items.removeFirst()
if let w = waiters.popLast() { w.resume() } // 자리가 나면 깨운다
return v
}
}(2) AsyncChannel을 쓴다. swift-async-algorithms 패키지가 제공하며, send가 async라 배압이 내장돼 있다.
(3) 요청-응답으로 뒤집는다. 소비자가 필요할 때 당겨오는 구조(pull)로 바꾸면 배압 문제 자체가 사라진다.
실무에서는 (3) → (2) → (1) 순으로 검토하는 것이 좋다. 직접 만든 대기 큐는 취소 처리에서 실수하기 쉽다 — 위 예제도 취소 시 waiters 정리가 빠져 있어 그대로 쓰면 안 된다.
AsyncSequence를 직접 만들 때 주의할 점은?
취소 처리와 반복자의 재사용 가능 여부가 핵심이다.
struct Ticker: AsyncSequence {
typealias Element = Int
let count: Int
struct Iterator: AsyncIteratorProtocol {
var remaining: Int
mutating func next() async throws -> Int? {
guard remaining > 0 else { return nil }
try Task.checkCancellation() // ← 취소를 확인한다
try await Task.sleep(for: .milliseconds(100))
remaining -= 1
return remaining
}
}
func makeAsyncIterator() -> Iterator { Iterator(remaining: count) }
}주의점을 정리하면 이렇다.
- 취소 확인 —
next()에서Task.checkCancellation()이나 취소 감응 API를 쓴다. 없으면 취소해도 안 멈춘다. - 종료 후
nil유지 — 한 번nil을 반환했으면 계속nil이어야 한다. Sendable여부 — 시퀀스를 다른 도메인으로 넘길 거면 반복자까지 안전해야 한다.- 타입 지정 throws(SE-0421) — Swift 6에서는
AsyncSequence가Failure연관 타입을 가져, 던지지 않는 시퀀스를 표현할 수 있다(32장 Q2와 이어진다).
// 던지지 않음이 타입에 드러난다 — for await 에 try 가 필요 없다
extension Ticker: AsyncSequence where Element: Sendable {
typealias Failure = Never
}대부분의 경우 직접 만들 필요가 없다. AsyncStream이나 swift-async-algorithms의 조합자로 충분하다.
Combine에서 async/await로 옮길 때 무엇이 달라지는가?
가장 큰 차이는 브로드캐스트 유무와 오류 모델이다.
| Combine | AsyncSequence | |
|---|---|---|
| 구독자 수 | 여럿 (multicast 가능) | 하나 (unicast) |
| 배압 | Demand로 내장 | 없음 (직접 구현) |
| 오류 | Failure 타입 + 완료 이벤트 | throws — 던지면 순회 종료 |
| 취소 | AnyCancellable 보관 | 구조적 — Task 취소로 전파 |
| 스레드 지정 | receive(on:) | 액터 격리 |
이전할 때 실제로 문제가 되는 지점은 셋이다.
(1) 다중 구독이 사라진다. share()·multicast()에 의존하던 코드는 브로드캐스터를 직접 만들어야 한다(위 예제).
(2) 오류가 스트림을 끝낸다. Combine에서 catch로 복구하며 이어가던 패턴은, for await에서 순회가 끝나므로 바깥에서 재시작해야 한다.
// 오류가 나도 계속 받고 싶으면 Result 로 감싼다
let safe = AsyncStream<Result<Value, Error>> { c in … }(3) 취소 모델이 바뀐다. AnyCancellable을 보관하던 것에서, Task를 보관하고 취소하는 것으로 바뀐다 — 34장 Q5의 관찰 토큰 관리와 같은 문제다.
권장은 화면 단위 점진 이전이다. 경계에서 values 프로퍼티로 Publisher를 AsyncSequence로 바꿔 양쪽을 붙여 가며 옮긴다.
for await v in publisher.values { … } // Combine → async 다리Q5. 프로퍼티가 async·throws가 되면 무엇을 잃는가?
"값처럼 보이는 것"의 성질 일부를 잃는다. Effectful Property(SE-0310)는 var v: Int { get async throws }처럼 접근자에 효과를 붙이는 기능이고, 액터의 await 프로퍼티가 그 대표다. 대가는 구체적이다 — 확인해 보면 key path를 만들 수 없고(error: cannot form key path to property with 'throws' or 'async'), 동기 프로토콜 요구사항을 만족시킬 수 없다(type 'A' does not conform to protocol 'Sync'). 32장 Q3의 KeyPath 기반 설계, 34장 Q5의 KVO, 그리고 @Observable의 추적까지 전부 이 프로퍼티에는 적용되지 않는다.
CS 원리
프로그래밍 언어에는 "이건 값이다"라는 암묵적 계약이 있다. 프로퍼티를 읽는 것은 싸고, 실패하지 않고, 여러 번 읽어도 같고, 순서에 무관하다고 가정된다.
async·throws를 붙이는 순간 그 계약이 깨진다.
| 보통 프로퍼티가 주는 보장 | effectful이 되면 |
|---|---|
| 즉시 값이 나온다 | 중단될 수 있다 — 그 사이 세상이 바뀐다 |
| 실패하지 않는다 | 던질 수 있다 |
| 참조로 지칭 가능(key path) | 불가능 |
| 동기 인터페이스를 만족 | 불가능 |
이것이 추상화의 누수(leaky abstraction)의 교과서적 사례다. 문법적으로는 여전히 obj.value지만, 의미론적으로는 함수 호출이다.
왜 key path가 불가능한가? KeyPath는 "값에 이르는 순수한 경로"라는 전제 위에 서 있다. 32장 Q3에서 본 대로 KeyPath는 합성 가능하고 저장 가능해야 하는데, async throws인 접근을 경로로 표현할 수단이 없다 — 그 경로를 "따라가는" 것 자체가 중단·실패할 수 있는 연산이기 때문이다.
프로토콜 준수도 같은 이유다. var v: Int { get }은 "동기적으로 읽힌다"는 계약이고, get async는 그것을 제공하지 못한다. 반대 방향(동기 구현이 async 요구사항 충족)은 가능하다 — 더 강한 보장이 약한 요구를 만족시키기 때문이다.
이 비대칭이 부분 순서(subtyping)의 전형이다. 33장 Q6의 static let vs lazy var에서 본 것처럼, 보장이 강한 쪽이 약한 쪽 자리에 들어갈 수 있다.
obj.value지만 의미론은 함수 호출이다 — 그래서 값 기반 도구가 안 붙는다.iOS에서는
가장 흔한 형태는 액터의 프로퍼티다. 이건 자동으로 effectful이다.
actor Session {
var token: String = ""
}
let s = Session()
let t = await s.token // 액터 프로퍼티 읽기는 이미 async 다
// let kp = \Session.token // ❌ key path 를 만들 수 없다
직접 선언하는 경우는 비싼 계산이나 I/O를 프로퍼티로 감쌀 때다.
struct RemoteConfig {
var flags: [String: Bool] {
get async throws {
let (d, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([String: Bool].self, from: d)
}
}
}
let cfg = try await config.flags // 프로퍼티처럼 보이지만 네트워크가 돈다
이 설계를 권장하지 않는 이유가 여기 드러난다. 읽는 쪽이 비용을 못 본다. 반복문 안에 들어가면 매번 네트워크다.
for key in keys {
if try await config.flags[key] == true { … } // ⚠️ 매 반복마다 요청
}
메서드로 만드는 편이 정직하다. 괄호가 "이건 연산이다"를 알려 준다.
func fetchFlags() async throws -> [String: Bool] { … } // ✅ 비용이 보인다
실제로 Apple 자신도 이 기준을 따른다. Data의 count는 프로퍼티지만 네트워크 조회는 data(from:) 메서드다.
@Observable과의 관계도 확인해야 한다. 34장 Q6의 접근 추적은 동기 읽기를 가로채므로, get async 프로퍼티는 추적 대상이 되지 않는다. 화면 갱신을 기대하면 안 된다.
실험 · 도구
두 제약을 한 파일로 확인한다.
// effectful.swift
protocol Sync { var v: Int { get } }
struct A {
var v: Int { get async { 1 } }
}
let kp = \A.v // key path 를 만들어 본다
extension A: Sync {} // 동기 요구사항을 만족시켜 본다
$ swiftc -swift-version 6 -c effectful.swift -o /dev/null
effectful.swift:6:13: error: cannot form key path to property with 'throws' or 'async'
effectful.swift:7:1: error: type 'A' does not conform to protocol 'Sync'
반대 방향(동기 구현이 async 요구를 만족)은 통과한다.
protocol Loose { var v: Int { get async throws } }
struct B { var v: Int { 1 } } // 그냥 동기 프로퍼티
extension B: Loose {} // ✅ 통과 — 더 강한 보장이므로
# effectful 프로퍼티 선언 찾기 — 메서드로 바꿀 후보
grep -rnE "\{ *get (async|throws|async throws)" --include=*.swift .
# @Observable 타입 안의 async 프로퍼티 — 추적되지 않으므로 위험
grep -rn -A15 "@Observable" --include=*.swift . | grep "get async"
프로젝트 적용
- I/O나 비싼 계산은 메서드로 만든다. 괄호가 비용을 알리는 신호다.
- effectful 프로퍼티는 액터 상태 노출에 한정한다. 그건 언어가 강제하는 것이라 선택의 여지가 없다.
- KeyPath 기반 설계와 섞지 않는다. 32장 Q3의 패턴들이 전부 막힌다.
@Observable타입에get async를 넣지 않는다. 추적되지 않아 화면이 안 갱신된다.- 반복문 안의 effectful 접근을 의심한다. 프로퍼티처럼 보여서 리뷰에서 놓치기 쉽다.
"프로퍼티에 async를 붙이면 메서드와 같아진다"는 틀렸다 — 더 적은 것을 할 수 있다(key path·프로토콜 준수 불가). "액터 프로퍼티도 key path로 관찰할 수 있다"도 틀렸다 — 컴파일 에러다. "동기 프로퍼티는 async 요구사항을 못 만족한다"도 틀렸다 — 만족한다. 방향이 비대칭이다. "@Observable이면 get async도 추적된다"도 틀렸다 — 접근 추적은 동기 읽기를 가로챈다. "프로퍼티가 문법 설탕이니 성능은 같다"도 맞지만 오해를 부른다 — 성능은 같아도 호출자가 비용을 인지하지 못하는 것이 문제다.
"이름표"와 "심부름"의 차이라고 생각하자.
보통 프로퍼티는 이름표다. 상자에 붙은 종이를 읽는 것 — 즉시, 실패 없이, 몇 번을 읽어도 같다.
그런데 이름표처럼 생겼는데 사실은 심부름인 게 있다. "이 상자 안에 뭐 있어?"라고 물으면 창고까지 갔다 와야 답이 나온다. 오래 걸리고, 가는 길에 사고가 날 수도 있다.
그러면 이름표에만 쓸 수 있던 도구들이 안 맞는다. "이 이름표를 가리키는 화살표를 만들어 줘"라고 하면 안 된다 — 화살표가 가리키는 게 종이가 아니라 심부름이니까.
그래서 진짜 심부름이면 심부름처럼 생기게 만드는 게 낫다. 이름 뒤에 괄호를 붙이는 것만으로도, 쓰는 사람이 "아 이건 시간이 걸리는구나"를 안다.
꼬리 질문
액터 프로퍼티를 화면에 바인딩하려면 어떻게 하는가?
액터 상태를 그대로 바인딩할 수는 없다. await가 필요한데 body는 동기이기 때문이다.
표준 해법은 메인 액터에 투영(projection)을 두는 것이다.
actor DataStore {
private var items: [Item] = []
func load() async -> [Item] { items }
}
@MainActor @Observable
final class ItemsViewModel {
private(set) var items: [Item] = [] // 화면이 읽는 것은 이쪽
private let store: DataStore
init(store: DataStore) { self.store = store }
func refresh() async {
items = await store.load() // 액터에서 가져와 메인에 반영
}
}구조가 이렇게 갈리는 이유는 관심사가 다르기 때문이다.
- 액터 — 진실의 원천, 일관성 보장.
@MainActor모델 — 화면이 동기적으로 읽을 수 있는 스냅샷.
이 분리는 34장 Q6에서 본 Observation의 접근 추적과도 맞물린다 — 추적은 동기 읽기에 걸리므로, 화면이 읽는 값은 반드시 동기여야 한다.
흔한 실수는 액터를 @Observable로 만드는 것이다. 컴파일은 될 수 있지만 추적이 걸리지 않아 화면이 안 갱신된다.
get async 프로퍼티에 캐싱을 넣으면 되지 않는가?
비용 문제는 줄지만 새 문제가 생긴다. 재진입과 무효화다.
actor ConfigCache {
private var cached: [String: Bool]?
private var inFlight: Task<[String: Bool], Error>?
var flags: [String: Bool] {
get async throws {
if let c = cached { return c }
if let t = inFlight { return try await t.value } // ← 중복 요청 방지
let t = Task { try await fetch() }
inFlight = t
defer { inFlight = nil }
let v = try await t.value
cached = v
return v
}
}
}inFlight가 필요한 이유가 액터 재진입이다. await 동안 다른 호출이 들어올 수 있어서, 그것을 막지 않으면 같은 요청을 여러 번 보낸다(P0 09장).
그다음 문제가 무효화다. 캐시는 언젠가 틀린다 — 언제 버릴지 정해야 한다.
- 시간 기반 —
N초 지나면 버린다. 단순하지만 정확하지 않다. - 이벤트 기반 — 서버 푸시·앱 포그라운드 진입에 버린다. 정확하지만 경로가 많다.
- 버전 기반 — 응답의 ETag를 비교한다. P0 16장의 조건부 요청과 이어진다.
여기까지 오면 프로퍼티로 감쌀 일이 아니라는 게 분명해진다. 캐시 정책·무효화·중복 억제를 전용 타입으로 빼는 편이 낫다.
throws만 붙은 프로퍼티는 언제 쓰는가?
거의 안 쓴다. 그리고 그게 맞다.
get throws는 "읽기가 실패할 수 있다"는 뜻인데, 이건 대부분 설계 신호다 — 그 값은 프로퍼티가 아니라 결과다.
// ⚠️ 읽을 때마다 실패할 수 있는 프로퍼티
struct Doc {
var parsed: AST { get throws { try parse(raw) } }
}
// ✅ 결과를 저장해 두는 편이 낫다 — 실패는 만들 때 한 번만
struct Doc2 {
let parsed: Result<AST, ParseError>
}
// ✅ 또는 실패 가능한 초기화 — 유효하지 않은 값이 존재하지 않게 한다
struct Doc3 {
let parsed: AST
init(raw: String) throws { parsed = try parse(raw) }
}세 번째가 32장 Q1·Q7과 이어지는 불법 상태를 표현 불가능하게 만드는 접근이다. 만들 때 검증하면 이후 모든 읽기가 안전해진다.
예외적으로 쓸 만한 곳은 외부 자원을 반영하는 값이다 — 파일 속성, 키체인 항목처럼 읽을 때마다 실패 가능성이 실재하는 경우. 그때도 메서드가 더 정직한 경우가 많다.
Q6. 액터 모델을 네트워크로 늘리면 무엇이 달라지는가?
세 가지가 추가된다 — 위치(id), 직렬화, 그리고 실패. distributed actor는 액터의 메시지 전달 모델을 그대로 유지하면서 상대가 다른 프로세스·기기에 있을 수 있게 한다. 실행해 보면 셋이 뚜렷하다 — 인스턴스가 id를 갖고(LocalTestingActorID(id: "1")), 그 id로 resolve해 되살릴 수 있고(이어서 호출하면 상태가 이어진다: 1, 2 → 3), 모든 distributed 호출이 try await가 된다. 그리고 컴파일러가 두 가지를 강제한다 — 인자·반환은 Codable이어야 하고, distributed가 아닌 메서드는 밖에서 못 부른다.
CS 원리
액터 모델(Hewitt, 1973)은 원래부터 분산을 염두에 둔 모델이다. 핵심 규칙이 셋뿐이다 — 액터는 메시지를 받아 (1) 상태를 바꾸고 (2) 다른 액터에 메시지를 보내고 (3) 새 액터를 만든다.
이 규칙에는 "같은 메모리 공간"이라는 전제가 없다. 그래서 네트워크로 늘리는 것이 자연스럽다.
하지만 공짜는 아니다. P0 16장에서 다룬 분산 시스템의 근본 문제가 그대로 들어온다.
| 로컬 액터 | 분산 액터 | |
|---|---|---|
| 참조 | 포인터 | id — 직렬화 가능한 주소 |
| 인자 전달 | 참조/값 그대로 | 직렬화 필요 (Codable) |
| 호출 실패 | 없음 | 있다 — 모든 호출이 throws |
| 지연 | 나노초 | 밀리초~ |
| 부분 실패 | 없음 | 있다 — 갔는지 안 갔는지 모른다 |
마지막 줄이 가장 중요하다. "요청이 도착했지만 응답이 유실"된 경우, 호출자는 구분할 수 없다. 그래서 P0 16장의 멱등성이 필수가 된다 — 재시도해도 결과가 같아야 한다.
Swift의 설계 선택이 흥미롭다. 투명한 원격 호출(transparent RPC)을 하지 않는다. distributed 키워드를 명시적으로 요구해서, 어떤 호출이 네트워크를 탈 수 있는지가 코드에 드러난다.
이것은 1980년대 분산 객체(CORBA, Java RMI)의 실패에서 배운 것이다. 원격 호출을 로컬처럼 보이게 만들면, 개발자가 지연과 실패를 잊는다. Waldo 등의 "A Note on Distributed Computing"(1994)이 지적한 바로 그 문제다.
distributed 호출이 try await라는 것 자체가 실패와 지연을 상기시키는 장치다.iOS에서는
표준 라이브러리가 테스트용 액터 시스템을 제공해 개념을 바로 확인할 수 있다.
import Distributed
typealias DefaultDistributedActorSystem = LocalTestingDistributedActorSystem
distributed actor Counter {
private var n = 0
distributed func bump() -> Int { n += 1; return n }
func localOnly() -> Int { n } // distributed 아님
}
let sys = LocalTestingDistributedActorSystem()
let c = Counter(actorSystem: sys)
print("id:", c.id) // LocalTestingActorID(id: "1")
print("bump:", try await c.bump(), try await c.bump()) // 1 2
let remote = try Counter.resolve(id: c.id, using: sys) // id 로 참조 복원
print("resolve 후 bump:", try await remote.bump()) // 3 — 상태가 이어진다
제약이 컴파일 타임에 걸린다.
final class NotCodable {}
distributed actor Counter {
distributed func bad() -> NotCodable { NotCodable() }
// error: result type 'NotCodable' of distributed instance method 'bad'
// does not conform to serialization requirement 'Codable'
}
func use(_ c: Counter) async throws {
_ = try await c.bump()
_ = c.localOnly()
// error: only 'distributed' instance methods can be called on a
// potentially remote distributed actor
}
iOS 앱에서 실제로 쓸 일이 있는가? 솔직히 드물다. 서버 사이드 Swift와 클러스터 구성이 주 무대다(swift-distributed-actors 패키지).
다만 개념은 앱에도 적용된다. 앱 확장·XPC 서비스·워치 통신은 같은 문제 구조를 갖는다 — 별도 프로세스, 직렬화, 부분 실패. P1 24장의 앱 확장 통신이 그 예다.
실험 · 도구
위 코드를 그대로 돌려 세 가지 성질을 확인한다.
$ swiftc -swift-version 6 -parse-as-library dist.swift -o dist && ./dist
id: LocalTestingActorID(id: "1")
bump: 1 2
resolve 후 bump: 3
제약이 실제로 막히는지 확인한다.
$ swiftc -swift-version 6 -c bad.swift -o /dev/null
bad.swift:10:22: error: result type 'NotCodable' of distributed instance method 'bad'
does not conform to serialization requirement 'Codable'
bad.swift:15:11: error: only 'distributed' instance methods can be called on a
potentially remote distributed actor
`- note: distributed actor-isolated instance method 'localOnly()' declared here
액터 시스템을 직접 만들 때 구현해야 하는 것들을 확인하려면 프로토콜을 보면 된다.
# DistributedActorSystem 요구사항 — 무엇을 구현해야 하는지가 다 보인다
swift -e 'import Distributed' 2>/dev/null
grep -rn "func remoteCall\|func resolve\|func assignID\|func actorReady" \
$(xcrun --show-sdk-path)/usr/lib/swift/Distributed 2>/dev/null | head
프로젝트 적용
- 일반 iOS 앱에는 도입하지 않는다. 서버·클러스터가 주 용도다.
- 개념은 프로세스 경계에 그대로 적용한다. 앱 확장·XPC도 직렬화와 부분 실패가 있다(P1 23장).
- 재시도가 필요한 곳에 멱등성을 설계한다. P0 16장의 요청 키·중복 제거를 그대로 쓴다.
- 경계를 코드에 드러낸다. 원격 호출을 로컬처럼 감추지 않는 것이 이 설계의 요지다.
Codable경계를 얇게 유지한다. 31장 Q7의 확장-축소 스키마 진화가 여기서도 필요하다.
"distributed actor를 쓰면 자동으로 네트워크 통신이 된다"는 틀렸다 — DistributedActorSystem을 직접 제공해야 하고, 표준 라이브러리 것은 테스트용이다. "원격 호출이 로컬처럼 보인다"도 틀렸다 — 의도적으로 distributed·try await를 요구한다. "액터의 모든 메서드를 원격에서 부를 수 있다"도 틀렸다 — distributed가 붙은 것만이다. "Codable이면 뭐든 넘어간다"도 주의가 필요하다 — 넘어가는 것은 복사본이라 참조 의미가 사라진다. "액터 모델이면 분산에서도 안전하다"도 틀렸다 — 부분 실패는 액터 모델이 해결해 주지 않는다.
액터는 "창구에 줄 서서 부탁하기"다. 그런데 그 창구가 같은 건물이 아니라 다른 도시에 있다면?
세 가지가 달라진다. 첫째, 주소가 필요하다 — "저 창구"가 아니라 "○○시 △△번 창구"라고 해야 한다. 둘째, 부탁 내용을 편지로 적어야 한다 — 말로 못 하니까. 셋째, 편지가 안 갈 수 있다.
실제로 해 보면 재밌다. 창구를 만들면 번호표(id)가 붙는다. 그 번호만 알면 나중에 다시 그 창구를 찾을 수 있고, 하던 일이 이어진다(1, 2 다음에 3).
중요한 설계 결정이 하나 있다. "멀리 있다는 걸 숨기지 않는다." 옛날 사람들은 멀리 있는 창구도 옆방처럼 보이게 만들었는데, 그러니까 사람들이 오래 걸린다는 걸 잊고 마구 불렀다. 그래서 지금은 "이건 멀리 있는 창구야"라고 코드에 적게 만든다.
꼬리 질문
부분 실패는 어떻게 다루는가?
"모른다"를 인정하는 것에서 시작한다. 요청이 도착했는지 알 수 없는 상태가 실재한다.
대응은 P0 16장의 것을 그대로 쓴다.
(1) 멱등성 — 같은 요청을 여러 번 보내도 결과가 같게 만든다.
distributed func transfer(_ req: TransferRequest) throws -> Receipt {
if let done = processed[req.idempotencyKey] { return done } // 이미 했으면 그 결과
let r = perform(req)
processed[req.idempotencyKey] = r
return r
}(2) 타임아웃과 재시도 — 무한정 기다리지 않고, 재시도는 지수 백오프 + 지터로 한다. 그냥 재시도하면 동시에 몰려 상황을 악화시킨다.
(3) 결과 확인 경로 — 재시도로 안 되면 "그래서 됐나?"를 물을 수 있는 별도 조회를 둔다. 결제·주문처럼 중복이 치명적인 경우 필수다.
(4) 취소 전파 — 호출자가 취소했을 때 원격 쪽도 멈출 수 있어야 한다. 안 그러면 자원이 남는다.
핵심은 "실패를 예외가 아니라 정상 경로로" 다루는 것이다. 분산에서는 실패가 드물지 않다.
앱 확장·XPC와 분산 액터의 관계는?
같은 문제 구조지만 다른 도구를 쓴다.
| 앱 확장 / XPC | 분산 액터 | |
|---|---|---|
| 경계 | 프로세스 | 프로세스·기기 |
| 직렬화 | NSSecureCoding / Codable | Codable |
| 실패 | 연결 끊김·중단 | 네트워크·노드 다운 |
| 도구 | NSXPCConnection, App Group | DistributedActorSystem |
iOS 앱 확장은 메모리 한도가 낮고 언제든 종료될 수 있어(P1 24장 Q7), 사실상 부분 실패가 일상이다.
// 확장에서 본체로 결과를 넘길 때 — 중단을 전제로 설계한다
func handle(_ req: Request) async {
let key = req.idempotencyKey
if await store.contains(key) { return } // 이미 처리됨
let result = try? await process(req)
await store.commit(key, result) // 원자적으로 기록
}흥미로운 점은 분산 액터의 규율이 여기서도 유효하다는 것이다 — 경계를 명시하고, 직렬화 가능한 것만 넘기고, 실패를 정상 경로로 다룬다.
즉 distributed actor를 도입하지 않더라도 그 설계 원칙은 앱에 적용된다.
액터 시스템을 직접 구현하려면 무엇이 필요한가?
DistributedActorSystem 프로토콜을 구현한다. 요구사항이 분산의 본질을 그대로 드러낸다.
| 요구사항 | 책임 |
|---|---|
assignID / resignID | 액터에 주소를 부여·회수 |
actorReady | 초기화 완료를 시스템에 알림 |
resolve | id로 로컬인지 원격인지 판단 |
remoteCall | 원격 호출을 실제로 전송 |
make…InvocationEncoder | 인자를 직렬화 |
resolve가 특히 중요하다. nil을 반환하면 원격 프록시가 만들어지고, 인스턴스를 반환하면 로컬 호출이 된다 — 같은 코드가 로컬/원격 둘 다로 동작하는 지점이다.
func resolve<Act>(id: ActorID, as type: Act.Type) throws -> Act?
where Act: DistributedActor {
if let local = registry[id] { return local as? Act }
return nil // nil → 원격 프록시를 만든다
}실무에서 직접 만들 일은 거의 없다. swift-distributed-actors 패키지가 클러스터·멤버십·장애 감지까지 제공한다.
다만 프로토콜을 읽어 보는 것은 가치가 있다 — 분산 시스템이 무엇을 결정해야 하는지가 목록으로 정리돼 있기 때문이다.
Q7. 데이터 경쟁 안전성이라는 목표는 어디까지 왔는가?
"검사를 강화한다"에서 "기본값을 바꾼다"로 방향이 한 번 꺾였다. 같은 코드(var counter = 0 + 그것을 바꾸는 함수)를 네 가지 설정으로 컴파일해 보면 사다리가 그대로 보인다 — Swift 5 기본은 침묵, -strict-concurrency=complete는 경고(this is an error in the Swift 6 language mode), Swift 6는 에러, 그런데 Swift 6 + -default-isolation MainActor는 다시 조용히 통과한다. 마지막 칸이 요지다 — 코드는 그대로인데 기본 격리를 바꾸니 안전해졌다. 이것이 SE-0466이 말하는 "접근하기 쉬운 동시성"이고, Q2의 SE-0461과 같은 철학이다.
CS 원리
데이터 경쟁 안전성은 안전 속성(safety property)이다 — "나쁜 일이 절대 일어나지 않는다"는 형태의 보장. 메모리 안전성과 같은 부류다.
정적 검사로 이런 보장을 만들 때 피할 수 없는 삼각형이 있다.
| 성질 | 의미 | 포기하면 |
|---|---|---|
| 건전성(soundness) | 나쁜 코드를 절대 통과시키지 않음 | 보장이 무의미해진다 |
| 완전성(completeness) | 좋은 코드를 절대 막지 않음 | 개발자가 싸워야 한다 |
| 결정 가능성 | 유한 시간에 판정 | 컴파일이 안 끝난다 |
라이스 정리(Rice's theorem)에 따라 셋 다 가질 수는 없다. Swift는 건전성과 결정 가능성을 지키고 완전성을 포기했다 — 안전한 코드도 막힐 수 있다.
초기 Swift 6 전환의 고통이 여기서 나왔다. 막히는 코드가 너무 많았다. 대응 방향이 둘로 갈린다.
- 분석을 정교하게 — Region-Based Isolation(Q1)이 이 방향이다. 거짓 양성을 줄인다.
- 기본값을 바꾼다 — SE-0461·SE-0466이 이 방향이다. 애초에 경계를 안 만든다.
두 번째가 더 근본적이다. 대부분의 앱 코드는 사실 동시성이 필요 없다 — 화면과 그 모델은 메인에서 순차적으로 돌아도 된다. 그런데 기본값이 "격리 없음(nonisolated)"이라, 실제로는 메인에서만 도는 코드까지 동시성 검사를 다 받았다.
-default-isolation MainActor는 이 기본값을 뒤집는다. 명시하지 않은 것은 메인 액터 격리다. 동시성이 필요한 곳만 nonisolated·actor로 표시한다.
철학적으로 이것은 "안전한 기본값(secure by default)"의 적용이다 — P1 23장의 최소 권한 원칙과 같은 발상이다. 기본이 안전하고, 위험한 것을 명시한다.
iOS에서는
전환 경로는 사다리를 하나씩 올라가는 것이다. 한 번에 Swift 6로 넘어가지 않는다.
// Package.swift — 모듈 단위로 단계를 올린다
.target(
name: "Core",
swiftSettings: [
.swiftLanguageMode(.v6), // 3단계: 에러
.defaultIsolation(MainActor.self), // 4단계: 기본을 메인으로
]
)
.target(
name: "Legacy",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency") // 2단계: 경고만
]
)
-default-isolation MainActor를 켠 뒤에는 표시가 뒤집힌다.
// 기본 격리가 MainActor 이므로 — 이건 자동으로 메인이다
final class ViewModel {
var items: [Item] = []
}
// 배경에서 돌아야 하는 것만 명시한다
nonisolated func parse(_ d: Data) throws -> [Item] { … }
actor ImageCache { // 별도 도메인이 필요한 것도 명시
private var store: [URL: Data] = [:]
}
주의할 점은 Q2 꼬리질문과 같다 — 무거운 작업이 메인에 남지 않는지 확인해야 한다.
// ⚠️ 기본이 MainActor 이므로 이 함수도 메인에서 돈다
func decodeAll(_ items: [Data]) -> [Item] { items.map(decode) }
// ✅ 무거운 것은 명시적으로 뺀다
nonisolated func decodeAll(_ items: [Data]) -> [Item] { items.map(decode) }
SwiftUI 앱은 이 기본값이 특히 잘 맞는다. 뷰·뷰모델·상태가 대부분 메인이고, 네트워크·디코딩만 밖으로 나가면 된다.
실험 · 도구
사다리를 직접 올라가 본다. 파일 하나면 된다.
// ladder.swift
var counter = 0
func bump() { counter += 1 }
$ run(){ echo "=== $* ==="; swiftc "$@" -parse-as-library -c ladder.swift \
-o /dev/null 2>&1 | grep -E "error:|warning:" | head -2; }
$ run -swift-version 5
=== -swift-version 5 ===
# 아무 말이 없다
$ run -swift-version 5 -strict-concurrency=complete
=== -swift-version 5 -strict-concurrency=complete ===
warning: var 'counter' is not concurrency-safe because it is nonisolated
global shared mutable state; this is an error in the Swift 6 language mode
$ run -swift-version 6
=== -swift-version 6 ===
error: var 'counter' is not concurrency-safe because it is nonisolated
global shared mutable state [#MutableGlobalVariable]
$ run -swift-version 6 -default-isolation MainActor
=== -swift-version 6 -default-isolation MainActor ===
# 다시 조용하다 — 코드는 그대로다
프로젝트 현황을 파악하는 명령들이다.
# 현재 언어 모드와 동시성 진단 수 세기
xcodebuild build 2>&1 | grep -cE "concurrency-safe|data races|Sendable"
# 가장 많이 나오는 진단 유형 순위 — 어디부터 손댈지 정한다
xcodebuild build 2>&1 | grep -oE "\[#[A-Za-z]+\]" | sort | uniq -c | sort -rn | head
# 아직 검사를 끄고 있는 곳
grep -rn "@unchecked Sendable\|nonisolated(unsafe)" --include=*.swift .
두 번째 명령이 유용하다 — 진단 코드별 빈도를 보면 한 가지 패턴이 대부분인 경우가 많고, 그걸 고치면 수백 개가 한 번에 사라진다.
프로젝트 적용
- 사다리를 한 칸씩 올린다. 경고 → 에러 → 기본값 변경. 한 번에 안 한다.
- 모듈 단위로 적용한다. 의존이 적은 말단부터 시작한다.
- 진단 빈도를 세어 우선순위를 정한다. 한 패턴이 대부분인 경우가 흔하다.
-default-isolation MainActor를 검토한다. UI 중심 모듈에서 진단이 급감한다.- 켠 뒤에는 메인 스레드 점유를 측정한다. P1 25장의 hitch 측정을 반드시 함께 한다.
@unchecked Sendable·nonisolated(unsafe)목록을 관리한다. 기술 부채로 추적하고 줄여 나간다.
"Swift 6로 올리면 데이터 경쟁이 사라진다"는 과장이다 — @unchecked Sendable·nonisolated(unsafe)·C 인터롭에는 구멍이 남는다. "진단이 많이 나오면 코드가 나쁜 것"도 틀렸다 — 검사가 불완전해서 안전한 코드도 막힌다. "-default-isolation MainActor는 성능을 해친다"도 단정할 수 없다 — 실행자 전환이 줄어 유리한 경우가 많다. 다만 무거운 작업이 메인에 남으면 확실히 나쁘다. "동시성은 어려우니 @MainActor를 다 붙이면 된다"도 위험하다 — 그건 동시성을 포기하는 것이고, 무거운 작업까지 메인에 오면 화면이 멈춘다. "Swift 5 모드면 안전하다"도 틀렸다 — 검사가 없을 뿐 경쟁은 그대로 있다.
같은 코드 두 줄을 네 가지 방식으로 검사해 봤다.
첫 번째는 아무 말도 안 한다. 위험이 없어서가 아니라 안 보고 있어서다.
두 번째는 "이거 나중에 문제 됩니다"라고 미리 알려준다.
세 번째는 아예 막는다. 여기서 사람들이 힘들어했다 — 막히는 게 너무 많았다.
네 번째가 재밌다. 코드는 한 글자도 안 바꿨는데 다시 통과한다. 뭘 바꿨냐면 "기본 규칙"이다.
원래 기본은 "이 물건은 아무나 만질 수 있다"였다. 그러니 전부 다 검사해야 했다. 새 기본은 "이 물건은 한 사람만 만진다"다. 그러면 대부분은 검사할 게 없어지고, 여럿이 만져야 하는 것만 따로 표시하면 된다.
중요한 건 이게 "검사를 느슨하게 한 게 아니다"는 점이다. 안전은 그대로고, 기본값을 현실에 맞게 바꾼 것이다. 대신 오래 걸리는 일은 꼭 밖으로 빼야 한다 — 안 그러면 화면이 멈춘다.
꼬리 질문
@unchecked Sendable을 정당하게 쓸 수 있는 경우는?
안전이 코드 밖에서 보장되고, 컴파일러가 그것을 볼 수 없을 때다. 셋 정도가 실재한다.
(1) 내부에서 락으로 보호되는 타입. 컴파일러는 락과 보호 대상의 관계를 모른다.
/// 모든 접근이 `lock` 으로 보호된다. `value` 는 lock 없이 절대 접근하지 않는다.
final class Counter: @unchecked Sendable {
private let lock = NSLock()
private var value = 0 // ← lock 이 보호
func increment() { lock.lock(); defer { lock.unlock() }; value += 1 }
func get() -> Int { lock.lock(); defer { lock.unlock() }; return value }
}(2) 생성 후 불변인데 컴파일러가 모르는 타입. var로 선언됐지만 초기화 이후 절대 안 바뀌는 경우.
(3) C·Objective-C 타입 래핑. 문서상 스레드 안전한 외부 타입.
지켜야 할 규율이 있다.
- 왜 안전한지 주석으로 남긴다 — 다음 사람이 깨뜨리지 않도록.
- 범위를 최소화한다 — 타입 전체가 아니라 꼭 필요한 것만.
- 목록을 관리한다 — 언젠가 정식 방법으로 옮길 후보다.
Q1·Q2를 배운 지금은 sending이나 region으로 해결되는지 먼저 확인해야 한다. 예전에 @unchecked가 필요했던 많은 경우가 이제 그냥 통과한다.
레거시 코드가 많은 프로젝트는 어떤 순서로 옮기는가?
의존성 그래프의 잎(leaf)부터다. 그래야 변경이 위로 퍼지지 않는다.
실제 순서는 이렇다.
(1) 의존 그래프를 그린다. 어떤 모듈이 무엇에 의존하는지 파악한다.
# SPM 프로젝트라면
swift package show-dependencies --format tree
# Xcode 프로젝트의 타깃 의존 관계
xcodebuild -list -project App.xcodeproj(2) 잎 모델·유틸리티 모듈부터 경고를 켠다. 대개 Sendable 준수 추가만으로 끝난다.
(3) 진단 빈도를 세어 패턴을 찾는다. 한 가지 유형이 대부분인 경우가 흔하다.
(4) 경계 타입을 먼저 정리한다. 모듈 사이를 오가는 타입을 Sendable로 만들면 양쪽 진단이 함께 준다.
(5) UI 계층에 @MainActor·기본 격리를 적용한다. 가장 마지막이다 — 가장 크고 가장 많이 얽혀 있다.
흔한 실패는 앱 타깃부터 켜는 것이다. 의존이 전부 모여 있어 수천 개의 진단이 한꺼번에 나오고, 어디서부터 손댈지 알 수 없게 된다.
P1 21장 Q6에서 다룬 조용한 실패를 빌드 실패로 만드는 원칙과 같은 방향이다 — 되돌릴 수 있는 작은 단계로 나눈다.
이 다음에는 무엇이 올 것으로 보이는가?
확정된 방향과 추정을 구분해서 말하는 것이 맞다.
이미 제안·구현된 것(사실).
- SE-0461 —
nonisolatedasync의 실행 위치 변경. Swift 6.2에 업커밍 기능으로 들어와 있다. - SE-0466 — 모듈 기본 격리 지정(
-default-isolation). 위 실험으로 동작을 확인했다. - SE-0431 —
@isolated(any). 표준 라이브러리가 이미 사용 중이다.
방향으로 읽히는 것(추정).
- 거짓 양성 축소의 지속 — region 분석이 더 정교해지는 흐름. Q1에서 본 대로 보수적으로 막히는 경우가 아직 있다.
- 진단 품질 개선 — Q2 꼬리질문에서 본
task-isolated/local nonisolated구분처럼, 해법을 가리키는 방향. - 동시성과 소유권의 결합 —
consuming·borrowing과sending이 같은 뿌리(선형 타입)를 갖는다.
확실하지 않은 것은 확실하지 않다고 말해야 한다. 로드맵은 공개된 제안(swift-evolution)으로 확인하는 것이 정확하고, 블로그·발표의 추정은 그렇게 표시해야 한다.
이 챕터에서 반복해서 본 것이 있다. 언어는 "개발자가 실제로 쓰는 방식"에 맞춰 기본값을 조정한다. 검사를 약하게 하지 않으면서 부딪히는 횟수를 줄이는 방향이다.
🎓 P2를 마치며 — 무엇을 얻었는가
27~35장은 "CS 개념이 iOS의 구체적 동작으로 어떻게 나타나는가"를 아홉 갈래로 따라갔다.
- 27~30장 — 표현의 문제. 유니코드·로케일·시간·부동소수점은 모두 "현실을 유한한 비트로 어떻게 담는가"의 다른 얼굴이다. 답은 매번 "완벽히는 못 담는다"였고, 중요한 건 어디서 어긋나는지 아는 것이었다.
- 31~32장 — 경계의 문제. 직렬화와 타입 설계는 "무엇을 약속하고 무엇을 감출 것인가"다. 스키마 진화와 API 진화가 같은 문제라는 것이 요지였다.
- 33~34장 — 기계의 문제. ABI와 상호운용은 추상화 아래에 실제로 무엇이 있는지를 본다. SIL을 읽고 isa 포인터를 확인하는 능력이 추측을 사실로 바꾼다.
- 35장 — 증명의 문제. 동시성 안전성은 컴파일러가 무엇을 증명할 수 있는가의 이야기였고, 그 한계와 대응이 언어를 움직이고 있다.
공통점이 하나 있다. 이 장들의 주장은 대부분 직접 확인할 수 있었다. swiftc로 컴파일하고, 출력을 세고, SIL을 읽었다. P0·P1에서 쌓은 도구가 여기서 검증 수단이 됐다.
그것이 이 코스가 궁극적으로 전하려는 것이다 — 모르는 것을 만났을 때 확인하는 방법. 외운 답은 낡지만, 재는 법은 낡지 않는다.