메이트 리스트 이미지 비동기 처리하기 ‐ 허혜민 - Team-HGD/SniffMEET GitHub Wiki

기존의 코드 분석

현재 메이트 리스트는 사진이 리스트의 순서대로 불러오는 것처럼 보입니다.

1. 메인스레드에서 동작할 필요 없는 코드

첫번째 문제점은 메인 스레드에서 처리할 필요가 없는 코드가 메인 스레드에서 실행되고 있다는 점입니다.

이는 불필요한 Context Switching 비용으로 이어질 수 있습니다.

image

이런 현상이 발생하는 이유는 Interactor에 정의한 requestProfileImage(id: imageName:) 이 MainActor Context에서 실행 중이기 때문입니다.

Task 내 정의된 클로저는 MainActor Context로 진입하기 때문에 메인 스레드에서 시작하고 await 키워드를 만나면 다른 스레드로 변경됩니다.

await에서 빠져나오면 다시 메인 스레드에서 실행됩니다.

func requestProfileImage(id: UUID, imageName: String?) {
	 Task { @MainActor in
	 // MainActor Context 
	 // 메인 스레드에서 시작 
	 // 다른 스레드로 변경 
       let imageData = try await requestProfileImageUseCase.execute(fileName: imageName ?? "")
	 // 메인 스레드에서 다시 실행      
       presenter?.didFetchProfileImage(id: id, imageData: imageData)
    }
}
  1. 계단식 요청 (이미지 요청 시점의 문제)

image

image

기존 코드의 Task State를 살펴보면 마치 계단처럼 이미지 요청을 처리하고 있는 모습을 발견할 수 있습니다.

이런 현상이 발생하는 이유는 요청 시점의 문제입니다. 프로필 이미지를 요청하는 부분은 각각 개별의 Task 블록을 갖기 때문에 동시에 실행됩니다. 하지만 요청을 보내는 시점이 Cell이 로드되는 시점이기에 사용자는 이미지가 없는 셀을 먼저 보고 일정 시간이 지나야 이미지를 볼 수 있습니다.

// View

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // ,,, 중략 
        if let imageData = imageDataSource[mate.userID] {
            
        } else {
        // cell이 처음 로드된 시점에 이미지 fetch를 요청 
            presenter?.didTableViewCellLoad(
                mateID: mate.userID,
                imageName: mate.profileImageURLString
            )
}

// Presenter 

func didTableViewCellLoad(mateID: UUID, imageName: String?) {
    interactor?.requestProfileImage(id: mateID, imageName: imageName)
}

// Interactor

func requestProfileImage(id: UUID, imageName: String?) {
	 Task { @MainActor in
       let imageData = try await requestProfileImageUseCase.execute(fileName: imageName ?? "")
       presenter?.didFetchProfileImage(id: id, imageData: imageData)
    }
}

개선 작업

1. Interactor에서 MainActor 제거

불필요한 메인 스레드 동작은 @MainActor 를 제거해주면 간단하게 해결할 수 있습니다.

func requestProfileImage(id: UUID, imageName: String?) {
	 Task {  
       let imageData = try await requestProfileImageUseCase.execute(fileName: imageName ?? "")   
       presenter?.didFetchProfileImage(id: id, imageData: imageData)
    }
}

image

image

이미지 요청 처리 부분이 메인 스레드 대신 다른 스레드에서 시작하고 종료되는 것을 확인할 수 있었습니다.

2-1. 이미지 요청 시점 변경

이미지를 요청하는 시점을 리스트의 셀이 로드되는 시점이 아니라 메이트 리스트 정보를 받아온 시점으로 변경하기로 했습니다.

// Presenter

// 메이트 리스트 정보를 받아오면 호출되는 메서드 
func didFetchMateList(mateList: [Mate]) {
        output.mates.send(mateList)
        // 메이트의 프로필 이미지를 요청 
        mateList.forEach { mate in
            guard let imageName = mate.profileImageURLString else { return }
            interactor?.requestProfileImage(id: mate.userID, imageName: imageName)
    }
}

2-2. CPU 사용량 문제

이미지를 불러오는 속도는 훨씬 빨라졌지만 또 다른 문제가 생겼습니다.

요청 시점을 변경하고 CPU 사용량이 급증하는 경우가 빈번해졌습니다. CPU 사용량이 급증할 때마다 Hang도 함께 발생했습니다.

이런 현상이 발생하는 이유는 이미지 요청을 실행할 때마다(requestProfileImage) 각각의 Task 블록을 통해 서로 다른 비동기 함수로 생성되기 때문입니다.

image

image

image

// Interactor

// 요청 할 때마다 새로운 Task 블록이 생성 
func requestProfileImage(id: UUID, imageName: String?) {
	 Task {  
       let imageData = try await requestProfileImageUseCase.execute(fileName: imageName ?? "")   
       presenter?.didFetchProfileImage(id: id, imageData: imageData)
    }
}

현재는 40개의 메이트 정보를 불러오고 있는 상황입니다. 만약 불러오는 메이트의 수가 더 많아지면 CPU에 가해지는 부하는 더 심각해질 것입니다.

2-3. 한번에 요청하는 이미지 수 제한

40개 가량의 Task가 동시에 실행되어 발생하는 문제라고 판단했습니다.

따라서 한 번에 처리하는 요청량에 제한을 두기로 결정했습니다.

한 페이지에 들어오는 메이트의 정보는 총 20개입니다. 1 / 4 크기인 5개로 쪼갰습니다.

// Presenter 

 private func didFetchMateList(mateList: [Mate]) {
        output.mates.send(output.mates.value + mateList)
        isFetching = false
        let chunkedSize: Int = 5

        Task { [weak self] in
            // 최대 5개의 이미지 요청만 수행합니다.
            for mates in mateList.chunked(into: chunkedSize) {
                if let profileImages = await self?.interactor?.requestProfileImages(
                    mates: mates
                ) {
                    self?.didFetchProfileImages(
                        profileImages: profileImages
                    )
                }
            }
        }
    }

extension Array {
    /// 지정된 size로 배열을 자릅니다.
    func chunked(into size: Int) -> [Element](/Team-HGD/SniffMEET/wiki/Element) {
        stride(from: 0, to: count, by: size).map {
            Array(self[$0..<Swift.min($0 + size, count)])
        }
    }
}

쪼갠 요소들의 동시 실행을 보장하기 위해 TaskGroup을 활용했습니다.

// Interactor

func requestProfileImages(mates: [Mate]) async -> [(mateID: UUID, imageData: Data)] {
        var result: [(UUID, Data)] = []

        await withTaskGroup(of: (UUID, Data?).self) { [weak self] group in
            for mate in mates {
                guard let profileImageURLString = mate.profileImageURLString else { continue }
                group.addTask {
                    let imageData = try? await self?.requestProfileImageUseCase.execute(
                        fileName: profileImageURLString
                    )
                    return (mate.userID, imageData)
                }
            }
            for await (mateID, profileImageData) in group {
                guard let profileImageData else { continue }
                result.append((mateID, profileImageData))
            }
        }
        return result
    }

하지만 배열로 쪼개기만 하면 문제는 해결되지 않습니다. 현재 코드로는 여전히 쪼개진 채로 동시에 실행되기 때문입니다. 약간의 시간차만 둔 셈입니다.

쪼개진 Task들의 처리를 어떻게 분산할 수 있을까 생각하다 한번에 하나의 작업만 처리하는 데 적합한 SerialQueue가 해결방법으로 떠올랐습니다. 하지만 Swift Concurrency를 쓰고 있는 상황에서 스레드 기반인 DispatchQueue의 SerialQueue를 쓰는 것은 Swift Concurrency의 장점을 못 살리는 것이기도 하고 예기치 못한 동작으로 이어질 수 있습니다.

그래서 Serial Queue를 Actor를 통해 구현하기로 결정했습니다.

Actor는 Actor내 mutable state에 대해 한번에 하나의 Task만 접근할 수 있다는 특징이 있습니다.

이 특징을 잘 살린다면 Serial Queue처럼 동작하게 만들 수 있다고 생각했습니다.

직렬화가 필요한 작업을 배열로 관리하고 하나씩 꺼내쓰는 방식입니다.

여기서 한 가지 더 신경써야 할 부분이 서로 다른 스레드에서 processNextTaskIfNeeded는 중복 호출될 수 있습니다. 이미 다른 곳에서 processNextTaskIfNeeded 가 호출되었으면 실행을 차단하기 위해 isProcessing 변수가 필요합니다.

actor TaskSerialQueue {
    private var tasks: [() async -> Void] = []
    private var isProcessing = false

    func addTask(_ task: @escaping () async -> Void) async {
        tasks.append(task)
        await processNextTaskIfNeeded()
    }

    private func processNextTaskIfNeeded() async {
        guard !isProcessing else { return }
        isProcessing = true

        while !tasks.isEmpty {
            let task = tasks.removeFirst()
            await task()
        }

        isProcessing = false
    }
}

의도한대로 5개의 요청이 병렬 실행되는 것을 확인할 수 있었습니다.

image

image

시각적으로 Task가 분산되어 처리되는 것을 확인할 수 있습니다.

  • 개선 전

image

  • 개선 중

image

  • 개선 후

image

결과

마지막으로 개선 결과 어떻게 달라졌는지 비교해보겠습니다. 시뮬레이터 iPhone 16 (iOS 18.0), 메이트 데이터 56개 기준으로 측정되었습니다. 실제 실행 시간을 측정하기 위해 SNMLogger에 OSSignPoster 기능을 추가하여 측정하였습니다.

실행 시간

실행 개선 전 Avg Duration 개선 중 Avg Duration 개선 후 Avg Duration 개선율 (%)
실행 1 288.76 ms 780.15 ms 123.57 ms 57.21%
실행 2 238.96 ms 713.44 ms 123.57 ms 48.29%
실행 3 288.76 ms 780.15 ms 123.57 ms 57.21%
총계 (평균) 272.16 ms 757.91 ms 123.57 ms 54.60%

CPU 사용시간

profiler에서 weight % 기준으로 비교하는 방식과 실행 시간을 비교하는 방식 중 실행 시간을 통한 비교 방식을 선택했습니다. weight는 단순히 전체 함수 실행에서 이미지를 로드하는 함수가 몇 퍼센트 차지했는지만 나타내기 때문에 각각 다른 실행 환경의 변화를 비교하는 데 적합하지 않다고 판단했습니다. CPU 부하 비율은 (감소한 실행 시간) / (개선 전 실행 시간) * 100 으로 계산했습니다.

실행 개선 전 실행 시간 개선 중 실행 시간 개선 후 실행 시간 개선 중 → 개선 후 감소량 개선 전 → 개선 후 감소량 CPU 부하 감소율
실행 1 19.47 s 5.38 s 3.94 s 1.44 s 감소 15.53 s 감소 79.76%
실행 2 18.99 s 4.51 s 3.86 s 0.65 s 감소 15.13 s 감소 79.67%
실행 3 18.97 s 7.34 s 3.89 s 3.45 s 감소 15.08 s 감소 79.49%
총계 (평균) 19.14 s 5.74 s 3.90 s 1.84 s 감소 15.24 s 감소 79.62%

CPU 사용량

CPU 전체 사용량을 캡처한 사진입니다. 사진 상에서 표시된 값은 가장 높은 사용량을 보였을 때입니다.

  • 개선 전

스크린샷 2025-03-17 오전 2 15 24 스크린샷 2025-03-17 오전 2 15 45 스크린샷 2025-03-17 오전 2 15 58

  • 개선 중

스크린샷 2025-03-17 오전 2 13 52 스크린샷 2025-03-17 오전 2 14 07 스크린샷 2025-03-17 오전 2 14 22

  • 개선 후

스크린샷 2025-03-17 오전 2 12 37 스크린샷 2025-03-17 오전 2 13 13 스크린샷 2025-03-17 오전 2 13 28

전반적으로 개선 후 peak를 찍는 부분이 많이 줄어든 것을 확인할 수 있습니다. 또한 최대 CPU 사용량 기준으로 얼마나 감소했는지 표로 정리해보겠습니다.

실행 개선 전 최대 CPU 사용량 개선 중 최대 CPU 사용량 개선 후 최대 CPU 사용량 개선 중 → 개선 후 감소율 개선 전 → 개선 후 감소율
실행 1 1170.0% 920.0% 560.0% 39.13% 52.14%
실행 2 1140.0% 1110.0% 610.0% 45.05% 46.49%
실행 3 1140.0% 920.0% 535.0% 41.85% 53.07%
총계(평균) 1150.0% 983.33% 568.33% 42.20% 50.58%

앞으로의 개선 방향

개선 후에도 간헐적으로 일부 구간에서 peak가 발생하는 부분이 존재합니다. 이를 해결하기 위해 이 글에서는 주로 다루지 않았지만, 이후 추가적으로 고려해볼 부분이 있습니다.

개선 작업 후 간헐적으로 메인스레드에서 100%의 사용량을 보인 시점의 call tree를 살펴봤습니다.

clipToSquareWithBackground의 CPU 비용이 생각보다 크다는 사실을 확인했습니다.

현재 clipToSquareWithBackgroundUIGraphicsImageRenderer 기반으로 동작하고 있습니다.

이 부분은 이미지 다운샘플링이 적용되면 대체될 것으로 예상됩니다.

이미지 다운 샘플링을 적용했을 때 CPU의 사용량이 어떻게 달라지는지도 확인해볼 필요가 있겠습니다.

image

image