오늘도 더 나은 코드를 작성하였습니까?

paging3 race conditions 처리와 remote keys 관리하기 본문

Android Jetpack Architecture/Paging3

paging3 race conditions 처리와 remote keys 관리하기

hik14 2021. 8. 19. 19:56

 

 race conditions

여러 소스에서 데이터를 로드할 때 로컬 캐시 데이터가 원격 데이터 소스와 동기화되지 않는 경우

 

1.  RemoteMediator initialize() 메서드가 LAUNCH_INITIAL_REFRESH를 반환하면 데이터가 오래된 것을 새 데이터로 교체

 

2. PREPEND 또는 APPEND 로드 요청은 원격 REFRESH 로드가 성공할 때까지 기다려야 합니다.

 

 PREPEND 또는 APPEND 요청이 REFRESH 요청 전에 큐에 추가되었으므로 요청이 실행되는 시점에 이러한 로드 호출에 전달된 PagingState가 만료될 수 있습니다.

 

데이터가 로컬에 저장되는 방식에 따라 캐시된 데이터 변경으로 인해 데이터가 무효화되거나 새 데이터를 가져오는 경우 앱이 중복 요청을 무시할 수 있습니다.

 

예를 들어, Room은 모든 데이터 삽입에 관한 쿼리를 무효화합니다.

즉, 데이터베이스에 새 데이터가 삽입될 때 대기 중인 로드 요청에 REFRESH한 데이터를 포함하는 새 PagingSource 객체가 제공됩니다.

 

사용자에게 가장 관련성 높은 최신 데이터를 표시하려면 이 데이터 동기화 문제를 반드시 해결해야 합니다.

가장 좋은 방법은 주로 네트워크 데이터 소스에서 데이터를 페이징하는 방식에 따라 달라집니다.

 

어떤 경우든 RemoteKey 를 사용하면 서버에서 요청한 가장 최신 페이지의 정보를 저장할 수 있습니다.

앱은 이 정보를 사용하여 다음에 로드할 정확한 데이터 페이지를 식별하고 요청할 수 있습니다.

RemoteKey 

원격 키는 RemoteMediator 구현에서 다음에 로드할 데이터를 백엔드 서비스에 지시하는 데 사용하는 키입니다.

가장 간단한 경우 페이징된 각 데이터 항목 하나 하나에 쉽게 참조할 수 있는 원격 키가 포함됩니다.

그러나 원격 키가 개별 항목에 상응하지 않는 경우에는 원격 키를 별도로 저장하고 load() 메서드에서 관리해야 합니다.

Item keys (페이징된 각 데이터 항목 하나 하나에 쉽게 참조할 수 있는 원격 키가 포함)

 개별 Item에 대응하는 remoteKey 로 작업하는 방법

 

API key가 개별 Item에서  떨어져 있을때는,  Item의 ID가 String(query) 매개변수로 전달됩니다.

 

parameter name은 서버가 앱에서 제공한 ID 앞 또는 뒤에 있는 Item에 응답해야 하는지 여부를 낸다.

 User 모델 클래스의 예에서 서버의 id 필드는 추가 데이터 요청 시 원격 키로 사용됩니다.

 

load() 메서드에서 Item별 원격 키를 관리해야 하는 경우 이러한 키는 일반적으로 서버에서 가져온 데이터의 ID입니다.

 

가장 최근 데이터만 검색하기 때문에, Refresh 작업은 로드 키가 필요하지 않습니다.

또한, Refresh 하면 항상 서버에서 최신 데이터를 가져오니까,  Prepend 추가 데이터를 가져올 필요가 없다.( 로드 키가 필요하지 않음)

하지만 append(뒷부분에 추가)되는 작업에는 ID가 필요하다.

 

즉, 로컬 데이터베이스에서 마지막 항목을 가져오고 이 항목의 ID를 사용하여 데이터의 다음 페이지를 로드해야 한다.

만약, 데이터베이스에 마지막 항목이 없으면 endOfPaginationReached가 true로 설정되어 데이터를  Refresh 한다.

 

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    return try {
      
      // load()는 선택적으로 String 매개변수(query)를 사용합니다.
      // 첫 번째 페이지 이후의 모든 페이지에 대해 
      // 이전 페이지에서 반환된 문자열 토큰을 전달하여 중단된 부분부터 계속되도록 합니다. 
      
      val loadKey = when (loadType) {
        // REFRESH의 경우 null을 전달하여 첫 번째 페이지를 로드합니다.
        LoadType.REFRESH -> null

        LoadType.PREPEND -> return MediatorResult.Success(
            // REFRESH가 항상 첫번째 페이지를 받아오기 때문에 여기서는 PREPEND 요청은 필요하지 않다.
            // 즉, 페이지가 최상위에 페이지에 도달했다고 알려준다. 
			endOfPaginationReached = true
        )
        
        // next remoteKey를 얻기 위해 가장 마지막으로 가져온 아이템의 객체를 필요로한다.
        LoadType.APPEND -> {
          
         // null값을 네트워크 요청 객체에 넘기는 작업은 최초로드에만 유효하기 때문에
         // Append 작업을 실행할때 명시적으로 마지막 아이템이 null인지 확인해야 한다. 
         val lastItem = state.lastItemOrNull()
         
         // 마지막 항목이 null 값이란 것은 초기 REFRESH 이후 어떤 아이템도 서버로 부터
         // 받지 못하였고, 더이상 로드될 아이템이 없다는 것이다. 
         // 즉, 마지막 페이지에 도달했단 뜻이다. 
          if (lastItem == null) {
            return MediatorResult.Success(
              endOfPaginationReached = true
            )
          } 
          
         // null이 아닌 마지막 항목이 존재한다면 더 가져올 페이지가 존재 하기 때문에
         // loadKey인 아이템의 id를 반환한다. 
          lastItem.id
        }
      }

      // Retrofit 호출을 통해 load()함수를 일시 중단합니다. 
      // worker thread에서 retrofit의 코루틴 CallAdapter가 디스패치되기 때문에,
      // withContext(Dispatcher.IO) { ... } block으로 감싸서 IO쓰레드로 디스패치
      // 할필요가 없다.
      val response = networkService.searchUsers(query, loadKey)
	
      // 로드한 데이터를 로컬 데이터베이스에 저장하고, 
      // nextKey(이번에 로드해온 목록중 마지막 아이템 Id)를 저장하여
      // 일관성(서버 = 로컬데이터베이스(캐쉬))을 유지 시킨다. 

      database.withTransaction {
       // 로드타입이 최초 로드라면, 기존에 데이터베이스에 있던 캐쉬된 데이터를 지운다. 
       if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query)
        }
        
	    // 새로 로드한 목록을 데이터베이스에 저장하여 현재 PagingData를 무효화 하여
        // Paging이 로컬데이베이스가 업데이트가 되었음을 알려준다.
        userDao.insertAll(response.users)
      }

      // 서버로 부터 어떤 목록도 받지 못했다면 페이지의 마지막에 도달한것이다. 
      MediatorResult.Success(
        endOfPaginationReached = response.users.isEmpty()
      )
    } catch (e: IOException) {
      MediatorResult.Error(e)
    } catch (e: HttpException) {
      MediatorResult.Error(e)
    }
  }
}

 

Page keys  (개별 항목에 해당하지 않는 remoteKey 로 작업하는 방법)

load() 메서드가 remote pageKey를 관리해야 하는 경우 RemoteMediator의 기본 사용법과 비교하여

다음과 같이 다르게 정의해야 합니다.

  • remoteKey Table의 DAO 참조를 보유하는 추가 속성을 포함합니다.
  • PagingState를 사용하는 대신 remoteKey Table에 쿼리하여 다음에 로드할 키를 결정합니다.
  • 페이징된 데이터 의 itemKey 외에, 네트워크 데이터 소스에서 반환된 데이터 키를 삽입하거나 저장합니다
@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()
  val remoteKeyDao = database.remoteKeyDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    return try {
    
      // load()는 선택적으로 String 매개변수(query)를 사용합니다.
      // 첫 번째 페이지 이후의 모든 페이지에 대해 
      // 이전 페이지에서 반환된 문자열 토큰을 전달하여 중단된 부분부터 계속되도록 합니다. 
    
    val loadKey = when (loadType) {
        // REFRESH의 경우 null을 전달하여 첫 번째 페이지를 로드합니다.
        LoadType.REFRESH -> null
        
        // REFRESH가 항상 첫번째 페이지를 받아오기 때문에 여기서는 PREPEND 요청은 필요하지 않다.
        // 즉, 페이지가 최상위에 페이지에 도달했다고 알려준다. 
        LoadType.PREPEND -> return MediatorResult.Success(
          endOfPaginationReached = true
        )
        
         // next remoteKey를 얻기 위해 remoteKeyDao에 쿼리한다.
        LoadType.APPEND -> {
          val remoteKey = database.withTransaction {
            // 기존 리모트키 삭제
            remoteKeyDao.remoteKeyByQuery(query)
          }
          
          // null값을 네트워크 요청 객체에 넘기는 작업은 최초로드에만 유효하기 때문에
          // Append 작업을 실행할때 명시적으로 마지막 아이템이 null인지 확인해야 한다. 
          if (remoteKey.nextKey == null) {
            return MediatorResult.Success(
              endOfPaginationReached = true
            )
          }

          remoteKey.nextKey
        }
      }

      // Retrofit 호출을 통해 load()함수를 일시 중단합니다. 
      // worker thread에서 retrofit의 코루틴 CallAdapter가 디스패치되기 때문에,
      // withContext(Dispatcher.IO) { ... } block으로 감싸서 IO쓰레드로 디스패치
      // 할필요가 없다.
      val response = networkService.searchUsers(query, loadKey)

      // 로드한 데이터를 로컬 데이터베이스에 저장하고, 
      // nextKey 저장하여
      // 일관성(서버 = 로컬데이터베이스(캐쉬))을 유지 시킨다. 
      database.withTransaction {
        if (loadType == LoadType.REFRESH) {
          remoteKeyDao.deleteByQuery(query)
          userDao.deleteByQuery(query)
        }

        // 해당 쿼리에 대한 RemoteKey 업데이트
        remoteKeyDao.insertOrReplace(
          RemoteKey(query, response.nextKey)
        )

        // 새로 로드한 목록을 데이터베이스에 저장하여 현재 PagingData를 무효화 하여
        // Paging이 로컬데이베이스가 업데이트가 되었음을 알려준다.
        userDao.insertAll(response.users)
      }

      MediatorResult.Success(
        endOfPaginationReached = response.nextKey == null
      )
      
    } catch (e: IOException) {
      MediatorResult.Error(e)
    } catch (e: HttpException) {
      MediatorResult.Error(e)
    }
  }
}