[swift] 비동기데이터를 처리하는 클래스(or 함수) 만들기


1️⃣ 목표

  • 이전 포스트에서 swift에서 Json 데이터를 받아오는 방법에 대해 알아 봤습니다.
  • 이번에는 더 나아가 비동기적으로 데이터를 받아오는 다양한 방법에 대해 알아보도록 하겠습니다.
  • 이전 포스트인 JSON데이터 받아오기에서 구현한 코드를 베이스로 코드를 구현해 나갈 예정입니다.
  1. 각각의 데이터를 불러오는과정은 "sleep()"메서드를 이용하여 의도적으로 시간이 걸리도록 했습니다.
  2. 비동기적으로 데이터를 불러오는 것이 핵심 목표이기 때문에 오른쪽 짤과같이 데이터를 불러오는 과정에서도 화면의 다른 기능들이 정상적으로 동작하도록 구현 했습니다.
  3. 또한 데이터를 받아오는 기능은 독립적인 클래스로 만들었습니다. (같은 클래스에 만들면 해당 클래스변수를 직접 대입하면 끝이기 때문, 하지만 이렇게 만들면 클래스의 크기가 너무 커지고 가독성이 떨어짐)
finished version

기본베이스가될 Http응답 클래스

  • 이번에 다음과 같이 다섯가지방법을 알아볼 것입니다.

    1. 나만의 옵저버클래스(추천x)
    2. 클로져
    3. 델리게이트
    4. 노티피케이션
    5. async/await
  • 위에서 async/await를 제외한 모든 방법이 다음의 베이스코드를 확장하는 식으로 구현할 예정입니다.
  • 베이스코드URLSession을 만들어 session의 Task안에서 데이터를 받아오는 식으로 구현됩니다.
  • URLSession은 다음 과 같이 싱글턴 혹은 configuration을 직접 지정해주는 식으로 생성할 수 있습니다.

    // 직접생성
    let session = URLSession(configuration:)
    
    // 싱글턴 사용
    let session = URLSession.shared
    
  • 당연히 싱글턴을 사용하면 한계(limitations)가 있는데, 다음과 같습니다.
    (출처: sharedSession - Apple developer)

    1. You can’t obtain data incrementally as it arrives from the server.
      서버에서 데이터가 들어오는 대로 점진적으로 데이터를 얻을 수 없다.
    2. You can’t significantly customize the default connection behavior.
      기본 연결동작의 커스터마이징에 제한이 있다.
    3. Your ability to perform authentication is limited.
      인증 수행 능력이 제한된다.
    4. You can’t perform background downloads or uploads when your app isn’t running.
      백그라운드에서 다운로드 또는 업로드를 수행할 수 없다.
  • 이번 포스트에서는 기본 configuration을 이용해서 Session을 만들어 줬습니다. 둘의 자세한 비교는 나중에 다루도록 하겠습니다.

< 베이스 코드 >

class HttpBase {
  static let shared = HttpUseCustomObserver()

  private init() { }

  public func readyData() {
    let sessionConfiguration = URLSessionConfiguration.default
    let session = URLSession(configuration: sessionConfiguration)

    let components = URLComponents(string: "http://localhost:8080")
    guard let url = components?.url else { return }

    var request = URLRequest(url: url)
    request.httpMethod = "GET"

    session.dataTask(with: request) { data, response, error in
      guard let httpResponse = response as? HTTPURLResponse,
            httpResponse.statusCode == 200,
            let data = data else {
              print(error.debugDescription)
              return
            } // 응답검증
      do {
        sleep(2) // 이곳에서 지연시간을 줌
        /* 데이터를 JSON형태로 받아오는 곳 */
      } catch {
        print(error)
      }
    }.resume()
    session.finishTasksAndInvalidate()
  }
}
  • 베이스코드싱글턴(singleton)을 사용해서 구현했는데 Java에서 싱글톤 사용시 멀티쓰레드에서 싱글턴의 인스턴스가 중복선언되는 문제가 있어서 클래스 안쪽에 lazy클래스를 만들어 의도적으로 Thread-Safe하도록 만들어줄 필요가 있습니다.
  • 하지만 swift에서는 사용시점에 초기화되는 성질이 있기 때문에 의도적으로 Thread-Safe하도록 만들 필요가 없습니다.
    (참고링크: https://babbab2.tistory.com/66)
  • 다음과 같이 생성시점에 호출되는 init()에 간단한 print문을 넣어 확인할 수 있습니다.

    class HttpUseCustomObserver {
      static let shared = HttpUseCustomObserver()
    
      private init() { print("싱글턴인스턴스 생성")}
    }
    
  • 델리게이트, 노티피케이션을 이용한 클래스는 일반적인 클래스로 만들어 줬습니다. 그 이유는 특정 delegate 혹은 notification키와 결합도(coupling)가 생기기 때문입니다.

2️⃣ 나만의 옵저버클래스를 이용한 방법(추천x)

  • session.dataTask()메서드의 클로져 부분이 비동기적으로 처리가 되다보니 데이터를 직접 return하는 함수를 만들더라도 nil값만을 반환했습니다.
  • 그래서 데이터를 저장해둘 클래스 변수(dataModel)을 만들어 줬고 변수(dataModel)에 비동기적으로 데이터를 저장하는 함수 readyData()를 만들어 줬습니다.
  • 또한 저장된 클래스 변수(dataModel)를 반환하는 함수 getData()함수를 재귀함수로 만들어 줬습니다.
class HttpUseCustomObserver {
  static let shared = HttpUseCustomObserver()
  private var dataModel: DataModel? // 데이터 임시 저장용
  private var isReady: Bool = false // 비동기처리 완료시 true
  private var count = 0 // 지연시간 확인용

  private init() { }

  public func readyData() {
    /* 생략 */

    guard isReady == false else { return } // 데이터가 있을 때 재호출 방지

    session.dataTask(with: request) { data, response, error
      guard let httpResponse =
	    response as? HTTPURLResponse,
        httpResponse.statusCode == 200 else {
          self.isError = true
          return
        }
      /* 생략 */
      do {
        sleep(2)
        self.dataModel = try JSONDecoder().decode(DataModel.self, from: data)
        self.isReady = true // 파싱완료시 true로 변경
      } catch {
        print(error)
      }
    }
    /* 생략 */
  }

  public func getData() -> DataModel? {
    self.count += 1
    print("Call getData() \(self.count) times")
	if (self.isError == true) {
      print("Fail Request")
      return nil
    }
    if (self.isReady == false) {
      usleep(100000) // 0.1초
      return getData()
    }
    return self.dataModel
  }
}
  • 다음과 같이 동작합니다.
    1. readyData()함수를 호출하여 비동기적으로 데이터를 받기를 시작
    2. 완료시 isReady클래스변수를 true로 변경
    3. getData()함수를 호출하면 0.1초의 간격으로 재귀적으로 getData()함수를 재호출
    4. isReadytrue가 되면 getData()함수에서 데이터를 반환
    5. isReadytrue인 상태라면 readyData()를 호출하더라도 데이터를 또다시 받아오지 않음

result custom observer

  • 최초로 호출했을때만 데이터를 불러오고 그 뒤에는 호출스텍이 사라지더라도 데이터를 다시 불러올 필요없이 바로 데이터를 불러올 수 있습니다.
  • 싱글턴으로 만들어진 클래스이기 때문에 클래스를 최초로 호출한 뷰의 스텍이 사라지더라도 데이터가 사라지지 않았기 때문
  • 어떻게 보면 다시 데이터를 불러올 필요가 없기 때문에 좋아보입니다. 하지만 데이터가 사라지지 않기 때문에 오히려 메모리가 무거워지는 단점이 생길 수 있습니다.
  • 그렇기 때문에 개인적인 생각으로는 싱글턴으로 구현한 클래스 내부직접적으로 데이터를 저장하지 않는 것이 좋을 것 같습니다.
  • 또한 위처럼 구현한 클래스는 0.1초간격으로 함수가 재호출되는 과정이 필요하며, 처리가 완료되는 시점을 정확히 아는데 한계가 있습니다.
  • 결과적으로 비동기함수를 다룰때 completion handler(처리 후에 실행되는 클로져)가 왜 중요한지 알게 되었습니다.

3️⃣ 클로져(closure)를 이용한 방법

  • 이번 포스트의 목표를 다시한번 말하자면 “데이터받아오기가 끝난 시점에 다른곳에서 데이터를 처리하는 방법”을 찾는 것입니다.
  • 사실 클로져함수의 사용법에 대해 잘 알고 있었더라면 위에서 2️⃣처럼 호출함수를 만들어줄 필요없가 없습니다. 클로져 파라미터를 만들어주는 방법을 사용하면 쉽게 해결할 수 있습니다.
class HttpUseClosure {
  static let shared = HttpUseClosure()

  private init() { }

  public func getData(completion: @escaping (DataModel?) -> Void) {
    /* 생략 */

    session.dataTask(with: urlRequest) { data, res, error in
      /* 생략 */
      do {
        sleep(2) // 의도적으로 딜레이를 줌
        let resultData = try JSONDecoder().decode(DataModel.self, from: data)
        completion(resultData)
      } catch {
        print(error)
      }
    }.resume()
    session.finishTasksAndInvalidate()
    completion(nil)
  }
}
  • completion이란 이름의 클로져 파라미터를 만들어 줬습니다. 클로져처리가 끝나면 클로져 내부의 데이터들은 초기화 됩니다. 즉, 외부 변수에 저장하는 것이 불가능합니다. 하지만 @escaping키워드를 사용하면 외부에서 작업을 할 수 있는 탈출 클로저를 만들어 줄 수 있습니다.
  • 위에 사용된 dataTask함수의 클로져의 파라미터들은 data, res, error 3개로 도핑하여 사용했습니다. (정확한 타입은 아래 이미지와 같다)

finished version

  • 이중 response(응답)error(에러)처리는 함수 자체에서 처리하도록 구현했기 때문에 탈출클로져의 파라미터로는 Data만 있으면 됩니다. 추가로 Data값의 JSON파싱 또한 이 곳에서 처리할 것 이기 때문에 처리한 뒤의 타입인 DataModel로 파라미터를 만들어줬습니다.
getData(completion: @escaping (DataModel?) -> Void)
  • 일반적으로 클로져는 변수를 반환하지 않으므로 반환값을 Void로 적어 줍니다.
  • 이렇게 만들어준 클로져를 이용한 클래스를 다음과 같이 사용합니다.
HttpUseClosure.shared.getData { data in
  self.myData = data
  DispatchQueue.main.async {
    self.myTableView.reloadData()
  }
}
  • session.dataTask의 클로져의 처리가 자동으로 외부 스레드에서 처리되는듯 합니다. 그렇기 때문에 UI에 영향을 주는 테이블뷰의 reloadData()메인스레드에서 처리하도록 DispatchQueue.main.async로 감싸 주었습니다.

4️⃣ 델리게이트(delegate)를 이용한 방법

  • 지금부터 다룰 델리게이트, 노티피케이션을 이용한 방법은 이전 포스트에서 정리한 <delegate와 notificationCenter을 이용해서 이벤트 전달하기>의 방법을 이용했습니다.

  • 다음과 같이 DataModel의 타입을 따르는 변수를 받아 처리하는 델리게이트 프로토콜(delegate protocol)을 만들어 줬습니다.

protocol MyHttpDelegate: AnyObject {
  func getDataUseCustomDelgate(data: DataModel)
}
  • http응답을 delegate를 이용하여 전달하는 클래스를 다음과 같이 구현했습니다.
  • 클래스에 내장된 delegate는 각각의 사용하는 곳에서 독립적으로 존재해야 되야되기 때문에 싱글턴으로 구현하지 않았습니다.
  • 또한 순환참조를 방지하기 위해 weak키워드를 이용해 약한참조로 사용할 수 있도록 만들어야 합니다. AnyObject(클래스타입)의 프로토콜로 만들어주어 weak키워드를 사용할 수 있도록 만들어 줍니다.
class HttpUseCustomDelegate {
    weak var myHttpDelegate: MyHttpDelegate?

    func getData() {
        /* 생략 */

        session.dataTask(with: urlRequest) { data, res, error in
            /* 생략 */
            do {
                sleep(2) // 지연시간을 줌
                let resultData = try JSONDecoder().decode(DataModel.self, from: data)
                self.myHttpDelegate?.getDataUseCustomDelgate(data: resultData) // JSON으로 파싱한 데이터를 넘겨줌
            } catch {
                print(error)
            }
        }.resume()
        session.finishTasksAndInvalidate()
    }
}
  • 이렇게 만들어준 delegate를 이용한 클래스를 다음과 같이 사용합니다.
override func viewDidLoad() {
  /* 생략 *//
  let httpUseDelegate = HttpUseCustomDelegate()
  httpUseDelegate.myHttpDelegate = self
  DispatchQueue.global().async {
    httpUseDelegate.getData()
  }
}

func getDataUseCustomDelgate(data: DataModel) {
  self.myData = data
  DispatchQueue.main.async {
    self.myTableView?.reloadData()
  }
}

5️⃣ 노티피케이션(notification)을 이용한 방법

  • delegate를 이용한 방법과 마찬가지로 노티피케이션(notification)을 이용한 클래스를 싱글턴으로 만들어주지 않았습니다.
  • 생성시점에서 Notification이름을 지정해줄 수 있도록 만들어 줬습니다.
class HttpUseNotification {
  private var notificationName: NSNotification.Name

  init(_ notificationName: NSNotification.Name) {
    self.notificationName = notificationName
  }

  func getData() {
    /* 생략 */

    session.dataTask(with: urlRequest) { data, res, error in
      /* 생략 */
      do {
        sleep(2)
        let resultData = try JSONDecoder().decode(DataModel.self, from: data)
        NotificationCenter.default.post(name: self.notificationName, object: resultData)
      } catch {
          print(error)
      }
    }.resume()
    session.finishTasksAndInvalidate()
  }
}
  • 이렇게 만들어준 notification을 이용한 클래스를 다음과 같이 사용합니다.
  • notifiaction의 observer를 사용한 후에 반드시 메모리해제를 해야한다는 것을 잊으면 안됩니다.
override func viewDidLoad() {
  /* 생략 */
  let notificationName = NSNotification.Name("getDataNotification")
  NotificationCenter.default.addObserver(self, selector: #selector(getDataUseNotification), name: notificationName, object: nil)
  let httpUseNotification = HttpUseNotification(notificationName)
  httpUseNotification.getData()
}

@objc func getDataUseNotification(_ notification: Notification) {
  guard let data = notification.object as? DataModel else { return }
  self.myData = data
  DispatchQueue.main.async {
    self.myTableView?.reloadData()
  }
  NotificationCenter.default.removeObserver(self, name: NSNotification.Name("getDataNotification"), object: nil)
}

6️⃣ async/await를 이용한 방법

  • data(for:)메서드를 이용하면 async/await의 비동기적인 방법으로 데이터를 받아올 수 있습니다.
  • 약간의 성능향상(?)을 위해서 data(for:)async let키워드를 사용해서 비동기적으로 선언해 줬으며 변수를 사용하는 시점에서 await키워드를 사용하여 동기적으로 처리하도록 했습니다.
  • 싱글턴으로 작성된 클래스이기 때문에 데이터를 저장하는 변수 resultData함수 내부에 선언해주어 사용후에 메모리가 해제되도록 했습니다.
  • 이런식으로 코드가 복잡한 곳에서 async/await을 사용하면 콜백 지옥(클로저 지옥)에서 벗어나 코드를 좀 더 깔끔하게 사용할 수 있습니다.
class HttpUseAsyncAwait {
  static let shared = HttpUseAsyncAwait()

  private init() { }

  public func getData() async -> DataModel? {
      /* 생략 */

      let request = urlRequest
      async let (data, response) = session.data(for: request)

      var resultData: DataModel?
      do {
          sleep(2)
          guard let httpResponse = try await response as? HTTPURLResponse,
                httpResponse.statusCode == 200 else { return nil }
          resultData = try await JSONDecoder().decode(DataModel.self, from: data)
      } catch {
          print(error)
      }
      return resultData
  }
}
  • 이렇게 만들어준 async/await를 이용한 클래스를 다음과 같이 사용합니다.
let httpUseAsyncAwait = HttpUseAsyncAwait.shared
Task {
  self.myData = await httpUseAsyncAwait.getData()
  DispatchQueue.main.async {
    self.myTableView?.reloadData()
  }
}
  • 여기서 Task의 클로저 안에 작업을 작성해줍니다. 아래의 경고메시지를 보면 Task.init는 기존의 async(priority:operation:)를 대체한다고 말하고 있습니다.

soon dispatch async

  • Task대신에 디스패치큐(dispatchQueue)를 만들어 처리해보려했지만 다음과 같은 에러가 발생하였습니다. asyn/await를 최종적으로 처리할 때는 Task로 감싸야하는데 그 이유에 대해서는 좀더 공부해봐야할 것 같습니다.

error use dispatchqueue at asyn/await

  • 그리고 주의할 점이 있는데 Task클로저 안쪽에서 변수를 선언해주면 앱이 멈추는 현상이 발생합니다.
let httpUseAsyncAwait = HttpUseAsyncAwait.shared
Task {
  let a = "apple" // Task안쪽에 변수 선언
  self.myData = await httpUseAsyncAwait.getData()
  DispatchQueue.main.async {
    self.myTableView?.reloadData()
  }
}
  • 그렇기 때문에 앱이 멈추지 않도록 클래스Task밖에서 초기화 해주었습니다.
  • 만약 초기화Task안쪽에서 하면 다음과 같이 Task안쪽의 업무가 끝날때까지 앱이 멈춰 있게 됩니다. (싱글턴클래스 뿐만아니라 일반클래스 또한 같은 문제가 발생)
  • print()sleep() 같은 단순한 코드도 앱을 멈추게 했는데 아마 Task클로저 안에 동기적인 처리를 하면 멈추게 되는 것 같습니다.
Task {
  let httpUseAsyncAwait = HttpUseAsyncAwait.shared
  /* 생략 */
}

< viewDidLoad()에서 사용 >

bad_example_in_scrollView

< viewDidAppear()에서 사용 >

good_example_in_scrollView
  • viewDidLoad()에서 위와같이 사용시 Task업무가 끝날때까지 화면이 나타나지 않습니다.
  • viewDidAppear()에서 사용시 화면은 나타나지만 그 후의 동작들이 멈춰있게 됩니다.
  • 당연한 결과이지만 라이프사이클로 해결할 수 있는 문제가 아닙니다. 그렇기 때문에 Task안쪽에는 비동기처리코드만 작성하는 것이 좋을 것 같습니다.
  • Taskpriority옵션을 주어 비동기 처리의 우선순위를 지정해줄 수 있습니다.

Task option

  • 여기까지 봤을때 TaskDispatchGroup(디스패치그룹)과 같은 기능을 하는데 좀 더 가시적으로 보여주는 역할을 하는 것이 아닌가 생각이 듭니다.

포스트를 마치며

  • 비동기 관련 내용은 Javascript에서도 공부해본적이 있지만 swift에서의 비동기는 색다르게 다가오는 것 같습니다. 이번 포스트에서도 아직 완벽하게 파악하지 못한부분도 있고 구글링을 통해봤을 때 아직 접해보지 못한 비동기관련 수많은 기능들과 개념들이 있습니다. iOS개발자는 결국에 자연스러운 UI구현이 목표이기 때문에 꾸준히 비동기처리관련해서 생각할 필요가 있을 것 같습니다.




© 2021.02. by kirim

Powered by kkrim