iOS 개발자가 많이 하는 실수 - DispatchQueue main/global 큐 혼동과 sync/async 잘못 사용으로 인한 데드락·성능 저하
Dev Study/iOS 2025. 12. 4. 21:06반응형
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 작업
}
이 코드를 이미 메인 스레드에서 호출하면:
- 현재 스레드는 메인 스레드
- DispatchQueue.main.sync는 “메인 큐에서 이 블록이 끝날 때까지 기다린다”는 뜻
- 하지만 메인 큐는 지금 현재 실행 중인 코드가 끝나야 다음 블록을 실행할 수 있음
- 서로가 서로를 기다리면서 영원히 진행되지 않는 상태 = 데드락
즉, 다음 코드는 가장 위험한 패턴입니다.
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 관련 코드를 작성할 때 다음을 점검합니다.
- 메인 스레드에서 DispatchQueue.main.sync를 호출하고 있지 않은가?
- 같은 직렬 큐 안에서 다시 sync를 호출하고 있지 않은가?
- 무거운 작업을 메인 큐에서 돌리고 있지 않은가?
- UI 작업을 백그라운드 큐에서 수행하고 있지 않은가?
- 공유 리소스 접근에 여러 직렬 큐를 사용하고 있지 않은가?
- QoS를 전혀 고려하지 않고 전부 DispatchQueue.global()만 사용하지는 않는가?
11. 요약
- DispatchQueue.main.sync는 메인 스레드에서 절대 호출하면 안 된다.
- 무거운 작업은 글로벌 큐, UI 업데이트는 메인 큐라는 원칙을 지켜야 한다.
- sync는 현재 스레드를 막기 때문에, 필요 이상으로 사용하면 성능 저하와 데드락 위험이 커진다.
- 적절한 QoS 설정과 직렬 큐 설계는 성능과 안정성에 큰 차이를 만든다.
핵심 문장:
데이터는 백그라운드, UI는 메인. sync는 정말 필요한 경우에만.
반응형

