Android/Theory

Android Room, SQLite 기본

sh1mj1 2022. 9. 6. 15:19

Room

Room 지속성 라이브러리는 SQLite에 대한 추상화 계층을 제공하여 SQLite의 모든 기능을 활용하면서 보다 강력한 데이터베이스 액세스를 허용한다.

즉, 완전히 새로운 개념은 아니고 SQLite 을 활용하여 객체 매핑을 해주는 역할을 한다.

https://developer.android.com/jetpack/androidx/releases/room?gclid=CjwKCAjwvNaYBhA3EiwACgndgjDo15TYN8-tpyBN9fm_rwUN_Q2ZFcmD4ccl-ITc9LllTLDPsLWQsRoCZGYQAvD_BwE&gclsrc=aw.ds#groovy

What is SQLite?

https://onlyfor-me-blog.tistory.com/271

 

[Android] SQLite 사용법 - INSERT -

SQLite에 대해선 이전에 포스팅을 작성한 적이 있다. onlyfor-me-blog.tistory.com/45 [Android] SQLite란? - 1 - SQLite는 쉐어드, 룸 DB, Realm 따위와 같이 안드로이드에서 제공하는 앱 DB의 한 종류이다. 특이..

onlyfor-me-blog.tistory.com

 

SQLite는 MySQL, PostgreSQL 같은 데이터베이스 관리 시스템이지만,서버가 아니라 응용 프로그램에 넣어 사용하는 비교적 가벼운 DB 이다. 대규모 작업엔 부적합하지만 중소 규모라면 속도에 손색이 없다.

 

API는 단순히 라이브러리를 호출하는 것만 있으며, 데이터를 저장하는 데 하나의 파일만을 사용하는 것이 특징이다. 구글 안드로이드 OS에 기본 탑재된 DB기도 하다.

 

MySQL 같은 RDBMS는 별도의 서버 프로세스가 작동해야 한다.  DB 서버에 액세스하려는 응용 프로그램은 TCP/IP 프로토콜을 써서 요청(Request)을 보내고 응답(Response)을 받는다.  이걸 클라이언트-서버 아키텍처 라고 한다.

RDBMS 클라이언트-서버 아키텍처

 

앱에서 냅다 다이렉트로 DB의 데이터를 건드릴 수는 없다. 이 때 앱과 DB를 연결해주는 매개체가 레트로핏 따위의 네트워킹 라이브러리 및 웹 서버다.

위와 같은 방식이 일반적인 클라이언트와 서버 사이에서 일어나는 데이터 이동의 형태다.

 

반면 SQLite는 위와 같이 작동하지 않는다.

SQLite를 실행하는 데 서버 따윈 필요없다. SQLite DB는 DB에 접근하는 응용 프로그램과 통합된 형태다. 응용 프로그램(앱)은 SQLite DB와 상호작용해서 디스크에 저장된 DB 파일에서 직접 읽고 쓴다.

SQLite Serverless 아키텍처

SQLite는 외부 서버를 쓰지 않고 안드로이드 OS 자체에 들어있는 DB를 쓰는 것이기 때문에, 준비만 적절하게 하면 다이렉트로 DB에 데이터를 저장하고 확인하고 지지고 볶을 수 있다.

Room 사용해서 로컬 database 에 데이터 저장하기.

적지 않은 양의 구조화된 데이터들을 다루기 위해서는 해당 데이터를 로컬에 유지시키면 매우 유리하다. 보통 디바이스가 인터넷 연결을 할 수 없을 때 오프라인 상태에서도 해당 콘텐츠를 계속 사용할 수 있도록 데이터를 캐싱할 때 많이 사용한다.

또 Room 은 SQLite 추상화 계층을 제공해서 유연하게 데이터베이스 엑세스를 허용해 아래 이점을 갖는다. 그리고 그 이점 때문에 SQLite API 를 직접 사용하지 않고 Room 을 사용한다.

  • SQL 쿼리를 compile-time 에 검증할 수 있다.
  • 에러가 나기 쉬운 코드를 어노테이션@ 으로 최소화하여 편리하게 사용
  • 간편한 데이터베이스 migration 경로

라이브러리 추가

앱 단위에 plugins 에 id 추가

plugins {
...
    id 'kotlin-kapt'
...
}

추가해야 하는 dependencies

dependencies {
    def room_version = "2.4.3"

    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"

    // To use Kotlin annotation processing tool (kapt)
    kapt "androidx.room:room-compiler:$room_version"
    // To use Kotlin Symbol Processing (KSP)
    ksp "androidx.room:room-compiler:$room_version"

    // optional - RxJava2 support for Room
    implementation "androidx.room:room-rxjava2:$room_version"

    // optional - RxJava3 support for Room
    implementation "androidx.room:room-rxjava3:$room_version"

    // optional - Guava support for Room, including Optional and ListenableFuture
    implementation "androidx.room:room-guava:$room_version"

    // optional - Test helpers
    testImplementation "androidx.room:room-testing:$room_version"

    // optional - Paging 3 Integration
    implementation "androidx.room:room-paging:2.5.0-alpha03"
}

주요 Components

  • Database
    • 데이터베이스를 갖고, 앱의 지속 데이터에 대한 연결을 위한 엑세스 지점
  • Entities
    • 데이터베이스의 테이블
  • DAO (Data Access Objects)
    • 데이터를 DB로부터 쿼리, 업데이트, 삭제 등에 사용할 수 있는 메서드를 제공.

대강적인 매커니즘은 이렇다

  1. 데이터베이스 클래스는 해당 데이터베이스와 연결된 DAO 인스턴스를 앱에 제공
  2. 앱은 DAO 을 사용하여 연결된 데이터 엔티티의 인스턴스로 데이터베이스에서 데이터 검색
  3. 앱은 정의된 데이터 엔티티를 사용해 해당 테이블을 업데이트하거나 삭제 등등

예시

Entity

@Entity(tablename = "users")
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)

Entity 인 데이터 클래스에는 PrimaryKey 을 구성하는 하나 이상의 열이 있어야 하고, 각 열의 필드가 있다.

기본적으로 클래스 이름을 데이터베이스 테이블 이름으로 사용하지만 테이블 이름을 다르게 설정할 수 있다. 위 예시에서는 users 로 설정했다.

마찬 가지로 필드의 이름을 데이터베이스의 열 이름으로 사용하지만 다르게 설정할 수 있다. 위 예시에서는 first_name , last_name 으로 바꾸었다.

PrimaryKey 을 지정할 때 여러 열의 조합으로 고유하게 식별하도록 설정할 수도 있다. 아래처럼 @Entity 오른쪽 괄호 안에 적어주어야 한다.

@Entity(primaryKeys = ["firstName", "lastName"])

어노테이션을 사용해 필드를 무시할 수도 있다.

@Ignore val picture: Bitmap?

추가로 FTS(전체 텍스트 검색)을 통해 SQLite 확장 모듈 을 사용하여 검색 속도를 높일 수 있다. 또 특정 열에 인덱스를 지정하여 속도를 높일 수도 있다.

https://developer.android.com/training/data-storage/room/defining-data

 

Room 항목을 사용하여 데이터 정의  |  Android 개발자  |  Android Developers

Room 라이브러리의 일부인 항목을 사용하여 데이터베이스 테이블을 생성하는 방법 알아보기

developer.android.com

DAO (Data access object)

DAO 에는 앱 데이터베이스에 관한 추상 엑세스 권한을 제공하는 메서드가 포함되어 있다. Room 은 컴파일 시간에 정의된 DAO 구현을 자동으로 생성한다.

@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getAll(): List<User>

    @Query("SELECT * FROM user WHERE uid IN (:userIds)")
    fun loadAllByIds(userIds: IntArray): List<User>

    @Query("SELECT * FROM user WHERE first_name LIKE :first AND " +
           "last_name LIKE :last LIMIT 1")
    fun findByName(first: String, last: String): User

    @Insert
    fun insertAll(vararg users: User)

    @Delete
    fun delete(user: User)
}

일반적으로 DAO는 인터페이스로 정의한다. (추상 클래스로도 가능하긴 함)

인터페이스 앞에 항상 @Dao 을 달아야 하고 DAO 에는 속성은 없지만 메서드가 있어야 한다.

  • 편의 메서드: SQL 코드 작성 없이 데이터베이스에서 행을 삽입, 업데이트, 삭제 가능.
    • @Insert
      • 인수는 엔티티의 속성. 삽입한다.
      • 단일 인수를 받으면 long 을 리턴. 이 long 값은 삽입된 항목의 새 rowId.
      • 인수가 배열이나 컬렉션이면 long 값의 배열이나 컬렉션을 리턴.
    • @Update
      • 특정 행을 업데이트한다.
      • 단일 인수를 받으면 @Insert 메서드와 같이 long을 리턴.
      • primarykey을 사용하여 전달된 인스턴스를 데이터베이스 행과 일치시킨다. pk가 같은 행이 없으면 변경되지 않는다.
    • @Delete
      • 특정 행을 삭제한다.
      • @Insert 처럼 엔티티의 인스턴스를 인수로 받는다.
      • primarykey을 사용하여 전달된 인스턴스를 데이터베이스 행과 일치시킨다. pk가 같은 행이 없으면 변경되지 않는다.
      • 성공적으로 삭제된 행 수를 알려주는 Int 값을 선택적으로 리턴할 수 있다.
  • 쿼리 메서드: 자체 SQL 쿼리를 작성하여 데이터베이스와 상호작용
    • @Query 주석을 사용해서 SQL 문을 작성하여 DAO 메서드로 사용할 수 있다. 편의 메서드보다 더 복잡한 메서드를 실행할 때 사용.
      • 단순 쿼리
      @Query("SELECT * FROM user")
      fun loadAllUsers(): Array<User>
      
      User 객체를 모두 리턴
      • 테이블 열의 하위 집합 리턴
      data class NameTuple(
          @ColumnInfo(name = "first_name") val firstName: String?,
          @ColumnInfo(name = "last_name") val lastName: String?
      )
      
      @Query("SELECT first_name, last_name FROM user")
      fun loadFullName(): List<NameTuple>
      
      필요한 필드만 쿼리할 수 있다. 필요한 필드만 가진 데이터 클래스를 만들고 해당 필드만 가진 간단한 객체를 리턴.
      • 쿼리에 단순 인수 전달
      @Query("SELECT * FROM user WHERE age > :minAge")
      fun loadAllUsersOlderThan(minAge: Int): Array<User>
      
      @Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge")
      fun loadAllUsersBetweenAges(minAge: Int, maxAge: Int): Array<User>
      
      @Query("SELECT * FROM user WHERE first_name LIKE :search " +
             "OR last_name LIKE :search")
      fun findUserWithName(search: String): List<User>
      
      • 쿼리에 인수 컬렉션 전달
      @Query("SELECT * FROM user WHERE region IN (:regions)")
      fun loadUsersFromRegions(regions: List<String>): List<User>
      
      일부 region 에 있는 모든 사용자 정보 리턴
      • 여러 테이블 쿼리
      @Query(
          "SELECT * FROM book " +
          "INNER JOIN loan ON loan.book_id = book.id " +
          "INNER JOIN user ON user.id = loan.user_id " +
          "WHERE user.name LIKE :userName"
      )
      fun findBooksBorrowedByNameSync(userName: String): List<Book>
      
      여러 테이블에 엑세스. JOIN 을 사용해서 테이블을 두 개 이상 참조할 수 있다.
interface UserBookDao {
    @Query(
        "SELECT user.name AS userName, book.name AS bookName " +
        "FROM user, book " +
        "WHERE user.id = book.user_id"
    )
    fun loadUserAndBookNames(): LiveData<List<UserBook>>

    // You can also define this class in a separate file.
    data class UserBook(val userName: String?, val bookName: String?)
}

여러 조인된 테이블에서 열 하위 집합을 리턴하도록 간단한 객체를 만들 수 있다. userName 이라는 것으로, bookName 이라는 것으로 가져온다. (user, book 으로부터).

user.id 와 book.user_id 가 같은 것을 가져온다. 이렇게 JOIN 이라는 명령어 없이 여러 테이블을 합쳐서 UserBook 이라는 데이터 클래스에 저장하도록 한다.

  • 멀티매핑 리턴
@Query(
    "SELECT * FROM user" +
    "JOIN book ON user.id = book.user_id"
)
fun loadUserAndBookNames(): Map<User, List<Book>>

User 인스턴스와 Book 인스턴스의 쌍을 보유하는 맞춤 데이터 클래스의 인스턴스 목록을 반환하는 대신 쿼리 메서드에서 User 및 Book의 매핑을 직접 반환할 수 있습니다.

@Query(
    "SELECT * FROM user" +
    "JOIN book ON user.id = book.user_id" +
    "GROUP BY user.name WHERE COUNT(book.id) >= 3"
)
fun loadUserAndBookNames(): Map<User, List<Book>>

멀티매핑을 리턴하면 GROUP BY 절을 사용하는 쿼리를 작성할 수 있다는 뜻. 그러므로 SQL 의 고급 계산, 필터링 기능을 활용할 수 있다.

@MapInfo(keyColumn = "userName", valueColumn = "bookName")
@Query(
    "SELECT user.name AS username, book.name AS bookname FROM user" +
    "JOIN book ON user.id = book.user_id"
)
fun loadUserAndBookNames(): Map<String, List<String>>

만약 전체 객체를 모두 매핑하지 않아도 된다면 쿼리 메서드의 @MapInfo 에서 keycolumn, valueColumn 속성을 설정하여 쿼리의 특정 열 간 매핑을 리턴할 수도 있다.

 

특수 리턴 타입

  • Paging 라이브러리 사용해 페이지로 나눈 쿼리
@Dao
interface UserDao {
  @Query("SELECT * FROM users WHERE label LIKE :query")
  fun pagingSource(query: String): PagingSource<Int, User>
}

PagingSource 객체를 반환할 수 있다.

  • 직접 커서 엑세스
@Dao
interface UserDao {
    @Query("SELECT * FROM user WHERE age > :minAge LIMIT 5")
    fun loadRawUsersOlderThan(minAge: Int): Cursor
}

리턴 행에 직접 엑세스해야 한다면 DAO 메서드로 Cursor 객체를 리턴할 수 있다. Cursor 는 데이터베이스 쿼리로 리턴되는 결과 set으로 랜덤의 read-write 엑세스를 제공한다.

Cursor API 는 사용하지 않는 것이 좋다. 행이 존재하는지 혹은 어떤 값이 포함되어 있는지 보장하지 않기 때문이다. 

Database

@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

데이터베이스를 가진 AppDatabase 클래스를 정의한다. AppDatabase 는 데이터페이스 구성을 결정하고, 지속 데이터로의 앱의 주요한 엑세스 역할을 한다.

데이터베이스 클래스는

  • @Database 어노테이션, 그 안에는 모든 entities 을 나열하는 배열을 포함해야 한다.
  • RoomDatabase 을 상속받는 클래스여야 한다.
  • DAO 클래스의 인스턴스를 리턴하는 추상 메서드를 가져야 한다.
💡 싱글 프로셋에서 실행되는 경우 AppDatabase 개체를 초기화할 때 싱글톤 디자인 패턴을 따라야 한다. 각 RoomDatabase 인스턴스는 코스트가 많이 들고, 한 프로세스에서 여러 인스턴스로 접근할 필요가 거의 없을 것이다.

사용법

val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "database-name"
        ).build()

사용하고 싶은 지점에서 db 을 선언해주고 나서

val userDao = db.userDao()
val users: List<User> = userDao.getAll()

Dao 을 선언하고 Dao 의 확장함수로 사용한다.

https://developer.android.com/training/data-storage/room

 

Room을 사용하여 로컬 데이터베이스에 데이터 저장  |  Android 개발자  |  Android Developers

Room 라이브러리를 사용하여 더 쉽게 데이터를 유지하는 방법 알아보기

developer.android.com

시간이 나면 아래도 읽어보자.

https://medium.com/androiddevelopers/7-pro-tips-for-room-fbadea4bfbd1

 

7 Pro-tips for Room

Learn how you can get the most out of Room

medium.com

 

Room 은 안드로이드에서 가장 중요한 부분 중 하나이다. 나중에 MVVM 모델로 앱을 설계할 때 LiveData, Repository, ViewModel 등과 함께 다루어져, 충분히 이해하지 못하고 넘어간다면 나중에 스노우볼이 쌓여 더 힘들어 질지도 모른다.. 이번 기회에 정확히 정리해 둡시다.

그리고 SQLite 문법에 대해서도 정리를 한번 해야겠다. 마침 이번 학기 데이터베이스 강의에서 SQL 을 배운다고 하니 좋은 것 같다!!!