프로필 등록 시점에 멀티스레딩 적용하기 ‐ 배현진 - Team-HGD/SniffMEET GitHub Wiki

백로그

회원가입 제출 테스크에서 멀티쓰레딩 적용

📱 프로필 등록 시점 측정

Time Profiling - Hang 발생

첫번째 이미지는 프로필 등록 버튼을 누른 바로 그 시점에 행이 발생하면서 메인 스레드를 사용하는 모습이다. 행은 346.68ms 동안 지속되었으며 메인스레드의 cpu 사용률은 100%이다.

두번째 이미지는 프로필 등록 버튼 누를 이후에 화면이 전환되기 직전에 다른 스레드에서 동작되는 모습이다.

1

2

기존 코드


func signInWithProfileData(dogInfo: UserInfo, imageData: (png: Data?, jpg: Data?)) {
    Task {
        do {
            await SupabaseAuthManager.shared.signInAnonymously()
            try saveUserInfoUseCase.execute(dog: UserInfo(
	              ....
	              )
            )
            var fileName: String? = nil
            if let jpgData = imageData.jpg {
                fileName = try await saveProfileImageUseCase.execute(
                    imageData: jpgData
                )
            }
            guard let userID = SessionManager.shared.session?.user?.userID else {
                return
            }
            await saveUserInfoRemoteUseCase.execute(
                info: UserInfoDTO(
                    ....
                )
            )
            presenter?.didSaveUserInfo()
        } catch {
            presenter?.didFailToSaveUserInfo(error: error)
        }
    }
}
  • await SupabaseAuthManager.shared.signInAnonymously()
  • try saveUserInfoUseCase.execute()
  • try await saveProfileImageUseCase.execute()
  • await saveUserInfoRemoteUseCase.execute()

위의 동작이 전부 하나의 스레드에서 실행되고 있고 그로인해 딜레이가 발생(행 존재)하게 되는 것으로 확인된다.

각각 익명로그인을 지원하고, 로컬저장소에 사용자 정보를 저장하고, 이미지를 저장하고, 서버에 사용자 정보를 저장하는 동작들을 수행하며 최종적으로 등록 이후 다음 화면으로 넘어가게 된다.

각 동작이 멀티 스레딩 환경에서 동작할 수 있도록 수정해줘야 할 필요가 있다.

기존 코드의 수행을 확인해보면, 각각의 동작들은 순차적으로 이뤄져야 했는데, 그 중에서 로컬 저장과 이미지 저장은 동시에 수행되는 것이 효율을 높이는데 더 적합하다고 판단했다.

💡 멀티스레딩

여러 작업을 병렬로 실행해 시스템 성능을 최적화하고 사용자 경험 향상시키는 기법으로 CPU의 여러 코어에서 동시 작업을 실행하도록 한다.

GCD, OperationQueue, Swift Concurrency 등의 사용 방식이 존재하지만, SniffMEET의 코딩 컨벤션에 맞춰 Swift Concurrency 이용해 멀티스레딩으로 동작하도록 구현해보고자 한다.

Swift Concurrency

  • async/await : 비동기 작업 선언과 실행
  • Task : 비동기 작업의 단위
  • Task Group : 여러 비동기 작업 병렬 관리

async/await

async 키워드를 사용하게 되면 코드가 비동기적으로 순차 동작된다.

await을 이용해 비동기 메서드가 완료될때까지 대기하는 형태로 동작되어 특정 사용에는 부적합할 수 있다.

(기존 프로필 등록 코드처럼..)

앞서 언급한 4가지의 함수들이 전부 이전 비동기 메서드가 완료될때까지 대기했다 다음 메서드를 실행하게 되어 딜레이가 발생하게 된다.

이런 경우를 해결하기 위해 사용해볼 수 있는 방법은 아래와 같다.

async let

async let을 사용하게 되면, async/await 사용한 경우와는 다르게 메서드 호출이후 반환 값을 기다리지 않고 다음 작업을 수행할 수 있다. async let 이후 작성되는 내용이 현재 Task의 하위 Task가 된다.

Task는 동시성의 단위이다. 따라서 단일 Task는 다른 동시성을 포함하지 않는다. 여러 비동기 작업을 수행하고자 한다면 하위 Task를 생성해 이용해야한다.

Task의 의미와 async let의 동작을 생각해보면, 아래 코드는 하나의 Task 안에서 두개의 하위 Task가 동시에 실행되는 흐름을 갖게 된다. 두 작업의 결과가 모두 처리된 이후 함수가 종료된다.

// 예시를 위한 자세한 구현은 생략된 코드
func example() async throws -> (String, String) {
    async let ex1 = self.saveExample()
    async let ex2 = self.saveExample()
    return (try await ex1, try await ex2)
}

하지만, 이런 형태로 코드를 작성하게 되면 async let 에서 호출하는 메서드가 에러를 발생시켜도 코드는 중단되지 않게 된다. → try await 분리되어 있기 때문

(아예 코드 중단이 안된다기 보다는 try await 시점까지는 중단이 안된다는 의미. 즉 메서드 안에서 오류가 나고 해당 메서드는 다 실행한다..)

Task Group

async let과 마찬가지로 하위 Task들을 동시 실행하는 것이 가능하지만, 수행해야하는 작업의 수를 알 수 없을 경우(배열로 작업이 주어질 경우 처럼) 순회하며 비동기 실행을 수행한다.

아래 코드에서처럼 addTask를 하며 하위 Task들을 수행한다. 그에 대한 결과는 for문을 다시 돌며 try await을 수행해 받게된다. 모든 결과를 받으면 최종 결과를 리턴한다.

func example() async -> [String] {
    let arr
    let result = [String]()
    try await withTaskGroup(of: ) { group in
        for a in arr {
            group.addTask {
                return try await self.saveExample()
            }
        }
        for try await ex in group {
            result.append(ex)
        }
    }
    return result
}

코드 수정

위에서의 분석과 TaskGroup을 이용한 멀티스레딩 구현 방식들을 취합해 적절한 방식으로 개선하고자 한다.

이때, 두가지 형태 중 어떤 방식이 더 적합할까?

  • async let

수행해야 하는 작업이 지정되어 있어 async let 만으로도 멀티스레딩 적용이 가능하다. 단점은 에러가 발생해도 try await 시점까지는 함수 실행이 유지된다는 점이다. 작성 방법도 간단하고 기존 코드에서 큰 변화없이 적용 가능하다. 혹시 이후에 해당 함수에서의 비동기 처리가 추가적으로 많이 필요해진다면 관리하기 어려워질 수도 있다.

async let saveUserInfoTask = saveUserInfoUseCase.execute(dog: UserInfo(
    ...
    )
)
async let fileNameTask: String? = {
    guard let jpgData = imageData.jpg else { return nil }
    return try await saveProfileImageUseCase.execute(
        imageData: jpgData
    )
}()

let (userResult, fileName) = try await (saveUserInfoTask, fileNameTask)
  • 하나의 TaskGroup → addTask 이용해 각각 로컬 저장과 이미지 저장을 태스크로 추가한다.

성능이 가장 빠르며, withThrowingTaskGroup 이용해 에러를 보장할 수 있다. 또한, 로컬과 이미지 저장이 끝난 이후에 서버 저장이 이뤄지도록 순서를 보장한다. → for try await 때문

// Group
let fileName = try await withThrowingTaskGroup(of: String?.self) { group in
    group.addTask {
        try self.saveUserInfoUseCase.execute(dog: UserInfo(
            ...
        ))
        return nil
    }
    if let jpgData = imageData.jpg {
            group.addTask {
            return try await self.saveProfileImageUseCase.execute(imageData: jpgData)
        }
    }
    var savedFileName: String? = nil
    for try await result in group {
        if let name = result {
            savedFileName = name
        }
    }
    return savedFileName
}
  • 로컬과 이미지 저장 / 서버 저장을 구분하기 위해 Task Group 자체를 2개로 나눈다. 로컬과 이미지 저장이 종료된 이후에 서버 저장을 수행하도록 순서를 보장하고 흐름이 더 명확해지지만 Group이 1개일 경우보다 성능이 좋지 않다.
    • 로컬에 사용자 정보를 저장하고 이미지 저장하기를 수행한다. 각각의 태스크로부터 반환된 값이 fileName 형태로 저장된다. → 사용자 정보 저장에서는 리턴값이 nil.
    • 서버에 사용자 정보를 저장한다.
// Group 1
let fileName = try await withTaskGroup(of: String?.self) { group in
    group.addTask {
        try saveUserInfoUseCase.execute(dog: UserInfo(
            ...
        ))
        return nil
    }

    group.addTask {
        if let jpgData = imageData.jpg {
            try await saveProfileImageUseCase.execute(imageData: jpgData)
        }
        return nil
    }
    
    var savedFileName: String? = nil
    for try await result in group {
        if let name = result {
            savedFileName = name
        }
    }
    return savedFileName
}

// Group 2
guard let userID = SessionManager.shared.session?.user?.userID else {
    return
}

try await withTaskGroup(of: Void.self) { group in
    group.addTask {
        try await saveUserInfoRemoteUseCase.execute(
            info: UserInfoDTO(
                ...
            )
        )
    }
}

OSSignPoster

Instruments에서 Time Profiler를 이용해 측정한 결과로는 명확하게 시간이 단축되었음을 확인하기가 어려웠다. OSSignPoster를 이용해 해당 태스크의 시간 지연을 직접 측정해보았다.

기존에 SNMLogger에 OSSignPoster를 만들어주신 부분이 있어 그대로 이용했다.

Task {
    let state = SNMLogger.begin(name: "RegisterProfile") // end와 동일 name
    do {
        ...
    } catch {
        ...
    }
    defer {
        SNMLogger.end(name: "RegisterProfile", state: state)
    }
    print("RegisterProfile")
}

측정 결과

전부 동일한 조건 속에서 멀티스레딩 적용 여부만 변경해 측정을 진행했다.

  • 1차시도 - 전부 동일한 프로필 정보
    • 기존 코드 (멀티스레딩 X) - 2.97
    • async let 사용 - 2.55 예상값보다 측정치가 높게 잡혔다. 추가적으로 몇번의 측정을 더해본 결과, 1.75정도의 측정 결과가 나온다. 이후 측정에서 전체적으로 비슷한 수치들이 측정되었기 때문에 2.55로 측정되는 시점에 다른(서버나 기기적) 문제가 있었을 것으로 추측할 수 있을 것 같다.
    • Task Group 사용 - 1.68
  • 2차시도 - 이미지만 좀 더 큰 용량으로 변경
    • 기존 코드 (멀티스레딩 X) - 2.50
    • async let 사용 - 1.85
    • Task Group 사용 - 1.75
  • 예외 - Group 2개의 경우 - 2.36
  • 예외 - 3가지 전부 병렬처리한 경우 - 1.52 최종적으로 Group을 1개 사용한 Task Group 방식을 사용해 프로필 등록 시점의 시간 지연을 줄였다.

인사이트

레퍼런스