[Database, AAC] 객체 사이의 관계 정의 및 복잡한 데이터 참조

dwjeong·2023년 11월 1일
0

안드로이드

목록 보기
10/28

🔎 객체 사이의 관계 정의

SQLite는 관계형 데이터베이스이므로 엔티티 사이의 관계를 정의할 수 있음.
대부분의 객체 관계 맵핑 라이브러리는 엔티티 객체가 서로를 참조하는 것을 허용하지만 Room은 명시적으로 이것을 금지함.


💡 두 가지 접근법

  • Room에서 엔티티 간의 관계를 정의하고 쿼리하는 방법
  1. Intermediate data class
    이 방식에서는 Room 엔티티 간의 관계를 모델링하는 데이터 클래스를 정의함.
    이 데이터 클래스는 하나의 엔티티 인스턴스와 다른 엔티티 인스턴스 간의 페어링을 포함하는 객체. 쿼리 메서드는 이 데이터 클래스의 인스턴스를 반환하여 앱에서 사용 가능.
@Dao
interface UserBookDao {
    @Query(
        "SELECT user.name AS userName, book.name AS bookName " +
        "FROM user, book " +
        "WHERE user.id = book.user_id"
    )
    fun loadUserAndBookNames(): LiveData<List<UserBook>>
}

data class UserBook(val userName: String?, val bookName: String?)

  1. Multimap return types
    ❗️ Room 2.4 이상에서만 지원
    이 방식은 추가로 데이터 클래스를 정의할 필요가 없으며 원하는 맵 구조에 기반한 멀티맵 반환 유형을 메서드에 정의하고 엔티티 간의 관계를 SQL 쿼리에서 직접 정의함.
@Query(
    "SELECT * FROM user" +
    "JOIN book ON user.id = book.user_id"
)
fun loadUserAndBookNames(): Map<User, List<Book>>

Intermediate data class를 반드시 사용해야 할 이유가 없는 경우 Multimap return types를 권장.





💡 Create embedded objects

객체가 여러개의 필드를 포함하더라도 데이터베이스 로직에서 객체 전체를 하나로 표현하고 싶을 경우 @Embedded 어노테이션을 사용하여 테이블 내의 하위 필드로 나타낼 수 있음.

data class Address(
    val street: String?,
    val state: String?,
    val city: String?,
    @ColumnInfo(name = "post_code") val postCode: Int
)

@Entity
data class User(
    @PrimaryKey val id: Int,
    val firstName: String?,
    @Embedded val address: Address?
)
  • User 객체를 나타내는 테이블에 포함되는 Column들: id, firstName, street, state, city, post_code
  • 엔티티에 동일한 유형의 포함된 필드가 있는 경우 각 Column을 고유하게 유지하기 위해 prefix 속성을 설정할 수 있음. Room은 객체의 각 열 이름 앞에 prefix 값을 추가함.





💡 1:1 관계 정의

1:1 관계를 정의하기 위해 두 엔티티 각각에 대한 클래스를 만들고 그 중 하나의 엔티티는 다른 엔티티의 기본 키에 대한 참조 변수를 포함해야 함.

@Entity
data class User(
    @PrimaryKey val userId: Long,
    val name: String,
    val age: Int
)

@Entity
data class Library(
    @PrimaryKey val libraryId: Long,
    val userOwnerId: Long
)
  1. 위의 코드에서 사용자 리스트와 Library를 쿼리하려면 먼저 두 엔티티 간의 일대일 관계를 모델링해야 함.

  2. 각 인스턴스가 부모 엔티티의 인스턴스와 해당하는 자식 엔티티의 인스턴스를 보유하는 새 데이터 클래스를 만듦.

  3. 자식 엔티티의 인스턴스에 @Relation 어노테이션을 추가.
    parentColumn을 부모 엔티티의 기본 키 Column으로 설정하고 entityColumn을 부모 엔티티의 기본 키를 참조하는 자식 엔티티의 열 이름으로 설정.

data class UserAndLibrary(
    @Embedded val user: User,
    @Relation(
         parentColumn = "userId",
         entityColumn = "userOwnerId"
    )
    val library: Library
)
  1. DAO 클래스에 부모 엔티티와 자식 엔티티를 결합하는 데이터 클래스의 모든 인스턴스를 반환하는 메서드를 추가. Room에서 두 개의 쿼리를 실행해야 하므로 작업 전체가 동시에 수행되도록 하려면 @Transaction 주석을 추가.
@Transaction
@Query("SELECT * FROM User")
fun getUsersAndLibraries(): List<UserAndLibrary>




💡 1:N 관계 정의

1:N 관계를 정의하기 위해 두 엔티티에 대한 클래스를 만들어야 함. 1:1 관계와 마찬가지로 자식 엔티티는 부모 엔티티의 기본 키에 대한 참조 변수를 포함해야 함.

@Entity
data class User(
    @PrimaryKey val userId: Long,
    val name: String,
    val age: Int
)

@Entity
data class Playlist(
    @PrimaryKey val playlistId: Long,
    val userCreatorId: Long,
    val playlistName: String
)
  1. User와 Playlist를 쿼리하려면 두 엔티티 간의 1:N 관계를 모델링해야 함.

  2. 각 인스턴스가 부모 엔티티의 인스턴스와 해당 자식 엔티티 인스턴스 목록을 보유하는 새 데이터클래스를 만듦.

  3. 자식 엔티티의 인스턴스에 @Relation 어노테이션을 추가.
    parentColumn을 부모 엔티티의 기본 키 Column으로 설정하고 entityColumn을 부모 엔티티의 기본 키를 참조하는 자식 엔티티의 열 이름으로 설정.

data class UserWithPlaylists(
    @Embedded val user: User,
    @Relation(
          parentColumn = "userId",
          entityColumn = "userCreatorId"
    )
    val playlists: List<Playlist>
)
  1. DAO 클래스에 부모 엔티티와 자식 엔티티를 결합하는 데이터 클래스의 모든 인스턴스를 반환하는 메서드를 추가. Room에서 두 개의 쿼리를 실행해야 하므로 작업 전체가 동시에 수행되도록 하려면 @Transaction 주석을 추가.
@Transaction
@Query("SELECT * FROM User")
fun getUsersWithPlaylists(): List<UserWithPlaylists>




💡 N:M 관계 정의

다대다 관계를 정의하려면 두 엔티티 각각에 대한 클래스를 만듦.
다대다 관계는 일반적으로 자식 엔티티에서 부모 엔티티를 참조하는 참조가 없음.
대신 두 엔티티 간 연결 엔티티 또는 교차 참조 테이블을 나타내는 세 번째 클래스를 만듦.

교차 참조 테이블은 다대다 관계에 표시된 두 엔티티 각각의 기본키를 열로 가져야 함.

@Entity
data class Playlist(
    @PrimaryKey val playlistId: Long,
    val playlistName: String
)

@Entity
data class Song(
    @PrimaryKey val songId: Long,
    val songName: String,
    val artist: String
)

@Entity(primaryKeys = ["playlistId", "songId"])
data class PlaylistSongCrossRef(
    val playlistId: Long,
    val songId: Long
)
  1. 재생 목록 및 재생 목록에 해당하는 노래 목록을 쿼리
    👉 하나의 Playlist 객체와 여기에 포함된 모든 노래 객체 목록을 포함하는 새로운 데이터 클래스를 만듦.

  2. 노래 및 노래에 해당하는 Playlist 목록을 쿼리
    👉 하나의 Song 객체와 이 객체가 포함된 모든 Playlist 객체의 목록을 포함하는 새로운 데이터 클래스를 만듦.

data class PlaylistWithSongs(
    @Embedded val playlist: Playlist,
    @Relation(
         parentColumn = "playlistId",
         entityColumn = "songId",
         associateBy = Junction(PlaylistSongCrossRef::class) //관계를 정의한 엔티티를 지정해 주어야 함.
    )
    val songs: List<Song>
)

data class SongWithPlaylists(
    @Embedded val song: Song,
    @Relation(
         parentColumn = "songId",
         entityColumn = "playlistId",
         associateBy = Junction(PlaylistSongCrossRef::class) //관계를 정의한 엔티티를 지정해 주어야 함.
    )
    val playlists: List<Playlist>
)
  1. DAO 클래스에 메서드 추가
    getPlaylistsWithSongs: 데이터베이스를 쿼리하고 결과로 나타난 모든 PlaylistWithSongs 객체를 반환.
    getSongsWithPlaylists: 데이터베이스를 쿼리하고 결과로 나타난 모든 SongWithPlaylists 객체를 반환.
@Transaction
@Query("SELECT * FROM Playlist")
fun getPlaylistsWithSongs(): List<PlaylistWithSongs>

@Transaction
@Query("SELECT * FROM Song")
fun getSongsWithPlaylists(): List<SongWithPlaylists>




💡 Define nested relationships

세 개 이상의 테이블을 쿼리해야할 경우

  1. 데이터 클래스 작성 및 다대다 교차 참조 테이블 작성
@Entity
data class User(
    @PrimaryKey val userId: Long,
    val name: String,
    val age: Int
)

@Entity
data class Playlist(
    @PrimaryKey val playlistId: Long,
    val userCreatorId: Long,
    val playlistName: String
)

@Entity
data class Song(
    @PrimaryKey val songId: Long,
    val songName: String,
    val artist: String
)

@Entity(primaryKeys = ["playlistId", "songId"])
data class PlaylistSongCrossRef(
    val playlistId: Long,
    val songId: Long
)

  1. 데이터 클래스와 @Relation 어노테이션을 사용하여 모델링.
    Playlist 엔티티 클래스와 Song 엔티티 클래스의 다대다 관계를 모델링.
data class PlaylistWithSongs(
    @Embedded val playlist: Playlist,
    @Relation(
         parentColumn = "playlistId",
         entityColumn = "songId",
         associateBy = Junction(PlaylistSongCrossRef::class)
    )
    val songs: List<Song>
)
  1. User 엔티티 클래스와 PlaylistWithSongs 관계 클래스 간의 일대 다 관계를 모델링.
data class UserWithPlaylistsAndSongs(
    @Embedded val user: User
    @Relation(
        entity = Playlist::class,
        parentColumn = "userId",
        entityColumn = "userCreatorId"
    )
    val playlists: List<PlaylistWithSongs>
)

  1. DAO 클래스에 앱이 필요로 하는 메서드 추가.
@Transaction
@Query("SELECT * FROM User")
fun getUsersWithPlaylistsAndSongs(): List<UserWithPlaylistsAndSongs>




🔎 복잡한 데이터 참조

Room은 기본 타입(primitive)과 boxed type 간의 변환 기능 제공

💡 type converter 사용

앱에서 사용자 정의 데이터 유형을 데이터베이스 column에 저장해야 할 경우,
Room에게 사용자 정의 데이터를 Room이 저장할 수 있는 유형으로 변환한다는 것을 알려주는 메서드인 타입 컨버터를 제공해야 함.

@TypeConverter 어노테이션을 이용하여 식별.

Room 데이터베이스에 Date 인스턴스를 저장해야 한다고 가정하면 Room은 Date 객체를 저장시키는 방법을 알지 못하므로 타입 컨버터를 정의해야 함.

  1. Date 객체를 Long 객체로 변환하는 메서드와 Long에서 Date로 역변환을 수행하는 컨버터 메서드 두 가지를 정의함. Room은 Long 객체를 저장하는 방법을 알고 있으므로 이러한 컨버터를 사용하여 Date 객체를 저장할 수 있음.
class Converters {
  @TypeConverter
  fun fromTimestamp(value: Long?): Date? {
    return value?.let { Date(it) }
  }

  @TypeConverter
  fun dateToTimestamp(date: Date?): Long? {
    return date?.time?.toLong()
  }
}



  1. AppDatabase 클래스에 @TypeConverters 어노테이션을 추가하여 Room이 정의한 컨버터 클래스를 인식하도록 함.
@Database(entities = [User::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
  abstract fun userDao(): UserDao
}



  1. 타입 컨버터를 정의하면 사용자 정의 타입을 엔티티와 DAO에서 기본 타입처럼 사용 가능.
@Entity
data class User(private val birthday: Date?)

@Dao
interface UserDao {
  @Query("SELECT * FROM user WHERE birthday = :targetDate")
  fun findUsersBornOnDate(targetDate: Date): List<User>
}



위의 예시에서는 AppDatabase에 @TypeConverters로 어노테이션 처리를 했기 때문에 정의된 타입 컨버터를 어디서든 사용 가능.

@Entity 또는 @Dao 클래스에 @TypeConverters로 어노테이션 처리를 하면 특정 엔티티 또는 DAO에 타입 컨버터를 제한할 수 있음.




💡 type converter 초기화 제어

Room은 타입 컨버터를 인스턴스화하는 작업을 하지만, 때로는 타입 컨버터 클래스에 추가 의존성을 전달해야 하는 경우가 있으므로 타입 컨버터 클래스의 초기화를 직접 제어해야 할 수도 있음.

타입 컨버터 클래스에 @ProvidedTypeConverter 어노테이션을 달아줌.

@ProvidedTypeConverter
class ExampleConverter {
  @TypeConverter
  fun StringToExample(string: String?): ExampleType? {
    ...
  }

  @TypeConverter
  fun ExampleToString(example: ExampleType?): String? {
    ...
  }
}

그 다음, RoomDatabase.Builder.addTypeConverter() 메서드를 사용하여 컨버터 클래스의 인스턴스를 RoomDatabase 빌더에 전달.

val db = Room.databaseBuilder(...)
 .addTypeConverter(exampleConverterInstance)
 .build()




🔎 Room이 객체 참조를 허용하지 않는 이유

⭐️ Room은 엔티티 클래스 간의 객체 참조를 허용하지 않음. 대신, 앱에서 필요한 데이터를 명시적으로 요청해야 함.

데이터베이스 객체 모델로의 관계 매핑은 일반적이며 서버 측에서는 잘 작동함.
프로그램이 필드에 액세스하여 로드를 할 때도 서버는 잘 동작함.


하지만 클라이언트 측에서 이런 로딩 지연은 주로 UI 스레드에서 일어나므로 알맞은 방식이 아니며, UI 스레드에서 디스크 정보를 쿼리하는 것은 성능 문제가 발생함.


UI 스레드는 일반적으로 액티비티의 업데이트된 레이아웃을 계산하고 그리는데 약 16ms 정도의 시간이 걸리는데, 쿼리가 5ms 정도만 걸린다 하더라도 앱이 프레임을 그리는 시간이 부족해져 시각적으로 눈에 띄는 결함이 발생하게 됨. 병렬로 실행중인 별도의 트랜잭션이 있는 경우 쿼리에 더 많은 시간이 걸림.


예를 들어 책 객체 목록을 로드하는 UI가 있다고 가정하면

authorNameTextView.text = book.author.name

위 코드는 Author 테이블이 메인 스레드에서 쿼리되도록 함.

Author 정보를 미리 쿼리하는 것은 해당 데이터가 더 이상 필요하지 않을 때 데이터 로드 방식을 변경하는 것을 어렵게 만듦.

UI가 더 이상 Author 정보를 표시할 필요가 없으면 앱은 더 이상 표시하지 않는 데이터를 로드하게 되어 메모리 공간을 낭비하게 됨. Author 클래스가 Books와 같은 다른 테이블을 참조하는 경우 효율은 더 떨어짐.



💡 요약: UI 스레드에서 데이터베이스에 접근할 경우 UI가 장시간 멈춰버릴 수 있고, 유저와의 상호작용이 중요한 모바일 환경에서는 일어나서는 안되는 일이기 때문

0개의 댓글