← Swift & Foundation 로드맵 28 / 33

28 · 시스템 유틸 — NSCache·Timer·Operation·Predicate

NSCache의 축출이 Dictionary와 근본적으로 다른 이유부터, 스크롤 중 멎는 Timer의 RunLoop 모드, 우선순위 역전 관점의 락 비교, Swift Concurrency 시대의 Operation, #Predicate·Regex가 NSPredicate·NSRegularExpression을 어떻게 대체하는지까지 직접 실행해 확인한다.
진행률
0 / 0 완료

이 챕터는 오래됐지만 여전히 실무에서 매일 쓰는 네 가지 시스템 유틸을 다룬다 — 캐시(NSCache), 타이머(Timer), 동시성 도구(락과 Operation), 그리고 질의(Predicate와 Regex)다. 공통점은 하나다. 겉으로는 단순한 API처럼 보이지만, 그 아래에는 메모리 압박 신호, RunLoop 모드, 우선순위 역전, KVC 같은 구체적인 시스템 메커니즘이 숨어 있고, 그 메커니즘을 모르면 "가끔씩만" 재현되는 버그를 만든다. 이 강의의 마지막 챕터이니, 실제로 실행해서 확인한 수치와 진짜 에러 메시지 위주로 진행한다.

Q1. NSCache는 Dictionary와 근본적으로 무엇이 다른가? countLimit이 "엄격한 제한이 아닌" 이유는?

🔑 30초 답변

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()을 자동으로 호출해준다 — 항목 전체를 지우는 대신 무거운 내부 콘텐츠만 버릴 수 있게 하는 훅이다.

메모리 압박 신호 — NSCache만 반응한다 시스템: 메모리 부족 신호 NSCache — 이전 (4개) NSCache — 압박 이후 (일부 축출, 순서 미보장) 점선 = 축출됨 — 어떤 게 지워질지는 문서화되지 않음(LRU 아님) Dictionary — 압박과 무관 직접 지우지 않으면 4개 그대로 유지
NSCache는 메모리 압박에 스스로 반응해 일부 항목을 지우지만, 그중 무엇이 지워질지는 계약이 아니다. Dictionary는 그런 개념 자체가 없다.

실험 · 도구

countLimit을 낮게 잡고, 메모리 압박을 전혀 주지 않은 채로 한도보다 훨씬 많이 넣어 실제로 몇 개가 남는지 확인했다.

countLimit=3인데 10개를 넣으면 실제로 몇 개가 남는가
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으로 브리지해서 쓰는 게 관용적인 패턴이다.

이미지 캐시 — 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로 가야 한다.

❌ NSCache를 정본 저장소로 오용 / ✅ 재계산 가능한 데이터만
// ❌ 나쁜 예 — 이 값이 사라지면 사용자가 방금 입력한 내용이 통째로 날아간다
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을 명시적으로 주는 게 안전하다.
쉽게 말하면 "선반이 꽉 차면 치워라"는 규칙만 있고 "매일 저녁 6시에 치워라" 같은 정기 청소 규칙은 없는 것과 같다. 아무도 선반이 꽉 찼다고 알려주지 않으면 계속 쌓인다.
NSCache에 Codable 구조체를 직접 넣을 수 있는가?
못 넣는다. NSCache<KeyType, ObjectType>는 두 제네릭 파라미터 모두 AnyObject 제약이 걸려 있어 struct/enum 같은 값 타입은 애초에 타입 체크를 통과하지 못한다. 값 타입 데이터를 캐시하려면 클래스로 감싼 래퍼를 만들거나, NSNumber/NSString처럼 이미 브리지되는 Foundation 클래스를 쓰거나, 캐시 계층 자체를 Dictionary + 직접 스레드 안전성 관리로 대체해야 한다.
쉽게 말하면 이 냉장고는 "포장 용기(클래스)에 담긴 것"만 넣을 수 있는 규칙이 있어서, 포장 없이 맨손으로 들고 온 음식(구조체)은 먼저 용기에 담아야 넣을 수 있다.
NSDiscardableContent를 구현하면 무엇이 달라지는가?
캐시가 항목 전체를 지우는 대신, 그 객체의 discardContentIfPossible()만 호출해 무거운 내부 데이터(예: 디코딩된 픽셀 버퍼)만 버리고 가벼운 메타데이터는 남겨둘 수 있게 된다. 항목이 통째로 사라지는 것보다 더 세밀한 단계적 메모리 절약이 가능해진다.
쉽게 말하면 선반 전체를 치우는 대신 "속은 비우고 상자 껍데기만 남겨둔다"는 규칙을 냉장고 관리인에게 알려주는 것과 같다 — 나중에 다시 채우기가 조금 더 쉬워진다.

Q2. 스크롤 중 Timer가 멈추는 이유는? — RunLoop .default vs .common 모드

🔑 30초 답변

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]로 캡처하면 이 문제를 피할 수 있다.

런루프 모드 전환 — .default 전용 타이머만 멎는다 평상시 (.default) 스크롤 중 (.tracking, common에 포함) 스크롤 종료 (.default) .default 전용 타이머 발화 없음 — 이 모드에서 안 보이는 입력 소스 .common 등록 타이머 스크롤 중에도 계속 발화 — 트래킹 모드가 common 집합에 포함되므로
같은 스크롤 구간에서 .default 전용 타이머는 발화가 끊기지만 .common에 등록된 타이머는 끊기지 않는다.

실험 · 도구

UIKit 스크롤을 터미널에서 그대로 재현할 수는 없지만, 그 원인인 "런루프가 지금 어떤 모드로 도는가"는 macOS 커맨드라인에서도 똑같이 검증할 수 있다. 커스텀 모드를 하나 만들고, 그 모드로만 런루프를 잠깐 돌려서 "스크롤 중"을 흉내냈다.

.default 전용 타이머 vs 여러 모드에 등록된 타이머 — 실제 실행 결과
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에 명시적으로 추가한다.

.common 모드 등록 + tolerance + weak self
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 같은 드리프트 보정은 직접 관리해야 한다).

DispatchSourceTimer — RunLoop 모드 개념 자체가 없다
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()하지 않으면 런루프가 타이머를 계속 붙잡고 있다는 사실은 동일하다.
쉽게 말하면 "누구를 꽉 붙잡을지"를 내가 고를 수 있게 됐을 뿐, 여전히 다 쓴 타이머는 손을 놓아줘야(invalidate) 한다.
DispatchSourceTimer는 UI 갱신에 써도 되는가?
이벤트 핸들러가 기본적으로 지정한 큐(보통 백그라운드)에서 실행되므로, UI를 직접 만지려면 핸들러 안에서 DispatchQueue.main.async로 명시적으로 메인 큐로 옮겨야 한다. Timer는 애초에 RunLoop 위에서 돌기 때문에 메인 스레드에 스케줄하면 핸들러가 자연히 메인에서 실행되는 것과 대조적이다.
쉽게 말하면 DispatchSourceTimer는 뒷마당에서 울리는 벨과 같아서, 그 소리를 듣고 거실(메인 스레드)에 알리려면 내가 직접 뛰어가서 전해야 한다.

Q3. os_unfair_lock / NSLock / DispatchSemaphore는 우선순위 역전 관점에서 어떻게 다른가?

🔑 30초 답변

os_unfair_lockNSLock(내부적으로 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는 "호출 스레드가 블록될 필요가 있을 때만 커널을 호출"하는 효율적인 카운팅 세마포어지만, 우선순위를 기부하지 않는다는 점이 커뮤니티와 애플 엔지니어들 사이에서 반복적으로 지적된 함정이다 — 락 대용으로 쓰지 말라는 것이 정설이다.

기부 없음 — DispatchSemaphore Low(락 보유) Medium(선점) High(대기) 기부가 없어 Low를 못 끌어올림 — High 무기한 대기 기부 있음 — os_unfair_lock / NSLock Low(락 보유) Medium Low가 우선순위를 빌려 먼저 끝냄 → Medium은 그 뒤로 밀림 High(대기 → 즉시 진행) High의 대기가 Low에게 우선순위를 "기부" → Low가 Medium을 제치고 빨리 unlock
우선순위 기부가 없으면 중간 우선순위 스레드가 락 보유자를 계속 밀어내 고우선순위 스레드가 굶는다. 기부가 있으면 락 보유자가 즉시 우선순위를 빌려 빨리 끝낸다.

실험 · 도구

우선순위 역전 자체를 재현하려면 실제 QoS가 다른 스레드 세 개가 필요해 번거롭다. 대신 더 기본적인 질문 — "무경합 상태에서 각 락의 순수 오버헤드는 얼마나 다른가" — 를 단일 스레드 루프로 직접 쟀다.

무경합 200만 회 lock/unlock — 실제 측정치
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을 쓴다.

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 — lock/unlock을 손으로 짝 맞출 필요가 없다
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를 쓰면 우선순위 역전이 완전히 사라지는가?
완전히 사라진다고 단언할 수는 없지만 위험이 구조적으로 크게 줄어든다. actor 내부는 명시적인 락이 아니라 Swift 런타임의 협력형 태스크 스케줄러가 직렬화를 담당하고, 이 스케줄러는 QoS를 고려해 태스크를 배정하도록 설계돼 있다. 전통적인 락처럼 "임의의 스레드가 임의의 순서로 블로킹 대기"하는 구조 자체가 없어서, 락 기반 코드에서 흔한 형태의 역전 시나리오가 원천적으로 성립하기 어렵다.
쉽게 말하면 줄을 서는 방식 자체를 사람이 알아서 서는 게 아니라 안내원이 순서대로 불러주는 방식으로 바꾼 것과 비슷하다. 새치기(전통적 역전)가 훨씬 어려워진다.
NSRecursiveLock은 왜 필요한가?
같은 스레드가 이미 쥔 락을 다시 lock하려는 상황(재귀 호출, 콜백 안에서 다시 같은 메서드 호출 등)에서 일반 NSLock을 쓰면 그 스레드 자신이 자신을 막아 데드락에 빠진다. NSRecursiveLock은 내부에 카운트를 유지해 "같은 스레드의 재획득"은 허용하고, unlock을 lock 횟수만큼 다 호출해야 실제로 풀리게 한다.
쉽게 말하면 내가 이미 열어놓은 문을 나 자신이 다시 열려다 못 여는 사고를 막기 위해, "같은 사람이면 몇 번을 다시 밀어도 괜찮다"고 규칙을 바꾼 문과 같다.
DispatchSemaphore를 값이 1인 상호배제 락 대용으로 쓰면 안 되는가?
값이 1인 세마포어는 동작만 보면 락과 거의 똑같이 쓸 수 있지만, 세마포어는 원래 "생산자-소비자 사이의 신호"를 위한 도구지 상호배제 전용 프리미티브가 아니다. 그래서 우선순위 기부 같은 락 전용 최적화가 빠져 있고, wait()를 비동기 컨텍스트(예: Swift Concurrency의 태스크)에서 잘못 쓰면 협력형 스레드 풀 전체를 블로킹시켜 다른 태스크들이 굶는 훨씬 심각한 문제로 번질 수 있다.
쉽게 말하면 망치로 나사를 박을 수는 있지만 나사드라이버가 아니라서, 겉으로 비슷해 보여도 나사 전용으로 만들어진 도구가 주는 세심한 배려(우선순위 기부)가 빠져 있다.

Q4. Operation/OperationQueue는 Swift Concurrency 시대에 아직 쓸 자리가 있는가?

🔑 30초 답변

있다 — 다만 자리가 좁아졌다. 세밀한 의존성 그래프를 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()을 부르면 isCancelledtrue가 되지만, 실행이 자동으로 멎지는 않는다 — 오퍼레이션 내부 로직이 주기적으로 isCancelled를 직접 확인해서 스스로 멈춰야 한다. 이 두 가지(수동 KVO, 수동 취소 체크)가 Operation 서브클래싱이 실수하기 쉬운 지점이다.

비동기 Operation의 상태 기계 — 전환마다 수동 KVO isReady isExecuting = true isFinished = true start() 작업 종료 각 전환마다 willChangeValue/didChangeValue 손으로 호출해야 큐가 인지한다 cancel() → isCancelled=true (자동 정지 아님) — 내부 로직이 직접 확인해서 스스로 멈춰야 isFinished로 간다
isExecuting/isFinished 전환은 수동 KVO 통지가 필요하고, cancel()은 깃발만 세울 뿐 실행을 강제로 멈추지 않는다.

실험 · 도구

이 상태 기계를 실제로 구현하고 큐에 태워 끝까지 실행해봤다.

isExecuting/isFinished를 수동 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 = true

KVO 통지를 정확히 짝 맞춰야만 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을 예외적으로 남긴다.

같은 파이프라인을 TaskGroup으로 — 취소가 자동 전파된다
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()를 직접 오버라이드하고 isAsynchronoustrue로 선언하는, 백그라운드 콜백 안에서 나중에 끝나는 비동기 서브클래스에 한정된다.
쉽게 말하면 그 자리에서 바로 끝나는 심부름은 다녀왔다고 굳이 보고할 필요 없이 문 닫는 소리로 충분하지만, 나중에 결과가 오는 심부름은 "다 됐어요"라고 직접 말해줘야 하는 것과 같다.
OperationQueue의 underlyingQueue는 무엇을 위한 것인가?
OperationQueue가 실제 작업을 어떤 GCD 큐에 던질지 지정하는 프로퍼티다. 이를 통해 Operation 기반 코드와 기존 DispatchQueue 기반 코드가 같은 큐를 공유하게 만들 수 있어, 레거시 GCD 코드와 Operation 코드를 점진적으로 섞어 쓸 때 유용하다.
쉽게 말하면 서로 다른 두 팀(Operation과 GCD)이 같은 작업대(큐) 하나를 같이 쓰도록 배정해주는 것과 같다.
Operation과 TaskGroup을 한 파이프라인 안에서 섞어 쓸 수 있는가?
가능은 하다 — withCheckedContinuation으로 Operation의 완료 콜백을 async 세계로 브리지하면 된다. 다만 그렇게 하는 순간 Operation이 주는 이점(취소·의존성의 명령형 제어)과 TaskGroup이 주는 이점(구조적 취소 전파)이 서로 다른 모델이라 정합성을 신경 써서 관리해야 한다. 신규 코드라면 처음부터 한쪽으로 통일하는 게 유지보수에 더 낫다.
쉽게 말하면 두 언어를 섞어 쓸 수는 있지만, 통역이 오갈 때마다 뉘앙스를 놓치기 쉬우니 가능하면 한 언어로 대화를 끝내는 게 편하다.

Q5. Foundation Predicate 매크로는 NSPredicate와 무엇이 다른가?

🔑 30초 답변

#Predicate는 클로저를 컴파일 타임에 매크로가 파싱해 타입 있는 표현식 트리(PredicateExpression)로 바꾼다 — 클로저 안 코드는 Swift로 실행되는 게 아니라 그 트리로 컴파일된다. 그래서 존재하지 않는 프로퍼티를 참조하면 컴파일이 아예 안 된다. NSPredicate는 포맷 문자열을 런타임에 파싱하고 KVC(valueForKey:)로 평가하므로, 키 이름 오타 같은 실수가 컴파일은 통과하고 실행 중에야, 그것도 크래시로 드러난다.

원리

Predicate<T>는 "입력값 집합을 검색·필터링용으로 테스트하는 논리 조건"을 표현하는 구조체이고, #Predicate<Book> { $0.year > 1990 }처럼 매크로로 만든다(Apple: Predicate, iOS 17+). 지원 연산은 산술·비교·불리언 논리·범위·옵셔널(?? if-let)·타입 체크·시퀀스 연산(allSatisfy filter contains)까지지만, 중첩 선언이나 for 같은 흐름 제어, 바깥 스코프 변수 수정은 지원하지 않는다 — 지원되는 부분집합만 컴파일된다는 것 자체가 타입 안전성의 근거다. NSPredicateNSPredicate(format:)처럼 사람이 손으로 쓴 문자열을 런타임에 파싱하는 오래된 방식으로, 여전히 모든 애플 플랫폼에서 동작하지만 Apple 문서는 신형 대체제로 Predicate를 소개한다.

내부 동작

결정적인 차이는 "클로저가 실행되느냐, 컴파일되느냐"다. #Predicate의 클로저 본문은 Swift 프로그램의 일부로 돌아가는 게 아니라, 매크로가 그 구문을 파싱해 별도의 표현식 트리로 변환한다. SwiftData는 이 트리를 SQL로 번역해 FetchDescriptor 쿼리에 그대로 쓴다. NSPredicate는 반대로 포맷 문자열("year > %d")을 evaluate 시점에야 파싱하고, 그 파싱 결과가 대상 객체에 valueForKey:를 호출하는 KVC 메커니즘으로 이어진다 — 이 말은 NSPredicate가 원래 KVC를 지원하는 객체(전형적으로 NSObject 서브클래스, 혹은 딕셔너리)를 겨냥해 설계됐다는 뜻이다. 순수 Swift 값 타입에 그대로 쓰면 KVC 자체가 성립하지 않아 다른 방식으로 실패한다.

컴파일 타임 — #Predicate Swift 클로저 소스 매크로 → 타입 있는 표현식 트리 SwiftData → SQL 변환 오타 키 → 컴파일 에러로 즉시 거부 런타임 — NSPredicate 포맷 문자열 "year > %d" 런타임 파싱 KVC valueForKey: 평가 오타 키 → NSUnknownKeyException 크래시 같은 실수, 다른 시점 — 컴파일 거부 vs 런타임 폭발
#Predicate는 컴파일 타임에 타입 검증된 표현식 트리를 만들지만, NSPredicate는 런타임까지 문자열을 들고 있다가 KVC로 평가한다.

실험 · 도구

세 가지를 직접 실행해 확인했다.

① #Predicate — 오타를 컴파일 타임에 거부하는 실제 진단
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 코드의 오타와 똑같은 컴파일 에러가 된다.

② NSPredicate — 정확한 키는 동작, 오타 키는 실행 중 크래시(실제 예외 텍스트)
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에 그대로 넘긴다.

SwiftData FetchDescriptor + #Predicate
@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(키패스)/%@(값) 치환을 써서 최소한의 안전장치라도 확보한다.

❌ 문자열 직접 삽입 / ✅ %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를 쓸 수 있는가?
Core Data의 전통적인 NSFetchRequest는 여전히 NSPredicate 기반이다. #Predicate가 1급 시민으로 통합된 것은 SwiftData 쪽이다. Core Data를 계속 쓴다면 NSPredicate%K/%@ 치환 패턴으로 안전성을 최대한 확보하는 게 현실적인 절충안이다.
쉽게 말하면 새로 지은 건물(SwiftData)은 최신 안전 설비가 기본이지만, 오래된 건물(Core Data)을 계속 쓰려면 그 안에서 쓸 수 있는 안전 수칙을 최대한 지키는 수밖에 없다.
Predicate.evaluate(_:)throws인 이유는?
표현식 트리 안에 나눗셈이나 옵셔널 강제 언래핑처럼 평가 도중 실패할 수 있는 연산이 포함될 수 있기 때문이다. 컴파일 타임에는 "이 표현식이 문법적으로/타입상 유효한가"까지만 검증되고, "0으로 나누기"처럼 값에 따라 달라지는 실패는 여전히 평가 시점에만 알 수 있어 throws로 남겨둔 것이다.
쉽게 말하면 문제 형식이 올바른지는 시험 시작 전에 확인할 수 있어도, 실제로 계산하다가 "0으로 나누면 안 되는데?" 같은 건 풀어보기 전까진 모르는 것과 같다.

Q6. Swift RegexNSRegularExpression과 실무적으로 무엇이 다른가?

🔑 30초 답변

Swift Regex는 Swift StringCharacter(extended grapheme cluster) 인덱스 위에서 그대로 동작해 타입 있는 캡처와 유니코드 정확성을 얻는다. NSRegularExpressionNSString 기반 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 정규식 사양을 따르는 오래된 클래스로, 매칭 결과를 NSTextCheckingResultrange/range(at:)로 돌려준다 — 이 range는 처음부터 끝까지 NSString의 세계, 즉 UTF-16 코드 유닛 인덱스다.

내부 동작

Swift의 StringCharacter 단위로 셈한다 — 사람이 보는 "글자 하나"에 해당하는 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를 그대로 쓰므로 이런 변환·불일치가 발생할 여지가 없다.

"가족👨‍👩‍👧‍👦 이모지" — 두 좌표계의 불일치 Character 단위 (Swift String.count = 7) 👨‍👩‍👧‍👦 (1글자) UTF-16 code unit 단위 (NSString.length = 17) family emoji = 11 code units 이모지 1글자가 코드유닛 11개로 "펼쳐진다" NSRegularExpression의 range는 아래(UTF-16) 좌표 — Character 감각으로 다루면 위치가 어긋난다
같은 문자열도 Character로 셀 때(7)와 UTF-16 코드유닛으로 셀 때(17)의 길이가 다르다 — family 이모지 하나가 코드유닛 11개를 쓰기 때문이다.

실험 · 도구

가족 이모지가 든 문자열로 두 좌표계 차이를 직접 확인했다.

Character 단위 vs UTF-16 code unit 단위 — 실제 측정치
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로 옮기는 게 더 안전하고 짧다.

❌ 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"이라고 자동으로 판단하지 말고 실제로 어떤 기능이 필요한지 먼저 확인한다.

서버가 내려준 동적 패턴도 런타임 Regex(_:) 초기화로 충분한 경우가 많다
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 코드유닛 수"인가?
NSString은 유니코드가 지금처럼 이모지·ZWJ 시퀀스로 복잡해지기 훨씬 전, UTF-16을 문자열의 기본 저장 형식으로 채택한 시절의 설계를 그대로 물려받고 있다. 당시엔 "코드유닛 하나 = 글자 하나"라는 가정이 대체로 맞았지만, 유니코드가 확장되면서(surrogate pair, ZWJ 시퀀스 등) 이 가정이 깨졌다. Swift String은 처음부터 이 문제를 피하려고 Character(extended grapheme cluster)를 기본 단위로 설계됐다.
쉽게 말하면 오래전에 "한 칸에 글자 하나씩만 들어간다"고 정한 규칙(UTF-16)을, 나중에 한 칸에 여러 조각을 이어붙여야 하는 복잡한 글자(이모지)가 등장한 뒤에도 그대로 쓰고 있는 것과 같다.
NSRange를 Swift의 Range<String.Index>로 안전하게 바꾸려면 어떻게 해야 하는가?
Range(nsRange, in: string) 이니셜라이저를 쓰면 된다. 이 함수가 UTF-16 코드유닛 오프셋과 Swift String의 Character 기반 인덱스 사이의 변환을 대신 해준다. 직접 오프셋 산술로 변환을 시도하면 정확히 이 챕터에서 본 것 같은 어긋남 버그가 생기기 쉽다.
쉽게 말하면 두 나라가 서로 다른 단위(마일 vs 킬로미터)를 쓸 때 직접 암산하지 말고 변환기(공식 변환 함수)를 쓰라는 것과 같다.
RegexBuilder DSL은 언제 슬래시 리터럴보다 유리한가?
패턴이 복잡해질수록 슬래시 리터럴 한 줄은 읽기 어려워진다. RegexBuilderCapture { }, 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로, 구조적 동시성으로 다시 태어나는 중이다. 언어를 이해하는 것과 그 위에 쌓인 시스템을 이해하는 것은 서로 다른 근육이지만, 결국 하나의 실무 감각으로 합쳐진다.

출처 · 참고자료