⚠️ 해당 내용은 Swift 공식문서인 동시성을 한글로 번역되어있는 내용을 바탕으로 작성한 글입니다. (말이 바탕이지 사실상 복사 붙혀넣기 입니다.)
정의
비동기 동작을 수행
Swift는 구조화된 방식으로 비동기와 병렬 코드 작성을 지원합니다. 비동기 코드는 잠시 멈췄다가 다시 이어갈 수 있지만 한번에 한 프로그램만 실행됩니다. 보통 비동기는 네트워크에서 데이터를 가져오는걸 기다리다 가져온 데이터로 UI를 업데이트 하는데 사용하게 됩니다.
병렬 코드는 동시에 코드의 여러 부분이 실행됨을 의미합니다. 예를 들어 4코어 프로세서의 컴퓨터는 각 코어가 하나씩 작업을 수행할 수 있기에 4개의 작업이 동시에 수행될 수 있습니다. 병렬과 비동기 코드를 사용하는 프로그램은 한 번에 여러 작업을 수행하고, 이 코드를 메모리 안전 방식으로 더 쉽게 작성할 수 있도록 합니다.
느리거나 버그가 있는 코드에 동시성을 추가한다고 해서 코드가 빠르거나 올바르게 동작한다는 보장은 없기도 합니다. 오히려 병렬 혹은 비동기 코드를 추가하면 코드의 복잡성이 증가하기도 하죠. 그러나 동시성이 꼭 필요한 코드에서 동시성에 대한 Swift에서 제공하는 것들을 사용하면 Swift가 컴파일 시간에 문제를 찾는데 도움이 될 수도 있습니다.
Swift의 동시성 관리
이전에 동시성(Concurrency) 코드를 작성한 경험이 있다면 스레드가 어떻게 동작하는지에 대해 익숙하실겁니다.
Swift 에서의 동시성 모델은 스레드 최상단에 구축되긴하지만, 직접적으로 상호작용을 하지 않습니다. 이 말인 즉슨 개발자가 직접 스레드를 관리하거나 다룰 필요가 없다는 것을 의미하게 되죠. 구체적으로 설명을 해볼까요?
1. 추상화된 동시성 모델
Swift는 동시성을 처리하기 위해서 DispatchQueue
, OperationQueue
와 같은 고수준의 추상화된 도구를 제공합니다. 이러한 것들은 내부적으로 스레드를 관리하지만, 개발자는 이런 도구를 사용함으로써 스레드를 직접 다루지 않고도 동시성을 구현할 수 있습니다.
2. 직관적인 코드 구성
Swift 5.5 부터 도입된 async/await
구문을 사용하면, 비동기 코드를 마치 동기 코드처럼 직관적으로 작성할 수 있습니다. 이 역시 내부적으로는 스레드를 활용하지만, 개발자는 직접 상호작용할 필요가 없죠.
이처럼 Swift의 동시성 모델은 쓰레드의 존재를 숨기고, 개발자가 더 높은 수준의 추상화를 통해 동시성을 쉽게 구현할 수 있도록 합니다. 이로 인해 코드가 더 간결하고 오류 발생 가능성이 줄어듭니다.
Swift의 비동기 동작과 스레드 관리 방식
또한 Swift에서 비동기 함수는 실행중인 스레드를 포기할 수 있습니다. (이 포기와 관련된 내용은 뒤에서 더 얘기해보죠) 그러면 첫번째 함수가 차단되는 동안 해당 스레드에서 다른 비동기 함수가 실행될 수 있습니다.
비동기 함수의 실행이 재개될 때 Swift는 해당 함수가 실행될 스레드에 대해 어떠한 보장도 하지 않습니다. 즉, 비동기 함수가 다시 시작을 할 때 동일한 스레드에서 실행되지 않을 수 있다는 말입니다. 이를 이해하기 위해서는 Swift의 비동기 동작과 스레드 관리 방식을 조금 더 깊이 이해해야 합니다.
Swift의 동시성 모델은 시스템 자원을 효율적으로 사용하기 위해 설계되었습니다. 특정 스레드에 함수의 실행을 고정시키지 않으면, Swift는 사용 가능한 모든 스레드를 자유롭게 활용할 수가 있죠. 이는 시스템의 전반적인 성능을 향상시키고, 스레드가 유휴 상태로 방치되는 것을 방지합니다.
Swift의 비동기 함수는 코루틴(coroutines) 모델을 따릅니다. 코루틴은 실행이 일시 중단되고 나중에 재개될 수 있는 함수를 말합니다. 이 때, 재개되는 지점은 원래의 스레드가 아닌 다른 스레드일 수 있습니다. 이는 코루틴이 어디에서나 실행을 재개할 수 있도록 하는 설계 원칙 때문입니다.
Swift의 컨커런시 모델은 작업을 Queue에 넣고, Queue에 있는 작업을 사용 가능한 스레드 풀(thread pool)에서 실행하게 됩니다. 따라서 특정 작업이 재개될 때, 스레드 풀 내의 아무 스레드에서나 실행될 수 있습니다. 이는 Swift가 고성능을 유지하면서 동시성을 관리하는 핵심 기법 중 하나 입니다.
예시
비동기 함수가 다음과 같이 실행된다고 가정을 해봅시다.
func asyncFunction() async {
print("Start")
await Task.sleep(1_000_000_000) // 1초 동안 일시 중단
print("End")
}
이 함수가 처음 실행될 때에는 A 라는 스레드에서 실행될 수 있죠. 그러나 Task.sleep
으로 인해서 1초 동안 일시 중단된 후, 재개될 때는 반드시 동일한 스레드 A 에서 실행될 필요는 없습니다. 시스템은 현재 사용이 가능한 스레드 B 에서 남은 작업을 재개할 수 있다는 것입니다.
실용적 측면
사실 대부분의 경우 다른 스레드에서 작동이 되어도 딱히 문제가 되지 않습니다. 대부분의 비동기 작업은 특정 스레드에서 실행되는 것에 의존하지 않기 때문이죠. 그러나, 특정 스레드에서 실행되어야 하는 작업이 있다면, 개발자는 이를 명시적으로 처리해줘야 합니다.
뭐,,예를 들자면 UI 업데이트는 메인스레드에서 실행되어야 하기에
DispatchQueue.main.async {
// UI 업데이트 작업
}
와 같이 명시해주어 메인 스레드에서의 실행을 보장할 수 있게 해줘야 합니다.
결론
Swift가 비동기 함수의 재개 시점에 대해 특정 스레드를 보장하지 않는 이유는 효율적인 자원 관리와 성능 최적화를 위해서입니다. 이는 대부분의 비동기 작업에 적합한 모델이며, 특정 스레드가 필요한 작업은 별도로 처리함으로써 문제를 해결할 수 있습니다.
서론이 조금 길어졌네요. (흥미로운 내용들이 조금 있어서.. ㅎ)
이제 비동기 처리를 하는 방법에 대해 알아보시죠!
Swift의 언어 지원을 사용하지 않고 동시성 코드를 사실 작성할 수 있습니다.
이미 여러분이 제일 잘 아실만한 Completion Handler 인데요.
listPhotos(inGallery: "여름 휴가") { photoNames in // Completion Handler
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
downloadPhoto(named: name) { photo in // Completion Handler
show(photo)
}
}
“여름 휴가” 라는 이름의 갤러리에 존재하는 사진들을 불러와 정렬 후 첫번째 사진을 다운로드 하는 코드입니다. 이 간단한 작업의 경우에도 Completion Handler 가 연속해서 작성되어야 하므로 결국 중첩 클로저를 작성하게 되죠…여기서 작업이 더 추가가 된다? 답이 없는 코드가 완성되게 됩니다..
(히히 클로저 지옥 발사!)
비동기 함수 (asynchronous function) 또는 비동기 메서드(asynchronous method) 는 실행 도중에 일시적으로 중단될 수 있는 특수한 함수 또는 메서드입니다.
이들은 완료될 때까지 실행되거나, 오류가 발생하거나 반환되지 않는 한 일반적인 동기 함수 또는 메서드와 차이를 보입니다. 비동기 함수는 이 세가지 중 (반환, 오류 발생, 반환 X) 하나를 수행하지만, 무언가를 기다리고 있을 때 중간에 일시 중지될 수도 있습니다. 비동기 함수는 본문 내에서 실행을 일시 중지할 수 있는 부분을 표시합니다.
함수가 비동기임을 나타내려면 던지는 함수를 나타내기 위해 throws
를 사용하는 것과 비슷하게 파라미터 뒤의 선언에 async
키워드를 작성하면 됩니다. 함수가 값을 반환한다면 ->
전에 async
를 작성하면 됩니다.
예를 들어 갤러리에서 사진의 이름을 가져오는 걸로 코드를 짜보죠.
func listPhotos(inGallery name: String) async -> [String] {
let result = await /// 비동기 코드..
return result
}
비동기 함수를 호출할 때, 해당 함수가 반환될 때까지 실행이 일시 중단됩니다.
중단될 가능성이 있는 지점을 표시하기 위해서 호출하는 코드 앞에 await
를 붙혀 작성합니다. 이건 마치 do - catch
사용 시 에러를 캐치할 수 있게 호출하는 try
와 비슷한 구조를 가집니다.
비동기 함수 내에서 실행 흐름은 다른 비동기 메서드를 호출할 때만 일시 중단 됩니다. (여기서 중단은 암시적이거나 선점적이지 않습니다.) 이 말인 즉슨, 가능한 모든 중단 지점이 await
으로 표시된다는 의미이기도 합니다.
코드에서 중단 가능한 모든 지점을 표시하면서 동시성 코드를 더 읽고 이해하기 쉽게 만들어 줍니다.
아래 예시를 보면서 얘기 해봅시다.
let photoNames = await listPhotos(inGallery: "여름 휴가")
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
let photo = await downloadPhoto(named: name)
show(photo)
위 코드는 여름 휴가 갤러리의 모든 사진의 이름을 가져온 다음 첫번째 사진을 보여주는 코드입니다.
listPhotos
, downloadPhoto
두 함수 모두 네트워크 요청을 필요로 하기에 완료하는데 비교적 오랜시간이 걸릴 수 있습니다. 리턴 화살표 전에 async
를 작성해서 둘 다 비동기로 만들면 이 코드는 그림이 준비될 때까지 기다리는 동안 앱의 나머지 코드가 계속 실행될 수 있습니다.
코드가 실행되는 순서는 다음과 같습니다.
listPhotos
함수를 호출하고 해당 함수가 반환될 때까지 실행을 일시 중단합니다.await
으로 표시된 다음 중단 지점 또는 완료될 때까지 실행됩니다.listPhotos
가 반환된 후에 반환된 값을 photoNames
에 할당합니다.sortedNames
와 name
을 정의하는 라인은 일반적인 동기 코드입니다. 이 라인은 await
으로 표시되지 않았으므로 가능한 중단 지점이 없습니다.await
은 downloadPhoto
함수에 대한 호출을 표시합니다. 이 코드는 해당 함수가 반환될 때까지 실행을 다시 일시 중단하여 다른 동시 코드에 실행할 기회를 제공합니다.downloadPhoto
가 반환된 후에 반환값은 photo
에 할당된 다음에 show
를 호출할 때 인수로 전달됩니다.await
으로 표시된 코드의 중단이 가능한 지점은 비동기 함수가 리턴되기를 기다리는 동안 실행을 일시적으로 중단할 수 있다는 것을 나타내기도 합니다.
Swift 가 현재 스레드에서 코드의 실행을 일시 중단하고 대신에 해당 스레드에서 다른 코드를 실행하기 때문에, 이걸 스레드 양보 (yielding the thread) 라고도 부른다고 합니다 :)
await
키워드가 있는 코드는 실행을 일시 중단할 수 있어야 하므로 프로그램의 특정 위치에서만 비동기 함수를 호출할 수 있습니다.
비동기 함수, 메서드 또는 프로퍼티의 본문에 있는 코드
await
키워드를 사용할 수 있습니다.func fetchData() async {
let data = await fetchFromNetwork()
}
@main 으로 표시된 구조체 , 클래스, 또는 열거형의 정적 main() 메서드에 있는 코드
@main
어노테이션이 붙은 구조체, 클래스, 또는 열거형의 main
메서드에서 비동기 함수를 호출할 수 있습니다.@main
어노테이션이 있는 구조체의 main
메서드에서 비동기 함수를 호출할 수 있습니다.@main
struct MyApp {
static func main() async {
await startApp()
}
}
구조화 되지 않은 동시성 (Unstructured Concurrency)
Task
를 사용하여 비동기 작업을 시작할 수 있으며, 이 작업은 구조화 되지 않은 동시성의 예입니다.Task {
await performAsyncTask()
}
이 패턴은 독립적으로 실행되는 비동기 작업을 생성하여 현재 코드 흐름과 별도록 동작할 수 있게 합니다.
Task.yield()
메서드를 호출해서 명시적으로 중단 지점을 추가할 수 있습니다.
func generateSlideshow(forGallery gallery: String) async {
let photos = await listPhotos(inGallery: gallery)
for photo in photos {
// ... 사진 또는 비디오를 렌더링 하는 작업 진행 ...
await Task.yield()
}
}
영상 렌더링을 동기화 하는 코드가 있다고 가정해보면, 딱히 명확한 중단 지점을 포함하지 않게 됩니다. 영상 렌더링 작업은 오랜시간이 걸리기 때문이죠. 그러나, Task.yield()
를 주기적으로 호출해서 명시적으로 중단 지점을 추가할 수 있습니다. 이렇게 코드를 구성하게 되면, Swift는 이 작업과 다른 작업의 진행을 균형적으로 맞출 수가 있습니다.
실제로 대기가 되지 않는 경우에는 위 키워드를 사용하여 강제적 & 명시적으로 대기를 명확하게 주어야 합니다.
방금 위 섹션까지는 비동기적으로 배열의 모든 요소가 준비된 후에 전체 배열을 한번에 반환하게 됩니다.
그럼 하나씩 처리하고 싶은 경우에는 어떻게 하면 될까요? 이런 접근 방식은 비동기 시퀀스를 사용하여 한번에 컬렉션의 한 요소를 기다리게 하는 방법이 있습니다.
import Foundation
let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
print(line)
}
비동기 시퀀스에 대한 조회 동작 방식은 위 코드와 같이 사용할 수 있습니다.
일반적인 for-in
루프 대신에 위 코드는 for
다음에 await
를 사용하게 됩니다. 비동기 함수를 호출할 때와 마찬가지로 await
작성은 가능한 중단 지점을 나타내게 됩니다. for-await-in
루프는 다음 요소(데이터)를 사용할 수 있을 때까지 기다리고 각 반복이 시작될 때 잠재적으로 실행을 일시 중단합니다.
Sequence
프로토콜에 준수성을 추가하여 for
-in
루프에서 자체 타입을 사용할 수 있는 것과 같은 방식으로 AsyncSequence
프로토콜에 준수성을 추가하여 for
-await
-in
루프에서 자체 타입을 사용할 수 있습니다.
💡 위 말이 정확히 이해가 가지 않아 조금 더 찾아보았습니다.
Swift에서Sequence
프로토콜은 순차적으로 접근이 가능한 컬렉션 타입을 정의할때 사용합니다. 예를 들어 배열, 딕셔너리, 세트 등이Sequence
프로토콜을 준수하죠.for-in
루프는Sequence
프로토콜을 준수하는 타입의 요소들을 순차적으로 접근할 수 있게 해줍니다!저희가 공부하고 있는 이 내용의 경우
AsyncSequence
라는 프로토콜을 사용하여 비동기적으로 처리할 수 있습니다.즉, 요소들이 순차적으로 접근 가능하지만, 각 요소가 준비될 때까지 비동기적으로 기다립니다.
Sequence와 AsyncSequence 프로토콜
Sequence 프로토콜
Sequence
프로토콜을 준수하는 타입은 Iterator
를 반환하는 makeIterator()
메서드를 구현해야 합니다. Iterator
는 next()
메서드를 구현하여 다음 요소를 반환합니다.
AsyncSequence 프로토콜
AsyncSequence
프로토콜을 준수하는 타입은 AsyncIterator
를 반환하는 makeAsyncIterator()
메서드를 구현해야 합니다. AsyncIterator
는 next()
메서드를 구현하여 await
키워드와 함께 비동기적으로 다음 요소를 반환합니다.
위 코드에서 MyAsyncSequence
는 AsyncSequence
프로토콜을 준수하고, MyAsyncIterator
는 AsyncIteratorProtocol
을 준수합니다. next()
메서드는 비동기적으로 요소를 반환하며, for-await-in
루프에서 사용될 수 있습니다.
await
를 사용해 비동기 함수를 호출하면 하나하나씩 처리하게 됩니다. 비동기 코드가 실행되는 동안 호출자는 코드의 다음 라인을 실행하기 위해서 이동하기 전에, 해당 코드가 완료 될 때까지 기다리게 되죠.
예를 들어서 갤러리에서 처음 세 장의 사진을 가져오려면 아래 코드와 같이 같은 함수를 총 3번 호출 후 대기를 해야합니다.
let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])
let photos = [firstPhoto, secondPhoto, thirdPhoto]
show(photos)
하지만 이런 방식은 크나큰 단점이 존재해요.
다운로드가 비동기로 작동되는 동안 다른 작업을 수행할 수는 있는데, downloadPhoto
이 함수에 대한 호출은 한 번에 하나씩만 호출되는 것이 단점입니다. 각 사진은 다음 사진이 다운로드를 시작하기 전까지 완료가 됩니다. 근데, 이 작업을 굳이 하나하나 기다리면서 처리할 이유가 없습니다. 사실 각 사진은 개별적으로 또는 동시에 다운로드 할 수 있기 때문입니다.
비동기 함수를 호출하고 주변의 코드와 병렬로 실행 하려면 상수를 정의할 때 let
앞에 async
를 작성하고 상수를 사용할 때마다 await
를 사용하면 됩니다.
async let firstPhoto = downloadPhoto(named: photoNames[0])
async let secondPhoto = downloadPhoto(named: photoNames[1])
async let thirdPhoto = downloadPhoto(named: photoNames[2])
let photos = await [firstPhoto, secondPhoto, thirdPhoto]
show(photos)
위 예시에서 downloadPhoto
를 호출하는 세가지는 모두 이전 호출이 완료되길 기다리지 않고 시작이 됩니다. 사용할 수 있는 시스템의 자원이 충분하다고 하면 동시에 실행할 수도 있죠.
코드가 함수의 결과를 기다리기 위해서 일시 중단되지 않기 때문에 이런 함수 호출 중 어느것도 await
으로 표시하지 않습니다. 대신에 photos
가 정의된 라인까지 실행이 계속 됩니다.
await
키워드를 붙힙니다.이렇게 각 방법이 필요한 시점에 알잘딱깔센으로 사용하시면 될 것 같네요 :)
제 회사 서비스에서 사용하는 코드 일부 예시를 잠깐 보여드리자면
강연 리스트를 불러오는 코드인데요.
화면 구조상 상단에 배너가 먼저 보이고 그 다음 강연 리스트가 보이는 구조입니다.
이 때 두 데이터를 비동기로 불러오고 싶었기에 async let
을 이용하여 패칭하는 코드는 동시에 호출하되, 데이터가 다 준비될 경우에 리턴하는 코드를 구성할 수 있었습니다.
내용이 너무 길어져 나눠서 작성하겠습니다!