← Mobile Foundation 인터뷰 7 / 13

07 · Thread Safety

두 가지 경합 → 격리 도구 → actor 재진입 → 메모리 모델과 캐시 일관성
진행률
0 / 0 완료
면접에서 나오는 형태
  1. Thread safety를 어떻게 보장하나요? 어떤 도구를 써 봤나요?
  2. Data race와 Race condition은 무엇이 다른가요?
  3. Swift 6가 데이터 경합을 막아 준다면, 이제 동시성 버그가 없어지나요?
L1 개념
Data race(동기화 없는 동시 접근·정의되지 않은 동작)와 Race condition(순서에 따라 결과가 달라지는 논리 버그)은 다른 것
L2 설계
도구 스펙트럼: 공유 안 함 → 격리 → 락 → atomic. 아래로 갈수록 세밀하고 위험하다
L3 구현
actor는 재진입한다 — 원자성은 awaitawait 사이뿐. @MainActor는 스레드가 아니라 격리 도메인
L4 CS
메모리 모델 · MESI · 재정렬 · 상호 배제 · 데드락 4조건 — 컴파일러와 CPU가 코드 순서를 바꾼다

30초 답변 🔥 먼저 이 문장

Thread safety는 "여러 스레드가 동시에 만져도 불변식이 깨지지 않는다"입니다. 먼저 두 가지를 구분해야 합니다.

Data race는 동기화 없이 같은 메모리를 동시에 읽고 쓰는 것 — 정의되지 않은 동작이고, 컴파일러와 도구가 잡아 줄 수 있습니다.
Race condition은 실행 순서에 따라 결과가 달라지는 논리 버그 — data race가 없어도 발생합니다.

그래서 Swift 6가 data race를 없애 준다고 race condition이 사라지지는 않습니다. 이건 설계로 풀어야 합니다.

L1개념 — 두 경합을 코드로 구분

① Data race — 정의되지 않은 동작. "값이 이상해지는" 정도가 아니다
var counter = 0

DispatchQueue.concurrentPerform(iterations: 1000) { _ in
    counter += 1        // 💥 data race
}
print(counter)          // 1000이 아니다. 그리고 매번 다르다.

// counter += 1 은 한 줄이지만 기계어로는 세 단계다
//   1. load   counter → 레지스터
//   2. add    레지스터 += 1
//   3. store  레지스터 → counter
// 두 스레드가 1을 동시에 하면 둘 다 같은 값을 읽고, 둘 다 +1 하고,
// 둘 다 쓴다 → 두 번 증가해야 하는데 한 번만 증가한다 (갱신 유실)
② Race condition — 각 접근은 안전한데 결과가 틀린다
actor Cache {
    private var store: [String: Data] = [:]
    func contains(_ k: String) -> Bool { store[k] != nil }
    func insert(_ k: String, _ v: Data) { store[k] = v }
}

// 두 접근 각각은 actor로 보호되어 data race가 없다.
// 그런데 "검사 → 사용" 사이가 원자적이지 않다.
let has = await cache.contains(key)      // (1)
if !has {
    let data = try await download(key)   // (2) 그 사이 다른 task가 넣었을 수 있다
    await cache.insert(key, data)        // (3) 중복 다운로드 + 덮어쓰기
}

// ✅ 해법: 검사와 사용을 한 원자 단위로 묶는다 (actor 메서드 하나로)
extension Cache {
    func value(for k: String, load: () async throws -> Data) async throws -> Data {
        if let hit = store[k] { return hit }
        let v = try await load()          // ⚠️ 이 await 동안에도 재진입은 가능하다
        store[k] = v                      //    → L3에서 in-flight Task 캐싱으로 해결
        return v
    }
}
동시성 버그 전체 Race Condition 순서에 따라 결과가 달라짐 = 논리 버그 설계로 풀어야 한다 Data Race 동기화 없는 동시 접근 = 정의되지 않은 동작 겹치는 영역 Swift 6가 잡는 범위 여기는 안 잡힌다 data race를 전부 없애도 race condition은 남는다 — 둘은 포함 관계가 아니다
Swift 6의 컴파일 타임 검사는 data race를 겨냥한다. "검사 후 사용", "중복 요청", "순서 의존" 같은 논리적 경합은 여전히 개발자 몫이다.

L2설계 — 도구 스펙트럼, 비용이 낮은 순

① 공유하지 않기 — 가장 싸고 가장 안전하다
// 값 타입 + 불변 → 애초에 경합이 성립하지 않는다
struct Config: Sendable {
    let baseURL: URL
    let timeout: TimeInterval
}
let config = Config(baseURL: url, timeout: 30)   // let + 값 타입 = 걱정 없음

// 스레드마다 자기 사본을 갖게 한다
func process(_ items: [Item]) async -> [Result] {
    await withTaskGroup(of: Result.self) { group in
        for item in items {
            group.addTask { transform(item) }    // item은 값 복사 → 공유 없음
        }
        return await group.reduce(into: []) { $0.append($1) }
    }
}
② 격리 — 접근을 한 줄로 세운다
// (a) GCD serial queue
final class LegacyStore {
    private var dict: [String: Int] = [:]
    private let q = DispatchQueue(label: "store")      // serial

    func value(_ k: String) -> Int? { q.sync { dict[k] } }
    func set(_ k: String, _ v: Int) { q.async { self.dict[k] = v } }
}

// (b) reader–writer: 읽기는 동시에, 쓰기는 barrier로 배타적
final class RWStore {
    private var dict: [String: Int] = [:]
    private let q = DispatchQueue(label: "rw", attributes: .concurrent)

    func value(_ k: String) -> Int? {
        q.sync { dict[k] }                              // 여러 읽기가 동시에
    }
    func set(_ k: String, _ v: Int) {
        q.async(flags: .barrier) { self.dict[k] = v }   // 쓰기는 혼자
    }
}

// (c) actor — 같은 일을 언어가 해 준다
actor Store {
    private var dict: [String: Int] = [:]
    func value(_ k: String) -> Int? { dict[k] }
    func set(_ k: String, _ v: Int) { dict[k] = v }
}
③ 락 — 세밀하지만 위험하다
import os

// ❌ os_unfair_lock 을 Swift에서 직접 쓰는 건 위험하다
final class Bad {
    private var lock = os_unfair_lock()     // struct라서 복사될 수 있다!
    func f() {
        os_unfair_lock_lock(&lock)          // 복사본을 잠그면 보호가 안 된다
        defer { os_unfair_lock_unlock(&lock) }
    }
}

// ✅ iOS 16+ : OSAllocatedUnfairLock — 복사 문제가 없는 래퍼
final class Good: @unchecked Sendable {
    private let lock = OSAllocatedUnfairLock(initialState: [String: Int]())
    func value(_ k: String) -> Int? {
        lock.withLock { $0[k] }
    }
    func set(_ k: String, _ v: Int) {
        lock.withLock { $0[k] = v }
    }
}

// ✅ 그 이전 타깃: NSLock (조금 느리지만 안전하고 명확)
final class Safe: @unchecked Sendable {
    private let lock = NSLock()
    private var dict: [String: Int] = [:]
    func set(_ k: String, _ v: Int) {
        lock.lock(); defer { lock.unlock() }
        dict[k] = v
    }
}
⚠️ 흔한 오답 두 개

① "serial queue는 항상 같은 스레드에서 실행된다" — 틀립니다. serial queue가 보장하는 건 "한 번에 하나"이지 스레드 동일성이 아닙니다. GCD는 스레드 풀에서 가용한 스레드를 씁니다. 그래서 스레드 로컬 저장소나 재귀 락 가정을 하면 안 됩니다.

② "sync는 그냥 기다리는 것"자기 자신 큐에 sync하면 데드락입니다. 큐가 지금 그 블록을 실행 중인데, 그 블록이 같은 큐가 비기를 기다리니까요.

고전 데드락 두 가지
let q = DispatchQueue(label: "serial")
q.async {
    q.sync { }              // 💥 데드락 — 자기가 자기를 기다린다
}

// 메인 스레드에서
DispatchQueue.main.sync { } // 💥 데드락 — 메인이 메인을 기다린다

L3구현 — actor의 진짜 보장 범위

actor는 재진입한다 — 원자성은 await 사이 구간뿐
actor ImageLoader {
    private var cache: [URL: UIImage] = [:]

    // ❌ 재진입 버그
    func image(for url: URL) async throws -> UIImage {
        if let hit = cache[url] { return hit }   // (1) "캐시에 없음" 확인
        let img = try await download(url)        // (2) ⚠️ 여기서 다른 task가 들어온다
        cache[url] = img                         // (3) 중복 다운로드 + 덮어쓰기
        return img
    }
}

// ✅ in-flight Task 캐싱 — 중복을 "발생 자체를" 막는다
actor SafeImageLoader {
    private var cache: [URL: UIImage] = [:]
    private var inFlight: [URL: Task<UIImage, Error>] = [:]

    func image(for url: URL) async throws -> UIImage {
        if let hit = cache[url] { return hit }
        if let running = inFlight[url] {
            return try await running.value       // 같은 요청은 같은 Task를 공유
        }
        let task = Task { try await download(url) }
        inFlight[url] = task
        defer { inFlight[url] = nil }

        let img = try await task.value
        cache[url] = img                         // await에서 돌아온 뒤 확정
        return img
    }
}
📝 재진입은 버그가 아니라 의도된 설계다

actor가 await 동안에도 다른 task를 받지 않으면 서로를 기다리다 데드락이 납니다. 그래서 Swift는 "직렬 접근은 보장하되, 중단 지점에서는 양보한다"를 택했습니다. 대신 "await 앞에서 본 것을 뒤에서도 믿지 마라"가 개발자 책임이 됩니다.

@MainActor는 "메인 스레드"가 아니라 "격리 도메인"이다
@MainActor
final class ViewModel {
    var items: [Item] = []          // MainActor에 격리된 상태

    func load() async {
        let data = await fetch()    // ← nonisolated 함수. 다른 실행기로 갈 수 있다
        items = data                // ← 다시 MainActor로 돌아와서 실행
    }

    nonisolated func pureHelper(_ x: Int) -> Int { x * 2 }  // 격리 밖
}

// 확인하는 법
@MainActor func check() {
    assert(Thread.isMainThread)                 // 결과적으로는 메인 스레드다
    MainActor.assertIsolated()                  // 정확히는 이 검사가 맞다
}

// ⚠️ 개념 차이가 드러나는 지점:
//   "메인 스레드에서 실행 중"  ≠  "MainActor에 격리됨"
//   Thread.isMainThread == true 여도 MainActor 격리가 아닐 수 있고,
//   컴파일러는 스레드가 아니라 "격리"를 기준으로 검사한다.
Sendable — 무엇을 보장하고 무엇은 안 하는가
// 자동으로 Sendable
struct Point: Sendable { var x, y: Int }              // 값 타입 + Sendable 멤버
enum Status: Sendable { case idle, running }
final class Immutable: Sendable { let name: String; init(_ n: String) { name = n } }

// ❌ Sendable 아님 — 컴파일러가 거부한다
final class Mutable { var count = 0 }
// Task { mutable.count += 1 }   // error: capture of non-Sendable type

// ✅ @unchecked Sendable = "내가 락으로 직접 보장한다"는 선언
final class Counter: @unchecked Sendable {
    private let lock = NSLock()
    private var _count = 0
    var count: Int { lock.withLock { _count } }        // NSLock.withLock 은 iOS 16+
    func increment() { lock.withLock { _count += 1 } }
}
// iOS 16 미만이면 명시적으로
//   lock.lock(); defer { lock.unlock() }
// ⚠️ 락 없이 @unchecked 만 붙이면 "검사만 끈 것"이다.
//    이건 컴파일러를 속이는 것이지 안전해지는 게 아니다.
진단 도구 — 면접에서 "이렇게 확인했습니다"라고 말할 근거
# ① Thread Sanitizer — 실제 data race를 런타임에 검출
#    Xcode: Scheme → Run → Diagnostics → Thread Sanitizer 체크
#    (성능이 5~15배 느려지므로 릴리즈 전 1회 정도)
swift test --sanitize=thread

# ② Main Thread Checker — 백그라운드에서 UIKit 접근 검출 (기본 켜짐)

# ③ Swift 6 컴파일 타임 검사 — 점진 도입
#    Package.swift 또는 빌드 설정에서 모듈 단위로
swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") ]
#    또는 SWIFT_STRICT_CONCURRENCY = complete

L4CS 근본 — 코드 순서는 지켜지지 않는다

① 메모리 모델 — 컴파일러와 CPU가 순서를 바꾼다

가장 충격적인 사실부터. 당신이 쓴 순서대로 실행된다는 보장이 없습니다. 컴파일러는 최적화를 위해 재배치하고, CPU는 비순차 실행(out-of-order execution)스토어 버퍼 때문에 쓰기가 다른 코어에 보이는 순서를 바꿉니다.

고전 예제 — 두 스레드 모두 "이전 값"을 볼 수 있다
// 초기: x = 0, y = 0
// 스레드 A            스레드 B
x = 1                  y = 1
r1 = y                 r2 = x

// 직관적으로 가능한 결과: (r1,r2) = (0,1), (1,0), (1,1)
// 실제로는 (0,0) 도 나온다!
//
// 왜? 각 코어의 "스토어 버퍼" 때문이다.
//   A가 x=1을 자기 스토어 버퍼에 넣고 아직 캐시에 반영하기 전에 y를 읽으면
//   B의 y=1이 아직 안 보인다. B도 대칭적으로 마찬가지.
//   → 두 쓰기가 서로에게 보이지 않은 채로 두 읽기가 일어난다.

// 이게 순차 일관성(sequential consistency)이 하드웨어 기본값이 아닌 이유이고,
// "락 없이 플래그 하나로 동기화" 같은 코드가 위험한 이유다.
그래서 이런 "직관적인" 코드가 틀린다
// ❌ 잘 돌아가는 것처럼 보이지만 보장이 없다
final class BadFlag {
    var ready = false
    var data: [Int] = []

    func producer() {
        data = [1, 2, 3]     // (1)
        ready = true         // (2) — (1)보다 먼저 보일 수 있다!
    }
    func consumer() {
        while !ready { }     // (3) — ready만 보고
        print(data)          // (4) — data가 아직 비었을 수 있다
    }
}
// 게다가 컴파일러가 while 루프를 "ready는 안 변하네"라고 판단해
// 무한 루프로 최적화해 버릴 수도 있다.

// ✅ 동기화 프리미티브를 쓰면 "메모리 배리어"가 함께 온다
//    락/actor/atomic은 상호 배제만 주는 게 아니라
//    "여기 이전의 쓰기는 여기 이후에 반드시 보인다"는 순서 보장도 준다.
actor GoodFlag {
    private var data: [Int] = []
    func produce() { data = [1, 2, 3] }
    func consume() -> [Int] { data }
}
🧠 L4 이게 "락은 느리다"의 진짜 의미

락이 비싼 이유는 대기 때문만이 아닙니다. 락은 메모리 배리어를 동반하는데, 배리어는 CPU에게 "스토어 버퍼를 비우고, 재정렬하지 말고, 다른 코어의 캐시와 동기화하라"고 명령합니다. 이건 파이프라인과 캐시 최적화를 일시적으로 포기하는 것이라 비쌉니다. 그래서 "공유하지 않는 것"이 항상 최선인 겁니다 — 배리어 자체가 필요 없어지니까요.

② 캐시 일관성 — MESI와 false sharing

각 코어에는 자기 L1 캐시가 있습니다. 그런데 두 코어가 같은 메모리를 캐싱하면 값이 어긋날 수 있죠. 이걸 막는 게 캐시 일관성 프로토콜이고, 대표적인 것이 MESI입니다.

상태의미
Modified내가 고쳤고 나만 갖고 있다 — 메모리와 다름
Exclusive나만 갖고 있고 아직 안 고쳤다
Shared여러 코어가 같은 값을 갖고 있다 — 읽기만 가능
Invalid내 사본은 무효다 — 다시 가져와야 함

어떤 코어가 쓰려고 하면, 다른 코어의 사본을 전부 Invalid로 만들어야 합니다. 이게 코어 간 통신이고 비용입니다. 그리고 여기서 유명한 함정이 나옵니다.

False sharing — 서로 다른 변수인데 성능이 죽는다
// 두 스레드가 서로 다른 변수를 만지는데도 느려지는 경우
final class Counters {
    var a = 0       // 이 둘이
    var b = 0       // 같은 캐시 라인(64B 또는 128B)에 들어간다
}

// 스레드 1: counters.a += 1  을 반복
// 스레드 2: counters.b += 1  을 반복
//
// 논리적으로는 전혀 겹치지 않는다. 그런데:
//   스레드 1이 a를 쓰면 → 그 캐시 라인 전체가 Modified
//   → 스레드 2의 사본은 Invalid
//   → 스레드 2가 b를 읽으려면 라인을 다시 가져와야 함
//   → 라인 소유권이 두 코어 사이를 계속 왕복 (ping-pong)
//
// 이게 false sharing 이다. "공유하지 않는데 공유된 것처럼 느려진다."

// ✅ 회피: 각자 다른 캐시 라인에 놓거나(패딩), 아예 스레드 로컬로 누적 후 합산
func parallelSum(_ items: [Int]) async -> Int {
    await withTaskGroup(of: Int.self) { group in
        // ⚠️ chunked(into:)는 표준 라이브러리에 없다 — 직접 정의하거나 stride를 쓴다
        for start in stride(from: 0, to: items.count, by: 1000) {
            let chunk = items[start..<min(start + 1000, items.count)]
            group.addTask { chunk.reduce(0, +) }   // 각자 지역 변수에 누적
        }
        return await group.reduce(0, +)            // 마지막에 한 번만 합침
    }
}

여기서 04번과 만납니다. ARC의 retain/release가 멀티코어에서 비싼 이유가 정확히 이거예요 — 여러 코어가 같은 객체의 refCount를 만지면 그 캐시 라인이 코어 사이를 왕복합니다.

③ 상호 배제와 데드락 4조건

Coffman 조건 — 넷이 동시에 성립해야 데드락이다
① 상호 배제 (Mutual Exclusion)   — 자원을 한 번에 하나만 쓸 수 있다
② 점유 대기 (Hold and Wait)      — 자원을 쥔 채로 다른 자원을 기다린다
③ 비선점  (No Preemption)        — 남의 자원을 강제로 뺏을 수 없다
④ 순환 대기 (Circular Wait)      — A→B→C→A 로 기다림이 순환한다

// → 하나만 깨면 데드락이 없어진다. 실무에서는 보통 ④를 깬다.
순환 대기를 깨는 법 — 락 순서를 전역으로 고정한다
// ❌ 데드락 — 두 스레드가 반대 순서로 잠근다
func transferBad(_ from: Account, _ to: Account, _ amount: Int) {
    from.lock.lock()          // 스레드1: A잠금 → B대기
    to.lock.lock()            // 스레드2: B잠금 → A대기   → 순환 대기
    defer { to.lock.unlock(); from.lock.unlock() }
    from.balance -= amount; to.balance += amount
}

// ✅ 항상 같은 순서로 잠근다 (id 순) → 순환이 생길 수 없다
func transfer(_ from: Account, _ to: Account, _ amount: Int) {
    let (first, second) = from.id < to.id ? (from, to) : (to, from)
    first.lock.lock();  defer { first.lock.unlock() }
    second.lock.lock(); defer { second.lock.unlock() }
    from.balance -= amount; to.balance += amount
}

// ✅ 더 나은 해법: 락을 아예 안 쓴다 — 하나의 actor가 둘 다 소유
actor Bank {
    private var balances: [AccountID: Int] = [:]
    func transfer(_ from: AccountID, _ to: AccountID, _ amount: Int) {
        balances[from, default: 0] -= amount
        balances[to,   default: 0] += amount
    }   // await 없이 동기 코드 → 이 구간 전체가 원자적
}
⚠️ 우선순위 역전 — iOS에서 실제로 겪는 문제

낮은 우선순위 스레드가 락을 쥔 채로 스케줄에서 밀리면, 높은 우선순위 스레드가 낮은 쪽을 기다리게 됩니다. os_unfair_lock과 GCD는 우선순위 상속(priority donation)으로 이를 완화합니다 — 락을 쥔 스레드의 우선순위를 일시적으로 올려 주는 것이죠. 그래서 바쁜 대기(spin lock)를 직접 구현하면 안 됩니다 — iOS에서는 우선순위 역전이 나면 그냥 굶어 죽습니다.

④ actor 모델 — 1973년에 나온 아이디어

Swift의 actor는 새 발명이 아니라 Hewitt·Bishop·Steiger가 1973년에 제안한 actor 모델의 구현입니다. 핵심 명제는 하나 — "메모리를 공유하지 말고 메시지를 주고받아라."

공유 메모리 + 락actor 모델CSP (Go 채널)
기본 발상같은 메모리를 번갈아 쓴다상태를 소유자만 만지고 메시지를 보낸다상태를 채널로 전달한다
주소 지정actor를 지목해 보냄채널에 보냄 (누가 받을진 모름)
Swift에서NSLock, GCDactor, @MainActorAsyncStream 이 유사
실패 지점데드락, 순서, 배리어 누락재진입, 메시지 순서채널 데드락, 버퍼링

정직하게 말할 것: actor는 data race를 구조적으로 없애지만 race condition은 없애지 못합니다. 실제로 actor의 대표적 함정인 재진입이 바로 race condition이에요 — data race는 없는데 순서 때문에 결과가 틀리는 상황입니다.

⑤ 락 없이 하기 — CAS와 원자 연산

Compare-And-Swap — 모든 락프리 자료구조의 기초
// CAS의 의미: "지금 값이 expected 이면 desired로 바꿔라. 아니면 실패해라."
// 이 전체가 하드웨어 명령 하나로 원자적으로 수행된다.

// ── 의사코드: CAS 의 의미 ──
func compareAndSwap(_ ptr: inout Int, expected: Int, desired: Int) -> Bool

// 이걸로 락 없이 카운터를 만든다 (재시도 루프)
var current = load(counter)
while !compareAndSwap(&counter, expected: current, desired: current + 1) {
    current = load(counter)       // 누가 먼저 바꿨으면 다시 읽고 재시도
}

// ── 실제 Swift API: swift-atomics 패키지 ──
import Atomics
let counter = ManagedAtomic<Int>(0)
counter.wrappingIncrement(ordering: .relaxed)
let v = counter.load(ordering: .acquiring)

// 실제 CAS 는 결과를 튜플로 돌려준다
let (exchanged, original) = counter.compareExchange(
    expected: 0, desired: 1, ordering: .sequentiallyConsistent)

// ⚠️ 메모리 순서(ordering)를 직접 고르는 건 전문 영역이다.
//    .relaxed 는 순서 보장이 없어서 카운터에는 되지만 플래그에는 안 된다.
//    확신이 없으면 .sequentiallyConsistent 를 쓰거나, actor를 쓰는 게 낫다.

CS 정본으로 더 내려가기

이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 CS 원리 → iOS 매핑 → 재현·측정 순서로 끝까지 파는 것은 허브의 iOS 개발자 CS 로드맵이고, 대응 챕터는 아래입니다.

경험으로 말하기

🎤 "actor 썼습니다"가 아니라 "무엇을 어떻게 좁혔는가"

"동시성 문제는 actor로 해결했습니다."

"이미지 캐시가 간헐적으로 잘못된 이미지를 보여 줬습니다. TSan을 켜니 딕셔너리 접근에서 data race가 잡혔고, 우선 DispatchQueue barrier로 막았습니다. 그런데 그 뒤에도 같은 URL을 세 번 다운로드하는 문제가 남았어요 — 이건 data race가 아니라 '검사 후 사용'이 원자적이지 않은 race condition이었습니다. 그래서 actor로 옮기면서 in-flight Task 캐싱을 넣어 중복 요청 자체를 합쳤습니다. 핵심은 두 문제가 다른 종류라 해법도 달랐다는 점입니다 — 첫 번째는 도구로, 두 번째는 설계로 풀었습니다."

꼬리 질문 대비

L2 actor를 쓰면 락이 필요 없어지나요?

대부분의 경우 그렇지만, 남는 자리가 있습니다.

  • 동기 API를 유지해야 할 때 — actor 접근은 await가 필요합니다. 프로토콜이 동기 메서드를 요구하거나 deinit·C 콜백처럼 await를 못 쓰는 자리에서는 락이 필요합니다.
  • 극히 짧은 임계 구역 — 카운터 증가 같은 경우 actor hop 비용이 락보다 클 수 있습니다. 다만 측정하고 나서 판단할 일입니다.
  • 레거시 경계 — 기존 GCD 코드와 섞일 때, 전면 전환 전까지 락이 남습니다.
  • @unchecked Sendable을 정당화할 때 — "락으로 직접 보장한다"의 그 락입니다.

정리: actor가 기본이고, 락은 "actor를 쓸 수 없는 자리"의 도구입니다. 그리고 NSRecursiveLock이나 세마포어로 async를 동기처럼 쓰는 건 스레드 고갈을 유발하므로 피해야 합니다.

쉽게: actor는 번호표 뽑고 줄 서는 창구입니다. 대부분 이게 낫지만, "3초 안에 끝나는 일에 번호표 뽑기"가 오히려 느릴 수도 있고, 번호표 기계가 없는 곳(동기 API)에서는 못 씁니다.
L3 Swift 6로 올리면 동시성 버그가 없어지나요?

아닙니다. 네 가지가 남습니다.

  • ① Race condition — 앞서 본 "검사 후 사용", 중복 요청, 순서 의존. 컴파일러가 잡는 대상이 아닙니다.
  • ② 데드락 — actor 간 순환 대기, DispatchQueue.sync 오용. 타입 시스템은 대기 그래프를 모릅니다.
  • ③ 경계 밖 — C/ObjC 상호운용, Unsafe*Pointer, 시스템 프레임워크 내부. Swift 코드 안에서만 보장됩니다.
  • @unchecked Sendable로 뚫은 구멍 — 근거 없이 붙이면 검사만 끈 것입니다.

그리고 02번의 Rice의 정리가 여기에도 적용됩니다 — 정적 분석은 보수적이라 안전한 코드도 거부합니다. 그래서 마이그레이션 중 경고 상당수가 실제로는 안전한데 증명 불가인 경우이고, 그걸 구분하는 게 실력입니다.

정확한 진술: "Swift 6는 data race라는 한 부류를 컴파일 타임으로 옮겼습니다. 동시성 설계가 쉬워진 게 아니라, 틀린 설계가 더 일찍 드러나게 된 겁니다."

쉽게: 맞춤법 검사기를 켠 것과 같습니다. 오타는 잡히지만 글이 논리적으로 말이 되는지는 안 봐 줍니다.
L4counter += 1 하나가 위험한가요? 한 줄인데요.

세 층으로 답하면 완결됩니다.

① 원자성 — 한 줄이 세 명령이다. load → add → store. 두 스레드가 사이에 끼어들면 갱신 유실이 납니다. 이건 잘 알려진 부분이죠.

② 가시성 — 쓴 값이 다른 코어에 안 보일 수 있다. 각 코어는 자기 캐시와 스토어 버퍼를 갖습니다. 동기화가 없으면 내가 쓴 값이 언제 다른 코어에 보이는지 보장이 없습니다. 그래서 while !flag {} 같은 코드가 영원히 안 끝날 수 있습니다.

③ 순서 — 컴파일러와 CPU가 재배치한다. 단일 스레드에서 결과가 같으면 순서를 바꿔도 되는 게 최적화의 전제입니다. 그런데 다른 스레드가 보고 있으면 그 전제가 깨집니다.

그래서 동기화 프리미티브가 주는 것은 상호 배제만이 아닙니다원자성 + 가시성 + 순서 셋을 함께 줍니다. 이 셋을 묶어 말할 수 있으면 "data race가 왜 정의되지 않은 동작인지"까지 설명됩니다. 결과가 이상한 게 아니라, 언어가 아무 것도 약속하지 않는 상태가 되는 겁니다.

쉽게: 공유 노트에 숫자를 고치는 상황입니다. 둘이 동시에 "3을 보고 4로 고치면" 5가 되어야 하는데 4가 됩니다. 내가 고쳐도 상대는 자기 복사본을 보고 있어서 모릅니다. 게다가 정리하는 사람이 순서를 바꿔서 옮겨 적을 수도 있어요. 자물쇠는 이 셋을 한꺼번에 막아 줍니다.

쉽게 이해하기

사무실에 공유 화이트보드가 하나 있다고 합시다.

Data race는 두 사람이 동시에 같은 칸에 마커를 대는 것입니다. 결과가 이상해지는 정도가 아니라 글자가 뭉개져서 뭐가 될지 아무도 모릅니다. 이건 규칙 위반이고, 감시 카메라(Thread Sanitizer)로 잡을 수 있습니다.

Race condition은 다릅니다. 둘 다 얌전히 번갈아 썼는데, 순서가 꼬여서 결과가 틀린 겁니다. "재고 확인 → 없으면 주문"을 두 사람이 각각 얌전히 했는데, 둘 다 확인하고 둘 다 주문해서 두 배로 시킨 상황이죠. 규칙은 다 지켰는데 결과가 틀렸어요. 그래서 카메라로는 안 잡히고, 일하는 방식 자체를 바꿔야 합니다.

그리고 여기 정말 이상하게 들리는 사실이 있습니다. 사람마다 화이트보드 사진을 각자 찍어서 자기 자리에서 봅니다(캐시). 내가 보드를 고쳐도, 상대는 옛날 사진을 보고 있을 수 있어요. 그래서 "고쳤으니까 알겠지"가 성립하지 않습니다.

더 이상한 건 순서까지 바뀔 수 있다는 겁니다. 일 잘하는 비서(컴파일러·CPU)가 "이 두 개는 순서 바꿔도 결과 같네" 하고 먼저 할 걸 나중에 합니다. 나 혼자 일할 땐 문제없는데, 남이 중간을 들여다보고 있으면 이상한 걸 보게 되죠.

자물쇠(락·actor)가 하는 일이 여기서 세 가지로 늘어납니다. ① 한 번에 한 명만 들어가게 하고, ② 나갈 때 모두에게 최신 사진을 돌리고, ③ 비서에게 "이 구간은 순서 바꾸지 마"라고 못 박습니다. "락은 느리다"는 말이 나오는 이유가 ②③ 때문이에요 — 최적화를 잠깐 포기하는 값입니다.

그래서 가장 좋은 방법은 언제나 "보드를 공유하지 않는 것"입니다. 각자 자기 종이에 쓰고 마지막에 합치면, 자물쇠도 사진도 순서도 신경 쓸 게 없습니다.

🔑 한 문장

동기화는 상호 배제만이 아니라 가시성과 순서까지 주는 장치다. 그래서 가장 싼 해법은 언제나 공유하지 않기이고, Swift 6가 잡아 주는 건 data race 한 부류일 뿐 race condition은 설계로 풀어야 한다.

설계 근거 · 1차 자료

이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.

📌 각 자료가 뒷받침하는 것
  • SE-0306Actor reentrancy 절 — 재진입이 데드락 회피를 위한 의도적 선택임을 밝힌 곳
  • SE-0302 — 경계를 넘는 값을 타입으로 검사하기로 한 근거
  • SE-0304 — 자식 Task 수명을 부모 스코프에 묶은 이유