HOME > android > jetpack

Android Jetpack - Room 소개 및 구현 방법

By JS | 17 Dec 2018

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개의 주요 룸 객체를 생성하고 정의해주면 나머지는 라이브러리에서 알아서 처리합니다.

jetpack room

Room 또한 JetPack의 ViewModelLiveData와 함께 쓰이도록 설계되었습니다. codelabs에 이를 알아볼 수 있는 간단한 예제가 있습니다. 아래 그림은 codelabs 예제에 대한 시스템 구조이며, JetPack의 다른 객체들과 어떻게 함께 동작하는지 알 수 있습니다. 특히, Repository라는 객체를 만들어 Room 데이터베이스를 관리하도록 하였습니다.

jetpack 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 databaseRoomDatabase 객체를 상속하는 추상 클래스이며, 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 클래스는 내부 변수로 WordDaoLiveData<List<Word>>를 갖고 있습니다. 그리고 getAllWordsinsert 메소드를 갖고 있습니다.

getAllWordsLiveData<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은 repositoryallWords를 데이터로 갖고 있습니다. 그리고 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 코드를 모두 구현하고 MainActivityonCreate에 다음과 같이 초기화합니다.

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을 사용하는 것이 읽기 쉽고 관리하기 쉬운 코드가 될 수 있습니다.

참고