SQLite는 안드로이드 운영체제에서 번들로 제공하는 데이터베이스 관리 시스템이다. Room 퍼시스턴스 라이브러리는 안드로이드 아키텍처 컴포넌트에 포함되어 있으며, 안드로이드 아키텍처 가이드라인을 준수하면서 안드로이드 앱에 데이터베이스 저장소 지원을 쉽게 추가하기 위해 디자인되었다.
모든 데이터베이스 테이블은 각 행을 고유하게 식별할 수 있는 하나 이상의 열을 포함한다. 데이터베이스 용어로는 이를 기본 키라 부른다. 기본 키를 이용하면 데이터베이스 관리 시스템은 항상 테이블의 특정 행을 고유하게 식별할 수 있다.
SQLite는 내장 관계형 데이터베이스 관리 시스템이다. 대부분의 관계형 데이터베이스(Oracle, MySQL)는 데이터베이스 접근을 요구하는 애플리케이션에 독립적으로 실행되는 스탠드얼론 서버 프로세스다. SQLite은 애플리케이션에 연결된 라이브러리 형태로 제공되기 때문에 내장형이라 불린다. 그렇기 때문에 백그라운드에서 스탠드얼론 데이터베이스 서버가 동작하지 않는다. 모든 데이터베이스 조작은 애플리케이션 안에서 SQLite 라이브러리에 포함된 함수를 호출해서 수행한다.

Room 퍼시스턴스 라이브러리를 이용해 SQLite 데이터베이스를 다루는 것과 관련된 핵심 요소들은 다음과 같다.
repository 모듈은 앱이 사용하는 모든 데이터 소스를 직접 조작하는 데 필요한 모든 코드를 포함한다. 이는 UI 컨트롤러나 ViewModel이 데이터베이스나 웹 서비스 같은 소스에 직접 접근하는 코드를 포함하는 것을 방지한다.
room 데이터베이스 객체는 내부 SQLite 데이터베이스에 대한 인터페이스를 제공한다. 또한 데이터 접근 객체(DAO)에 접근할 수 있는 저장소를 제공한다. 앱은 하나의 room 데이터베이스 인스턴스를 포함하며, 이를 이용해 여러 데이터베이스 테이블에 접근한다.
DAO는 SQLite 데이터베이스 안에서 데이터를 삽입, 추출, 삭제하는 저장소가 필요로 하는 SQL 구문들을 포함한다. SQL 구문은 저장소 안에서 호출되는 메서드로 매핑되어 있으며, 이에 해당하는 쿼리들을 실행한다.
엔티티는 데이터베이스 안의 테이블에 대한 스키마를 정의하는 클래스로 테이블 이름, 열 이름, 데이터 타입을 정의하고 어떤 열이 기본 키인지 식별한다. 엔티티 클래스는 데이터 필드들에 접근하는 getter, setter 메서드를 포함한다.
SQLite 데이터베이스는 데이터를 저장하고 데이터에 대한 접근을 제공한다. 저장소를 포함하는 앱 코드는 내부 데이터베이스에 직접 접근해서는 안된다. 모든 데이터베이스 조작은 Room 데이터베이스, DAO, 엔티티를 조합해서 수행한다.

위의 아키텍처 다이어그램에서 각각의 의미는 다음과 같다
1. 저장소는 Room 데이터베이스와 상호작용을 해서 데이터베이스 인스턴스를 얻고 이를 이용해 DAO 인스턴스에 대한 참조를 얻는다.
2. 저장소는 엔티티 인스턴스를 만들고 데이터를 설정한 뒤 DAO로 전달해 검색과 삽입 조작을 수행한다.
3. 저장소는 데이터베이스에 삽입할 엔티티를 DAO에 전달해서 호출한다. 검색 쿼리의 응답으로 엔티티 인스턴스를 돌려받는다.
4. DAO가 저장소에 반환할 결과를 가진 경우, 해당 결과들을 엔티티 객체에 패키징한다.
5. DAO는 Room 데이터베이스와 상호작용해서 데이터베이스 조작을 시작하고, 결과를 처리한다.
6. Room 데이터베이스는 쿼리를 전달하고 결과를 받는 등, 내부 SQLite와의 모든 저수준 인터랙션을 처리한다.
각 데이터베이스 테이블은 하나의 엔티티 클래스와 연관된다. 이 클래스는 테이블의 스키마를 정의하고 특별한 Room 에너테이션을 가진 표준 코틀린 클래스의 형태를 갖는다.
class Customer {
var id: Int = 0
var name: String? = null
var address: String? = null
constructor() {}
constructor(id: Int, name: String, address: String) {
this.id = id
this.name = name
this.address = address
}
constructor(name: String, address: String) {
this.name = name
this.address = address
}
}
위의 코드는 기본 코틀린 클래스이다. 이를 엔티티로 만들어 SQL 구문 안에서 접근할 수 있게 하려면 다음과 같이 수정해야 한다.
@Entity(tableName = "customers")
class Customer {
@PrimaryKey(autoGenerate = true)
@NonNull
@ColumnInfo(name = "customerId")
var id: Int = 0
@ColumnInfo(name = "customerName")
var name: String? = null
@Ignore
var address: String? = null
constructor() {}
constructor(id: Int, name: String, address: String) {
this.id = id
this.name = name
this.address = address
}
constructor(name: String, address: String) {
this.name = name
this.address = address
}
}
@Entity 애너테이션을 통해 설정한 테이블 이름은 DAO의 SQL 구문에서 참조하게 된다.
이 테이블은 기본 키로 id를 사용하고 있고 autoGenerate를 통해 자동 생성되도록 설정했다.
address 필드에는 열 이름을 할당하지 않았다. 이는 address 데이터는 데이터베이스 안에 여전히 저장되지만 SQL 구문 안에서 참조될 필요는 없음을 의미한다. 엔티티 안의 필드를 데이터베이스에 저장하지 않아도 된다면 @Ignore 애너테이션을 추가한다.
데이터 접근 객체를 이용하면 SQLite 데이터베이스 안에 저장된 데이터에 접근할 수 있다. @DAO 애너테이션을 이용해 DAO로 선언한다.
@Dao
interface CustomerDao {
@Query("SELECT * FROM customers")
fun getAllCustomers(): LiveData<List<Customer>>
}
위의 코드는 getAllCustomers()라는 메서드를 통해 customers 테이블의 모든 행을 추출하도록 선언하는 코드 예시다. getAllCustomers() 메서드는 데이터베이스 테이블에서 추출한 각 레코드에 대해 하나의 Customer 엔티티 객체를 포함하는 List 객체를 반환한다. DAO는 LiveData를 이용하므로 저장소는 데이터베이스의 변경사항을 관찰한다.
@Query("SELCET * FROM customers WHERE name = :customerName")
fun findCustomer(customerName: String): List<Customer>
이 예시에서 메서드는 문자열값을 전달받고, 변수 이름에 접두사 콜론(:)을 붙여서 SQL 구문 안에 포함된다. 기본 삽입 조작은 @Insert 애네테이션을 이용해 다음과 같이 선언할 수 있다.
@Insert
fun addCustomer(Customer customer)
다음 DAO 선언은 제공한 고객 이름과 일치하는 모든 레코드를 삭제한다.
@Query("DELETE FROM customers WHERE name = :name")
fun deleteCustomers(String name)
다음은 deleteCustomers() 메서드에 전달된 엔티티 집합과 일치하는 모든 Customer 레코드를 데이터베이스에서 삭제한다.
@Delete
fun deleteCustomers(Customer... customers)
@Update 애너테이션을 이용하면 레코드를 업데이트할 수 있다.
@Update
fun updateCustomers(Customer... customers)
Room 데이터베이스 클래스는 새로운 room 데이터베이스 인스턴스 생성 및 반환, 해당 데이터베이스와 관련된 DAO 인스턴스 접근 제공에 관한 책임을 진다. 각 안드로이드 앱은 하나의 room 데이터베이스 인스턴스만 가질 수 있으므로, 클래스 안에서 인스턴스를 하나만 만들도록 방어 코드를 구현하는 것이 좋다.
@Database(entries = [(Customer::class)], version = 1)
abstract class CustomerRoomDatabase: RoomDatabase() {
abstract fun customerDao(): CustomerDao companion object {
private var INSTANCE: CustomerRoomDatabase? = null
fun getInstance(context: Context): CustomerRoomDatabase {
synchronized(this) {
var instance = INSTANCE
if (instance == null) {
instance = Room.databaseBuilder(
context.applicationContext,
CustomerRoomDatabase::class.java,
"customer_database"
).fallbackToDestructiveMigration()
.build()
INSTANCE = instance
}
return instance
}
}
}
}
위의 코드는 customer 테이블을 이용해 Room 데이터베이스를 구현한 간략한 코드이다.
저장소는 데이터베이스 조작을 수행하는 DAO 메서드를 호출하는 코드를 포함한다.
class CustomerRepository(private val customerDao: CustomerDao) {
private val coroutineScope = CoroutineScope(Dispatchers.Main)
fun insertCustomer(customer: Customer) {
coroutineScope.launch(Dispatchers.IO) {
customerDao.insertCustomer(customer)
}
}
fun deleteCustomer(name: String) {
coroutineScope.launch(Dispatchers.IO) {
customerDao.deleteCustomer(name)
}
}
}
저장소가 Dao에 접근한 뒤 데이터 접근 메서드를 호출할 수 있다. 다음은 getAllCustomers() Dao 메서드를 호출하는 코드 예시다.
val allCustomers: LiveData<List<Customer>>?
customerDao.getAllCustomers()
DAO 메서드를 호출할 때는 해당 메서드가 LiveData 인스턴스(이 인스턴스는 자동으로 별도의 스레드에서 쿼리를 실행한다)를 반환하지 않으면, 해당 조작을 앱의 메인 스레드에서 수행할 수 없다는 점에 주의해야 한다.
일부 데이터베이스 트랜잭션은 완료하기까지 오랜 시간이 걸리므로, 분리된 스레드에서 해당 작업을 실행함으로써 앱이 중지된 것처럼 보이는 것을 방지할 수 있다. 이 문제는 코루틴을 이용하면 쉽게 해결할 수 있다.
모든 클래스를 선언했다면 데이터베이스 인스턴스, DAO, 저장소를 만들고 초기화해야 한다.
private val repository: CustomerRepository
val customerDb = CustomerRoomDatabase.getInstance(application)
val customerDao = customerDb.customerDao()
repository = CustomerRepository(customerDao)