개발을 하다보면 조금 많은 양의 데이터들을 기기 내에 저장해놓고 필요할 때마다, 잦은 빈도로 불러와야 하는 경우가 생깁니다.
데이터를 저장하는 방법에는 서버에 연결을 하여 서버 데이터베이스에 저장을 하는 방법과 기기내부의 데이터베이스에 저장하는 방법이 있습니다.
후자의 경우 전자보다 규모와 처리할 수 있는 양은 비교적 적지만 속도가 매우 빠르다는 차이점이 있습니다.
안드로이드에서는 어플리케이션의 효과적인 데이터 관리를 위하여 구조화된 내부 SQL Database인 SQLite Database를 지원하고 있으며 이를 다루기 위한 라이브러리가 Room입니다.
Room 지속성 라이브러리는 SQLite에 추상화 계층을 제공하여 SQLite를 완벽히 활용하면서 더 견고한 데이터베이스 액세스를 가능하게 합니다.
// RoomDB
implementation 'androidx.room:room-runtime:2.3.0'
implementation 'androidx.room:room-ktx:2.3.0'
kapt 'androidx.room:room-compiler:2.3.0'
위의 안드로이드 공식 개발문서를 참고하여 적절한 종속성들을 추가해 주어야 RoomDB 라이브러리를 사용할 수 있습니다.
Room 라이브러리를 사용하기 위해서는 다음과 같은 세가지 요소들을 구현해주어야 합니다.
관련이 있는 속성들이 모여 하나의 정보 단위를 이룬 것을 의미합니다.
@Entity(tableName = "SongTable")
data class Song(
@ColumnInfo("musicTitle") var title : String = "",
var artist : String = "",
var albumTitle : String = "",
@PrimaryKey val music : String = "",
var playTime : Int = 0,
var currentMillis : Int = 0,
var isLike : Boolean = false,
val coverImg : Int = R.drawable.img_album_exp2,
@Ignore val dummyData : Int = 0
) {
//
// @PrimaryKey(autoGenerate = true) var id : Int = 0
}
데이터 클래스에 @Entity 어노테이션을 붙여서 구현할 수 있습니다.
관계형 데이터베이스의 테이블이 이 Entity를 통해서 만들어진다고 생각하면 됩니다.
즉 SongTable이라는 이름을 가진 테이블에 데이터 클래스의 인자로 들어오는 title, artist... 들이 필드 또는 칼럼명이 되는 것입니다. (어노테이션 뒤에 테이블 명을 지정해주지 않으면 자동으로 클래스 이름이 테이블 명이 됩니다.)
또한 @ColumnInfo("칼럼명") 를 통해서 임의로 칼럼명을 지정해줄 수 있습니다. 지정해주지 않는다면 변수명이 그대로 칼럼명이 됩니다.
SQLite에서는 대소문자를 구분하지 않습니다.
굳이 데이터베이스에 넣지않고 해당 데이터 클래스로만 사용하고 싶은 변수가 있을 수 있습니다. 이때에는 @Ignore 어노테이션을 붙이면 해당 변수는 칼럼으로 만들지 않습니다.
PrimaryKey(기본키)가 하나 필수로 들어가야 하며 autoGenerate = true 로 설정해주었을 시 autoIncrement설정이 되어 1부터 시작하여 자동으로 1씩 증가하는 Integer값이 할당되게 됩니다.
autoIncrement의 자세한 할당 규칙에 대해서는 MySQL의 autoIncrement 에 대해 검색해보시면 됩니다.
PrimaryKey를 위와같이 직접 지정해줄 수도 있으나, 절대 중복되는 값이 생기면 안됩니다.
Data Access Object의 줄임말입니다.
@Query/@Insert/@Delete/@Update 들을 정의하여 해당 메소드의 동작을 지정해줍니다.
@Dao
interface SongDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(song:Song)
@Update
fun update(song:Song)
@Delete
fun delete(song:Song)
@Query("SELECT * FROM SongTable")
fun getSongs() : List<Song>
@Query("SELECT * FROM SongTable WHERE music = :music")
fun getSong(music : String) : Song
@Query("UPDATE SongTable SET isLike = :isLike WHERE music = :music")
fun updateIsLikeByMusic(isLike : Boolean, music : String)
@Query("SELECT * FROM SongTable WHERE isLike = :isLike")
fun getIsLikedSongs(isLike : Boolean) : List<Song>
}
@Insert/@Update/@Delete 의 경우 데이터의 삽입,업데이트,삭제를 해주는 메서드로 동작하게 됩니다. 특별히 @Insert의 경우에는 뒤에 onConflict 를 정의해줄 수 있는데 이는 위의 Entity 파트에서 언급했던 PrimaryKey가 겹치는 경우 어떻게 처리해줄 것인지에 대해 알려주는 코드입니다.
[링크]를 참고하여 혹시라도 PrimaryKey가 겹치는 일이 생길 때에 대비해 적절한 대응을 해주면 되겠습니다.
이외에 @Query 어노테이션을 통해 직접 사용할 쿼리문을 정의해 줄 수도 있습니다.
@Database(entities = [Song::class], version = 1)
abstract class SongDB : RoomDatabase() {
abstract fun SongDao() : SongDao
companion object{
private var instance : SongDB? = null
@Synchronized
fun getInstance(context: Context) : SongDB? {
if (instance == null){
synchronized(SongDB::class){
instance = Room.databaseBuilder(
context.applicationContext,
SongDB::class.java,
"user-database"
).fallbackToDestructiveMigration().build()
}
}
return instance
}
}
}
먼저 RoomDatabase()클래스를 상속받는 추상클래스를 정의해 준 뒤, @Database 어노테이션으로 데이터베이스임을 표시해줍니다.
어노테이션의 괄호 안에는 위에서 정의해 주었던 Entity(테이블)들과 version이 들어갑니다.
앱을 계속 개발하고 업데이트를 진행하다보면 entity의 구조가 변경될 일이 생깁니다. 이 때 이전 구조와 현재 구조를 구분해 주는 역할을 하는 것이 version이기 때문에 데이터베이스 구조가 바뀌고, 이전의 데이터들을 보존해야 하는 상황이라면 버전을 꼭 올려주어야 합니다.
그렇지 않다면 다음과 같은 오류를 마주하게 됩니다.
Looks like you've changed schema but forgot to update the version number. You can simply fix this by increasing the version number.
객체를 생성할 때 databaseBuilder라는 static 메서드를 사용하는데
context와, database 클래스 그리고 데이터 베이스를 저장할 때 사용할 데이터베이스의 이름을 정해서 넘겨주면 됩니다.
이때 마지막으로 넘겨준 데이터베이스의 이름이 다른 데이터베이스랑 겹치면 꼬여버리니 주의해야 합니다.
데이터베이스를 빌드하는 과정은 매우 무겁고 자원소모가 크기 때문에 메인스레드에서 작업하는 것을 기본적으로 허용하지 않습니다. 따라서 스레드를 따로 생성하여 데이터베이스에 접근해야 하지만, 굳이 메인스레드에서 작업을 해야 한다면
Room.databaseBuilder(context, DBclass, name).allowMainThreadQueries().build()
를 통해서 허용해줄 수 있습니다.
안드로이드 공식문서에서는 다음과 같이 데이터베이스 객체를 인스턴스화 할 때 에는 리소스를 많이 소비하기 때문에 싱글톤 패턴을 사용할 것을 권장하고 있습니다.
애플리케이션이 시작될 때 어떤 클래스가 최초 한번만 메모리를 할당하고 이후 똑같은 객체를 여러번 생성하지 않고 최초 생성된 하나의 객체만 계속해서 사용하는 디자인 패턴입니다.
다음의 블로그에서 코틀린으로 싱글톤 패턴을 구현하는 방법에 대해 다루므로 참고하면 되겠습니다.
참고 블로그 주소 : https://bacassf.tistory.com/59
코틀린의 경우 object로 클래스를 정의하면 바로 싱글톤 패턴이 되며, companion object를 사용해서 자바처럼 구현할 수도 있습니다.
따라서 위의 데이터베이스 클래스는 이러한 companion object 싱글톤 패턴을 사용하여 구현되었음을 알 수 있습니다.
companion object 내에 private으로 instance를 생성하여 외부에서 접근할 수 없게 하였습니다.
그 다음에 외부에서 접근할 때에는 getInstance함수를 사용하여 companion object내부에서 생성된 instance에 접근하는 방식으로 구현한 것입니다.
이렇게 한다면 외부에서 객체를 새로 생성하지 않고도 최초 한번 생성한 companion object 내의 instance만 사용하게 되겠죠?
데이터베이스의 구조가 바뀌면 버전을 올려주어야 한다고 위에서 설명했습니다.
그렇다면 이전버전의 데이터들은 어떻게 처리해야 하는걸까요?
데이터 구조가 바뀌었을 때, 이전 버전의 데이터들을 다 지울 것인지, 또는 어떤 데이터가 추가되고 바뀌었는지를 알려주고 이전의 데이터들을 보존할 것인지를 정의하는 것이 Migration 입니다.
개발자 공식문서 : https://developer.android.com/training/data-storage/room/migrating-db-versions?hl=ko
@Database(entities = [Song::class], version = 1)
abstract class SongDB : RoomDatabase() {
abstract fun SongDao() : SongDao
companion object{
private var instance : SongDB? = null
@Synchronized
fun getInstance(context: Context) : SongDB? {
if (instance == null){
synchronized(SongDB::class){
instance = Room.databaseBuilder(
context.applicationContext,
SongDB::class.java,
"user-database"
).fallbackToDestructiveMigration().build()
}
}
return instance
}
}
}
databaseBuilder.build()이전에 .fallbackToDestructiveMigration()을 호출하면 버전이 올라갔을 때 이전의 테이블을 모두 삭제하고 새로 만들게 됩니다. 새로운 버전의 테이블은 만들어지지만, 이전에 저장되었던 사용자들의 데이터들이 모두 유실되므로 실제 서비스를 제공함에 있어서 유실되면 안되는 데이터를 다룰때에는 절대절대절대 이 메서드를 사용하면 안됩니다.@Database(entities = [Song::class], version = 2)
abstract class SongDB : RoomDatabase() {
abstract fun SongDao() : SongDao
companion object{
private var instance : SongDB? = null
@Synchronized
fun getInstance(context: Context) : SongDB? {
if (instance == null){
synchronized(SongDB::class){
instance = Room.databaseBuilder(
context.applicationContext,
SongDB::class.java,
"user-database"
).addMigrations(MIGRATION_1_2).build()
}
}
return instance
}
// Migration 코드
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE 'expense_table' ADD COLUMN 'category' INTEGER")
}
}
}
}
위의 코드처럼 데이터베이스의 버전이 1에서 2로 올라갔다면, 어떤 부분이 바뀌었는지를 알려주는 코드를 exeSQL() 을 사용해 SQL 쿼리문으로 작성해서 정의해주고,
Room.databaseBuilder가 build()되기 전에 .addMigration()를 호출하여 작성한 migration을 인자로 넘겨주면 변경사항이 적용된 버전2의 데이터베이스가 생성되게 됩니다.
이 과정이 잘 이루어졌다면 이전버전의 데이터가 유지되면서 변경사항이 적용된 데이터베이스가 생성됩니다.
migration 내용은 쓰는 것이 쉽지 않고, crash 를 내기 쉽습니다. 따라서 앱의 안정성을 위해서 migration을 시행하기 이전에 먼저 test 를 해야 합니다.
Room 은 test 를 위해 testing Maven artifact 를 제공하지만 이를 위해서는 db schema export 가 필요합니다. 이는 공식문서에 나와있고 다소 복잡하므로 여기서 다루지는 않겠습니다.
좋은 정보 감사합니다 :>