Jetpack 또는 AAC(Android Architecture Component)의 Room은 SQLite를 추상화한 객체이며, ORM(Object Relational Mapping)입니다. 그렇기 때문에 객체를 사용하듯이 데이터베이스를 사용할 수 있습니다. 하지만 Room에 대한 지식을 습득해야 합니다.
룸은 3개의 주요 Component로 구성되어 있습니다.
- Entity: 데이터베이스 안에 있는 테이블을 표현합니다
- Database: 데이터베이스를 의미합니다
- DAO(Data Access Object): 데이터베이스에 접근하는 메소드들이 있습니다. 해당 메소드에 대한 SQLite 쿼리는 직접 작성하여야 합니다.
Entity
는 데이터베이스 테이블에 대한 정보를 표현하며, Database
는 DAO 객체를 제공하여 데이터베이스를 이용할 수 있는 Access point입니다.
DAO
는 데이터베이스에 접근하는 메소드들이 정의되어 있으며, 해당 메소드가 사용하는 SQLite 쿼리는 직접 작성해야 합니다.
아래 그림은 룸의 구조를 다이어그램으로 표현한 것입니다. 개발자는 3개의 주요 룸 객체를 생성하고 정의해주면 나머지는 라이브러리에서 알아서 처리합니다.
Room 또한 JetPack의 ViewModel과 LiveData와 함께 쓰이도록 설계되었습니다. codelabs에 이를 알아볼 수 있는 간단한 예제가 있습니다. 아래 그림은 codelabs 예제에 대한 시스템 구조이며, JetPack의 다른 객체들과 어떻게 함께 동작하는지 알 수 있습니다. 특히, Repository라는 객체를 만들어 Room 데이터베이스를 관리하도록 하였습니다.
저는 codelabs의 예제를 코틀린과 AndroidX로 리팩토링하였습니다. 이 예제로, Room을 어떻게 사용하는지 알아보겠습니다. 완성된 예제는 GitHub에 있습니다. 파일 위치 등의 자세한 내용은 이 프로젝트를 참고해주세요.
의존성
Recycler, ViewModel, LiveData, Room을 사용하려면 아래와 같은 의존성을 추가해야 합니다.
// RecyclerView
implementation "androidx.recyclerview:recyclerview:1.0.0"
// Room
implementation "androidx.room:room-runtime:2.0.0"
kapt "androidx.room:room-compiler:2.0.0"
testImplementation "androidx.room:room-testing:2.0.0"
// ViewModel and LiveData
implementation "androidx.lifecycle:lifecycle-extensions:2.0.0"
kapt "androidx.lifecycle:lifecycle-compiler:2.0.0"
Entity
Entity
는 데이터베이스의 테이블과 구성요소를 표현합니다.
Word
라는 Entity 클래스를 만듭니다. 예제에서 만드는 Word
Entity는 단순히 스트링 1개를 저장하는 객체입니다.
class Word {
private val word: String
constructor(word: String) {
this.word = word
}
fun getWord() = this.word
}
이 객체에 annotation을 붙여주면 Room이 이해할 수 있는 Entity 객체가 됩니다.
- @Entity(tableName = "word_table") : 테이블 이름을 설정합니다
- @PrimaryKey : Entity를 Primary key로 설정합니다
- @NonNull : 이 Entity의 값은 절대 null이 될 수 없음을 의미합니다
- @ColumnInfo(name = "word") : column 이름을 설정합니다
그리고 각각의 field는 값을 리턴하는 public getter method가 있어야 합니다.
여기서는 word
를 리턴하는 getWord
메소드를 만들었습니다.
@Entity(tableName = "word_table")
class Word {
@PrimaryKey
@NonNull
@ColumnInfo(name = "word")
private val word: String
constructor(word: String) {
this.word = word
}
fun getWord() = this.word
}
DAO(Data Access Object)
DAO 객체는 메소드와 SQLite의 쿼리를 매핑을 해줍니다.
메소드 위에 annotation으로 @Query("...")
를 설정해주면, 저 메소드와 저 쿼리가 매핑이 됩니다.
그래서 저 메소드를 호출하면 설정한 쿼리로 데이터를 가져와서 정해진 형식으로 리턴합니다.
@Insert
의 경우, 개발자가 추가로 쿼리문을 작성하지 않아도 Room이 알아서 데이터를 저장해줍니다.
@Dao
interface WordDao {
@Query("SELECT * from word_table ORDER BY word ASC")
fun getAllwords(): List<Word>
@Insert
fun insert(word: Word)
@Query("DELETE FROM word_table")
fun deleteAll()
}
Room은 LiveData와 함께 사용하도록 설계되었기 때문에 리턴 타입을 LiveData로 설정할 수 있습니다. 이 LiveData 객체에 옵저버를 전달하면, 데이터베이스의 데이터가 변할 때 옵저버로 이벤트를 전달해주기 때문에 추가로 쿼리를 할 필요가 없습니다.
getAllwords
의 리턴타입 List<Word>
를 LiveData<List<Word>>
로 변경하였습니다.
@Dao
interface WordDao {
@Query("SELECT * from word_table ORDER BY word ASC")
fun getAllwords(): LiveData<List<Word>>
@Insert
fun insert(word: Word)
@Query("DELETE FROM word_table")
fun deleteAll()
}
Room database
Room database
는 RoomDatabase
객체를 상속하는 추상 클래스이며, DAO를 리턴하는 추상메소드를 갖고 있습니다.
Room.databaseBuilder
를 이용하여 이 추상 클래스를 객체로 만들 수 있습니다.
이 클래스를 구현할 때 @Database(entities = [Word::class], version = 1)
처럼 데이터베이스에서 사용하는 entity 클래스와 version 정보를 annotation으로 설정해야 합니다.
@Database(entities = [Word::class], version = 1)
abstract class WordRoomDatabase : RoomDatabase() {
abstract fun wordDao(): WordDao
}
위의 코드가 RoomDatabase
코드의 전부입니다.
하지만 이 객체를 생성하는데 비용이 많이 들기 때문에 Singleton 패턴으로 1개의 객체만 만들어지도록 해야 합니다.
아래 코드처럼 1개의 객체만 생성하여 WordRoomDatabase
을 리턴하는 static getter 메소드를 만들었습니다.
추상 클래스 RoomDatabase
를 객체로 생성할 때는 Room.databaseBuilder
를 사용하였습니다.
인자로는 context, 추상클래스, 데이터베이스의 이름이 순서대로 들어갑니다.
@Database(entities = [Word::class], version = 1)
abstract class WordRoomDatabase : RoomDatabase() {
companion object {
@Volatile private var INSTANCE: WordRoomDatabase? = null
fun getDatabase(context: Context): WordRoomDatabase {
if (INSTANCE == null) {
synchronized(this) {
INSTANCE = Room.databaseBuilder(
context.applicationContext,
WordRoomDatabase::class.java, "word_database"
).build()
}
}
return INSTANCE!!
}
}
abstract fun wordDao(): WordDao
}
Repository
지금까지 Room의 주요 객체 3개를 모두 생성하였습니다. 이 객체들을 조합해서 사용하면 되지만, 이 객체들을 관리하고 외부의 인터페이스 역할을 Repository라는 클래스를 만들겠습니다.
WordRepository 클래스는 내부 변수로 WordDao
와 LiveData<List<Word>>
를 갖고 있습니다.
그리고 getAllWords
와 insert
메소드를 갖고 있습니다.
getAllWords
는 LiveData<List<Word>>
를 리턴해주는데, 데이터베이스 내부 객체가 변경되면 옵저버로 변경을 알려주기 때문에
생성자에서 한번만 초기화를 해주고 다시 설정되는 일이 없습니다.
insert
는 요청이 오면 인자로 전달받은 Word
객체를 데이터베이스에 저장합니다.
시간이 오래걸릴 수 있기 때문에 AsyncTask
를 이용하여 비동기적으로 저장하도록 구현하였습니다.
저장할 때는 우리가 구현한 DAO를 사용합니다.
DAO는 메소드와 쿼리가 매핑되어있기 때문에 insert
메소드를 호출하면 우리가 설정한 쿼리로 데이터를 저장합니다.
class WordRepository {
companion object {
private class insertAsyncTask constructor(private val asyncTaskDao: WordDao) :
AsyncTask<Word, Void, Void>() {
override fun doInBackground(vararg params: Word): Void? {
asyncTaskDao.insert(params[0])
return null
}
}
}
private val wordDao: WordDao
private val allWords: LiveData<List<Word>>
constructor(app: Application) {
val db = WordRoomDatabase.getDatabase(app)
wordDao = db.wordDao()
allWords = wordDao.getAllwords()
}
fun getAllWords(): LiveData<List<Word>> {
return allWords
}
fun insert(word: Word) {
insertAsyncTask(wordDao).execute(word)
}
}
UI
지금까지 Entity, RoomDatabase, DAO 클래스를 구현하였고, 이들을 쉽게 관리할 수 있도록 Repository 클래스를 만들었습니다. 이제 코드에서 Repository 객체만 있으면 우리가 원하는 모든 일을 할 수 있습니다.
이제 데이터베이스에 객체를 저장하고 내용을 보여주는 UI를 만들어보겠습니다. 먼저 View의 데이터를 관리하는 ViewModel을 만들어야 합니다.
AndroidViewModel를 상속하는 WordViewModel 객체를 생성합니다.
ViewModel 내부적으로 Application객체를 사용하기 때문에 꼭 AndroidViewModel를 사용해야 합니다.
아래 코드를 보시면, ViewModel은 repository
와 allWords
를 데이터로 갖고 있습니다.
그리고 insert
메소드로 repository
에게 저장할 데이터를 전달해줍니다.
class WordViewModel : AndroidViewModel {
private val repository: WordRepository
private val allWords: LiveData<List<Word>>
constructor(app: Application) : super(app) {
repository = WordRepository(app)
allWords = repository.getAllWords()
}
fun getAllWords(): LiveData<List<Word>> = allWords
fun insert(word: Word) {
repository.insert(word)
}
}
데이터들은 RecyclerView로 화면에 보여줄 것입니다. (자세한 코드는 완성된 예제를 참고해주세요)
RecyclerView 코드를 모두 구현하고 MainActivity
의 onCreate
에 다음과 같이 초기화합니다.
val adapter = WordListAdapter(this)
recyclerview.adapter = adapter
recyclerview.layoutManager = LinearLayoutManager(this)
recyclerview.addItemDecoration(
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
)
ViewModel도 초기화해줍니다.
그리고 getAllWords
로 LiveData객체를 얻고 옵저버를 설정해줍니다.
옵저버의 내용은 변경된 데이터가 전달되면 바로 RecyclerView 어댑터에 전달하여 화면에 출력해주는 내용입니다.
wordViewModel = ViewModelProviders.of(this).get(WordViewModel::class.java)
wordViewModel.getAllWords().observe(this, Observer<List<Word>> { words ->
adapter.setWords(words)
})
위 코드가 Room과 LiveData를 조합하여 사용하는 부분인데요. 앱을 개발하다보면 데이터 변화를 매번 체크하고 다시 업데이트 코드 많이 짜는데요. 이런 코드를 두줄로 해결할 수 있었습니다.
이제 데이터를 DB에 추가하는 UI를 구현하겠습니다.
FAB버튼을 누르면 Word
를 입력하는 액티비티를 실행하여 입력을 받도록 할 계획입니다.
아래처럼 NewWordActivity를 만듭니다. edit_word
에 문자를 입력하고 button_save
을 누르면 MainActivity로 스트링을 전달하는 구조입니다.
class NewWordActivity : AppCompatActivity() {
companion object {
val EXTRA_REPLY = "com.example.android.wordlistsql.REPLY"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_new_word)
button_save.setOnClickListener {
val replyIntent = Intent()
if (TextUtils.isEmpty(edit_word.text)) {
setResult(Activity.RESULT_CANCELED, replyIntent)
} else {
val word = edit_word.text.toString()
replyIntent.putExtra(EXTRA_REPLY, word)
setResult(Activity.RESULT_OK, replyIntent)
}
finish()
}
}
}
MainActivity는 NewWordActivity로부터 전달된 스트링을 wordViewModel.insert
로 저장합니다.
데이터를 저장하면 옵저버로 이벤트가 전달되어 UI를 업데이트합니다.
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == NEW_WORD_ACTIVITY_REQUEST_CODE && resultCode == RESULT_OK) {
val word = Word(data!!.getStringExtra(NewWordActivity.EXTRA_REPLY))
wordViewModel.insert(word)
} else {
Toast.makeText(applicationContext, "Word not saved because it is empty",
Toast.LENGTH_LONG).show()
}
}
정리
Room에 대해서 알아보았습니다. Room은 안드로이드의 ORM(Object Relational Mapping)이며 LiveData와 ViewModel과 함께 사용할 수 있습니다. 순수 SQLite를 사용하는 것보다 Room을 사용하는 것이 읽기 쉽고 관리하기 쉬운 코드가 될 수 있습니다.
참고
Related Posts
- Android 14 - 사진/동영상 파일, 일부 접근 권한 소개
- Android - adb push, pull로 파일 복사, 다운로드
- Android 14 - 암시적 인텐트 변경사항 및 문제 해결
- Jetpack Compose - Row와 Column
- Android 13, AOSP 오픈소스 다운로드 및 빌드
- Android 13 - 세분화된 미디어 파일 권한
- Android 13에서 Notification 권한 요청, 알림 띄우기
- Android 13에서 'Access blocked: ComponentInfo' 에러 해결
- 에러 해결: android gradle plugin requires java 11 to run. you are currently using java 1.8.
- 안드로이드 - 코루틴과 Retrofit으로 비동기 통신 예제
- 안드로이드 - 코루틴으로 URL 이미지 불러오기
- Android - 진동, Vibrator, VibrationEffect 예제
- Some problems were found with the configuration of task 에러 수정
- Query method parameters should either be a type that can be converted into a database column or a List
- 우분투에서 Android 12 오픈소스 다운로드 및 빌드
- Android - ViewModel을 생성하는 방법
- Android - Transformations.map(), switchMap() 차이점
- Android - Transformations.distinctUntilChanged() 소개
- Android - TabLayout 구현 방법 (+ ViewPager2)
- Android - 휴대폰 전화번호 가져오는 방법
- Android 12 - Splash Screens 알아보기
- Android 12 - Incremental Install (Play as you Download) 소개
- Android - adb 명령어로 bugreport 로그 파일 추출
- Android - adb 명령어로 App 데이터 삭제
- Android - adb 명령어로 앱 비활성화, 활성화
- Android - adb 명령어로 특정 패키지의 PID 찾기
- Android - adb 명령어로 퍼미션 Grant 또는 Revoke
- Android - adb 명령어로 apk 설치, 삭제
- Android - adb 명령어로 특정 패키지의 프로세스 종료
- Android - adb 명령어로 screen capture 저장
- Android - adb 명령어로 System 앱 삭제, 설치
- Android - adb 명령어로 settings value 확인, 변경
- Android 12 - IntentFilter의 exported 명시적 선언
- Android - adb 명령어로 공장초기화(Factory reset)
- Android - adb logcat 명령어로 로그 출력