티스토리 뷰

 

async & await을 알아보면서 콜백지옥을 어떻게 해결하는지 이해해 보자.

콜백 지옥을 알아보기 전 클로저를 잘 모른다면 참고해도 좋다.

 

 

[Swift] - 클로저(Closure)

iOS 면접 준비를 위한 3탄이다. Swift에서 중요한 개념인 클로저에 대해 알아보자. - 아래 링크 참고 더보기https://github.com/JeaSungLEE/iOSInterviewquestions질문 리스트1. Swift에서 클로저(Closure)란 무엇이며

steelbeartaeng2.tistory.com

 


1. 콜백 지옥

 

 

completion handler를 사용한 예시를 하나 생각해 보자.

 

이렇게 completion handler를 사용한 함수가 두 개일 때 doubleNumber를 실행하고 값이 있는 경우에만 addThree를 한다고 해보자.

func doubleNumber(_ number: Int, completion: @escaping (Int) -> Void) {
    let result = number * 2
    completion(result)
}


func addThree(_ number: Int, completion: @escaping (Int) -> Void) {
    let result = number + 3
    completion(result)
}

 

startProcessing() 코드를 보면 아직은 이중으로 중첩되어서 괜찮은 것 같다.

// 콜백 지옥
func startProcessing() {
    doubleNumber(10) { doubled in
        print("Doubled: \(doubled)")
        addThree(doubled) { added in
            print("Added 3: \(added)")
        }
    }
}

 

divideByFive()를 추가해 보자.

func divideByFive(_ number: Int, completion: @escaping (Int) -> Void) {
    let result = number / 5
    completion(result)
}

 

그럼 이렇게 삼중으로 중첩된 걸 확인할 수 있는데,,, 이때부터 뭔가 잘못됨을 감지했다.

// 콜백 지옥
func startProcessing() {
    doubleNumber(10) { doubled in
        print("Doubled: \(doubled)")
        addThree(doubled) { added in
            print("Added 3: \(added)")
            divideByFive(added) { divided in
                print("Divided by 5: \(divided)")
            }
        }
    }
}

 

addThree는 doubleNumber의 결과를 받아야 실행할 수 있고, divideByFive는 addThree와 doubleNumber의 결과를 모두 받아야 실행할 수 있다. 

 

즉, "한 단계의 결과를 받아야만 다음 단계로 넘어갈 수 있기 때문에, 클로저 안에 클로저가 계속 중첩된다"로 정리할 수 있다.

 

이게 바로 콜백 지옥이다!

startProcessing()
    
doubleNumber(10) 
     completion(doubled)
        
    print("Doubled: \(doubled)")
    
addThree(doubled)
     completion(added)
        
    print("Added 3: \(added)")
    
divideByFive(added)
     completion(divided)
        
    print("Divided by 5: \(divided)")

 

 

 

🔴 콜백 지옥이 문제가 되는 이유

 

 

1. 가독성이 나빠진다. 

 

아직은 삼중이라서 확인하려면 할 수는 있지만 만약 사중, 오중 이렇게 되면 한눈에 전체 흐름을 파악하기 어려워진다.

 

// 콜백 지옥
func startProcessing() {
    doubleNumber(10) { doubled in
        addThree(doubled) { added in
            divideByFive(added) { divided in
            }
        }
    }
}

 

2. 에러 처리가 복잡해진다.

 

각 단계마다 에러 처리를 하면 흐름 파악이 어려워 복잡해진다.

 

doubleNumber(10) { result in
    guard let doubled = result else {
        // 에러 처리
        return
    }
    addThree(doubled) { result in
        guard let added = result else {
            // 에러 처리
            return
        }
        divideByFive(added) { result in
            guard let divided = result else {
                // 에러 처리
                return
            }
            print(divided)
        }
    }
}

 

3. 수정, 확장이 어려워진다.

 

만약 addThree가 끝나고 난 후 다른 함수를 먼저 실행하고 divideByFive를 실행하고 싶다고 하면,

여러 흐름 중 어디에 속하는지 파악해야 한다.

 

그리고 여러 개가 중첩되어 있다 보니 원하는 결과를 만들기 복잡하다.

 


2. async/await 이해하기

 

 

위에서 다루었던 콜백 지옥을 async/await으로 리팩토링 해보자.

 

 1️⃣ async

 

먼저 completion handler를 지우고 함수 이름 뒤에 async 키워드를 추가해 준다.

이렇게 되면 해당 함수를 비동기로 실행하겠다는 의미이다.

func doubleNumber(_ number: Int) async -> Int {
    return number * 2
}

 

만약 에러를 반환하는 경우에는 async throws를 사용한다.

 

func doubleNumber(_ number: Int) async throws -> Int {
    return number * 2
}

 

 

2️⃣ await

 

async로 선언한 함수를 호출하기 위해서는 await이라는 키워드를 앞에 붙이고 호출한다.

func startProcessing() async {
    let doubled = await doubleNumber(10)
    print("Doubled: \(doubled)")
}

 

async throws로 선언한 함수를 호출하기 위해서는 try await이라는 키워드를 앞에 붙이고 호출한다.

func startProcessing() async {
    let doubled = try await doubleNumber(10)
    print("Doubled: \(doubled)")
}

 

async/await 키워드를 정리하면 다음과 같다.

async → 이 함수는 비동기적으로 동작할 수 있다.
await → 비동기 작업이 끝날 때까지 기다린다.

 

✏️ 최종 리팩토링

 

 

최종적으로 에러를 반환하지 않는 경우, 다음과 같다.

func doubleNumber(_ number: Int) async -> Int {
    return number * 2
}

func addThree(_ number: Int) async -> Int {
    return number + 3
}

func divideByFive(_ number: Int) async -> Int {
    return number / 5
}

func startProcessing() async {
    let doubled = await doubleNumber(10)
    print("Doubled: \(doubled)")
    
    let added = await addThree(doubled)
    print("Added 3: \(added)")
    
    let divided = await divideByFive(added)
    print("Divided by 5: \(divided)")
}

 

 

최종적으로 에러를 반환하는 경우, 다음과 같다.

divideByFive만 에러를 반환하도록 처리했다.

enum MathError: Error {
    case divideByZero
}

func divideByFive(_ number: Int) async throws -> Int {
    if number == 0 {
        throw MathError.divideByZero
    }
    return number / 5
}

func startProcessing() async {
    do {
        let doubled = await doubleNumber(10)
        let added = await addThree(doubled)
        let divided = try await divideByFive(added)
        print("Divided by 5: \(divided)")
    } catch {
        print("Error: \(error)")
    }
}

 

 


3. async/await 내부 동작 이해하기

 

 

1️⃣ completion handler 사용

 

썸네일을 가져오는 fetchThumbnail 함수가 있다고 해보자.

 

 

fetchThumbnail 함수 내부에서 위와 같은 작업들이 진행된다.

여기서 dataTask와 prepareThumbnail은 오래 걸리는 작업이기 때문에 비동기 작업으로 진행되어야 한다.

 

또한 dataTask의 return 값이 이후의 과정에서 쓰이기 때문에, 이후의 과정을 dataTask의 completion Handler 내부에 구현해야 한다.

 

코드로 만들면 이렇게 나온다.

 

여기서 completion Handler 내부에 여러 로직을 처리하고 있으니 가독성이 떨어진다.

그리고 error가 발생했을 때 error를 전달하지 않고 return하는 로직이 있다. 

 

error에 대한 모든 케이스에 return으로 error를 하는 걸 깜빡하면 그냥 return하게 되는 문제가 있다.

 

 

2️⃣ async/await 적용

 

그럼 배운 대로 async/await을 적용해 보자.

error를 반환해야 하니 함수 이름 뒤에 async가 아니라 async thorws를 붙여주고, 

await이 아니라 try await를 앞에 붙이고 함수를 호출해야 한다.

 

 

확실히 이전 코드보다 가독성이 좋고 error가 발생할 때 error값 반환 없이 return하는 경우는 없다.

 

 

3️⃣ 내부 동작 이해

 

 

1. sync function 

 

 

1. fetchThumbnail이 thumbnailURLRequest 동기 함수 호출

 

  • 호출 순간, fetchThumbnail은 자신의 실행 흐름(Thread Control)을 thumbnailURLRequest에게 넘겨줌.

2. thumbnailURLRequest 함수 실행

3. return 후 흐름 복구

 

  • thumbnailURLRequest가 끝나면서 다시 Thread Control이 fetchThumbnail에게 돌아옴.
  • 이후 fetchThumbnail은 다음 코드 (URLSession.shared.dataTask)를 이어서 실행.

 

 

2. async function

 

  • async: 해당 함수가 일시 정지(suspend)될 수 있음을 나타냄
  • await: 실제로 일시정지가 발생할 수 있는 지점임을 표시

 

함수가 await 지점에서 멈추면, 시스템은 해당 Thread를 다른 작업에 사용할 수 있다.

await으로 일시 정지되었던 함수는, 비동기 작업이 끝나면 다시 그 지점 다음 줄부터 실행이 재개된다.

 

➡️ 중요한 점은, 다시 실행되는 Thread는 원래 Thread와 다를 수 있음.

 

 

 

 

1. Caller (호출자)가 await를 통해 비동기 함수 호출

 

let (data, response) = try await URLSession.shared.data(for: request)

 

  • fetchThumbnail 함수는 여기서 suspend(일시 정지)됨.
  • 현재 사용 중이던 Thread의 제어권(Thread Control)을 호출된 함수에 넘김.

 

2. 호출된 함수가 비동기 작업 준비

 

  • 호출된 비동기 함수(data(for:))는 곧바로 비동기 작업(예: 네트워크 요청)을 시스템에게 맡기고, 자신도 suspend.
  • 이 시점에서 실제 Thread는 해제됨 → 다른 중요한 작업에 재사용 가능.

 

3. System이 해당 비동기 작업 수행

  • 백그라운드에서 네트워크 요청을 수행.
  • 해당 작업이 완료되면, System은 이전에 중단되었던 data(for:) 함수 실행을 resume.

 

4. 호출된 함수가 완료되면 다시 Caller로 돌아감

  • data(for:) 함수가 결과를 반환하고 종료되면, 시스템은 fetchThumbnail 함수의 나머지 부분을 계속 실행함.
  • 이때 새로운 Thread에서 실행이 재개될 수 있음 (이전과 동일한 Thread일 필요 없음).

 

🧐 Async 함수의 실행과 메모리 구조 비교

 

 

1️⃣ 동기 함수의 Stack 처리 구조

 

 

일반적인 함수 호출에서는 각 함수마다 Stack Frame이 Stack 메모리에 순차적으로 쌓인다.

예를 들어 func1() → func2() → func3() 순서대로 호출되면 Stack에 각각의 frame이 쌓인다.

 

Thread 별로 Stack 메모리는 독립되어 있어서 다른 Thread에서 함수 실행 시 Context Switching 발생할 수 있다.

 

 

2️⃣ 동기 함수의 Stack 처리 구조

 

 

updateDatabase에서 add 함수를 호출하면, add 함수 호출로 stack 영역에 add를 위한 stack frame이 생성된다.

 

 

 

add() 함수 내부에서 await을 만나면 함수가 일시 중단(suspended) 된다.

이때 Stack Frame은 사라지고, 상태를 Heap 메모리의 Async Frame에 저장한다. 이 Async Frame은 continuation을 위한 정보들을 포함한다.

 

 

 

이후 Stack에는 새로운 작업 save의 Stack Frame이 쌓인다.

 

suspension 이후, 다른 Thread에서 continuation(남은 로직)을 resume 할 수 있다.

이때 Async Frame이 Heap 메모리에 존재하므로 Thread 간 context switching 없이 즉시 재개가 가능하다.

 

즉, Async 함수는 Thread 간 이동이 자유롭고 효율적이다.

 

 

 

 

✏️ 정리 

1. await 키워드를 만나기 전까지는 함수가 동기 함수처럼 Stack 위에서 실행되므로 Stack Frame을 사용.
2. await 이후엔 함수가 중단(suspend)되기 때문에, 이후의 상태는 Heap 메모리의 Async Frame에 저장됨.
3. 변수는 사용 시점에 따라 저장 위치가 다름: suspension 이후만 사용하면 Stack, suspension 전후 모두 필요하면 Async Frame (Heap)

 

 

"await 전에는 일반 동기 함수처럼 Stack Frame을 사용하지만, await을 만나면 Heap으로 전환되어 Thread 독립적인 실행이 가능해지는구나"라고 이해했다.

 


참고

 

https://developer.apple.com/videos/play/wwdc2021/10132/

https://developer.apple.com/videos/play/wwdc2021/10254/

 

 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함