개발? 새발!
개발? 새발!

Dispatch Queue, Group, Semaphore – 동시성 제어 3총사 제대로 알기

2025.12.13

Dispatch Queue, Group, Semaphore - 동시성 제어 3총사 제대로 알기

여러 네트워크 요청을 동시에 보내고, 모두 완료되면 화면을 업데이트하고 싶은데 어떻게 하죠?
동시에 접근할 수 있는 리소스 개수를 제한하고 싶은데, 어떤 걸 써야 할까요?

오늘은 GCD(Grand Central Dispatch)에서 동시성을 제어하는 핵심 도구인 Dispatch Queue, Dispatch Group, Dispatch Semaphore에 대해 알아볼게요

각각 어떤 상황에서 사용하는지, 어떻게 조합해서 쓰는지 하나씩 살펴보겠습니다


Dispatch Queue란?

작업들을 FIFO 순서로 보관하고, 직렬 또는 동시로 실행해 주는 객체예요

실제 스레드 생성이나 관리는 OS가 알아서 하기 때문에, 개발자는 큐에 작업만 던지면 돼요

백그라운드 작업, 네트워크 요청, 파일 I/O 같은 비동기 처리에 사용하고
UI 업데이트는 반드시 메인 큐에서 처리해야 해요

큐의 종류

1. Main Queue
메인 스레드와 1:1로 연결된 직렬 큐예요
UI 업데이트나 사용자 이벤트 처리에 사용해요

DispatchQueue.main.async {
    // UI 업데이트
}

2. Global Queue
시스템이 제공하는 동시 큐예요
QoS에 따라 우선순위가 다른 여러 글로벌 큐가 존재해요

DispatchQueue.global(qos: .utility).async {
    // 백그라운드 작업
}

3. Private / Custom Queue
개발자가 직접 생성하는 큐예요
기본은 직렬 큐이고, 옵션으로 동시 큐도 생성 가능해요

let serialQ = DispatchQueue(label: "serialQ")
let concurrentQ = DispatchQueue(label: "concurrentQ", attributes: .concurrent)

sync vs async

async는 비동기로 작업을 큐에 넣고 즉시 리턴해요
호출한 스레드는 블로킹되지 않아요

sync는 큐에 넣은 작업이 끝날 때까지 기다렸다가 리턴해요
현재 큐와 같은 큐에 sync로 보내면 교착상태(deadlock)가 발생할 수 있어요
특히 DispatchQueue.main.sync를 메인 스레드에서 호출하면 즉시 데드락이 발생해요

관련 이미지 1

이미지 출처: naljin - Medium


Dispatch Group이란?

여러 비동기 작업을 하나의 그룹으로 묶어서 관리하는 객체예요

"이 그룹에 포함된 모든 작업이 끝난 시점"을 알고 싶을 때 사용해요

여러 API 호출을 병렬로 보내고, 전부 완료 후 한 번에 UI 업데이트하거나 후처리할 때 유용해요

사용 패턴

1. enter / leave 방식

let group = DispatchGroup()

group.enter()
someQueue.async {
    // 작업 1
    group.leave()
}

group.enter()
anotherQueue.async {
    // 작업 2
    group.leave()
}

group.notify(queue: .main) {
    // 모든 작업 완료 후 실행
}

2. async(group:) 방식

let group = DispatchGroup()
let queue = DispatchQueue.global()

queue.async(group: group) {
    // 작업 1
}

queue.async(group: group) {
    // 작업 2
}

group.notify(queue: .main) {
    // 모두 완료
}

notify는 비동기 콜백으로, 지정한 큐에서 실행돼요
wait()를 사용하면 동기적으로 그룹 완료까지 블로킹하는데
메인 스레드에서 남용하면 UI가 멈출 수 있으니 주의해야 해요


Dispatch Semaphore란?

공유 자원에 동시에 접근할 수 있는 허용 개수를 정해 놓고, 그 이상 접근하려 하면 대기시키는 동기화 도구예요

GCD의 DispatchSemaphore는 내부적으로 카운터를 가지고 있어요
wait()는 카운터를 1 감소시키고, 0 미만이면 블록(대기)해요
signal()은 카운터를 1 증가시키고, 대기 중인 스레드가 있으면 깨워줘요

주요 용도

동시 실행 개수를 제한하고 싶을 때 사용해요
예를 들어, 한 번에 최대 3개까지만 API 요청을 허용하고 싶은 경우예요

비동기 작업을 사실상 동기처럼 기다릴 수도 있어요
콜백 안에서 signal()을 호출하고, 호출부에서 wait()하여 응답을 기다리는 패턴이에요
단, 데드락과 UI 블로킹 위험이 커서 신중하게 사용해야 해요

사용 예시

동시 접근 제한

let semaphore = DispatchSemaphore(value: 3)
let queue = DispatchQueue.global()

for i in 0..<10 {
    queue.async {
        semaphore.wait()   // 3개 이상이면 여기서 대기
        print("작업 \(i) 시작")
        // 작업 수행
        semaphore.signal() // 작업 종료
    }
}

비동기를 동기처럼 기다리기 (지양 권장)

let semaphore = DispatchSemaphore(value: 0)
var result: Data?

someAsyncAPICall { data in
    result = data
    semaphore.signal()
}

semaphore.wait() // 응답 올 때까지 현재 스레드 블로킹
// 여기서 result 사용

관련 이미지 2

이미지 출처: haon.blog


세 가지를 함께 생각하기

Dispatch Queue는 "어느 스레드에서 어떤 방식으로 작업을 실행할 것인가?"를 결정해요

Dispatch Group은 "여러 비동기 작업을 묶어서 언제 모두 끝나는지 알고 싶을 때" 사용해요

Dispatch Semaphore는 "동시 실행 개수를 제한하거나, 동기화·순서 보장이 필요할 때" 사용해요

실무에서는 백그라운드 작업을 Global Queue나 Private Queue에 async로 던지고
여러 네트워크나 I/O 작업을 병렬로 보내고 마지막 한 번에 UI 갱신할 때 Dispatch Group을 사용하고
리소스 제한(DB 접근, 파일 쓰기, 특정 API의 rate limit 등) 시 Dispatch Semaphore로 동시 실행 수를 제한하는 식으로 조합해서 사용할 수 있어요

실전 예제

여러 이미지를 다운로드하고, 모두 완료되면 화면을 업데이트하는 예제를 만들어 볼게요

let group = DispatchGroup()
let semaphore = DispatchSemaphore(value: 3) // 동시에 3개까지만
let queue = DispatchQueue.global()
var images: [UIImage] = []

let urls = [url1, url2, url3, url4, url5]

for url in urls {
    group.enter()
    queue.async {
        semaphore.wait()
        
        // 이미지 다운로드
        let image = downloadImage(from: url)
        images.append(image)
        
        semaphore.signal()
        group.leave()
    }
}

group.notify(queue: .main) {
    // 모든 이미지 다운로드 완료
    updateUI(with: images)
}

이렇게 하면 동시에 3개씩만 다운로드하면서, 모든 작업이 끝나면 메인 스레드에서 UI를 업데이트할 수 있어요


주의사항

메인 스레드에서 sync 호출은 절대 금지예요
특히 DispatchQueue.main.sync는 데드락을 일으켜요

Semaphore의 wait()는 현재 스레드를 블로킹하므로
메인 스레드에서 사용하면 UI가 멈출 수 있어요

Group의 enter/leave는 반드시 짝을 맞춰야 해요
leave를 빠뜨리면 notify가 영원히 호출되지 않아요

동시성 코드는 테스트하기 어려우니
충분한 테스트와 함께 사용해야 해요

각 도구의 특성을 잘 이해하고, 상황에 맞게 조합해서 사용하면
안정적인 동시성 처리를 구현할 수 있을 거예요


이전 글 보기

AVPlayer 분석하기 - iOS 미디어 재생의 핵심을 파헤쳐보자

CGAffineTransform 분석하기 - 2D 변환의 모든 것

이 블로그의 다음 이야기도 받아보세요

새 글 구독 (RSS)