Room + ALL WAL(Write-Ahead Logging)
스마트폰 내장 DB에 데이터를 저장하는 라이브러리인 Room에 대해 학습한다.
Room
1. Room 탄생 배경
기존에 안드로이드는 SQLite를 이용해 DB 작업을 했지만 여러 문제가 있었다.
- SQL 쿼리 관리의 어려움
- 쿼리가 문자열로 작성되기 때문에 컴파일 시 문법 오류를 잡기 어려워 런타임 오류로 이어질 가능성이 크다.
- 데이터베이스 접근 코드의 반복성
- 보일러 플레이트 코드가 많아져 생산성이 떨어진다.
- 스레드 이슈
- 별도의 쓰레드를 생성해 작업이 필수였다.
- 코드의 복잡성
- DB 생성, 쿼리 작성, 데이터 읽기, 쓰기 등 모든 작업을 SQLiteOpneHelper를 통해 직접 처리해야했다.
이런 문제를 해결하고자 Google은 Room을 만들고 사용을 권장하고 있다.
2. Room이란 ?
Room은 SQLite를 추상화해 코드의 간결성과 안전성을 향상 시켜준다. 직접 쿼리를 작성하지 않고 어노테이션과 DAO를 통해 상호작용이 가능하다. SQLite의 저수준 작업을 감싸고 현대적인 개발 패턴과 결합하였다. SQLite의 모든 기능을 지원하면서 더욱 쉽게 활용이 가능하도록 설계된 도구이다.
SQLiteOpenHelper vs Room
특징 | SQLiteOpenHelper | Room |
코드 간결성 | 보일러플레이트 코드가 많다. | 어노테이션으로 구현 가능하다. |
안전성 | 컴파일 타임에 쿼리 오류를 잡을 수 없다. | 컴파일 타임에 쿼리 검증이 된다. |
비동기 작업 | 스레드 관리가 필요하다. | 코루틴, RxJava를 지원한다. |
3. Room의 구성 요소
4. Database
데이터베이스 구성을 하고 영구 데이터에 대한 앱의 기본 엑세스 포인트 역할을 한다. @Database 주석을 달아야하며 테이블인 entities 배열이 포함해야한다. RoomDatabase를 확장하는 추상 클래스로 생성해야하며 DAO 클래스 인스턴스를 반환하는 추상 메소드를 정의해야한다. Room.databaseBuilder를 통해 인스턴스를 생성한다.
@Database(netites = [User::class], version = 1)
abstract class UserDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
val db = Room.databaseBuilder(
applicationContext,
UserDatabase::class.java, "db_name"
).build()
4-1. Database 컬럼이 추가, 삭제 되는 경우엔 ?
SQLiteOpenHelper를 상속 받아
5. Entitiy
데이터베이스의 테이블에 해당된다. 데이터를 저장하는 클래스로 일반적으로 데이터 클래스로 작성되며 각 필드가 데이터베이스의 컬럼으로 매핑된다. SQL 코드를 작성하지 않고 스키마를 정의할 수 있다.
@Entitiy(tableName = "users")
data class User(
@PrimaryKey(autoGenerate = true) val id: Int,
@ColumnInfo(name = "first_name") val firstName: String,
@ColumnInfo(name = "last_name") val lastName: String,
val age: Int
)
- @Entitiy
- 주석이 달린 data class명이 테이블명이 된다.
- tableName
- 별도의 테이블명을 지정해주고 싶을때 사용한다.
- @PrimaryKey
- 테이블 각 행의 고유한 키 번호이다.
- autoGenerate
- 키 값을 자동으로 부여하는 옵션이다.
- @ColumnInfo
- db의 컬럼명을 별도로 지정할때 사용한다.
5-1 Room은 기본 데이터 타입만 저장 가능하다 그럼 복잡한 데이터를 저장하는 방법은 없나 ?
@TypeConverter (유형 변환기)를 사용해 Room에서 이해 할 수 있는 유형으로 변경한다. list나 data class를 기본 데이터 타입으로 변경하기 위해선 Gson 라이브러리를 사용한다. 이 과정을 통해 Room에 저장할 수 있는 데이터, 실제로 필요로하고 사용하는 데이터 타입으로 변환이 가능하다.
implementation 'com.google.code.gson:gson:버전'
data class User(
val firstName: String,
val lastName: String,
val age: Int
)
// object일 경우
class Converters{
@TypeConverter
fun userToJson(user: User): String{
return Gson().toJson(user)
}
@TypeConverter
fun jsonToUser(value: String): User{
return Gson().fromJson(value, User::class.java)
}
}
// list일 경우
class Converters{
@TypeConverter
fun usersToJson(users: List<User>): String{
return Gson().toJson(users)
}
@TypeConverter
fun jsonToUsers(value: String): List<User>{
return Gson().fromJson(value, Array<User>::class.java).toList()
}
}
6. DAO(Data Access Objects)
Database에 접근하는 객체로 CRUD를 작업하는 인터페이스 또는 추상 클래스이다. @Dao 어노테이션을 사용해 정의한다. 컴파일 시점에서 Room이 @DAO을 찾아 코드를 구현하기 때문에 반드시 인터페이스나 추상클래스여야한다. DAO 메소드에는 2개의 유형이 있다.
- 편의 메소드: SQL 코드 작성 없이 CUD가 가능하다.
- 쿼리 메소드: SQL 쿼리를 작성해 DB와 상호작용한다.
6-1. Insert
DB에 데이터 삽입을 위해 사용되며 @Insert 메소드의 매개변수는 모두 @Entity 주석이 달린 데이터여야 한다. retrun 값으로 Long만 반환 받을 수 있으며 rowId이다. 만일 List로 매개변수를 넘겼다면 반환되는 Long도 List이다.
onConflict = OnConflictStrategy.REPLACE: 기존과 동일한 ID가 삽입 된다면 데이터를 대체한다는 옵션값이다.
vararg: list로 인자를 받지 않고도 n개의 인자를 받을 수 있다.
@Dao
interface UserDao{
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertUse(vararg user: User)
@Insert
fun insertBothUsers(user1: User, user2: User)
@Insert
fun insertUserAndFrieds(user: User, friends: List<User>)
@Insert
fun insertSingleUser(user: User): Long
@Insert
fun insertUsers(users: List<User>): List<Long>
}
6-2. Update
데이터베이스 테이블에서 특정 행을 업데이트한다. PrimaryKey를 찾아 업데이트하기 때문에 유효한 PrimaryKey를 찾지 못하면 아무것도 변경하지 않는다. 만일 변경된 데이터가 있다면 그 개수를 Int형으로 반환 받을 수 있다.
@Dao
interface UserDao{
@Update
fun updateUsers(vararg users: User)
@Update
fun updateUser(user: User): Int
}
6-3. Delete
업데이트와 동일하게 PrimaryKey로 특정 데이터 행을 삭제한다. 성공적으로 삭제된 행의 개수를 Int로 반환 받을 수 있다.
@Dao
interface UserDao{
@Delete
fun deleteUsers(vararg users: User)
@Delete
fun deleteUser(user: User): Int
}
6-4 Query
편의 메소드를 사용해 CUD하기엔 복잡한 데이터를 SQL문을 사용해 처리한다. 컴파일 시간에 SQL 쿼리를 검증하기 때문에 런타임 오류가 아니라 컴파일 오류가 발생한다.
테이블에 데이터를 가져오기
@Query를 이용해 데이터를 꺼내올 수 있다. 테이블과 다른 새로운 데이터 모델을 만들어 원하는 값만 출력하는 것이 가능하다.
@Entitiy(tableName = "users")
data class User(
@PrimaryKey(autoGenerate = true) val id: Int,
@ColumnInfo(name = "first_name") val firstName: String,
@ColumnInfo(name = "last_name") val lastName: String,
val age: Int
)
data class NameTuple(
@ColumnInfo(name = "first_name") val firstName: String,
@ColumnInfo(name = "last_name") val lastName: String,
)
@Dao
interface UserDao{
@Query("SELECT * FROM user")
fun loadAllUsers(): Array<User>
@Query("SELECT first_name, last_name FROM user")
fun loadFullName(): List<NamTuple>
}
매개변수 전달
쿼리문에 조건을 걸고 싶은 매개변수를 : 키워드를 이용해 적용할 수 있다.
@Dao
interface UserDao{
@Query("SELECT * FROM user WHERE age > :minAge")
fun loadAllUsersOlder(minAge: Int): Array<User>
}
ALL WAL(Write-Ahead Logging)
1. ALL WAL(Write-Ahead Logging)이란?
WAL은 SQLite에서 제공하는 기능으로 데이터 베이스의 모든 변경 사항을 실제 데이터에 반영하기 전에 로그 파일에 먼저 기록하는 방식이다. 로그를 먼저 쌓기 때문에 .db 파일을 잠그지 않아 읽고 쓰기가 동시에 진행 가능해 병렬 작업에 효율적이다. 또, 시스템 장애가 발생해도 로그를 사용해 데이터 복구가 가능하다. Room은 기본적으로 ALL WAL 모드로 생성되기 때문에 DataBase를 생성하면 3개의 파일이 디바이스에 만들어지는걸 확인 할수가 있다.
- .db 파일
- 체크포인트가 일어나 커밋된 데이터를 보관한다.
- -wal 파일
- 쓰기 작업의 변경 로그를 기록한다.
- 체크포인트가 발생하면 .db파일에 해당 로그를 병합시킨다.
- -shm 파일
- 다중 쓰레드 엑세스를 관리하기 위한 공유 메모리 파일이다.
2. 커밋 vs WAL 커밋
커밋이란? (ROLLBACK Journal 방식)
- 기본적으로 커밋은 트랜잭션(transaction)이 성공적으로 종료되었음을 의미한다.
- 트랜잭션을 종료하며 모든 변경 사항을 영구적으로 저장하겠다는 선언이다.
- 트랜잭션이 실패하게 된다면 롤백이 발생해 기존 데이터베이스 백업을 유지(롤백 저널 사용)한다.
- 커밋이 발생하면 변경 즉시 .db 파일에 적용된다.
WAL 커밋이란? (WAL 방식)
- .db 파일이 아니라 -wal 파일에 변경 사항을 기록한다.
- -wal 파일에 '이 트랜젝션 완료' 라는 특수 레코드(커밋 레코드)를 추가될 때 커밋이 발생한다.
- .db 파일에 데이터를 쓰지 않아도 트랜잭션이 완료 된다고 간주하는 특징이있다.
- .db 파일에 직접 작성하지 않기 때문에 커밋과 동시에 원본 데이터베이스에서 다른 작업이 가능하다.
- 커밋 후에도 .db 파일에 즉시 반영되는 것이 아니다.
- .db 파일에 병합되는 것은 체크포인트에서 이루어진다.
3. WAL 읽기/쓰기 방식
원래 내용은 데이터베이스 파일에 보존하고 변경된 내용은 별도의 WAL 파일에 추가된다. .db의 파일에 데이터를 쓰지 않아도 커밋이 일어나면 트랜잭션이 완료 되었다고 본다. WAL 방식에서 커밋은 데이터베이스 파일과의 독립성을 보장한다.
- 읽기 : SELECT 쿼리를 사용해 DB를 읽어오는 경우 .db와 -wal 파일의 데이털르 결합해 최신 데이터를 가져온다.
- 쓰기 : INSERT / UPDATE / DELETE 작업은 -wal 파일에 기록 되었고 커밋 레코드가 추가되면 완료된 것으로 간주된다.
4. 체크 포인트란?
-wal 파일에 저장된 데이터가 실제 데이터베이스 파일로 병합되는 과정을 의미한다.
WAL 모드에서 데이터 흐름
- INSERT, UPDATE, DELETE 작업이 발생하면 변경된 데이터는 -wal 파일에 기록된다.
- -wal 파일은 데이터의 변경 내역을 저장하고 커밋 시점에도 .db 파일은 그대로 유지한다.
- 체크포인트는 -wal 파일에 커밋된 변경 사항을 .db 파일로 병합 시킨다.
체크 포인트 동작
- -wal 파일의 데이터를 읽는다. (변경된 트랜잭션 내역)
- 변경 사항을 순차적으로 .db 파일에 적용시킨다.
- 커밋되었던 데이터가 .db로 병합되면 처리된 -wal의 데이터는 삭제된다.
5. 체크 포인트 발생 시점
자동 체크 포인트
별도의 설정을 해주지 않는다면 내부에서 자동으로 체크포인트를 수행한다. -wal 파일의 크기가 기본값 1000페이지 (1페이지당 4KB이기 때문에 약 4MB의 데이터)가 쌓이면 체크포인트가 트리거 된다. PRAGMA wal_autocheckpoint = n; 을 이용해 페이지 값을 변경하는 것도 가능하다.
val db = Room.databaseBuilder(
context,
AppDatabase::class.java,
"example-database"
).addCallback(object : RoomDatabase.Callback() {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
db.execSQL("PRAGMA wal_autocheckpoint = 100;")
}
}).build()
수동 체크 포인트
PRAGMA wal_checkpoint(체크 포인트 모드); 을 호출해 강제로 체크 포인트를 수행 할 수도 있다. 체크 포인트 모드는 3가지가 있다.
- PASSIVE
- 체크포인트를 시도하지만, 실행 중인 트랜잭션이나 다른 프로세스가 데이터베이스를 사용하는 경우 점유 중이지 않은 데이터만 .db로 옮기는 작업을 진행한다.
- 다른 프로세스가 점유 중이었던 데이터는 -wal 파일 로그에 남아있는다.
- FULL
- -wal 파일에 있는 모든 데이터를 .db 파일로 옮기고 -wal 파일을 비우려고 시도한다.
- 진행 중인 읽기, 쓰기 작업이 있다면 대기하거나 병합을 중단한다.
- RESTART
- FULL과 비슷하지만 수행 중간에 다른 트랜잭션이 개입하거나 중단 되면 체크포인트를 다시 시도한다.
데이터 베이스 연결이 닫힐 때
- 앱 종료
- 데이터 베이스 수동 닫기
- GC
- 데이터베이스 객체가 더 이상 참조되지 않는다면 GC에 의해 제거될수 있다.
- 백그라운드 전환 후 강제 종료
이슈였던 것..
Room을 이용해 데이터 하나씩 계속 insert하는 작업을 진행하던 도중 저장된 값을 확인하기 위해 .db 파일을 열어봤는데 테이블도 만들어지지 않아서 당황했다.. 코드로 SELECT로 테이블에 모든 값을 가져오면 저장된 값이 출력되고..
당황 + 의문에 연속인 상황속에서 -wal 갱신된 시간은 실시간인걸 발견했다..! -wal가 뭔지 몰라서 찾아보니 유레카
DB 파일을 생성하면 같이 생기는 파일들이 뭔지 궁금했었는데 나중에 찾아봐야지 했던게 이런 스노우볼이 되었다..
처음 의문이 생겼을때 찾아봤다면 없었을 이슈라 조금 아쉽지만 이번 스터디로 왜 그렇게 동작했는지,
Room에 대해 더 이해할 수 있어서 좋은 경험이었다 ~ 라고 오늘도 긍정적으로 생각해본다.