iOS 프로젝트/카멜레온

[iOS] 카멜레온 개발 일지 - 6 (async/await 적용하기)

유정주 2022. 8. 2. 11:40
반응형

completion handler 리팩토링

기존 비동기 처리를 completion handler를 이용했습니다. (자세한 내용은 여기를 확인해주세요.)

최근에 공부한 async / await를 이용해 리팩토링을 진행해보았습니다.

이번 포스팅에서는 아주 일부분만 간단하게 다루려고 해요!

그럼에도 코드가 달라진게 충분히 보이거든요.

 

기존 코드

오늘 포스팅에서 다룰 코드는 파이어베이스에서 최신 버전과 강제 업데이트 버전을 가져오는 역할로

아래 로직을 수행합니다.

  1. 파이어베이스에서 version 값을 읽는다
  2. version 값을 읽은 뒤 version/data를 가져온다.

1번이 끝난 뒤 2번이 진행돼야 하기 때문에 completion handler를 이용해 처리했습니다.

FirebaseService.shared.checkServer(completionHandler: { [weak self] (result, message) in
    ...
    if result {
        //파이어베이스에서 버전 정보 가져옴
        FirebaseService.shared.fetchVersion(completionHandler: { [weak self] (result, versions) in
            ...
        })
    } else {
        ...
    }
})

굉장히 간단한 로직에 비해 depth가 깊다는 문제가 있었습니다.

2단계 로직에 2depth 이상이니 3, 4...단계에서는 아주 끔찍하겠죠 ㅠㅠ

 

함수 body 부분도 가독성면에서 문제가 있었습니다.

func checkServer(completionHandler: @escaping ((Bool, String) -> Void)) {
    firebaseRef.child("version/").getData(completion:  { error, snapshot in
        guard error == nil else {
            print(error!.localizedDescription)
            return
        }

        guard let snapData = snapshot.value as? [String: Any] else {
            print("snapshot.value: \(String(describing: snapshot.value))")
            return
        }
        print("[checkServer] snapData: \(snapData)")

        let result = snapData["result"] as? String ?? "failed"
        let message = snapData["message"] as? String ?? ""
        print("[checkServer] result: \(result) / message: \(message)")

        completionHandler(result == "ok", message)
    });
}
func fetchVersion(completionHandler: @escaping ((Bool, [String]) -> Void)) {
    firebaseRef.child("version/data").getData(completion:  { error, snapshot in
        guard let snapData = snapshot.value as? [String: String] else {
            print("snapshot.value: \(String(describing: snapshot.value))")
            return
        }

        let versions: [String] = [snapData["lasted"] ?? "0.0.1", snapData["forced"] ?? "0.0.1"]
        print("[fetchVersion] snapData: \(snapData)")

        completionHandler(true, versions)
    });
}

파이어베이스에서 값을 읽어 Bool과 데이터 튜플을 리턴합니다.

error와 snapshot을 받아 escaping 클로저 안에서 처리하는데요.

 

첫 번째 문제는 중복되는 guard문이었습니다.

error가 발생할 가능성을 항상 고려해야 하므로 guard문을 이용해 error 처리를 해주어야 하는데요.

guard문을 이용해 에러핸들링을 하니 do-catch보다 편의성과 가독성이 떨어졌습니다.

 

두 번째 문제는 escaping에 대한 학습 문제입니다.

지금은 괜찮지만 당시에는 escaping이 무엇인지 정확히 이해하기 힘들었고

다른 사람의 코드를 읽을 때도 사고의 흐름이 한 번씩 끊기는 느낌을 받았어요.

async/await를 도입한 후에는 평범한 다른 메서드처럼

비동기코드가 동기처럼 작성되고 return 문을 이용해 값을 반환하니

흐름을 읽는 난이도도 개선이 되었습니다.

 

세 번째 문제는 개발자의 실수가 발생한다는 것입니다.

지금 위 코드에서도 에러가 발생했을 때 그냥 return을 시키는데요.

이러면 호출부에서는 무한정 completionHandler 호출을 대기하므로 원치 않은 결과가 나올 수 있습니다.

심지어 컴파일 타임에 잡을 수도 없기 때문에 항상 신경써야 하는 부분이었죠.

"잘하면 되지?!" 라고 할 수 있지만 실수 없이 개발하기는 쉽지 않았습니다... ㅠㅠ

async/await를 도입함으로서 이런 실수를 걱정하는 피곤함이 줄어들었습니다.

 

async / await 적용 후

async / await를 적용한 호출부를 먼저 보도록 합시다.

Task {
    let (isLiveServer, message) = await FirebaseService.shared.checkServer()
    guard isLiveServer else {
        self.showErrorAlert(erorr: message.replacingOccurrences(of: "/n", with: "\n"))
        return
    }

    let (lasted, forced) = await FirebaseService.shared.fetchVersion()
    self.setupAppInfo(lasted: lasted, forced: forced)
    self.presentNextVC()
}

기존 코드와 달리 코드 depth가 아예 사라졌고,

depth가 사라짐에 따라 코드 길이도 짧아졌습니다.

 

기존 코드를 작성한 것처럼 불필요한 부분을 생략해서 보여드리면

Task {
    let (isLiveServer, message) = await FirebaseService.shared.checkServer()
    ...
    let (lasted, forced) = await FirebaseService.shared.fetchVersion()
    ...
}

두 줄이 되어버려요 ㄴㅇOㅇㄱ

이렇게 하니 확 체감이 되죠?

 

completion handler가 호출되지 않을 수 있다는 걱정도 사라졌습니다.

async / await는 그럴 일이 없거든요!

혹시나 await 키워드를 잊는다면 컴파일러가 알려주니 마음 편히 개발할 수 있었습니다.

 

함수 정의 부분은 어떻게 변했을까요

이번에도 두 개 메서드 코드를 먼저 보여드리겠습니다.

func checkServer() async -> (Bool, String) {
    do {
        let snapshot = try await firebaseRef.child("version/").getData()
        let snapData = snapshot.value as? [String: Any]

        let result = snapData?["result"] as? String ?? "failed"
        let message = snapData?["message"] as? String ?? ""
        print("[checkServer] result: \(result) / message: \(message)")

        return (result.lowercased() == "ok", message)
    } catch {
        print("[checkServer] Error: \(error.localizedDescription)")
        return (false, "failed")
    }
}
func fetchVersion() async -> (String, String) {
    do {
        let snapshot = try await firebaseRef.child("version/data").getData()
        let snapData = snapshot.value as? [String: String]

        let versions: (String, String) = (snapData?["lasted"] ?? "0.0.0", snapData?["forced"] ?? "0.0.0")
        print("[fetchVersion] versions: \(versions)")

        return versions
    } catch {
        print("[fetchVersion] Error: \(error.localizedDescription)")
        return ("0.0.0", "0.0.0")
    }
}

 

가독성은 훨씬 좋아졌습니다.

불필요한 depth가 줄어들어 가독성이 훨씬 좋아졌습니다.

무조건 결과를 return 하면 되니

completion handler를 미호출할 걱정도 사라졌습니다.

 

하지만 의외로 body의 코드 길이는 크게 달라지지 않은 거 같아요.

호출부에서는 코드 길이가 훨씬 줄어들었지만 정의부에서는 크게 줄어들지 않았네요.

가독성이 늘어난거로 만족입니다!

try-catch를 조금 손보면 더 좋을 거 같은데 계속 고민해볼 사항인거 같습니다.

 

에러 핸들링은 훨씬 명확해졌어요.

기존에는 에러 로직과 정상 로직이 escaping 클로저 안에 모두 있어서 경계가 모호했는데요.

try-catch문을 이용해 에러 로직을 명확히 나눠졌습니다.

error 종류에 따라 처리도 가능해졌습니다. (지금은 안 했지만 ㅎ;)

 

더 알아봐야할 점

Task에서도 강한 순환 참조를 깨기 위해 [weak self]를 써야하는가? 에 대해 

더 알아봐야 합니다.

 

escaping 클로저에서는 강한 참조 순환을 깨기 위해 [weak self]를 사용했습니다.

그렇다면 Task { } 에서도 [weak self]를 써야하는가? 에 대한 것은 아직 명확히 모르겠더라고요.

 

Task를 전역으로 선언하여 viewWillDisppear나 deinit에 task.cancel()을 하는 방식으로

task를 종료시켜 참조를 끊을 수 있으므로 [weak self]가 필요 없다는 의견도 있었습니다.

그러면 모든 Task를 전역으로 선언해야 하는가? 에 대해서도 생각해봐야할 점 같아요.

 

+) 관련하여 작성한 글입니다.

자세한 분석글...은 아니고 간단 결과 공유랄까요 ㅎ?

 

[Swift] async/await와 [weak self]

서론 최근 completion handler를 async/await로 리팩토링하면서 [weak self]에 대해 궁금한 점이 생겼습니다. 이번 포스팅은 자세한 내용이 아니라 간단 궁금증 해결(?)인 점 이해해주시고 틀린 점이 있다면

jeong9216.tistory.com

틀린 점, 다른 의견은 댓글로 알려주시면 정말 감사하겠습니다.

 

감사합니다!


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

 

 

 

반응형