서버 데이터를 로컬DB에 저장하여, 로딩 중에 콘텐츠 표시하기

들어가며

iOS 개발자로서 서버 데이터를 가져오고 UI에 바인딩 하는 것은 거의 모든 프로젝트에서 있는 일이라고 할 수 있습니다. 페이버 프로젝트는 사용자 경험을 극대화하기 위해서 로딩 시간을 단축시킬 만한 소스들을 찾았습니다. 우연히 LINE에서 사용하는 Fetcher 개념을 도입하여 페이버 만의 구현 방식으로 리펙토링하여 사용했습니다. 이번 시간에는 Fetcher가 무엇인지, 어떻게 리펙토링 했는지, 사용 방법 및 장점에 대해서 소개해드리려고 합니다.

 

Fetcher란 무엇인가

Fetcher는 서버 데이터를 로컬DB에 저장하여 로딩 중에도 콘텐츠가 표시되도록 하는 기술입니다.

서버 데이터를 불러올 때 자주 사용하는 로딩 인디케이터는 현재 네트워크가 진행 중인지 아닌지 표시해주는 중요한 UI입니다. 하지만 Fetcher는 네트워크 없이 최근 데이터를 로딩 없이 바로 보여줄 수 있습니다. 로딩 인디케이터가 작동하는 동안 콘텐츠를 표시하여 사용자 경험을 증진시킬 수 있습니다. 

일반적인 서버 데이터 로딩
Fetcher를 사용한 데이터 로딩

 

Fetcher는 LocalDB와 RemoteDB와 상호작용하고 최종적으로 두 DB에서 나온 데이터를 화면에 표시합니다. 한 과정씩 살펴보겠습니다.

  1. Local Fetch: 서버 데이터 로딩이 시작되면 불러올 데이터 ID와 맞는 LocalDB의 데이터를 가져옵니다.
  2. Emit: 화면에 로컬 데이터를 표시합니다. (로딩 인디케이터는 아직까지 진행 중)
  3. Request: 서버에게 원하는 데이터를 요청합니다.
  4. Fetch by Response: 원하는 데이터를 받습니다.
  5. Emit: 서버에서 불러온 리모트 데이터를 표시합니다. (로딩 인디케이터 종료)
  6. Update Local: 방금 불러온 리모트 데이터를 Local DB에 업데이트합니다.

우리는 Local DB를 Realm DB를 사용했습니다. Swift로 접근할 수 있는 Local DB라면 어느 것을 사용해도 상관없습니다. 

 

Fetcher 구현하기

이제 본격적으로 Fetcher를 Swift로 구현할 시간입니다. 우리는 Fetcher를 RxSwift와 Async, Async를 이용해 구현했습니다. 예시로 사용자가 어떤 화면에 진입했을 때 Fetcher에게 요청을 보낸다고 가정하겠습니다. *이 프로젝트는 ReactorKit을 사용하고 있습니다. Reactor를 ViewModel이라고 생각하셔도 상관없습니다.

먼저 Fetcher는 어떤 생김새를 가졌는지 확인해야합니다.

/// - T: LocalDB class
public class Fetcher<T> {

  // MARK: - Constants

  public enum Status: Equatable {
    case inProgress
    case success
    case failure(Error)

    public static func == (lhs: Fetcher<T>.Status, rhs: Fetcher<T>.Status) -> Bool {
      switch (lhs, rhs) {
      case (.inProgress, .inProgress):
        return true
      case (.success, .success):
        return true
      case let (.failure(lhsError), .failure(rhsError)):
        return lhsError == rhsError
      default:
        return false
      }
    }
  }
  
  // MARK: - Properties

  /// 서버에서 데이터를 받아오는 클로저
  public var onRemote: (() async throws -> Single<[T]>)?
  /// LocalDB에서 데이터를 받아오는 클로저
  public var onLocal: (() async throws -> [T])?
  /// LocalDB를 업데이트 하는 클로저
  public var onLocalUpdate: ((_ local: [T], _ remote: [T]) async throws -> Void)?

  // MARK: - Initializer

  public init() { }

  // MARK: - Functions

  /// 로컬 DB와 서버로부터 데이터를 `fetch`해옵니다.
  ///
  /// **로직 순서**
  /// 1. 로컬 DB로부터 데이터를 `read`하고 방출합니다. (`status` = `.inProgress`)
  /// 2. 서버에서부터 데이터를 `GET`해옵니다.
  /// 3. `request`가 성공했다면
  /// 4. 로컬 DB를 `response`를 적용하여 업데이트합니다.
  /// 5. 업데이트된 로컬 DB로부터 데이터를 `read`하고 방출합니다. (`status` = `.success`)
  /// 6. `request`가 실패했다면
  /// 7. 로컬 DB에 있는 데이터를 그대로 `read`하여 방출합니다. (`status` = `.failure`)
  public func fetch() -> Observable<(status: Status, results: [T])> {
    guard
      let onRemote = self.onRemote,
      let onLocal = self.onLocal,
      let onLocalUpdate = self.onLocalUpdate
    else {
      fatalError("Failed to setup closures which are needed in fetch() method.")
    }

    return .create { observer in
      let task = _Concurrency.Task {
        do {
          // 로컬에 저장된 데이터를 방출하며 status를 inProgress로 설정합니다.
          os_log(.debug, "📂 🟡 FETCHER STATUS: inProgress")

          let localData = try await onLocal()
          observer.onNext((.inProgress, localData))

          do {
            let remoteData = try await onRemote().value
            os_log(.debug, "🌐 FETCHER GOT REMOTE DATA: \(String(describing: remoteData))")
            try await onLocalUpdate(localData, remoteData)

            observer.onNext((.success, try await onLocal()))
            os_log(.debug, "📂 🟢 FETCHER STATUS: success")
            observer.onCompleted()
          } catch {
            observer.onNext((.failure(error), localData))
            os_log(.error, "📂 🔴 FETCHER STATUS: failure")
          }
        } catch {
          observer.onError(error)
          os_log(.error, "📂 ❌ FETCHER STATUS: error \(error.localizedDescription)")
        }
      }

      return Disposables.create {
        task.cancel()
      }
    }
  }
}

 

Fetcher는 fetch()라는 메서드를 통해 Observable<Status, [T]>를 방출합니다. 

먼저 Status는 inProgress, success, error 세 가지 종류가 있습니다. 이것은 View가 네트워크 상황에 따라서 다른 UI를 보여주게끔 하는 장치입니다. 서버로 요청을 보내는 순간 Fetcher는 inProgress를 방출하고 성공적으로 응답을 받으면 success가 방출됩니다. 만약 에러가 발생하면 error가 방출되게 되죠. 동시에 [T]도 함께 방출합니다. 로컬이든 서버든 어느쪽에서나 화면에 표시를 해줘야합니다.

이 모든 로직은 onLocal, onRemote, onLocalUpdate를 Fetcher가 생성된 객체에서 모두 정의해줘야합니다. 그렇지 않으면 guard문을 통해 Crash가 나도록 구현했습니다. 이것은 매우 중요한 작업이니 반드시 구현되어야 하거든요.

class MyPageViewReactor: Reactor { 
  let fetcher = Fetcher<[User]>()
  // ...
}

private extension MyPageViewReactor {
  func setupUserFetcher() {
    // onRemote
    self.userFetcher.onRemote = {
      let networking = UserNetworking()
      let user = networking.request(.getUser)
        .flatMap { user -> Observable<[User]> in
          do {
            let responseDTO: ResponseDTO<UserSingleResponseDTO> = try APIManager.decode(user.data)
            return .just([User(singleDTO: responseDTO.data)])
          } catch {
            print(error)
            return .just([])
          }
        }
        .asSingle()
      return user
    }
    // onLocal
    self.userFetcher.onLocal = {
      return await self.workbench.values(UserObject.self)
        .map { User(realmObject: $0) }
    }
    // onLocalUpdate
    self.userFetcher.onLocalUpdate = { _, remoteUser in
      guard let remoteUser = remoteUser.first else { return }
      try await self.workbench.write { transaction in
        transaction.update(remoteUser.realmObject())
      }
    }
  }
}

 

이 코드는 Fetcher를 이용하는 ViewModel중 하나입니다. setupUserFetcher()를 통해 Fetcher를 사용하기 전 필수로 구현해줄 onRemote, onLocal, onLocalUpdate 클로저를 정의해야합니다.

onRemote에서는 UserAPI를 통해 Request를 보내고 Response를 받는 과정입니다. 

onLocal은 RealmDB로 접근해 로컬 데이터를 반환합니다.

onLocalUpdate는 onRemote에서 가져온 서버데이터를 다시 RealmDB에 저장하는 로직입니다.

  func mutate(action: Action) -> Observable<Mutation> {
    switch action {
    case .viewNeedsLoaded:
      switch FTUXStorage.authState {
      case .undefined:
        return .empty()
      default:
        return self.userFetcher.fetch() // onLocal, onRemote의 데이터가 방출
          .flatMap { (status, user) -> Observable<Mutation> in
            guard let user = user.first else { return .empty() }
            return .concat([
              .just(.updateUser(user)),
              .just(.updateLoading(status == .inProgress))
            ])
      }
   }

 

이제 이 ViewModel에서 Fetcher.fetch()를 호출하게 되면 onLocal → onRemote → onLocalUpdate 순서대로 로직이 수행됩니다. fetch()는 구독을 하고 있으므로 데이터가 완료되면 알아서 flatMap을 통해 View에게 가공된 데이터가 전달됩니다.

 

마치며

  • 반복되는 코드를 모듈단위로 분리하여 유지보수 비용을 줄일 수 있었습니다.
  • 로딩 화면 대신 콘텐츠를 보여줌으로써 사용자 경험이 훨씬 향상 되었습니다.
  • 네트워크가 연결되지 않더라도 콘텐츠를 보여줄 수 있습니다.

 

Reference