Swift async/await 흔한 실수와 디버깅 팁 | 실전 체크리스트
이 글의 핵심
Swift 동시성에서 동기 컨텍스트의 await, Task 누수, MainActor, 데이터 레이스를 피하는 법. Xcode·Swift 6 기준 진단과 패턴을 정리했습니다.
들어가며
Swift의 async/await는 비동기 코드를 읽기 쉽게 만들지만, 동기 함수와의 경계·Task 생명 주기·액터 격리를 잘못 다루면 컴파일 경고가 아니라 런타임 데이터 레이스로 이어질 수 있습니다. Swift async·await 실수는 대부분 “경계 한 줄”에서 생깁니다. 특히 Swift 6의 엄격한 동시성 검사는 “예전에 돌아가던 코드”를 다시 드러냅니다. 이 글은 실무에서 반복되는 실수 패턴과 디버깅 순서를 정리했습니다. 기본 문법은 Swift 비동기 가이드를 먼저 보는 것을 권합니다.
개념 설명
async함수: 실행이 중단(suspend)될 수 있으며, 반드시 다른 async 컨텍스트나Task에서 시작되어야 합니다.- Task: 비동기 작업의 취소·우선순위를 담는 핸들입니다. 누수는 “아무도 기다리지 않는 장수명 Task”에서 자주 납니다.
- MainActor: UI 갱신은 기본적으로 메인 스레드에서 이루어집니다. 백그라운드에서 UI 상태를 직접 바꾸지 않도록 격리를 맞춥니다.
- 데이터 레이스: 같은 가변 상태를 여러 실행자가 동시에 쓰면 발생합니다. 액터·
@MainActor·Sendable로 경계를 짓습니다.
실전 구현 (단계별 코드)
실수 1: 동기 함수에서 async 함수 호출 시도
❌ 잘못된 코드:
func loadData() {
// 컴파일 에러: 'async' call in a function that does not support concurrency
let data = await fetchFromAPI()
}
✅ 해결 방법 1: 함수를 async로 변경
func loadData() async {
let data = await fetchFromAPI()
processData(data)
}
// 호출부
Task {
await loadData()
}
✅ 해결 방법 2: Task로 감싸기 (결과 필요 없을 때)
func loadData() {
Task {
let data = await fetchFromAPI()
await MainActor.run {
self.updateUI(with: data)
}
}
}
✅ 해결 방법 3: Task 값 반환 (결과 필요할 때)
func loadData() -> Task<Data, Error> {
return Task {
return try await fetchFromAPI()
}
}
// 호출부
let task = loadData()
let data = try await task.value
실수 2: Task 누수 (생성만 하고 기다리지 않음)
❌ 잘못된 코드:
class DataManager {
func startBackgroundSync() {
Task {
while true {
await syncData()
try await Task.sleep(for: .seconds(60))
}
}
// Task 참조를 저장하지 않음 → 취소 불가
}
}
✅ 해결 방법: Task 참조 저장 및 취소
class DataManager {
private var syncTask: Task<Void, Never>?
func startBackgroundSync() {
syncTask?.cancel() // 기존 작업 취소
syncTask = Task {
while !Task.isCancelled {
await syncData()
do {
try await Task.sleep(for: .seconds(60))
} catch {
break // 취소 시 종료
}
}
}
}
func stopBackgroundSync() {
syncTask?.cancel()
syncTask = nil
}
deinit {
syncTask?.cancel()
}
}
실수 3: MainActor 격리 위반
❌ 잘못된 코드:
class ViewModel: ObservableObject {
@Published var items: [Item] = []
func loadItems() async {
let data = await api.fetchItems()
// ❌ 백그라운드에서 UI 상태 변경
self.items = data // 데이터 레이스 발생 가능
}
}
✅ 해결 방법 1: 클래스를 MainActor로 격리
// 실행 예제
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
func loadItems() async {
let data = await api.fetchItems()
self.items = data // ✅ 메인 스레드에서 실행 보장
}
}
✅ 해결 방법 2: 명시적 MainActor.run
class ViewModel: ObservableObject {
@Published var items: [Item] = []
func loadItems() async {
let data = await api.fetchItems()
await MainActor.run {
self.items = data // ✅ 명시적으로 메인에서 실행
}
}
}
실수 4: Task 그룹에서 에러 처리 누락
❌ 잘못된 코드:
func loadMultipleResources() async {
await withTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
return try await fetchData(from: url) // ❌ 에러 전파 안 됨
}
}
for await data in group {
process(data)
}
}
}
✅ 해결 방법: Result 타입 사용
func loadMultipleResources() async {
await withTaskGroup(of: Result<Data, Error>.self) { group in
for url in urls {
group.addTask {
do {
let data = try await fetchData(from: url)
return .success(data)
} catch {
return .failure(error)
}
}
}
for await result in group {
switch result {
case .success(let data):
process(data)
case .failure(let error):
handleError(error)
}
}
}
}
실수 5: SwiftUI에서 onAppear로 Task 생성
❌ 잘못된 코드:
struct ContentView: View {
@State private var data: [Item] = []
var body: some View {
List(data) { item in
Text(item.title)
}
.onAppear {
Task {
data = await loadData()
}
// Task 참조 없음 → 뷰 사라져도 계속 실행
}
}
}
✅ 해결 방법: .task 모디파이어 사용
struct ContentView: View {
@State private var data: [Item] = []
var body: some View {
List(data) { item in
Text(item.title)
}
.task {
data = await loadData()
}
// 뷰 사라지면 자동 취소
}
}
✅ 해결 방법 2: Task 저장 및 취소
struct ContentView: View {
@State private var data: [Item] = []
@State private var loadTask: Task<Void, Never>?
var body: some View {
List(data) { item in
Text(item.title)
}
.onAppear {
loadTask = Task {
data = await loadData()
}
}
.onDisappear {
loadTask?.cancel()
}
}
}
실수 6: 취소 확인 누락
❌ 잘못된 코드:
func processLargeDataset() async {
for item in largeDataset {
await processItem(item) // 취소 확인 없음
}
}
✅ 해결 방법: 주기적 취소 확인
func processLargeDataset() async throws {
for item in largeDataset {
try Task.checkCancellation() // 취소되면 CancellationError 발생
await processItem(item)
}
}
// 또는
func processLargeDataset() async {
for item in largeDataset {
if Task.isCancelled {
print("작업 취소됨")
break
}
await processItem(item)
}
}
실수 7: Sendable 위반
❌ 잘못된 코드:
class Cache {
var data: [String: Any] = [:] // ❌ 가변 참조 타입
}
func processData() async {
let cache = Cache()
await withTaskGroup(of: Void.self) { group in
for i in 0..<10 {
group.addTask {
cache.data[key\(i)] = i // ❌ 데이터 레이스
}
}
}
}
✅ 해결 방법 1: Actor 사용
// 실행 예제
actor Cache {
private var data: [String: Any] = [:]
func set(_ key: String, value: Any) {
data[key] = value
}
func get(_ key: String) -> Any? {
return data[key]
}
}
func processData() async {
let cache = Cache()
await withTaskGroup(of: Void.self) { group in
for i in 0..<10 {
group.addTask {
await cache.set("key\(i)", value: i) // ✅ 안전
}
}
}
}
✅ 해결 방법 2: 값 타입 사용
struct CacheData: Sendable {
let data: [String: Int] // 불변
}
func processData() async -> [String: Int] {
await withTaskGroup(of: (String, Int).self) { group in
var result: [String: Int] = [:]
for i in 0..<10 {
group.addTask {
return ("key\(i)", i)
}
}
for await (key, value) in group {
result[key] = value
}
return result
}
}
고급 활용: MainActor와 Sendable
MainActor 격리 패턴
전체 클래스 격리:
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var isLoading = false
func loadItems() async {
isLoading = true
// 백그라운드 작업은 명시적으로 분리
let data = await Task.detached {
return await api.fetchItems()
}.value
// UI 업데이트는 자동으로 메인에서
items = data
isLoading = false
}
}
부분 격리 해제 (nonisolated):
@MainActor
class ImageProcessor {
var processedImages: [UIImage] = []
// 메인 스레드에서 실행 (기본)
func addImage(_ image: UIImage) {
processedImages.append(image)
}
// 백그라운드에서 실행 가능
nonisolated func processInBackground(data: Data) async -> UIImage? {
// ❌ self.processedImages 접근 불가 (MainActor 격리)
// ✅ 백그라운드 작업만 수행
guard let image = UIImage(data: data) else { return nil }
// 이미지 처리 (CPU 집약적)
let processed = applyFilter(to: image)
// UI 업데이트는 호출자가 MainActor에서 처리
return processed
}
}
// 사용
Task {
let processor = ImageProcessor()
// 백그라운드 처리
if let processed = await processor.processInBackground(data: imageData) {
// 메인 스레드에서 추가
await processor.addImage(processed)
}
}
Sendable 프로토콜
Sendable이 필요한 이유:
// ❌ 가변 참조 타입은 동시성 도메인 간 전달 위험
class Config {
var settings: [String: String] = [:]
}
func processConfig() async {
let config = Config()
await withTaskGroup(of: Void.self) { group in
group.addTask {
config.settings[key] = "value" // ❌ 데이터 레이스
}
group.addTask {
print(config.settings[key]) // ❌ 데이터 레이스
}
}
}
✅ 해결 방법 1: 값 타입 (Struct)
// 타입 정의
struct Config: Sendable {
let settings: [String: String] // 불변
}
func processConfig() async {
let config = Config(settings: ["key": "value"])
await withTaskGroup(of: Void.self) { group in
group.addTask {
print(config.settings[key]) // ✅ 안전 (복사본)
}
group.addTask {
print(config.settings[key]) // ✅ 안전 (복사본)
}
}
}
✅ 해결 방법 2: Actor
actor ConfigManager {
private var settings: [String: String] = [:]
func set(_ key: String, value: String) {
settings[key] = value
}
func get(_ key: String) -> String? {
return settings[key]
}
}
func processConfig() async {
let manager = ConfigManager()
await withTaskGroup(of: Void.self) { group in
group.addTask {
await manager.set("key", value: "value") // ✅ 직렬화됨
}
group.addTask {
if let value = await manager.get("key") { // ✅ 직렬화됨
print(value)
}
}
}
}
@unchecked Sendable (주의해서 사용):
class ThreadSafeCache: @unchecked Sendable {
private let lock = NSLock()
private var data: [String: Any] = [:]
func set(_ key: String, value: Any) {
lock.lock()
defer { lock.unlock() }
data[key] = value
}
func get(_ key: String) -> Any? {
lock.lock()
defer { lock.unlock() }
return data[key]
}
}
구조화된 동시성 (Structured Concurrency)
withTaskGroup 패턴:
func downloadImages(urls: [URL]) async -> [UIImage] {
await withTaskGroup(of: UIImage?.self) { group in
var images: [UIImage] = []
for url in urls {
group.addTask {
return try? await downloadImage(from: url)
}
}
for await image in group {
if let image = image {
images.append(image)
}
}
return images
}
}
withThrowingTaskGroup 패턴:
func fetchAllData() async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
var results: [Data] = []
for endpoint in endpoints {
group.addTask {
return try await fetch(from: endpoint)
}
}
for try await data in group {
results.append(data)
}
return results
}
}
성능·비교: Task 유형 선택
| API | 용도 |
|---|---|
Task { } | 기본 비동기 작업. 부모 Task의 우선순위·취소를 상속(컨텍스트에 따라 다름) |
Task.detached | 완전 분리. 꼭 필요할 때만 — 디버깅이 어려워질 수 있음 |
withTaskGroup | 병렬 청크·fan-out. 구조화된 동시성에 유리 |
과도한 detached는 스레드 폭주와 취소 누락을 부릅니다. |
실무 사례
- 네트워크 레이어:
URLSession의 async API는 취소가Task에 연결되기 쉽습니다. 요청 단위 Task를 뷰와 묶으세요. - 이미지 디코딩: CPU 작업은 백그라운드, UIImage/NSImage 적용은 메인에서.
- 로깅·분석: 전역 큐에 던지되, UI 상태 스냅샷은 MainActor에서 한 번에 복사해 보냅니다.
트러블슈팅
증상: Expression is 'async' but... 컴파일 에러
→ 호출 체인을 async로 올리거나, Task 진입점을 한 곳으로 모으세요.
증상: 간헐적 크래시 / 데이터 레이스
→ Xcode Thread Sanitizer, Swift Concurrency 진단을 켜으며, 가변 공유 상태를 액터로 옮기세요.
증상: 화면이 나갔는데도 네트워크가 돈다
→ task(id:) / Task.cancel() / 요청 취소를 연결했는지 확인하세요.
증상: MainActor에서만 써야 하는데 백그라운드에서 호출
→ await MainActor.run { } 또는 Task { @MainActor in }으로 명시적 홉을 남깁니다.
마무리
async/await는 경계 설계가 곧 품질입니다. 동기 컨텍스트에서의 await 욕구, 장수명 Task, 메인이 아닌 곳의 UI 접근을 체크리스트로 걸러면 디버깅 시간이 크게 줄어듭니다. SwiftUI와의 연동은 SwiftUI 시리즈, 에러 처리는 에러 핸들링과 함께 보면 좋습니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Swift 동시성에서 동기 컨텍스트의 await, Task 누수, MainActor, 데이터 레이스를 피하는 법. Xcode·Swift 6 기준 진단과 패턴을 정리했습니다. 실전 예제와 코드로 개념부터 활용까지 정리… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- Swift 비동기 프로그래밍 | async/await, Task
- JavaScript 비동기 디버깅 실전 사례 | Promise 체인 에러 추적하기
- C++ async & launch | std::async·future·launch 정책 완벽 정리
이 글에서 다루는 키워드 (관련 검색어)
Swift, async, await, 동시성, 디버깅, 실수, Task 등으로 검색하시면 이 글이 도움이 됩니다.