ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Swift Concurrency - map & forEach
    Concurrency 2023. 3. 9. 07:51

    안녕하세요. 그린입니다🍏

    이번 포스팅에서는 map과 forEach에 대해 Swift Concurrency 세상으로 가져와 비동기 및 동시(병렬) 처리를 해보겠습니다🙌

     

    우리는 많은 데이터들이 들어올때 map과 forEach로 종종 다뤄주곤 합니다.

    그런데 해당 데이터들을 다뤄줄때 항상 비동기적으로 일어나지 않을뿐더라 해당 내부 작업들을 동시 처리를 하지 않습니다.

    즉 한 스레드에서 돌게되고 처리할것이 많아지면 다른 작업에 병목이 생길때가 있죠.

     

    그래서 이번 주제는 이런 데이터 변환을 수행할 때 Concurrency의 동시성 시스템을 활용해보려고 합니다!

     

    동기 변환

    우선 우리가 아주 익숙한 구조부터 볼께요.

    class MovieListViewController: UIViewController {
        private var movies: [Movie]
        private let favoritesManager: FavoritesManager
        private lazy var tableView = UITableView()
        ...
    
        func markSelectedMoviesAsFavorites() {
            tableView.indexPathsForSelectedRows?.forEach { indexPath in
                let movie = movies[indexPath.row]
                favoritesManager.markMovieAsFavorite(movie)
            }
        }
    }

    자 이렇게 VC에서 특정 영화를 즐겨찾기로 표시할 수 있는 코드가 있습니다.

    내부에서 forEach를 통해 사용자가 선택한 인덱스 경로를 반복하며 해당하는 영화를 즐겨찾기 표시하기 위해 메서드를 호출하죠.

     

    여기까지는 아주 익숙하죠?

     

    class FavoritesManager {
        private let database: Database
        private var favoriteIDs = Set<Movie.ID>()
        ...
        
        func loadFavorites() throws -> [Movie] {
            try favoriteIDs.map { id in
                try database.loadMovie(withID: id)
            }
        }
    
        func markMovieAsFavorite(_ movie: Movie) {
            ...
        }
    }

    위처럼 더 나아가서 FavoriteManager에서 이전에 즐겨찾기한 영화 모델을 로드할 수도 있습니다.

    해당 내부에서는 map을 사용해 DB에서 검색하고 영화 ID를 실제 모델로 변환해줍니다.

     

    여기까지도 아주아주 익숙한 그림입니다!

    이제 그럼 Concurrency를 이용해 위 작업들을 비동기적이고 실행 시 블록이 일어나지 않는 비차단적인 방식으로 변환시켜보죠🙌

     

    비동기화

    우선 우리가 늘 먹던 async/await를 통해 해당 작업을 아래처럼 비동기적으로 만들 수 있습니다.

    class FavoritesManager {
        ...
        
        func markMovieAsFavorite(_ movie: Movie) async {
            ...
        }
    
        func loadFavorites() async throws -> [Movie] {
            try await favoriteIDs.map { id in
                try await database.loadMovie(withID: id)
            }
        }
    }

    이렇게 말이죠!

    근데 이거 컴파일이 안됩니다ㅎㅎ...

    왜냐면 map은 기본 Swift에서 제공하는 기능인데 아쉽게도 아직 Swift 최신 버전에서도 map이 비동기 클로저를 지원하지 않기에 실제 컴파일이 되진 않습니다.

     

    그! 렇! 지! 만! 우리는 Swift의 아주 좋은 Extension이라는 기능을 사용해볼 수 있어요.

    Swift에서 일반적으로 다양한 컬렉션에서 수행하는 대부분의 작업인 forEach, map, flatMap과 같은 녀석들은 시퀀스 프로토콜을 확장하여 구현할 수 있습니다.

    시퀀스 프로토콜은 Array, Set, Dictionary와 같은 내장 컬렉션이 모두 준수하는 프로토콜입니다.

    또한, Swift의 루프 동작에서도 사용되는 프로토콜이기도 하죠!

     

    그렇기에 우리는 Sequence를 확장해볼께요🎉

     

    extension Sequence {
        func asyncMap<T>(
            _ transform: (Element) async throws -> T
        ) async rethrows -> [T] {
            var values = [T]()
    
            for element in self {
                try await values.append(transform(element))
            }
    
            return values
        }
    }

    자 요렇게 asyncMap을 만들어보죠.

    우선 transform이라는 파라미터에 비동기 클로저를 넣어줄 수 있도록 해봅니다.

    해당 transform 클로저 블록은 self 자기 자신을 가지고 for문을 돌며 실행하고 values에 추가합니다.

     

    여기서 해당 만들어진 asyncMap 메서드에 rethrows 키워드가 붙었어요!

    이건 컴파일러가 우리가 만든 이런 새로운 방법을 throws한걸로 취급하도록 지시합니다.

    이는 클로저가 throw하지 않을 때에도 우리가 항상 try로 새 메서드를 호출 할 필요 없이 필요할 때 오류를 던질 수 있는 유연성을 제공한다고 해요.

     

    자 다시 돌아가서, 위의 코드를 이용해서 사용해볼께요.

    class FavoritesManager {
        ...
    
        func loadFavorites() async throws -> [Movie] {
            try await favoriteIDs.asyncMap { id in
                try await database.loadMovie(withID: id)
            }
        }
    }

    이렇게 기존에는 await를 사용하지 못했던 map을 비동기적으로 사용할 수 있도록 변환했습니다.

    실제로 컴파일도 되구요😄

     

    그럼 다음으로 forEach도 보시죠!

     

    forEach를 그냥 두면 사용자가 선택한 영화를 즐겨찾기로 표시하는 동안 메인 스레드가 막히기에 UI적 다른 작업을 할 수 없습니다.

    이를 비동기 처리를 통해 바꿔볼께요.

    extension Sequence {
        func asyncForEach(
            _ operation: (Element) async throws -> Void
        ) async rethrows {
            for element in self {
                try await operation(element)
            }
        }
    }

    동일하게 시퀀스를 확장시켜 asyncForEach라는 새로운 메서드를 만듭니다.

    async 메서드이고 동일하게 내부에서 조건문을 돌며 실행받아온 비동기 클로저를 실행시킵니다.

     

    여기서 주의할 부분은 비동기로 forEach를 처리하기에 해당 작업이 진행되는 동안 영화 리스트의 값이 변경될 수도 있습니다.

    그럼으로 이러한 상황에서 충돌이 일어나지 않도록 해당 리스트를 캡쳐하여 아래와 같이 사용해야 합니다!

    class MovieListViewController: UIViewController {
        ...
    
        func markSelectedMoviesAsFavorites() {
            Task {
                await tableView.indexPathsForSelectedRows?.asyncForEach { 
                    [movies] indexPath in
    
                    let movie = movies[indexPath.row]
                    await favoritesManager.markMovieAsFavorite(movie)
                }
            }
        }
    }

    자 이렇게 우리는 map과 forEach를 비동기적으로 동작시키면서 UI를 막지 않는 구현을 완성했습니다!

     

    그런데 비동기적인것도 좋은데 우리는 한단계 더 나아가서 이걸 동시적으로 풀어볼까해요🕺🏻

     

    동시성

    위 작업들로 비동기는 다 좋았어요.

    그렇지만 작업의 속도를 높여주진 않는건 사실입니다.

    왜냐? 순차적으로 진행되다보니 그렇죠.

    그렇기에 이 작업들을 병렬로 실행하도록 구현해봐야합니다.

    extension Sequence {
        func concurrentForEach(
            _ operation: @escaping (Element) async -> Void
        ) async {
            // A task group automatically waits for all of its
            // sub-tasks to complete, while also performing those
            // tasks in parallel:
            await withTaskGroup(of: Void.self) { group in
                for element in self {
                    group.addTask {
                        await operation(element)
                    }
                }
            }
        }
    }

    저번 포스팅에서 봤던 Task 그룹을 통한 Parallel 처리가 보이네요!

    아까 만든 asyncForEach를 withTaskGroup으로 감싸주고 내부에서 addTask로 그룹에 Task를 넣어줌으로

    각 operation이 병렬적으로 일을 처리할 수 있도록 구현할 수 있습니다.

    class MovieListViewController: UIViewController {
        ...
    
        func markSelectedMoviesAsFavorites() {
            Task {
                await tableView.indexPathsForSelectedRows?.concurrentForEach {
                    [movies, favoritesManager] indexPath in
    
                    let movie = movies[indexPath.row]
                    await favoritesManager.markMovieAsFavorite(movie)
                }
            }
        }
    }

    이젠 요렇게 forEach를 돌려주면서 비동기적이고도 병렬적으로 빠른 작업을 해줄 수 있습니다.

    여기서 캡쳐리스트로 movies와 favoriteManager만을 담고 있는 이유는 전부 self로 캡쳐해 가져오기보다 작업 수행에 필요한 실제 개체들만 캡쳐하는것이 좋습니다.

     

    자 그럼 이어서 map도 그렇게 처리해볼까요?

     

    map은 forEach처럼 TaskGroup을 사용하여 처리할 수 없습니다.

    왜냐하면 map은 데이터를 가지고 새로운 데이터로 변환하고 그 순서를 지켜 반환해야 합니다.

    그런데 TaskGroup을 사용한다면 내부 그룹에서 Task들이 완전한 병렬이고 반환되기에 어떤것이 먼저 반환되야 하는지 컨트롤 할 수 없습니다.

    즉, 완료 순서 측면에서 어떠한 보장도 할 수 없고 입력과는 다른 출력 순서를 내뱉을거에요ㅠㅠ

     

    그렇기에 아래처럼 해볼 수 있습니다.

    extension Sequence {
        func concurrentMap<T>(
            _ transform: @escaping (Element) async throws -> T
        ) async throws -> [T] {
            let tasks = map { element in
                Task { 
                    try await transform(element)
                }
            }
    
            return try await tasks.asyncMap { task in
                try await task.value
            }
        }
    }

    concurrentMap을 만들어보죠!

    TaskGroup을 사용하면 안되니 각 요소에 대해 비동기 작업을 생성하는것을 시작한 다음 이전의 비동기 map을 만든 asyncMap 메서드를 통해 비동기적으로 돌리고 결과를 순서대로 기다립니다.

    물론 결과를 순서대로 기다리는 시간은 있지만 해당 작업들을 비동기로 그리고 또 병렬적으로 처리했기에 기존 순차적인 처리보단 속도가 훨씬 빠를거라 생각합니다.

     

    class FavoritesManager {
        ...
    
        func loadFavorites() async throws -> [Movie] {
            try await favoriteIDs.concurrentMap { [database] id in
                try await database.loadMovie(withID: id)
            }
        }
    }

    만들어진 concurrentMap은 이제 요렇게 사용할 수 있겠죠?

     

    결론

    compatMap이나 flatMap도 이런 구현을 통해 해보시길 추천드립니다👍

     

    마무리

    비동기적이고 병렬적으로 처리하면서 성능의 이점을 고려하는 extension 구현을 사실 크게 생각을 못했었는데 많은 귀감이 되었습니다.

    사실 map을 병렬적으로 처리한다는 것이 정말 감이 안왔는데 어느정도 루트를 잡아줬던 학습이였습니다🎉

     

    [참고 자료]

    https://www.swiftbysundell.com/articles/async-and-concurrent-forEach-and-map/

     

    Building async and concurrent versions of forEach and map | Swift by Sundell

    If we think about it, so much of the code that we write on a daily basis essentially consists of a series of data transformations. We take data in one shape or form — whether that’s actual model data, network responses, or things like user input or oth

    www.swiftbysundell.com

Designed by Tistory.