Swift GCD와 비동기 프로그래밍

Ios_Roy·2025년 9월 9일
0

TIL

목록 보기
24/25
post-thumbnail

1. GCD (Grand Central Dispatch) 개념

목적과 역할

GCD는 작업(클로저)을 큐(Queue)에 제출하면, 스레드 풀이 자동으로 효율적인 스레드 배정과 실행을 담당하는 런타임 시스템입니다.

큐 타입별 특성

큐 타입특성용도
Main Queue직렬 큐, UI 전용UI 업데이트, 메인 스레드 작업
Serial Queue순차 실행 (FIFO)순서가 중요한 작업, 데이터 일관성
Concurrent Queue병렬 실행 가능독립적인 작업들의 병렬 처리

디스패치 방식

  • sync: 현재 스레드가 작업 완료까지 대기
  • async: 현재 스레드는 비대기, 즉시 다음 코드 실행
// 비동기 실행 (권장)
DispatchQueue.global().async {
    // 백그라운드 작업
    print("백그라운드 실행")
}

// 동기 실행 (주의 필요)
DispatchQueue.global().sync {
    // 현재 스레드 블로킹
    print("동기 실행 완료까지 대기")
}

한 줄 요약

"작업을 큐에 던지면, 시스템이 스레드를 적절히 배정해 실행해준다."

2. Main Queue Sync 데드락 문제

데드락 발생 메커니즘

// ❌ 데드락 발생!
DispatchQueue.main.async {
    // 메인 스레드에서 실행 중...
    DispatchQueue.main.sync {
        // 메인 큐에 sync로 작업 제출
        print("이 코드는 실행되지 않음!")
    }
}

왜 데드락이 발생하는가?

  1. 현재 상황: 메인 스레드에서 main.sync 호출
  2. sync 동작: 블록이 끝날 때까지 현재 스레드를 대기시킨다
  3. 모순: 그 블록은 메인 큐(현재 점유 중인 스레드)에서 실행되어야 한다
  4. 결과: 자기 자신이 끝나길 기다리는 상호 대기 = 데드락

올바른 해결 방법

// ✅ 올바른 방법
DispatchQueue.main.async {
    // UI 업데이트나 메인 스레드 작업
    updateUI()
}

// ✅ 백그라운드에서 메인으로 전환
DispatchQueue.global().async {
    // 백그라운드 작업
    let result = heavyComputation()
    
    DispatchQueue.main.async {
        // UI 업데이트
        self.updateUI(with: result)
    }
}

안전 규칙

메인 스레드에서 메인 큐로는 반드시 async만 사용

3. 병렬성 vs 동시성

개념 구분

구분동시성 (Concurrency)병렬성 (Parallelism)
정의여러 작업을 논리적으로 동시에 처리하는 구조물리적으로 실제 동시 실행
구현스케줄링, 인터리빙을 통한 겉보기 동시성멀티코어를 활용한 진짜 동시 실행
의존성단일 코어에서도 가능멀티코어 하드웨어 필요

실생활 비유

  • 동시성: 한 요리사가 여러 요리를 번갈아가며 처리 (스마트한 일 분배)
  • 병렬성: 여러 요리사가 각각 다른 요리를 동시에 처리 (실제 인력 증가)

Swift에서의 활용

// 동시성: 구조적 동시 처리
DispatchQueue.global().async {
    // 작업1 시작
}
DispatchQueue.global().async {
    // 작업2 시작 (논리적 동시성)
}

// 병렬성: 실제 물리적 동시 실행 (시스템이 자동 결정)
// 사용 가능한 CPU 코어에 따라 실제 병렬 실행 여부 결정

4. 동기 vs 비동기

기본 개념

방식특성장점단점
동기결과가 올 때까지 대기코드 흐름 단순, 에러 처리 직관적UI 블로킹, 자원 비효율
비동기즉시 반환, 결과는 나중에응답성 좋음, 자원 효율적콜백 헬, 에러 처리 복잡

코드 예시

// 동기 방식
func fetchDataSync() -> Data {
    let data = networkRequest() // 완료까지 대기
    return data
}

// 비동기 방식 (콜백)
func fetchDataAsync(completion: @escaping (Data?) -> Void) {
    networkRequest { data in
        completion(data) // 나중에 결과 전달
    }
}

// 비동기 방식 (async/await)
func fetchDataAsync() async -> Data {
    let data = await networkRequest()
    return data
}

핵심 포인트

  1. 응답성: UI 블로킹 없이 사용자 경험 향상
  2. 자원 활용: 스레드를 효율적으로 활용
  3. 에러/취소 전파: 비동기 체인에서의 에러 처리 전략

5. 콜백을 async/await로 변환

변환이 필요한 이유

  • 기존 콜백 기반 API (Objective-C, 서드파티 SDK)를 modern Swift의 async/await 패턴으로 통합
  • 콜백 헬 해결 및 가독성 향상

주요 API들

withCheckedContinuation (WCC)

func legacyAPIWrapper() async -> String {
    return await withCheckedContinuation { continuation in
        legacyAPI { result in
            continuation.resume(returning: result)
        }
    }
}

withCheckedThrowingContinuation (WCTC)

func legacyAPIWrapperWithError() async throws -> Data {
    return try await withCheckedThrowingContinuation { continuation in
        legacyNetworkAPI { data, error in
            if let error = error {
                continuation.resume(throwing: error)
            } else if let data = data {
                continuation.resume(returning: data)
            } else {
                continuation.resume(throwing: APIError.unknown)
            }
        }
    }
}

사용 기준

API사용 시점특징
WCC실패 개념이 없는 콜백항상 성공, throw 없음
WCTC에러를 throw로 전파하고 싶은 경우실패 시 예외 던짐 (가장 일반적)

실제 사용 예시

// 기존 콜백 기반 API
func requestLocationPermission(completion: @escaping (Bool) -> Void) {
    // 권한 요청 로직
}

// async/await 래퍼
func requestLocationPermission() async -> Bool {
    return await withCheckedContinuation { continuation in
        requestLocationPermission { granted in
            continuation.resume(returning: granted)
        }
    }
}

// 사용
let isGranted = await requestLocationPermission()

주의사항

  • 반드시 한 번만 resume 호출: 중복 호출 시 크래시
  • 모든 경로에서 resume 보장: 콜백이 호출되지 않으면 영원히 대기
  • 메모리 누수 주의: 강한 참조 순환 방지

6. Actor 키워드와 동시성 격리

Actor란 무엇인가?

Actor데이터 경쟁(Data Race)을 방지하기 위해 해당 인스턴스의 상태 접근을 직렬화하는 동시성 격리 타입입니다.

actor BankAccount {
    private var balance: Double = 0
    
    func deposit(amount: Double) {
        balance += amount // 안전한 상태 변경
    }
    
    func withdraw(amount: Double) -> Bool {
        guard balance >= amount else { return false }
        balance -= amount
        return true
    }
    
    func getBalance() -> Double {
        return balance
    }
}

Actor의 핵심 특징

특징설명의미
격리된 상태mutable state는 actor 내부에서만 안전동시 접근 차단
비동기 접근외부에서 접근 시 await 필요컨텍스트 전환(hop)
순차 실행한 번에 하나의 작업만 실행데이터 레이스 방지

사용 예시

let account = BankAccount()

// 외부에서 접근 시 await 필요
Task {
    await account.deposit(amount: 100)
    let balance = await account.getBalance()
    print("현재 잔액: \(balance)")
}

Actor 주의사항

1. 재진입성 (Re-entrancy)

actor Counter {
    private var value = 0
    
    func increment() async {
        let current = value
        await someAsyncWork() // 일시 중단 지점
        value = current + 1 // ⚠️ 다른 태스크가 value를 변경했을 수도 있음
    }
}

2. 교착 상황 방지

// ❌ 교착 위험
actor A {
    func callB(_ b: B) async {
        await b.callA(self) // A → B → A 순환 대기
    }
}

actor B {
    func callA(_ a: A) async {
        await a.callB(self)
    }
}

7. MainActor - UI 전용 글로벌 액터

정의와 목적

MainActor메인 스레드 전용 실행자에 격리된 글로벌 액터로, UI 업데이트와 관련된 코드가 항상 메인 스레드에서만 실행되도록 보장합니다.

동작 원리

@MainActor
class ViewModel: ObservableObject {
    @Published var data: String = ""
    
    // 메인 액터에 격리된 메서드
    func updateUI(with newData: String) {
        data = newData // 메인 스레드에서 안전하게 실행
    }
    
    // 백그라운드 작업
    func fetchData() async {
        // 백그라운드에서 실행
        let result = await heavyComputation()
        
        // 메인 액터로 자동 전환
        updateUI(with: result)
    }
}

MainActor 사용 패턴

1. 클래스 전체 격리

@MainActor
class UIController {
    var title: String = ""
    
    func updateTitle() {
        // 모든 멤버가 메인 액터에 격리됨
        title = "새로운 제목"
    }
}

2. 개별 메서드 격리

class DataManager {
    @MainActor
    func updateUI() {
        // 이 메서드만 메인 액터에서 실행
    }
    
    func processData() {
        // 백그라운드에서 실행 가능
    }
}

3. 컨텍스트 전환

// 백그라운드에서 메인으로 전환
Task {
    let data = await fetchFromAPI()
    
    await MainActor.run {
        // UI 업데이트를 배치로 처리
        updateLabel(with: data.title)
        updateImage(with: data.image)
    }
}

8. Task vs Task.detached

비교표

특성TaskTask.detached
컨텍스트 상속부모로부터 상속독립 실행
취소 전파부모 취소 시 자식도 취소독립적 생명주기
액터 격리부모 액터 상속격리 없음
우선순위부모 우선순위 상속별도 지정
TaskLocal상속독립

사용 예시

Task - 컨텍스트 상속

@MainActor
func viewDidLoad() {
    Task { // MainActor 컨텍스트 상속
        let data = await fetchData()
        updateUI(with: data) // await 없이 호출 가능
    }
}

Task.detached - 독립 실행

@MainActor
func viewDidLoad() {
    Task.detached { // 독립 실행
        let data = await fetchData()
        
        await MainActor.run { // 명시적 전환 필요
            updateUI(with: data)
        }
    }
}

언제 무엇을 사용하나?

상황권장 선택이유
일반적인 비동기 작업Task컨텍스트 상속으로 안전하고 편리
백그라운드 파이프라인Task.detachedUI와 완전 분리된 독립 작업
로깅, 분석Task.detached메인 작업에 영향 없는 독립 처리
순수 계산 작업Task.detached액터 격리가 불필요한 경우

9. Sendable 프로토콜

정의와 목적

Sendable은 스레드/액터 경계를 넘어가도 데이터 레이스 없이 안전하게 전달될 수 있음을 컴파일 타임에 보증하는 마커 프로토콜입니다.

기본 규칙

1. 값 타입 (Struct/Enum)

// 자동으로 Sendable
struct UserProfile: Sendable {
    let name: String
    let age: Int
}

// 저장 프로퍼티가 Sendable이면 자동 합성
struct Response: Sendable {
    let data: Data
    let timestamp: Date
}

2. 클래스

// ❌ 기본적으로 Sendable 아님
class MutableCounter {
    var count = 0
}

// ✅ 불변 클래스는 Sendable 가능
final class ImmutableData: @unchecked Sendable {
    let value: String
    let timestamp: Date
    
    init(value: String) {
        self.value = value
        self.timestamp = Date()
    }
}

3. 클로저

// Sendable 클로저 - 캡처된 값도 Sendable이어야 함
func processAsync(_ handler: @Sendable @escaping () -> Void) {
    Task {
        await someWork()
        handler()
    }
}

// 사용
let message = "완료" // String은 Sendable
processAsync {
    print(message) // ✅ 안전한 캡처
}

Sendable 위반 예시와 해결

문제 상황

class Counter {
    var value = 0
}

let counter = Counter()

// ❌ 컴파일 에러: Counter는 Sendable이 아님
Task {
    counter.value += 1 // 데이터 레이스 위험
}

해결 방법

// 1. Actor로 감싸기
actor SafeCounter {
    private var value = 0
    
    func increment() {
        value += 1
    }
    
    func getValue() -> Int {
        return value
    }
}

// 2. 값 복사로 전달
let currentValue = counter.value
Task {
    let newValue = currentValue + 1
    // 안전한 처리
}

10. 컨텍스트 스위칭 (Context Switching)

정의

CPU가 실행 중인 스레드/태스크를 바꾸며 레지스터/스택 저장·복원하는 과정입니다.

Swift 동시성에서의 두 레벨

1. 스레드 컨텍스트 스위치 (OS 레벨)

  • 커널 스케줄러가 스레드를 변경
  • 높은 비용: 캐시 미스, 레지스터 저장/복원

2. 태스크/실행자 Hop (언어 레벨)

  • await 지점에서 태스크 일시 중단
  • 다른 실행자(예: MainActor)로 이동
  • 상대적으로 낮은 비용, 하지만 빈번하면 체감

성능 최적화 전략

1. Hop 최소화

// ❌ 빈번한 hop
@MainActor
func updateUI() async {
    title = await computeTitle()    // hop 1
    subtitle = await computeSubtitle() // hop 2
    image = await loadImage()       // hop 3
}

// ✅ 배치 처리
func updateUI() async {
    let (title, subtitle, image) = await (
        computeTitle(),
        computeSubtitle(), 
        loadImage()
    )
    
    await MainActor.run {
        self.title = title
        self.subtitle = subtitle
        self.image = image
    }
}

2. 적절한 작업 크기

// ❌ 과도한 분할
for item in items {
    Task {
        await process(item) // 수천 개의 작은 태스크
    }
}

// ✅ 배치 처리
let batches = items.chunked(into: 100)
for batch in batches {
    Task {
        await processBatch(batch)
    }
}

3. 순수 계산 분리

@MainActor
class ViewModel {
    var data: [Item] = []
    
    // ✅ 계산은 격리 해제
    nonisolated func processData(_ input: [RawData]) -> [Item] {
        return input.map { Item(from: $0) }
    }
    
    func loadData() async {
        let rawData = await fetchFromAPI()
        let processed = processData(rawData) // hop 없음
        
        data = processed // 메인 액터에서 상태 업데이트
    }
}

11. 스레드 폭발 (Thread Explosion)

정의와 위험성

업무량 대비 과도한 스레드가 생성되어 오버서브스크립션 상태가 되는 현상으로, 성능 저하나 시스템 불안정을 야기합니다.

주요 원인

1. 블로킹 작업의 대량 투입

// ❌ 스레드 폭발 위험
for i in 0..<1000 {
    DispatchQueue.global().async {
        Thread.sleep(forTimeInterval: 1.0) // 블로킹
        print("작업 \(i) 완료")
    }
}

2. 무제한 동시성 확장

// ❌ 제한 없는 Task 생성
for url in urls { // urls.count = 10000
    Task {
        await downloadImage(from: url)
    }
}

대응 전략

1. 블로킹 API를 비블로킹으로 대체

// ❌ 동기적 네트워크 요청
let data = try Data(contentsOf: url) // 블로킹

// ✅ 비동기 네트워크 요청
let (data, _) = try await URLSession.shared.data(from: url)

2. 동시성 제한

// ✅ 동시 실행 수 제한
func downloadImages(urls: [URL]) async {
    await withTaskGroup(of: Void.self) { group in
        let semaphore = AsyncSemaphore(value: 5) // 최대 5개 동시 실행
        
        for url in urls {
            group.addTask {
                await semaphore.wait()
                defer { semaphore.signal() }
                await downloadImage(from: url)
            }
        }
    }
}

3. 배치 처리

// ✅ 작업을 배치로 묶어서 처리
func processLargeDataset(_ items: [DataItem]) async {
    let batchSize = 100
    let batches = items.chunked(into: batchSize)
    
    for batch in batches {
        await processBatch(batch)
    }
}

4. 우선순위와 QoS 관리

// ✅ 적절한 우선순위 설정
Task(priority: .background) { // UI에 영향 없는 백그라운드 작업
    await processAnalytics()
}

Task(priority: .userInitiated) { // 사용자 요청 작업
    await loadUserProfile()
}

12. Actor 설계 모범 사례

올바른 Actor 설계

1. 기능별 Actor 분리

// ✅ 역할별로 분리된 액터
actor NetworkCache {
    private var cache: [String: Data] = [:]
    
    func store(data: Data, for key: String) {
        cache[key] = data
    }
    
    func retrieve(for key: String) -> Data? {
        return cache[key]
    }
}

actor UserSessionManager {
    private var currentUser: User?
    private var sessionToken: String?
    
    func login(user: User, token: String) {
        currentUser = user
        sessionToken = token
    }
    
    func logout() {
        currentUser = nil
        sessionToken = nil
    }
}

2. 상태 전환의 안전한 관리

actor FileProcessor {
    private var state: ProcessingState = .idle
    private var currentFile: File?
    
    func startProcessing(_ file: File) async throws {
        guard state == .idle else {
            throw ProcessorError.alreadyProcessing
        }
        
        state = .processing
        currentFile = file
        
        do {
            await processFile(file)
            state = .completed
        } catch {
            state = .failed
            throw error
        }
    }
    
    private func processFile(_ file: File) async {
        // await 지점에서도 상태가 일관되도록 설계
    }
}

3. 교차 호출 방지

// ✅ 데이터를 복사해서 전달
actor OrderProcessor {
    func processOrder(_ order: Order) async {
        let orderData = OrderData(from: order) // 복사본 생성
        await paymentService.processPayment(orderData) // 안전한 전달
    }
}

핵심 요약

기본 개념

  1. GCD: 큐 기반 작업 스케줄링 시스템
  2. 데드락 방지: 메인→메인은 항상 async
  3. 동시성 vs 병렬성: 구조적 설계 vs 물리적 실행
  4. 비동기의 중요성: 응답성과 자원 효율성

Modern Swift 동시성

  1. Actor: 데이터 레이스 방지를 위한 격리 타입
  2. MainActor: UI 작업을 메인 스레드로 강제 격리
  3. Task vs Task.detached: 컨텍스트 상속 vs 독립 실행
  4. Sendable: 스레드 간 안전한 데이터 전달 보장

성능 최적화

  1. 컨텍스트 스위칭: Hop 최소화와 배치 처리
  2. 스레드 폭발: 동시성 제한과 비블로킹 API 활용
  3. 콜백 마이그레이션: withCheckedContinuation으로 현대화

설계 원칙

  • 안전성 우선: 컴파일 타임 보장 활용
  • 적절한 격리: 기능별 액터 분리
  • 성능 고려: 불필요한 hop과 스레드 생성 방지
  • 에러 처리: 취소와 예외 상황 대비

이러한 개념들은 iOS 개발에서 안전하고 효율적인 동시성 프로그래밍의 핵심입니다.

profile
iOS 개발자 공부하는 Roy

0개의 댓글