동시성 프로그래밍(Concurrent Programming)은 작업이 언제 끝날지 알 수 없는 비동기 작업(예: 네트워크 통신, 파일 I/O 등)을 효율적으로 처리하는 기술입니다. 이 기법은 여러 작업을 번갈아 실행하여 마치 동시에 수행되는 것처럼 보이도록 하는 방식으로 동작합니다. 이를 통해 시간이 오래 걸리는 작업이 진행되는 동안에도 다른 작업을 실행할 수 있어 애플리케이션의 응답성을 향상시킬 수 있습니다. 예를 들어, 네트워크에서 데이터를 가져오는 동안 UI가 멈추지 않고 사용자가 원활하게 앱을 조작할 수 있도록 하려면 동시성 프로그래밍이 필수적입니다.
🟡 Important 동시성(concurrency)과 병렬성(parellelism)의 차이점은 여기를 참조하세요.
동시성 프로그래밍을 논할 때 빠짐없이 등장하는 개념이 바로 경쟁 조건(Race Condition)과 데이터 경합(Data Race)입니다. 이 두 용어는 비슷해 보이지만, 각각 고유한 의미를 가지고 있으며 명확히 구분해야 합니다.
데이터 경합(Data Race)
데이터 경합(Data Race)은 동기화(synchronization) 메커니즘 없이 서로 다른 스레드가 공유된 가변 상태에 동시에 접근하며, 그중 하나 이상의 스레드가 쓰기(write) 작업을 수행할 때 발생합니다.
경쟁 조건(Race Condition)
그 반면에, 경쟁 조건은 프로그램의 실행 순서나 타이밍이 제어할 수 없는 다른 작업과 충돌할 때 발생하며, 이로 인해 예측하지 못한 결과가 나타날 때 발생합니다. 문법적으로는 오류가 없지만, 실행 결과가 기대와 다르게 동작하는 논리적 오류(semantic error)이기도 합니다. 대부분의 경쟁 조건은 데이터 경합으로 인해 발생하지만, 항상 그런 것은 아닙니다.
데이터 경합과 경쟁 조건은 반드시 함께 발생하는 것은 아닙니다. 경쟁 조건이 존재하지만 데이터 경합이 발생하지 않을 수도 있으며, 반대로 데이터 경합이 있지만 경쟁 조건이 일어나지 않을 수도 있습니다. 즉, 두 개념은 독립적이며, 어느 한쪽이 다른 쪽에 속하는 개념이 아닙니다. 이제, 간단한 예제를 통해 데이터 경합과 경쟁 조건을 자세히 살펴보겠습니다.
단일 스레드 환경
아래는 _value_의 값을 1 증가시키는 단순한 예제입니다. Counter 클래스는 참조 타입(reference type)이므로, 하나의 객체 상태가 변경되면 동일한 객체를 참조하는 모든 코드에 영향을 미칩니다. 즉, 문제가 발생하기 쉬운 코드입니다.
class Counter {
var value: Int = 0
func increment() {
let newValue = value + 1
value = newValue
return newValue
}
}
코드를 실행할 때 DispatchQueue나 Task와 같은 비동기 컨텍스트를 사용하지 않으면, 실행 흐름은 항상 동기적으로(sync) 동작합니다. 즉, 현재 실행 중인 작업이 완료된 후에만 다음 작업이 실행됩니다.
counter.increment()
counter.increment()
print("Final Value:", counter.value) // 2
따라서, 여러 스레드가 동시에 공유된 가변 상태(value)에 접근할 일이 없으므로 경쟁 조건이나 데이터 경합이 발생하지 않습니다. 결과적으로, 항상 일관된 실행 결과가 보장됩니다.
다중 스레드 환경
여러 스레드가 동일한 공유된 가변 상태에 접근할 수 있는 환경에서는 경쟁 조건과 데이터 경합이 발생하지 않도록 주의를 기울여야 합니다.
경쟁 조건이 항상 데이터 경합을 유발하는 것은 아니며, 반대로 데이터 경합이 경쟁 조건으로 이어지는 것도 아닙니다. 즉, 두 개념은 독립적이며, 어느 한쪽이 다른 쪽에 속하는 개념이 아닙니다. 이 점을 유의하며 아래 예제를 살펴보겠습니다.
경쟁 조건와 데이터 경합이 모두 발생하는 경우
let queue = DispatchQueue.global(qos: .userInitiated)
let group = DispatchGroup()
for i in 1...10 {
queue.async(group: group) {
counter.increment()
if i == 5 { counter.value -= 5 }
}
}
group.notify(queue: queue) {
print("Final Value:", counter.value) // 1 or 7...
}
위 예제는 increment를 비동기적으로 10번 호출하는 코드입니다. 실행 결과를 보면 최종 값이 5가 아닌 1, 7 또는 그 외의 값이 됩니다. 이는 여러 스레드가 공유된 가변 상태를 읽는 작업(let newValue = value + 1)뿐만 아니라, 쓰는 작업(value = newValue)도 수행하기 때문입니다.
예를 들어, ①번 스레드가 value 값을 1로 읽고 이를 증가시키려는 순간, ②번 스레드도 동일한 1 값을 읽고 증가 연산을 수행할 수 있습니다. 이처럼 여러 스레드가 동시에 같은 값을 읽고 쓰는 과정에서 데이터 불일치가 발생하는 현상을 데이터 경합이라고 합니다.
또한, increment가 호출된 순서대로 실행된다는 보장이 없습니다. 다섯 번째 작업은 increment를 호출한 후 5를 빼는 작업을 수행하지만, 운영체제의 스케줄링에 따라 다섯 번째로 호출된 increment가 실제로는 다른 작업들보다 늦게 실행될 수도 있습니다.
이로 인해 value가 정확히 언제 5가 빠지는지 예측할 수 없으며, 어떤 작업이 먼저 실행될지, 특정 연산이 다른 연산보다 앞설지 여부도 보장되지 않습니다. 이처럼 실행 순서나 타이밍을 제어할 수 없어 발생하는 문제를 경쟁 조건이라고 합니다.
경쟁 조건은 존재하지만, 데이터 경합이 발생하지 않는 경우
class Counter {
var value = 0
let lock = NSLock()
func increment() -> Int {
lock.lock() // 🔒
let newValue = value + 1
value = newValue
lock.unlock() // 🔓
return newValue
}
}
데이터 경합을 해결하려면 임계 구역(Critical Section)에 직렬 디스패치 큐(Serial Dispatch Queue), NSLock, DispatchSemaphore 등의 동기화 매커니즘을 적용하면 됩니다. 위 예제는 _NSLock_을 사용하여 여러 스레드가 동시에 임계 구역에 접근하지 못하도록 막는 코드입니다.
하지만, 위 예제는 데이터 경합은 막아주지만 경쟁 조건은 여전히 존재합니다. increment는 어떤 순서로 실행될지 예측할 수 없기 때문이죠.
경쟁 조건과 데이터 경합이 발생하지 않는 경우
for i in 1...10 {
queue.sync {
counter.increment()
if i == 5 { counter.value -= 5 }
}
}
print("Final Value:", counter.value) // 5
마지막으로 경쟁 조건을 해결해보겠습니다. 위 예제에서 경쟁 조건이 발생하는 원인은 첫 번째 increment가 완료되기 전에 두 번째, 세 번째 increment가 실행되기 때문입니다. 따라서 코드가 순차적으로 실행되도록 하면 순서가 뒤바뀌는 문제가 발생하지 않습니다. 즉, 모든 코드를 동기적으로 실행하면 경쟁 조건을 방지할 수 있습니다.
여러 스레드가 동일한 가변 공유 상태에 접근해 읽거나 쓸 경우, 상태가 유실될 수 있으므로 데이터 경합은 반드시 방지해야 합니다. 그런데 경쟁 조건까지 무조건 막아야 할까요? 정답은 ‘아니’라고 할 수 있습니다. 경쟁 조건을 완전히 차단하면 동시성의 이점을 충분히 활용하지 못할 수 있습니다. 경쟁 조건은 반드시 막아야 하는 문제가 아니라, 상황에 따라 적절히 제어해야 하는 요소입니다.
예를 들어, 은행 계좌 업데이트처럼 데이터 무결성이 중요한 경우 경쟁 조건을 방지해야 합니다. 그 반면에, 독립된 여러 개의 독립적인 이미지를 다운로드 하는 작업처럼 서로 영향을 주지 않는 작업을 수행할 때는 경쟁 조건을 허용할 수 있습니다.
데이터 경합을 해결하는 방법
Swift는 데이터 경합을 해결하기 위한 다양한 방법을 제공합니다. 앞서 소개한 NSLock뿐만 아니라, DispatchSemaphore나 직렬 디스패치 큐를 활용할 수 있습니다. 또한, Swift 5.5에서 새롭게 도입된 액터(Actor)를 사용하면, 컴파일 단계에서 동시성 코드의 안전성을 보장하고 데이터 경합 문제를 효과적으로 방지할 수 있습니다.
actor Counter {
var value: Int = 0
func increment() -> Int {
let newValue = value + 1
value = newValue
return newValue
}
}
let counter = Counter()
Task {
async let tasks: [Void] = (1...10).map { _ in
Task {
await counter.increment()
}
}
_ = await tasks
print("Final Value:", await counter.value)
}
