← 메인으로

0819-1 구조화된 동시성(2)

구조화된 동시성은 쉽게 동시 실행을 구현할 수 있게 도와준다

 func doSomething() { print("Start \(Date())") takesTooLong() print("End \(Date())") } func takesTooLong() { sleep(5) print("Async task completed at \(Date())") } 

async

구조화된 동시성의 기초는 async/await 쌍이다. async 키워드는 함수가 호출된 스레드에 대해 비동기적(한번에)으로 실행됨을 나타내기 위해 함수를 선언할 때 사용한다. 따라서 두 예제 함수 모두를 다음과 같이 선언해야 한다.

func domSomething() async { print("Start \(Date())") takesTooLong() print("End \(Date())") } func takesToLong() async { sleep(5) print("Async task completed at \(Date())") } 

비동기 함수에 대해 주의해야 할 또 다른 점은 일반적으로 다른 비동기 함수의 범위 내에서만 호출할 수 있다는 것이다. 후에 다루겠지만 Task 객체는 동기 코드와 비동기 코드 사이의 다리를 제공하는 데 사용될 수 있다. 마지막으로 비동기 함수가 다른 비동기 함수를 호출하는 경우, 모든 하위 작업도 완료될 때까지 상위 함수를 종료할 수 없다.

가장 중요한 건 함수가 비동기식으로 선언되면 await 키워드를 통해서만 호출할 수 있다는 것이다. await 키워드를 보기 전에 동기 코드에서 비동기 함수를 호출하는 방법을 이해해야 한다.

[!note] 와...적어놓고도 무슨 말인지 도통 모르겠는 기분 또 오랜만에 느낀다. 여기서 또 포기하고 침대에 누울 것인가? 그렇게 영영 제대로 된 개발자가 되는 걸 포기할 건가? 심호흡 한 번 하고, 이해를 시도해보자.


GPT와 함께 이해하기

첫번째 예제에서 takesTooLong()은 5초 동안 스레드를 완전히 점유한다. 즉, doSomething() 안에서 takesTooLong()이 끝나기 전까지는 그 다음 줄이 절대 실행될 수 없는 것이다. -> 앱이 멈춘 것 같다.

async가 그래서 뭔데?

async는 "이 함수는 중간에 잠깐 멈췄다가 나중에 다시 이어질 수 있다"라고 컴파일러에게 알려주는 표시다. 즉, 이 함수는 실행 도중 시스템에게 제어권을 잠깐 돌려줄 수 있다는 뜻이다.

await는 뭐야?

await는 "이 비동기 작업이 끝날 때까지 잠시 기다렸다가, 결과가 준비되면 다시 이어서 실행해라"라는 표시다. 즉, 일시정지 -> 다른 일 처리 -> 결과 오면 이어서 실행 패턴

예제 고쳐보기

func doSomething() async { print("Start \(Date())") await takesTooLong() // ⬅️ 여기서만 잠시 멈췄다가 print("End \(Date())") // ⬅️ 다시 이어서 실행됨 } func takesTooLong() async { try? await Task.sleep(nanoseconds: 5_000_000_000) // sleep 대신 Task.sleep 사용 print("Async task completed at \(Date())") } 

동기 - 비동기 연결(중요 포인트)

문제는 async 함수는 그냥 동기 코드에서 바로 못 부른다는 것이다. 왜? async 함수는 "일시정지" 가능성을 갖고 있으니까, 일반 함수랑 다르게 실행 흐름을 보장할 수 없기 때문. 그래서 동기 코드에서 쓰려면 Task 블록을 써서 감싸야 한다.

Task { await doSomething() } 

핵심 요약

내가 느낀 "뭔소린가?"의 포인트는 단어가 어려워서 생기는 걸까? 조금만 더 기본으로 가보자.

동기(Synchronous)

한 줄씩 차례대로 해야 하는 방식. 순서대로 가기에 중간에 뭔가 하는 동안에는 다른 일을 X

비동기(Asynchronous)

중간에 기다리는 동안 다른 일도 할 수 있는 방식

print("물 끓이기 시작") Task { try? await Task.sleep(nanoseconds: 3_000_000_000) // 3초 기다림 print("면 넣기") } print("기다리는 동안 유튜브 보기") 

왜 중요한가?

앱 개발에서는 사용자가 멈춘 것처럼 느끼는 걸 막기 위해 비동기를 쓴다. 예를 들어: 서버에서 데이터를 받아올 때(몇 초 걸림), 이미지 다운로드 할 때, 긴 계산 돌릴 때 -> 이걸 동기로 하면 앱이 멈춰서 "응답 없음"처럼 보인다. 비동기로 하면 앱은 계속 반응하고, 끝나면 결과만 받아오면 된다.

[!note] 와!!! 미쳤다 드디어 뭔가 이해가 되는 기분이 든다. 이제 이어서 다시 해보자.

동기 함수에서 비동기 함수 호출

구조화된 동시성의 규칙은 비동기 함수가 비동기 콘텍스트 내에서만 호출될 수 있음을 나타낸다. 프로그램의 진입점이 동기 함수라면, 비동기 함수가 어떻게 호출될 수 있는지 의문이 생긴다. 그에 대한 대답은 동기 함수 내에서 Task 객체를 사용하여 비동기 함수를 시작하는 것이다. 다음과 같이 하나의 비동기 함수를 호출하려고 하는 main()이라는 동기 함수가 있다고 가정해보자.

func main() { doSomething() // 위에서 async를 붙인 함수임. } 
'async' call in a function that does not support concurrency 
func main() { Task { // 이 Task 안에서는 doSomething()이 끝날 때까지 기다려줌 // 하지만 main() 함수 자체는 멈추지 않고, Task를 실행만 하고 바로 끝남 await doSomething() } } 

흐름의 이해를 더 쉽게 하기 위해 예시를 만들었다.

func main() { print("A: main 시작") Task { print("B: Task 시작") await doSomething() // 이 시점에서 잠시 멈출 수 있음 print("C: Task 끝") } print("D: main 끝") } 

await 키워드

앞에서 공부했듯 await 키워드는 비동기 함수를 호출할 때 필요하며 일반적으로 다른 비동기 함수 범위 내에서만 사용할 수 있다. await 키워드 없이 비동기 함수를 호출하려고 하면 다음과 같은 오류가 나온다.

Expression is 'async' but is not marked with 'await' 
func doSomething() async { print("Start \(Date()))") await takesTooLong() print("End \(Date())") } 

동기 콘텍스트에서 비동기 함수를 호출하려고 하기 때문에 한 번 더 변경해야 한다. 이 문제를 해결하기 위해 Task 객체를 사용하여 doSomething() 함수를 시작해야 한다.

var body: some View { Button(action: { Task { await doSomething() } }) { Text("Do Something") } } 

Task의 역할

func main() { //여기서는 await 못 씀 Task { await doSomething() // 비동기 함수 호출 OK } } 

여기서 await 키워드가 약간 혼란스러울 수 있다. doSomething()함수는 계속 진행하지 못하고 takesTooLong() 함수가 반환될 때까지 기다려야 하기 때문에, 작업을 호출한 스레드를 여전히 차단하고 있다는 인상을 준다. 사실 작업은 다른 스레드에서 수행되었지만 await 키워드는 작업이 완료될 때까지 기다리라고 시스템에게 요청하는 것이다.

그렇게 된 이유는 앞서 언급했듯 부모 비동기 함수는 모든 하위 함수가 완료될 때까지 완료할 수 없기 때문이다. 이 말은 takesTooLong() 함수 다음에 있는 코드 줄이 실행되기 전에 비동기 takesTooLong() 함수가 반환될 때까지 기다리는 것 외엔 선택의 여지가 없음을 의미한다.

비동기 호출을 허용하는 것 외에도 await 키워드는 doSomething() 함수 내에서 일시적인 중단점을 정의한다. 실행 중에 이 지점에 도달하면 doSomething() 함수는 일시적으로 멈출 수 있게 되며 실행 중인 스레드가 다른 용도로 사용된다는 것을 시스템에 알린다.

이를 통해 시스템은 우선순위가 더 높은 작업에 리소스를 할당할 수 있으며 나중에 doSomething() 함수에 제어를 반환하여 실행을 계속할 수 있게 한다. 일시 중단점을 표시함으로써 doSomething() 함수는 시스템이 다른 작업 처리에 리소스를 잠시 할당할 수 있도록 하여 근본적으로 앱 성능을 좋게 한다. 시스템의 속도를 감안할 때 일시 중단이 몇분의 1초 이상 지속되지 않는다면 앱의 전체 성능에 도움이 되면서도 사용자 눈에 띄지 않을 것이다.

[!note] 잘 이해했다고 생각했는데, 마지막 말을 읽으니 또 혼란에 빠진다. 이 책은 과연 잘 쓰여진 책이 맞을까..?

Pasted image 20250819174654.png 1. 왜 헷갈리냐? -> await를 보면 기다린다는 단어 때문에, "그럼 스레드 멈추는 거 아님? 동기랑 뭐가 달라?" 이런 류의 의문이 들 수 있다. 2. 진짜 동작은 이렇다. - await takesTooLong()을 만나면 doSomething() 함수는 잠깐 멈춘다. - 하지만 "스레드" 자체가 멈추는 게 아님 -> 스레드는 다른 일을 하러 간다. - takesTooLong()이 끝나면 시스템이 다시 doSomething()의 멈춘 지점으로 돌아와서 실행을 이어준다. - 즉, 함수는 멈추지만 스레드는 놀지 않고 다른 일을 한다! 3. 왜 이렇게 하는가? - 스레드가 멈춰버리면 CPU 자원이 낭비되고, 앱은 멈춘 것처럼 보임 - 근데 await는 멈춘 척하면서 스레드를 풀어줘서 다른 일을 시킬 수 있게 함 - 일이 끝나면 다시 돌아와 이어서 실행 -> 사용자 입장에서는 멀쩡히 돌아가게 느껴짐 4. 책이 말한 핵심 - await는 일시적 중단점 - 이 지점에서 시스템은 "아, 이 함수는 잠깐 멈출 수 있구나" 하며 스레드를 다른 작업에 씀 - 나중에 결과가 오면 다시 이어서 실행 - 이게 앱을 멈추지 않게 하고 성능도 좋아지게 하는 이유