← Study Hub

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 비용이 든다.

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가 잡히는 것을 눈으로 볼 수 있다.

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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·부하를 보고 스레드 풀에서 결정한다. 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로 여러 코어가 동시에 점유되는지 눈으로 확인할 수 있다.

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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) 행동 — 그 사이 다른 스레드가 이미 채웠을 수 있음
}

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을 잡지 못한다 — 논리 결함은 스트레스 테스트·불변식 검증·순서에 대한 단위 테스트로 걸러야 한다.

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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).

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로 임계구역 체류 시간을 측정하면 병목을 정량화할 수 있다.

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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는 워커 스레드의 우선순위에 반영된다.

실험 · 도구

global concurrent queue에 DispatchSemaphore.wait() 같은 동기 대기 작업을 다수 제출하고 Thread.activeCount를 관찰하면 스레드 수가 급증하는 것을 볼 수 있다. Instruments의 System Trace로 스레드 생성 폭증과 그로 인한 스케줄링 오버헤드를 확인한다.

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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를 디버그 빌드에서 조기에 잡는다.

프로젝트 적용

⚠️ 흔한 오해

"sync는 어떤 큐로 호출해도 deadlock 위험이 있다"는 과장이다. self-deadlock의 핵심은 '자기가 이미 실행 중인 serial queue로 다시 sync'라는 조건이다. concurrent queue로의 sync는 새 실행 컨텍스트를 얻을 수 있어 이 자기 교착이 나지 않는다(물론 다른 자원을 서로 기다리는 일반적 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으로 저장하고 스레드를 양보하기 때문에, 스레드를 점유한 채 대기하는 커널 스레드와는 자원 소모가 근본적으로 다르다.

iOS에서는

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

실험 · 도구

수천 개의 Task를 만들어도 실제 스레드 수는 코어 수 근처에 머무는 것을 Thread.activeCount로 확인할 수 있다. async 함수에서 await 앞뒤로 Thread.current를 찍으면 스레드가 바뀔 수 있음이 드러난다. Instruments의 Swift Concurrency 템플릿으로 Task·continuation 상태를 추적한다.

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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) }
    }
}

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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가 바뀌었을 수 있다
    }
}

iOS에서는

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

실험 · 도구

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

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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

🔑 30초 답변

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

CS 원리

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

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의 상당수를 코드가 돌기 전에 걸러낸다.

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

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()   // 실행 중인 루프를 멈추지 않는다 — 플래그만 세운다

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의 핸들러 호출 시점을 로깅해 관찰한다.

프로젝트 적용

⚠️ 흔한 오해

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

꼬리 질문

출처 · 참고자료