
-
코딩 실습: Swift 동시성으로 앱 수준 높이기
기존 샘플 앱을 업데이트함에 따라 Swift 동시성으로 앱의 사용자 경험을 최적화하는 방법을 학습하세요. 먼저 메인 액터 앱을 시작한 다음 필요에 따라 점진적으로 비동기 코드를 도입하겠습니다. 작업을 사용하여 메인 액터에서 실행되는 코드를 최적화하고 백그라운드에 작업을 오프로드하여 코드를 병렬화하는 방법을 알아보세요. 데이터 레이스 안전성을 통해 활용할 수 있는 기능을 살펴보고 데이터 레이스 안전성 오류를 해석 및 수정하는 방법을 살펴보세요. 마지막으로, 앱의 맥락에서 구조화된 동시성을 최대한 활용하는 방법을 보여드리겠습니다.
챕터
- 0:00 - 서론
- 2:11 - 접근 가능한 동시성 구성
- 2:51 - 샘플 앱 아키텍처
- 3:42 - 사진 라이브러리에서 사진을 비동기식으로 로드
- 9:03 - 사진에서 스티커 및 색상 추출
- 12:30 - 백그라운드 스레드에서 작업 실행
- 15:58 - 작업 병렬화
- 18:44 - Swift 6로 데이터 레이스 방지
- 27:56 - 구조화된 동시성으로 비동기식 코드 관리
- 31:36 - 요약
리소스
관련 비디오
WWDC25
WWDC23
-
비디오 검색…
안녕하세요 저는 Sima이고 Swift와 SwiftUI를 개발합니다 이 영상에선 Swift 동시성으로 앱 성능을 높이는 방법을 배웁니다 개발자가 작성하는 코드 대부분은 메인 스레드에서 실행됩니다
단일 스레드 코드는 이해하고 유지보수하기 쉽죠 하지만 최신 앱은 종종 시간이 드는 작업이 필요합니다 네트워크 요청이나 자원 소모가 큰 작업처럼요 이런 경우엔 작업을 메인 스레드에서 분리해 앱의 반응성을 유지하는 것이 좋습니다 Swift는 동시성 코드 작성에 필요한 도구를 전부 제공하죠 이 세션에서 함께 앱을 빌드하며 그 방법을 알아보겠습니다 단일 스레드 앱으로 시작해서 필요에 따라 비동기 코드를 점차 도입해 볼게요 그리고 앱 성능 개선을 위해 자원이 많이 소모되는 작업을 오프로드하고 병렬로 실행하겠습니다 다음으로 일반적인 데이터 경쟁 안전 시나리오와 해결을 위한 접근 방식도 알아보겠습니다 마지막으로 구조화된 동시성을 살펴보고 동시 코드를 더 세밀하게 조정하는 TaskGroup 등 도구 사용법도 보여드립니다 얼른 시작해 봅시다 저는 일기를 쓰고 스티커로 꾸미는 걸 좋아하는데요 지금부터 같이 원하는 사진으로 스티커 팩을 구성하는 앱을 만들어보겠습니다 앱은 두 개의 주요 뷰를 갖습니다 첫 번째 뷰에서는 원본 사진의 색을 반영한 그라디언트와 스티커가 포함되는 캐러셀을 보여 줍니다 두 번째 뷰에서는 내보낼 준비가 된 전체 스티커 팩의 그리드 미리보기를 보여줄 겁니다 따라하려면 아래의 샘플 앱을 다운로드하세요 프로젝트를 구축했을 때 Xcode에 몇 가지 기능이 추가돼 동시성을 더 쉽게 도입할 수 있었습니다 여기엔 기본으로 메인 액터와 향후 추가될 기능이 포함됩니다 Xcode 26의 새 앱 프로젝트에서 기본으로 활성화된 기능입니다
접근 가능한 동시성 구성에서 Swift 6 언어 모드가 데이터 경쟁 안전을 제공하는데 준비가 될 때까지 동시성을 도입하지 않습니다 해당 기능을 기존 프로젝트에서 활성화하려면 Swift 마이그레이션 가이드를 확인하세요
앱의 코드엔 StickerCarousel과 StickerGrid, 두 메인 뷰가 있는데 여기서는 PhotoProcessor 구조체가 추출하는 스티커를 사용합니다
PhotoProcessor는 스티커를 반환하기 전, 사진 라이브러리에서 원본 이미지를 가져옵니다
StickerGrid 보기에는 스티커 공유에 쓰는 ShareLink 가 있죠
PhotoProcessor 유형은 리소스 소모가 큰 두 작업을 수행하는데 스티커 추출과 주요 색상 계산입니다 Swift 동시성 기능으로 더 원활한 사용자 경험을 위해 최적화하면서도 기기가 리소스 소모가 큰 작업을 수행하도록 두는 방법을 알아보죠 StickerCarousel 뷰부터 시작하겠습니다 이 뷰는 스티커를 수평 스크롤 뷰로 표시합니다 스크롤 뷰에서 뷰 모델이 저장된 사진 라이브러리에서 선택된 사진 배열을 반복하는 ForEach가 있습니다 viewModel에서 processedPhotos 사전을 확인해 사진 라이브러리에서 해당되는 처리된 사진을 가져옵니다 지금은 아직 처리된 사진이 없는데, 사진 선택기에서 이미지 불러오는 코드를 작성하지 않았기 때문이죠 지금 앱을 실행하면 스크롤 뷰 안에는 StickerPlaceholder 뷰만 보입니다 command 클릭을 사용해 StickerViewModel로 이동해 보죠 StickerViewModel은 사진 라이브러리에서 현재 선택된 사진 배열을 SelectedPhoto 형식으로 저장합니다 Option 키를 누르고 Quick Help를 열어 더 알아 보겠습니다
SelectedPhoto는 PhotosUI 프레임워크의 PhotosPickerItem과 해당 ID를 저장하는 식별 가능한 타입입니다 이 모델엔 processedPhotos란 사전도 있는데 선택된 사진의 ID를 해당하는 SwiftUI 이미지에 매핑합니다 저는 선택된 사진을 처리하는 loadPhoto 함수 작업을 이미 시작했는데요 지금은 저장된 사진 선택 항목에서 어떤 데이터도 로드하지 않습니다 PhotosPickerItem은 SDK의 Transferable 프로토콜을 준수하며 요청한 표현을 비동기 loadTransferable 함수로 로드할 수 있게 해 줍니다 저는 데이터 표현을 요청하겠습니다
이제 컴파일러 오류가 발생했습니다
`loadTransferable` 호출이 비동기적이고 제가 호출하는 `loadPhoto` 함수가 비동기 호출을 처리하도록 설정되지 않았기 때문이죠 그래서 Swift가 `loadPhoto`에 비동기 키워드 추가를 제안합니다 제안을 적용해 보겠습니다
저희 함수가 비동기 코드를 처리할 수 있습니다 하지만 아직 오류가 하나 남았습니다 `loadPhoto`로 비동기 처리를 해도 기다릴 작업을 지정해야 하죠 그러려면 `loadTransferable` 호출에 `await` 키워드를 추가해야 합니다 제안된 수정 사항을 적용해 보죠
StickerCarousel 뷰에서 이 함수를 호출하겠습니다 Command-Shift-O를 누르면 Xcode의 빠르게 열기 기능으로 StickerCarousel로 다시 돌아갈 수 있죠
StickerPlaceholder 뷰가 나타나면 loadPhoto 함수를 호출하고 싶은데 비동기 기능이기 때문에 SwiftUI 작업 한정자를 사용해서 이 뷰가 나타날 때 사진 처리를 시작하려고 합니다
제 기기에서 확인해 보죠
좋네요 정상적으로 작동합니다 사진 몇 장을 선택해서 테스트 해 보겠습니다
멋지네요 이미지가 사진 라이브러리에서 로드되고 있는 것 같습니다 이 작업으로 데이터에서 이미지가 로드되는 동안 앱의 UI 반응성을 유지합니다 저는 이미지 표시에 LazyHStack을 사용하고 있어 화면 렌더링이 필요한 뷰에 한해 사진 로딩 작업을 시작하기 때문에 앱이 불필요한 작업을 수행하지 않습니다 async/await이 앱 반응성을 높이는 이유를 알아보겠습니다
`loadTransferable` 메서드 호출할 경우엔 `await` 키워드를 추가하고 `loadPhoto` 함수에 `async`를 지정했습니다 `await` 키워드는 중단될 수 있는 지점을 표시합니다 즉, loadPhoto 함수는 처음에 메인 스레드에서 시작되며 await로 loadTransferable을 호출하면 해당 작업이 완료될 때까지 실행이 일시 중단됩니다 loadPhoto가 중단된 동안 Transferable 프레임워크가 배경 스레드에서 loadTransferable을 실행합니다 loadTransferable이 완료되면 loadPhoto가 실행을 재개하죠 메인 스레드에서 이미지를 업데이트합니다 loadPhoto가 중단된 동안 메인 스레드는 UI 이벤트 및 다른 작업을 실행할 수 있습니다 await 키워드는 함수가 중단된 동안 코드에서 다른 작업이 발생할 수 있는 지점을 나타냅니다 이렇게 사진 라이브러리에서의 이미지 로딩이 완료됐습니다 그 과정에서 우리는 비동기 코드의 의미와 비동기 코드를 작성하고 이해하는 방법을 배웠죠 이제 앱에 코드 몇 개를 추가해 사진에서 스티커를 추출하고 캐러셀 뷰에 나타낼 때 사진의 주요 색상을 배경 그라디언트에 활용할 수 있습니다
Command-클릭으로 loadPhoto로 이동해 효과를 적용해 보죠
이 프로젝트엔 이미 데이터를 가져오는 PhotoProcessor가 있어 색상과 스티커를 추출하고 처리된 사진을 반환합니다 데이터로부터 기본 이미지를 제공하지 않고 PhotoProcessor를 대신 사용하려고 합니다
PhotoProcessor는 처리된 사진을 반환하므로 사전의 유형을 업데이트하겠습니다
ProcessedPhoto는 사진에서 추출한 스티커와 그라디언트를 구성하는 데 쓸 색상 배열을 제공합니다
이미 processedPhoto를 받는 GradientSticker 뷰는 포함됐죠 Open Quickly를 사용해서 탐색해보겠습니다
이 뷰는 처리된 사진에 저장된 스티커를 ZStack의 선형 그라디언트 위에서 보여 줍니다
캐러셀에 GradientSticker를 추가해 보겠습니다
현재 StickerCarousel에서는 사진 크기만 조절하고 있지만 이제 처리된 사진이 있어 GradientSticker를 대신 씁니다
앱을 빌드하고 실행해 스티커를 확인해 봅시다
잘 작동하네요
오 이런 스티커를 추출하는 동안 캐러셀을 그다지 매끄럽게 스크롤할 수 없습니다
이미지 처리에는 많은 자원이 들 것으로 예상됩니다 Instruments로 앱을 프로파일링해 확인했습니다 추적 결과 앱에 심각한 지연이 발생했습니다
확대해서 가장 무거운 스택 추적을 살펴보면 사진 프로세서가 리소스를 많이 쓰는 작업을 10초 이상 수행하는 메인 스레드를 차단하는 것을 볼 수 있습니다 앱에서 지연을 분석하는 방법을 자세히 알아보려면 `Instruments로 행 분석하기` 세션을 확인하세요 이제 메인 스레드에서 앱이 수행하는 작업을 더 알아보죠
`loadTransferable`의 구현은 작업을 배경으로 오프로드해서 메인 스레드에서 로딩 작업이 발생하지 않도록 합니다
이제 이미지 처리 코드를 추가하는데 메인 스레드에서 구동되고 완료하는 데 시간이 오래 걸리며 스크롤 제스처에 응답 등의 UI 업데이트를 받지 못해서 앱의 사용자 경험이 저하됩니다
이전에는 SDK에서 제공하는 비동기 API를 사용해 작업을 대신 배경으로 오프로드했지만 이제 지연 현상 해결을 위해 작성한 코드를 병렬로 실행합니다 일부 이미지 변환을 배경에서 처리할 수 있습니다 이미지 변환은 세 가지 작업으로 구성됩니다 UI와 상호작용해서 원본 이미지를 가져오고 업데이트합니다 그래서 이 작업은 배경으로 옮길 수 없죠 하지만 이미지 처리는 오프로드 할 수 있습니다 이렇게 하면 메인 스레드가 리소스를 많이 소모하는 이미지 처리 작업 중에도 다른 이벤트에 응답할 수 있죠 PhotoProcessor 구조체를 살펴보고 구현 방법을 알아보겠습니다
제 앱은 기본적으로 메인 액터 모드에 있어 PhotoProcessor가 @MainActor에 연결됩니다 모든 메서드는 메인 액터에서 실행되어야 하는 거죠 `process` 메서드는 스티커 및 색상 추출 메서드를 호출합니다 이 메서드에 메인 액터 외부 실행 가능 표시를 해야 하는데 그러려면 전체 PhotoProcessor 유형을 비격리로 표시하면 됩니다 Swift 6.1에 도입된 새로운 기능이죠 유형이 비격리로 표시되면 모든 속성과 메서드는 자동으로 비격리됩니다
이제 PhotoProcessor가 MainActor에 연결되지 않았으므로 프로세스 함수에 대한 새로운 `@concurrent` 속성을 적용하고 `async`라고 표시할 수 있습니다 그러면 Swift는 이 메서드 실행 시 항상 배경 스레드로 전환하죠 Open Quickly를 사용하여 PhotoProcessor로 돌아가겠습니다
먼저 유형에 비격리를 적용하여 PhotoProcessor를 메인 액터에서 분리하고 해당 메서드를 동시 코드에서 호출하도록 합니다
이제 PhotoProcessor가 비격리되었으므로 프로세스 메서드가 배경 스레드에서 호출됐는지 확인하려고 @concurrent와 async를 적용하겠습니다
이제 Open Quickly를 사용해 StickerViewModel로 돌아갑니다
loadPhoto 메서드에서는 `await` 키워드로 프로세스 메서드를 호출해서 Swift의 제안처럼 메인 스레드에서 벗어나야 합니다 제안을 적용해 보겠습니다
앱을 빌드하고 실행해 작업을 메인 액터 밖으로 옮긴 것이 지연 현상 해결에 도움이 되는지 확인해 보죠
스크롤 지연 현상이 해결된 것 같습니다
하지만 UI 상호작용이 가능해도 스크롤 하는 중에 이미지가 UI에 나타나는 데는 시간이 걸립니다 앱의 반응성 유지가 사용자 경험을 개선하는 유일한 요소는 아니죠 작업을 메인 스레드에서 옮기면 사용자가 결과를 얻는 데 오랜 시간이 걸려 앱 사용 경험이 실망스러울 수 있습니다
이미지 처리 작업을 배경 스레드로 옮겼지만 여전히 완료하는 데 시간이 오래 걸립니다 동시성을 최적화해서 더 빠르게 완료하는 방법을 알아보겠습니다 이미지를 처리하려면 스티커와 주요 색상을 추출해야 하는데 이런 작업은 서로 독립적이므로 async let을 사용해 각 작업을 병렬로 실행할 수 있죠 이제 모든 배경 스레드를 관리하는 동시 스레드 풀이 두 작업이 두 배경 스레드에서 한 번에 시작되도록 합니다 덕분에 휴대폰에 있는 여러 개의 코어를 십분 활용할 수 있죠
프로세스 메서드를 Command-클릭하고 async let을 채택합니다
Control+Shift와 아래 화살표 키를 누르면 다중줄 커서를 사용해 스티커와 색상 변수 앞에 async를 추가할 수 있습니다
이제 두 작업을 병렬로 호출했으니 각 작업의 결과를 기다린 후 프로세스 함수를 재개해야 합니다 이제 편집기 메뉴를 사용해 모든 문제를 해결해 보죠
아직 오류가 하나 남았습니다 이번에는 데이터 경쟁에 관한 오류입니다 이 오류를 이해하려면 시간이 좀 필요합니다
이 오류는 나의 PhotoProcessor 유형이 동시 작업에 공유되기 안전하지 않다는 의미입니다 그 이유를 이해하려면 저장된 속성을 살펴봐야 합니다 PhotoProcessor가 저장한 유일한 속성은 사진에서 색상을 추출하는 데 필요한 ColorExtractor의 인스턴스입니다 ColorExtractor 클래스는 이미지의 주요 색상을 계산합니다 픽셀 버퍼를 포함한 하위 수준의 변경 가능 이미지 데이터를 다뤄 색상 추출 유형이 동시에 접근하기 안전하지 않습니다
색상 추출 작업은 ColorExtractor와 현재 동일 인스턴스를 공유합니다 이로인해 동일한 메모리에 동시 접근이 발생할 수 있습니다
이것을 `데이터 경쟁`이라고 부르는데 충돌이나 예측할 수 없는 동작 등 런타임 버그로 이어질 수 있습니다 Swift 6 언어 모드는 병렬 실행 코드를 작성할 때 이와 같은 버그를 컴파일 시점에 찾아내 원천 방지해 줍니다 덕분에 복잡한 런타임 버그가 컴파일 오류로 바뀌어 바로 처리할 수 있게 됩니다 오류 메시지의 `도움말` 버튼을 클릭하면 Swift 웹사이트에서 해당 에러를 자세히 확인할 수 있습니다 데이터 경쟁을 해결할 때 고려 가능한 여러 옵션이 있습니다 코드의 공유 데이터 사용 방식에 따라 선택이 달라집니다 먼저, 이런 질문을 던져 보세요 변경 가능한 상태를 동시 코드 간에 공유해야 하나요? 대부분의 경우 그냥 공유하지 않으면 됩니다 그러나 코드 간에 상태를 공유해야 하는 경우도 있습니다 그렇다면 공유해야 할 데이터를 안전하게 전달 가능한 값 유형으로 분리하는 것을 고려해보세요 이 중 어느 솔루션도 상황에 맞지 않는다면 이 상태를 MainActor 등의 액터로 분리하는 걸 고려해 보세요 이 경우엔 첫 번째 솔루션이 효과가 있나 보겠습니다 해당 유형이 동시 작업을 처리할 수 있도록 리팩토링하는 방법도 있겠지만 대신 색상 추출기를 extractColors 함수 안에 지역 변수로 옮기면 처리 중인 각 사진이 고유한 색상 추출기 인스턴스를 갖게 되죠 이 방법은 올바른 코드 변경인데 색상 추출기가 한번에 하나의 사진에서 작동되게 의도되었기 때문입니다 그래서 색상 추출 작업마다 별도의 인스턴스가 필요합니다 이렇게 변경하면 extractColors 함수 외부의 어떤 것도 색상 추출기에 접근하지 못 해 데이터 경쟁을 방지할 수 있습니다
직접 변경하기 위해 색상 추출기 속성을 extractColors 함수로 옮기겠습니다
좋습니다 컴파일러의 도움으로 앱의 데이터 경쟁을 감지하고 없앨 수 있었습니다 이제 실행해 보겠습니다
앱이 더 빨라진 게 느껴지네요
Instruments에서 프로파일러 추적을 수집하여 열어도 지연 현상이 보이지 않습니다 Swift 동시성 기능을 활용한 최적화 내용을 간략히 요약해 보죠 `@concurrent` 속성을 채택해서 성공적으로 이미지 처리 코드를 메인 스레드 밖으로 분리했습니다 또한 `async let`으로 스티커 및 색상 추출 작업을 병렬화해 앱의 성능을 크게 향상시켰죠 Swift 동시성 기능을 활용한 최적화는 항상 시간 프로파일러 도구 등 분석 도구 데이터 기반이 돼야 합니다 동시성을 도입하지 않고도 코드를 더 효율적으로 만들 수 있다면 그 방법을 항상 우선시 해야 합니다 앱이 빠릿빠릿해졌습니다 이미지 처리에서 잠시 벗어나 재밌는 것을 해 볼까요?
처리한 스티커에 시각 효과를 더해 스크롤되며 흐릿해져 사라지도록 만드세요 Xcode로 전환해서 작성해 봅시다
Xcode 프로젝트 탐색기를 사용해 StickerCarousel로 돌아가겠습니다
이제 스크롤 뷰의 각 이미지에 시각적 효과를 적용하겠습니다 visualEffect 한정자를 이용해서요
지금 뷰에 몇 가지 효과를 적용하고 있습니다 마지막 스티커만 스크롤 뷰에서 오프셋, 흐림, 불투명도를 변경하고 싶습니다 그럼 viewModel의 selection 속성에 접근해 마지막 스티커에 시각적 효과가 적용됐는지 확인해야 합니다
컴파일러 오류가 있는 것 같은데 제가 메인 액터로 보호된 뷰 상태에 접근하려고 해서 그런 것 같습니다 시각 효과 계산은 리소스를 많이 소모하므로 SwiftUI는 앱 성능 극대화를 위해 메인 스레드에서 오프로드합니다
도전해 보고 싶은 마음과 더 배우고 싶은 마음이 있다면 `SwiftUI에서 동시성 살펴보기` 세션을 확인하세요 이 에러는 이런 의미입니다 이 클로저는 나중에 배경에서 실행될 것입니다 `visualEffect`의 정의를 살펴보고 확인해 보겠습니다 command 클릭을 사용해서요
정의에서 이 클로저는 @Sendable입니다 SwiftUI의 표시이죠 이 클로저는 나중에 배경에서 실행될 것입니다
SwiftUI는 선택이 바뀔 때마다 시각 효과를 다시 호출합니다 그래서 클로저의 캡처 목록으로 복사본을 만들 수 있죠
SwiftUI가 이 클로저를 호출하면 선택 값의 복사본에서 작동합니다 데이터 경쟁 없이 작업을 수행할 수 있죠
시각적 효과를 확인해 봅시다
멋진데요 스크롤할 때 이전 이미지가 흐릿해지고 사라지네요
우리가 마주한 이 두 가지 데이터 경쟁 시나리오에서 동시 코드에서 변형 가능 데이터를 공유하지 않는 게 해결책이었죠 주요 차이점은 첫 번째 예시에서 일부 코드를 병렬 실행해서 직접 데이터 경쟁을 도입했단 거죠 두 번째 예시에선 SwiftUI API를 사용해 직접 처리하지 않고도 배경 스레드로 작업을 넘겼습니다
변경 가능 상태를 공유해야 한다면 다른 방법으로 보호해야 하는데 Sendable 값 유형은 동시 코드에서 공유하지 않도록 막습니다 한 예로 extractSticker, extractColors 메서드는 병렬로 실행되며 동일한 이미지 데이터를 갖죠 하지만 Data가 Sendable 값이어서 데이터 경쟁이 발생하지 않습니다 데이터는 복사-쓰기를 구현하므로 수정된 경우에만 복사됩니다 값 유형을 사용할 수 없다면 상태를 메인 액터로 격리해 보세요 다행히 기본 메인 액터는 이미 그렇게 하고 있습니다 예를 들어 저희 모델은 클래스라서 동시 작업에서도 접근이 가능하죠 모델이 암묵적으로 MainActor로 표시되므로 동시성 코드를 참고하는 것이 안전합니다 상태에 접근하려면 코드가 메인 액터로 전환돼야 합니다 이 경우, 클래스는 메인 액터에 의해 보호되지만 코드에 존재 가능한 다른 액터에도 동일하게 적용됩니다 지금까지는 앱이 아주 훌륭해 보입니다 하지만 아직 완성된 것 같진 않네요
스티커를 내보내기 위해 스티커 그리드 뷰를 추가합니다 아직 처리되지 않은 각 사진에 처리 작업을 시작하게 해 주고 모든 스티커를 한꺼번에 표시하죠 또한 공유 버튼이 있어 스티커를 내보낼 수 있습니다 이제 다시 코드로 돌아가 볼까요
먼저 Command-클릭을 사용해 StickerViewModel로 이동합니다
우리 모델 `processAllPhotos()`에 또 다른 방법을 추가하려고 합니다
모델에 저장된 처리된 사진을 전부 반복하고 싶습니다 처리되지 않은 사진이 있다면 다수의 병렬 작업을 한번에 시작해 즉시 처리하고 싶습니다
이전에 사용한 async let은 시작할 작업이 두 가지뿐임을 알고 있어야만 합니다 이전엔 스티커와 색상 추출이었죠 이제 배열의 모든 원본 사진의 새 태스크를 만들어야 하는데 처리 작업은 얼마든지 많을 수 있습니다
TaskGroup과 같은 API를 사용하면 앱이 수행해야 하는 비동기 작업을 더 세밀히 제어할 수 있습니다
작업 그룹을 사용하면 자식 작업과 결과를 세밀하게 제어할 수 있죠 작업 그룹을 사용하면 원하는 만큼 자식 작업을 병렬 시작할 수 있죠
각 자식 작업은 완료에 걸리는 시간이 제각각이라 다른 순서로 완료될 수도 있습니다 저희의 경우 처리된 사진은 사전에 저장되므로 순서는 상관 없죠
TaskGroup은 AsyncSequence를 따르기 때문에 완료된 순서대로 결과를 반복하여 사전에 저장할 수 있습니다 마지막으로 전체 그룹이 자식 작업 끝내기를 기다립니다 이제 코드로 돌아가서 작업 그룹을 채택해 봅시다 태스크 그룹을 채택하려면 선언부터 해야 합니다
클로저 내부에 그룹 참조가 있어 이미지 처리 작업을 추가할 수 있습니다 모델에 저장된 선택을 반복해 보겠습니다
이 사진이 처리됐다면 작업을 생성할 필요가 없죠
새로운 데이터 로드 및 사진 처리 작업을 시작하겠습니다
그룹은 비동기 시퀀스이기 때문에 반복해서 준비되면 처리된 사진을 processedPhotos 사전에 저장할 수 있습니다
다 됐네요! 이제 StickerGrid의 스티커를 보여줄 준비가 됐습니다
Open Quickly를 사용하여 StickerGrid로 돌아가죠
여기에는 상태 속성 finishedLoading이 모든 사진 처리가 완료되었는지를 보여 줍니다
사진이 아직 처리되지 않은 경우 진행 상황이 표시됩니다 방금 구현한 processAllPhotos() 메서드를 호출하려고 합니다
모든 사진이 처리된 후에 상태 변수를 설정할 수 있죠 마지막으로, 스티커 공유가 가능한 공유 링크를 툴바에 추가합니다
선택한 각 사진에 대한 스티커로 공유 링크 항목을 채웁니다
이제 앱을 실행해 봅시다
StickerGrid 버튼을 탭합니다 TaskGroup 덕에 미리보기 그리드가 모든 사진을 한 번에 처리합니다 그리고 완성되면 모든 스티커를 바로 볼 수 있죠 마지막으로 툴바의 공유 버튼을 사용해 모든 스티커를 파일로 내보내서 저장할 수 있습니다
앱에서는 스티커가 처리되는 순서대로 수집됩니다 하지만 주문 추적 등, 작업 그룹엔 더 많은 기능이 있습니다 ’구조화된 동시성의 기초를 넘어’ 세션에서 더 자세히 알아보세요
축하합니다 앱이 완성되었네요 이제 스티커를 저장할 수 있습니다 앱에 새 기능을 추가해 그 기능이 UI에 영향을 미치는 때를 알아냈고 응답성과 성능을 개선하기 위해 필요한 만큼 동시성을 활용했죠 또한 구조화된 동시성과 데이터 경쟁 방지법도 알아봤죠
함께 해보지 않았더라도 앱의 최종 버전을 다운받아 여러분의 사진으로 스티커를 만들어 보세요 이 세션에 등장한 새로운 Swift 동시성 기능과 기술에 익숙해지려면 앱을 더 최적화하거나 조정해 보세요 마지막으로, 이 기술을 앱에 적용할 수 있나 확인하세요 프로파일링 먼저 하는 것 잊지 마시고요 Swift의 동시성 모델 개념을 더 깊이 이해하려면 ’Swift 동시성 사용하기’ 세션을 확인해 보세요 기존 프로젝트를 마이그레이션해 새로운 동시성 기능을 채택하려면 ’Swift 마이그레이션 가이드’를 확인하세요 가장 신나는 점은 공책에 붙일 스티커가 생겼다는 거죠 시청해 주셔서 감사합니다
-
-
6:29 - Asynchronously loading the selected photo from the photo library
func loadPhoto(_ item: SelectedPhoto) async { var data: Data? = try? await item.loadTransferable(type: Data.self) if let cachedData = getCachedData(for: item.id) { data = cachedData } guard let data else { return } processedPhotos[item.id] = Image(data: data) cacheData(item.id, data) }
-
6:59 - Calling an asynchronous function when the SwiftUI View appears
StickerPlaceholder() .task { await viewModel.loadPhoto(selectedPhoto) }
-
9:45 - Synchronously extracting the sticker and the colors from a photo
func loadPhoto(_ item: SelectedPhoto) async { var data: Data? = try? await item.loadTransferable(type: Data.self) if let cachedData = getCachedData(for: item.id) { data = cachedData } guard let data else { return } processedPhotos[item.id] = PhotoProcessor().process(data: data) cacheData(item.id, data) }
-
9:56 - Storing the processed photo in the dictionary
var processedPhotos = [SelectedPhoto.ID: ProcessedPhoto]()
-
10:45 - Displaying the sticker with a gradient background in the carousel
import SwiftUI import PhotosUI struct StickerCarousel: View { @State var viewModel: StickerViewModel @State private var sheetPresented: Bool = false var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: 16) { ForEach(viewModel.selection) { selectedPhoto in VStack { if let processedPhoto = viewModel.processedPhotos[selectedPhoto.id] { GradientSticker(processedPhoto: processedPhoto) } else if viewModel.invalidPhotos.contains(selectedPhoto.id) { InvalidStickerPlaceholder() } else { StickerPlaceholder() .task { await viewModel.loadPhoto(selectedPhoto) } } } .containerRelativeFrame(.horizontal) } } } .configureCarousel( viewModel, sheetPresented: $sheetPresented ) .sheet(isPresented: $sheetPresented) { StickerGrid(viewModel: viewModel) } } }
-
14:13 - Allowing photo processing to run on the background thread
nonisolated struct PhotoProcessor { let colorExtractor = ColorExtractor() @concurrent func process(data: Data) async -> ProcessedPhoto? { let sticker = extractSticker(from: data) let colors = extractColors(from: data) guard let sticker = sticker, let colors = colors else { return nil } return ProcessedPhoto(sticker: sticker, colorScheme: colors) } private func extractColors(from data: Data) -> PhotoColorScheme? { // ... } private func extractSticker(from data: Data) -> Image? { // ... } }
-
15:31 - Running the photo processing operations off the main thread
func loadPhoto(_ item: SelectedPhoto) async { var data: Data? = try? await item.loadTransferable(type: Data.self) if let cachedData = getCachedData(for: item.id) { data = cachedData } guard let data else { return } processedPhotos[item.id] = await PhotoProcessor().process(data: data) cacheData(item.id, data) }
-
20:55 - Running sticker and color extraction in parallel.
nonisolated struct PhotoProcessor { @concurrent func process(data: Data) async -> ProcessedPhoto? { async let sticker = extractSticker(from: data) async let colors = extractColors(from: data) guard let sticker = await sticker, let colors = await colors else { return nil } return ProcessedPhoto(sticker: sticker, colorScheme: colors) } private func extractColors(from data: Data) -> PhotoColorScheme? { let colorExtractor = ColorExtractor() return colorExtractor.extractColors(from: data) } private func extractSticker(from data: Data) -> Image? { // ... } }
-
24:20 - Applying the visual effect on each sticker in the carousel
import SwiftUI import PhotosUI struct StickerCarousel: View { @State var viewModel: StickerViewModel @State private var sheetPresented: Bool = false var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: 16) { ForEach(viewModel.selection) { selectedPhoto in VStack { if let processedPhoto = viewModel.processedPhotos[selectedPhoto.id] { GradientSticker(processedPhoto: processedPhoto) } else if viewModel.invalidPhotos.contains(selectedPhoto.id) { InvalidStickerPlaceholder() } else { StickerPlaceholder() .task { await viewModel.loadPhoto(selectedPhoto) } } } .containerRelativeFrame(.horizontal) .visualEffect { [selection = viewModel.selection] content, proxy in let frame = proxy.frame(in: .scrollView(axis: .horizontal)) let distance = min(0, frame.minX) let isLast = selectedPhoto.id == selection.last?.id return content .hueRotation(.degrees(frame.origin.x / 10)) .scaleEffect(1 + distance / 700) .offset(x: isLast ? 0 : -distance / 1.25) .brightness(-distance / 400) .blur(radius: isLast ? 0 : -distance / 50) .opacity(isLast ? 1.0 : min(1.0, 1.0 - (-distance / 400))) } } } } .configureCarousel( viewModel, sheetPresented: $sheetPresented ) .sheet(isPresented: $sheetPresented) { StickerGrid(viewModel: viewModel) } } }
-
26:15 - Accessing a reference type from a concurrent task
Task { @concurrent in await viewModel.loadPhoto(selectedPhoto) }
-
29:00 - Processing all photos at once with a task group
func processAllPhotos() async { await withTaskGroup { group in for item in selection { guard processedPhotos[item.id] == nil else { continue } group.addTask { let data = await self.getData(for: item) let photo = await PhotoProcessor().process(data: data) return photo.map { ProcessedPhotoResult(id: item.id, processedPhoto: $0) } } } for await result in group { if let result { processedPhotos[result.id] = result.processedPhoto } } } }
-
30:00 - Kicking off photo processing and configuring the share link in a sticker grid view.
import SwiftUI struct StickerGrid: View { let viewModel: StickerViewModel @State private var finishedLoading: Bool = false var body: some View { NavigationStack { VStack { if finishedLoading { GridContent(viewModel: viewModel) } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() } } .task { await viewModel.processAllPhotos() finishedLoading = true } .toolbar { ToolbarItem(placement: .topBarTrailing) { if finishedLoading { ShareLink("Share", items: viewModel.selection.compactMap { viewModel.processedPhotos[$0.id]?.sticker }) { sticker in SharePreview( "Sticker Preview", image: sticker, icon: Image(systemName: "photo") ) } } } } .configureStickerGrid() } } }
-