안드로이드/안드로이드 Kotlin

[Android Basics in Kotlin] Data Persistence: SQL / Room / Flow

요빈 2023. 3. 23. 16:30

https://developer.android.com/courses/android-basics-kotlin/unit-5?hl=ko 

 

Kotlin으로 배우는 Android Kotlin 기본사항  |  Android Basics in Kotlin - Data Persistence  |  Android Developers

Keep your apps working through any disruptions to essential networks or processes for a smooth and consistent user experience.

developer.android.com

해당 글은 위 사이트의 PATHWAY 1 과정인 SQL, Room, Flow 소개를 공부하며 작성한 글입니다.

이 과정에서는 SQL로 데이터를 읽고 조장하는 기본 방법과 Room 라이브러리를 사용해 Android 앱에서 관계형 데이터베이스를 만들고 사용하는 방법에 대해 배웁니다.


고품질 앱을 빌드할 수 있는 Android 개발 기본 기술에는 지속성(Persistence)이 있다.

앱은 지속성을 사용하여 원활한 사용자 환경을 제공한다.

관계형 데이터베이스

 

휴대기기에서 데이터베이스는 인터넷과 같은 다른 소스에서 데이터를 검색하는 방식 대신 앱 내 데이터를 저장되는 데 사용된다.

이를 데이터 지속성이라고 한다.

 

데이터 지속성을 이야기할 때 '관계형 데이터베이스'라는 용어가 자주 사용된다.

관계형 데이터베이스는 데이터를 테이블과 열, 행으로 구성하는 일반적인 데이터베이스 유형이다. 

테이블은 데이터는 나타내는 것 외에도 다른 테이블을 참조할 수 있으며, 테이블 간 관계를 나타낼 수 있다.

 

특정 속성은 열로 표현되며 이름과  데이터 유형으로 구성된다.

개별 테이블 항목을 행이라고 한다. 쉽게 말하면 Kotlin 클래스의 인스턴스와 같다. 

기본 키는 테이블의 각 행에 고유하며 다른 테이블에서 참조해야 하는 경우에 사용된다.

즉, 기본 키를 통해 관계형 데이터베이스에서 관계를 확인할 수 있다.

 

관계형 데이터베이스에 접근할 때는 자체적으로는 라이브러리를 사용하든 SQL이 필요하다.

SQL은 구조화된 쿼리언어를 뜻하며 이를 통해 관계형 데이터베이스에서 데이터를 읽고 조작할 수 있다.

다음 표는 가장 일반적인 SQL문이다.

 

SELECT 데이터 테이블에서 특정 정보를 가져오고 결과를 다양한 방법으로 필터링하고 정렬할 수 있습니다.
INSERT 테이블에 새 행을 추가합니다.
UPDATE 테이블의 기존 행을 업데이트합니다.
DELETE 테이블의 기존 행을 삭제합니다.

 

* SQL 관련 예제

 

1. 일반적인 SQL 함수

SELECT * FROM park

SELECT name, established, city FROM park
LIMIT 5

SELECT name FROM park WHERE type = "national_park"

SELECT COUNT(*) FROM park

SELECT SUM(visitor) FROM park

SELECT MAX(area_acres) FROM park

SELECT DISTINCT type FROM park
SELECT COUNT(DISTINCT type) FROM park

* DISTINCT는 쿼리 결과에서 중복값을 제거한다. 즉, 고유한 값을 구할 때 사용한다.

 

2. 쿼리 결과 정렬 및 그룹화

SELECT name FROM park
ORDER BY name DESC

SELECT type, name FROM park
GROUP BY type
ORDER BY name

* 결과를 더 쉽게 읽기 위해 GROUP BY를 통해 열별로 그룹화할 수 있다.

 

3.  행 삽입 및 삭제

INSERT INTO park
VALUES (null, 'Googleplex', 'Mountain View', 12, null, 0, '')

UPDATE park
SET area_acres = 46,
established = 1088640000,
type = 'office'
WHERE name = 'Googleplex'

DELETE FROM park
WHERE name = 'Googleplex'

Room 

 

Room 종속 항목 추가

 

- build.gradle(project) 파일의 ext 블록에 room_version 정의

ext {
   kotlin_version = "1.6.20"
   nav_version = "2.4.1"
   room_version = '2.4.2'
}

- build.gradle(module) 파일의 dependencies 블록에 종속 항목 추가

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

// optional - Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"

* 에러 Could not find method kapt() for arguments [androidx.room:room-compiler:2.4.2] ...

build.gradle(module) 파일 상단의 plugins 블록에 'kotlin-kapt'를 추가해주니 해결되었다.

 

모델 클래스

 

Room을 사용할 때 각 테이블은 클래스로 표시된다. Room과 같은 ORM 라이브러리에서는 이를 모델 클래스라 한다.

클래스 별로 더 효과적인 액세스를 위해 패키지를 구분하는 것이 좋다.

따라서 모델 클래스를 따로 정리할 database 패키지를 생성한다.

모델 클래스는 데이터 클래스로 정의된다. 데이터 클래스는 소괄호를 사용하며 각 변수를 콤마(,)로 구분한다.

@Entity
data class Schedule(
	@PrimaryKey val id: Int,
	@NonNull @ColumnInfo(name = "stop_name") val stopName: String,
	@NonNull @ColumnInfo(name = "arrival_time") val arrivalTime: Int
)

   - @PrimaryKey: 각 행을 고유하게 식별하는 기본키

   - @ColumInfo: 새 열의 경우 해당 주석을 추가해 열 이름을 지정

   - @NonNull: 값이 null이 되지 않도록 표시

   - @Entity: 이 클래스를 데이터베이스 테이블 정의에 사용할 수 있는 것으로 Room이 인식하도록 주석 추가

                    기본적으로 Room은 클래스 이름을 데이터베이스 이름으로 사용하지만 선택적으로 다음과 같이 지정할 수 있음

                    Ex. @Entity(tableName = "schedule")

DAO

 

DAO(Data Access Object)는 데이터 액세스 객체로 데이터 액세스 권한을 제공하는 코틀린 클래스이다.

DAO에는 데이터를 읽고 조작하는 함수가 포함되며, DAO에서 함수를 호출하는 것은 데이터베이스에서 SQL 명령어를 실행하는 것과 같다.

DAO 클래스에 @Dao 주석을 추가한 인터페이스를 정의한다.

 

쿼리는 @Query 주석에 전달된 문자열로 지정된다.

함수 매개변수값을 쿼리 내에서 사용하려면 콜론(:)을 붙여 Kotlin 값을 참조할 수 있다.

@Dao
interface ScheduleDao{
    @Query("SELECT * FROM schedule ORDER BY arrival_time ASC")
    fun getAll(): List<Schedule>

    @Query("SELECT * FROM schedule WHERE stop_name = :stopName ORDER BY arrival_time ASC")
    fun getByStopName(stopName: String): List<Schedule>
}

 

ViewModel

 

뷰에 노출되는 DAO의 일부를 뷰 모델이라는 별도의 클래스로 분리하는 것이 좋다. 이는 모바일 앱의 일반적인 아키텍처 패턴이다.

뷰 모델을 사용하면 앱의 UI 코드와 데이터 모델을 명확하게 구분할 수 있다.

뷰 모델을 사용하면 ViewModel 클래스를 사용할 수 있다. 

ViewModel 클래스는 앱 UI 관련 데이터를 저장하는 데 사용되고 수명 주기도 인식하므로 수명 주기 이벤트에 응답한다.

화면 회전과 같은 수명 주기 이벤트로 인해 활동이나 프래그먼트가 소멸되었다가 다시 생성되어도 연결된 ViewModel을 다시 만들 필요가 없다. DAO 클래스에 직접 액세스할 때는 불가능하므로 ViewModel 클래스를 사용해 UI에서 데이터를 로드하는 책임을 분리하는 것이 좋다.

 

* 현재 예제에서는 두 화면에 거의 똑같은 컨텐츠를 사용하기 때문에 단일 뷰 모델 클래스를 사용하지만, 더 큰 앱에서는 각 화면에 별도의 뷰 모델을 사용하는 것이 좋다. 즉, 여러 화면이 서로 다른 데이터를 다룰 경우 뷰 모델을 분리하라는 의미이다.

 

뷰 모델 클래스는 다음과 같다.

class BusScheduleViewModel(private val scheduleDao: ScheduleDao):ViewModel() {
	fun fullSchedule(): List<Schedule> = scheduleDao.getAll()
	fun scheduleForStopName(name: String): List<Schedule> = scheduleDao.getByStopName(name)
}

Dao로 부터 데이터를 받아와야 하기 때문에 ScheduleDao 유형의 단일 매개변수를 사용한다.

이 뷰 모델은 두 화면에서 모두 사용되므로 ScheduleDao에서 상응하는 메서드를 호출해 원하는 데이터를 불러오는 메서드를 추가한다.

 

뷰 모델 정의를 완료했지만 바로 사용할 수는 없다.

ViewModel 클래스는 수명 주기를 인식해야 하므로 수명 주기 이벤트에 응답할 수 있는 객체로 인스턴스화해야 한다.

프래그먼트에서 직접 인스턴스화하면 프래그먼트 객체가 모든 메모리 관리를 해야하기 때문에 바람직하지 않다.

대신 뷰 모델 객체를 인스턴스화하는 팩토리라는 클래스를 만들 수 있다.

// 뷰 모델 객체를 인스턴스화 하는 클래스
class BusScheduleViewModelFactory(
    private val scheduleDao: ScheduleDao
): ViewModelProvider.Factory{

    // 클래스를 직접 초기화, 일부 오류 검사와 함께
    // BusScheduleViewModelFactory 를 반환 하는 create 메서드 재정의
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if(modelClass.isAssignableFrom(BusScheduleViewModel::class.java)){
            @Suppress("UNCHECKED_CAST")
            return BusScheduleViewModel(scheduleDao) as T
        }
        throw IllegalAccessException("Unknown ViewModel class")
    }
}

이렇게 하면 BusScheduleViewModelFactory.create()로 BusScheduleViewModelFactory 객체를 인스턴스화 할 수 있으므로 프래그먼트가 이를 직접 처리하지 않고도 뷰 모델이 수명 주기를 인식할 수 있다.

 

데이터베이스(AppDatabase) 클래스

 

Room을 사용하는 Android앱은 AppDatabase 클래스가 필요하다.

AppDatabase 클래스는 다음 작업을 실행한다.

 

   - 데이터베이스에서 정의되는 항목 지정

   - 각 DAO 클래스의 단일 인스턴스 액세스 권한을 제공

   - 데이터베이스 미리 채우기와 같은 추가 설정 실행

 

AppDatabase 클래스를 사용하면 모델과 DAO 클래스, 실행하려는 모든 데이터베이스 설정을 완벽히 제어할 수 있다.

 

AppDatabase 클래스를 사용할 때는 경합 상태나 다른 잠재적 문제를 방지하기 위해 데이터베이스 인스턴스가 하나만 있는지 확인한다.

인스턴스는 컴패니언 객체에 저장된다. getDatabase는 AppDatabase 인스턴스를 반환하는 함수이다.

companion object{
    @Volatile
    private var INSTANCE: AppDatabase? = null

    //  AppDatabase 인스턴스 반환하는 함수
    fun getDatabase(context: Context):AppDatabase{
        
        return INSTANCE ?: synchronized(this){
            val instance = Room.databaseBuilder(
                context,
                AppDatabase::class.java,
                "app_database")
                .createFromAsset("database/bus_schedule.db")
                .build()
            INSTANCE = instance
            instance
        }
    }
}

getDatabase() 구현은 다음과 같다.

   - Elvis(?:) 연산자를 이용해 데이터베이스의 기존 인스턴스를 반환하거나 필요하다면 데이터베이스를 생성한다.

   - createFromAsset()을 호출해 기존 데이터를 로드한다. 

 

데이터베이스 클래스에는 특정 정보를 제공하는 주석이 있어야 한다.

모든 항목 유형은 배열로 나열되며, 버전번호도 부여되어 1로 설정된다. 

@Database(entities = arrayOf(Schedule::class), version = 1)

 

Application 클래스

 

Application 클래스를 생성해 getDatabase()의 결과를 보유할 lazy 속성을 만들어야 한다.

AppDatabase 유형의 데이터베이스 속성을 추가한다.

이 속성은 lazy 하며 getDatabase()호출결과를 반환한다.

class BusScheduleApplication : Application() {
   val database: AppDatabase by lazy { AppDatabase.getDatabase(this) }

* lazy 속성을 만들려면 변수 선언 후 by lazy를 붙인 뒤, 중괄호 내부에 변수 값을 지정하는 코드를 추가한다.

 

Application 클래스 작성이 완료되면 기본 Application이 아닌 BusScheduleApplication 클래스가 사용되도록 매니패스트를 변경해야 된다.

<application
    android:name="com.example.busschedule.BusScheduleApplication"
    ...

ListAdapter

이전에는 RecyclerView를 사용할 때 RecyclerView.Adapter를 사용해 정적 데이터를 표시했다.

데이터베이스를 사용할 때 일반적인 시나리오는 데이터 변경사항을 실시간으로 처리해야 한다.

한 항목의 콘텐츠만 변경되더라도 전체 recycler 뷰가 새로고침되는 것은 지속성을 사용하는 대부분의 앱에서 바람직하지 않다.

동적으로 변경되는 리스트의 대안은 ListAdapter이다.

ListAdapter는 AsyncListDiffer를 사용해 이전 데이터 목록과 새 데이터 목록의 차이를 확인한다. 

그러면 RecyclerView가 두 목록 간 차이에 기반해서만 업데이트 한다.

 

BusStopAdapter 클래스는 Schedule의 객체 목록과 UI의 BusStopViewHolder 클래스를 사용하는 일반 ListAdapter를 확장한다.

어댑터 클래스의 매개변수인 onItemClicked는 첫 번째 화면에서 탐색을 처리하는 데 사용된다.

class BusStopAdapter(private val onItemClicked: (Schedule) -> Unit
) : ListAdapter<Schedule, BusStopAdapter.BusStopViewHolder>(DiffCallback) {
	// 구현 목록 
	// onCreateViewHolder 메서드
	// onBindViewHolder
	// ViewHolder 클래스
	// DiffUtil 객체
}

RecyclerView 어댑터와 마찬가지로 뷰 홀더가 있어야 파일에서 만들어진 뷰에 액세스할 수 있다.
BusStopViewHolder 클래스에 bind() 함수를 구현해 데이터를 결합한다.

 

onCreateViewHolder() 를 재정의해 레이아웃을 확장하고 현재 위치의 항목에 onItemClicked()를 호출하도록 onClickListener()를 설정한다.

onBindViewHolder()를 재정의해 지정된 위치에 뷰를 바인딩한다.

 

DiffCallback 객체는 ListAdapter가 목록을 업데이트할 때 새 목록과 이전 목록에서 어떤 항목이 다른지 확인하는 객체이다.

이 클래스에는 두 가지 메서드가 있다. 컴패니언 객체(companion object)를 추가해 DiffCallback을 구현한다.

 

* companion object

 

자바에 static(정적) 변수 및 메서드가 존재하듯 코틀린에서도 정적 변수와 함수가 존재한다.

이 때 사용하는 것이 companion object이다. 즉, 정적 변수를 선언할 때 companion object라는 블록으로 감싸 선언한다고 생각하면 된다.

하나의 클래스에는 오직 하나의 companion object가 존재할 수 있다.

 


Flow를 사용해 데이터 변경사항에 응답

 

위 단계까지 진행했다면 기기에 데이터베이스 내 데이터가 출력된 것을 확인할 수 있다.

하지만 데이터베이스에 데이터를 추가해도 기기에서는 데이터가 업데이트된 것을 바로 확인할 수 없다.

submitList()가 호출될 때마다 데이터 변경사항을 처리하도록 설정되어 있지만, 기본 데이터 업데이트 시 UI 업데이트를 위해 submitList()를 호출하지 않으므로 사용자 관점에서는 아무것도 변경되지 않은 것처럼 보인다.

 

이 문제를 해결하려면 DAO가 데이터베이스에서 데이터를 지속적으로 내보낼 수 있는 asynchronous flow(비동기 흐름)이라는 코틀린 기능을 사용하면 된다.

위에서 말한 Flow를 사용하면 항목이 삽입되거나 업데이트 및 삭제가 되면 그 결과가 프래그먼트로 다시 전송된다.

collect() 함수를 사용하면 Flow에서 내보낸 새 값을 사용해 submitList()를 호출할 수 있으므로 새 데이터에 기반에 UI를 업데이트할 수 있다.

 

Flow를 사용하려면 첫 번째로 Dao 파일을 Flow를 반환하도록 수정하면 된다.

List<Schedule>을 반환하던 메소드들의 반환값을 Flow<List<Schedule>>로 수정한다.

해당 메소드를 사용했던 곳에서도 유형을 변경한다.

 

다음은 collect() 함수를 통해 submitList()를 호출하는 것이다.

프래그먼트에서 submitList()를 호출하던 코드를 다음과 같이 수정한다.

fullSchedule()에서 반환된 흐름을 사용하도록 수정한 것이다. scheduleForStopName도 마찬가지로 수정한다.

lifecycle.coroutineScope.launch {
   viewModel.fullSchedule().collect() {
       busStopAdapter.submitList(it)
   }
}