[iOS] NSLock부터 Actor까지... 동기화 도구 정리

Zerom·2026년 6월 13일

iOS 정리

목록 보기
11/14

들어가며

iOS에서 동시성 버그를 막기 위한 도구는 생각보다 많습니다. NSLock, os_unfair_lock, OSAllocatedUnfairLock, Mutex, NSRecursiveLock, DispatchQueue, DispatchSemaphore, Swift Atomics, 그리고 actor까지. 이름이 다르고 등장한 시기도 다르지만, 모두 "여러 스레드·Task가 같은 상태를 동시에 건드리지 못하게 막는다"는 목적을 공유합니다.

그런데 도구마다 추상화 레벨, 성능 특성, 안전 보장 수준이 다릅니다. 어떤 도구는 컴파일러가 강제하고, 어떤 도구는 개발자가 lock()/unlock() 쌍을 직접 맞춰야 합니다. 어떤 도구는 스레드를 블로킹하고, 어떤 도구는 Task를 suspend합니다. 각 도구의 동작 원리와 트레이드오프를 한 번에 정리해봤습니다.


Lock 계열

NSLock

NSLock은 POSIX pthread_mutex를 Objective-C로 래핑한 클래스입니다. iOS 2+에서 사용 가능하며, API가 직관적이고 가독성이 좋습니다.

경합이 발생하면 대기 스레드가 커널 레벨에서 블로킹됩니다. 블로킹된 스레드를 깨우는 과정에서 커널 컨텍스트 스위칭이 발생하므로, 임계 구간이 매우 짧은 경우에는 불필요한 오버헤드가 생길 수 있습니다.

레거시 코드베이스에서 가독성 우선으로 선택할 때 적합합니다.

os_unfair_lock / OSAllocatedUnfairLock

os_unfair_lock은 iOS 10에서 OSSpinLock을 대체하며 도입된 저수준 락입니다. 이름의 "unfair"는 잠금 획득 순서를 보장하지 않는다는 의미입니다. 대신 커널이 락 소유권을 추적하여, 높은 우선순위 스레드가 대기 중일 때 락 보유자의 우선순위를 일시적으로 올리는 방식으로 우선순위 역전(priority inversion) 을 방지합니다. 스핀락은 CPU를 바쁘게 점유하며 기다리는(busy-wait) 방식이라 우선순위 역전에 취약했는데, os_unfair_lock은 이 문제를 해결합니다.

단, os_unfair_lock 자체는 C API이며 Swift에서 직접 사용하면 address stability 문제가 생길 수 있습니다. Swift에서는 iOS 16부터 제공되는 OSAllocatedUnfairLock 사용을 권장합니다. 이 타입은 값과 잠금을 한 타입으로 묶어 관리하여, 잠금 없이 값에 접근하는 실수 자체를 차단합니다.

import os

// 값과 잠금을 하나의 타입으로 묶어 관리
let counter = OSAllocatedUnfairLock(initialState: 0)

// withLock 클로저 안에서만 값에 접근 가능 — 잠금 누락 실수를 원천 차단
counter.withLock { value in
    value += 1
}

// 읽기도 withLock을 통해서
let current = counter.withLock { $0 }

Mutex (Swift 6, iOS 18+)

SE-0433으로 Swift 6 표준 라이브러리의 Synchronization 모듈에 추가된 공식 뮤텍스입니다. Mutex는 보호할 값과 잠금을 하나의 타입으로 묶어 관리하며, withLock 클로저 안에서만 값에 접근할 수 있습니다. OSAllocatedUnfairLock과 API가 거의 동일하지만 import os 없이 표준 라이브러리만으로 사용할 수 있다는 점이 다릅니다.

import Synchronization

let counter = Mutex(0)

counter.withLock { value in
    value += 1
}

플랫폼 제약이 있습니다. iOS 18+, macOS 15+ 부터만 사용 가능합니다. iOS 17 이하를 지원해야 한다면 OSAllocatedUnfairLock이 현실적인 대안이며, 두 타입의 withLock API는 거의 동일하므로 나중에 Mutex로 교체하기도 어렵지 않습니다.

NSRecursiveLock

NSLock과 달리 같은 스레드에서 여러 번 lock()을 호출해도 데드락이 발생하지 않습니다. 내부적으로 카운터를 유지하고, lock() 횟수만큼 unlock()을 호출해야 완전히 해제됩니다.

재귀 함수나 여러 호출 계층에서 같은 락을 중첩으로 사용해야 할 때 전용입니다. 일반적인 상황에서는 NSLock이 더 가볍고 적합합니다.


Queue / Signal 계열

DispatchQueue

DispatchQueue는 락이 아니라 작업 직렬화 도구입니다. Serial Queue를 사용하면 여러 작업이 한 번에 하나씩 처리되어 상태를 보호할 수 있습니다.

  • async: 호출 스레드를 블로킹하지 않고 작업을 큐에 넣음
  • sync: 작업이 완료될 때까지 호출 스레드 대기

경합이 증가할수록 GCD가 내부적으로 스레드를 추가 생성할 수 있어 Thread Explosion 위험이 있습니다. Swift Concurrency 컨텍스트에서 DispatchQueue.sync를 호출하면 Cooperative Thread Pool의 스레드를 블로킹하므로 주의가 필요합니다.

DispatchSemaphore

카운팅 신호(counting semaphore) 로, 특정 구간에 동시에 진입할 수 있는 작업 수를 N개로 제한합니다. DispatchSemaphore(value: N)으로 초기화하고, wait()/signal() 쌍으로 사용합니다.

semaphore.wait()는 스레드를 블로킹합니다. async 함수 안에서 사용하면 Cooperative Thread Pool의 스레드를 점유하므로, Swift Concurrency 컨텍스트에서는 사용을 피해야 합니다.


Lock-free 계열

Swift Atomics

count += 1처럼 단순해 보이는 연산도 CPU 내부에서는 읽기 → 더하기 → 쓰기의 세 단계로 이루어집니다. 두 스레드가 이 세 단계 사이에 끼어들면 서로의 결과를 덮어쓸 수 있습니다. 락은 이 구간 전체를 잠그는 방식으로 해결하지만, Swift Atomics는 다른 접근을 취합니다. 세 단계를 CPU 명령어 하나로 묶어 중간에 다른 스레드가 끼어들 수 없도록 하드웨어 수준에서 보장합니다.

apple/swift-atomics 패키지가 이 연산 타입을 제공합니다. 락이 없으므로 대기하거나 블로킹되는 스레드가 없고, 경합이 발생해도 락 획득 없이 CPU가 직접 처리합니다.

단, 이 보장은 값 하나에만 적용됩니다. 두 개 이상의 값을 묶어 함께 갱신해야 한다면(예: count와 isReady를 동시에 바꿔야 하는 경우) 락이 필요합니다. 단순 카운터, on/off 플래그, 상태 값처럼 독립적인 단일 값을 빠르게 갱신해야 할 때 가장 잘 맞습니다.


Swift Concurrency 계열

actor

actor는 Swift Concurrency 레이어에서 Serial Executor를 통해 상태를 보호합니다. 외부에서 격리된 상태에 접근하려면 await가 필요하며, 이 규칙을 컴파일러가 강제합니다.

락 계열 도구들과의 결정적 차이는 스레드를 블로킹하지 않는다는 점입니다. await 지점에서 Task가 suspend되면 스레드는 즉시 다른 Task를 처리합니다. 경합이 많은 환경에서도 Cooperative Thread Pool이 Thread Explosion 없이 효율적으로 동작합니다.

반면 단순 정수 하나를 보호하는 것처럼 임계 구간이 매우 작을 때는 Swift Concurrency 런타임 오버헤드가 상대적으로 크고, async 컨텍스트에서만 사용할 수 있다는 제약도 있습니다.


성능 비교

성능 특성을 볼 때 주의할 점이 있습니다. 단순 호출 비용(비경합)과 경합 시 시스템 처리량은 서로 다른 차원이며, 반드시 같은 방향이 아닙니다. 단순 호출이 빠른 도구가 경합 환경에서도 좋은 처리량을 보장하지는 않습니다.

도구단순 호출 비용경합 시 동작스레드 블로킹
Swift Atomics가장 낮음CAS 재시도 (비블로킹)없음
os_unfair_lock / OSAllocatedUnfairLock낮음커널 yield있음
Mutex낮음커널 yield있음
NSLock약간 높음커널 블로킹있음
NSRecursiveLock약간 높음커널 블로킹있음
DispatchQueue (sync)중간GCD 스레드 풀있음
DispatchSemaphore중간커널 블로킹있음
actor높음Task suspend (비블로킹)없음

단순 호출 비용 기준으로는 Swift Atomics < os_unfair_lock ≈ Mutex < NSLock < DispatchQueue < actor 순입니다. 반면 경합이 많은 환경에서는 actor가 Thread Explosion 없이 안정적인 처리량을 유지하는 데 유리합니다. 절대적인 나노초 수치는 하드웨어와 워크로드에 따라 크게 달라지므로, 병목이 의심된다면 실제 환경에서 직접 측정하는 것이 중요합니다.


정리

도구추천 상황최소 버전
Swift Atomics단순 카운터·플래그, 락 없는 최고 성능제약 없음 (외부 패키지, 프로젝트 최소 버전 준수)
OSAllocatedUnfairLock고성능 동기화, iOS 18 미만 지원 필요iOS 16+
MutexSwift 6 신규 프로젝트, 표준 라이브러리 선호iOS 18+
NSLock레거시 코드베이스, 하위 호환 우선iOS 2+
NSRecursiveLock재귀 함수 내 중첩 잠금이 불가피한 경우iOS 2+
DispatchQueue (serial)비동기 작업 직렬화, GCD 기반 코드베이스iOS 8+
DispatchSemaphore동시 접근 수를 N개로 제한해야 할 때iOS 8+
actor복잡한 상태 + async 코드, Swift Concurrency 전용iOS 15+

도구를 고를 때 유용한 판단 기준은 세 가지입니다. 지원해야 하는 최소 iOS 버전, 동기(sync) 코드냐 async 코드냐, 그리고 단순한 단일 값 보호냐 복잡한 상태 관리냐. 이 세 기준을 먼저 정하면 후보를 빠르게 좁힐 수 있습니다.


참고 자료

글에 대한 피드백이나 더 좋은 방법이 있다면 댓글로 공유해주세요.

profile
꼼꼼한 iOS 개발자 /
Apple Developer Academy @ POSTECH 2기 / 멋쟁이사자처럼 앱스쿨 1기

0개의 댓글