← iOS 개발자 CS 로드맵 2 / 35

02 · 동시성·병렬성·스레드

프로세스·스레드 같은 OS 기초부터 GCD, 그리고 Swift Concurrency의 actor·Sendable·Task까지 — 동시성의 원리와 iOS 매핑을 면접 수준으로 정리한다.
진행률
0 / 0 완료

프로세스와 스레드라는 운영체제 기초부터 GCD, 그리고 Swift Concurrency의 actor·Task까지 — 같은 '동시성'이라는 주제를 CS 원리와 iOS 구현 두 층위에서 오간다. 각 질문은 30초 답변으로 요지를 먼저 잡고, 원리 → iOS 매핑 → 실험 순으로 파고든다.

Q1. 프로세스와 스레드는 무엇이며 어떤 자원을 공유하는가?

🔑 30초 답변

프로세스는 실행 중인 프로그램의 인스턴스로, 독립된 가상 주소 공간·열린 파일·권한을 소유하는 '자원 단위'다. 스레드는 그 프로세스 안에서 CPU가 실제로 실행하는 '흐름 단위'로, 자기만의 스택·레지스터·프로그램 카운터(PC)를 갖되 코드(text)·전역 데이터·힙·열린 파일은 같은 프로세스의 스레드끼리 공유한다. 그래서 스레드 간 통신은 공유 메모리로 값싸지만, 동기화가 없으면 그 공유가 곧 data race의 통로가 된다. 반대로 프로세스 사이는 메모리가 격리돼 안전하지만 IPC(Inter-Process Communication, 프로세스 간 통신) 비용이 든다.

CS 원리

프로세스는 '주소 공간 + 자원 소유', 스레드는 '스케줄링(실행) 단위'로 나눠 이해하면 정확하다. 여러 스레드가 공유하는 것은 text·data·heap·열린 파일 테이블·시그널 핸들러 등 프로세스에 귀속된 자원이고, 스레드마다 고유한 것은 스택·레지스터 상태(PC, SP 포함)·TLS(thread-local storage)·커널 스케줄링 상태다. 논리적으로 '스택은 스레드 전용'이지만, 같은 주소 공간이라 하드웨어가 물리적으로 막아주지는 않는다.

프로세스 (Mach task) · 단일 가상 주소 공간 공유 (모든 스레드 공통) · Text (실행 코드) · Data · 전역/정적 변수 · Heap (동적 할당) · 열린 파일 · 소켓 · 시그널 핸들러 동기화 없이 접근하면 여기서 data race 발생 Thread 1 · 고유 스택 · 레지스터 · PC · TLS Thread 2 · 고유 스택 · 레지스터 · PC · TLS Thread 3 · 고유 스택 · 레지스터 · PC · TLS
스레드는 코드·데이터·힙을 공유하지만 스택과 레지스터 상태는 각자 갖는다. 공유가 통신을 값싸게 만드는 동시에 data race의 원인이 된다.

iOS에서는

Darwin/XNU에서 프로세스는 Mach task, 스레드는 Mach thread로 표현된다. task가 가상 메모리 맵과 port right 같은 자원을 소유하고, thread가 실제 실행 단위다. POSIX pthread는 이 Mach thread 위에 얹힌 얇은 층이다. iOS 앱은 보통 샌드박스당 하나의 프로세스로 돌고, App Extension은 host 앱과 별개의 프로세스로 실행돼 메모리가 격리된다(그래서 확장이 죽어도 앱 본체는 살아남는다). 화면 갱신은 메인 스레드가 전담한다.

실험 · 도구

LLDB에서 thread list·bt all로 스레드별 스택을 확인하고, Instruments의 System Trace로 스레드 상태 전이를, vmmap으로 프로세스 주소 공간을 관찰할 수 있다. 전역 변수를 두 스레드에서 락 없이 증가시키고 Thread Sanitizer를 켜면, 공유 영역에서 data race가 잡히는 것을 눈으로 볼 수 있다.

프로젝트 적용

스레드 간 공유 mutable 상태는 예외 없이 동기화(actor·lock·serial queue)로 감싼다.

공유 가변 상태를 actor로 감싸기
import Foundation

// ❌ 여러 스레드가 동기화 없이 만지는 공유 상태 — data race(UB)
final class UnsafeCounter {
    private var value = 0
    func increment() { value += 1 }     // 읽기·더하기·쓰기 사이에 끼어들 수 있다
    var current: Int { value }
}

// ✅ ① actor — 컴파일러가 격리를 강제한다(가장 권장)
actor Counter {
    private var value = 0
    func increment() { value += 1 }
    func current() -> Int { value }
}

// ✅ ② 락 — 동기 API를 유지해야 할 때
import os
final class LockedCounter: @unchecked Sendable {
    // 모든 접근이 이 락을 통과한다 — 아래 두 메서드가 유일한 진입점이다
    private let state = OSAllocatedUnfairLock(initialState: 0)
    func increment() { state.withLock { $0 += 1 } }
    func current() -> Int { state.withLock { $0 } }
}

// ✅ ③ serial queue — 레거시 코드와 섞여 있을 때
final class QueuedCounter {
    private var value = 0
    private let queue = DispatchQueue(label: "com.app.counter")
    func increment() { queue.async { self.value += 1 } }
    func current() -> Int { queue.sync { value } }
}

// 셋 다 목적은 같다: "한 번에 하나만 이 상태를 만진다"

크래시 격리가 중요하면 무거운 작업을 App Extension 등 별도 프로세스로 분리하는 것을 고려한다.

크래시 격리가 필요하면 별도 프로세스로
import Foundation

// 스레드는 주소 공간을 공유한다 — 한 스레드가 메모리를 깨뜨리면 프로세스 전체가 죽는다.
// 진짜 격리가 필요하면 경계는 스레드가 아니라 '프로세스'여야 한다.

// ✅ 신뢰할 수 없는 입력을 다루는 무거운 작업을 App Extension으로 분리
//    (예: 사용자가 올린 파일 파싱·썸네일 생성)
//    확장이 죽어도 호스트 앱은 살아 있다.

// 호스트 앱 쪽 — 확장에 작업을 넘기고 결과만 받는다
import UniformTypeIdentifiers

func requestThumbnail(for url: URL) async throws -> Data {
    // App Group 컨테이너에 입력을 두고 확장이 읽게 한다
    let shared = FileManager.default.containerURL(
        forSecurityApplicationGroupIdentifier: "group.com.app")!
    let input = shared.appendingPathComponent("in.bin")
    try Data(contentsOf: url).write(to: input, options: .atomic)

    // 확장 호출(예: Share/Action Extension, 또는 XPC 기반 서비스)
    return try await ThumbnailService.shared.run(inputAt: input)
}

enum ThumbnailService {
    static let shared = ThumbnailServiceImpl()
}
struct ThumbnailServiceImpl {
    func run(inputAt url: URL) async throws -> Data { Data() }
}

// ⚠️ 대가: IPC 직렬화 비용 + 확장의 메모리 상한이 본체보다 훨씬 낮다.
//    "크래시 격리 가치 > IPC 비용"일 때만 고른다.
// ⚠️ iOS에는 macOS 같은 임의 XPC 서비스가 없다 — 확장 종류가 정해져 있다.

TLS는 편의상 남용하기 쉽지만 스레드 재사용 풀에서는 예측 못 한 값이 남을 수 있으니 최소화한다.

TLS 대신 인자로 컨텍스트 넘기기
import Foundation

// 스레드 로컬 저장소(TLS)는 '이 스레드가 내 것'이라는 가정 위에서만 맞다.
// GCD·Swift Concurrency는 스레드를 풀에서 빌려 쓰므로 그 가정이 깨진다.

// ❌ 스레드가 재사용되면 이전 작업의 값이 그대로 남아 있다
func handleBad(requestID: String) {
    Thread.current.threadDictionary["requestID"] = requestID
    process()
}
func process() {
    let id = Thread.current.threadDictionary["requestID"] as? String
    print("처리 중: \(id ?? "없음")")     // 엉뚱한 요청 ID가 찍힐 수 있다
}

// ✅ ① 그냥 인자로 넘긴다 — 가장 단순하고 확실하다
func handle(requestID: String) { process(requestID: requestID) }
func process(requestID: String) { print("처리 중: \(requestID)") }

// ✅ ② 호출 깊이가 깊어 인자 전달이 번거로우면 task-local 값을 쓴다
//     (Task 단위로 스코프가 잡히므로 스레드 재사용과 무관하다)
enum RequestContext {
    @TaskLocal static var id: String = "-"
}

func handleAsync(requestID: String) async {
    await RequestContext.$id.withValue(requestID) {
        await deepWork()
    }
}

func deepWork() async {
    print("처리 중: \(RequestContext.id)")   // ✅ 항상 이 Task의 값
}
⚠️ 흔한 오해

"스레드는 메모리를 전부 공유한다"는 부정확하다. 스택은 스레드마다 고유하다. 다만 '고유'는 논리적 소유일 뿐, 같은 주소 공간이므로 한 스레드가 다른 스레드의 스택 주소에 포인터로 접근하는 것은 물리적으로 가능하다 — 하드웨어가 보호하지 않는다. 이 미묘함이 스택 변수 주소를 다른 스레드에 넘겼을 때의 버그를 만든다.

🧒 쉽게 이해하기

프로세스를 아파트 한 채, 스레드를 그 집에 사는 식구라고 생각해 보자. 거실·냉장고·현관에 쌓인 우편물은 식구 모두가 같이 쓴다 — 실행 코드, 전역 변수, heap, 열어둔 파일이 여기에 해당한다. 대신 각자 자기 책상 서랍은 따로 갖는데, 그게 스택이고 레지스터고 PC(지금 코드의 어느 줄을 실행 중인지 적어둔 쪽지)다. 냉장고를 같이 쓰니까 식구끼리 뭘 주고받는 건 엄청 싸다. 다른 집(다른 프로세스)에 우유를 건네려면 현관 밖으로 나가 문을 두드려야 하는데, 그게 IPC 비용이다.

여기서 딱 하나 조심할 게 있다. 서랍이 '내 것'인 건 집안의 약속이지, 자물쇠가 달렸다는 뜻이 아니다. 같은 집 안이라 마음먹으면 남의 서랍도 열린다 — 하드웨어가 막아주지 않는다. 그래서 스택에 있는 변수의 주소를 다른 스레드에 넘기면, 그 서랍이 이미 치워졌거나 다른 사람 물건으로 채워진 뒤에 열어보는 사고가 난다.

꼬리 질문

스택에 있는 지역 변수 주소를 다른 스레드로 넘겨 접근하면 어떤 문제가 생기나?

주소를 넘긴 스레드가 그 스택 프레임을 벗어나면(함수 반환 등) 그 메모리는 다른 프레임에 재사용되므로, 받은 쪽은 use-after-scope로 엉뚱한 값을 읽거나 덮어쓰게 된다. 프레임이 아직 살아 있어도 두 스레드가 동기화 없이 같은 위치를 건드리면 data race다. 같은 주소 공간이라 하드웨어가 접근 자체는 막지 않기 때문에 크래시 없이 조용히 값이 깨지는 형태로 나타나기 쉽다. 굳이 넘겨야 한다면 힙에 복사해 소유권을 명확히 하거나 값 타입으로 복사해 전달한다.

쉽게 말하면 친구한테 "내 책상 셋째 서랍 열어봐"라고 쪽지를 남겨놓고, 정작 나는 짐 싸서 나가버린 상황이다. 그 서랍엔 이미 다음 사람 물건이 들어와 있다. 그래서 서랍 위치(주소)를 알려주지 말고 내용물을 복사해서 손에 쥐여주는 게 맞다.
멀티스레드 프로세스에서 fork()를 호출하면 자식에는 스레드가 몇 개 복제되나?

호출한 스레드 하나만 복제되고 나머지 스레드는 자식에 존재하지 않는다. 문제는 사라진 스레드가 쥐고 있던 락(malloc의 힙 락 등)이 잠긴 상태 그대로 상속돼, 자식이 그 락을 다시 잡으려다 즉시 교착에 빠질 수 있다는 점이다. 그래서 POSIX는 forkexec 사이에서는 async-signal-safe 함수만 부르라고 규정한다. iOS 앱 개발에서 직접 fork를 다룰 일은 거의 없지만 원리는 이 위험 때문이다.

쉽게 말하면 사무실을 통째로 복사하는데 사람은 복사 버튼을 누른 한 명만 따라간다. 그런데 안 따라온 사람이 잠가둔 창고 문은 잠긴 그대로 복사본에 남는다 — 열쇠 가진 사람이 그 세상에 없으니 그 문을 열려는 순간 영영 멈춘다.
App Extension과 host 앱은 메모리를 공유하나? 데이터를 주고받으려면 무엇을 쓰나?

공유하지 않는다. 확장은 host와 별개의 프로세스이고 각자 샌드박스가 격리돼 있어 메모리가 분리된다(그래서 확장이 죽어도 앱 본체는 산다). 데이터 교환은 App Group을 설정한 뒤 공유 컨테이너를 쓰는 방식이 표준이다 — UserDefaults(suiteName:), FileManager.containerURL(forSecurityApplicationGroupIdentifier:)로 얻은 공유 폴더가 그것이다. 값이 바뀐 즉시 알려야 하면 payload 없는 Darwin notification(CFNotificationCenter)으로 신호만 보내고 실제 데이터는 공유 컨테이너에서 읽는다. iOS에서는 앱과 자기 확장 사이에 임의의 XPC 연결을 직접 세우는 것은 표준이 아니다(그건 macOS 방식이다).

쉽게 말하면 앱과 확장은 한 집이 아니라 옆집이다. 그래서 물건을 건네려면 둘 다 열쇠를 가진 공동 창고(App Group 컨테이너)에 놓고 가야 한다. Darwin notification은 "창고에 뭐 넣어놨어" 하고 벨만 누르는 것이고, 물건 자체는 여전히 창고에서 꺼내 온다.

Q2. 동시성과 병렬성은 무엇이 다른가?

🔑 30초 답변

동시성(concurrency)은 여러 작업을 논리적으로 겹쳐 진행할 수 있게 만드는 '구조·설계'의 문제이고, 병렬성(parallelism)은 여러 작업을 물리적으로 같은 순간에 실행하는 '실행'의 문제다. 싱글 코어에서도 시분할로 동시성은 성립하지만, 병렬성은 코어가 둘 이상이라야 가능하다. Rob Pike의 표현대로 "동시성은 여러 일을 다루는 것, 병렬성은 여러 일을 동시에 하는 것"이다. 동시성 있게 설계한 프로그램이 병렬 하드웨어를 만나 병렬성으로 실현된다.

CS 원리

동시성은 작업들의 실행이 시간적으로 interleaving 될 수 있는 구조를 뜻하고, 병렬성은 실제로 동시에(simultaneously) 실행됨을 뜻한다. 병렬성은 동시성이 있어야 성립하는 실행 형태이지, 동시성이 곧 병렬성은 아니다. 병렬화로 얻는 속도 향상에는 상한이 있는데, 순차 부분의 비율이 전체 속도 향상을 제한한다(Amdahl's law).

동시성 · 단일 코어에서 번갈아 (interleaving) CPU A B A B A B 병렬성 · 두 코어에서 같은 순간에 Core 1 A Core 2 B 시간
단일 코어에서도 시분할로 동시성은 성립하지만, 병렬성은 코어가 둘 이상이라야 한다. 동시성은 구조, 병렬성은 실행이다.

iOS에서는

GCD의 concurrent queue에 작업을 넣으면 '동시성'을 표현한 것이고, 실제로 병렬 실행될지는 시스템이 코어 수·QoS(Quality of Service, 작업의 중요도 등급)·부하를 보고 스레드 풀에서 결정한다. Swift Concurrency에서는 async 함수 하나가 동시성을 주지만 그 자체로 병렬은 아니며, async let이나 TaskGroup으로 여러 작업을 동시에 띄워야 병렬 실행 후보가 된다. 실제 병렬 폭은 cooperative thread pool의 너비(대략 코어 수)로 제한된다. Apple Silicon은 성능(P)·효율(E) 코어가 섞여 있어 QoS에 따라 배치가 달라진다.

실험 · 도구

ProcessInfo.processInfo.activeProcessorCount로 가용 코어 수를 확인하고, CPU 바운드 작업을 DispatchQueue.concurrentPerform(iterations:)으로 돌리며 코어 수에 따라 처리 시간이 스케일되는지 관찰한다. Instruments의 CPU 사용률·Points of Interest로 여러 코어가 동시에 점유되는지 눈으로 확인할 수 있다.

프로젝트 적용

I/O 바운드(네트워크·디스크)는 동시성(async)만으로 충분하다 — 스레드를 더 늘려도 빨라지지 않는다.

I/O 바운드는 동시성만으로 충분하다
import Foundation

// I/O는 CPU가 노는 시간이 대부분이다. 스레드를 늘려 봐야
// 노는 스레드가 늘 뿐, 대기 시간 자체는 줄지 않는다.

// ❌ 스레드를 늘려 해결하려는 시도 — thread explosion만 부른다
func fetchAllBad(_ urls: [URL]) {
    for url in urls {
        DispatchQueue.global().async {
            _ = try? Data(contentsOf: url)     // 스레드를 붙잡고 대기
        }
    }
}

// ✅ 대기를 '겹친다' — 스레드는 그대로 두고 왕복만 병행시킨다
func fetchAll(_ urls: [URL]) async throws -> [Data] {
    try await withThrowingTaskGroup(of: (Int, Data).self) { group in
        for (i, url) in urls.enumerated() {
            group.addTask {
                let (data, _) = try await URLSession.shared.data(from: url)
                return (i, data)
            }
        }
        // 순서를 보존해 모은다
        var out = [Data?](repeating: nil, count: urls.count)
        for try await (i, data) in group { out[i] = data }
        return out.compactMap { $0 }
    }
}

// 소수의 고정 개수라면 async let이 더 읽기 쉽다
func loadScreen() async throws -> (Profile, [Post]) {
    async let profile = api.profile()
    async let posts = api.posts()
    return try await (profile, posts)          // 두 왕복이 겹쳐서 진행된다
}

struct Profile {}; struct Post {}
enum api {
    static func profile() async throws -> Profile { Profile() }
    static func posts() async throws -> [Post] { [] }
}

병렬화는 CPU 바운드 작업에서만 이득이며, 코어 수를 넘는 병렬 스레드는 컨텍스트 스위칭·캐시 미스로 오히려 손해다.

병렬화는 CPU 바운드에서만, 코어 수만큼만
import Foundation

// 코어보다 많은 병렬 스레드는 이득이 없고 스위칭·캐시 미스로 손해다.

let cores = ProcessInfo.processInfo.activeProcessorCount

// ✅ ① concurrentPerform — 런타임이 코어 수에 맞춰 분배한다
func renderTiles(_ tiles: [Tile], into out: UnsafeMutableBufferPointer<Pixel>) {
    DispatchQueue.concurrentPerform(iterations: tiles.count) { i in
        out[i] = render(tiles[i])
    }
}

// ✅ ② TaskGroup을 쓰되 '작업 개수'가 아니라 '청크 개수'를 코어에 맞춘다
func thumbnails(_ images: [RawImage]) async -> [Thumb] {
    let chunkSize = max(1, (images.count + cores - 1) / cores)
    var chunks: [[RawImage]] = []
    var i = images.startIndex
    while i < images.endIndex {
        let j = images.index(i, offsetBy: chunkSize, limitedBy: images.endIndex)
                ?? images.endIndex
        chunks.append(Array(images[i..<j]))
        i = j
    }

    return await withTaskGroup(of: [Thumb].self) { group in
        for chunk in chunks {
            group.addTask { chunk.map(makeThumb) }   // 청크 하나 = Task 하나
        }
        var out: [Thumb] = []
        for await part in group { out += part }
        return out
    }
}

// ❌ images.count가 10,000이면 Task 10,000개 — Task는 값싸지만 공짜는 아니다.
//    협력 풀은 Task를 선점하지 않으므로 스레드 스위칭이 느는 건 아니고,
//    Task별 할당·그룹 부기 비용과 잘게 쪼갠 만큼의 캐시 지역성 손실이 쌓인다

struct Tile {}; struct Pixel {}; struct RawImage {}; struct Thumb {}
func render(_ t: Tile) -> Pixel { Pixel() }
func makeThumb(_ r: RawImage) -> Thumb { Thumb() }

순차 의존 구간이 크면 아무리 병렬화해도 상한에 막힌다는 점(Amdahl)을 성능 목표 설정에 반영한다.

Amdahl — 순차 구간이 상한을 정한다
import Foundation

// 전체의 s만큼이 순차라면, 코어를 무한히 늘려도 최대 배속은 1/s를 못 넘는다.
//   순차 5%  → 최대 20배
//   순차 20% → 최대 5배   (코어 32개를 줘도 5배가 천장이다)

func maxSpeedup(sequentialFraction s: Double, cores n: Int) -> Double {
    1.0 / (s + (1.0 - s) / Double(n))
}

func planTarget() {
    for s in [0.05, 0.20, 0.50] {
        let eight = maxSpeedup(sequentialFraction: s, cores: 8)
        let inf   = 1.0 / s
        print(String(format: "순차 %.0f%% → 8코어 %.1f배 · 이론상한 %.1f배",
                     s * 100, eight, inf))
    }
}

// 실무 함의: 목표를 세우기 전에 '순차 구간이 얼마인지' 먼저 잰다.
// 아래처럼 파이프라인을 나눠 각 구간을 측정하면 s가 드러난다.
func measureStages(_ items: [Item]) {
    let clock = ContinuousClock()

    let decode = clock.measure { _ = items.map(decodeOne) }     // 병렬화 가능
    let merge  = clock.measure { _ = mergeAll(items) }          // 순차 (공유 상태)

    print("decode \(decode) · merge \(merge)")
    // merge가 전체의 30%면, decode를 아무리 병렬화해도 3.3배가 천장이다
}

struct Item {}
func decodeOne(_ i: Item) -> Item { i }
func mergeAll(_ i: [Item]) -> Item { Item() }
⚠️ 흔한 오해

"async를 쓰면 병렬로 돌아 빨라진다"는 오해다. async는 동시성을 줄 뿐 자동 병렬이 아니다. 하나의 async 함수 체인은 여전히 순차적으로 진행되며, 병렬은 별도의 Task나 TaskGroup으로 명시해야 생긴다.

🧒 쉽게 이해하기

혼자서 라면 물을 올려놓고, 끓는 동안 숙제를 하는 장면을 떠올려 보자. 손은 하나인데 두 가지 일이 같이 굴러간다 — 이게 동시성이다. 병렬성은 아예 사람이 둘이라, 한 명은 라면만 보고 한 명은 숙제만 해서 진짜로 같은 순간에 두 일이 진행되는 것이다. 그래서 코어가 하나뿐인 기기에서도 동시성은 되지만 병렬성은 안 된다.

제일 많이 미끄러지는 지점이 async다. async는 "이 일엔 기다리는 구간이 있으니 그 동안 다른 걸 해도 된다"고 구조를 잡아준 것이지, 사람을 한 명 더 데려온 게 아니다. 사람을 더 부르려면 async let이나 TaskGroup으로 "이건 따로 시작해"라고 말해줘야 한다. 그리고 사람을 아무리 늘려도, 라면이 다 끓어야만 시작할 수 있는 일이 남아 있으면 결국 거기서 막힌다 — Amdahl's law가 말하는 상한이 그 '순서를 못 바꾸는 구간'이다.

꼬리 질문

코어가 하나뿐인 기기에서도 동시성 설계가 주는 이득은 무엇인가?

가장 큰 이득은 대기 시간 숨기기와 응답성이다. 한 작업이 네트워크나 디스크 응답을 기다리는 동안 코어를 놀리지 않고 다른 작업을 진행시킬 수 있고, UI는 무거운 작업 중에도 멈추지 않는다. 즉 코어가 하나여도 I/O 바운드 작업이 섞여 있으면 전체 처리량과 체감 반응이 좋아진다. 병렬 실행으로 CPU 계산 자체가 빨라지는 것과는 다른 종류의 이득이다.

쉽게 말하면 라면 물 올려놓고 3분을 멍하니 서 있느냐, 그 사이 숙제를 하느냐의 차이다. 손은 여전히 하나라 숙제 자체가 빨라지진 않지만, 버려지던 3분이 통째로 살아난다. 화면이 안 멈추는 것도 같은 이치다.
병렬화했는데 오히려 느려지는 대표적 원인 두 가지는?

첫째는 오버헤드가 실제 일보다 커지는 경우다 — 작업 쪼개기, 스레드 컨텍스트 스위칭, 결과 취합 비용이 계산량을 압도한다. 둘째는 경합이다 — 여러 스레드가 같은 락을 두고 다투거나(lock contention), 서로 다른 스레드가 만지는 변수가 같은 캐시 라인에 있어 캐시가 코어 사이를 계속 튕겨 다니는 false sharing이 대표적이다. 메모리 대역폭 포화도 비슷한 결과를 낸다.

쉽게 말하면 라면 한 개 끓이는 데 여덟 명을 부르면, 역할 나누고 결과 합치느라 혼자 할 때보다 오래 걸린다. 게다가 냄비가 하나뿐이면 다들 그 앞에 줄을 선다(lock contention). false sharing은 좀 얄궂은데, 서로 다른 물건을 만지는데도 그것들이 같은 선반 칸(캐시 라인)에 놓여 있어서 선반을 통째로 주고받느라 느려지는 경우다.
Amdahl's law는 병렬화로 얻을 수 있는 최대 속도 향상에 어떤 제약을 주나?

전체 속도 향상은 1 / (s + p/N)로, 코어 수 N이 무한히 커져도 순차 부분 비율 s 때문에 1/s를 넘지 못한다. 예컨대 10%가 순차라면 코어를 아무리 늘려도 최대 10배까지다. 그래서 성능 목표를 잡을 때는 코어 수보다 순차 구간을 얼마나 줄이느냐가 실질적인 상한을 결정한다.

쉽게 말하면 요리 30분 중 10분이 "밥이 다 될 때까지 무조건 기다려야 하는" 구간이라면, 주방에 100명을 넣어도 10분 밑으로는 못 내려간다. 그래서 "사람을 몇 명 더 넣을까"보다 "못 나누는 구간을 어떻게 줄일까"가 진짜 승부처다.

Q3. Race condition과 Data race는 무엇이 다른가?

🔑 30초 답변

Data race는 둘 이상의 스레드가 동기화 없이 같은 메모리에 접근하고 그중 최소 하나가 쓰기일 때 발생하는, 언어 메모리 모델상 '정의되지 않은 동작(UB)'이다. Race condition은 실행 순서(타이밍)에 따라 결과가 달라지는 더 넓은 논리적 결함으로, 모든 연산이 atomic이어서 data race가 전혀 없어도 발생할 수 있다. 즉 둘은 겹치되 독립적이다 — data race는 메모리 안전 문제, race condition은 정확성(로직) 문제. 그래서 "atomic으로 감쌌으니 안전"이라거나 "TSan을 통과했으니 스레드 안전"이라는 결론은 둘 다 성급하다.

CS 원리

Data race는 형식적으로 정의된다 — 동시에·동기화 없이·같은 위치에·최소 하나의 쓰기. C/C++/Swift 메모리 모델은 이를 UB로 규정하므로, 컴파일러 최적화가 개입해 예측 불가능한 결과를 낼 수 있다. Race condition은 결과가 스케줄에 의존한다는 더 일반적 성질이며, 대표적으로 '확인 후 행동(check-then-act, TOCTOU)'은 각 연산이 원자적이어도 두 연산 사이에 상태가 바뀌면 깨진다.

check-then-act는 atomic 연산만으로 안전해지지 않는다 (Swift)
// 두 연산이 각각 원자적이어도, 그 사이에 다른 스레드가 끼어들 수 있다
if cache[key] == nil {      // (1) 확인
    cache[key] = compute()  // (2) 행동 — 그 사이 다른 스레드가 이미 채웠을 수 있음
}
재고 1개 · 두 스레드가 각각 "0보다 크면 하나 판다" Thread A Thread B 재고 > 0 ? 예 재고 1 → 0 재고 > 0 ? 예 재고 0 → -1 시간 확인과 감소 사이에 상대가 끼어드는 순간이 구멍이다 연산 하나하나는 원자적인데 결과는 -1 — race condition
각 연산이 atomic이어도 '확인 후 행동'을 하나로 묶지 않으면 결과가 깨진다. data race가 없어도 남는 종류의 버그다.

iOS에서는

Swift 6의 complete concurrency checking은 data race를 컴파일 타임에 제거하는 것을 목표로 한다. Sendable과 actor isolation으로 '경계를 넘는 접근'을 정적으로 검증하기 때문이다. 하지만 race condition(로직 순서 결함)은 컴파일러가 잡지 못한다 — actor 안이라도 await 지점에서 다른 호출이 끼어들어(actor reentrancy) 불변식이 깨질 수 있다.

실험 · 도구

Thread Sanitizer(TSan)는 happens-before 관계를 추적해 data race를 런타임에 검출한다. 그러나 TSan은 race condition을 잡지 못한다 — 논리 결함은 스트레스 테스트·불변식 검증·순서에 대한 단위 테스트로 걸러야 한다.

프로젝트 적용

TSan을 CI에 상시로 켜고 data race 0을 하한선으로 둔다.

TSan을 CI에 상시로 켜기
# Thread Sanitizer는 happens-before 관계를 추적해 data race를 런타임에 검출한다.
# 재현이 어려운 race를 '한 번이라도 실행되면' 잡아낸다.

# ① Xcode에서 켜기
#    Scheme → Test → Diagnostics → Thread Sanitizer ✓
#    (Debug 구성에서만. Release 성능 측정과는 함께 쓰지 않는다)

# ② CI에서 상시로 돌린다 — 여기서 실패하면 머지 불가로 둔다
xcodebuild test \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -enableThreadSanitizer YES

# ③ 로컬에서 SwiftPM 패키지라면
swift test --sanitize=thread

# ⚠️ TSan은 실행 속도를 5~15배 늦추고 메모리를 5~10배 쓴다.
#    전체 스위트가 느려지면 '동시성 테스트 타깃'만 따로 떼어 상시로 돌린다.

# ⚠️ 한계: TSan은 data race(메모리 안전)만 본다.
#    "잔고가 음수가 됐다" 같은 논리 race(race condition)는 못 잡는다.
#    → 그건 아래 ②의 불변식 설계로 막아야 한다.

# ④ Swift 6 strict concurrency는 상당수를 컴파일 타임에 잡는다 — 함께 켠다
#    Package.swift: swiftSettings: [.enableUpcomingFeature("StrictConcurrency")]

불변식(예: 잔고는 음수가 될 수 없다)은 원자 변수 하나가 아니라 하나의 임계구역/actor 메서드 안에서 트랜잭션처럼 지킨다.

불변식은 하나의 임계구역 안에서 지키기
import Foundation

// 각 연산이 원자적이어도, 연산 '사이'에 상태가 바뀌면 불변식이 깨진다(TOCTOU).

actor Account {
    private var balance: Int
    init(balance: Int) { self.balance = balance }

    // ❌ 검사와 변경이 갈라져 있다 — 호출자가 이렇게 쓰면 막을 방법이 없다
    func read() -> Int { balance }
    func set(_ v: Int) { balance = v }
    // 호출부: if await a.read() >= 100 { await a.set(await a.read() - 100) }
    //         → 두 await 사이에 다른 출금이 끼어든다

    // ✅ 검사 + 변경을 하나의 메서드 = 하나의 임계구역으로
    func withdraw(_ amount: Int) throws {
        guard balance >= amount else { throw BankError.insufficient }
        balance -= amount           // 사이에 await가 없다 → 끼어들 틈이 없다
    }

    // ✅ 부수효과가 필요하면 '상태 확정 먼저, await 나중'
    func withdrawAndAudit(_ amount: Int) async throws {
        guard balance >= amount else { throw BankError.insufficient }
        balance -= amount                       // ① 먼저 확정하고
        await Audit.shared.record(amount)       // ② 그다음 await
    }
}

enum BankError: Error { case insufficient }
actor Audit {
    static let shared = Audit()
    func record(_ amount: Int) {}
}

// 원자 변수 하나로는 이걸 못 막는다:
// ❌ atomicBalance.subtract(100)  — 음수가 되는 것 자체를 막지 못한다

actor의 await 경계 이후에는 앞서 읽은 상태를 다시 확인한다.

await 경계 이후 상태 재확인
import Foundation

actor UploadManager {
    private var inFlight: Set<UUID> = []
    private var cancelled: Set<UUID> = []

    // ❌ await 이전에 확인한 것을 이후에도 유효하다고 가정
    func uploadBad(_ id: UUID) async throws {
        guard !cancelled.contains(id) else { return }
        try await network.send(id)          // ⚠️ 여기서 재진입 창이 열린다
        markDone(id)                        // 그사이 취소됐어도 완료 처리된다
    }

    // ✅ await 뒤에서 다시 확인한다
    func upload(_ id: UUID) async throws {
        guard !cancelled.contains(id), inFlight.insert(id).inserted else { return }
        defer { inFlight.remove(id) }

        try await network.send(id)          // 여기서 다른 호출이 끼어들 수 있다

        // ✅ 앞서 확인한 조건을 재검증
        guard !cancelled.contains(id) else { return }
        markDone(id)
    }

    func cancel(_ id: UUID) { cancelled.insert(id) }
    private func markDone(_ id: UUID) {}
}

enum network {
    static func send(_ id: UUID) async throws {}
}

// 규칙으로 만들면 리뷰에서 걸러진다:
//   "actor 메서드 안의 모든 await 뒤에는, 그 앞에서 읽은 상태를 다시 읽었는가?"
⚠️ 흔한 오해

"atomic 변수를 쓰면 race condition이 사라진다"와 "TSan을 통과했으면 스레드 안전하다"는 둘 다 틀렸다. atomic은 data race만 없앨 뿐 TOCTOU 같은 논리 race는 그대로 남고, TSan은 data race만 볼 뿐 로직 결함은 못 본다. 두 문제는 별개의 도구·기법으로 각각 방어해야 한다.

🧒 쉽게 이해하기

공책 한 칸에 두 사람이 동시에 연필을 대면 글씨가 뭉개진다. 이게 data race다 — 같은 자리에 동시에 손을 대는데 누가 먼저인지 정해주는 규칙이 아예 없는 상태. 심지어 컴파일러는 "이런 일은 없다"고 믿고 코드를 줄여 쓰기 때문에, 글씨만 뭉개지는 게 아니라 문장 자체가 사라지거나 순서가 뒤바뀌기도 한다. 그래서 UB(정의되지 않은 동작)라고 부른다.

race condition은 결이 다르다. 글씨는 한 명씩 또박또박 잘 썼는데, 누가 먼저 썼느냐에 따라 답이 틀리는 경우다. 빵이 하나 남았는데 두 사람이 각자 "있네?" 확인하고 각자 집으러 가면, 확인도 집기도 얌전히 한 번씩 했는데 빵은 -1개가 된다. 그러니 atomic은 연필이 겹치지 않게 해줄 뿐이고, TSan도 연필 겹침만 잡아준다 — 빵 개수가 틀어진 건 둘 다 못 본다. 확인과 집기를 한 덩어리로 묶어야 그게 막힌다.

꼬리 질문

atomic counter만으로는 정확성이 깨지는 구체적 예를 하나 들어보라.

재고를 원자 카운터로 관리하며 "if 재고 > 0 이면 감소"를 한다고 하자. 읽기와 감소가 각각 원자적이어도, 재고가 1일 때 두 스레드가 거의 동시에 "재고 > 0" 검사를 통과하면 둘 다 팔아 재고가 -1이 된다. 이 check-then-act는 두 연산 사이의 순간을 보호하지 못해 깨진다. 정확성을 지키려면 검사와 감소를 하나의 임계구역이나 compare-and-swap 루프로 묶어야 한다.

쉽게 말하면 남은 빵이 하나인데, 두 사람이 각자 "있네?" 확인하고 각자 집으러 간다. 보는 것도 집는 것도 한 번씩 얌전히 했는데 빵은 -1개가 된다. atomic은 "보기"와 "집기"를 각각 깔끔하게 해줄 뿐, 그 둘 사이에 벌어진 틈은 안 막아준다.
data race가 왜 단순히 '값이 꼬여 읽히는' 수준이 아니라 언어 메모리 모델상 '정의되지 않은 동작(UB)'인가? 컴파일러가 '데이터 레이스는 없다'고 가정하고 수행하는 최적화(로드 호이스팅·재정렬·torn read/write)가 개입하면 코드가 구체적으로 어떻게 깨지나?

C/C++/Swift 메모리 모델은 "잘 동기화된 프로그램에는 data race가 없다"를 전제로 최적화를 허용하기 때문에, race가 있으면 컴파일된 코드가 소스와 더 이상 대응하지 않는다 — 이게 "값이 가끔 틀리는" 것과 질적으로 다른 이유다. 예컨대 공유 플래그를 도는 루프에서 컴파일러가 그 로드를 루프 밖으로 끌어올려 레지스터에 캐싱하면(load hoisting) 다른 스레드가 플래그를 바꿔도 영영 못 보고, 스토어 재정렬로 "데이터 먼저, 플래그 나중"이라는 의도한 순서가 뒤집히며, 워드보다 넓은 값의 쓰기가 쪼개져 다른 스레드가 반쯤 갱신된 값을 읽는 torn read가 난다. 그래서 증상이 최적화 레벨·아키텍처마다 달라져 -Onone 디버그 빌드는 멀쩡한데 -O 릴리스에서만 깨지는 식으로 튀고, 해법은 volatile이나 수제 플래그가 아니라 happens-before를 세우는 진짜 동기화(적절한 순서의 atomic·락·actor 격리)뿐이다.

쉽게 말하면 컴파일러는 "이 변수는 나 말고 아무도 안 건드린다"고 철석같이 믿고 코드를 줄여 쓴다. 그래서 플래그를 계속 확인하는 반복문이 "한 번만 읽고 기억해두자"로 바뀌어, 남이 플래그를 바꿔도 영영 못 보는 일이 생긴다. 값이 가끔 틀리는 정도가 아니라 실행되는 코드 자체가 내가 쓴 것과 달라지는 것이라, 디버그 빌드는 멀쩡한데 릴리스에서만 터지는 식으로 튄다.
happens-before 관계란 무엇이고 TSan은 이를 어떻게 활용하나?

happens-before는 두 메모리 연산 사이의 '확실한 선후' 순서로, 락 해제→획득, 스레드 시작/조인, atomic의 release/acquire 같은 동기화 지점에서 성립한다. 같은 위치에 대한 두 접근이 이 순서로 묶이지 않고 그중 하나가 쓰기면 곧 data race다. TSan은 실행 중 각 스레드에 벡터 클록을 두고 shadow memory로 접근 이력을 추적하다가, happens-before로 정렬되지 않은 충돌 접근이 나타나면 그 자리를 신고한다.

쉽게 말하면 happens-before는 "A가 확실히 먼저, B가 그다음"이라고 도장이 찍힌 관계다. 락을 놓고 다시 잡는 자리, 스레드를 시작하고 합치는 자리 같은 곳에서만 이 도장이 찍힌다. TSan은 실행 내내 "누가 언제 어디를 만졌는지"를 받아 적다가, 도장 없이 같은 자리를 만진 두 접근을 발견하면 손을 든다.

Q4. Critical section이란 무엇이며 어떻게 보호하는가?

🔑 30초 답변

Critical section(임계구역)은 공유 자원에 접근해 한 번에 한 스레드만 실행해야 하는 코드 구간이다. 보호하려면 상호배제(mutual exclusion)를 제공하는 도구 — mutex/lock, semaphore, serial queue, actor — 로 그 구간을 감싼다. 좋은 보호는 상호배제·진행성(progress)·유한 대기(bounded waiting)를 만족해야 하며, 실무에서는 구간을 최대한 짧게 유지하고 그 안에서 블로킹 호출이나 외부 콜백을 부르지 않는 것이 핵심이다.

CS 원리

임계구역 문제는 Dijkstra가 정식화한 상호배제 문제다. 해법은 계층적이다 — 하드웨어 atomic 명령(CAS, test-and-set) 위에 OS primitive(mutex, semaphore)가 서고, 그 위에 언어 수준 추상(monitor, actor)이 있다. 올바른 해법은 세 조건을 지켜야 한다: 동시에 한 스레드만 들어가고(mutual exclusion), 아무도 못 들어가는 교착이 없고(progress), 무한히 기다리는 스레드가 없어야 한다(bounded waiting).

동시에 진입하려는 스레드 Thread A Thread B Thread C 한 번에 1개 B·C는 대기 임계구역 (critical section) Thread A 실행 중 공유 자원 A가 나오면 락이 풀리고 대기하던 B가 들어간다. 단, 들어가는 순서는 보장되지 않는다. 보호 수단 (같은 목적, 다른 층위) os_unfair_lock · NSLock · Semaphore serial queue · actor (언어 수준) 구간이 길수록 · 그 안에서 블로킹하거나 외부 콜백을 부를수록 위험해진다.
임계구역이 보장하는 건 "한 번에 하나"뿐이다. 들어가는 순서나 대기 시간은 보장되지 않으므로, 구간을 짧게 유지하고 그 안에서 블로킹 호출·외부 콜백을 부르지 않는 것이 핵심이다.

iOS에서는

Apple 플랫폼에서 대표 도구는 os_unfair_lock(과거 스핀락을 대체하며 priority inversion을 회피), NSLock, DispatchSemaphore, serial DispatchQueue, 그리고 Swift Concurrency의 actor(컴파일러가 강제하는 임계구역)다. os_unfair_lock은 재귀 잠금을 지원하지 않으며, iOS 16+에서는 값 복사 위험이 없는 OSAllocatedUnfairLock을 권장한다.

iOS 16+ 권장: 값 복사로 깨지지 않는 락 (Swift)
let counter = OSAllocatedUnfairLock(initialState: 0)
counter.withLock { $0 += 1 }   // 임계구역: 짧게 · 이 안에서 await·동기 대기 금지

실험 · 도구

공유 카운터를 락 없이/락으로 증가시키며 TSan으로 차이를 확인한다. Instruments의 System Trace로 lock contention(락을 기다리며 blocked 상태로 쌓이는 스레드)을 관찰하고, os_signpost로 임계구역 체류 시간을 측정하면 병목을 정량화할 수 있다.

프로젝트 적용

임계구역 안에서 await·동기 대기·외부 콜백 호출을 하지 않는다(deadlock·priority inversion의 씨앗).

임계구역 안에서 하면 안 되는 것
import Foundation
import os

final class MetricsStore {
    private let state = OSAllocatedUnfairLock(initialState: [String: Int]())
    private let semaphore = DispatchSemaphore(value: 0)
    weak var delegate: MetricsDelegate?

    // ❌ 임계구역 안에서 하면 안 되는 세 가지
    func recordBad(_ key: String) {
        state.withLock { dict in
            dict[key, default: 0] += 1
            semaphore.wait()            // ① 동기 대기 — deadlock 씨앗
            delegate?.didUpdate()       // ② 외부 콜백 — 어떤 락을 또 잡을지 모른다
            // ③ await — 애초에 여기선 컴파일도 안 된다(그래서 락과 async는 섞기 어렵다)
        }
    }

    // ✅ 락 안에서는 '순수 계산'만. 부수효과는 밖으로 꺼낸다.
    func record(_ key: String) {
        let snapshot: [String: Int] = state.withLock { dict in
            dict[key, default: 0] += 1
            return dict                 // 필요한 값만 복사해서 나온다
        }
        delegate?.didUpdate(snapshot)   // ✅ 락을 놓은 뒤에 호출
    }
}

protocol MetricsDelegate: AnyObject {
    func didUpdate()
    func didUpdate(_ snapshot: [String: Int])
}

// 이유: 락을 쥔 채 남의 코드를 부르면 그 코드가 무엇을 하는지 알 수 없다.
// 그쪽이 다른 락을 잡으면 잠금 순서가 뒤엉켜 deadlock이 되고,
// 낮은 우선순위 스레드가 락을 쥐고 밀리면 priority inversion이 된다.

락 하나가 보호하는 상태의 범위를 문서화하고, 여러 락을 잡아야 하면 잠그는 순서 규칙을 정해 deadlock을 예방한다.

락 범위 문서화와 잠금 순서 고정
import Foundation
import os

// 여러 락이 있으면 '어떤 순서로 잡는가'를 프로젝트 전역 규칙으로 못 박는다.
// 순서가 스레드마다 다르면 그 자체로 deadlock이다.

//   스레드 A: userLock → cartLock
//   스레드 B: cartLock → userLock     ← 서로를 기다리며 영원히 멈춘다

final class Store {
    // ── 잠금 순서 규칙: 반드시 user → cart 순서로만 잡는다 (역순 금지) ──

    /// 보호 범위: currentUser, loginState
    private let userLock = OSAllocatedUnfairLock(initialState: UserState())

    /// 보호 범위: items, coupon  (userLock을 잡은 뒤에만 잡을 수 있다)
    private let cartLock = OSAllocatedUnfairLock(initialState: CartState())

    // ✅ 규칙을 지킨 접근
    func applyMemberDiscount() {
        userLock.withLock { user in
            let isMember = user.isMember          // inout 파라미터를 클로저로 캡처하지 않는다
            cartLock.withLock { cart in           // (Swift 6에서는 캡처 시 컴파일 에러)
                cart.discount = isMember ? 0.1 : 0
            }
        }
    }

    // ✅ 더 나은 방법: 애초에 락을 하나로 합쳐 순서 문제를 없앤다
    private struct Combined { var user = UserState(); var cart = CartState() }
    private let combined = OSAllocatedUnfairLock(initialState: Combined())

    func applyBetter() {
        combined.withLock { state in          // 클로저는 inout 값 '하나'를 받는다
            state.cart.discount = state.user.isMember ? 0.1 : 0
        }
    }
}

struct UserState { var isMember = false }
struct CartState { var discount: Double = 0 }

// 락이 둘 이상 필요해지는 순간이 "이 상태들을 하나의 actor로 합쳐야 하나?"를
// 물어볼 시점이다.

os_unfair_lock은 struct라 값으로 복사되면 보호가 깨진다 — 반드시 포인터나 OSAllocatedUnfairLock으로 다룬다.

os_unfair_lock 복사 사고 막기
import Foundation
import os

// os_unfair_lock은 struct다. 값으로 복사되면 서로 다른 락이 되어 보호가 무효가 된다.

// ❌ 이 코드는 컴파일도 되고 지금은 대개 동작하지만, 언어가 아무것도 보장하지 않는다
final class BrokenCache {
    private var lock = os_unfair_lock()      // struct
    private var storage: [String: Int] = [:]

    func set(_ v: Int, _ k: String) {
        // &lock의 inout-to-pointer 변환이 주는 포인터는 '이 호출 동안만' 유효하고
        // 주소 안정성이 보장되지 않는다. withUnsafeMutablePointer로 감싸도 똑같다 —
        // 그건 해법이 아니다. 아래 ①·②처럼 주소를 고정해야 한다
        os_unfair_lock_lock(&lock)
        storage[k] = v
        os_unfair_lock_unlock(&lock)
    }
}

// ✅ ① 가장 안전 — 복사 사고가 구조적으로 불가능한 래퍼 (iOS 16+)
final class SafeCache: @unchecked Sendable {
    // 모든 storage 접근이 이 락을 통과한다 — 아래 메서드가 유일한 진입점
    private let state = OSAllocatedUnfairLock(initialState: [String: Int]())

    func set(_ v: Int, _ k: String) { state.withLock { $0[k] = v } }
    func get(_ k: String) -> Int?   { state.withLock { $0[k] } }
}

// ✅ ② 하위 버전을 지원해야 하면 포인터로 고정한다
final class LegacyCache {
    private let lock: UnsafeMutablePointer<os_unfair_lock>
    private var storage: [String: Int] = [:]

    init() {
        lock = .allocate(capacity: 1)
        lock.initialize(to: os_unfair_lock())   // 주소가 고정된다
    }
    deinit { lock.deinitialize(count: 1); lock.deallocate() }

    func set(_ v: Int, _ k: String) {
        os_unfair_lock_lock(lock); defer { os_unfair_lock_unlock(lock) }
        storage[k] = v
    }
}

// 같은 이유로 NSLock(class)은 복사 문제가 없지만 os_unfair_lock보다 느리다.
⚠️ 흔한 오해

"락만 걸면 안전하다"는 위험한 단순화다. 락 범위가 불변식 전체를 덮지 못하거나, 여러 락의 잠금 순서가 스레드마다 엇갈리면 deadlock이 난다. 게다가 os_unfair_lock을 값 타입처럼 복사해 쓰면 서로 다른 락 인스턴스가 되어 보호가 아예 무효가 된다.

🧒 쉽게 이해하기

화장실 한 칸에 문고리 잠금장치가 달린 이유와 똑같다. 공유 자원을 만지는 그 코드 구간이 critical section이고, 한 번에 한 명만 들어가게 막는 게 상호배제다. 제대로 된 잠금장치는 세 가지를 지켜야 한다 — 두 명이 동시에 못 들어가고, 아무도 못 들어가는 상태로 굳지 않고, 한 사람만 계속 밀려 영영 못 들어가는 일도 없어야 한다.

실무에서 더 중요한 건 규칙보다 "짧게 쓰고 나오기"다. 안에서 전화를 받거나(외부 콜백) 배달을 기다리면(await·동기 대기) 밖에 줄이 순식간에 길어지고, 최악의 경우 안에 있는 사람이 밖에 있는 사람을 기다리는 교착이 된다. 그리고 os_unfair_lock을 값으로 복사해 쓰는 실수는, 문고리를 복사해다가 문마다 하나씩 달아버린 꼴이다 — 각자 자기 문은 잘 잠그는데 정작 화장실은 하나라 아무도 막히지 않는다.

꼬리 질문

spinlock과 mutex는 각각 어떤 상황에서 유리한가?

spinlock은 임계구역이 극히 짧고 멀티코어일 때 유리하다 — 잠깐 CPU를 돌며 기다리는 비용이 스레드를 재웠다 깨우는 컨텍스트 스위칭 비용보다 싸기 때문이다. 반대로 락을 오래 쥐거나 싱글코어면 spin은 CPU를 통째로 낭비하므로 스레드를 재우는 mutex가 낫다. 다만 iOS에서 순수 spinlock(OSSpinLock)은 priority inversion 위험 때문에 폐기됐고, 짧은 임계구역이면 os_unfair_lock을 쓴다.

쉽게 말하면 곧 비겠다 싶으면 문 앞에 서서 기다리는 게 빠르고(spinlock), 오래 걸릴 것 같으면 자리로 돌아갔다가 부르면 오는 게 낫다(mutex). 코어가 하나뿐일 때 서서 기다리는 건 최악인데, 내가 CPU를 붙잡고 있는 동안 정작 안에 있는 사람이 일을 끝낼 수가 없기 때문이다.
재귀 락(recursive lock)이 필요한 상황과 그 위험은?

같은 스레드가 이미 잡은 락을 재진입 호출 경로에서 다시 잡아야 할 때 재귀 락이 필요하다. 위험은 그것이 대개 설계 냄새라는 점이다 — 락 소유 경계가 흐려지고, 재귀 도중 불변식이 반쯤 깨진 상태로 다시 임계구역에 들어갈 수 있어 버그를 숨긴다. os_unfair_lock은 재귀를 지원하지 않으므로, 재귀 락이 당기면 대개 락 범위를 재설계하는 편이 낫다.

쉽게 말하면 내가 잠근 문을 나 자신이 또 열어야 하는 상황이다. 재귀 락은 그걸 허락해주지만, 대개는 "왜 이런 구조가 됐지?"를 물어야 할 신호다. 방을 반쯤 어질러둔 채 다시 들어가는 셈이라, 그 중간 상태를 보고 판단하는 코드가 조용히 틀린 답을 낸다.
priority inversion이란 무엇이고 os_unfair_lock은 이를 어떻게 다루나?

낮은 우선순위 스레드가 락을 쥔 채 중간 우선순위 스레드에 선점당하면, 그 락을 기다리는 높은 우선순위 스레드가 사실상 중간 스레드에 발목 잡히는 현상이다. os_unfair_lock은 현재 소유 스레드를 커널에 기록하기 때문에, 대기자가 높은 우선순위면 커널이 소유자에게 우선순위를 상속(priority donation)시켜 빨리 락을 놓게 만든다. 폐기된 OSSpinLock이 이 정보를 몰라 취약했던 것과 대비된다.

쉽게 말하면 급한 사람이 화장실 앞에 줄을 섰는데, 안에 있는 사람은 안 급한 사람이다. 문제는 그 안 급한 사람이 어정쩡하게 급한 다른 사람들에게 계속 밀려서 나오질 못한다는 것. os_unfair_lock은 "지금 안에 누가 있는지" 이름표를 커널에 적어두기 때문에, 커널이 그 사람에게 급함을 잠깐 빌려줘 빨리 나오게 한다. 이름표가 없던 OSSpinLock은 누구에게 빌려줘야 할지조차 몰랐다.

Q5. Serial queue가 항상 하나의 고정된 Thread를 사용하는가?

🔑 30초 답변

아니다. Serial queue가 보장하는 것은 "한 번에 하나의 작업만, 제출한 순서대로" 실행한다는 것이지, 매번 같은 물리 스레드에서 실행한다는 것이 아니다. GCD는 관리형 스레드 풀에서 그때그때 스레드를 배정하므로, 같은 serial queue의 서로 다른 블록이 서로 다른 스레드에서 돌 수 있다. 유일하게 스레드가 고정되는 예외는 main queue(항상 메인 스레드)뿐이다. 그래서 특정 스레드 식별이나 TLS에 의존하는 코드는 serial queue 위에서 깨질 수 있다.

CS 원리

serial queue의 계약은 '직렬화(순차성) + 상호배제'이지 'thread affinity(스레드 고정)'가 아니다. 큐는 무엇을 어떤 순서로 실행할지 정하는 정책이고, 스레드는 그것을 돌리는 실행 자원이다. 순차성만 지켜지면 어떤 스레드가 그 일을 하든 계약 위반이 아니다.

iOS에서는

GCD serial queue는 한 시점에 한 스레드만 이 큐의 작업을 꺼내 실행하지만, 작업과 작업 사이에 그 스레드가 풀로 회수되고 다음 작업은 다른 스레드가 집어들 수 있다. DispatchQueue.main만 main 스레드에 고정된다. 현재 실행 위치를 확인하려면 스레드가 아니라 큐를 식별해야 하며, 이를 위해 DispatchQueue.setSpecific(key:value:)/getSpecificdispatchPrecondition이 제공된다.

순서는 약속, 스레드는 약속이 아니다 serial queue · 제출 순서 Block A Block B Block C Worker Thread 7 Worker Thread 3 Worker Thread 7 A → B → C 순서와 "한 번에 하나"는 지켜진다. 그런데 B만 다른 스레드에서 돌았다 — 계약 위반이 아니다. 스레드가 고정되는 큐는 main queue 하나뿐
serial queue의 계약은 직렬화이지 thread affinity가 아니다. 그래서 TLS나 스레드 식별에 기댄 코드는 블록 사이에서 조용히 어긋난다.

실험 · 도구

사용자 serial queue에 여러 블록을 제출하고 각 블록에서 pthread_self() 또는 Thread.current를 찍어보면, 값이 블록마다 달라질 수 있음을 확인할 수 있다. 반면 main queue에서는 항상 같은 스레드다. 큐 판별이 필요하면 queue-specific key로 확인한다(스레드 식별이 아님).

프로젝트 적용

"이 serial queue니까 항상 이 스레드"라는 가정을 코드에 심지 않는다.

스레드 고정을 가정하지 않기
import Foundation

let serial = DispatchQueue(label: "com.app.db")

// ❌ serial queue라고 스레드가 고정될 거라 믿는 코드
final class BrokenSession {
    private var threadName: String?

    func begin() {
        serial.async {
            self.threadName = Thread.current.name   // 이번엔 "Thread-7"
        }
    }

    func commit() {
        serial.async {
            // 다음 블록은 전혀 다른 스레드에서 돌 수 있다 → 항상 불일치
            assert(self.threadName == Thread.current.name)   // 💥 깨진다
        }
    }
}

// ✅ 보장되는 것은 '순서'와 '상호배제'뿐이다. 그걸 전제로 설계한다.
actor DBSession {
    private var isOpen = false
    func begin() { isOpen = true }
    func commit() { precondition(isOpen); isOpen = false }
}

// 유일한 예외: main queue = main thread (UI 프레임워크 요구 때문에 고정된다)
@MainActor
func updateUI() {
    assert(Thread.isMainThread)      // ✅ 이건 항상 참이다
}

// ⚠️ 스레드에 묶인 레거시 API(일부 C 라이브러리, OpenGL 컨텍스트 등)를 써야 하면
//    GCD가 아니라 전용 Thread + RunLoop를 직접 만들어야 한다.

현재 실행 컨텍스트 확인은 Thread.isMainThread(메인 한정) 또는 queue-specific key로 하고, 임의 스레드 식별에 의존하지 않는다.

실행 컨텍스트는 queue-specific key로 확인
import Foundation

// "지금 이 큐 위인가?"를 스레드 신원으로 판단하면 틀린다. 큐에 표식을 달아 확인한다.

final class Database {
    private let queue = DispatchQueue(label: "com.app.db")
    private static let key = DispatchSpecificKey<UInt8>()
    private let marker: UInt8 = 1

    init() {
        queue.setSpecific(key: Self.key, value: marker)   // ✅ 큐에 표식을 붙인다
    }

    private var isOnQueue: Bool {
        DispatchQueue.getSpecific(key: Self.key) == marker
    }

    // ✅ 이미 큐 위면 그냥 실행, 아니면 진입 — 재진입 self-deadlock을 피한다
    func perform<T>(_ work: () throws -> T) rethrows -> T {
        isOnQueue ? try work() : try queue.sync(execute: work)
    }

    // ✅ 진입점 방어
    func assertOnQueue() {
        dispatchPrecondition(condition: .onQueue(queue))
    }
}

// 메인 한정이면 이게 가장 단순하다
func requireMain() {
    dispatchPrecondition(condition: .onQueue(.main))
    // 또는 assert(Thread.isMainThread)
}

// Swift Concurrency에서는 컴파일러가 대신 검사해 준다 — 런타임 확인이 필요 없다
@MainActor func onMainOnly() {}

큐 컨텍스트 저장 용도로 TLS를 쓰지 않는다 — 스레드가 재사용/교체되면 값이 어긋난다.

큐 컨텍스트를 TLS에 저장하지 않기
import Foundation

// TLS(thread-local storage)는 '스레드 = 작업 단위'일 때만 성립한다.
// GCD·Swift Concurrency에서는 스레드가 여러 작업 사이를 오간다.

let queue = DispatchQueue(label: "com.app.tx")

// ❌ 트랜잭션 컨텍스트를 TLS에 — 다음 작업이 남은 값을 주워 간다
func beginBad(_ tx: String) {
    queue.async {
        Thread.current.threadDictionary["tx"] = tx
        step1()
    }
}
func step1() {
    let tx = Thread.current.threadDictionary["tx"] as? String
    print("tx: \(tx ?? "없음")")     // 다른 작업의 tx가 보일 수 있다
}

// ✅ ① 컨텍스트를 값으로 들고 다닌다
struct Transaction { let id: String }

func begin(_ tx: Transaction) {
    queue.async { step1(tx) }
}
func step1(_ tx: Transaction) { print("tx: \(tx.id)") }

// ✅ ② Swift Concurrency라면 task-local — Task 스코프라 스레드와 무관하다
enum TxContext {
    @TaskLocal static var current: Transaction?
}

func beginAsync(_ tx: Transaction) async {
    await TxContext.$current.withValue(tx) {
        await deepStep()
    }
}
func deepStep() async {
    print("tx: \(TxContext.current?.id ?? "없음")")   // ✅ 항상 이 Task의 값
}
⚠️ 흔한 오해

"serial queue는 전용 스레드 하나를 갖는다"는 오해다. 보장되는 것은 순서와 상호배제이지 스레드 고정이 아니다. 스레드가 고정되는 큐는 main queue뿐이며, 그것도 UI 프레임워크가 메인 스레드를 요구하기 때문이지 serial이라서가 아니다.

🧒 쉽게 이해하기

번호표 뽑고 기다리는 은행 창구를 떠올려 보자. serial queue가 약속하는 건 "한 번에 한 명씩, 번호 순서대로"다. 그런데 그 번호를 불러주는 직원이 매번 같은 사람이라는 약속은 어디에도 없다. 1번 손님은 김 대리가, 2번은 박 주임이 받아도 "한 명씩 순서대로"는 하나도 안 깨진다.

여기서 사고가 나는 게 TLS(thread-local storage, 스레드마다 따로 갖는 개인 메모지)다. 앞 블록에서 메모지에 적어둔 값이 다음 블록에선 사라져 있거나, 반대로 남이 쓰던 메모지를 물려받아 엉뚱한 값이 이미 적혀 있기도 하다. 그래서 "지금 어느 줄에 서 있나"는 직원 얼굴(Thread.current)이 아니라 창구에 붙여둔 표식(queue-specific key)으로 확인해야 한다. 직원이 고정된 유일한 창구가 main queue인데, 그것도 serial이라서가 아니라 UI 장비가 그 자리에만 놓여 있어서다.

꼬리 질문

특정 물리 스레드에 반드시 고정돼야만 하는 작업(스레드 친화성을 요구하는 C 라이브러리, 특정 런루프에 등록된 리소스 등)이 있다면 GCD 큐만으로 스레드 고정을 보장할 수 있나? 없다면 어떻게 해결하나(예: 전용 NSThread + 런루프)?

못 한다. serial queue조차 보장하는 건 직렬화지 같은 물리 스레드가 아니고(main queue만 예외인데, 그것도 UIKit·메인 런루프가 그 스레드에 묶여 있어서다), 그래서 TLS에 상태를 숨기거나 초기화·사용을 같은 스레드에서 요구하는 C 라이브러리, 특정 런루프에 등록된 리소스(CFStream·일부 타이머·current-context를 요구하는 API 등)는 블록 사이에 풀이 워커를 갈아끼우면 조용히 깨진다. 이럴 땐 스레드를 직접 소유하는 게 정석이다 — 전용 Thread(NSThread)를 띄우고 그 위에서 RunLoop을 소스나 포트로 살려 둔 뒤, 모든 호출을 perform(_:on:...)이나 그 런루프 스케줄을 통해 그 한 스레드로 모으면 라이브러리가 요구하는 단일 스레드 친화성이 지켜진다.

쉽게 말하면 "이 서류는 반드시 김 대리 손으로만 처리해야 한다"는 요구는 번호표 시스템(GCD 큐)으로 못 맞춘다. 큐가 약속하는 건 순서지 사람이 아니니까. 그럴 땐 김 대리를 전담으로 앉히는 수밖에 없다 — 전용 Thread를 하나 띄우고 그 위에서 RunLoop을 계속 돌려 자리를 지키게 한 뒤, 그 일은 전부 그쪽으로만 보낸다.
serial queue에서 TLS에 값을 저장하면 어떤 종류의 버그가 생기나?

한 블록에서 TLS에 넣은 값이 다음 블록에서는 사라져 있을 수 있다 — 두 블록이 서로 다른 풀 스레드에서 돌 수 있기 때문이다. 반대로 재사용된 스레드에 다른 큐의 작업이 남긴 값이 그대로 보여, 초기화했다고 착각한 값이 오염된 채 읽히기도 한다. 결과는 재현이 어려운 간헐적 버그다. 큐 컨텍스트를 남기고 싶으면 TLS가 아니라 queue-specific key를 써야 한다.

쉽게 말하면 TLS(thread-local storage)는 스레드마다 하나씩 붙어 있는 개인 메모지다. 그런데 serial queue는 블록마다 담당 직원이 바뀔 수 있으니, 아까 적어둔 메모지가 다음 블록에선 없다. 더 고약한 건 남이 쓰던 메모지를 물려받아 지워지지 않은 낙서가 이미 적혀 있는 경우 — 초기화한 줄 알고 남의 값을 읽는다.
queue-specific key로 '지금 이 큐 위인지'를 안전하게 판별하는 방법은?

대상 큐에 setSpecific(key:value:)로 표식 값을 심어 두고, 판별 시점에 DispatchQueue.getSpecific(key:)로 읽어 그 값과 같은지 본다. getSpecific은 현재 실행 중인 큐와 그 target queue 체인을 거슬러 조회하므로, 스레드가 아니라 '지금 이 큐 계층 위인지'를 정확히 알려준다. dispatchPrecondition(condition:.onQueue:)은 이와 다른 메커니즘을 쓴다 — queue-specific 값이 아니라 큐의 drain lock 소유자와 현재 스레드의 dispatch 큐 프레임 스택을 보고, target queue 계층도 함께 따라간다. 그래서 setSpecific을 하지 않은 큐에도 그대로 쓸 수 있다.

쉽게 말하면 큐에 명패를 미리 붙여두고, 나중에 "지금 내가 선 창구에 그 명패가 붙어 있나?"를 물어보는 방식이다. getSpecific은 그 창구뿐 아니라 그 창구가 얹혀 있는 상위 큐(target queue)까지 거슬러 올라가며 확인해준다. 직원 얼굴이 아니라 명패를 보는 것이라 담당자가 바뀌어도 답이 흔들리지 않는다.

Q6. GCD Queue와 Thread는 왜 같은 개념이 아닌가?

🔑 30초 답변

Queue는 '무엇을 어떤 순서·정책으로 실행할지'를 담는 작업 대기열이고, Thread는 그 작업을 실제 CPU에서 돌리는 실행 자원이다. GCD의 핵심은 개발자가 스레드를 직접 만들지 않고 큐에 작업을 제출하면, 런타임이 스레드 풀에서 스레드를 붙였다 뗐다 하는 것이다. 그래서 큐 개수와 스레드 개수는 무관하며, 하나의 스레드가 여러 큐의 작업을, 하나의 큐가 시간에 따라 여러 스레드를 거칠 수 있다.

CS 원리

이것은 '작업(task)'과 '실행자(worker)'를 분리하는 스레드 풀 패턴이다. 큐는 생산자-소비자 대기열에 스케줄 정책(serial/concurrent, 우선순위)을 얹은 논리적 개념이고, 스레드는 커널이 스케줄하는 물리적 실행 단위다. 이 분리 덕분에 수많은 작업을 소수의 스레드로 처리할 수 있다.

iOS에서는

GCD는 전역 concurrent queue와 사용자 큐를 관리형 워커 스레드 풀에 매핑한다. 문제는 스레드 폭증(thread explosion)이다 — concurrent queue에 제출된 작업이 동기 블로킹으로 워커를 막으면, GCD는 남은 작업을 처리하려고 새 스레드를 계속 만든다. Swift Concurrency는 이를 개선해 cooperative pool의 스레드 수를 대략 코어 수로 묶는다. qos_class는 워커 스레드의 우선순위에 반영된다.

동기 블로킹 · 워커가 막힘 대기 중인 작업 ▮▮▮▮▮▮▮▮ Worker 1 · blocked Worker 2 · blocked Worker 3 · blocked Worker 4 · 새로 투입 → 또 blocked 하나 막힐 때마다 하나씩 더 투입된다 일은 안 늘고 스레드만 늘어난다 await · 워커를 반납 대기 중인 작업 ▮▮▮▮▮▮▮▮ Worker 1 · 다른 작업 처리 중 Worker 2 · 다른 작업 처리 중 Worker 3 · 다른 작업 처리 중 기다리는 작업은 스레드를 쥐고 있지 않다 워커 수는 대략 코어 수에 머문다 스레드는 그대로, 일은 계속 나간다
스레드가 폭증하는 원인은 큐 개수가 아니라 워커를 붙잡는 동기 블로킹이다. 해법은 스레드를 늘리는 게 아니라 워커를 막지 않는 것이다.

실험 · 도구

global concurrent queue에 DispatchSemaphore.wait() 같은 동기 대기 작업을 다수 제출하고, 아래 코드의 logThreadCount(task_threads 기반)나 Xcode Debug Navigator의 스레드 목록으로 관찰하면 스레드 수가 급증하는 것을 볼 수 있다. Instruments의 System Trace로 스레드 생성 폭증과 그로 인한 스케줄링 오버헤드를 확인한다.

프로젝트 적용

블로킹 작업을 global concurrent queue에 대량 제출하지 않는다 — thread explosion으로 오히려 병목/교착이 생긴다.

global 큐에 블로킹 작업을 쏟아붓지 않기
import Foundation

// GCD 전역 큐의 워커 스레드는 유한하다(대략 64개/QoS). 블로킹 작업으로 다 채우면
// 새 작업이 스레드를 못 받아 굶고, 최악의 경우 교착으로 보인다.

// ❌ 100개를 한꺼번에 던진다 — 각각이 스레드를 붙잡고 대기한다
func downloadAllBad(_ urls: [URL]) {
    for url in urls {
        DispatchQueue.global().async {
            _ = try? Data(contentsOf: url)     // 동기 블로킹
        }
    }
}

// 스레드 수를 실제로 관찰해 보면 폭증이 눈에 보인다
func logThreadCount(_ tag: String) {
    var count: mach_msg_type_number_t = 0
    var threads: thread_act_array_t?
    if task_threads(mach_task_self_, &threads, &count) == KERN_SUCCESS {
        print("[\(tag)] 스레드 \(count)개")
        if let t = threads {
            vm_deallocate(mach_task_self_, vm_address_t(UInt(bitPattern: t)),
                          vm_size_t(Int(count) * MemoryLayout<thread_t>.size))
        }
    }
}

// ✅ 애초에 블로킹을 안 하는 API로 바꾸는 게 1순위다
func downloadAll(_ urls: [URL]) async throws -> [Data] {
    try await withThrowingTaskGroup(of: Data.self) { group in
        for url in urls {
            group.addTask { try await URLSession.shared.data(from: url).0 }
        }
        var out: [Data] = []
        for try await d in group { out.append(d) }
        return out
    }
}

블로킹이 불가피하면 전용 serial queue로 몰거나 동시 실행 개수를 세마포어로 제한한다.

불가피한 블로킹은 전용 큐·세마포어로 제한
import Foundation

// 레거시 C 라이브러리처럼 정말 블로킹뿐이라면, 그 블로킹을 '가둔다'.

// ✅ ① 전용 serial queue — 동시 실행 1개로 고정
final class LegacyDecoder {
    private let queue = DispatchQueue(label: "com.app.decode", qos: .utility)

    func decode(_ data: Data, completion: @escaping (Image) -> Void) {
        queue.async {
            let image = blockingDecode(data)     // 이 큐 하나만 붙잡는다
            completion(image)
        }
    }
}

// ✅ ② 동시 실행 개수를 명시적으로 제한하는 풀
final class BoundedPool {
    private let semaphore: DispatchSemaphore
    private let queue: DispatchQueue

    init(limit: Int, label: String) {
        semaphore = DispatchSemaphore(value: limit)
        queue = DispatchQueue(label: label, qos: .utility, attributes: .concurrent)
    }

    func run(_ work: @escaping () -> Void) {
        semaphore.wait()                   // ✅ 제출 '전'에 자리를 확보한다 — 워커를 붙잡지 않는다
        queue.async {
            defer { self.semaphore.signal() }
            work()
        }
    }
}
// ⚠️ wait를 queue.async 안에 넣으면 정반대가 된다 — 대기하는 블록마다 워커를 하나씩
//    붙잡아 그 자체로 thread explosion이다(limit 4에 100개 제출 → 실측 71스레드).

let decodePool = BoundedPool(limit: ProcessInfo.processInfo.activeProcessorCount,
                             label: "com.app.decode.pool")

// ✅ ③ async 세계라면 세마포어 대신 이 패턴 (스레드를 막지 않는다)
actor Limiter {
    private let limit: Int
    private var running = 0
    private var waiters: [CheckedContinuation<Void, Never>] = []

    init(limit: Int) { self.limit = limit }

    func run<T>(_ work: () async throws -> T) async rethrows -> T {
        if running >= limit {
            await withCheckedContinuation { waiters.append($0) }
        }
        running += 1
        defer {
            running -= 1
            if !waiters.isEmpty { waiters.removeFirst().resume() }
        }
        return try await work()
    }
}

struct Image {}
func blockingDecode(_ d: Data) -> Image { Image() }

QoS를 작업 성격(사용자 대기 vs 백그라운드)에 맞게 지정해 우선순위 역전을 줄인다.

QoS를 작업 성격에 맞게 지정
import Foundation

// QoS는 '얼마나 급한가'를 시스템에 알려 주는 신호다. 잘못 주면
// 우선순위 역전(낮은 QoS가 높은 QoS를 막음)이나 전력 낭비가 생긴다.

// ✅ 사용자가 화면에서 기다린다 → .userInitiated
func loadCurrentScreen() {
    DispatchQueue.global(qos: .userInitiated).async {
        let data = fetchSync()
        DispatchQueue.main.async { render(data) }
    }
}

// ✅ 곧 필요하지만 지금 기다리진 않는다 → .utility (E 코어로 갈 수 있다)
func prefetchNextPage() {
    Task(priority: .utility) { await prefetch() }
}

// ✅ 사용자가 전혀 모르는 뒷정리 → .background
let cleanup = DispatchQueue(label: "com.app.cleanup", qos: .background)

// ❌ 전부 .userInitiated로 올리면 우선순위가 의미를 잃고 전력만 더 쓴다
// ❌ 반대로 UI 대기 작업을 .background로 두면 E 코어에서 한참 걸린다

// ⚠️ 우선순위 역전: 높은 QoS가 낮은 QoS의 결과를 기다리면 시스템이
//    낮은 쪽을 일시적으로 승격해 준다(priority donation). 다만 승격이
//    통하지 않는 경로(예: 세마포어로 막아 둔 경우)가 있으니
//    "높은 QoS가 낮은 QoS를 기다리는 구조" 자체를 피하는 게 낫다.

// Swift Concurrency에서는 Task 우선순위가 같은 역할을 하고,
// 자식 Task에 자동으로 상속된다.
func structured() async {
    await withTaskGroup(of: Void.self) { group in
        group.addTask(priority: .utility) { await prefetch() }
    }
}

func fetchSync() -> Data { Data() }
func render(_ d: Data) {}
func prefetch() async {}
⚠️ 흔한 오해

"큐를 많이 만들면 스레드가 많아진다" 또는 "큐 하나 = 스레드 하나"는 둘 다 틀렸다. 큐는 논리적 대기열이고 스레드는 물리적 자원이며, 그 매핑은 런타임이 관리한다. 스레드가 폭증하는 진짜 원인은 큐 개수가 아니라 워커를 붙잡는 동기 블로킹이다.

🧒 쉽게 이해하기

택배 물류센터로 보면 깔끔하다. 배송 목록이 queue고, 실제로 뛰는 배송기사가 thread다. 목록을 열 장으로 쪼개 만든다고 기사가 열 명이 되지는 않는다 — 목록은 "무엇을 어떤 순서로"고, 기사는 "실제로 움직이는 사람"이라, 그 둘을 짝지어 주는 건 센터장(런타임)이다. 그래서 큐 개수와 스레드 개수는 애초에 딴 얘기고, 한 기사가 이 목록 저 목록을 오갈 수도 있다.

기사가 폭증하는 진짜 이유는 목록 수가 아니다. 벨을 누르고 문이 열릴 때까지 기사가 그 자리에 가만히 서 있으면(동기 블로킹), 센터장 눈에는 "물량은 쌓였는데 움직이는 사람이 없네"로 보여 기사를 한 명 더 부른다. 그 기사도 또 다른 문 앞에 서 있으면 또 한 명. 이렇게 붙는 게 thread explosion이다. 해법은 기사를 더 뽑는 게 아니라 문 앞에서 서 있지 않는 것 — await와 비동기 I/O가 하는 일이 바로 "벨만 누르고 다른 집부터 돌기"다.

꼬리 질문

thread explosion이 나면 워커 스레드가 무한정 늘어나나, 아니면 상한이 있나? GCD가 '이 워커는 블로킹됐으니 새 스레드를 띄워야 한다'고 판단해 스레드를 늘리는 시점은 어떤 메커니즘으로 정해지나?

무한은 아니고 상한이 있다 — GCD 워커 풀은 대략 QoS 버킷당 64개 수준으로 묶여 있어, 진짜 끝없이 늘기보다 그 한계 근처에서 교착하거나 스케줄링으로 허덕이는 쪽에 가깝다. 증설을 결정하는 건 GCD가 네 코드를 들여다봐서가 아니라, 커널 워크큐가 우선순위 버킷마다 '지금 실제로 돌고 있는(블로킹되지 않은) 워커 수'를 목표 동시 실행 폭(대략 활성 코어 수)과 견주는 데서 나온다. 워커가 블로킹 syscall(sema.wait·동기 I/O)에 들어가면 커널은 그 스레드를 돌고 있지 않은 것으로 세고, 처리할 작업이 남았는데 폭이 덜 찼다고 판단해 워커를 하나 더 올린다 — 모든 워커가 막힐 때까지 기다리는 게 아니라 하나 막힐 때마다 하나씩 채우므로, 블로킹이 이어지면 상한까지 기어오른다. 그래서 근본 해법은 "스레드를 더"가 아니라 워커를 아예 블로킹하지 않는 것으로, await·비동기 I/O로 워커가 블로킹 상태에 들어가지 않게 해 애초에 새 스레드 요청이 발생하지 않게 한다.

쉽게 말하면 커널은 "지금 실제로 움직이는 기사가 몇 명인가"만 센다. 벨 누르고 문 앞에 서 있는 기사(블로킹된 워커)는 안 움직이는 걸로 치니까, 물량은 남았는데 인원이 모자란다고 보고 한 명 더 부른다. 한 명 막힐 때마다 한 명씩 채우니 무한은 아니어도 QoS 등급당 64명 근처 천장까지 기어오르고, 그 전에 앱은 이미 허덕인다.
Swift Concurrency의 cooperative pool이 GCD 스레드 관리와 다른 점은?

cooperative pool은 스레드 수를 대략 코어 수로 묶어 두고, 모든 스레드가 항상 전진(forward progress)한다고 가정한다 — 즉 그 위에서 블로킹하면 안 되고, 대기는 await로 스레드를 반납해 처리한다. 반면 GCD는 워커가 막히면 새 스레드를 늘려 이를 보완하려 한다. 계약이 정반대라, GCD에서는 견디던 블로킹이 cooperative pool에서는 오히려 더 치명적이다.

쉽게 말하면 GCD의 방침은 "사람이 막히면 더 뽑는다"이고, cooperative pool의 방침은 "인원은 코어 수로 고정, 대신 아무도 멈춰 서 있지 않기로 약속한다"이다. 약속이 정반대라서, GCD에서 그럭저럭 굴러가던 동기 대기 코드를 Swift Concurrency로 그대로 옮기면 훨씬 크게 터진다.
QoS는 실제 스레드 스케줄링에 어떻게 반영되나?

QoS는 커널 스케줄링 우선순위 밴드로 매핑돼, 높은 QoS일수록 CPU를 먼저 받는다. Apple Silicon에서는 성능(P)·효율(E) 코어 배치에도 영향을 줘서 낮은 QoS 작업은 E 코어로, 타이머 합치기(coalescing)나 I/O 스로틀링 대상이 되기도 한다. 또 우선순위 역전을 줄이기 위해, 높은 QoS 작업이 낮은 QoS 작업의 결과를 기다리면 QoS가 올라가는 승격(donation)이 일어난다.

쉽게 말하면 QoS는 일감에 붙이는 "얼마나 급함" 딱지다. 급한 딱지가 붙으면 CPU를 먼저 얻고 P코어(빠른 코어)에 올라갈 확률이 높아지며, 안 급한 딱지는 E코어(느리지만 전기를 적게 먹는 코어)로 밀리고 I/O도 뒤로 미뤄진다. 그리고 급한 일이 안 급한 일의 결과를 기다리게 되면, 시스템이 뒷사람 딱지를 잠깐 올려준다.

Q7. DispatchQueue.sync가 Deadlock을 만드는 경우는 언제인가?

🔑 30초 답변

현재 스레드가 이미 어떤 serial queue에서 실행 중인데, 그 스레드에서 같은 serial queue로 sync를 호출하면 즉시 deadlock이다. sync는 제출한 블록이 끝날 때까지 호출 스레드를 블록하는데, 그 블록은 자기가 점유한 바로 그 serial queue가 비어야 실행되므로, 서로가 서로의 완료를 영원히 기다린다. 대표 사례가 메인 스레드에서의 DispatchQueue.main.sync {}다. concurrent queue나 다른 큐로의 sync는 일반적으로 이 self-deadlock이 나지 않는다.

CS 원리

sync는 '제출 + 완료까지 호출 스레드 블로킹'이다. serial queue는 한 번에 하나만 실행하므로, 자기 자신을 기다리는 순환 대기(circular wait)가 만들어진다. deadlock의 네 조건 중 hold-and-wait와 circular wait가 여기서 성립한다 — 스레드는 자원(자신의 실행)을 쥔 채 큐가 비기를 기다리고, 큐는 그 스레드가 끝나기를 기다린다.

Main Thread main.sync 호출 → 완료까지 블록됨 Main Queue (serial) 현재 블록(=Main Thread)이 끝나야 sync 블록 실행 블록이 끝나길 대기 큐가 비길 대기 순환 대기 → Deadlock
자기가 점유한 serial 큐로 다시 sync하면 서로가 서로의 완료를 기다린다. 메인에서 DispatchQueue.main.sync를 부르면 앱이 즉시 멈춘다.

iOS에서는

메인 스레드에서 실행하면 즉시 hang (Swift)
// 이미 Main Thread(= Main Queue의 실행 흐름) 위에서
DispatchQueue.main.sync {
    print("절대 실행되지 않음")   // 여기 도달 못 함 → 앱 정지
}

GCD serial queue는 재진입(reentrant)을 지원하지 않아 이 상황이 곧바로 교착이 된다. target queue로 서로 연결된 큐 계층에서도 같은 원리로 발생할 수 있다. 방어 수단으로 dispatchPrecondition(condition: .notOnQueue(queue))를 두면, 잘못된 컨텍스트에서의 sync를 디버그 빌드에서 조기에 잡는다.

프로젝트 적용

serial queue로 sync하기 전에 현재 그 큐 위가 아닌지 dispatchPrecondition으로 방어한다.

sync 호출 전 dispatchPrecondition으로 방어
import Foundation

final class Cache {
    private let queue = DispatchQueue(label: "com.app.cache")
    private var storage: [String: Data] = [:]

    // ❌ 이미 이 큐 위에서 호출되면 즉시 self-deadlock — 앱이 그대로 멈춘다
    func valueUnsafe(_ key: String) -> Data? {
        queue.sync { storage[key] }
    }

    // ✅ 개발 중에 크래시로 잡는다 — 배포 후 정지보다 훨씬 낫다
    func value(_ key: String) -> Data? {
        dispatchPrecondition(condition: .notOnQueue(queue))
        return queue.sync { storage[key] }
    }

    // ✅ 더 나은 방법: 재진입을 구조적으로 허용한다 (queue-specific key)
    private static let key = DispatchSpecificKey<UInt8>()
    init() { queue.setSpecific(key: Self.key, value: 1) }

    func valueReentrant(_ key: String) -> Data? {
        if DispatchQueue.getSpecific(key: Self.key) == 1 {
            return storage[key]              // 이미 큐 위 → 직접 접근
        }
        return queue.sync { storage[key] }   // 밖 → 진입
    }
}

// 가장 흔한 사고: 메인 스레드에서 메인 큐로 sync
func classicDeadlock() {
    DispatchQueue.main.sync {        // 💥 메인에서 부르면 영구 정지
        print("절대 실행되지 않는다")
    }
}

// ✅ 이미 메인인지 확인하고 분기
func onMain(_ work: @escaping () -> Void) {
    if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
}

값을 돌려받으려고 습관적으로 sync를 쓰지 말고, 구조를 async + continuation으로 바꾸는 것을 우선 검토한다.

sync 대신 async + continuation으로 구조 바꾸기
import Foundation

// "값을 돌려받아야 하니 sync를 쓴다"는 대부분 구조를 바꿔 없앨 수 있다.

// ❌ 동기 게터 — 호출자가 어디서 부르는지에 따라 교착 위험이 따라다닌다
final class SyncStore {
    private let queue = DispatchQueue(label: "com.app.store")
    private var value = 0
    func read() -> Int { queue.sync { value } }
}

// ✅ ① actor — 기다림이 suspend라서 스레드를 붙잡지 않는다. 교착이 생길 자리가 없다.
actor Store {
    private var value = 0
    func read() -> Int { value }
    func write(_ v: Int) { value = v }
}

// ✅ ② 레거시 콜백 API를 감싸야 하면 continuation
func legacyLoad(_ done: @escaping (Data) -> Void) {}

func load() async -> Data {
    await withCheckedContinuation { cont in
        legacyLoad { data in cont.resume(returning: data) }
    }
}

// ✅ ③ 실패할 수 있으면 throwing 버전
enum LoadError: Error { case failed }
func legacyLoadMaybe(_ done: @escaping (Data?) -> Void) {}

func loadThrowing() async throws -> Data {
    try await withCheckedThrowingContinuation { cont in
        legacyLoadMaybe { data in
            if let data { cont.resume(returning: data) }
            else { cont.resume(throwing: LoadError.failed) }
        }
    }
}

// ⚠️ continuation은 정확히 한 번만 resume해야 한다.
//    두 번 부르면 크래시, 한 번도 안 부르면 그 Task가 영원히 깨어나지 않는다.
//    withCheckedContinuation은 이 실수를 런타임에 잡아 준다(디버그 빌드).

락과 큐를 섞어 쓸 때는 '큐 진입 → 락' 같은 획득 순서를 프로젝트 전역에서 일관되게 고정한다.

락과 큐를 섞을 때 획득 순서 고정
import Foundation
import os

// 큐와 락을 함께 쓰면 '획득 순서'가 두 종류로 늘어난다. 규칙을 정하지 않으면
// 한쪽은 큐→락, 다른 쪽은 락→큐로 잡아 교착이 난다.

let ioQueue = DispatchQueue(label: "com.app.io")
let stateLock = OSAllocatedUnfairLock(initialState: 0)

// ❌ 스레드 A: 락을 쥔 채 큐로 진입
func badA() {
    stateLock.withLock { _ in
        ioQueue.sync { print("A") }      // 락 → 큐
    }
}

// ❌ 스레드 B: 큐 위에서 락을 잡으려 함
func badB() {
    ioQueue.async {
        stateLock.withLock { _ in print("B") }   // 큐 → 락
    }
}
// A가 락을 쥐고 큐를 기다리고, B가 큐를 쥐고 락을 기다리면 영원히 멈춘다.

// ✅ 프로젝트 전역 규칙: "큐에 먼저 진입하고, 그 안에서만 락을 잡는다"
func good() {
    ioQueue.async {
        stateLock.withLock { $0 += 1 }   // 항상 큐 → 락
    }
}

// ✅ 더 나은 해법: 둘 중 하나를 없앤다. 큐가 이미 직렬화를 해 주면 락은 불필요하다.
final class Counter {
    private let queue = DispatchQueue(label: "com.app.counter")   // serial
    private var value = 0                                          // 락 없음
    func increment() { queue.async { self.value += 1 } }
}

// 규칙은 코드 주석이 아니라 CI 린트나 아키텍처 문서에 남겨야 지켜진다.
⚠️ 흔한 오해

"sync는 어떤 큐로 호출해도 deadlock 위험이 있다"는 과장이다. self-deadlock의 핵심은 '자기가 이미 실행 중인 serial queue로 다시 sync'라는 조건이다. concurrent queue로의 sync는 대개 호출 스레드에서 인라인 실행되므로 이 자기 교착이 나지 않는다. 다만 예외가 있다 — 그 큐의 barrier 블록 안에서 같은 큐로 sync하면 자기 자신을 기다리는 꼴이라, libdispatch가 dispatch_sync called on queue already owned by current thread로 즉시 트랩시킨다(물론 다른 자원을 서로 기다리는 일반적 deadlock은 또 별개 문제다).

🧒 쉽게 이해하기

앞 사람이 나와야 다음 사람이 들어가는 1인 상담실이 있다고 하자. 그런데 지금 그 안에 앉아 있는 사람이 나다. 여기서 내가 "이 일을 상담실 순서에 넣어주세요, 처리될 때까지 저는 여기서 안 나가고 기다릴게요"라고 하면 어떻게 될까. 상담실은 내가 나가야 다음 일을 시작하고, 나는 그 일이 끝나야 나간다. 둘 다 영원히 안 움직인다.

메인 스레드에서 DispatchQueue.main.sync를 부르면 정확히 이 장면이라 앱이 그 자리에서 얼어붙는다. 조건을 정확히 짚자면 "자기가 이미 앉아 있는 serial 큐에 다시 sync"다. sync라는 단어 자체가 위험한 게 아니고, 방이 여러 칸인 concurrent queue라면 다른 칸에서 처리하면 되니 이 자기 교착은 안 난다. 다만 그렇다고 안심하긴 이르다 — 넘긴 블록이 내가 이미 쥐고 있는 락을 다시 잡으려 들면, 방 개수와 상관없이 또 서로 기다리게 된다.

꼬리 질문

concurrent queue로 sync해도 deadlock이 나는 경우가 있나?

concurrent queue 자체로는 self-deadlock이 안 난다 — 새 실행 컨텍스트를 얻거나 호출 스레드에서 인라인 실행하기 때문이다. 하지만 일반적 교착은 여전히 가능하다. 예컨대 sync로 넘긴 블록이 호출 스레드가 이미 쥔 락을 다시 잡으려 하거나, 두 스레드가 서로의 큐로 sync해 결과를 맞기다리면 순환 대기가 만들어진다. 또 그 concurrent queue의 target이 바쁜 serial queue면 간접적으로 막힐 수 있다.

쉽게 말하면 방이 여러 칸이면 "내가 앉은 그 칸이 비기를 기다리는" 자기 교착은 안 난다. 그런데 넘긴 일이 하필 내 주머니에 든 열쇠(락)를 달라고 하면, 방이 몇 칸이든 다시 서로 기다리게 된다. 두 스레드가 서로의 큐로 sync해서 결과를 맞기다리는 것도 같은 그림이다.
target queue로 연결된 큐 계층에서 sync가 교착을 만드는 시나리오는?

큐 A가 serial 큐 B를 target으로 두면 A의 작업은 결국 B의 직렬 컨텍스트에서 실행된다. 이미 B(또는 A) 위에서 실행 중인데 A로 sync하면, 같은 직렬화 컨텍스트를 자기가 점유한 채 비기를 기다리는 꼴이라 교착된다. target으로 얽힌 큐들은 겉보기엔 다른 큐라도 실행상 하나의 직렬 지점을 공유할 수 있다는 게 함정이다.

쉽게 말하면 큐 A가 큐 B를 target으로 두면, A에 넣은 일도 결국 B의 그 한 줄에서 처리된다. 창구 간판은 둘인데 뒤에 앉은 사람은 한 명인 셈이다. 그래서 이미 그 사람 자리에 앉은 채로 "A 창구에 넣고 여기서 기다릴게요"라고 하면, 간판만 다를 뿐 자기 자신을 기다리는 것과 똑같다.
sync가 async보다 오히려 유리한 경우(스레드 도약 회피 등)는?

락으로 보호되는 짧은 값 하나를 그 자리에서 읽어와야 할 때가 대표적이다. sync는 클로저를 힙에 올리거나 스레드를 도약(hop)시키지 않고 호출 스레드에서 곧장 실행될 수 있어 지연이 작고 결과를 인라인으로 받는다. 다만 self-deadlock 위험을 늘 안고 가므로, 이런 용도라면 애초에 OSAllocatedUnfairLock 같은 락이 더 단순하고 안전한 경우가 많다.

쉽게 말하면 락으로 지킨 값 하나만 후딱 읽어오는 경우다. async는 할 일을 상자에 싸서 다른 사람에게 넘기고 결과를 되받는 절차가 붙지만, sync는 그 자리에서 바로 처리하고 값을 손에 쥔다. 다만 딱 그 용도라면 큐를 끌어들이지 말고 락 하나 쓰는 편이 더 단순하고 self-deadlock 걱정도 없다.

Q8. Swift Concurrency의 Task는 Thread인가?

🔑 30초 답변

아니다. Task는 비동기 작업의 논리적 단위 — 실행할 코드에 우선순위·취소 상태·task-local 값이 묶인 것 — 이지 스레드가 아니다. Task는 실행되는 동안 cooperative thread pool의 스레드를 잠깐 빌려 쓰다가, await로 suspend되면 그 스레드를 반납한다. 그래서 하나의 Task가 생애 동안 여러 스레드를 거칠 수 있고, 수천 개의 Task가 코어 수만큼의 스레드 위에서 돌아간다. Task는 값싸고 Thread는 무겁다는 것이 실무적 함의다.

CS 원리

Task는 continuation 기반 비동기의 스케줄 단위로, 개념적으로는 코루틴(협력적 경량 실행)에 가깝다. suspension point에서 실행 상태를 continuation으로 저장하고 스레드를 양보하기 때문에, 스레드를 점유한 채 대기하는 커널 스레드와는 자원 소모가 근본적으로 다르다.

Task — 논리적 작업 단위 (값싸다 · 수천 개 가능) Task 1 Task 2 Task 3 Task 4 Task 5 … 수천 개 런타임이 배정 cooperative thread pool — 스레드는 비싸다 (대략 코어 수만큼만) Thread 1 Thread 2 Thread 3 Thread 4 Task 하나의 생애 — 스레드가 고정되지 않는다 Thread 2에서 실행 await — 스레드 반납 Thread 4에서 재개 완료 suspend 구간에는 어떤 스레드도 이 Task를 붙들고 있지 않다 — 그 스레드는 다른 Task가 쓴다. 그래서 Task 수를 늘려도 스레드는 늘지 않는다. 스레드를 늘리는 건 Task가 아니라 그 안의 블로킹 호출이다.
Task는 "할 일"이고 Thread는 "일할 자리"다. Task는 실행 중에만 스레드를 빌려 쓰고 await에서 반납하므로, 하나의 Task가 여러 스레드를 거칠 수 있고 수천 개의 Task가 코어 수만큼의 스레드 위에서 돌아간다.

iOS에서는

Swift 런타임은 너비가 대략 코어 수인 cooperative thread pool에서 Task를 executor에 스케줄한다. Task {}, Task.detached, 그리고 구조적 동시성의 async let·TaskGroup이 Task를 만든다. 취소·우선순위·task-local 값이 부모에서 자식으로 구조적으로 전파된다는 점이 원시 Thread와 크게 다르다.

실험 · 도구

수천 개의 Task를 만들어도 실제 스레드 수는 코어 수 근처에 머무는 것을 task_threads() 기반 카운터나 Xcode Debug Navigator의 스레드 목록으로 확인할 수 있다. async 함수에서 await 앞뒤로 pthread_self()를 찍으면 스레드가 바뀔 수 있음이 드러난다 — Thread.current는 async 컨텍스트에서 아예 쓸 수 없으므로(Swift 6에서 컴파일 에러) 이 실험에는 pthread_self()를 쓴다. Instruments의 Swift Concurrency 템플릿으로 Task·continuation 상태를 추적한다.

프로젝트 적용

CPU 바운드 무한 루프를 Task에서 돌리면 cooperative pool 스레드를 독점해 다른 작업을 굶긴다 — 중간에 await Task.yield()를 넣거나 별도 처리로 분리한다.

CPU 루프가 협력 풀을 굶기지 않게
import Foundation

// cooperative pool의 스레드 수는 대략 코어 수다. 그중 하나를 CPU 루프가
// 독점하면 다른 Task가 그만큼 못 돈다. 전부 독점하면 앱이 멈춘 것처럼 보인다.

// ❌ 양보 없는 CPU 루프
func renderAllBad(_ frames: [Frame]) {
    Task {
        for f in frames { heavyRender(f) }    // 끝날 때까지 워커 하나를 붙잡는다
    }
}

// ✅ ① 주기적으로 양보한다 — 간단하지만 여전히 협력 풀을 쓴다
func renderAll(_ frames: [Frame]) {
    Task {
        for (i, f) in frames.enumerated() {
            heavyRender(f)
            if i % 50 == 0 { await Task.yield() }   // 다른 Task에 차례를 준다
        }
    }
}

// ✅ ② 더 나은 방법: 긴 CPU 작업은 협력 풀 밖 전용 큐로 내보낸다
private let cpuQueue = DispatchQueue(label: "com.app.render",
                                     qos: .userInitiated,
                                     attributes: .concurrent)

func render(_ frame: Frame) async -> Image {
    await withCheckedContinuation { cont in
        cpuQueue.async { cont.resume(returning: heavyRender(frame)) }
    }
}

// ✅ ③ 취소도 함께 챙긴다 — 긴 루프는 취소 확인 지점이 필요하다
func renderCancellable(_ frames: [Frame]) async throws -> [Image] {
    var out: [Image] = []
    for f in frames {
        try Task.checkCancellation()
        out.append(await render(f))
    }
    return out
}

struct Frame {}; struct Image {}
@discardableResult func heavyRender(_ f: Frame) -> Image { Image() }

Task는 스레드가 아니므로 특정 스레드나 TLS에 의존하는 코드와 섞지 않는다.

Task를 스레드 의존 코드와 섞지 않기
import Foundation

// Task는 await를 지날 때마다 다른 스레드에서 재개될 수 있다.
// 스레드 신원에 의존하는 코드와 섞으면 조용히 깨진다.

// ❌ await 전후로 같은 스레드일 거라 가정
func brokenFlow() async throws {
    let before = Thread.current
    try await Task.sleep(for: .milliseconds(1))
    let after = Thread.current
    assert(before === after)          // 💥 보장되지 않는다
}
// ⚠️ Swift 6에서는 Thread.current를 async 컨텍스트에서 쓰는 것 자체가
//    컴파일 에러다 — 언어가 "스레드에 의존하지 마라"를 강제하는 셈이다

// ❌ TLS에 값을 넣고 await 뒤에 읽기
func brokenContext() async {
    Thread.current.threadDictionary["k"] = "v"
    await someAsyncWork()
    let v = Thread.current.threadDictionary["k"]   // nil일 수 있다
    print(v ?? "사라짐")
}

// ✅ 보장되는 것은 스레드가 아니라 'actor 격리'다
@MainActor
func safeFlow() async {
    assert(Thread.isMainThread)
    await someAsyncWork()
    assert(Thread.isMainThread)       // ✅ MainActor라면 이건 참이다
}

// ✅ 컨텍스트가 필요하면 task-local (Task 스코프라 await를 넘어 유지된다)
enum Ctx { @TaskLocal static var traceID = "-" }

func tracedWork() async {
    await Ctx.$traceID.withValue(UUID().uuidString) {
        await someAsyncWork()
        print(Ctx.traceID)            // ✅ await 뒤에도 같은 값
    }
}

// ⚠️ 스레드에 묶인 레거시(OpenGL 컨텍스트, 일부 C 라이브러리)를 써야 하면
//    Task로 감싸지 말고 전용 Thread나 serial queue에 가둔 뒤
//    continuation으로 노출한다.

func someAsyncWork() async {}

가능하면 구조적 동시성(async let·TaskGroup)을 쓰고, Task.detached는 취소·우선순위 전파가 끊기므로 꼭 필요할 때만 쓴다.

구조적 동시성 우선, detached는 최소로
import Foundation

// Task.detached는 부모의 취소·우선순위·actor 격리·task-local을 전부 끊는다.
// "그게 필요해서" 쓰는 게 아니라면 구조적 동시성이 항상 낫다.

// ✅ ① 고정 개수 → async let
func loadScreen() async throws -> (Profile, [Post]) {
    async let profile = api.profile()
    async let posts = api.posts()
    return try await (profile, posts)
    // 어느 하나가 throw하면 나머지는 자동 취소된다
}

// ✅ ② 가변 개수 → TaskGroup
func loadAll(_ ids: [String]) async throws -> [Post] {
    try await withThrowingTaskGroup(of: Post.self) { group in
        for id in ids { group.addTask { try await api.post(id) } }
        var out: [Post] = []
        for try await p in group { out.append(p) }
        return out
    }
    // 그룹을 벗어나면 남은 자식이 모두 취소·정리된다
}

// ✅ ③ 화면 수명에 묶인 작업 → Task { }  (부모 컨텍스트 상속)
@MainActor
final class ViewModel {
    private var loadTask: Task<Void, Never>?

    func onAppear() {
        loadTask = Task {                 // MainActor 격리·우선순위를 상속
            await self.refresh()
        }
    }
    func onDisappear() { loadTask?.cancel() }
    private func refresh() async {}
}

// ⚠️ detached가 정당한 경우: 호출자 수명과 무관하게 끝까지 가야 하는 작업
func fireAndForgetLog(_ event: String) {
    Task.detached(priority: .background) {
        try? await Analytics.send(event)   // 화면이 닫혀도 전송은 마친다
    }
}
// 이 경우에도 "부모 취소가 전파되지 않는다"를 팀 규칙으로 명시해 둔다.

struct Profile {}; struct Post {}
enum api {
    static func profile() async throws -> Profile { Profile() }
    static func posts() async throws -> [Post] { [] }
    static func post(_ id: String) async throws -> Post { Post() }
}
enum Analytics { static func send(_ e: String) async throws {} }
⚠️ 흔한 오해

"Task를 만들면 새 스레드가 생긴다", 또는 "Task.detached는 새 스레드다"는 둘 다 틀렸다. 두 경우 모두 공유 cooperative pool에서 실행되며, detached는 스레드가 아니라 '부모의 컨텍스트(취소·우선순위·actor·task-local)를 상속하지 않는' Task라는 뜻이다.

🧒 쉽게 이해하기

Task는 "할 일을 적은 포스트잇"이고 Thread는 "앉아서 일하는 책상"이다. 포스트잇은 수천 장 써 붙여도 종이값밖에 안 들지만, 책상은 자리를 차지하고 값이 비싸다. 그래서 Swift는 책상을 대략 코어 수만큼만 두고, 포스트잇을 든 사람이 책상에 잠깐 앉았다 일어나게 한다. await를 만나면 "기다리는 동안 자리 비울게요" 하고 일어나고, 다시 시작할 땐 아까 그 책상이 아닐 수도 있다 — 그래서 Task 하나가 살아 있는 동안 여러 스레드를 거친다.

포스트잇에는 할 일만 적혀 있는 게 아니다. 마감 급한 정도(우선순위), 취소 도장, task-local 값이 같이 적혀 있고 자식 Task에 그대로 베껴진다. 이 '따라다니는 메모'가 그냥 Thread와 제일 다른 점이다. Task.detached는 흔한 오해와 달리 새 책상을 하나 사오는 게 아니라, 부모 포스트잇의 그 메모를 한 줄도 안 베껴온 백지다.

꼬리 질문

높은 우선순위 Task가 낮은 우선순위 Task(또는 actor)의 결과를 await하면 우선순위는 어떻게 되나? 이 priority escalation은 원시 Thread를 직접 다룰 때와 무엇이 다른가?

기다림의 대상이 승격된다 — 높은 우선순위 Task가 낮은 우선순위 자식 Task의 결과나 낮은 우선순위로 도는 actor를 await하면, 런타임이 그동안 피호출자의 우선순위를 대기자 수준 이상으로 끌어올려, 정작 내가 막혀 있는 작업이 중간 우선순위에 밀려 굶는 우선순위 역전을 막는다. 이 승격은 스케줄러에 내장돼 있어 자식 Task와 actor hop을 따라 구조적으로 전파되며, 내가 요청하지 않아도 자동으로 일어난다. 반면 원시 Thread/pthread에는 await 같은 경계를 가로지르는 자동 승격이 없어 역전을 감수하거나 직접 donation을 짜야 하고, os_unfair_lock의 donation조차 '락 소유자'만 커버할 뿐 "저 스레드의 결과를 기다린다"는 임의 관계는 다루지 못한다. 결국 Task 우선순위는 한 번 박아 두는 스레드 우선순위가 아니라 런타임이 능동적으로 관리하는 동적 힌트라는 게 Task ≠ Thread의 핵심 단면이다.

쉽게 말하면 급한 사람이 안 급한 사람의 결과를 기다리게 되면, 런타임이 그 안 급한 쪽에 "지금은 당신도 급한 일"이라고 딱지를 바꿔 달아준다. 그래야 어정쩡하게 급한 다른 일에 밀려 내 일이 굶지 않는다. 그래서 Task의 우선순위는 한 번 새기면 끝인 문신이 아니라, 런타임이 상황 봐서 고쳐 붙이는 스티커에 가깝다 — 원시 Thread에는 이런 스티커가 없어 직접 짜야 한다.
Task {}Task.detached는 컨텍스트 상속 관점에서 어떻게 다른가?

Task {}는 현재 컨텍스트의 actor 격리, 우선순위, task-local 값을 물려받는다 — 예컨대 @MainActor 안에서 만들면 그 자식도 MainActor에서 시작한다. Task.detached는 이 셋을 전부 끊는다. actor 격리 없이, 기본 우선순위로, task-local도 비운 채 백지에서 출발한다. 그래서 detached는 취소·우선순위 전파도 끊기므로 정말 독립 실행이 필요할 때만 쓴다.

쉽게 말하면 Task {}는 부모의 신분증을 복사해서 들고 나간다 — 어느 actor 소속인지, 얼마나 급한지, task-local에 뭐가 적혀 있는지까지. @MainActor 안에서 만든 Task가 메인에서 시작하는 게 그래서다. Task.detached는 그 복사를 통째로 건너뛴 백지 신분증이라, 부모가 취소돼도 소식이 전달되지 않는다.
async 함수의 suspension point 전후로 스레드가 유지된다는 보장이 있나?

없다. await를 지나면 continuation이 풀의 다른 스레드에서 재개될 수 있다. 유지가 보장되는 건 물리 스레드가 아니라 actor 격리다 — @MainActor 함수라면 await 이후에도 메인 executor(=메인 스레드)로 돌아오지만, 그 외 일반 async 함수는 같은 스레드로 돌아온다는 보장이 없다. 그래서 스레드 정체성이나 TLS에 기대는 코드는 async와 섞으면 깨진다.

쉽게 말하면 await는 자리를 비웠다가 다시 앉는 것이라, 아까 그 책상일 수도 아닐 수도 있다. 보장되는 건 책상(스레드)이 아니라 소속(actor 격리)이다 — @MainActor 함수면 await 뒤에도 메인 자리로 돌아오지만, 그냥 async 함수는 어느 책상에 앉을지 아무 약속이 없다.

Q9. async/await 중 Thread가 block되는가?

🔑 30초 답변

제대로 설계된 async 코드에서 await는 스레드를 block하지 않고 suspend한다 — 실행 상태를 continuation으로 저장하고 스레드를 pool에 반납해 다른 작업이 그 스레드를 쓰게 한다. 이것이 async의 핵심 이점이다. 반대로 async 함수 안에서 동기 블로킹 API(DispatchSemaphore.wait(), 동기 I/O, Thread.sleep)를 호출하면 그 순간 실제 스레드가 막혀 cooperative pool의 소중한 워커 하나가 죽고, 최악의 경우 전체 concurrency가 기아에 빠진다. 즉 non-blocking은 공짜가 아니라 API 선택의 결과다.

CS 원리

핵심은 suspend(양보)와 block(점유한 채 대기)의 차이다. await는 전자라 스레드를 놓아주지만, blocking syscall은 후자라 스레드를 붙잡는다. cooperative pool은 스레드 수가 코어 수로 제한되므로, 스레드를 붙잡는 블로킹은 GCD 시절보다 오히려 더 치명적이다.

await → suspend · 스레드를 pool에 반납 Thread Task 실행 다른 Task가 스레드 사용 Task 재개 await 동기 블로킹 → 스레드 점유 · pool 워커 낭비 Thread Task 실행 blocked · 스레드 낭비 재개 시간
await는 논블로킹 양보라 그 스레드가 다른 일을 하지만, semaphore.wait·Thread.sleep 같은 동기 블로킹은 협력 풀의 스레드를 붙잡아 기아를 부른다.

iOS에서는

Swift 런타임은 cooperative pool의 스레드가 항상 '전진(forward progress)'한다고 가정한다 — 그래서 그 위에서 블로킹하면 안 된다. 콜백 기반 API를 감쌀 때는 withCheckedContinuation으로 continuation을 정확히 한 번 resume한다. 대기가 필요하면 블로킹인 Thread.sleep이 아니라 논블로킹인 Task.sleep을 쓴다.

동기 블로킹 vs continuation (Swift)
// ❌ async 안에서 실제 스레드를 막는다 → 협력 풀 기아 위험
func loadBad() async -> Data {
    let sema = DispatchSemaphore(value: 0)
    var result = Data()
    legacyLoad { data in result = data; sema.signal() }
    sema.wait()                     // 스레드 점유
    return result
}

// ✅ suspend: 스레드를 반납하고 콜백에서 재개
func loadGood() async -> Data {
    await withCheckedContinuation { cont in
        legacyLoad { data in cont.resume(returning: data) }
    }
}

프로젝트 적용

async 컨텍스트에서 semaphore.wait·동기 I/O·Thread.sleep 같은 블로킹 API를 호출하지 않는다.

async 안에서 블로킹 API 부르지 않기
import Foundation

// await는 스레드를 반납하지만, 그 안에서 블로킹 API를 부르면 스레드가 그대로 막힌다.
// 협력 풀은 스레드 수가 코어 수라서, GCD 시절보다 오히려 더 치명적이다.

// ❌ 세 가지 대표적인 실수
func badPatterns() async {
    // ① 세마포어로 비동기를 동기처럼 기다리기
    let sema = DispatchSemaphore(value: 0)
    legacyLoad { _ in sema.signal() }
    sema.wait()                                  // 워커 스레드 하나 사망

    // ② 동기 I/O
    _ = try? Data(contentsOf: URL(fileURLWithPath: "/big"))

    // ③ Thread.sleep
    Thread.sleep(forTimeInterval: 1)
}

// ✅ 각각의 올바른 대응
func goodPatterns() async throws {
    // ① continuation으로 감싼다 — 스레드를 반납한 채 기다린다
    let data = await withCheckedContinuation { cont in
        legacyLoad { cont.resume(returning: $0) }
    }
    _ = data

    // ② async I/O API를 쓴다
    let (bytes, _) = try await URLSession.shared.bytes(from: URL(string: "https://x")!)
    for try await _ in bytes {}

    // ③ Task.sleep — 취소도 인지한다
    try await Task.sleep(for: .seconds(1))
}

// 굶주림을 확인하는 방법: 협력 풀 워커가 다 막히면
// 아래처럼 단순한 Task조차 실행이 한참 지연된다
func canary() {
    Task {
        let t = ContinuousClock.now
        await Task.yield()
        print("yield 지연: \(ContinuousClock.now - t)")   // 수십 ms면 굶주림 의심
    }
}

func legacyLoad(_ done: @escaping (Data) -> Void) {}

블로킹뿐인 레거시는 withCheckedContinuation + 전용 스레드/큐로 격리해 감싼 뒤 노출한다.

블로킹 레거시를 전용 스레드로 격리
import Foundation

// 정말 블로킹뿐인 C API는 없앨 수 없다. 대신 '협력 풀 밖'에 가둔다.

// ✅ 전용 큐에 격리하고 async 인터페이스로만 노출한다
actor LegacyBridge {
    // 협력 풀과 분리된 자체 큐 — 여기가 막혀도 Swift Concurrency는 멀쩡하다
    private let queue = DispatchQueue(label: "com.app.legacy", qos: .userInitiated)

    func decode(_ input: Data) async throws -> Data {
        try await withCheckedThrowingContinuation { cont in
            queue.async {
                do    { cont.resume(returning: try blockingDecode(input)) }
                catch { cont.resume(throwing: error) }
            }
        }
    }
}

// ✅ 동시 실행이 필요하면 concurrent 큐 + 개수 제한
final class BlockingPool {
    private let queue = DispatchQueue(label: "com.app.blocking",
                                      qos: .utility, attributes: .concurrent)
    private let semaphore: DispatchSemaphore

    init(limit: Int) { semaphore = DispatchSemaphore(value: limit) }

    func run<T>(_ work: @escaping () throws -> T) async throws -> T {
        semaphore.wait()                 // ✅ 큐에 넣기 '전'에 기다린다
        return try await withCheckedThrowingContinuation { cont in
            queue.async {
                defer { self.semaphore.signal() }
                cont.resume(with: Result { try work() })
            }
        }
    }
}

// ⚠️ 세마포어는 큐에 넣기 '전'에 기다려야 한다. 안에서 기다리면 대기 블록마다 GCD
//    워커를 하나씩 붙잡아 전역 워커 풀에 thread explosion이 난다 — 커스텀 concurrent
//    큐도 '전용 풀'이 아니라 같은 전역 워커 풀을 공유하기 때문이다.
//    이렇게 해야 협력 풀도, GCD 풀도 굶지 않는다 — 그게 핵심이다.

func blockingDecode(_ d: Data) throws -> Data { d }

지연은 Task.sleep으로 처리한다(취소도 인지한다).

지연은 Task.sleep으로
import Foundation

// ❌ Thread.sleep — 스레드를 통째로 막고, 취소도 인지하지 못한다
func retryBad() async {
    for _ in 0..<3 {
        Thread.sleep(forTimeInterval: 2)     // 워커 하나가 2초간 죽는다
    }
}

// ✅ Task.sleep — suspend라 스레드를 반납하고, 취소 시 즉시 throw한다
func retry() async throws {
    for attempt in 1...3 {
        do { return try await send() }
        catch {
            try await Task.sleep(for: .seconds(pow(2, Double(attempt))))
            // 취소되면 여기서 CancellationError가 던져져 루프가 즉시 끝난다
        }
    }
}

// ✅ 테스트 가능하게 만들려면 Clock을 주입받는다
struct Backoff<C: Clock> where C.Duration == Duration {
    let clock: C

    func wait(attempt: Int) async throws {
        let base = min(pow(2.0, Double(attempt)), 30)
        let jittered = base * Double.random(in: 0.5...1.5)
        try await clock.sleep(for: .seconds(jittered))
    }
}

// 프로덕션
let real = Backoff(clock: ContinuousClock())

// ⚠️ Task.sleep은 '최소 지연'을 보장할 뿐 정확한 시각을 보장하지 않는다.
//    tolerance를 명시하면 시스템이 타이머를 묶어 전력을 아낀다.
func lowPowerWait() async throws {
    try await ContinuousClock().sleep(for: .seconds(60), tolerance: .seconds(5))
}

func send() async throws {}
⚠️ 흔한 오해

"await는 스레드를 block한다", 또는 반대로 "async면 알아서 non-blocking이 된다"는 둘 다 틀렸다. await 자체는 논블로킹 양보지만, 그 안에서 블로킹 API를 부르면 스레드는 그대로 막힌다. 논블로킹은 async 키워드가 주는 게 아니라 논블로킹 API를 골라 쓴 결과다.

🧒 쉽게 이해하기

세탁기에 빨래를 넣고 나면 두 가지 선택지가 있다. 앞에 서서 다 될 때까지 쳐다보거나(block), 돌려놓고 나가서 다른 일을 하다가 삐 소리를 듣고 돌아오거나(suspend). await는 두 번째다 — 기다리는 동안 그 사람(스레드)을 풀어줘서 다른 일을 시킬 수 있다.

그런데 async 함수 안에서 DispatchSemaphore.wait()Thread.sleep을 부르면 첫 번째로 되돌아간다. 함수 앞에 async라고 써 있어도 소용없다. 여기서 무서운 건 Swift Concurrency의 일꾼이 코어 수만큼밖에 없다는 사실이다. GCD 시절엔 사람이 막히면 새로 뽑아 어떻게든 굴러갔지만, cooperative pool은 그 몇 명이 전부다. 그 몇이 전부 세탁기 앞에 붙박이로 서 버리면 할 일은 산더미인데 아무도 안 움직이는 상태가 된다. 그래서 non-blocking은 문법이 주는 게 아니라, 기다림을 await로 처리하는 API를 골라 쓴 결과다.

꼬리 질문

withCheckedContinuation의 continuation을 두 번 resume하거나 한 번도 resume하지 않으면?

두 번 resume하면 checked 버전은 런타임에서 이를 잡아 즉시 트랩(크래시)시킨다 — 반면 withUnsafeContinuation은 검사가 없어 정의되지 않은 동작이 된다. 한 번도 resume하지 않으면 그 await는 영원히 재개되지 않아 Task가 매달린 채 누수되고, checked 버전은 continuation이 resume 없이 해제될 때 경고를 남긴다. 그래서 규칙은 '정확히 한 번' resume이다.

쉽게 말하면 continuation은 "다 되면 이 번호로 전화 주세요" 하고 남긴 연락처다. 두 번 걸면 이미 자리를 뜬 사람을 또 부르는 꼴이라 checked 버전은 그 자리에서 앱을 세워 알려준다. 아예 안 걸면 그 Task는 평생 대기실에 앉아 있고, 붙잡고 있던 메모리도 같이 묶인다.
cooperative pool이 고갈됐는지 어떻게 진단하나?

증상은 CPU는 한가한데 async 작업이 진행되지 않는 것이다. cooperative 스레드들이 코어 수만큼 전부 blocked 상태로 멈춰 있으면 의심한다. Instruments의 Swift Concurrency 템플릿으로 Task들이 오래 suspended/running에 걸려 있는지 보고, 그 스레드들의 스택에서 semaphore.wait·동기 I/O 같은 블로킹 호출을 찾는다. Xcode의 hang 진단과 스레드 상태도 단서가 된다.

쉽게 말하면 증상이 좀 특이하다 — CPU 그래프는 한가한데 일이 하나도 안 나간다. 일꾼이 바쁜 게 아니라 전부 어딘가에 붙박여 서 있다는 뜻이다. 그 스레드들의 스택을 열어봐서 semaphore.wait이나 동기 파일 읽기가 보이면 그게 범인이다.
MainActor 위에서 블로킹하면 UI에 무슨 일이 벌어지나?

메인 스레드가 막히면 메인 런루프가 이벤트를 처리하지 못해 화면이 얼어붙는다 — 렌더링도, 터치 반응도 멈춘 hang 상태다. 앱 시작이나 화면 전환처럼 시스템이 지켜보는 구간에서 오래 막히면 워치독이 0x8badf00d 코드로 앱을 강제 종료할 수도 있다. 그래서 메인 액터에서는 절대 동기 대기를 하지 않고 await로 양보해야 한다.

쉽게 말하면 메인 스레드는 화면을 그리고 손가락 터치를 받아주는 담당인데, 여기서 기다려 버리면 그 일을 아무도 대신 안 한다. 화면이 그대로 굳고 터치도 안 먹는다. 앱 시작이나 화면 전환처럼 시스템이 시계를 재고 있는 구간에서 너무 오래 굳으면 워치독이 앱을 그냥 죽여버린다.

Q10. actor와 MainActor는 무엇을 보장하는가?

🔑 30초 답변

actor는 자신의 mutable 상태에 대한 접근을 직렬화해서, 컴파일러가 강제하는 상호배제(한 번에 하나의 실행만 내부 상태에 접근)를 보장한다 — 즉 low-level data race를 정적으로 없앤다. MainActor는 특정 코드를 항상 메인 스레드에서 실행하도록 격리하는 전역 actor다. 둘 다 '동시 접근으로 인한 data race 없음'은 보장하지만, await 경계에서 다른 호출이 끼어드는 actor reentrancy 때문에 논리적 원자성(불변식 유지)까지 보장하지는 않는다.

CS 원리

actor는 monitor(상태 + 그 상태를 지키는 직렬 실행자)의 현대적 형태다. actor isolation은 외부에서 상태에 직접 손대지 못하게 하고, 접근을 serial executor로 줄 세운다. 다만 reentrancy가 허용된다 — actor 메서드가 await로 양보하는 동안 같은 actor의 다른 메서드가 실행될 수 있으므로, await를 사이에 둔 두 지점 사이에서 상태가 이미 바뀌었을 수 있다.

actor reentrancy로 불변식이 깨지는 예 (Swift)
actor Bank {
    var balance = 100
    func withdraw(_ amount: Int) async {
        guard balance >= amount else { return }   // (1) 확인
        await notifyAudit()                        // await → 다른 호출이 끼어들 수 있음
        balance -= amount                          // (2) 그 사이 balance가 바뀌었을 수 있다
    }
}
actor executor · await 사이에 다른 호출이 끼어든다 A: 잔고 확인 A: await notifyAudit executor 양보 B: 100 인출 완료 A: 재개 후 차감 balance 100 100 0 -100 시간 → actor 덕분에 두 호출이 동시에 balance를 만지지는 않았다 (data race 없음). 그래도 A가 확인한 100은 재개 시점엔 이미 옛날 값 — 불변식이 깨진다
actor는 동시 접근은 막지만 await 구간의 재진입까지 막지는 않는다. await 이전에 검사한 상태는 이후에 다시 확인해야 한다.

iOS에서는

actor는 기본적으로 자체 serial executor를 갖는다. MainActor는 UIKit/SwiftUI 갱신을 위해 메인 스레드 실행을 보장하는 전역 actor로, @MainActor를 타입·메서드·프로퍼티에 붙여 격리한다. actor 외부에서의 접근은 서로 다른 격리 도메인을 넘기 때문에 await가 필요하고, 격리가 필요 없는 순수 멤버는 nonisolated로 열어줄 수 있다.

실험 · 도구

여러 Task에서 actor의 카운터를 동시에 증가시켜도 TSan이 깨끗하게 나오는 것으로 data race 제거를 확인할 수 있다. 반대로 위 Bank 예처럼 await 앞뒤로 상태를 로깅하면, reentrancy로 값이 어긋나는 상황을 재현할 수 있다.

프로젝트 적용

UI를 만지는 코드는 @MainActor로 격리한다.

UI 코드를 @MainActor로 격리
import UIKit

// ❌ 런타임에 확인하는 방식 — 놓치면 크래시나 미정의 동작
final class OldViewModel {
    var onUpdate: ((String) -> Void)?
    func load() {
        URLSession.shared.dataTask(with: url) { data, _, _ in
            self.onUpdate?("완료")        // 백그라운드 스레드에서 UI 갱신 💥
        }.resume()
    }
    private let url = URL(string: "https://x")!
}

// ✅ @MainActor로 격리하면 컴파일러가 강제한다
@MainActor
final class ProfileViewModel: ObservableObject {
    @Published private(set) var name = ""
    @Published private(set) var isLoading = false

    func load() async {
        isLoading = true                       // 메인에서 실행
        defer { isLoading = false }

        // await 동안 메인 스레드는 반납된다 — UI는 계속 반응한다
        let fetched = await api.name()

        name = fetched                         // 재개 시 다시 메인에서
    }
}

// ✅ 타입 전체가 아니라 일부만 격리할 수도 있다
final class ImageLoader {
    nonisolated func fetch(_ url: URL) async throws -> Data {
        try await URLSession.shared.data(from: url).0    // 어느 스레드든 OK
    }

    @MainActor
    func apply(_ data: Data, to view: UIImageView) {     // 여기만 메인 강제
        view.image = UIImage(data: data)
    }
}

// ✅ 프로토콜·클로저 경계에도 붙일 수 있다
@MainActor protocol ViewUpdating { func refresh() }

enum api { static func name() async -> String { "" } }

actor 메서드 안에서 await 전에 검사한 상태는 await 이후 다시 확인한다(reentrancy 방어).

await 전후 상태 재확인 (reentrancy 방어)
import Foundation

actor ImageCache {
    private var cache: [URL: Data] = [:]
    private var loading: Set<URL> = []

    // ❌ await 사이에 다른 호출이 끼어들어 같은 URL을 두 번 받아온다
    func imageBad(_ url: URL) async throws -> Data {
        if let hit = cache[url] { return hit }
        let data = try await download(url)     // ⚠️ 재진입 창
        cache[url] = data
        return data
    }
    // 동시에 5번 호출되면 5번 다운로드된다 — 캐시가 무의미해진다

    // ✅ 진행 중임을 상태로 남기고, await 뒤에 다시 확인한다
    private var inFlight: [URL: Task<Data, Error>] = [:]

    func image(_ url: URL) async throws -> Data {
        if let hit = cache[url] { return hit }
        if let running = inFlight[url] { return try await running.value }  // 합류

        let task = Task { try await download(url) }
        inFlight[url] = task
        defer { inFlight[url] = nil }

        let data = try await task.value

        // ✅ await 뒤 재확인 — 그사이 누가 이미 채웠을 수도 있다
        if let hit = cache[url] { return hit }
        cache[url] = data
        return data
    }

    private func download(_ url: URL) async throws -> Data {
        try await URLSession.shared.data(from: url).0
    }
}

// 규칙: actor 메서드에서 await를 볼 때마다
//   "이 앞에서 읽은 값이 뒤에서도 유효한가?"를 반드시 물어본다.

actor를 '긴 임계구역'처럼 쓰지 않는다 — await가 등장할 때마다 재진입 창이 열린다.

actor를 '긴 임계구역'처럼 쓰지 않기
import Foundation

// actor는 락이 아니다. await마다 재진입 창이 열리므로
// "actor 안에 넣었으니 이 메서드 전체가 원자적"이라는 기대는 틀렸다.

actor OrderProcessor {
    private var stock: [String: Int] = [:]

    // ❌ 긴 메서드 안에 await가 여러 개 — 매 지점마다 상태가 바뀔 수 있다
    func processBad(_ order: Order) async throws {
        guard stock[order.sku, default: 0] >= order.qty else { throw ShopError.soldOut }
        await payment.charge(order)                 // 재진입 창 ①
        await shipping.reserve(order)               // 재진입 창 ②
        stock[order.sku]! -= order.qty              // 이미 남이 다 팔았을 수 있다
    }

    // ✅ 상태 변경은 await 없는 짧은 구간에서 원자적으로 끝낸다
    func reserve(_ order: Order) throws {
        let left = stock[order.sku, default: 0]
        guard left >= order.qty else { throw ShopError.soldOut }
        stock[order.sku] = left - order.qty         // await 없음 → 원자적
    }

    func release(_ order: Order) {
        stock[order.sku, default: 0] += order.qty
    }
}

// 긴 흐름은 actor '밖'에서 조립한다 — actor는 상태 보관만 담당
func placeOrder(_ order: Order, in processor: OrderProcessor) async throws {
    try await processor.reserve(order)              // ① 원자적으로 선점
    do {
        await payment.charge(order)                 // ② 느린 작업은 밖에서
        await shipping.reserve(order)
    } catch {
        await processor.release(order)              // ③ 실패하면 보상
        throw error
    }
}

struct Order { let sku: String; let qty: Int }
enum ShopError: Error { case soldOut }
enum payment { static func charge(_ o: Order) async {} }
enum shipping { static func reserve(_ o: Order) async {} }
⚠️ 흔한 오해

"actor면 모든 race와 deadlock이 사라진다"는 과신이다. actor는 data race를 없애지만, reentrancy로 인한 논리 race(불변식 깨짐)는 그대로 남는다. 흔히 반대로 오해하는데, reentrancy는 오히려 서로를 await 하는 actor들이 락처럼 맞물려 굳는 교착을 막아주는 장치다 — actor는 await 동안 executor를 붙잡지 않기 때문이다. 다만 영영 resume되지 않는 continuation이나 서로의 결과만 기다리는 의존 사이클 같은 논리적 교착은 이와 별개로 여전히 가능하다. 또 "MainActor = 특정 스레드 하나"는 대체로 맞지만, 미래 호환을 위해 '메인 executor'라는 개념으로 이해하는 편이 정확하다.

🧒 쉽게 이해하기

actor는 물건을 손님이 직접 못 만지게 하고 창구 직원을 통해서만 꺼내주는 사물함이다. 손님이 아무리 몰려도 직원은 한 번에 한 명만 상대하니까, 두 사람이 같은 칸을 동시에 헤집는 일(data race)은 아예 생기지 않는다. 게다가 이 규칙은 "다들 잘 지켜주세요"가 아니라 컴파일러가 강제한다. MainActor는 그 창구가 항상 메인 스레드 자리에 놓인 특별한 사물함이고, 그래서 화면을 건드리는 코드를 거기에 맡긴다.

딱 하나 더 짚을 게 reentrancy(재진입)다. 직원이 처리 도중 await를 만나면 "잠깐 확인 전화 좀 하고 올게요" 하고 자리를 비우는데, 그 사이 다음 손님이 처리된다. 그래서 전화 걸기 전에 확인한 잔고가 돌아왔을 때도 같으리란 보장이 없다 — 확인은 await 다음에 한 번 더 해야 한다. 참고로 자리를 비우는 이 습성 덕분에, 창구끼리 서로를 기다리다 통째로 굳는 교착은 오히려 잘 안 생긴다.

꼬리 질문

actor reentrancy가 실제 버그를 만드는 구체적 시나리오는?

이미지 캐시가 흔한 예다. "캐시에 없으면 네트워크로 로드해 저장" 로직에서, 첫 호출이 await load() 중일 때 아직 캐시가 비어 있으므로 두 번째 호출도 없음을 보고 똑같은 이미지를 중복 로드한다 — 저장은 await 이후에나 일어나기 때문이다. 잔고 차감을 await로 가르면 이중 인출이 나는 것도 같은 구조다. 해법은 '진행 중' 상태를 await 이전에 기록해두거나, await 직후 조건을 다시 검사하는 것이다.

쉽게 말하면 "창고에 없으면 사 온다"는 심부름을 생각해 보자. 첫 사람이 사러 나간 사이 창고는 여전히 비어 있으니, 두 번째 사람도 없다고 보고 똑같은 걸 또 사 온다. 물건을 채워 넣는 건 다녀온 다음이기 때문이다. 그래서 나가기 전에 "이거 지금 사러 감"이라고 먼저 적어두거나, 돌아와서 한 번 더 확인해야 한다.
MainActor.assumeIsolated는 무엇을 하고 언제 안전한가?

지금 실행 흐름이 이미 MainActor executor(메인 스레드) 위에 있다고 컴파일러에 단언하고, await 없이 MainActor 격리 상태에 접근하게 해준다. 안전한 건 실제로 메인에서 동기적으로 도는 게 확실할 때뿐이다 — 예컨대 메인 스레드 보장이 있는 델리게이트 콜백 안. 그 단언이 거짓이면(다른 스레드였다면) 런타임에 트랩(크래시)하는데 — 디버그뿐 아니라 릴리스 빌드에서도 무조건 검사하는 것이 이 API의 안전 보장이다 — 남용하면 크래시로 이어진다.

쉽게 말하면 "지금 여기 메인 맞으니까 확인 절차 생략하고 그냥 만질게"라고 내가 컴파일러에게 하는 약속이다. 델리게이트 콜백처럼 메인 보장이 확실한 자리에서만 써야 한다. 약속이 틀리면 앱이 그 자리에서 죽는데, 릴리스에서도 꼬박꼬박 검사한다는 게 오히려 안전장치다 — 조용히 망가지느니 크게 터지는 쪽을 택한 것이다.
actor에 custom executor를 지정하면 무엇이 달라지나?

actor의 작업이 '어디서, 어떻게' 실행될지가 바뀐다. 기본 serial executor 대신 특정 DispatchQueue 위에서 직렬화하도록 지정하면, 기존 큐 기반 코드와 실행 컨텍스트를 맞추거나 스레드 친화성을 통제할 수 있다. 바뀌는 건 밑단의 스케줄링·스레드 배치일 뿐, 한 번에 하나만 실행한다는 직렬화·격리 보장 자체는 그대로 유지된다.

쉽게 말하면 창구를 어느 자리에 놓을지 바꾸는 것이다. 기본 자리 대신 "예전부터 쓰던 그 DispatchQueue 위에 놓아라"라고 지정하면, 옛 큐 기반 코드와 실행 자리를 맞출 수 있다. 자리만 옮겼을 뿐 "한 번에 한 명"이라는 창구 규칙 자체는 그대로다.

Q11. Sendable은 무엇을 보장하고 무엇은 보장하지 않는가?

🔑 30초 답변

Sendable은 "이 타입의 값을 여러 동시성 도메인(actor·Task) 사이로 넘겨도 data race가 나지 않는다"는 성질을 타입 시스템에 표시하고 컴파일 타임에 검증하는 마커 프로토콜이다. 값 타입이면서 내부가 전부 Sendable이거나, 불변이거나, 내부적으로 스스로 동기화하는 타입만 안전하게 Sendable일 수 있다. 보장하는 것은 '경계를 넘는 전달의 안전성'이지, 그 값을 쓰는 로직이 옳다거나 race condition이 없다는 것이 아니다. @unchecked Sendable은 컴파일러 검증을 끄고 개발자가 책임지겠다는 선언이라, 실제 동기화가 없으면 거짓 약속이 된다.

CS 원리

경계를 넘는 값이 안전하려면 공유 mutable 상태가 없거나(불변·값 복사) 있어도 보호돼야 한다. Sendable은 이 속성을 타입 시스템에 인코딩한 것으로, value semantics·불변성·내부 동기화가 그 근거가 된다.

동시성 도메인 A Task · actor 격리 경계 — 컴파일 타임 검사 지점 동시성 도메인 B 다른 actor · Task 값 타입 · 내부도 Sendable 불변 타입 · let만 스스로 동기화하는 타입 통과 ✓ 막힌다 ✕ — Sendable 아님 var 프로퍼티를 가진 class 등 보호 없는 가변 참조 타입 → 컴파일 에러로 잡힌다 @unchecked Sendable — 검사를 끈 것 "내가 책임진다"는 선언일 뿐 안전해지지 않는다. 실제 동기화가 없으면 거짓 약속.
Sendable이 보장하는 것은 경계를 넘는 전달의 안전성뿐이다. Sendable한 값을 받아도 그것을 쓰는 순서·로직의 race condition은 별개 문제이고, @unchecked는 안전 보증이 아니라 컴파일러 검증을 꺼 둔 상태다.

iOS에서는

Swift 6의 complete checking 아래에서 struct/enum(멤버가 모두 Sendable), 불변 저장 프로퍼티만 가진 final class, 그리고 actor(항상 Sendable)가 안전하게 Sendable이 된다. 클로저에는 @Sendable을 붙인다. 락 등으로 직접 보호한 참조 타입은 @unchecked Sendable로 표시하되 그 정당성을 개발자가 책임지며, 레거시 모듈은 @preconcurrency import로 점진 도입한다.

실험 · 도구

가장 강력한 도구는 컴파일러 자신이다. 빌드 설정을 -strict-concurrency=complete로 올린 뒤 non-Sendable 값을 actor 경계로 넘기면 진단이 뜬다. 이 진단을 CI 경고 0 정책으로 강제하면 data race의 상당수를 코드가 돌기 전에 걸러낸다.

프로젝트 적용

경계를 넘나드는 모델은 값 타입 + 불변을 지향한다.

경계를 넘는 모델은 값 타입 + 불변으로
import Foundation

// ✅ 값 타입이고 내부가 전부 Sendable이면 컴파일러가 자동으로 Sendable을 추론한다
//    단, 자동 추론은 non-public 타입(과 frozen public 타입)에만 적용된다 —
//    public non-frozen 타입은 아래처럼 명시적으로 선언해야 한다
struct FeedItem: Sendable, Identifiable {
    let id: UUID
    let title: String
    let createdAt: Date
    let tags: [String]           // Array<String>도 Sendable
}

// ✅ 참조 타입이어야 한다면 '불변'으로 만들어 Sendable을 정당화한다
final class Configuration: Sendable {
    let baseURL: URL
    let timeout: TimeInterval
    init(baseURL: URL, timeout: TimeInterval) {
        self.baseURL = baseURL
        self.timeout = timeout
    }
    // 전부 let이라 만들어진 뒤에는 아무도 바꿀 수 없다 → 공유해도 안전
}

// ❌ var가 하나라도 있으면 Sendable이 아니다 (컴파일 에러로 잡힌다)
final class MutableConfig {
    var baseURL: URL = URL(string: "https://x")!
}
// func send(_ c: MutableConfig) async {}   // 경계를 넘기려 하면 경고/에러

// ✅ 변경이 필요하면 '새 값을 만드는' 방식으로 (불변 유지)
extension Configuration {
    func with(timeout: TimeInterval) -> Configuration {
        Configuration(baseURL: baseURL, timeout: timeout)
    }
}

// ✅ enum도 페이로드가 Sendable이면 자동 Sendable
enum LoadState: Sendable {
    case idle
    case loading
    case loaded([FeedItem])
    case failed(String)          // Swift 6에서는 Error가 Sendable을 상속하므로 case failed(any Error)도 가능하다
}

@unchecked Sendable은 반드시 실제 내부 동기화를 동반하고, 그 근거를 주석으로 남긴다.

@unchecked Sendable은 실제 동기화와 근거를 함께
import Foundation
import os

// @unchecked는 "컴파일러 검사를 끄겠다"는 선언이다.
// 실제 동기화가 없으면 그냥 거짓 약속이고, 오히려 더 위험하다.

// ❌ 경고를 없애려고 붙인 것 — 아무것도 보호하지 않는다
final class BadCounter: @unchecked Sendable {
    var value = 0                        // 동기화 없음. 그냥 data race.
}

// ✅ 근거를 주석으로 남기고, 실제로 락을 통과시킨다
/// 스레드 안전성: 모든 저장 프로퍼티 접근이 `state` 락을 통과한다.
/// 아래 세 메서드가 유일한 진입점이며, 내부 상태를 외부로 노출하지 않는다.
/// 가변 상태를 통째로 락 안에 넣었으므로 @unchecked 없이 검증된 Sendable이 된다.
final class MetricsBuffer: Sendable {
    private let state = OSAllocatedUnfairLock(initialState: [String: Int]())

    func increment(_ key: String) {
        state.withLock { $0[key, default: 0] += 1 }
    }

    func snapshot() -> [String: Int] {
        state.withLock { $0 }            // 복사본을 돌려준다 (내부 참조 유출 금지)
    }

    func reset() {
        state.withLock { $0.removeAll() }
    }
}

// ✅ 더 나은 선택지가 있으면 그쪽을 먼저 검토한다
actor MetricsActor {                     // @unchecked가 아예 필요 없다
    private var counts: [String: Int] = [:]
    func increment(_ key: String) { counts[key, default: 0] += 1 }
    func snapshot() -> [String: Int] { counts }
}

// @unchecked가 정당한 경우는 사실상 둘뿐이다:
//   ① var 저장 프로퍼티를 별도 락으로 지켜 컴파일러가 그 관계를 증명할 수 없는 타입
//      (상태를 OSAllocatedUnfairLock·Mutex 안에 넣으면 @unchecked 없이 검증을 통과한다)
//   ② 불변인데 컴파일러가 증명하지 못하는 경우(예: C 타입 래핑)

참조 타입 공유를 최소화하고, 레거시는 @preconcurrency로 단계적으로 편입한다.

참조 공유 줄이고 레거시는 단계적으로 편입
import Foundation

// 레거시 모듈을 한 번에 Sendable로 만들 수는 없다. 경계를 그어 단계적으로 옮긴다.

// ① 아직 동시성을 모르는 모듈은 @preconcurrency로 들여온다
//    → 그 모듈에서 오는 타입에 대한 Sendable 경고를 유예한다
@preconcurrency import Foundation

// ② 경계에서 '내 값 타입'으로 즉시 변환한다 — 레거시 참조가 안으로 새지 않는다
final class LegacyUser {                 // 남이 만든 가변 클래스
    var name: String = ""
    var email: String = ""
}

struct User: Sendable {                  // ✅ 내 도메인 모델은 값 타입
    let name: String
    let email: String

    init(_ legacy: LegacyUser) {         // 경계에서 한 번만 복사
        name = legacy.name
        email = legacy.email
    }
}

// ③ 레거시 API를 감싸는 얇은 어댑터를 두고, 그 밖으로는 값 타입만 내보낸다
actor UserService {
    private let legacy = LegacyUserAPI()

    func currentUser() async -> User {
        let raw = await legacy.fetch()   // 여기까지만 레거시 타입
        return User(raw)                 // 밖으로는 Sendable 값만
    }
}

final class LegacyUserAPI {
    func fetch() async -> LegacyUser { LegacyUser() }
}

// ④ 마지막에 strict concurrency를 켜서 남은 구멍을 컴파일 타임에 드러낸다
//    Package.swift:
//      swiftSettings: [.enableUpcomingFeature("StrictConcurrency")]
//    또는 Xcode Build Settings → Strict Concurrency Checking → Complete
⚠️ 흔한 오해

"Sendable이면 스레드 안전하고 아무 문제 없다"는 오해다. Sendable은 '전달 안전성'만 보장한다 — Sendable한 값을 받아도 그것을 쓰는 순서·로직의 race condition은 별개 문제다. 더 위험한 오해는 "@unchecked Sendable을 붙였으니 안전하다"는 것으로, 이는 오히려 컴파일러 검증을 꺼둔 상태다.

🧒 쉽게 이해하기

도시락과 공용 냄비의 차이로 보면 쉽다. 도시락은 사람마다 하나씩 복사해 들고 가니까 옆 사람이 뭘 하든 내 밥은 안 바뀐다 — 값 타입이 그렇다. 반대로 냄비 하나에 여럿이 숟가락을 넣으면 순식간에 엉망이 되는데, 그게 아무 보호 장치 없는 참조 타입이다. Sendable은 "이건 다른 사람 손에 넘겨도 안 깨진다"는 검사 도장이고, 컴파일러가 내용물을 뜯어보고 직접 찍어준다.

헷갈리지 말아야 할 건 도장이 보장하는 범위다. "옮기는 동안 안 깨진다"까지지, "그 재료로 만든 요리가 제대로 됐다"는 아니다. 받은 값을 엉뚱한 순서로 쓰면 race condition은 그대로 난다. 그리고 @unchecked Sendable은 검사를 통과했다는 도장이 아니라 "검사는 생략하고 내가 책임진다"고 내가 직접 찍은 도장이다. 안에 진짜 락이 들어 있으면 정직한 도장이지만, 없다면 컴파일러 경고만 사라진 거짓말이 된다.

꼬리 질문

final class를 안전하게 Sendable로 만들 수 있는 조건은?

모든 저장 프로퍼티가 let(불변)이고 그 타입들 역시 전부 Sendable이면, 컴파일러가 스스로 Sendable을 허용한다. final이어야 하는 이유는 하위 클래스가 mutable 상태를 덧붙일 여지를 없애기 위해서다. mutable 프로퍼티를 갖되 내부를 락으로 직접 보호했다면 컴파일러가 증명할 수 없으므로 @unchecked Sendable로 개발자가 책임진다.

쉽게 말하면 안에 든 게 전부 let이고 그 안의 것들도 다 검사 도장을 받았으면, 컴파일러가 알아서 도장을 찍어준다. final이어야 하는 건 자식 클래스가 나중에 몰래 바뀌는 값을 끼워 넣을 수 있기 때문이다. 락으로 직접 지키는 경우엔 컴파일러가 그 사실을 읽어낼 방법이 없어서 내가 책임진다고 써 붙이는 것뿐이다.
@unchecked Sendable을 정당하게 쓰는 예와, 오용했을 때의 위험은?

정당한 예는 mutable 상태를 os_unfair_lock 등으로 전부 감싸 실제로 스스로 동기화하지만 컴파일러가 그 사실을 알 길이 없는 참조 타입이다. 오용은 아무 동기화 없는 mutable 클래스에 그냥 붙이는 것 — 컴파일러 경고만 사라질 뿐 data race는 그대로 남아 오히려 '검증을 꺼둔' 상태가 된다. 그래서 붙일 때는 어떻게 동기화하는지 근거를 주석으로 남기는 게 관행이다.

쉽게 말하면 안에 진짜 자물쇠가 달려 있는데 컴파일러가 그걸 못 알아볼 때만 정당한 도장이다. 자물쇠 하나 없는 클래스에 그냥 붙이면 경고만 사라지고 위험은 고스란히 남는다 — 화재경보기를 떼어냈다고 불이 안 나는 건 아니다.
@Sendable 클로저에는 어떤 캡처 제약이 붙나?

캡처하는 모든 값이 그 자체로 Sendable이어야 하고, 지역 var를 가변 캡처(참조로 잡아 변형)할 수 없다 — 캡처는 값으로 이뤄진다. non-Sendable 참조를 끌어들이는 것도 막힌다. 이 제약들이 함께 작동해, 클로저가 다른 동시성 도메인으로 넘어가 실행돼도 data race를 일으키지 않음을 컴파일 타임에 보장한다.

쉽게 말하면 클로저를 멀리 부치는 소포라고 보면, 안에 넣을 수 있는 건 검사 도장 받은 물건뿐이다. 바깥의 var를 "그 변수 자체"로 끌어안는 것도 막힌다 — 값만 복사해서 담아간다. 그래야 소포가 저 멀리서 열려도 여기 있는 원본이 흔들리지 않는다.

Q12. Task cancellation은 자동으로 실행 중인 작업을 멈추는가?

🔑 30초 답변

아니다. Swift의 취소는 협력적(cooperative)이다. cancel()은 Task에 '취소됨' 플래그를 세울 뿐, 실행 중인 코드를 강제로 중단하거나 스레드를 죽이지 않는다. 작업이 실제로 멈추려면 코드가 스스로 Task.isCancelled를 확인하거나 try Task.checkCancellation()을 호출하거나, 취소를 인지하는 async API가 던지는 에러를 처리해야 한다(Task.sleepCancellationError를, URLSession async는 URLError(.cancelled)(코드 -999)를 던진다 — API마다 에러 타입이 다르므로 catch 시 주의). 취소를 전혀 확인하지 않는 CPU 루프는 cancel 이후에도 끝까지 돈다.

CS 원리

협력적 취소는 강제 종료(preemptive termination)와 대비된다. 스레드를 강제로 죽이면 잡고 있던 락이 풀리지 않거나 불변식이 반쯤 깨진 채 남는 등 자원 누수·상태 손상 위험이 크기 때문에, 현대 런타임은 '멈춰야 할 안전한 지점에서 스스로 멈추는' 협력식을 택한다. 구조적 동시성에서는 취소가 부모→자식으로 트리를 따라 전파된다.

취소는 플래그일 뿐, 코드가 확인해야 멈춘다 (Swift)
let task = Task {
    for item in items {
        try Task.checkCancellation()   // 협력 지점: 취소되면 여기서 throw
        process(item)
    }
}
task.cancel()   // 실행 중인 루프를 멈추지 않는다 — 플래그만 세운다
task.cancel() '취소됨' 표시만 세운다 확인 지점이 없는 루프 · item 1 처리 · item 2 처리 (표시를 보지 않음) · … 마지막까지 그대로 진행 결과: 멈추지 않는다 확인 지점이 있는 루프 · item 1 처리 · try Task.checkCancellation() · 표시를 보고 throw 결과: 그 자리에서 중단 취소는 방송일 뿐 — 코드가 귀를 기울여야 멈춘다
cancel()은 실행 중인 코드를 강제로 끊지 않는다. 멈출 수 있는 안전한 지점을 코드가 직접 만들어 둬야 취소가 실제 중단이 된다.

iOS에서는

도구는 Task.isCancelled, Task.checkCancellation(), 그리고 정리 로직을 붙이는 withTaskCancellationHandler다. async let·TaskGroup 같은 구조적 동시성에서는 부모 취소가 자식에게 자동 전파된다. Task.sleep은 취소를 인지해 CancellationError를 던지고, URLSession async API는 URLError(.cancelled)(코드 -999)를 던지지만(에러 타입이 다르므로 catch 시 주의), Task.detached는 부모의 취소를 상속하지 않는다.

실험 · 도구

취소 확인이 없는 무한 루프 Task를 만들어 cancel()을 호출해도 로그가 계속 찍히는 것으로 '안 멈춤'을 재현하고, 루프 안에 try Task.checkCancellation()을 넣으면 즉시 종료되는 것을 확인한다. 정리 동작은 withTaskCancellationHandler의 핸들러 호출 시점을 로깅해 관찰한다.

프로젝트 적용

긴 루프나 단계 사이에 try Task.checkCancellation() 또는 Task.isCancelled 검사를 넣는다.

협력 지점을 실제로 넣기
import Foundation

// cancel()은 플래그만 세운다. 코드가 확인하지 않으면 끝까지 돈다.

// ❌ 취소를 확인하지 않는 루프 — cancel 후에도 100만 번을 다 돈다
func processBad(_ items: [Item]) -> [Result] {
    items.map(transform)
}

// ✅ ① throw 방식 — 취소되면 CancellationError로 즉시 빠져나온다
func process(_ items: [Item]) async throws -> [Result] {
    var out: [Result] = []
    out.reserveCapacity(items.count)
    for item in items {
        try Task.checkCancellation()
        out.append(transform(item))
    }
    return out
}

// ✅ ② 부분 결과를 살려야 하면 플래그로 확인하고 정상 반환
func processPartial(_ items: [Item]) async -> (done: [Result], cancelled: Bool) {
    var out: [Result] = []
    for item in items {
        if Task.isCancelled { return (out, true) }   // 여기까지의 결과를 돌려준다
        out.append(transform(item))
    }
    return (out, false)
}

// ✅ ③ 촘촘한 루프라면 매 반복이 아니라 주기적으로 — 검사도 공짜는 아니다
func processChunked(_ items: [Item]) async throws -> [Result] {
    var out: [Result] = []
    for (i, item) in items.enumerated() {
        if i % 256 == 0 { try Task.checkCancellation() }
        out.append(transform(item))
    }
    return out
}

// ⚠️ 취소를 인지하는 API(Task.sleep, URLSession async)만 쓰는 코드라면
//    별도 확인 없이도 자동으로 멈춘다. 직접 도는 CPU 루프가 문제다.

struct Item {}; struct Result {}
func transform(_ i: Item) -> Result { Result() }

취소 시 자원 해제·롤백은 withTaskCancellationHandler로 처리한다.

취소 시 자원 정리는 withTaskCancellationHandler로
import Foundation

// 취소는 "그만두라"는 신호일 뿐, 열어 둔 자원을 대신 닫아 주지 않는다.

final class Transfer {
    func start(_ url: URL) -> Handle { Handle() }
    final class Handle {
        func result() async throws -> Data { Data() }
        func abort() {}
    }
}

// ✅ 취소 순간 즉시 정리한다
func download(_ url: URL) async throws -> Data {
    let handle = Transfer().start(url)
    return try await withTaskCancellationHandler {
        try await handle.result()
    } onCancel: {
        handle.abort()               // ⚠️ 이 클로저는 취소 스레드에서 즉시 실행된다
        // ⚠️ onCancel 핸들러는 @Sendable이라, 여기서 만지는 타입(Transfer.Handle)도
        //    Sendable이어야 한다. 아니면 Swift 6에서 컴파일 에러다.
    }
}

// ⚠️ onCancel 클로저 안에서는 await를 쓸 수 없고, 동기적으로 짧게 끝내야 한다.
//    비동기 정리가 필요하면 플래그만 세우고 본체에서 처리한다.
actor Uploader {
    private var shouldAbort = false

    func upload(_ data: Data) async throws {
        try await withTaskCancellationHandler {
            try await send(data)
            if shouldAbort { try await rollback() }   // ✅ 비동기 정리는 본체에서
        } onCancel: {
            Task { await self.setAbort() }
        }
    }
    private func setAbort() { shouldAbort = true }
    private func send(_ d: Data) async throws {}
    private func rollback() async throws {}
}

// ✅ 파일·트랜잭션은 defer와 조합하면 경로가 하나로 모인다
func writeWithCleanup(_ url: URL) async throws {
    let tmp = url.appendingPathExtension("tmp")
    defer { try? FileManager.default.removeItem(at: tmp) }   // 정상·취소·에러 모두 커버
    try await writeChunks(to: tmp)
    _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp)
}
func writeChunks(to url: URL) async throws {}

취소는 CancellationError로 다루되, 사용자에게는 '실패'가 아니라 '취소됨'으로 구분해 노출한다.

취소를 '실패'가 아니라 '취소됨'으로 노출
import Foundation
import UIKit

// 사용자가 스스로 취소했는데 "오류가 발생했습니다" 토스트가 뜨면 버그처럼 보인다.

@MainActor
final class SearchViewModel {
    private var task: Task<Void, Never>?

    func search(_ query: String) {
        task?.cancel()                          // 이전 검색 취소
        task = Task {
            do {
                let results = try await api.search(query)
                self.show(results)
            } catch is CancellationError {
                // ✅ 조용히 종료 — 사용자 의도이므로 아무것도 표시하지 않는다
                return
            } catch let error as URLError where error.code == .cancelled {
                // ✅ URLSession은 CancellationError가 아니라 이 코드를 던진다(-999)
                return
            } catch {
                self.showError(error)           // ✅ 진짜 실패만 노출
            }
        }
    }

    private func show(_ r: [String]) {}
    private func showError(_ e: Error) {}
}

// ⚠️ API마다 취소를 알리는 에러 타입이 다르다 — 헬퍼로 한곳에 모아 둔다
extension Error {
    var isCancellation: Bool {
        self is CancellationError
            || (self as? URLError)?.code == .cancelled
            || (self as NSError).code == NSUserCancelledError
    }
}

func handle(_ error: Error) {
    guard !error.isCancellation else { return }
    // 실제 오류 처리
}

enum api { static func search(_ q: String) async throws -> [String] { [] } }

Task.detached는 취소가 전파되지 않는다는 점을 팀 규칙으로 명시한다.

Task.detached는 취소가 전파되지 않는다
import Foundation

// 구조적 동시성에서는 부모가 취소되면 자식도 취소된다. detached는 그 사슬을 끊는다.

@MainActor
final class Screen {
    private var work: Task<Void, Never>?

    // ✅ Task { } — 부모 취소가 전파된다
    func startStructured() {
        work = Task {
            await longRunningWork()          // cancel() 하면 내부 await가 취소된다
        }
    }

    // ❌ 부모 Task 안에서 detached를 띄우면 부모 취소가 전파되지 않는다
    func startDetached() {
        work = Task {                        // 이 Task를 cancel해도…
            Task.detached { await longRunningWork() }   // …이 안쪽은 계속 돈다
            await longRunningWork()
        }
    }
    // ⚠️ 반대로 detached Task의 '핸들'을 직접 들고 cancel()하면 detached도 정상 취소된다.
    //    끊기는 건 부모→자식 자동 전파이지 핸들 취소가 아니다.

    func close() { work?.cancel() }
}

// detached를 꼭 써야 한다면 취소를 직접 배선한다
final class Coordinator {
    private var handle: Task<Void, Never>?

    func start() {
        let task = Task.detached { await longRunningWork() }
        handle = task
    }
    func stop() { handle?.cancel() }         // ✅ 직접 붙들고 직접 취소
}

/*  팀 규칙으로 명시할 것:
      · 기본은 Task { } / async let / TaskGroup
      · Task.detached는 "호출자 수명과 무관하게 끝까지 가야 하는 작업"에만
      · detached를 쓰면 취소·우선순위·actor 격리·task-local이 모두 끊긴다는 점을
        코드 리뷰 체크리스트에 넣는다                                          */

func longRunningWork() async {}
⚠️ 흔한 오해

"cancel()을 부르면 즉시 멈춘다" 또는 "스레드가 죽는다"는 오해다. cancel은 플래그를 세울 뿐이며, 코드가 협력하지 않으면 아무 일도 일어나지 않는다. 반대로 "취소는 예외처럼 알아서 전파되니 아무것도 안 해도 된다"는 것도 절반만 맞다 — 자동 전파는 취소를 인지하는 API와 구조적 동시성 안에서만 성립한다.

🧒 쉽게 이해하기

달리기 시합 도중 "경기 취소" 방송이 나왔다고 하자. 방송은 스피커에서 흘러나올 뿐, 선수를 뒤에서 붙잡아 세우지 않는다. 선수가 귀를 기울여 들어야 멈춘다. cancel()이 하는 일이 딱 이거다 — Task에 '취소됨' 표시를 세울 뿐이고, 코드가 Task.isCancelled를 보거나 try Task.checkCancellation()을 불러야 실제로 멈춘다.

왜 억지로 끌어내지 않을까? 전력질주하는 사람을 뒤에서 낚아채면 손에 든 걸 다 떨어뜨리기 때문이다. 락을 쥔 채 죽으면 그 락은 영영 안 풀리고, 반쯤 고쳐놓은 데이터는 그 상태로 남는다. 그래서 "스스로 안전한 자리에서 멈춘다"를 택한 것이다. 다만 몇몇 API는 방송을 대신 들어준다 — Task.sleepCancellationError를, URLSession은 URLError(.cancelled)를 던진다. 던지는 에러 종류가 다르니 catch할 때 하나로 뭉뚱그리면 놓친다. 그리고 확인이 전혀 없는 계산 루프는 cancel을 불러도 끝까지 다 돈다.

꼬리 질문

withTaskCancellationHandler의 핸들러는 정확히 언제 실행되나(이미 취소된 상태로 진입하면)?

operation이 실행되는 도중 취소가 발생하면 핸들러가 곧바로(동기적으로) 한 번 호출된다. 함정은 이미 취소된 상태로 이 함수에 진입하는 경우인데, 이때는 operation이 시작되기도 전에 핸들러가 즉시 실행될 수 있다. 그래서 핸들러는 operation이 아직 아무 자원도 잡지 않은 상태에서 불려도 안전하도록(예: 정리할 대상이 nil일 때도 무해하게) 작성해야 한다.

쉽게 말하면 이미 취소된 뒤에 이 함수로 들어오면, 본 작업이 시작도 하기 전에 뒷정리 담당자가 먼저 뛰어들 수 있다. 그러니 "아직 치울 게 아무것도 없는 상태"에서 불려도 아무 탈이 없게 써야 한다 — 열지도 않은 파일을 닫으려다 터지지 않도록.
TaskGroup에서 자식 하나가 에러를 던지면 나머지 자식은 자동으로 취소되나?

withThrowingTaskGroup에서 자식이 던진 에러가 next()를 통해 그룹 밖으로 전파되면(즉 당신이 그 에러를 rethrow하면) 남은 자식들에게 취소가 걸린다. 단 취소는 협력적이라, 자식들은 여전히 스스로 취소를 확인해야 실제로 멈춘다. 그리고 에러를 try로 꺼내지 않고 삼켜버리면 자동 취소도 일어나지 않으니, 전파 흐름을 끊지 않는 게 핵심이다.

쉽게 말하면 자식이 던진 에러를 내가 try로 받아 위로 흘려보내야 나머지 형제들에게 "그만"이 전달된다. 에러를 조용히 삼키면 아무한테도 소식이 안 간다. 그리고 소식이 갔더라도 결국 협력적 취소라, 형제들이 스스로 확인해야 진짜로 멈춘다.
취소된 Task가 그때까지의 부분 결과를 반환해야 한다면 어떻게 설계하나?

CancellationError를 무조건 위로 던지지 말고, 취소를 감지한 지점에서 그때까지 누적한 부분 결과를 담아 정상 반환하도록 설계한다. 진행 상황을 값 타입에 계속 쌓아두거나 withTaskCancellationHandler로 스냅샷을 남기고, API 경계에서는 '취소되어 부분만 완료'와 '실패'를 별도 케이스로 구분해 노출한다. 그래야 호출자가 부분 결과를 살릴지 버릴지 판단할 수 있다.

쉽게 말하면 취소를 그냥 "실패"로 던져버리면 그때까지 모아둔 것도 같이 버려진다. 사진 100장 중 40장을 올린 뒤 취소됐다면, 40장은 살리고 나머지 60장만 다시 하면 되는 것이다. 그래서 "여기까지 했어요"를 값에 담아 정상 반환하고, 부르는 쪽이 살릴지 버릴지 고르게 한다.

출처 · 참고자료