반응형

iOS 개발자가 많이 하는 실수 - DispatchQueue main/global 큐 혼동과 sync/async 잘못 사용으로 인한 데드락·성능 저하

GCD(Grand Central Dispatch)는 iOS에서 동시성과 비동기 처리를 담당하는 핵심 기술입니다.
그 중에서도 DispatchQueue.main, DispatchQueue.global() 사용과
sync, async의 차이를 제대로 이해하지 못하면 다음과 같은 문제가 발생합니다.

  • 메인 스레드 데드락(앱 멈춤)
  • UI 프리즈(스크롤/터치가 먹지 않음)
  • CPU 100% 근접, 배터리 과소모
  • 미묘한 레이스 컨디션 및 크래시

 

1. DispatchQueue 기본 개념 정리

  • DispatchQueue.main
    • 메인 스레드에서 돌아가는 직렬 큐
    • UI 업데이트, 이벤트 처리, 레이아웃, 애니메이션 등은 반드시 여기에서 처리
  • DispatchQueue.global(qos:)
    • 시스템이 제공하는 글로벌 동시(concurrent) 큐
    • QoS에 따라 우선순위가 다름 (.userInitiated, .background 등)
    • CPU 집약적 작업, 파일 I/O, 네트워크 후처리 등에 사용
  • sync vs async
    • sync: 현재 스레드를 블록하고, 큐에서 블록이 끝날 때까지 기다렸다가 다음 코드 실행
    • async: 블록을 큐에 넣고 즉시 반환, 나중에 큐에서 비동기로 실행

2. 실수 1: 메인 스레드에서 DispatchQueue.main.sync 호출 → 100% 데드락

가장 치명적인 실수:

DispatchQueue.main.sync {
    // UI 작업
}

이 코드를 이미 메인 스레드에서 호출하면:

  1. 현재 스레드는 메인 스레드
  2. DispatchQueue.main.sync는 “메인 큐에서 이 블록이 끝날 때까지 기다린다”는 뜻
  3. 하지만 메인 큐는 지금 현재 실행 중인 코드가 끝나야 다음 블록을 실행할 수 있음
  4. 서로가 서로를 기다리면서 영원히 진행되지 않는 상태 = 데드락

즉, 다음 코드는 가장 위험한 패턴입니다.

func someFunctionOnMainThread() {
    // 이미 main thread
    DispatchQueue.main.sync {
        // ❌ 절대 이렇게 쓰지 말 것
        doSomething()
    }
}

안전한 패턴

  • 메인 스레드라면 그냥 직접 호출:
doSomething()
  • 백그라운드에서 메인 스레드로 보내고 싶다면 항상 async:
DispatchQueue.main.async {
    self.updateUI()
}

3. 실수 2: 같은 큐에 대해 중첩 sync 호출 → 데드락

커스텀 직렬 큐에서도 같은 문제가 발생합니다.

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

serialQueue.async {
    // some work

    serialQueue.sync {
        // ❌ 같은 큐에 sync → 데드락
    }
}
  • 직렬 큐는 한 번에 하나의 작업만 실행
  • 내부에서 같은 큐에 sync를 걸면,
    현재 작업이 끝나야 다음 블록이 실행되는데
    sync는 “다음 블록이 끝날 때까지 기다리기” 때문에 서로 기다리며 데드락

해결: 같은 큐에서 sync 재호출하지 않기

  • 큐 내부에서는 그냥 직접 메서드 호출하거나
  • 구조를 분리하여 별도의 큐 사용
  • 정말 필요하다면 Reentrant-safe 구조 설계 (대부분은 피하는 게 좋음)

4. 실수 3: 무거운 작업을 메인 큐에서 실행 → UI 프리즈

예:

DispatchQueue.main.async {
    // ❌ JSON 파싱, 이미지 처리, 대용량 파일 I/O 등 무거운 작업
    let result = heavyCompute()
    self.label.text = result
}

문제:

  • 메인 큐는 UI와 이벤트를 모두 담당
  • 여기서 무거운 작업을 돌리면:
    • 프레임 드롭
    • 스크롤 끊김
    • 터치/제스처 딜레이
    • “앱이 멈춘 것처럼” 보이는 현상 발생

안전한 패턴: 백그라운드 + 메인 조합

DispatchQueue.global(qos: .userInitiated).async {
    let result = heavyCompute()

    DispatchQueue.main.async {
        self.label.text = result
    }
}
  • 무거운 작업: 글로벌 큐
  • UI 업데이트: 메인 큐

5. 실수 4: UI 작업을 글로벌 큐에서 실행 → 예측 불가 동작

반대로 다음도 문제입니다.

DispatchQueue.global().async {
    self.tableView.reloadData()   // ❌ 백그라운드에서 UI 접근
}

UIKit는 스레드 세이프하지 않기 때문에:

  • 크래시
  • Auto Layout 경고
  • 간헐적인 UI 깨짐

이 발생할 수 있습니다.

반드시 메인큐 사용

DispatchQueue.main.async {
    self.tableView.reloadData()
}

6. 실수 5: 불필요한 sync 사용으로 성능 저하 및 복잡도 증가

초보자가 동기/비동기를 잘못 이해하여,

“순서를 보장해야 하니까 sync를 써야겠다”고 생각하는 경우가 많습니다.

예:

DispatchQueue.global().sync {
    heavyCompute()
}
print("done")

이 경우:

  • global 큐에서 작업이 끝날 때까지 현재 스레드 블록
  • 굳이 비동기로 넘기고 다시 기다릴 이유가 없음

차라리:

heavyCompute()
print("done")

또는 정말 비동기로 보내고 싶다면:

DispatchQueue.global().async {
    let result = heavyCompute()
    DispatchQueue.main.async {
        self.updateUI(result)
    }
}

sync는 현재 스레드를 블록하는 강한 도구이고,

대부분의 앱 코드에서는 필요하지 않은 경우가 많습니다.


7. 실수 6: QoS를 고려하지 않고 전부 default/global()만 사용

DispatchQueue.global() 기본값은 .default QoS입니다.

일부 작업은:

  • .userInitiated: 즉시 결과가 필요한 작업 (버튼 탭 후 바로 결과가 필요한 로직)
  • .utility: 네트워크, 다운로드, 계산 등 약간 느려도 되는 작업
  • .background: 동기화, 로그 저장 등 사용자가 체감하지 않는 작업

등으로 나누는 것이 좋습니다.

예:

DispatchQueue.global(qos: .userInitiated).async {
    // 사용자가 바로 결과를 기대하는 작업
}

DispatchQueue.global(qos: .background).async {
    // 로그 업로드, 캐시 정리 등
}

QoS를 전혀 고려하지 않으면:

  • 우선순위가 뒤섞여 시스템 스케줄링 효율 저하
  • 사용자가 중요하게 느끼는 작업이 늦어보일 수 있음

8. 실수 7: 커스텀 직렬 큐를 여러 개 만들어 오히려 동시성 제어 실패

let queue1 = DispatchQueue(label: "com.example.queue1")
let queue2 = DispatchQueue(label: "com.example.queue2")

queue1.async { shared.append(1) }
queue2.async { shared.append(2) } // ❌ shared 리소스에 대한 동시 접근

이 경우:

  • 서로 다른 직렬 큐들은 서로 독립적으로 동시 실행 가능
  • shared 리소스에 대한 동시 접근/레이스 컨디션 발생 가능

해결

  • 특정 공유 리소스를 보호할 직렬 큐를 하나만 만들어 그 큐에서만 접근
let sharedQueue = DispatchQueue(label: "com.example.shared")

sharedQueue.async { shared.append(1) }
sharedQueue.async { shared.append(2) }

또는:

  • NSLock, actor(Swift Concurrency), DispatchBarrier 등 사용

9. 실무용 패턴 정리


9-1. “무거운 작업 + UI 업데이트” 기본 패턴

DispatchQueue.global(qos: .userInitiated).async {
    let result = heavyCompute()

    DispatchQueue.main.async {
        self.updateUI(with: result)
    }
}

9-2. 메인 스레드에서 안전하게 호출하기

func onMain(_ work: @escaping () -> Void) {
    if Thread.isMainThread {
        work()
    } else {
        DispatchQueue.main.async {
            work()
        }
    }
}

이렇게 유틸 함수를 만들어두면

“이미 메인인지, 아닐지” 고민 없이 안전하게 UI 업데이트를 할 수 있습니다.


10. 실무용 체크리스트

DispatchQueue 관련 코드를 작성할 때 다음을 점검합니다.

  1. 메인 스레드에서 DispatchQueue.main.sync를 호출하고 있지 않은가?
  2. 같은 직렬 큐 안에서 다시 sync를 호출하고 있지 않은가?
  3. 무거운 작업을 메인 큐에서 돌리고 있지 않은가?
  4. UI 작업을 백그라운드 큐에서 수행하고 있지 않은가?
  5. 공유 리소스 접근에 여러 직렬 큐를 사용하고 있지 않은가?
  6. QoS를 전혀 고려하지 않고 전부 DispatchQueue.global()만 사용하지는 않는가?

11. 요약

  • DispatchQueue.main.sync는 메인 스레드에서 절대 호출하면 안 된다.
  • 무거운 작업은 글로벌 큐, UI 업데이트는 메인 큐라는 원칙을 지켜야 한다.
  • sync는 현재 스레드를 막기 때문에, 필요 이상으로 사용하면 성능 저하와 데드락 위험이 커진다.
  • 적절한 QoS 설정과 직렬 큐 설계는 성능과 안정성에 큰 차이를 만든다.

핵심 문장:

데이터는 백그라운드, UI는 메인. sync는 정말 필요한 경우에만.

반응형
Posted by 까칠코더
,