본문 바로가기

정보

Swift의 동시성(Concurrency) 프로그래밍에 대해 설명해주세요.

 

 동시성 프로그래밍은 여러 작업을 병렬로 처리하거나 비동기적으로 실행하여 앱의 응답성을 높이는 프로그래밍 기법입니다. Swift는 이를 지원하기 위해 Grand Central Dispatch(GCD), OperationQueue, 그리고 Swift Concurrency(Async/Await)를 제공합니다.

  • 비동기 처리: UI 작업과 네트워크 작업을 분리해 메인 스레드의 부담을 줄입니다.
  • 병렬 처리: 여러 스레드에서 작업을 동시에 처리하여 성능을 최적화합니다.

Swift 5.5부터 추가된 Async/Await를 통해 동시성 코드가 읽기 쉽고 유지보수가 용이하도록 개선되었습니다.

 

 

Grand Central Dispatch(GCD)의 주요 개념과 사용 방법을 설명해주세요.

GCD는 저수준의 동시성 프로그래밍을 지원하는 라이브러리로, 작업을 작업 큐(Dispatch Queue)에 추가해 효율적으로 관리합니다.

  1. Dispatch Queue:
    • 작업을 실행할 스레드를 관리합니다.
    • Serial Queue: 하나의 작업이 완료된 후에 다음 작업을 실행.
    • Concurrent Queue: 여러 작업을 동시에 실행.
    • Main Queue: UI 관련 작업 실행 (Serial).
  2. QoS(Quality of Service):
    • 작업의 우선순위를 설정합니다. (e.g., .userInteractive, .userInitiated, .background).
  3. Sync와 Async:
    • Sync: 작업이 완료될 때까지 현재 스레드가 대기.
    • Async: 작업을 큐에 넣고 현재 스레드는 즉시 반환.

 

GCD의 사용 방법

  • 작업 큐 생성
let backgroundQueue = DispatchQueue(label: "com.example.backgroundQueue", qos: .background)

 

  • 비동기 작업 실행
backgroundQueue.async {
    // 백그라운드에서 작업 실행
    print("Background work")
}

DispatchQueue.main.async {
    // 메인 스레드에서 UI 업데이트
    print("Update UI")
}

 

  • DispatchGroup으로 작업 그룹 관리
let group = DispatchGroup()

group.enter()
backgroundQueue.async {
    print("Task 1")
    group.leave()
}

group.enter()
backgroundQueue.async {
    print("Task 2")
    group.leave()
}

group.notify(queue: .main) {
    print("All tasks completed")
}

 

 

OperationQueue와 DispatchQueue의 차이점은 무엇인가요?

특성 DispatchQueue OperationQueue
레벨 저수준 API 고수준 API
종속성 작업 간 종속성 설정 불가능 작업 간 종속성 설정 가능 (addDependency)
취소 가능성 불가능 가능 (cancel())
우선순위 설정 제한적 (QoS 기반) 세밀하게 설정 가능 (queuePriority)
커스텀 작업 블록 작업만 추가 가능 Operation 클래스를 서브클래싱하여 커스텀 작업 가능

 

DispatchQueue.global(qos: .background).async {
    print("Background task")
}


let operationQueue = OperationQueue()

let operation1 = BlockOperation {
    print("Operation 1")
}

let operation2 = BlockOperation {
    print("Operation 2")
}

operation2.addDependency(operation1) // 종속성 설정

operationQueue.addOperations([operation1, operation2], waitUntilFinished: false)

 

DispatchQueue는 GCD를 기반으로 하는 저수준 API이고, OperationQueue는 더 고수준의 기능을 제공합니다.

 

OperationQueue 사용 예제

  • 블록 작업 추가
let operationQueue = OperationQueue()

let operation1 = BlockOperation {
    print("Operation 1")
    sleep(2)
    print("Operation 1 Done")
}

let operation2 = BlockOperation {
    print("Operation 2")
    sleep(1)
    print("Operation 2 Done")
}

operationQueue.addOperations([operation1, operation2], waitUntilFinished: false)

 

실행 결과: 작업이 동시에 실행됩니다.

Operation 1
Operation 2
Operation 2 Done
Operation 1 Done

 

  • 종속성 추가
let operationQueue = OperationQueue()

let operation1 = BlockOperation {
    print("Downloading file...")
    sleep(2)
    print("File downloaded.")
}

let operation2 = BlockOperation {
    print("Processing file...")
    sleep(1)
    print("File processed.")
}

operation2.addDependency(operation1) // operation1이 완료된 후 실행

operationQueue.addOperations([operation1, operation2], waitUntilFinished: false)

 

Downloading file...
File downloaded.
Processing file...
File processed.

 

동시성 프로그래밍에서 발생할 수 있는 문제(Race Condition, Deadlock 등)와 해결 방법은 무엇인가요?

Race Condition(경쟁 상태)는 동시성 프로그래밍에서 두 개 이상의 스레드가 동일한 자원(변수, 메모리 등)에 동시에 접근할 때 발생할 수 있는 문제입니다. 각 스레드가 자원을 독립적으로 읽거나 쓰는 시점이 서로 겹치면, 예상치 못한 결과가 나타날 수 있습니다.

 

예시: 공유 변수 접근 문제

아래 코드는 sharedResource라는 변수를 두 개의 스레드가 동시에 증가시키거나 감소시키는 상황을 보여줍니다:

let concurrentQueue = DispatchQueue(label: "com.example.concurrent", attributes: .concurrent)
var sharedResource = 0

concurrentQueue.async {
    for _ in 0..<5 {
        sharedResource += 1
        print("Incremented: \(sharedResource)")
    }
}

concurrentQueue.async {
    for _ in 0..<5 {
        sharedResource -= 1
        print("Decremented: \(sharedResource)")
    }
}

 

문제 발생:

  • 두 스레드가 sharedResource를 동시에 읽거나 쓰는 경우가 발생.
  • 이로 인해 변수의 값이 의도한 값(예: 0)이 아니라 예측 불가능한 값이 될 수 있음.

 

해결 방안: 동기화(Synchronization)

동기화를 통해 한 번에 하나의 스레드만 자원에 접근하도록 제한할 수 있습니다. Swift에서는 다음 방법으로 해결할 수 있습니다.

 

1. NSLock 사용

NSLock는 스레드 간의 자원 접근을 직렬화합니다.

let lock = NSLock()
let concurrentQueue = DispatchQueue(label: "com.example.concurrent", attributes: .concurrent)
var sharedResource = 0

concurrentQueue.async {
    for _ in 0..<5 {
        lock.lock() // 자원 접근 시작
        sharedResource += 1
        print("Incremented safely: \(sharedResource)")
        lock.unlock() // 자원 접근 종료
    }
}

concurrentQueue.async {
    for _ in 0..<5 {
        lock.lock()
        sharedResource -= 1
        print("Decremented safely: \(sharedResource)")
        lock.unlock()
    }
}
Incremented safely: 1
Incremented safely: 2
Decremented safely: 1
Incremented safely: 2
Decremented safely: 1

 

동작 방식:

  • lock.lock(): 현재 스레드가 자원에 접근할 권한을 얻음.
  • lock.unlock(): 자원 접근이 끝났음을 다른 스레드에 알림.
 

2. Dispatch Barrier 사용

Dispatch Barrier는 Concurrent Queue에서 특정 작업을 실행할 때, 모든 다른 작업을 차단하고 단독 실행을 보장합니다.

let concurrentQueue = DispatchQueue(label: "com.example.concurrent", attributes: .concurrent)
var sharedResource = 0

concurrentQueue.async {
    for _ in 0..<5 {
        concurrentQueue.async(flags: .barrier) { // Barrier 작업
            sharedResource += 1
            print("Incremented safely with barrier: \(sharedResource)")
        }
    }
}

concurrentQueue.async {
    for _ in 0..<5 {
        concurrentQueue.async(flags: .barrier) {
            sharedResource -= 1
            print("Decremented safely with barrier: \(sharedResource)")
        }
    }
}
Incremented safely with barrier: 1
Incremented safely with barrier: 2
Decremented safely with barrier: 1
Decremented safely with barrier: 0

 

동작 방식:

  • async(flags: .barrier): 해당 작업이 실행되는 동안 큐에서 다른 작업을 실행하지 않음.
  • Barrier 작업이 끝난 후 다른 작업이 재개됨.

 

3. Dispatch Semaphore 사용

DispatchSemaphore는 제한된 자원에 접근할 수 있는 스레드의 개수를 제한합니다. 세마포어 값이 0이면 다른 스레드가 자원에 접근하려고 할 때 대기 상태가 됩니다.

let semaphore = DispatchSemaphore(value: 1) // 자원 접근 허용 값 1
let concurrentQueue = DispatchQueue(label: "com.example.concurrent", attributes: .concurrent)
var sharedResource = 0

concurrentQueue.async {
    for _ in 0..<5 {
        semaphore.wait() // 자원 접근 대기
        sharedResource += 1
        print("Incremented safely with semaphore: \(sharedResource)")
        semaphore.signal() // 자원 반환
    }
}

concurrentQueue.async {
    for _ in 0..<5 {
        semaphore.wait()
        sharedResource -= 1
        print("Decremented safely with semaphore: \(sharedResource)")
        semaphore.signal()
    }
}
Incremented safely with semaphore: 1
Incremented safely with semaphore: 2
Decremented safely with semaphore: 1
Decremented safely with semaphore: 0

 

동작 방식:

  • semaphore.wait(): 세마포어 값을 감소. 값이 0이면 대기.
  • semaphore.signal(): 세마포어 값을 증가. 대기 중인 스레드가 자원에 접근할 수 있도록 신호를 보냄.

 

4. Serial Queue로 자원 접근 관리

Serial Queue를 사용하면 한 번에 하나의 작업만 처리되므로 Race Condition을 방지할 수 있습니다.

let serialQueue = DispatchQueue(label: "com.example.serial")
var sharedResource = 0

serialQueue.async {
    for _ in 0..<5 {
        sharedResource += 1
        print("Incremented safely with serial queue: \(sharedResource)")
    }
}

serialQueue.async {
    for _ in 0..<5 {
        sharedResource -= 1
        print("Decremented safely with serial queue: \(sharedResource)")
    }
}

 

Incremented safely with serial queue: 1
Incremented safely with serial queue: 2
Decremented safely with serial queue: 1
Decremented safely with serial queue: 0

 

동작 방식:

  • Serial Queue는 작업을 하나씩 순차적으로 실행하여 데이터 충돌을 방지.

 

언제 어떤 방법을 선택해야 할까?

  1. 간단한 자원 보호: NSLock 사용.
  2. 고성능 및 특정 작업 보호: Dispatch Barrier 사용.
  3. 동시 작업 제한: Dispatch Semaphore 사용.
  4. 순차적 실행 보장: Serial Queue 사용.

 

Deadlock이란?

Deadlock(교착 상태)은 두 개 이상의 작업이 서로의 자원을 기다리며 무한 대기 상태에 빠지는 상황을 말합니다. 주로 여러 스레드가 상호 의존적으로 락(lock)을 요청하는 경우 발생합니다.

 

Deadlock의 발생 조건

Deadlock이 발생하려면 아래 네 가지 조건이 동시에 성립해야 합니다:

  1. 상호 배제: 자원은 한 번에 하나의 스레드만 사용할 수 있음.
  2. 점유와 대기: 하나의 자원을 점유한 상태에서 다른 자원을 요청하며 대기.
  3. 비선점: 다른 스레드가 점유한 자원을 강제로 빼앗을 수 없음.
  4. 순환 대기: 스레드 간 자원을 서로 점유한 상태로 순환적으로 대기.

 

Deadlock의 예제

1. 동기 호출로 발생하는 Deadlock

let serialQueue = DispatchQueue(label: "com.example.serial")

serialQueue.sync {
    print("Task 1")
    serialQueue.sync {
        print("Task 2") // 여기서 Deadlock 발생
    }
}

 

문제:

  • serialQueue.sync는 현재 큐가 비어야 작업을 실행합니다.
  • Task 1이 실행 중인 상태에서 Task 2는 동일한 큐를 사용하려고 대기 중.
  • 하지만 Task 1은 Task 2가 끝날 때까지 대기하므로 무한 대기 상태가 됩니다.

🔥 serialQueue는 Serial Queue라서 한 번에 하나의 작업만 처리합니다. Task A가 실행 중이므로 Task B는 대기 상태에 들어가며, Task A도 Task B가 끝나기를 기다려 Deadlock 발생.

 

Deadlock의 해결 방안

 

1. 동기 호출(sync)와 메인 큐의 조합 피하기

메인 스레드에서 동기 호출은 Deadlock의 대표적인 원인입니다. 대신 **비동기 호출(async)**을 사용합니다.

 

동기 호출로 인한 Deadlock 문제의 본질

Deadlock은 주로 자신이 사용 중인 큐를 다시 동기 호출하면서 발생합니다. 예를 들어, 메인 큐에서 sync 호출을 하면 아래와 같은 상황이 생깁니다:

 

  1. 현재 작업(Task A)이 메인 큐를 점유하고 있음.
  2. Task A가 메인 큐에 동기 호출(sync)을 요청.
  3. 메인 큐는 Task A가 끝나야 다음 작업(Task B)을 실행할 수 있음.
  4. Task A와 Task B가 서로를 기다리며 Deadlock 발생.

해결 방안: 다른 큐를 사용

let serialQueue1 = DispatchQueue(label: "com.example.serial1")
let serialQueue2 = DispatchQueue(label: "com.example.serial2")

serialQueue1.sync {
    print("Task A Started")
    serialQueue2.sync { // 다른 큐를 사용하므로 Deadlock 방지
        print("Task B Started")
    }
    print("Task A Ended")
}
Task A Started
Task B Started
Task A Ended

 

 serialQueue1와 serialQueue2는 서로 독립적인 큐이므로, Task A와 Task B가 서로를 기다리지 않아 Deadlock이 발생하지 않습니다.

 

 

해결 방안: 비동기 호출 사용 

Deadlock을 방지하려면 동기 호출(sync)을 비동기 호출(async)로 변경하여 큐가 대기 상태에 들어가지 않도록 합니다.

let serialQueue = DispatchQueue(label: "com.example.serial")

serialQueue.sync {
    print("Task A Started")
    serialQueue.async { // async를 사용해 Deadlock 방지
        print("Task B Started")
    }
    print("Task A Ended")
}
Task A Started
Task A Ended
Task B Started

 

async 호출은 현재 작업(Task A)을 끝낸 후 Task B를 실행하므로 Deadlock이 발생하지 않습니다.

 

동시성 프로그래밍에서 발생할 수 있는 추가적인 문제

 

Thread Starvation (스레드 기아)

  • 문제: 낮은 우선순위 작업이 높은 우선순위 작업에 밀려 실행되지 않음.
  • 원인: 큐에서 높은 우선순위의 작업만 계속 처리되면서 낮은 우선순위 작업이 대기 상태에 빠짐.

 

Priority Inversion (우선순위 역전)

  • 문제: 낮은 우선순위 작업이 높은 우선순위 작업에 의해 계속 대기하지만, 그 사이에 우선순위가 중간인 작업이 계속 실행되어 문제가 발생.
  • 해결 방안:
    • 우선순위를 명시적으로 조정하거나, 락 메커니즘에서 스케줄링 알고리즘을 개선.

 

Live Lock (활성 교착 상태)

  • 문제: 두 작업이 서로를 피해 계속 상태를 변경하지만, 작업을 완료하지 못함.
  • 예시: 두 스레드가 서로 동시에 자원을 양보하려고 하여 무한 반복.
  • 해결 방안:
    • 작업 시도 횟수를 제한하거나, 충돌 검출 및 후속 처리 로직을 추가.

 

Overhead (오버헤드)

  • 문제: 지나치게 많은 스레드가 생성되거나 불필요한 동시성 작업으로 인해 성능 저하.
  • 해결 방안:
    • 적절한 스레드 수와 작업 분배를 설계.
    • OperationQueue의 maxConcurrentOperationCount로 동시 작업 제한.