Develup

[Concurrency] Swift async/await 완벽 가이드: 스레드 점유 이해하기 본문

Swift/Concurrency

[Concurrency] Swift async/await 완벽 가이드: 스레드 점유 이해하기

Develup 2025. 3. 6. 14:15
반응형

Swift의 async/await 시스템은 비동기 프로그래밍의 복잡성을 크게 줄여주는 혁신적인 기능입니다. 특히 스레드 관리 측면에서 기존 방식과 확연히 다른 접근법을 제공합니다. 이 글에서는 async/await와 스레드 점유권의 관계를 심층적으로 알아보겠습니다.

기존 비동기 프로그래밍에서는 콜백이나 completionHandler를 사용해 스레드 관리를 직접 처리했습니다. 이는 코드가 복잡해지고 디버깅이 어려워지는 원인이 되었죠. Swift의 async/await는 이런 문제를 해결하면서도 효율적인 스레드 활용을 가능하게 합니다.

async/await와 스레드의 관계는 무엇인가요?

async/await의 가장 큰 특징은 스레드를 "양보(yield)"할 수 있다는 점입니다. 기존 동기 코드가 실행 중 해당 스레드를 계속 점유했던 것과 달리, async 함수는 비동기 작업을 기다리는 동안 스레드를 다른 작업에 양보할 수 있습니다.

func synchronousFunction() {
    // 이 함수가 실행되는 동안 스레드는 계속 점유됨
    let data = heavyComputation() // 이 작업 동안 스레드 차단
    print(data)
}

async func asynchronousFunction() async throws -> Data {
    // await 포인트에서 스레드를 양보할 수 있음
    let data = try await fetchDataFromNetwork() // 스레드 양보 가능
    return data
}

여기서 핵심은 await 키워드입니다. await가 표시된 지점에서 Swift 런타임은 해당 비동기 작업이 완료될 때까지 함수 실행을 일시 중단하고, 현재 스레드를 다른 작업에 양보할 수 있습니다.

await 지점에서 정확히 어떤 일이 발생하나요?

await 키워드가 있는 지점은 코드의 "suspension point"(중단점)입니다. 이 지점에서:

  1. 비동기 작업이 시작됩니다
  2. 현재 함수의 실행이 일시 중단됩니다
  3. 현재 스레드는 다른 작업을 실행하기 위해 해제됩니다
  4. 비동기 작업이 완료되면, 시스템은 적절한 스레드(원래 스레드가 아닐 수 있음)를 선택해 중단된 함수를 계속 실행합니다
async func processImage() async throws -> UIImage {
    // 첫 번째 await 지점
    let data = try await fetchImageData() // 스레드 A에서 실행 중, 이 지점에서 스레드 양보
    
    // 비동기 작업 완료 후 다시 재개 - 스레드 B에서 실행될 수 있음
    let image = try await decodeImage(data) // 두 번째 await 지점, 다시 스레드 양보
    
    // 스레드 C에서 재개될 수 있음
    return applyFilter(to: image)
}

이 예제에서 processImage() 함수는 실행 중 두 번의 await 지점을 통과합니다. 각 지점에서 함수는 다른 스레드에서 재개될 수 있습니다. 이것이 바로 Swift의 "스레드 호핑(thread hopping)"이라 불리는 현상입니다.

스레드 호핑이 왜 중요한가요?

스레드 호핑의 가장 큰 의미는 개발자가 더 이상 스레드 관리를 직접 걱정할 필요가 없다는 것입니다. Swift 런타임이 효율적으로 스레드를 관리해줍니다. 하지만 이로 인해 몇 가지 중요한 영향이 있습니다:

  1. 스레드 신원 가정 불가: await 전후로 같은 스레드에 있다고 가정해서는 안 됩니다.
async func riskyCode() async {
    let currentThread = Thread.current
    print("Before await: \(currentThread)")
    
    await someAsyncOperation()
    
    // 주의: 아래 코드는 다른 스레드에서 실행될 수 있음
    print("After await: \(Thread.current)")
    
    // 위험: 스레드 ID에 의존하는 코드
    if Thread.current == currentThread {
        // 이 조건이 항상 참이라고 가정할 수 없음
    }
}
  1. 스레드 국한 객체 사용 주의: 특정 스레드에서만 접근해야 하는 객체(예: UI 객체)를 다룰 때 주의해야 합니다.

스레드 점유와 관련해 async/await의 장점은 무엇인가요?

기존 GCD나 completion handler 기반 코드와 비교했을 때, async/await의 스레드 관리는 여러 장점을 제공합니다:

1. 스레드 자원의 효율적 사용

기존 방식에서는 비동기 작업을 기다리는 동안 스레드가 차단되어 있었지만, async/await에서는 그렇지 않습니다:

// 기존 방식 - 비효율적 스레드 사용
func fetchDataOld(completion: @escaping (Data?, Error?) -> Void) {
    // 이 스레드는 네트워크 응답을 기다리는 동안 차단됨
    URLSession.shared.dataTask(with: url) { data, response, error in
        completion(data, error)
    }.resume()
}

// async/await 방식 - 효율적 스레드 사용
func fetchDataNew() async throws -> Data {
    // 스레드는 이 작업 중에 다른 일을 할 수 있음
    let (data, _) = try await URLSession.shared.data(from: url)
    return data
}

2. 스레드 폭발 방지

복잡한 비동기 작업 체인에서 기존 방식은 스레드 수가 급증할 수 있었지만, async/await는 이러한 문제를 방지합니다:

// 기존 방식 - 중첩된 각 단계마다 새 스레드가 필요할 수 있음
func processMultiStepOld(completion: @escaping (Result<ProcessedData, Error>) -> Void) {
    fetchData { result in
        switch result {
        case .success(let data):
            self.processFirstStep(data) { result in
                switch result {
                case .success(let intermediateData):
                    self.processFinalStep(intermediateData, completion: completion)
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

// async/await 방식 - 스레드를 효율적으로 재사용
async func processMultiStepNew() async throws -> ProcessedData {
    let data = try await fetchData()
    let intermediateData = try await processFirstStep(data)
    return try await processFinalStep(intermediateData)
}

async/await에서 UI 업데이트는 어떻게 처리해야 하나요?

UI 작업은 메인 스레드에서 수행해야 합니다. async/await에서는 이를 위해 @MainActor 속성을 사용합니다:

// 클래스 전체를 메인 스레드에 바인딩
@MainActor
class MyViewController: UIViewController {
    // 이 클래스의 모든 코드는 메인 스레드에서 실행됨
    
    async func updateUI() async {
        let data = try? await fetchDataFromServer()
        // 이 UI 업데이트는 자동으로 메인 스레드에서 수행됨
        titleLabel.text = data?.title
    }
}

// 또는 특정 메서드만 메인 스레드에 바인딩
class DataService {
    async func fetchData() async throws -> Data {
        // 백그라운드 스레드에서 실행될 수 있음
        return try await networkCall()
    }
    
    @MainActor
    async func updateUIWithData() async throws {
        // 이 메서드는 항상 메인 스레드에서 실행됨
        let data = try await fetchData()
        // UI 업데이트 코드...
    }
}

@MainActor는 해당 코드가 메인 스레드에서 실행되도록 보장합니다. 이는 async/await에서 스레드 호핑이 발생하더라도 UI 관련 코드가 항상 안전하게 메인 스레드에서 실행되도록 합니다.

스레드 점유와 관련한 일반적인 실수는 무엇인가요?

async/await를 사용할 때 발생할 수 있는 스레드 관련 실수들:

1. 동기 코드에서 스레드 차단

// 주의: 이 코드는 메인 스레드를 차단할 수 있음
func badPractice() {
    // Task를 생성하지만 결과를 동기적으로 기다림
    let result = Task {
        return try await longRunningAsyncFunction()
    }.result
    
    // 위 코드는 longRunningAsyncFunction이 완료될 때까지
    // 현재 스레드를 차단함
}

// 올바른 방법
async func goodPractice() async {
    let result = try? await longRunningAsyncFunction()
    // 차단 없이 비동기적으로 처리
}

2. 스레드 신원에 의존

// 주의: 스레드 신원에 의존하는 위험한 코드
async func riskyThreadAssumption() async {
    let threadBefore = Thread.current
    
    await someAsyncOperation()
    
    // 위험: 이 비교는 실패할 가능성이 높음
    if threadBefore == Thread.current {
        // 이 코드는 신뢰할 수 없음
    }
}

3. 불필요한 Task 중첩

// 주의: 불필요한 Task 중첩
async func nestedTasksAntiPattern() async {
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            // 이미 Task 내부에 있으므로 새 Task를 만들 필요가 없음
            Task {
                await someAsyncOperation()
            }
        }
    }
}

// 올바른 방법
async func properTaskUsage() async {
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            await someAsyncOperation()
        }
    }
}

스레드 점유 최적화 팁

async/await를 사용할 때 스레드 점유를 최적화하기 위한 팁:

1. 전략적 Task 그룹 사용

async func optimizedConcurrentOperations() async throws -> [Result] {
    try await withThrowingTaskGroup(of: Result.self) { group in
        // 여러 작업을 동시에 추가
        for id in ids {
            group.addTask {
                try await fetchData(for: id)
            }
        }
        
        // 결과 수집
        var results = [Result]()
        for try await result in group {
            results.append(result)
        }
        return results
    }
}

2. 명시적 양보로 스레드 공정성 개선

오래 실행되는 작업에서는 Task.yield()를 사용해 주기적으로 스레드를 양보하는 것이 좋습니다:

async func longRunningTask() async {
    for item in largeCollection {
        // 일부 처리 수행
        process(item)
        
        // 주기적으로 다른 작업에 스레드 양보
        if item.isCheckpoint {
            await Task.yield()
        }
    }
}

3. 비동기 시퀀스 활용

대량의 데이터를 스트리밍할 때는 AsyncSequence를 사용하여 작은 청크로 처리:

async func processLargeDataStream() async throws {
    for try await chunk in dataStream {
        // 청크별로 처리
        process(chunk)
        // 각 청크 사이에 자동으로 스레드 양보 가능
    }
}

결론

Swift의 async/await 시스템은 스레드 점유와 관리를 자동화해 개발자 부담을 크게 줄였습니다. 기존의 복잡한 비동기 코드에서 벗어나 더 직관적이고 안전한 코드를 작성할 수 있게 되었습니다.

async/await를 사용하면:

  • 스레드 자원을 더 효율적으로 사용하게 됩니다
  • 스레드 호핑이 자동으로 처리되어 개발자가 저수준 스레드 관리를 걱정할 필요가 없습니다
  • @MainActor를 통해 UI 관련 코드를 안전하게 보호할 수 있습니다
  • 스레드 폭발 없이 복잡한 비동기 작업 체인을 구현할 수 있습니다

스레드 점유권을 고려한 async/await 시스템의 이해는 효율적이고 안정적인 Swift 애플리케이션 개발의 핵심입니다. 특히 고성능 앱과 대량의 비동기 작업을 처리하는 시스템에서 그 중요성이 더욱 부각됩니다.

자주 묻는 질문 (FAQ)

Q: async/await가 항상 스레드를 양보하나요?

A: 모든 await 포인트는 '잠재적' 양보 지점입니다. 실제로 양보가 발생할지는 런타임 상황에 따라 결정됩니다. 시스템이 필요하다고 판단하면 스레드를 양보하고, 그렇지 않으면 같은 스레드에서 계속 실행될 수 있습니다.

Q: 특정 스레드에서 작업을 강제로 실행하려면 어떻게 해야 하나요?

A: 메인 스레드의 경우 @MainActor를 사용하세요. 다른 특정 스레드가 필요하다면 커스텀 Actor를 정의하거나 기존의 DispatchQueue와 함께 Task를 사용할 수 있습니다.

Q: async 함수 내에서 스레드 차단이 절대 발생하지 않나요?

A: async 함수 내에서도 스레드 차단은 발생할 수 있습니다. 특히 동기 API를 호출하거나 무거운 계산을 수행할 때 발생합니다. 이러한 경우에는 Task.detached를 사용하여 별도 태스크에서 실행하는 것이 좋습니다.

Q: 비동기 코드와 동기 코드를 혼합해 사용할 수 있나요?

A: 비동기 코드는 동기 코드를 호출할 수 있지만, 동기 코드에서 직접 비동기 코드를 호출할 수는 없습니다. 동기 컨텍스트에서 비동기 코드를 호출하려면 Task { } 블록을 사용해야 합니다.

반응형