이 챕터는 오래됐지만 여전히 실무에서 매일 쓰는 네 가지 시스템 유틸을 다룬다 — 캐시(NSCache), 타이머(Timer), 동시성 도구(락과 Operation), 그리고 질의(Predicate와 Regex)다. 공통점은 하나다. 겉으로는 단순한 API처럼 보이지만, 그 아래에는 메모리 압박 신호, RunLoop 모드, 우선순위 역전, KVC 같은 구체적인 시스템 메커니즘이 숨어 있고, 그 메커니즘을 모르면 "가끔씩만" 재현되는 버그를 만든다. 이 강의의 마지막 챕터이니, 실제로 실행해서 확인한 수치와 진짜 에러 메시지 위주로 진행한다.
Q1. NSCache는 Dictionary와 근본적으로 무엇이 다른가? countLimit이 "엄격한 제한이 아닌" 이유는?
NSCache는 Dictionary에 없는 세 가지를 얹은 캐시 전용 컬렉션이다. 메모리가 부족해지면 시스템이 알아서 항목을 비워주고(자동 축출), 여러 스레드에서 락 없이 접근해도 안전하며, NSMutableDictionary와 달리 키를 복사하지 않는다. countLimit/totalCostLimit은 "이 정도만 유지하고 싶다"는 힌트일 뿐 계약이 아니다 — 한도를 넘어도 항목이 즉시·나중에·혹은 영원히 안 지워질 수 있다. 그러니 캐시에 남아 있어야만 정확한 데이터는 애초에 NSCache에 넣으면 안 된다.
원리
Apple 공식 문서는 NSCache를 "일시적으로 낮은 우선순위인 key-value 쌍을 임시 저장하는 mutable 컬렉션이며, 리소스가 부족할 때 축출 대상이 되는" 컬렉션으로 정의한다. Dictionary는 이런 개념 자체가 없다 — 넣은 건 직접 지우기 전까지 그대로 있다. NSCache는 "값이 없어도 다시 계산하거나 다시 받아오면 되는, 비싸지만 대체 가능한 데이터"를 위한 것이지 정본 저장소가 아니다.
내부 동작
세 가지 차이를 하나씩 보면: 자동 축출은 다른 앱이나 시스템이 메모리를 필요로 할 때 캐시가 스스로 항목을 비워 메모리 사용량을 줄이는 것이다. Dictionary라면 메모리 워닝을 받아 개발자가 직접 정리 로직을 짜야 한다. 스레드 안전성은 "다른 스레드에서 항목을 추가·제거·조회할 때 직접 락을 걸 필요가 없다"는 것 — NSMutableDictionary를 여러 스레드에서 동시에 건드리면 직접 동기화해야 하는 것과 대조된다. 키를 복사하지 않는다는 것은 NSMutableDictionary가 키 객체에 copy(with:)를 호출해 복사본을 저장하는 것과 달리, NSCache는 넘긴 키 객체 자체를 그대로 참조로 들고 있어 오버헤드가 적다는 뜻이다.
countLimit 프로퍼티의 원문은 이렇다: "This is not a strict limit—if the cache goes over the limit, an object in the cache could be evicted instantly, later, or possibly never, depending on the implementation details of the cache." (Apple: countLimit) 기본값은 0(무제한)이고, 축출 순서도 문서화돼 있지 않다 — LRU라고 가정하면 안 된다. totalCostLimit은 개수가 아니라 setObject(_:forKey:cost:)로 매긴 비용(cost) 합계를 기준으로 축출한다. NSDiscardableContent 프로토콜을 구현한 객체는 캐시가 제거할 때 discardContentIfPossible()을 자동으로 호출해준다 — 항목 전체를 지우는 대신 무거운 내부 콘텐츠만 버릴 수 있게 하는 훅이다.
실험 · 도구
countLimit을 낮게 잡고, 메모리 압박을 전혀 주지 않은 채로 한도보다 훨씬 많이 넣어 실제로 몇 개가 남는지 확인했다.
let cache = NSCache<NSString, NSNumber>()
cache.countLimit = 3
for i in 1...10 {
cache.setObject(NSNumber(value: i), forKey: "key\(i)" as NSString)
}
var stillThere = 0
for i in 1...10 {
if cache.object(forKey: "key\(i)" as NSString) != nil { stillThere += 1 }
}
print("countLimit=3인데 실제로 남아있는 항목 수:", stillThere) // 3
var dict: [String: Int] = [:]
for i in 1...10 { dict["key\(i)"] = i }
print("Dictionary는 10개 다 그대로:", dict.count) // 10실행 결과는 countLimit=3인데 실제로 남아있는 항목 수: 3, Dictionary는 10개 다 그대로: 10이었다. 여기서 중요한 건 "3개로 줄었다"는 숫자 자체가 아니라, 그 3이 무엇을 근거로 정해진 값인지 API가 전혀 보장하지 않는다는 점이다. 문서 그대로 "instantly, later, or possibly never"이므로, 다른 실행에서는 5개가 남을 수도 있고 메모리 압박이 실제로 없는 환경에서는 10개가 그대로 남을 수도 있다 — 이 결과는 "축출이 실제로 일어날 수 있다"는 존재 증명이지, 몇 개가 남는지에 대한 보장이 아니다.
프로젝트 적용
① NSCache의 제네릭 파라미터는 where KeyType: AnyObject, ObjectType: AnyObject라 값 타입을 직접 못 넣는다. String 키는 NSString으로 브리지해서 쓰는 게 관용적인 패턴이다.
final class ImageCache {
static let shared = ImageCache()
private let cache = NSCache<NSString, UIImage>()
func image(for key: String) -> UIImage? {
cache.object(forKey: key as NSString)
}
func insert(_ image: UIImage, for key: String, cost: Int) {
// 디코딩된 픽셀 바이트 수를 cost로 넘기면 totalCostLimit이 더 의미 있게 동작한다
cache.setObject(image, forKey: key as NSString, cost: cost)
}
}② "캐시에 있어야만 정확한" 데이터는 절대 NSCache에 두면 안 된다. 로그인 세션이나 아직 서버에 반영 안 된 임시 상태처럼, 사라지면 안 되는 값은 Dictionary(+ 직접 관리) 혹은 파일/UserDefaults로 가야 한다.
// ❌ 나쁜 예 — 이 값이 사라지면 사용자가 방금 입력한 내용이 통째로 날아간다
let draftCache = NSCache<NSString, NSString>()
draftCache.setObject(userDraftText as NSString, forKey: "draft" as NSString)
// ✅ 좋은 예 — 서버에서 다시 받아오면 그만인 디코딩된 썸네일만 캐시에 둔다
let thumbnailCache = NSCache<NSString, UIImage>()
thumbnailCache.setObject(decodedThumbnail, forKey: url.absoluteString as NSString)"NSCache는 그냥 스레드 세이프한 Dictionary다"는 틀렸다 — 자동 축출과 키 비복사는 Dictionary에 없는 별개 동작이다. 반대로 "countLimit을 설정하면 그 이상은 절대 못 들어간다"도 틀렸다. 위 실험처럼 한도를 넘는 삽입 자체는 항상 성공하고, 축출은 시스템이 판단할 때 일어나는 별개의 사후 조치일 뿐이다 — 심지어 메모리 압박이 전혀 없으면 축출이 아예 안 일어나 한도보다 많은 항목이 계속 남아 있을 수도 있다.
Dictionary는 내 방에 있는 개인 서랍이다. 아무도 손 안 대니 넣은 건 내가 치우기 전까지 그대로 있다. NSCache는 기숙사 공용 냉장고와 비슷하다. 내가 음식을 넣어두면 평소엔 잘 있지만, 냉장고가 꽉 차서 다른 사람 음식이 안 들어가면 관리인(시스템)이 알아서 오래된 것부터 — 혹은 아무 기준으로 — 몇 개를 치워버릴 수 있다. 비유가 깨지는 곳: 진짜 냉장고 관리인은 보통 "오래된 순서"라는 규칙을 따르지만, NSCache의 축출 규칙은 애플조차 "어떤 순서인지" 공개하지 않은 구현 세부사항이라, LRU라고 믿고 설계하면 안 된다.
꼬리 질문
countLimit을 0(기본값)으로 두면 메모리 압박이 없는 한 무한정 쌓이기만 하는가?
countLimit = 0은 "개수 제한 없음"을 뜻하고, 축출은 어디까지나 시스템이 메모리 압박 신호를 보낼 때 일어나는 반응이지 주기적인 정리 정책이 아니다. 압박 신호가 한 번도 안 오는 짧은 세션이라면 캐시는 계속 커지기만 할 수 있다 — 그래서 크기가 예측 가능한 데이터라면 countLimit이나 totalCostLimit을 명시적으로 주는 게 안전하다.NSCache에 Codable 구조체를 직접 넣을 수 있는가?
NSCache<KeyType, ObjectType>는 두 제네릭 파라미터 모두 AnyObject 제약이 걸려 있어 struct/enum 같은 값 타입은 애초에 타입 체크를 통과하지 못한다. 값 타입 데이터를 캐시하려면 클래스로 감싼 래퍼를 만들거나, NSNumber/NSString처럼 이미 브리지되는 Foundation 클래스를 쓰거나, 캐시 계층 자체를 Dictionary + 직접 스레드 안전성 관리로 대체해야 한다.NSDiscardableContent를 구현하면 무엇이 달라지는가?
discardContentIfPossible()만 호출해 무거운 내부 데이터(예: 디코딩된 픽셀 버퍼)만 버리고 가벼운 메타데이터는 남겨둘 수 있게 된다. 항목이 통째로 사라지는 것보다 더 세밀한 단계적 메모리 절약이 가능해진다.Q2. 스크롤 중 Timer가 멈추는 이유는? — RunLoop .default vs .common 모드
UIKit은 사용자가 스크롤·드래그하는 동안 메인 스레드의 RunLoop를 .tracking 계열 모드로 바꿔 그 동안은 입력 이벤트만 처리한다. Timer.scheduledTimer로 만든 타이머는 기본적으로 .default 모드에만 등록되므로, 그 모드가 아닌 트래킹 모드에서는 아예 "안 보이는" 입력 소스가 되어 발화가 멎는다. 해법은 타이머를 .common(여러 구체 모드를 한 번에 아우르는 pseudo-mode)에 명시적으로 추가하는 것이다.
RunLoop 자체의 동작 원리 — 입력 소스, 타이머, 모드가 어떻게 이벤트 루프를 이루는지 — 는 iOS CS 로드맵 07장에서 다뤘다. 여기서는 그 모드 개념이 Timer 스케줄링에 정확히 어떤 결과를 만드는지에 집중한다.
원리
Timer는 실시간 메커니즘이 아니다. Apple 문서는 "타이머의 예정된 발화 시각이 긴 런루프 콜아웃 도중이거나, 런루프가 그 타이머를 모니터링하지 않는 모드에 있으면, 런루프가 다음에 체크할 때까지 발화하지 않는다"고 명시한다. 즉 타이머 발화는 "그 타이머가 등록된 모드로 런루프가 실제로 돌고 있을 때"라는 전제 조건이 있다. .default는 "connection 객체 외의 입력 소스를 처리하는" 일반적인 모드이고, .common은 그 자체가 실제 모드가 아니라 "다른 여러 모드를 포함하는 pseudo-mode"다(Apple: RunLoop.Mode). 스크롤·드래그 같은 사용자 트래킹은 별도의 트래킹 모드에서 처리되는데, UIKit은 이 트래킹 모드를 "common modes" 집합에 미리 등록해둔다 — 그래서 .common에 추가한 입력 소스는 트래킹 모드에서도 자동으로 살아남는다.
내부 동작
tolerance는 예정된 발화 시각 이후 얼마나 늦게 발화해도 되는지 지정하는 값이고(기본 0), 발화 시각보다 일찍 발화하는 일은 없다. 반복 타이머는 이 tolerance와 무관하게 "원래 발화 시각"을 기준으로 다음 발화 시각을 계산해 드리프트를 방지한다 — 그래서 애플은 반복 타이머에 interval의 최소 10%를 tolerance로 주는 것을 전력 절약 차원에서 권장한다. 또 하나 흔한 함정은 리테인 사이클이다: 런루프가 타이머를 강하게 참조하고, target: 파라미터로 만든 타이머는 다시 그 target을 강하게 참조한다. target이 self이고 self가 그 타이머를 프로퍼티로 들고 있으면 순환 참조가 생겨 invalidate()를 부르기 전까진 self가 해제되지 않는다. 클로저 기반 API(Timer.scheduledTimer(withTimeInterval:repeats:block:))를 쓰고 [weak self]로 캡처하면 이 문제를 피할 수 있다.
실험 · 도구
UIKit 스크롤을 터미널에서 그대로 재현할 수는 없지만, 그 원인인 "런루프가 지금 어떤 모드로 도는가"는 macOS 커맨드라인에서도 똑같이 검증할 수 있다. 커스텀 모드를 하나 만들고, 그 모드로만 런루프를 잠깐 돌려서 "스크롤 중"을 흉내냈다.
let trackingLikeMode = RunLoop.Mode("com.example.trackingLike")
var defaultOnlyFired = false
var multiModeFired = false
let timerDefaultOnly = Timer(timeInterval: 0.05, repeats: false) { _ in defaultOnlyFired = true }
let timerMultiMode = Timer(timeInterval: 0.05, repeats: false) { _ in multiModeFired = true }
RunLoop.main.add(timerDefaultOnly, forMode: .default) // .default에만 등록
RunLoop.main.add(timerMultiMode, forMode: .default)
RunLoop.main.add(timerMultiMode, forMode: trackingLikeMode) // .common이 실제로 하는 일 — 여러 구체 모드에 동시 등록
// 런루프를 트래킹 모드로만 0.2초 돌린다 — 스크롤 중과 같은 상황
RunLoop.main.run(mode: trackingLikeMode, before: Date().addingTimeInterval(0.2))
print("결과 — default 전용 발화:", defaultOnlyFired, "/ 여러 모드 등록 발화:", multiModeFired)
// 결과 — default 전용 발화: false / 여러 모드 등록 발화: true런루프가 trackingLikeMode로만 도는 0.2초 동안, .default에만 등록된 타이머는 끝내 발화하지 않았고(false), 두 모드 모두에 등록된 타이머는 발화했다(true). .common은 정확히 이 "여러 구체 모드에 동시 등록"을 대신 해주는 pseudo-mode이고, iOS에서는 스크롤 트래킹 모드가 미리 그 common 집합에 들어 있어 실제 앱에서도 같은 메커니즘이 작동한다.
프로젝트 적용
① 스크롤 중에도 계속 돌아야 하는 타이머는 생성 직후 .common에 명시적으로 추가한다.
final class TickerView {
private var timer: Timer?
func start() {
let t = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
self?.tick()
}
t.tolerance = 0.1 // interval의 10% — 전력 절약
RunLoop.main.add(t, forMode: .common) // 스크롤 중에도 계속 발화
timer = t
}
func tick() { /* ... */ }
deinit { timer?.invalidate() } // target-retain 사이클 방지
}② RunLoop 모드와 무관하게 항상 정확한 주기로 돌아야 하는 백그라운드 작업이라면, 애초에 RunLoop 기반 Timer 대신 GCD 큐에서 도는 DispatchSourceTimer를 쓰는 게 더 단순하다. 구조적 취소와 async 컨텍스트가 필요하면 Task.sleep 루프도 대안이다(다만 Timer의 tolerance 같은 드리프트 보정은 직접 관리해야 한다).
func makeBackgroundTicker() -> DispatchSourceTimer {
let t = DispatchSource.makeTimerSource(queue: .global())
t.schedule(deadline: .now() + 1, repeating: 1)
t.setEventHandler { print("백그라운드 큐에서 항상 정시 발화") }
t.resume()
return t
}"Timer는 지정한 시각에 정확히 발화한다"는 틀렸다 — 런루프가 바쁘거나 그 타이머를 모니터링하지 않는 모드에 있으면 지연되거나 건너뛰어진다. ".common은 특수한 모드 하나다"도 부정확하다 — .common은 실제 모드가 아니라 "지금 common으로 등록된 여러 구체 모드"로 펼쳐지는 집합 개념이다. 그리고 "스크롤하면 앱 전체가 멈춘다"도 틀렸다. 실제로는 런루프가 트래킹 모드로 전환돼 그 모드에 등록 안 된 입력 소스만 무시되는 것이지, 앱이 통째로 정지하는 게 아니다.
선생님이 교실에서 "지금은 수학 시간 모드"라고 선언하면 국어 관련 안내 방송(.default 전용 타이머)은 못 듣고 지나친다. 그런데 "화재 경보"(.common 등록)는 무슨 시간이든 항상 들리게 미리 설정해뒀다. 스크롤은 "지금은 트래킹 시간"이라고 선언하는 것과 같아서, 거기 맞춰 미리 등록해두지 않은 타이머는 그 시간 동안 안내가 안 들리는 것뿐이다. 비유가 깨지는 곳: 교실 방송은 사람이 늘 정해진 스케줄대로 켜고 끄지만, RunLoop 모드 전환은 UIKit이 터치 이벤트 처리 도중 아주 빈번하게(매 스크롤마다) 자동으로 켜고 끄는 훨씬 촘촘한 전환이다.
꼬리 질문
Timer를 .common에 등록하면 배터리 소모가 더 커지지 않는가?
timeInterval마다 한 번만 발화한다. 다만 스크롤 중에도 안 멎고 계속 돈다는 건 그만큼 CPU 웨이크업이 더 자주 일어난다는 뜻이므로, 꼭 스크롤 중에도 필요한 타이머(예: 애니메이션 진행률 표시)에만 .common을 쓰고 그렇지 않은 건 .default로 남겨 불필요한 웨이크업을 줄이는 게 정석이다.블록 기반 Timer.scheduledTimer(withTimeInterval:repeats:block:)도 target-retain 문제가 있는가?
self를 강하게 캡처하면 여전히 같은 문제가 생긴다. 다만 target-selector 방식과 달리 클로저 캡처 리스트를 직접 제어할 수 있어 [weak self]로 캡처하면 순환 참조를 피할 수 있다는 점이 다르다. 어느 API든 타이머 자체를 invalidate()하지 않으면 런루프가 타이머를 계속 붙잡고 있다는 사실은 동일하다.DispatchSourceTimer는 UI 갱신에 써도 되는가?
DispatchQueue.main.async로 명시적으로 메인 큐로 옮겨야 한다. Timer는 애초에 RunLoop 위에서 돌기 때문에 메인 스레드에 스케줄하면 핸들러가 자연히 메인에서 실행되는 것과 대조적이다.Q3. os_unfair_lock / NSLock / DispatchSemaphore는 우선순위 역전 관점에서 어떻게 다른가?
os_unfair_lock과 NSLock(내부적으로 pthread_mutex 기반)은 우선순위 기부(priority donation)를 지원한다 — 고우선순위 스레드가 락을 기다리면, 그 락을 쥔 저우선순위 스레드의 우선순위를 일시적으로 끌어올려 빨리 끝내고 넘겨주게 만든다. DispatchSemaphore는 이 기부를 하지 않아서, 저우선순위 스레드가 세마포어를 쥔 채로 중간 우선순위 스레드에 CPU를 뺏기면 고우선순위 스레드가 하염없이 굶는 고전적인 우선순위 역전이 일어날 수 있다. 새 코드에서 상호 배제가 필요하면 락을 직접 잡는 대신 actor로 설계하는 게 가장 안전하다.
원리
우선순위 역전(priority inversion)이란, 저우선순위 스레드가 공유 자원(락)을 쥔 채 있는데 고우선순위 스레드가 그 자원을 기다리며 멈춰버리고, 그사이 중간 우선순위 스레드가 스케줄러에 의해 계속 CPU를 차지해 저우선순위 스레드조차 실행을 못 해 락을 못 풀어주는 상황이다. 결과적으로 우선순위가 가장 높은 스레드가 자기보다 낮은 두 스레드 때문에 무기한 대기하게 된다. 이를 막는 표준 기법이 우선순위 기부다 — 락을 기다리는 고우선순위 스레드가 자신의 우선순위를 락 보유자에게 잠시 "빌려줘서" 보유자가 중간 우선순위 스레드를 제치고 빨리 끝내게 만든다.
내부 동작
os_unfair_lock은 iOS 10/macOS Sierra부터 제공되는 가장 가벼운 저수준 블로킹 락으로, 우선순위 역전 문제로 폐기된 OSSpinLock의 대체제이며 우선순위 기부를 지원한다. 단, 구조체이므로 절대 복사되면 안 된다 — Swift의 var는 별생각 없이 복사될 수 있어 조용히 서로 다른 락 인스턴스로 갈라지는 버그가 나기 쉽다. 이 문제 때문에 애플은 클래스로 감싼 OSAllocatedUnfairLock을 제공한다. NSLock은 POSIX 스레드 기반이며, 락을 건 스레드가 아닌 다른 스레드에서 unlock하면 undefined behavior이고, 같은 스레드가 두 번 lock하면 데드락에 빠진다(재진입이 필요하면 NSRecursiveLock). DispatchSemaphore는 "호출 스레드가 블록될 필요가 있을 때만 커널을 호출"하는 효율적인 카운팅 세마포어지만, 우선순위를 기부하지 않는다는 점이 커뮤니티와 애플 엔지니어들 사이에서 반복적으로 지적된 함정이다 — 락 대용으로 쓰지 말라는 것이 정설이다.
실험 · 도구
우선순위 역전 자체를 재현하려면 실제 QoS가 다른 스레드 세 개가 필요해 번거롭다. 대신 더 기본적인 질문 — "무경합 상태에서 각 락의 순수 오버헤드는 얼마나 다른가" — 를 단일 스레드 루프로 직접 쟀다.
let iterations = 2_000_000
let nsLock = NSLock()
var counter1 = 0
// nsLock.lock(); counter1 += 1; nsLock.unlock() 을 200만 번 반복
var unfair = os_unfair_lock()
var counter2 = 0
// os_unfair_lock_lock(&unfair); counter2 += 1; os_unfair_lock_unlock(&unfair) 을 200만 번
let sem = DispatchSemaphore(value: 1)
var counter3 = 0
// sem.wait(); counter3 += 1; sem.signal() 을 200만 번
// 측정 결과 (macOS 26 / Apple Silicon, -O 빌드)
// NSLock: 12.5 ms
// os_unfair_lock: 4.2 ms
// DispatchSemaphore: 6.9 ms같은 조건에서 os_unfair_lock이 가장 빨랐고, NSLock이 가장 느렸다(ObjC 메시지 전송 오버헤드가 더해지는 것으로 보인다). 다만 이 수치는 무경합·단일 스레드 벤치마크일 뿐이라 우선순위 역전 여부와는 직접 관련이 없다 — 역전은 경합 상황에서 스케줄러가 어떤 스레드를 골라 실행하느냐의 문제이지, 락 자체의 처리 속도 문제가 아니다. 두 성질을 섞어서 "빠른 락이니 역전도 안 생긴다"고 착각하지 말아야 한다.
프로젝트 적용
① 짧은 임계구역을 보호할 때는 raw os_unfair_lock 대신 값 타입 복사 실수를 원천 차단하는 OSAllocatedUnfairLock을 쓴다.
import os
final class Counter {
private let lock = OSAllocatedUnfairLock(initialState: 0)
func increment() { lock.withLock { $0 += 1 } }
var value: Int { lock.withLock { $0 } }
}② 새로 짜는 동시성 코드라면 락을 직접 잡고 놓는 대신 actor로 옮기는 걸 우선 고려한다. actor는 컴파일러가 격리된 상태 접근을 직렬화해줘서 lock/unlock 짝을 손으로 맞출 필요가 없고, Swift 런타임의 협력형 스레드 풀이 전통적인 락 기반 코드보다 데드락·역전 가능성을 구조적으로 줄여준다. 자세한 내용은 Swift Concurrency 강의 참고.
actor CounterActor {
private var value = 0
func increment() { value += 1 }
func current() -> Int { value }
}"세마포어는 커널 콜이 있어서 무조건 락보다 느리다"는 과도한 일반화다 — 문서에 따르면 세마포어는 "블록될 필요가 있을 때만" 커널을 호출하므로 무경합 상황에서는 오히려 빠를 수 있다(위 실측에서도 NSLock보다 빨랐다). 세마포어를 피해야 하는 진짜 이유는 속도가 아니라 우선순위를 기부하지 않는다는 것이다. 또한 "os_unfair_lock 구조체를 var로 들고 있어도 안전하다"도 틀렸다 — 실수로 복사되면 서로 다른 락이 되어 상호 배제가 조용히 깨진다.
줄 서서 화장실을 기다리는 상황을 생각해보자. os_unfair_lock/NSLock은 "안에 있는 사람이 느긋한 사람(저우선순위)이어도, 밖에서 정말 급한 사람(고우선순위)이 기다리고 있으면 관리인이 안에 있는 사람더러 '빨리 좀 나와주세요'라고 재촉해주는" 시스템이다. DispatchSemaphore는 그런 재촉이 없어서, 안에 있는 느긋한 사람이 나오려는 순간 새치기하는 사람(중간 우선순위)이 계속 끼어들면 정작 제일 급한 사람은 하염없이 기다려야 한다. 비유가 깨지는 곳: 실제 화장실 재촉은 사람이 눈치껏 하지만, 우선순위 기부는 커널 스케줄러가 스레드의 실제 우선순위 값을 일시적으로 바꿔주는 기계적인 절차다.
꼬리 질문
actor를 쓰면 우선순위 역전이 완전히 사라지는가?
NSRecursiveLock은 왜 필요한가?
NSLock을 쓰면 그 스레드 자신이 자신을 막아 데드락에 빠진다. NSRecursiveLock은 내부에 카운트를 유지해 "같은 스레드의 재획득"은 허용하고, unlock을 lock 횟수만큼 다 호출해야 실제로 풀리게 한다.왜 DispatchSemaphore를 값이 1인 상호배제 락 대용으로 쓰면 안 되는가?
wait()를 비동기 컨텍스트(예: Swift Concurrency의 태스크)에서 잘못 쓰면 협력형 스레드 풀 전체를 블로킹시켜 다른 태스크들이 굶는 훨씬 심각한 문제로 번질 수 있다.Q4. Operation/OperationQueue는 Swift Concurrency 시대에 아직 쓸 자리가 있는가?
있다 — 다만 자리가 좁아졌다. 세밀한 의존성 그래프를 UI에서 실시간으로 재구성·취소해야 하거나, maxConcurrentOperationCount로 동시 실행 수를 조절해야 하거나, KVO 기반 진행률 바인딩이 이미 갖춰진 레거시 코드베이스에서는 Operation이 여전히 실용적이다. 반면 구조적 수명, 자동 취소 전파, 가독성이 중요한 새 코드는 async/await + TaskGroup이 기본 선택지다.
원리
Operation은 "작업 하나"를 객체로 표현하고, OperationQueue는 그 객체들을 우선순위·의존성·동시성 제한에 따라 실행하는 스케줄러다. addDependency(_:)로 "이 작업은 저 작업이 끝나야 시작한다"는 순서를 선언적으로 표현할 수 있고, maxConcurrentOperationCount를 1로 두면 사실상 직렬 큐가 된다. 이 모델은 async/await보다 먼저 나왔지만, "이미 큐에 들어간 작업을 취소한다", "의존성 그래프를 런타임에 다시 그린다" 같은 조작을 UI 코드에서 명령형으로 하기엔 여전히 편하다.
내부 동작
커스텀 비동기(concurrent) Operation 서브클래스는 start()를 오버라이드해야 하고 super를 호출하면 안 된다. isExecuting/isFinished를 직접 관리해야 하며, 이 상태 변화는 KVO로 통지해야 큐가 "이 작업이 끝났다"를 알아챈다 — 그래서 willChangeValue(forKey:)/didChangeValue(forKey:)를 손으로 짝 맞춰 불러야 하고, 이 통지는 어느 스레드에서든 발생할 수 있어 상태 변경 자체를 스레드 안전하게 만들어야 한다. 취소는 더 미묘하다: cancel()을 부르면 isCancelled가 true가 되지만, 실행이 자동으로 멎지는 않는다 — 오퍼레이션 내부 로직이 주기적으로 isCancelled를 직접 확인해서 스스로 멈춰야 한다. 이 두 가지(수동 KVO, 수동 취소 체크)가 Operation 서브클래싱이 실수하기 쉬운 지점이다.
실험 · 도구
이 상태 기계를 실제로 구현하고 큐에 태워 끝까지 실행해봤다.
final class AsyncFetchOperation: Operation, @unchecked Sendable {
private let lock = NSLock()
private var _isExecuting = false
private var _isFinished = false
override var isAsynchronous: Bool { true }
override var isExecuting: Bool {
get { lock.withLock { _isExecuting } }
set {
willChangeValue(forKey: "isExecuting")
lock.withLock { _isExecuting = newValue }
didChangeValue(forKey: "isExecuting")
}
}
override var isFinished: Bool {
get { lock.withLock { _isFinished } }
set {
willChangeValue(forKey: "isFinished")
lock.withLock { _isFinished = newValue }
didChangeValue(forKey: "isFinished")
}
}
override func start() {
if isCancelled { isFinished = true; return } // super 호출 안 함
isExecuting = true
DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, !self.isCancelled else { self?.isExecuting = false; self?.isFinished = true; return }
print("작업 완료")
self.isExecuting = false
self.isFinished = true
}
}
}
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
let op = AsyncFetchOperation()
queue.addOperation(op)
queue.waitUntilAllOperationsAreFinished()
print("큐 완료, isFinished =", op.isFinished)
// 실행 결과:
// 작업 완료
// 큐 완료, isFinished = trueKVO 통지를 정확히 짝 맞춰야만 waitUntilAllOperationsAreFinished()가 실제로 끝난 시점에 반환된다 — 통지를 빠뜨리면 큐는 이 작업이 끝난 줄 모르고 영원히 기다리거나, 반대로 isFinished를 직접 읽는 다른 코드가 아직 최신 값을 못 보는 경합이 생길 수 있다.
프로젝트 적용
① 순서가 있는 여러 단계(다운로드 → 파싱 → 렌더링)를 UI에서 취소·재시도까지 가능한 형태로 표현하고 싶다면 의존성 그래프가 여전히 직관적이다.
let queue = OperationQueue()
let fetchOp = BlockOperation { /* 다운로드 */ }
let parseOp = BlockOperation { /* 파싱 */ }
let renderOp = BlockOperation { /* 렌더링 */ }
parseOp.addDependency(fetchOp) // fetch가 끝나야 parse 시작
renderOp.addDependency(parseOp) // parse가 끝나야 render 시작
queue.addOperations([fetchOp, parseOp, renderOp], waitUntilFinished: false)② 반대로 단순한 순차/병렬 비동기 흐름이라면 Swift Concurrency가 구조적 취소와 가독성에서 확실히 이긴다. 새 코드는 기본을 TaskGroup으로 잡고, 위와 같은 명시적 의존성 그래프가 정말 필요할 때만 Operation을 예외적으로 남긴다.
func pipeline() async throws {
let data = try await fetch()
let parsed = try await parse(data)
try await render(parsed)
// 상위 Task가 취소되면 각 await 지점에서 자동으로 CancellationError 전파
}"operation.cancel()을 부르면 실행 중이던 작업이 즉시 멈춘다"는 틀렸다. isCancelled 플래그만 켜질 뿐이고, 그 작업의 main()/start() 안에서 직접 그 플래그를 확인해 스스로 리턴하지 않으면 계속 실행된다. "Operation은 이제 완전히 죽은 API다"도 틀렸다 — Objective-C/UIKit 상호운용, KVO 기반 진행률 UI, 세밀한 의존성 조작이 필요한 코드에서는 여전히 실용적인 선택이다.
Operation은 "할 일 목록을 압정으로 순서대로 코르크판에 붙여두는" 방식이다. 압정 사이에 실을 이어서 "이거 끝나야 저거 시작"이라는 순서를 눈으로 보면서 재배치할 수 있다. Swift Concurrency(async/await)는 그보다는 "요리 레시피를 순서대로 읽어 내려가는" 방식에 가깝다 — 읽는 순서 자체가 실행 순서라 훨씬 직관적이지만, 레시피 중간에서 "3번과 5번만 따로 떼서 취소"하는 식의 자유로운 재배치는 압정 방식만큼 쉽지 않다. 비유가 깨지는 곳: 코르크판의 압정을 "취소"해도 이미 시작한 요리(실행 중인 코드)가 저절로 멈추진 않는다 — 요리사(작업 내부 로직)가 스스로 "어, 취소됐네" 하고 알아채야 멈춘다.
꼬리 질문
동기(non-concurrent) Operation도 isExecuting/isFinished를 손으로 관리해야 하는가?
main()만 오버라이드하는 일반 동기 Operation은 큐가 main()을 호출하고 그것이 리턴하면 자동으로 완료 처리를 해준다. 수동 KVO 관리가 필요한 건 start()를 직접 오버라이드하고 isAsynchronous를 true로 선언하는, 백그라운드 콜백 안에서 나중에 끝나는 비동기 서브클래스에 한정된다.OperationQueue의 underlyingQueue는 무엇을 위한 것인가?
Operation과 TaskGroup을 한 파이프라인 안에서 섞어 쓸 수 있는가?
withCheckedContinuation으로 Operation의 완료 콜백을 async 세계로 브리지하면 된다. 다만 그렇게 하는 순간 Operation이 주는 이점(취소·의존성의 명령형 제어)과 TaskGroup이 주는 이점(구조적 취소 전파)이 서로 다른 모델이라 정합성을 신경 써서 관리해야 한다. 신규 코드라면 처음부터 한쪽으로 통일하는 게 유지보수에 더 낫다.Q5. Foundation Predicate 매크로는 NSPredicate와 무엇이 다른가?
#Predicate는 클로저를 컴파일 타임에 매크로가 파싱해 타입 있는 표현식 트리(PredicateExpression)로 바꾼다 — 클로저 안 코드는 Swift로 실행되는 게 아니라 그 트리로 컴파일된다. 그래서 존재하지 않는 프로퍼티를 참조하면 컴파일이 아예 안 된다. NSPredicate는 포맷 문자열을 런타임에 파싱하고 KVC(valueForKey:)로 평가하므로, 키 이름 오타 같은 실수가 컴파일은 통과하고 실행 중에야, 그것도 크래시로 드러난다.
원리
Predicate<T>는 "입력값 집합을 검색·필터링용으로 테스트하는 논리 조건"을 표현하는 구조체이고, #Predicate<Book> { $0.year > 1990 }처럼 매크로로 만든다(Apple: Predicate, iOS 17+). 지원 연산은 산술·비교·불리언 논리·범위·옵셔널(?? if-let)·타입 체크·시퀀스 연산(allSatisfy filter contains)까지지만, 중첩 선언이나 for 같은 흐름 제어, 바깥 스코프 변수 수정은 지원하지 않는다 — 지원되는 부분집합만 컴파일된다는 것 자체가 타입 안전성의 근거다. NSPredicate는 NSPredicate(format:)처럼 사람이 손으로 쓴 문자열을 런타임에 파싱하는 오래된 방식으로, 여전히 모든 애플 플랫폼에서 동작하지만 Apple 문서는 신형 대체제로 Predicate를 소개한다.
내부 동작
결정적인 차이는 "클로저가 실행되느냐, 컴파일되느냐"다. #Predicate의 클로저 본문은 Swift 프로그램의 일부로 돌아가는 게 아니라, 매크로가 그 구문을 파싱해 별도의 표현식 트리로 변환한다. SwiftData는 이 트리를 SQL로 번역해 FetchDescriptor 쿼리에 그대로 쓴다. NSPredicate는 반대로 포맷 문자열("year > %d")을 evaluate 시점에야 파싱하고, 그 파싱 결과가 대상 객체에 valueForKey:를 호출하는 KVC 메커니즘으로 이어진다 — 이 말은 NSPredicate가 원래 KVC를 지원하는 객체(전형적으로 NSObject 서브클래스, 혹은 딕셔너리)를 겨냥해 설계됐다는 뜻이다. 순수 Swift 값 타입에 그대로 쓰면 KVC 자체가 성립하지 않아 다른 방식으로 실패한다.
실험 · 도구
세 가지를 직접 실행해 확인했다.
struct Book { var title: String; var year: Int }
// year를 yaer로 오타 — 실제 컴파일러 진단
let bad = #Predicate<Book> { $0.yaer > 1990 }
// error: value of type 'Book' has no member 'yaer'매크로가 클로저를 파싱하는 시점에 Book 타입 정보를 그대로 갖고 있기 때문에, 존재하지 않는 프로퍼티 참조는 일반 Swift 코드의 오타와 똑같은 컴파일 에러가 된다.
final class BookObj: NSObject {
@objc var title: String
@objc var year: Int
init(title: String, year: Int) { self.title = title; self.year = year }
}
let books = [BookObj(title: "SICP", year: 1985), BookObj(title: "TAPL", year: 2002)]
let good = NSPredicate(format: "year > %d", 1990)
let goodResult = (books as NSArray).filtered(using: good) as! [BookObj]
print(goodResult.map(\.title)) // ["TAPL"] — 정상 동작
let typo = NSPredicate(format: "yaer > %d", 1990) // 오타: yaer
_ = (books as NSArray).filtered(using: typo)
// *** Terminating app due to uncaught exception 'NSUnknownKeyException',
// reason: '... valueForUndefinedKey:]: this class is not key value
// coding-compliant for the key yaer.'실행해보면 정확한 키(year)는 문제없이 ["TAPL"]을 돌려주지만, 오타 키(yaer)는 컴파일은 통과했다가 filtered(using:)를 호출하는 순간 NSUnknownKeyException으로 크래시한다 — #Predicate였다면 애초에 빌드가 안 됐을 실수다. 덤으로 확인한 사실 하나: NSPredicate를 순수 Swift struct(NSObject가 아닌)에 그대로 적용하면 정확한 키를 써도 크래시한다 — KVC 자체가 NSObject 계열에서만 성립하기 때문이다. NSPredicate를 쓰려면 대상이 KVC 준수 객체여야 한다는 것이 실무에서 자주 간과되는 전제조건이다.
프로젝트 적용
① SwiftData 쿼리는 #Predicate가 1급 시민이다. FetchDescriptor에 그대로 넘긴다.
@Model
final class Note {
var title: String
var year: Int
init(title: String, year: Int) { self.title = title; self.year = year }
}
func fetchRecentNotes(context: ModelContext) throws -> [Note] {
let descriptor = FetchDescriptor<Note>(predicate: #Predicate { $0.year > 2020 })
return try context.fetch(descriptor)
}② Objective-C 상호운용이나 NSFetchRequest(Core Data) 같은 레거시 경계에서는 여전히 NSPredicate가 필요하다. 그 경계에서는 포맷 문자열을 손으로 이어붙이지 말고 %K(키패스)/%@(값) 치환을 써서 최소한의 안전장치라도 확보한다.
// ❌ 나쁜 예 — 키 이름을 문자열 안에 그대로 박아 오타에 취약하고 값 이스케이프도 안 된다
let bad = NSPredicate(format: "title == '\(searchText)'")
// ✅ 좋은 예 — %K는 키패스, %@는 값으로 치환되어 최소한 값 이스케이프는 안전해진다
let good = NSPredicate(format: "%K == %@", "title", searchText)"Predicate는 그냥 NSPredicate의 Swift 버전이다"는 부정확하다. 이름과 용도는 비슷하지만 내부 구현 방식 자체가 다르다 — 하나는 컴파일 타임 매크로가 만드는 표현식 트리이고, 다른 하나는 런타임 문자열 파서 + KVC다. 이 차이 때문에 #Predicate는 애초에 NSObject가 아닌 순수 Swift 타입에도 자연스럽게 쓸 수 있지만, NSPredicate는 KVC가 성립하는 대상에서만 제대로 동작한다.
#Predicate는 선생님이 시험 문제를 내기 전에 미리 답안지 형식을 검토해서 "이 문제는 애초에 답할 수 없는 질문이네요"라고 시험 시작 전에 걸러주는 것과 같다. NSPredicate는 일단 시험을 다 치르게 한 다음, 채점하다가 "어, 이 문제 자체가 말이 안 되네"라는 걸 그제서야 발견하는 것과 같다 — 그것도 조용히 넘어가는 게 아니라 채점 자체가 멈춰버리는(크래시) 식으로. 비유가 깨지는 곳: 실제 선생님은 문제를 미리 검토하는 데 시간이 걸리지만, 컴파일러의 사전 검토(매크로 확장)는 빌드 과정에 자연스럽게 녹아들어 개발자가 따로 시간을 들이지 않는다.
꼬리 질문
#Predicate 안에서 바깥 스코프의 변수를 참조할 수 있는가?
Core Data의 NSFetchRequest도 #Predicate를 쓸 수 있는가?
NSFetchRequest는 여전히 NSPredicate 기반이다. #Predicate가 1급 시민으로 통합된 것은 SwiftData 쪽이다. Core Data를 계속 쓴다면 NSPredicate의 %K/%@ 치환 패턴으로 안전성을 최대한 확보하는 게 현실적인 절충안이다.Predicate.evaluate(_:)가 throws인 이유는?
throws로 남겨둔 것이다.Q6. Swift Regex는 NSRegularExpression과 실무적으로 무엇이 다른가?
Swift Regex는 Swift String의 Character(extended grapheme cluster) 인덱스 위에서 그대로 동작해 타입 있는 캡처와 유니코드 정확성을 얻는다. NSRegularExpression은 NSString 기반 NSRange, 즉 UTF-16 code unit 위에서 동작하기 때문에, 이모지처럼 코드유닛을 여러 개 쓰는 문자에서 range를 Swift String 인덱스로 잘못 다루면 엉뚱한 위치를 가리키는 버그가 난다.
원리
Regex<Output>는 리터럴(/pattern/), 런타임 문자열(try Regex("...")), RegexBuilder DSL 세 가지 방식으로 만들 수 있다(iOS 16+). SE-0354는 그 동기를 이렇게 설명한다 — 예전 런타임 컴파일 방식은 문법 오류가 실행 중에야 드러나고, 캡처 타입이 컴파일 타임에 안 알려져 AnyRegexOutput을 강제로 써야 했다. 리터럴 도입으로 컴파일 타임 검증과 타입 추론된 캡처(예: Regex<(Substring, Substring)>)를 동시에 얻는다. NSRegularExpression은 ICU 정규식 사양을 따르는 오래된 클래스로, 매칭 결과를 NSTextCheckingResult의 range/range(at:)로 돌려준다 — 이 range는 처음부터 끝까지 NSString의 세계, 즉 UTF-16 코드 유닛 인덱스다.
내부 동작
Swift의 String은 Character 단위로 셈한다 — 사람이 보는 "글자 하나"에 해당하는 extended grapheme cluster다. 반면 NSString은 UTF-16 코드 유닛 단위로 길이를 잰다. 대부분의 라틴 문자·한글 음절은 1글자 = 1~수 코드유닛으로 큰 차이가 안 나 보이지만, 가족 이모지(👨👩👧👦) 같은 ZWJ(zero-width joiner) 시퀀스는 사람 눈엔 하나의 이모지(Character 1개)인데, 그 뒤에 유니코드 스칼라 여러 개가 ZWJ로 이어붙어 있어 UTF-16 코드유닛으로는 두 자릿수에 이른다. NSRegularExpression이 돌려주는 NSRange는 이 코드유닛 좌표계이므로, Swift String의 Character 개수 감각으로 그 range를 다루면(예: "14번째 문자부터"라고 착각) 완전히 다른 위치를 가리키게 된다. 네이티브 Regex는 애초에 String.Index를 그대로 쓰므로 이런 변환·불일치가 발생할 여지가 없다.
실험 · 도구
가족 이모지가 든 문자열로 두 좌표계 차이를 직접 확인했다.
let s = "가족👨👩👧👦 이모지"
// 네이티브 Regex — String.Index(Character 기반) 그대로 사용
let swiftRe = try! Regex("이모지")
if let m = s.firstMatch(of: swiftRe) {
print(m.0) // "이모지" — 그대로 서브스크립트 가능
}
print(s.count) // 7 (Character 단위)
// NSRegularExpression — NSString(UTF-16) 기준 range
let ns = s as NSString
print(ns.length) // 17 (UTF-16 code unit 단위)
let re = try! NSRegularExpression(pattern: "이모지")
if let match = re.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) {
print(match.range) // {14, 3} — UTF-16 좌표
}
let family = "👨👩👧👦"
print(family.count) // 1 (Character 하나)
print((family as NSString).length) // 11 (UTF-16 code unit 11개)측정 결과 String.count는 7인데 NSString.length는 17이었고, 그 차이 대부분은 가족 이모지 하나가 Character로는 1인데 UTF-16 코드유닛으로는 11이라는 데서 나왔다. NSRegularExpression이 돌려준 매치 range는 {14, 3}(UTF-16 좌표 기준 14번째 코드유닛부터 3개)이었는데, 만약 이 숫자를 "14번째 글자부터"로 오해해서 Swift String 인덱싱에 그대로 쓰면 가족 이모지가 차지하는 10개 코드유닛만큼 어긋난 완전히 다른 위치를 가리키게 된다. 실무에서 이런 변환은 보통 Range(match.range, in: s)로 안전하게 해야 하며, 그 변환 과정 자체가 네이티브 Regex에는 필요 없는 단계다.
프로젝트 적용
① NSRegularExpression + NSRange 변환 춤을 추던 단순 패턴 매칭은 대부분 네이티브 Regex로 옮기는 게 더 안전하고 짧다.
// ❌ 나쁜 예 — NSRange를 Swift Range로 매번 변환해야 하고 실수하기 쉽다
let text = "연락처: 010-1234-5678"
let nsText = text as NSString
let regex = try! NSRegularExpression(pattern: #"\d{3}-\d{4}-\d{4}"#)
if let m = regex.firstMatch(in: text, range: NSRange(location: 0, length: nsText.length)) {
let phoneRange = m.range
let phone = nsText.substring(with: phoneRange) // NSString API로만 안전하게 슬라이싱 가능
print(phone)
}
// ✅ 좋은 예 — String.Index를 그대로 쓰므로 변환 단계 자체가 없다
let phonePattern = try! Regex(#"\d{3}-\d{4}-\d{4}"#)
if let m = text.firstMatch(of: phonePattern) {
print(text[m.range]) // 바로 서브스크립트
}② 서버에서 동적으로 내려오는 패턴 문자열이나 ICU 전용 기능(일부 유니코드 프로퍼티 확장 등)이 필요하면 여전히 NSRegularExpression이 필요할 수 있다. 다만 대부분의 앱 내부 패턴은 Regex(_:) 런타임 초기화로도 충분히 커버되므로, "동적 패턴이니 NSRegularExpression"이라고 자동으로 판단하지 말고 실제로 어떤 기능이 필요한지 먼저 확인한다.
func makeValidator(pattern: String) throws -> Regex<AnyRegexOutput> {
try Regex(pattern) // 정적 리터럴이 아니어도 런타임에 그대로 생성 가능
}"Swift Regex가 NSRegularExpression보다 무조건 빠르다"는 과도한 일반화다. 공식 세션(WWDC22 "Meet Swift Regex")도 속도보다는 정확성과 타입 안전성, 인체공학을 우위로 설명한다. 실제 성능 이득은 주로 "UTF-16 인덱스 변환 비용이 아예 없다"는 데서 오는 것이지, 매칭 엔진 자체가 항상 더 빠르다는 뜻은 아니다. 패턴이 복잡하고 백트래킹이 많으면 어느 엔진이든 느려질 수 있다.
Swift String은 문자열을 "사람이 눈으로 읽는 글자" 단위로 센다 — 가족 이모지 하나는 그냥 글자 하나다. NSString은 컴퓨터 내부 저장 방식(UTF-16) 단위로 센다 — 같은 가족 이모지가 저장 공간을 11칸이나 차지한다. 두 셈법이 다른데, NSRegularExpression이 알려주는 "몇 번째부터"는 항상 두 번째(저장 공간) 셈법을 쓴다. 첫 번째 셈법(사람이 읽는 글자 수)으로 착각하고 그 숫자를 쓰면, 마치 책의 "몇 페이지"를 "몇 번째 문단"으로 착각해 엉뚱한 곳을 펴는 것과 같은 일이 생긴다. 비유가 깨지는 곳: 이모지가 왜 11칸이나 차지하는지는 유니코드가 색깔·성별·인원수 같은 정보를 여러 개의 코드 조각(ZWJ 시퀀스)을 이어붙여 표현하기 때문인데, 이건 일상적인 "페이지/문단" 비유로는 설명이 안 되는 유니코드 고유의 사정이다.
꼬리 질문
왜 NSString.length는 "문자 수"가 아니라 "UTF-16 코드유닛 수"인가?
Character(extended grapheme cluster)를 기본 단위로 설계됐다.NSRange를 Swift의 Range<String.Index>로 안전하게 바꾸려면 어떻게 해야 하는가?
Range(nsRange, in: string) 이니셜라이저를 쓰면 된다. 이 함수가 UTF-16 코드유닛 오프셋과 Swift String의 Character 기반 인덱스 사이의 변환을 대신 해준다. 직접 오프셋 산술로 변환을 시도하면 정확히 이 챕터에서 본 것 같은 어긋남 버그가 생기기 쉽다.RegexBuilder DSL은 언제 슬래시 리터럴보다 유리한가?
RegexBuilder는 Capture { }, OneOrMore { } 같은 선언적 빌더 문법으로 패턴을 여러 줄에 걸쳐 이름 붙여가며 조립할 수 있고, 각 조각에 이름을 주면 캡처 결과도 그 이름으로 접근할 수 있어 가독성이 크게 좋아진다. 짧고 한 번만 쓰는 패턴이라면 리터럴이 더 간결하다.Part A(01~13장)는 Swift라는 언어 자체가 값과 참조, 제네릭과 프로토콜을 통해 어떻게 안전성과 성능을 동시에 추구하는지를 다뤘다. Part B(14~28장)는 그 언어가 40년 된 Foundation이라는 프레임워크와 만나 무엇을 물려받았고, swift-foundation 재작성을 통해 그 유산을 어떻게 다시 쓰고 있는지를 좇았다. 이 마지막 챕터의 NSCache·Timer·Operation·Predicate·Regex는 모두 그 교차점에 서 있는 API들이다 — Objective-C 시절의 아이디어(자동 축출 캐시, KVC 기반 질의, 콜백 상태 기계)가 Swift의 타입 시스템과 Concurrency 모델을 만나 매크로로, actor로, 구조적 동시성으로 다시 태어나는 중이다. 언어를 이해하는 것과 그 위에 쌓인 시스템을 이해하는 것은 서로 다른 근육이지만, 결국 하나의 실무 감각으로 합쳐진다.