본문 바로가기

Swift/개인프로젝트

[Swift] Petpion - 과연 Swift Concurrency를 제대로 활용하고 있는가?

 

안녕하세요! 제 두 번째 개인 프로젝트 Petpion이 2023년 5월 27일 앱스토어에 출시가 되었습니다.

Petpion은 제가 1부터 100까지 혼자 힘으로 개발한 앱입니다. 그래서 더욱 다양한 새로운 기술들을 시도해 본 앱 이기도 한데 그중 오늘은 Concurrency에 대하여 다루어 보려 합니다.

 

WWDC 2015 - Building Responsive and Efficient Apps with GCD

WWDC 2016 - Concurrent Programming With GCD in Swift 3

WWDC 2017 - Modernizing Grand Central Dispatch Usage

WWDC 2022 - Eliminate data races using Swift Concurrency

 

2015년부터 WWDC에서 발표된 동시성 관련된 세션들을 따라오다 보면 GCD부터 Concurrency까지 애플에서 동시성 프로그래밍을 작성하며 가장 중요하게 생각하는 부분은 공통적으로 ‘데이터 간 경쟁 조건(race condition)등의 동시성 문제를 방지하며 가장 효율적인 동시성 코드를 작성하는 방법’ 입니다.

 

가장 최근에 나온 Concurrency는 위에서 말한 ‘데이터 간 경쟁 조건(race condition)등의 동시성 문제를 방지’ 하는데에 가장 장점이 많은 API입니다.

잘 알려진 장점으로는 async/await을 통한 간결하고 읽기 쉬운 선언형 비동기 코드의 작성, Task와 Task Group을 통하여 작업을 조직화하여 작업의 시작과 종료를 명확하게 추적할 수 있습니다.

또한 Actor모델을 통하여 Actor내부의 상태에 동시에 액세스 되는 상황을 방지하여 공유 데이터에 대한 안전한 동시성 접근을 보장합니다.

 

그렇다면 위에서 말한 애플에서 동시성 프로그래밍을 작성하며 가장 중요하게 생각하는 부분 중 나머지 ‘효율적인 동시성 코드를 작성하는 방법’ 은 어떻게 이룰 수 있을까요?

이 방법을 Concurrency Instruments를 활용하여 앱의 동시성 부분을 최적화하는 방법에 대해서 자세히 기술해 보겠습니다.

Concurrency Instruments는 Xcode의 개발자 도구 중 하나로, 앱 내의 동시성 작업을 분석하고 최적화할 수 있는 강력한 도구입니다. 이를 통해 작업의 수행 시간, 병렬 실행, 작업 그룹화 등을 측정하고 분석하여 앱의 성능을 향상하는 데 도움을 줍니다.

 

 

먼저 Concurrency Instruments에서 동시성 작업의 최적화를 위하여 사용할 지표(항목)들을 소개하겠습니다.

 

  • Total Tasks: 현재 앱에서 생성된 총 Task의 수를 보여줍니다. 이 값은 메모리 사용량과 관련이 있을 수 있으며, 너무 많은 작업이 생성되면 시스템 리소스를 과도하게 사용할 수 있습니다.
  • Alive Tasks: 현재 실행 중인 Task의 수를 보여줍니다. 실행 중인 작업은 메모리에 계속해서 유지되므로, 메모리 사용량과 관련이 있습니다. 만약 이 값이 지속적으로 늘어난다면, 작업이 적절하게 완료되지 않고 메모리에 남아있을 수 있는 문제가 있을 수 있습니다.
  • Running Tasks: 현재 실행 중인 Task의 세부 정보를 보여줍니다. 실행 중인 작업의 상태, 실행 시간 등을 확인할 수 있습니다. 이 정보를 통해 작업의 실행 상태를 분석하고, 병목 현상이 있는지 확인할 수 있습니다.
  • Task Lifetime: Task의 수명 주기에 대한 정보를 보여줍니다. 작업이 생성되고 완료될 때까지의 시간을 측정하고, 작업의 수명 주기를 시각적으로 표시합니다. 이를 통해 작업이 적절하게 생성되고 완료되는지 확인할 수 있습니다.
  • Task States: Task의 상태에 대한 정보를 보여줍니다. 작업이 생성될 때부터 완료될 때까지의 상태 변화를 시각적으로 표시합니다. 이를 통해 작업이 어떻게 진행되고 있는지, 어떤 상태 변화가 있는지 확인할 수 있습니다.
  • Task forest: Task의 계층 구조를 나타냅니다. 작업이 서로 어떻게 관련되어 있는지, 어떤 작업이 다른 작업을 생성하는지 등을 시각적으로 표현합니다. 이를 통해 작업 간의 관계를 파악하고, 작업의 구조를 최적화하는 데 도움을 줍니다.
  • Task Creation Calltree: Task 생성에 대한 호출 트리(Call tree)를 보여줍니다. 작업이 생성되는 호출 경로를 시각적으로 표시하여 어떤 코드 블록에서 작업이 생성되는지 확인할 수 있습니다. 이를 통해 작업 생성의 원인을 파악하고, 작업 생성에 대한 최적화와 개선을 진행할 수 있습니다. 호출 트리를 분석하여 불필요한 작업 생성이나 중복 생성을 방지하고, 작업의 구조와 관련된 문제를 해결할 수 있습니다.

 

위 Instruments의 기록은 현재 출시된 버전의 Petpion에서 가장 핵심 도메인 부분을 실험하기 위하여 투표하기 탭을 확인했습니다.

 

 

메인 화면(왕관버튼 클릭) → 투표하기 화면(트로피버튼 클릭) → 명예의 전당 화면 → 다시 투표하기 화면 → 다시 메인 화면

까지의 기록입니다.

 

정말 간단한 행동임에도 Total Tasks를 살펴보면 총 1634개의 생각보다 방대한 양의 Task가 생성된 것을 확인할 수 있습니다.

이 Task들이 어디서 생성됐는지 확인해 보기 위하여 Task Creation Calltree탭을 확인해 보겠습니다.

 

 

1634개의 Task 중 libswift_Concurrency.dylib (Swift Concurrency) 라이브러리에서 1612개의 Task가 생성된 것을 확인할 수 있습니다.

오른쪽 탭에 Heaviest Stack Trace를 통해서 가장 무거운(가장 많은 시간을 소비한) 코드 실행 경로의 스택 호출 프레임들을 확인할 수 있습니다.

그중 비중이 가장 많은 Task 콜 스택을 확인해 봤습니다.

결국 Task Creation Calltree에서 정확히 몇 개의 작업 생성이 어느 곳에서 실행되었고 그중 가장 많은 작업이 어느 곳에서 생성되었는지도 확인하였습니다.

 

사실 이는 Swift Tasks 그래프에서도 어느 정도 짐작할 수 있었습니다.

 

위 사진에서 7초 ~ 16초 구간과 28초 ~ 33초 구간의 Alive Tasks 그래프가 두꺼운 것을 확인할 수 있습니다.

당연히 Total Tasks의 그래프 또한 이때 증가폭이 가장 큰 것을 확인할 수 있습니다.

즉 이 구간에서 Task의 과도한 부하가 생성되었다고 판단할 수 있습니다.

 

이중 7초 ~ 16초 구간을 자세히 확대하여 보겠습니다.

 

보기 쉽게 20개의 구간으로 나눠 첫 번째 구간을 확인해 보겠습니다.

 

첫 번째 구간에서 63개의 Task가 실행 중인걸 확인할 수 있습니다. 하단에 Task Lifetime의 요약을 확인하면 VoteMainViewModel.startFetchingVotePareArray() 가 3번 실행되었고DefaultFirestoreRepository.fetchRandomFeedArrayWithLimit(to:) 가 나머지 60번 실행된 것을 확인할 수 있습니다. (VoteMainViewModel.startFetchingVotePareArray() 는 구간마다 1번만 실행되어야 하는데 코드를 잘못 짜 3번씩 호출되고 있는 상황입니다)

 

 

이를 Task Forest 탭에서 Task의 계층 구조를 더 쉽게 파악할 수 있습니다.

이 계층 구조를 통해서 VoteMainViewModel.startFetchingVotePareArray() 가 호출되면 DefaultFirestoreRepository.fetchRandomFeedArrayWithLimit(to:) 가 병렬로 20번 호출된다는 것을 알 수 있습니다.

 

지금까지의 Task Lifetime과 Task Forest에서 확인된 메서드명을 통해 위 구간은 투표하기 화면에서 투표 목록을 구성할 때 서버와 통신을 하는 구간에서 발생한 Task들임을 대략적으로 예상할 수 있습니다.

 

 

 

즉 이 화면에서 발생한 Task입니다.

투표하기 화면의 기본적인 로직을 설명하자면 투표할 펫의 데이터 배열을 준비하기 위해서 서버에서 병렬로 20개의 사용자들이 올린 펫피드의 데이터를 랜덤으로 가져와 데이터를 가공하여 10개의 쌍으로 만들어 투표를 준비하는 방식입니다.

 

또한 위 메서드를 반복적으로 20번 실행하는 것은 (구간 1~20) 이는 구간 1에서 20개의 데이터를 다 불러오더라도 중복된 피드가 있을 시 이를 제거하기 때문에 중복으로 제거된 개수만큼 구간 2에서 다시 피드를 불러오는 방식으로 최대 20번을 반복할 수 있도록 설계되어 있기 때문입니다.

 

 

 

이제 문제점이 하나씩 보이기 시작합니다.

  1. Alive Tasks에서 7초~16초 구간들은 모두 VoteMainViewModel.startFetchingVotePareArray() 가 3번 실행되었습니다. 한 번만 실행되어야 할 메서드인데 추가로 호출되어 20개의 구간마다 대략적으로 불필요한 startFetchingVotePareArray 호출과 이에 따른 20개의 병렬 Task 호출이 두 번씩 총 42개의 Task들이 생성되었습니다. (뒤에 28초 ~ 33초 구간은 정상적으로 1번씩 호출되고 있습니다)
  2. 위 동영상과 같이 투표를 하기 위한 목적이 아니라 명예의 전당을 확인하고 싶을 때도 구조상 투표하기 화면을 거쳐야 하는데 이때마다 투표하기 화면이 넘어갔음에도 호출된 VoteMainViewModel.startFetchingVotePareArray() 을 무조건 다 실행하고 완료까지 하는 상태입니다.
  3. 또한 동영상의 메인화면에서 확인할 수 있듯이 해당 달에 올린 피드가 2개 미만일 경우, 투표할 펫의 쌍을 맞출 수 없기 때문에 구간 1에서 확인 후 반복하지 않아도 되는 상황인데도 구간 20까지 반복하고 있습니다.

 

우선 1번 문제를 해결하기 위하여 VoteMainViewModel.startFetchingVotePareArray() 가 호출되는 부분을 확인해봐야 합니다. 코드를 살펴보겠습니다.

 

// ViewController
final class VoteMainViewController: HasCoordinatorViewController {
			...
	override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        self.navigationController?.navigationBar.tintColor = .white
        self.view.backgroundColor = .petpionIndigo
        self.navigationItem.leftItemsSupplementBackButton = true
        self.navigationItem.leftBarButtonItems = [trophyBarButton]
        viewModel.viewWillAppear()
    }

			...

	private func bindVoteMainStateSubject() {
        viewModel.voteMainViewControllerStateSubject.sink { [weak self] viewControllerState in
            switch viewControllerState {
            case .preparing:
                self?.configurePreparing()
            case .ready:
                self?.configureReady()
            case .start:
                self?.configureStart()
            case .disable:
                self?.configureDisable()
            case .noneVotePare:
                self?.configureNoneVotePare()
            }
        }.store(in: &cancellables)
    }
    
    private func configurePreparing() {
        catLoadingView.isHidden = false
        catLoadingView.play()
        mainCommentLabel.text = "펫들을 부르는 중이에요!"
        startVoteButton.backgroundColor = .lightGray
        startVoteButton.stopAnimating()
        startVoteButton.isEnabled = false
        appearCatView.stop()
        appearCatView.isHidden = true
        sleepingCatView.stop()
        sleepingCatView.isHidden = true
        startVoteLabel.isHidden = true
        viewModel.startFetchingVotePareArray()
    }

			...
}

// ViewModel
final class VoteMainViewModel: VoteMainViewModelProtocol {

	lazy var voteMainViewControllerStateSubject: CurrentValueSubject<VoteMainViewControllerState, Never> = .init(getCurrentState(with: user.voteChanceCount))

			...

	func viewWillAppear() {
        viewWillDisappeared = false
        voteMainViewControllerStateSubject.send(getCurrentState(with: user.voteChanceCount))
    }
			...
}

 

VoteMainViewController 는 위에서 보인 화면 사진의 뷰컨입니다.

이는 bindVoteMainStateSubject() 메서드에서 확인할 수 있듯이 뷰 모델의 voteMainViewControllerStateSubject 라는 스트림을 바인딩하고 있는 상태이고 해당 스트림이 .preparing 상태일 때 configurePreparing() 에서viewModel.startFetchingVotePareArray() 을 호출합니다.

 

그리고 뷰컨의 라이프사이클에 맞춰 뷰가 보일 때 뷰모델에서 voteMainViewControllerStateSubject.send(getCurrentState(with: user.voteChanceCount)) 메서드를 통하여 voteMainViewControllerStateSubject에 유저의 투표기회가 있다면 .preparing, 없다면 .disable 을 send 해줍니다.

 

그렇다면 이것 외에 왜 2번이나 추가로 뷰 모델의 voteMainViewControllerStateSubject 스트림에 .preparing이 send 되고 있는지 확인해 보겠습니다.

 

첫 번째 문제는 위 코드에도 나와있습니다. CurrentValueSubject 타입은 현재 값을 가지고 있는 Subject인데 이를 lazy variables로 설정했습니다.

그러므로 프로퍼티가 초기화될 때 설정된 초기값을 값을 한번 send 하게 됩니다.

그리고 viewWillAppear에서 추가로 send 하게 되는 것입니다.

이를 방지하기 위하여 CurrentValueSubject의 타입을 PassthroughSubject로 변경해 주었습니다.

 

두 번째 문제는 뷰 모델에 init() 부분에서도 투표 기회와 충전 시간 표시를 위해서 서버와 유저 정보를 실시간 동기화 해줘야 하는 메서드가 있는데 이 부분에서도 추가로 voteMainViewControllerStateSubject에 send 하는 것을 확인했습니다.

final class VoteMainViewModel: VoteMainViewModelProtocol {
	
	init(calculateVoteChanceUseCase: CalculateVoteChanceUseCase,
         makeVoteListUseCase: MakeVoteListUseCase,
         fetchFeedUseCase: FetchFeedUseCase,
         fetchUserUseCase: FetchUserUseCase,
         uploadUserUseCase: UploadUserUseCase,
         makeNotificationUseCase: MakeNotificationUseCase,
         user: User) {
        self.calculateVoteChanceUseCase = calculateVoteChanceUseCase
        self.makeVoteListUseCase = makeVoteListUseCase
        self.fetchFeedUseCase = fetchFeedUseCase
        self.fetchUserUseCase = fetchUserUseCase
        self.uploadUserUseCase = uploadUserUseCase
        self.makeNotificationUseCase = makeNotificationUseCase
        self.user = user
        requestNotification()
        synchronizeWithServer()
    }

				...

	public func synchronizeWithServer() {
        fetchUserUseCase.bindUser { [weak self] user in
            guard let chanceRemainingTime = self?.calculateVoteChanceUseCase.getRemainingTimeIntervalToCreateVoteChance(latestVoteTime: user.latestVoteTime) else { return }
            self?.user = user
            self?.sendHeart(voteChance: user.voteChanceCount)
            self?.sendRemainingTime(voteChance: user.voteChanceCount,
                                    timeInterval: chanceRemainingTime)
            
            // 문제지점 - 제거
            if self?.viewWillDisappeared == false {
                self?.sendMainState(voteChance: user.voteChanceCount)
            }

        }
    }
}

 

그리고 다시 같은 테스트를 Instruments를 통하여 확인해 보았습니다.

 

 

예상했던 대로 문제점 1에서 언급했던 구간마다 42번의 추가적인 Task생성이 없어졌습니다.

Total Tasks가 822개로 측정되었고 구간 20개 * 42 = 840 이므로 대략 처음 테스트의 Total Tasks 1634개에서 840을 뺀 숫자와 얼추 비슷한 결과입니다.

또한 첫 번째 구간과 두 번째 구간의 Alive Tasks 그래프의 높이도 같아진 것을 확인할 수 있습니다.

 

 

이제 두 번째 문제인 화면이 넘어갔을 때 진행 중인 Task를 취소하는 것을 구현해 보겠습니다.

final class VoteMainViewModel: VoteMainViewModelProtocol {
			
	let makeVoteListUseCase: MakeVoteListUseCase

			...

	func startFetchingVotePareArray() {
        Task.detached(priority: .userInitiated) { [weak self] in
            guard let self else { return }
            
            let petpionVotePareArr = await makeVoteListUseCase.fetchVoteList(pare: 10)
            
            fetchedVotePare = await prefetchAllPareDetailImage(origin: petpionVotePareArr)
            
            await MainActor.run { [fetchedVotePare] in
                if fetchedVotePare.isEmpty {
                    self.voteMainViewControllerStateSubject.send(.noneVotePare)
                } else {
                    self.voteMainViewControllerStateSubject.send(.ready)
                }
            }
        }
    }

}

 

20개의 병렬 DefaultFirestoreRepository.fetchRandomFeedArrayWithLimit(to:) 의 호출부인 startFetchingVotePareArray() 메서드 입니다.

 

이 부분을 뷰 모델에서 detached Task에 대한 인스턴스를 생성하고 이를 VoteMainViewController의 뷰컨의 라이프사이클에 따라 cancel 메소드를 통하여 관리하도록 변경해 보겠습니다.

 

// ViewController
final class VoteMainViewController: HasCoordinatorViewController {

	private let viewModel: VoteMainViewModelProtocol

			override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        viewModel.viewWillDisappear()
    }

			...
}


// ViewModel
final class VoteMainViewModel: VoteMainViewModelProtocol {
			
	let makeVoteListUseCase: MakeVoteListUseCase

	private var fetchingVotePareTask: Task<Void, Never>?

			...

	func viewWillDisappear() {
        fetchingVotePareTask?.cancel()
    }

	func startFetchingVotePareArray() {
        fetchingVotePareTask = Task.detached(priority: .userInitiated) { [weak self] in
            guard let self else { return }
            
            let petpionVotePareArr = await makeVoteListUseCase.fetchVoteList(pare: 10)
            
            fetchedVotePare = await prefetchAllPareDetailImage(origin: petpionVotePareArr)
            
            await MainActor.run { [fetchedVotePare] in
                if fetchedVotePare.isEmpty {
                    self.voteMainViewControllerStateSubject.send(.noneVotePare)
                } else {
                    self.voteMainViewControllerStateSubject.send(.ready)
                }
            }
        }
    }

}

하지만 이렇게 해도 결과는 이전과 같습니다.

 

간단한 코드의 흐름은 뷰모델 → 유스케이스(도메인로직) → 레포지토리(서버통신)로 향하는데 이때 레포지토리에서 병렬 실행을 위하여 TaskGroup을 생성하여 20개의 병렬 실행을 진행합니다.

 

이때 Task Group(in 레포지토리)은 부모 detached Task(in 뷰모델)의 취소 여부에 영향을 받지 않는 독립적인 실행 단위이기 때문에 Task Group 또한 자체적으로 cancel 메서드를 호출해주어야 하기 때문입니다.

 

 

따라서 레포지토리에서 TaskGroup의 취소를 추가로 호출해 주기 위해서 뷰모델의 부모 Task를 파라미터로 레포지토리의 DefaultFirestoreRepository.fetchRandomFeedArrayWithLimit(to:) 까지 전달하도록 하였습니다.

그리고 TaskGroup 또한 해당 레포지토리에 인스턴스를 생성하고 부모 Task의 내장 메서드인 isCancelled 를 확인하여 TaskGroup의 취소 또한 관리하도록 변경하였습니다.

// ViewModel - Presentation Layer
final class VoteMainViewModel: VoteMainViewModelProtocol {

	private var fetchingVotePareTask: Task<Void, Never>?

			...
   
	func startFetchingVotePareArray() {
        fetchingVotePareTask = Task.detached(priority: .userInitiated) { [weak self] in
            guard let self else { return }

            // 변경부분
            let petpionVotePareArr = await makeVoteListUseCase.fetchVoteList(pare: 10, parentsTask: fetchingVotePareTask!)
            
            fetchedVotePare = await prefetchAllPareDetailImage(origin: petpionVotePareArr)
            
            await MainActor.run { [fetchedVotePare] in
                if fetchedVotePare.isEmpty {
                    self.voteMainViewControllerStateSubject.send(.noneVotePare)
                } else {
                    self.voteMainViewControllerStateSubject.send(.ready)
                }
            }
        }
    }
}


// UseCase - Domain Layer
public final class DefaultMakeVoteListUseCase: MakeVoteListUseCase {

			...

	public func fetchVoteList(pare: Int, parentsTask: Task<Void, Never>) async -> [PetpionVotePare] {

		// 변경부분
        let randomFeedArr = await fetchRandomFeedArray(to: pare*2,
                                                       parentsTask: parentsTask)
        let randomFeedArrWithThumbnail = await addThumbnailImageURL(feeds: randomFeedArr)
        return makePetpionVotePare(with: randomFeedArrWithThumbnail.shuffled())
    }

	private func fetchRandomFeedArray(to count: Int,
                                      parentsTask: Task<Void, Never>) async -> [PetpionFeed] {

	// 변경부분
        let randomFeeds = await firestoreRepository.fetchRandomFeedArrayWithLimit(to: count, parentsTask: parentsTask)

        var removeDuplicateRandomFeeds = removeDuplicate(with: randomFeeds)
        
        var roopRunCount = 1
        
        while true {
            if roopRunCount == 20 {
                break
            }
            
            let neededFeedCount = count - removeDuplicateRandomFeeds.count
            if neededFeedCount == 0 {
                break
            }
			// 변경부분
            let neededFeeds = await firestoreRepository.fetchRandomFeedArrayWithLimit(to: neededFeedCount, parentsTask: parentsTask)

            removeDuplicateRandomFeeds = removeDuplicate(with: removeDuplicateRandomFeeds + neededFeeds)
            roopRunCount += 1
        }
        return removeDuplicateRandomFeeds
    }

}


// Repository - Data Layer
public final class DefaultFirestoreRepository: FirestoreRepository {

	private var fetchingVotePareTaskGroup: TaskGroup<[PetpionFeed]>?

			public func fetchRandomFeedArrayWithLimit(to count: Int, parentsTask: Task<Void, Never>) async -> [PetpionFeed] {
        return await withTaskGroup(of: [PetpionFeed].self) { taskGroup in
            fetchingVotePareTaskGroup = taskGroup
            
            for _ in 0 ..< count {

			// 뷰 모델의 detached Task의 취소 여부 확인 후 취소됐을 시 TaskGroup을 취소
                if parentsTask.isCancelled {
                    fetchingVotePareTaskGroup?.cancelAll()
                    break
                }
                
                taskGroup.addTask {
                    let singleCollection = await self.fetchSingleRandomFeedCollection()
                    return self.convertCollectionToModel(singleCollection)
                }
            }
            var resultArr: [PetpionFeed] = []
            for await feed in taskGroup {
                if feed.count == 1 {
                    resultArr.append(feed[0])
                }
            }
            return resultArr
        }
    }

		...
}

 

이후 다시 Instruments 테스트를 통하여 확인하여 보면 차이점이 다시 확인됩니다.

다시 Tatal Tasks의 개수를 확인해 보면 321개의 Task가 생성된 것을 확인할 수 있습니다.

 

 

투표하기 화면이 나타났을 때의 구간을 자세히 보면 첫 번째에는 8번 반복하고 병렬 작업이 취소 되었고, 두 번째 구간에서는 6번 반복하고 취소되었습니다.

 

 

 

마지막으로 세 번째 문제였던 해당 달에 올린 피드가 2개 미만일 경우, 투표할 펫의 쌍을 맞출 수 없기 때문에 구간 1에서 확인 후 반복하지 않도록 변경해 보겠습니다.

 

public final class DefaultMakeVoteListUseCase: MakeVoteListUseCase {

	private func fetchRandomFeedArray(to count: Int,
                                      parentsTask: Task<Void, Never>) async -> [PetpionFeed] {
        let randomFeeds = await firestoreRepository.fetchRandomFeedArrayWithLimit(to: count, parentsTask: parentsTask)
        var removeDuplicateRandomFeeds = removeDuplicate(with: randomFeeds)
        
		// 추가된 부분
        if removeDuplicateRandomFeeds.count < 2 {
            return []
        }
        
        var roopRunCount = 1
        
        while true {
            if roopRunCount == 20 {
                break
            }
            
            let neededFeedCount = count - removeDuplicateRandomFeeds.count
            if neededFeedCount == 0 {
                break
            }
            let neededFeeds = await firestoreRepository.fetchRandomFeedArrayWithLimit(to: neededFeedCount, parentsTask: parentsTask)
            removeDuplicateRandomFeeds = removeDuplicate(with: removeDuplicateRandomFeeds + neededFeeds)
            roopRunCount += 1
        }
        return removeDuplicateRandomFeeds
    }

}

 

fetchRandomFeedArray 메서드에서 처음 불러온 중복이 제거된 랜덤 피드의 개수가 2개 미만일 경우, 이후 추가 반복을 하지 않고 빈 배열을 return 하도록 변경하였습니다.

 

그리고 다시 Instruments를 통해 다시 테스트했을 때는 예상대로 구간이 하나씩만 생성된 것을 확인할 수 있습니다.

또한 Total Tasks 도 98개가 생성되었습니다.

 

 

하지만 이 경우는 메인화면에 피드가 아예 없거나 하나뿐인 경우만 실행되기 때문에 피드가 2개 이상부터는 다시 반복문이 실행됩니다.

 

결론

지금까지 Concurrency Instruments를 통하여 Alive Tasks와 Total Tasks를 확인해 Task의 생성 개수를 1634개에서 98개로 최소화 하였습니다.

 

이를 통해 확인할 수 있었던 이점은 메모리 사용량이 확실하게 줄어든 점이었습니다.

또한 Task의 생성개수가 줄어들게 되면서 스레드의 관리 부담이 감소하고, 앱의 리소스 사용이 효율적으로 이루어질 수 있었습니다.

그 외에도 동시성 관리가 더욱 용이해지고, 동시에 실행되는 작업들 간의 경합과 충돌 가능성의 감소를 기대할 수 있을 것입니다.

 

 

느낀 점

사실 개인 프로젝트 개발 막바지에 출시에 급급해 신경 쓰지 못했지만 어렴풋이 감지는 하고 있었던 문제점들을 이번 Concurrency Instruments라는 훌륭한 도구와 함께 수치상으로 정확히 확인하며 개선해 나가는 과정에서 ‘Swift가 밀고 있는 Concurrency라는 비동기 지원 툴을 어떻게 효율적으로 사용할 수 있을까?’ 에 대한 근본적인 의문점이 해소되어 가장 만족스러웠습니다.

그리고 앞으로 에 개발에 있어 효율적이고 합리적인 프로그래밍에 대해 더 큰 확신을 가질 수 있었던 경험이었습니다.

 

 

 

Petpion 앱 링크

https://apps.apple.com/kr/app/%ED%8E%AB%ED%94%BC%EC%96%B8/id6446166435

 

‎펫피언

‎* 공유해요 자신의 사랑스러운 반려동물 사진을 올리고 다른 사용자들과 공유해요. 당신의 사랑스러운 반려동물을 세상에 자랑하고, 그들의 매력과 인기를 다른 사용자들과 공유할 수 있어요

apps.apple.com

 

Petpion Github
https://github.com/kswen23/Petpion

 

GitHub - kswen23/Petpion

Contribute to kswen23/Petpion development by creating an account on GitHub.

github.com

 

 

참고자료

https://developer.apple.com/videos/play/wwdc2022/110350

 

Visualize and optimize Swift concurrency - WWDC22 - Videos - Apple Developer

Learn how you can optimize your app with the Swift Concurrency template in Instruments. We'll discuss common performance issues and show...

developer.apple.com