24.02.06 유튜브 데이터 Room에 저장해 캐시 데이터 만들기

KSang·2024년 2월 11일
0

TIL

목록 보기
52/101
post-thumbnail

유튜브 API3를 사용하면 다양한 유튜브 영상들을 가져 올 수 있다.

그런데 유튜브에서 불특정 다수에게 무료로 막 풀면 사용자가 이상하게 사용할 경우 요금을 감당하지 못할 것인데

그걸 막기위해 할당량이라는 제한이 존재한다.

할당량은 키당 10000인데 검색을 한번 할 때마다 할당량이 100씩 까인다.

그런데 api로 받은 데이터로만 화면을 구성하면 프래그먼트 전환을 하거나 입력했던 데이터를 다시 입력을 해도 서버에 신호를 보내기 때문에 할당량이 계속 까인다.

이를 해결하기 위해 Room을 구현했는데, 데이터를 저장하고 Room에 저장된 정보로 화면을 구성하게 되면 화면 전환이나 같은 이름을 검색하는 등 직접 새로운 검색을 하지 않았을때를 제외하곤 할당량소모를 줄일 수 있다.

구조

클린 아키텍쳐를 유지하며 구현 할 생각인데

저번 시간에 만든 레트로핏과 레포지토리의 데이터 흐름을 보면

ServerRepository가 RDS로부터 데이터를 가져와서 DTO를 Entity로 변환하고, 이를 ViewModel에 전달한다

하지만 ServerRepository에서 반환된 데이터를 Room에 보내려면 어디서 보내야 할까?

Repository에서 바로 보내야 할까? 아니면 Viewmodel 에서?

객체 지향 설계 원칙(SOLID)의 단일 책임의 원칙에 따르면 클래스(함수와 데이터를 결합한 집합)는 하나의 이유(액터)만으로 수정되어야 하며 하나의 책임/기능/역할을 가져야 한다.

ServerRepository는 서버의 데이터를 전달 하는 역활을 하기 때문에

Room에 데이터를 넣는다면 원칙에 위배 된다.

그렇기 때문에 Room에 연결된 새로운 레포지토리를 만들어야 한다.

데이터를 받을때 이렇게 서버와 룸의 레포지토리와 엔티티를 따로 만들어주고 뷰모델에 보내야한다

entity는 같은거 써도 되는거 아님? 이라고 생각 할 수 있지만 단일 책임 원칙에 따라 서버의 데이터를 담는 엔티티와 룸의 데이터를 담는 테이블을 다르게 한다.

뿐만 아니라 앱내에서 북마크추가 등 새로운 데이터를 추가 할 수도 있고 서버에서 보내는 데이터가 변경 될 수 도 있기 때문에 개방-폐쇄 원칙에 따라 entity를 나눠야한다

그렇기 때문에 local entity, repsitory, repositoryImpl, dao, room 이렇게 5가지를 구현할 것 이다.

Entity

package com.example.pintube.data.local.entity

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "video_info")
data class LocalVideoEntity(
    @PrimaryKey
    val id: String,
    val publishedAt: String?,
    val channelId: String?,
    val title: String?,
    val description: String?,
    val thumbnailHigh: String?,
    val thumbnailMedium: String?,
    val thumbnailLow: String?,
    val channelTitle: String?,
    val tags: List<String>?,
    val categoryId: String?,
    val liveBroadcastContent: String?,
    val defaultLanguage: String?,
    val localizedTitle: String?,
    val localizedDescription: String?,
    val defaultAudioLanguage: String?,
    val duration: String?,
    val dimension: String?,
    val definition: String?,
    val caption: String?,
    val licensedContent: Boolean?,
    val projection: String?,
    val viewCount: String?,
    val likeCount: String?,
    val favoriteCount: String?,
    val commentCount: String?,
    val player: String?,
    val topicDetails: List<String>?,
    val saveDate: String?,
    val isPopular: Boolean? = false,
)

레포지토리에서 받은 데이터를 저장하는 엔티티다

video의 id를 식별자(PrimaryKey)로 지정해줬다.

캐시로 쓸 예정인데 한번 저장하고 평생 그 데이터로 유지된다면 댓글이나 좋아요 수 등 별 의미가 없을 것 이다.

디바이스에서 저장한 시간을 데이터에 저장하고, 비디오 전체를 저장하기에 인기영상인지 아닌지 구별할 식별자를 만들어 줬다.

repository

class LocalVideoRepositoryImpl @Inject constructor(
    private val videoDAO: VideoDAO,
    private val channelDAO: ChannelDAO,
) : LocalVideoRepository {
...
    override suspend fun saveVideos(
        item: VideoEntity,
        isPopular: Boolean?,
    ) {
        item.convertToLocalVideoEntity(isPopular).apply {
            this?.let {
                videoDAO.insert(it)
            }
        }
    }

레포지토리에서 데이터를 저장하는 함수인데 데이터를 Room의 table형식에 맞게 convert하고 DAO를 이용해서 넣어준다.

    override suspend fun findPopularVideos()
           : List<VideoWithThumbnail>? =
       videoDAO.findPopularVideos(
           LocalDateTime.now().minusHours(12).convertLocalDateTime()
       )?.map { video ->
           VideoWithThumbnail(
               video = video,
               thumbnail = channelDAO.getChannelThumbnail(video.channelId)
           )
       }

불러올땐 현재 시간을 구한뒤 시간을 빼주고 저장한 시간과 비교해서 데이터를 불러올지 아니면 null을 반환하고 sever에서 데이터를 받아올지를 정해준다.

DAO

@Dao
interface VideoDAO {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  fun insert(item: LocalVideoEntity)

  @Update
  fun update(item: LocalVideoEntity)

  @Delete
  fun delete(item: LocalVideoEntity)

  // 비디오 아이디 입력시 테이블에 저장된 상세 비디오 값 호출
  @Query("Select * From video_info Where id = :id")
  fun findVideo(id: String): LocalVideoEntity?

  @Query("SELECT * FROM video_info WHERE isPopular = 1 AND saveDate >= :date")
  fun findPopularVideos(date: String): List<LocalVideoEntity>?
}

레포지토리에서 데이터를 보낼때 이미 Room에 있는 데이터라면 Insert할때 기본적으로 무시된다.

@Insert(onConflict = OnConflictStrategy.IGNORE)가 기본값이기 때문에 이를 변경해줘야한다.

캐시 데이터로 쓴다면 시간이 지난 뒤 조회수, 댓글 수 등 데이터가 변경 되었기 때문에 갱신을 해줄 필요가 있다.

그래서 @Insert(onConflict = OnConflictStrategy.REPLACE)로 바꿔서 같은 id의 값이 insert될때 무시되지 않고 대체하게 만들어준다.

DAO는 @Query 어노테이션을 이용해 쿼리언어로 로직을 구현 할 수 있다.

인기 동영상 데이터를 찾을땐
@Query("SELECT * FROM video_info WHERE isPopular = 1 AND saveDate >= :date")로 설정을 해줬는데
video_info테이블에 isPopular가 true이고 저장된시간이 파라미터로 보낸 시간보다 클때 반환한다.

파라미터로 보낸 시간은 레포지토리에서 현재시간에 12시간 만큼 빼줬으니 12시간이 지나면 데이터를 반환하지 않아 서버에서 데이터를 받으며 자연스럽게 데이터가 갱신된다.

converter

localVideoEntity를 보면 tags: List등 리스트 형식으로 되어 있는게 있는데 일반적으로 Room에서 리스트 타입의 데이터를 저장하진 못한다.

그렇기 때문에 Json 타입으로 변경한뒤 저장해주는 방법을 사용하는데 이때 @TypeConverter를 사용한다.

  class LocalTypeConverters {
    @TypeConverter
    fun toStringList(items: List<String>?): String? {
        return Gson().toJson(items)
    }

    @TypeConverter
    fun fromString(item: String?): List<String>? {
        val listType = object : TypeToken<List<String?>?>() {}.type
        return Gson().fromJson(item, listType)
    }

어노테이션으로 컨버터를 설정해주면 룸에 저장할때 자동으로 구별 해줘서 리스트 타입의 데이터를 json형태로 저장 할 수 있다.

Database

@Database(
  entities = [
      FavoriteEntity::class,
      LocalSearchEntity::class,
      LocalVideoEntity::class,
      LocalChannelEntity::class,
      LocalCommentEntity::class
             ],
  version = 5
)
@TypeConverters(LocalTypeConverters::class)
abstract class YoutubeDatabase : RoomDatabase() {
  abstract fun searchDao(): SearchDAO
  abstract fun videoDao(): VideoDAO
  abstract fun channelDao(): ChannelDAO
  abstract fun commentDao(): CommentDAO

}

Room데이터 베이스를 설정하는 부분이다 만들어둔 Dao를 넣어두고 @Database 어노테이션 안에 엔티티들을 넣어준다.

데이터 베이스 생성은 이럼 어떻게 하냐? 할텐대 Hilt를 사용하고 있음으로 모듈에서 초기화 해주면 된다.

Module


@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

  @Singleton
  @Provides
  fun provideSearchDao(database: YoutubeDatabase): SearchDAO = database.searchDao()

  @Singleton
  @Provides
  fun provideVideoDao(database: YoutubeDatabase): VideoDAO = database.videoDao()

  @Singleton
  @Provides
  fun provideChannelDao(database: YoutubeDatabase): ChannelDAO = database.channelDao()

  @Singleton
  @Provides
  fun provideCommentDao(database: YoutubeDatabase): CommentDAO = database.commentDao()

  @Singleton
  @Provides
  fun provideDatabase(@ApplicationContext context: Context): YoutubeDatabase =
      Room.databaseBuilder(
          context.applicationContext,
          YoutubeDatabase::class.java,
          "youtube-database"
      ).fallbackToDestructiveMigration() // 마이그레이션 전략 설정
          .build()

  @Singleton
  @Provides
  fun provideSearchService(): YoutubeSearchService =
      YouTubeApi.youtubeNetwork

}

Room은 생성비용이 크기 때문에 컴포넌트를 길게 잡아서 싱글턴 컴포넌트에 배치했다.

여기서 데이터 베이스를 생성 해주는데 애플리케이션 레벨에서 @HiltAndroidApp이 실행되고 이어서 싱글톤 컴포넌트가 형성되면서 데이터베이스가 만들어진다.

DB의 준비가 끝났다.

이제 뷰모델에서 사용만해주면 된다.

ViewModel

    init {
      updatePopulars()
  }

    private fun updatePopulars() = viewModelScope.launch(Dispatchers.IO) {
      try {
          val result = getPopularVideos()
          _populars.postValue(
              result.map {
                  it.convertVideoItemData()
              })
      } catch (error: Exception) {
          Log.e("HomeViewModel", "Error popular videos: ${error.message}", error)
      }
  }

  private suspend fun getPopularVideos(): List<VideoWithThumbnail> {
      var result = localVideoRepository.findPopularVideos()
      if (result.isNullOrEmpty()) {
          val channelIds = repository.getPopularVideo()?.mapNotNull { video ->
              localVideoRepository.saveVideos(video, true)
              video.channelId
          }.orEmpty()
          repository.getChannelDetails(channelIds)?.forEach { localChannelRepository.saveChannel(it) }
          result = localVideoRepository.findPopularVideos()
      }
      return result ?: emptyList()
  }

앱을 실행할때 인기순 영상을 받아오는 함수를 만들었다.

일단 레포지토리에서 데이터를 받아오는데 앱을 처음 실행시켰거나, 이전에 데이터가 저장된지 12시간이 지났다면 Room에서 데이터 반환을 안할 것 이다.

그래서 값이 NullOrEmpty일때 서버에서 데이터를 불러오고 LocalRepository로 데이터를 변환하여 Room에 저장한다.

그런뒤 room에서 데이터를 다시 불러오고 그 값을 리턴한다, 물론 room에 보낼 데이터가 있다면 저런 과정없이 바로 반환해서 캐시를 구현했다.

성능을 생각한다면 서버에서 데이터를 불러올때값을 라이브데이터로 보내면서 Room에 저장하는게 더 효율적이라고 생각이든다, 하지만 그렇게 되면 컨버트 해야하는 함수가 늘어나게 되고 코드의 가독성이 저하될 수 있기 때문에 Room의 데이터만 받아오게 만들었다.

0개의 댓글