[안드로이드스튜디오_문화][나만의 운동루틴 후기-RoomDB]

기말 지하기포·2024년 6월 15일
0

룸 라이브러리

기존에는 SQlite를 사용하였으나 Room 라이브러리 데이터 그래프의 변경에 영향을 받는 쿼리를 수동으로 업데이트 해야하는 등의 여러 이유들로 인해서 RoomDB의 사용을 권장합니다.

안드로이드 스튜디오에서는 로컬에서 데이터를 관리하는 라이브러리들이 존재한다. SharedPreference , RoomDB , RealmDB , DataStore 가 있는데 이번 포스팅에서는 RoomDB에 대해서 설명하겠습니다.

RoomDB의 등장이전에 등장한 라이브러리이다. SharedPreference와 DataStore가 소규모의 데이터를 관리하는 것과는 다르게 , RoomDB와 RealmDB는 대량의 데이터를 관리합니다.

또한 위의 4가지의 라이브러리와 동일하게 로컬(네트워크 상태일 때가 오프라인일 때에도 앱 내부에 저장되기 때문에)에서 사용 할 수 있습니다.

룸 라이브러리 1편 링크

이번 포스팅은 룸 라이브러리의 기초적인 사용법 보다 더 깊게 들어가는 포스팅입니다.

https://velog.io/@antking/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C%EC%8A%A4%ED%8A%9C%EB%94%94%EC%98%A4%EB%AC%B8%ED%99%94Room

Room 테이블의 열 이름

AppInspection에서 보이는 전체 화면을 데이터 테이블이라고 불리며 , Entitiy의 객체 각각(데이터 테이블의 행)은 데이터 레코드를 의미합니다.

기본적으로 Room이 데이터 베이스 테이블에서 이름을 변수명 그 자체로 가져간다. 무슨 의미냐 하면

@Entity(tableName = "Excercises")
data class ExcerciseEntity(
    @PrimaryKey(autoGenerate = true)
    val id : Int = 0,
    val name : String,
    val setNum : Int,
)

위와 같은 코드가 있을 때에 App Inspection을 클릭하여 데이터 테이블을 확인해보면 아래의 사진과 같은 화면이 보인다.
즉 , tableName으로 설정한 값이 데이터 테이블의 네임으로 설정된다.
또한 데이터 베이스의 열 이름 또한 변수명으로 선언한 name과 setNum으로 설정된다.


위의 사진처럼 테이블 이름은 Excercies로 , 첫번째 열은 name으로 , 두번째열은 setNum으로 반영된 것을 볼 수 있습니다.

결론적으로 data class가 룸의 데이터 베이스 테이블과 매핑 될 때 data class에 작성된 기본적인 내용이 그대로 반영되어 작성된다는 것 을 알 수 있다.

그렇다면 만약에 열 이름과 테이블의 이름을 변경하고 싶다면 어떡해 할 수 있을 까 ?

@Entity(tableName = "Excercises")
data class ExcerciseEntity(
    @PrimaryKey(autoGenerate = true)
    val id : Int = 0,
    @ColumnInfo(name = "first_column") val name : String,
    @ColumnInfo(name = "second_column") val setNum : Int,
)

정답은 바로 위 의 코드처럼 tablename에는 자신이 원하는 테이블 이름을 작성해주고 , 열의 이름을 변경하고 싶다면 , @ColumnInfo의 name parameter에 자신이 원하는 이름을 작성하면 된다.

위와 같이 코드를 작성하고 App Inspection을 통해 테이블을 확인해보면 아래와 같은 화면이 보일 것 입니다.


기존의 table name이 excercise -> excercises로 변경되었고 , name -> first_column , setNumb -> second_column으로 변경된것을 확인 할 수 있습니다.

위와 같이 ColumnInfo의 이름을 변경하였다면 , dao 단에서도 기존의 name과 setNum으로 작성된 내용들을 각각 first_column과 second_column과 같이 변경해주면 됩니다. 아래는 예시코드입니다.

@Query("SELECT * from ExcerciseEntity ORDER BY first_column DESC")
    fun getAll() : List<ExcerciseEntity>

복합 기본 키 정의

공식문서에서 해당 글을 보고 처음 느낀점은 어? tableName과 id 없이 어떡해 행을 구별하는지에 대한 의문이였다. 공식문서에는 다음과 같이 작성되어 있다.

항목 인스턴스가 여러 열의 조합으로 고유하게 식별되도록 하려면 이러한 
열을 @Entity의 primaryKeys 속성에 나열하여 복합 기본 키를 정의하면 됩니다.

읽어보니 열의 값이 하나라도 다르면 그 자체로 행이 분리 된다는 의미 같아서 코드를 작성 한 후 확인해 보았습니다.

@Entity(primaryKeys = ["name" , "setNum"])
data class ExcerciseEntity(
//    @PrimaryKey(autoGenerate = true)
//    val id : Int = 0,
    val name : String,
    val setNum : Int,
)

코드를 위 와같이 작성하였습니다.
tableName 파라미터을 삭제하고 , id 값을 주석 처리하고 AppInspection을 확인해보았습니다.


위와 같이 name과 setNum 두개의 값이 하나라도 다르면 각기 다른 "데이터 레코드"로 분류됩니다. 이런식으로 두개 이상의 열을 primary key로서 사용 할 수 있습니다. 또한 tableName을 명시적으로 작성하지 않았기 때문에 data class의 이름이 tableName으로 활용되고 있습니다.

이렇게 복합키를 사용하면 복합키의 조합에 따라서 각각의 데이터 레코드를 구별 할 수 있게 됩니다. 벤치프레스10 과 벤치프레스9 라는 값들에서 name은 같지만 setNum의 값이 다르기 때문에 서로 다른 데이터 레코드로 분류되는 것입니다.

이렇게 복합키를 사용하게 되면 여러 조합으로 데이터의 고유성과 무결성을 보장 할 수 있습니다.

복합키를 사용하여 데이터를 식별 할 때의 장점과 단점은 다음과 같습니다.

[장점]
1. 여러 열의 조합으로 데이터를 식별하기 때문에 데이터 간의 논리적 관계를 활용하여 비즈니스 로직을 더 명확하게 작성 할 수 있습니다.
2. 여러 열을 조합하여 다양한 상황에서 데이터를 식별하여 활용할 수 있습니다.

[단점]
1. 매핑과 쿼리 작성 할 때 기존의 id를 통해서 작성 할 때보다 비용이 더 많이 소모됩니다. 물론 불편 할 수 있지만 논리성을 이해 할 수 있다면 코드 작성이 불가능한것은 아닙니다.
2. 프로젝트의 규모가 커질 때 관리가 복잡해집니다.
3. 두가지 이상의 열을 결합하여 하나의 데이터 레코드를 만들기 때문에 Room 엔진이 작동 할 때 많은 비용이 소모되며 , 레코드를 탐색 할 때 더 많은 페이지를 조회하여야 하기 때문에 범위가 방대해질수록 , 요청한 데이터가 캐시에 없을 확율이 높아지고 이로 인해 캐시 히트율이 낮아져서 데이터베이스가 데이터를 디스크에서 직접 읽어와야 하는데 , 디스크IO작업은 캐시에서 데이터를 읽어올때보다 느리기 때문에 응답속도가 느리게 됩니다.
(cf. 데이터베이스는 캐시를 사용하여 자주 조회되는 데이터를 메모리에 저장합니다.)

Id를 통해서 데이터를 식별할 때의 장점과 단점

[장점]
1. id 하나만으로 식별 할 수 있기 때문에 관리하기 편합니다.
2. 조회속도가 빠르기 때문에 성능 최적화에 용이합니다.

[단점]
1. 복잡한 로직을 사용하고 싶을때에는 추가적인 코드를 작성해야 합니다.
2. 데이터 레코드 간의 논리적인 연산을 직관적으로 표현 할 수 없습니다.

필드 무시

Room에서는 굳이 저장하고 싶지 않은 데이터를 저장하지 않을 수 있습니다.
이러한 개념을 필드 무시라고 칭합니다.
필드무시가 사용 될 때는 다음과 같은 이유가 있습니다.

1. 앱에서 사용되기는 하지만 굳이 저장할 필요 없는 데이터
2. 상속 구조에서 부모 클래스의 필드를 무시하고 싶을 때
3. 런타임에서 계산된 값임과 동시에 다음 런타임에 재사용되지 않을 데이터

주의 할점
@Ignore가 마킹된 필드는 저장되지 않을 것이기 때문에 데이터 베이스의 열로 간주되지 않도록 해야 합니다. 그러나 이 때 복합 기본 키를 사용하게 되면 다음과 같은 에러가 발생하게 됩니다.

error: Entities and POJOs must have a usable public constructor. You can have an empty constructor or a constructor whose parameters match the fields (by name and type).
public final class ExcerciseEntity {
^
Tried the following constructors but they failed to match:
ExcerciseEntity(java.lang.String,int,java.lang.String) -> [param:name -> matched field:name, param:setNum -> matched field:setNum, param:ignore -> matched field:unmatched]

위의 에러 메시지는 다음과 같은 에러 메시지 내용입니다.
"Entities and POJOs must have a usable public constructor"라는 문장이 핵심문장입니다.
이는 룸이 데이터베이스 엔티티를 생성 할 때 사용할 수 있는 생성자를 찾아내지 못했다는 의미입니다.

룸에서 엔티티가 마킹된 클래스 객체들을 데이터 테이블의 각각의 데이터 레코드에 매핑하려고 할 때에는 "클래스의 생성자에 정의된 파라미터들이 클래스의 필드와 '변수명, 자료형'이 일치"해야 합니다.
Room에서 생성자와 필드의 개념은 다음과 같습니다. 아래 코드에서 함께 설명하겠습니다.

@Entity(primaryKeys = ["name" , "setNum"])
data class ExcerciseEntity(
    val name : String,
    val setNum : Int,
    @Ignore val ignore: String? = null
)

생성자는 객체 생성 시 호출되는 함수이며, 필드는 클래스 내에서 데이터를 저장하는 변수입니다. 이때 @Ignore가 마킹된 ignore는 필드에서 무시되기 때문에 생성자와 필드가 일치하지 않게 됩니다.
따라서 보조 생성자를 사용하여 해당 클래스의 생성자와 필드를 일치시켜야 합니다.

아래코드는 보조생성자를 사용하여 클래스의 생성자와 필드를 일치시킨 코드입니다.

@Entity(primaryKeys = ["name" , "setNum"])
data class ExcerciseEntity(
    val name : String,
    val setNum : Int,
    @Ignore val ignore: String? = null
)
{
    constructor(name : String , setNum: Int) : this(name , setNum , null)
}

위 코드처럼 @Ignore가 마킹된 생성자가 있다면 룸은 해당 필드를 인식하지 못하므로 , 생성자는 3개 , 필드는 2개 로 인식하게 됩니다.
따라서 생성자에서 ignore를 제거한 후 해당 클래스의 보조생성자에 넣어 해당 클래스에게 생성자에 대한 정보를 제공해야 합니다. 이렇게 하여 객체가 생성될 때 보조생성자를 통해 객체가 생성되므로 생성자와 필드가 일치하게 되어 에러가 사라지게 됩니다.

일반적으로는 엔티티의 생성자와 필드가 자동으로 일치하기 때문에(data class가 자동생성) 명시적으로 정의하지 않아도 됩니다. 하지만 @Ignore가 적용된 클래스에서는 자동으로 생성자를 인시하지 못하므로 보조 생성자를 통해서 생성자를 명시적으로 작성해줘야 합니다.
그결과 아래와 같이 ignore는 데이터 레코드에 기록되지 않게 됩니다.

1.앱에서 사용되기는 하지만 굳이 저장할 필요가 없는 데이터

@Entity
data class User(
    @PrimaryKey val id: Int,
    val firstName: String?,
    val lastName: String?,
    @Ignore val picture: Bitmap?
)

위 코드는 공식문서에 작성된 예시코드입니다.
@Ignore가 마킹된 변수는 데이터 테이블에 저장되지 않습니다.
해당 변수는 런타임에서만 사용되고 , picture 필드는 메모리 상에서만 존재하고 서버에 업로드 될 가능성은 있지만 룸에는 절대로 저장되지 않습니다.

  1. 상속 구조에서 부모 클래스의 필드를 무시하고 싶을 때
    만약 상속구조에서 부모 클래스의 필드를 무시하고 싶은 경우네는 아래와 같은 코드를 작성해주면 됩니다.
open class User {
    var picture: Bitmap? = null
}
@Entity(ignoredColumns = ["picture"])
data class RemoteUser(
    @PrimaryKey val id: Int,
    val hasVpn: Boolean
) : User()

위 코드처럼 User를 상속 받았을 때 @Entity의 parameter에 무시하고 싶은 열의 변수명을 작성해주면 됩니다. ignore를 사용해도 되지만 공식문서상에서 "ignoredColumns" 속성의 사용이 더 쉽다고 "ignoredColumns"의 사용을 권장하고 있습니다.

3.런타임에서 계산된 값임과 동시에 다음 런타임에 재사용되지 않을 데이터
1번의 이유와 비슷한 이유이지만 1번으 서버에 저장될 가능성이 있지만 3번의 경우에는 아예 어딘가에도 저장할 필요없이 단순 계산 목적으로 사용 될 때 해당 필드를 무시 할 수 있습니다.

객체 간 관계 정의(ORM)

Room은 관계형 데이터베이스(ORM)이기 때문에 객체가 서로 서로를 참조 할 수 있을 것 같지만 , Room은 이러한 상호참조를 명시적으로 금지하고 있습니다. 이러한 이유는 공식문서에서 다음과 같이 설명하고 있습니다.

  1. 서버측과 클라이언트 측의 차이
    서브측에서는 잘 작동하지만 , 클라이언트 측(우리가 작성하는 앱)의 입장에서는 로드가 지연 될 때 UI 스레드에서 발생하기 때문에 성능 문제를 야기 할 수 있습니다.
  1. UI Thread에서 성능 문제
    UI 스레드는 애플리케이션의 업데이트된 레이아웃을 계산하고 그리는 데 약 16ms가 소요됩니다. 만약 데이터베이스 쿼리가 5ms 정도 걸린다면, 이 시간도 UI 스레드에 추가되어 시각적 결함을 유발할 수 있습니다. 또한 병렬로 실행 중인 별도의 트랜잭션이나 다른 디스크 집약적인 작업으로 인해 쿼리가 더 오래 걸릴 수 있습니다.
  1. 메모리 소비 문제
    지연 로드를 사용하지 않으면 필요한 것보다 더 많은 데이터를 가져오게 되어 메모리 소비가 증가할 수 있습니다.
  1. 확장성 문제
    UI가 변경됨에 따라 공유된 모델이 문제를 일으킬 수 있습니다.
  1. 데이터 로드의 유연성 저하 문제
    데이터를 미리 쿼리하면 나중에 데이터 로드 방식을 변경하기 어렵습니다. 예를 들어, UI에서 Author 정보를 더 이상 표시하지 않더라도 여전히 데이터를 로드하게 됩니다. 이는 메모리 낭비를 초래할 수 있습니다.

위와 같은 이유로 상호참조를 금지하고 , 각 항목을 포함하는 POJO를 생성한 후 테이블을 조인하는 쿼리의 사용을 권장하고 있습니다.

객체 간 참조 할 수 있는 Room에서 제공하는 두가지 방법

1.중간 데이터 클래스
이 부분은 취준 마감되고 작성하겠습니다 마감기한이 얼마 남지 않았네요ㅠ

2.멀티 매핑 반환 유형
멀티 매핑 반환은 Dao에서 Query 메서드 자체만으로 데이터 테이블 간의 참조가 가능합니다. 이는 중간 데이터 클래스 방식과는 다르게 새로운 데이터 클래스를 추가로 정의하지 않고도 복잡한 관계를 가진 데이터 테이블 간의 참조가 가능하며 , SQL에서 지원하는 join 연산자를 사용하면 됩니다.
공식문서에서도 해당 방식을 권장하고 있습니다.

일단 데이터베이스를 하나 만들겠습니다.

@Database(entities = [User::class, Book::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract val UserDao: UserDao
    abstract val BookDao: BookDao
    abstract val libraryDao : LibraryDao
}

해당 데이터베이스에는 2개의 데이터 테이블(User , Book)이 존재합니다.
또한 libraryDao라는 Dao가 속해있는데 해당 Dao에서 User 테이블과 Book 테이블을 멀티 매핑해서 반환하도록 하겠습니다.

두번째로는 테이블 두개를 작성해줍니다.

@Entity(tableName = "user")
data class User(
    @PrimaryKey val id: Int,
    val name: String
)
@Entity(tableName = "book")
data class Book(
    @PrimaryKey val id: Int,
    val title: String,
    val user_id: Int
)

세번째로는 다오를 3개 작성해줍니다.

@Dao
interface LibraryDao {
    @Query(
        "SELECT * FROM user " +
                "JOIN book ON user.id = book.user_id"
    )
    fun loadUserAndBookNames(): Flow<Map<User, List<Book>>>
}
@Dao
interface UserDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(user: User)
    @Query("SELECT * FROM user")
    suspend fun getAllUsers(): List<User>
}
@Dao
interface BookDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(book: Book)
    @Query("SELECT * FROM book")
    suspend fun getAllBooks(): List<Book>
}

위 코드에서 LibraryDao 보면 Query 문 자체적으로 SQL의 연산자 JOIN을 사용하여 user 테이블과 book 테이블을 결합하여 데이터를 가져오는 함수가 존재합니다. 해당 함수는 user에 해당하는 book데이터를 가져옵니다. 이 함수를 MainActity에서 아래와 같이 코드를 수동으로 주입한 후 로그를 찍어보았습니다.

val user = User(id = 1, name = "John Doe")
val book1 = Book(id = 1, title = "Android Development1", user_id = 1)
val book2 = Book(id = 2, title = "Android Development2", user_id = 1)
val book3 = Book(id = 3, title = "Android Development3", user_id = 1)
libraryViewModel.insertUser(user)
libraryViewModel.insertBook(book1)
libraryViewModel.insertBook(book2)
libraryViewModel.insertBook(book3)
val s = libraryViewModel.userAndBooks.collectAsState().value

그렇다면 아래와 같은 로그 기록이 찍히게 됩니다.

이처럼 멀티 매핑 반환 유형 접근 방식을 사용할때는 Query에서 JOIN 연산자를 활용해서 테이블의 데이터를 참조 할 수 있습니다.

중간 데이터 클래스 또는 멀티 매핑 반환 유형 중 하나의 방법을 앱의 구조에 가장 적합한 방법을 택하여 사용하면 됩니다.
중간 데이터 크래스 접근 방식을 사용하게 되면 복잡한 쿼리를 작서앟지 않아도 되지만 추가 적인 데이터 클래스가 필요하여 코드의 복잡성이 증가할 수 있습니다.

즉, 멀티매핑 반환 유형 접근 방식을 사용하게 되면 쿼리 코드를 더 많이 작성해야 하며 , 중간 데이터 클래스 접근 방식에서는 코드를 더 많이 작성해야하는 특징이 있습니다.
공식문서에서는 중간 데이터 클래스가 반드시 필요한 경우가 아니라면 멀티 매핑 반환 유형 접근 방식을 권장하고 있습니다.

profile
포기하지 말기

0개의 댓글