[내일배움캠프][TIL] 24.01.23 (화) - Android 앱개발 심화 - SharedPreferences, Room
1. Android 앱개발 심화 - 데이터 저장 (SharedPreferences, Room)
오늘부터 심화 과정에 들어간다.
이전 과정은 데이터의 효율적인 사용을 위한 RecyclerView, Fragment 등을 배우는 데에 중점을 두었다면, 이번 과정은 이 데이터 자체를 주고받는 것에 초점이 맞춰져 있었다. 이를테면 데이터 저장 개념이라든가, API, JSON, retrofit 등등.
거기에 이제 실제로 앱을 출시하기 위해 필요한 프로세서와 디버깅까지.
오늘은 그 첫 과정으로 데이터 저장에 대해서 알아보고자 한다.
Preferences
○ Preferences
- 프로그램의 설정 정보 (사용자의 옵션 선택 사항 이나 프로그램의 구성 정보)를 영구적으로 저장하는 용도로 사용
- XML 포맷의 텍스트 파일에 키-값 세트로 정보를 저장
- SharedPreferences 클래스
- Preferences의 데이터(키-값 세트)를 관리하는 클래스
- 응용 프로그램 내의 액티비티 간에 공유하며, 한쪽 액티비티에서 수정 시 다른 액티비티에서도 수정된 값을 읽을 수 있다.
- 응용 프로그램의 고유한 정보이므로 외부에서는 읽을 수 없다.
○ 공유 환경설정의 핸들을 가져오는 법
- getSharedPreferences (name, mode)
- 여러개의 Shared Preference 파일들을 사용하는 경우
- name : Preference 데이터를 저장할 xml 파일의 이름
- mode : 파일의 공유 모드
- MODE_PRIVATE : 생성된 xml 파일은 호출한 어플리케이션 내에서만 읽기/쓰기가 가능
- MODE_WORLD_READABLE, MODE_WORLD_WRITEABLE : 보안상의 이유로 API lv 17에서 deprecated
val sharedPref = activity?.getSharedPreferences( getString(R.string.preference_file_key), Context.MODE_PRIVATE)
- 사용 가능한 데이터 타입: Boolean, Float, Int, Long, String, StringSet
- xml 포맷의 텍스트 파일에 키-값 세트로 정보를 저장
- 프로그램의 설정 정보 (사용자의 옵션 선택 사항 이나 프로그램의 구성 정보)를 영구적으로 저장하는 용도로 사용
- getPreferences (보통은 사용할 일이 많이 없음)
- 한개의 Shared Preference 파일을 사용하는 경우
- Activity 클래스에 정의된 메소드 이므로, Activity 인스턴스를 통해 접근 가능
- 생성한 액티비티 전용이므로 같은 패키지의 다른 액티비티는 읽을 수 없다.
- 액티비티와 동일한 이름의 XML 파일 생성
val sharedPref = activity?.getPreferences(Context.MODE_PRIVATE)
Room
데이터 저장을 하는 방법 중에는 DataBase, 즉 DB도 존재하는데 이 때에 사용되는 것이 바로 Room이다.
○ Room
- SQLite를 쉽게 사용할 수 있는 데이터베이스 객체 매핑 라이브러리
- 쉽게 Query를 사용할 수 있는 API를 제공
- Query를 컴파일 시간에 검증함
- Query 결과를 LiveData로하여 데이터베이스가 변경될 때 마다 쉽게 UI를 변경할 수 있음
(SQLite보다 Room을 사용할 것을 권장함)
○ Room의 3요소
Room은 크게 Database, Entity, DAO로 나뉘어진다.
- Database : 클래스를 데이터베이스로 지정하는 annotation. RoomDatabase를 상속 받은 클래스여야 함
- Room.databaseBuilder를 이용하여 인스턴스를 생성함
- Entity : 클래스를 테이블 스키마로 지정하는 annotation
- DAO : 클래스를 DAO(Data Access Object)로 지정하는 annotation
- 기본적인 insert, delete, update SQL은 자동으로 만들어주며, 복잡한 SQL은 직접 만들 수 있음
○ gradle 파일 설정
- Room은 안드로이드 아키텍처에 포함되어 있음
- 사용하기위해 build.gradle 파일의 dependencies에 아래 내용을 추가해야 함
- Androidx 사용하는 경우를 가정함, Android Studio와 SDK는 최신 버전으로 사용
- 'kotlin-kapt' 플러그인이 추가
- dependencies 추가
plugins {
....
id 'kotlin-kapt'
}
.....
dependencies {
......
def room_version = "2.5.1"
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
kapt "androidx.room:room-compiler:$room_version"
// optional - Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"
// optional - Test helpers
testImplementation "androidx.room:room-testing:$room_version"
}
○ Entity 생성
- Entity는 테이블 스키마 정의
- CREATE TABLE student_table (student_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
- @Entity data class Student
@Entity(tableName = "student_table") // 테이블 이름을 student_table로 지정함
data class Student (
@PrimaryKey
@ColumnInfo(name = "student_id")
val id: Int,
val name: String
)
○ DAO 생성
- DAO는 interface나 abstract class로 정의되어야 함
- Annotation에 SQL 쿼리를 정의하고 그 쿼리를 위한 메소드를 선언
- 가능한 annotation으로 @Insert, @Update, @Delete, @Query가 있음
@Query("SELECT * from table") fun getAllData() : List<Data>
- @Insert, @Update, @Delete는 SQL 쿼리를 작성하지 않아도 컴파일러가 자동으로 생성함
- @Insert나 @Update는 key가 중복되는 경우 처리를 위해 onConflict를 지정할 수 있음
- OnConflictStrategy.ABORT: key 충돌시 종료
- OnConflictStrategy.IGNORE: key 충돌 무시
- OnConflictStrategy.REPLACE: key 충돌시 새로운 데이터로 변경
- @Update나 @Delete는 primary key에 해당되는 튜플을 찾아서 변경/삭제 함
- @Insert나 @Update는 key가 중복되는 경우 처리를 위해 onConflict를 지정할 수 있음
- @Query로 리턴되는 데이터의 타입을 LiveData<>로 하면, 나중에 이 데이터가 업데이트될 때 Observer를 통해 할 수 있음
@Query("SELECT * from table") fun getAllData() : LiveData<List<Data>>
- @Query에 SQL을 정의할 때 메소드의 인자를 사용할 수 있음
@Query("SELECT * FROM student_table WHERE name = :sname") suspend fun getStudentByName(sname: String): List<Student>
인자 sname을 SQL에서 :sname으로 사용
- fun 앞에 suspend는 Kotlin coroutine을 사용하는 것임, 나중에 이 메소드를 부를 때는 runBlocking {} 내에서 호출해야 함
- LiveData는 비동기적으로 동작하기 때문에 coroutine으로 할 필요가 없음
@Dao
interface MyDAO {
@Insert(onConflict = OnConflictStrategy.REPLACE) // INSERT, key 충돌이 나면 새 데이터로 교체
suspend fun insertStudent(student: Student)
@Query("SELECT * FROM student_table")
fun getAllStudents(): LiveData<List<Student>> // LiveData<> 사용
@Query("SELECT * FROM student_table WHERE name = :sname")
suspend fun getStudentByName(sname: String): List<Student>
@Delete
suspend fun deleteStudent(student: Student); // primary key is used to find the student
// ...
}
- @Query("SELECT * from table") fun getAllData() : LiveData<List<Data>>
@Query("SELECT * FROM student_table WHERE name = :sname") suspend fun getStudentByName(sname: String): List<Student>
- 인자 sname을 SQL에서 :sname으로 사용
○ Database 생성
- RoomDatabase를 상속하여 자신의 Room 클래스를 만들어야 함
- 포함되는 Entity들과 데이터베이스 버전(version)을 @Database annotation에 지정함
- version이 기존에 저장되어 있는 데이터베이스보다 높으면, 데이터베이스를 open할 때 migration을 수행하게 됨
- Migration 수행 방법은 RoomDatabase 객체의 addMigration() 메소드를 통해 알려줌
- DAO를 가져올 수 있는 getter 메소드를 만듬
- 실제 메소드 정의는 자동으로 생성됨
- Room 클래스의 인스턴스는 하나만 있으면 되므로 Singleton 패턴을 사용
- Room 클래스의 객체 생성은 Room.databaseBuilder()를 이용함
@Database(entities = [Student::class, ClassInfo::class, Enrollment::class, Teacher::class], version = 1)
abstract class MyDatabase : RoomDatabase() {
abstract fun getMyDao() : MyDAO
companion object {
private var INSTANCE: MyDatabase? = null
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) { 생략 }
}
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) { 생략 }
}
fun getDatabase(context: Context) : MyDatabase {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(
context, MyDatabase::class.java, "school_database")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
}
return INSTANCE as MyDatabase
}
}
}
○ Migration
- MyRoomDatabase객체 생성 후 addMigrations() 메소드를 호출하여 Migration 방법을 지정했음
- 여러개의 Migration 지정 가능
Room.databaseBuilder(...).addMigrations(MIGRATION_1_2, MIGRATION_2_3)
private val MIGRATION_1_2 = object : Migration(1, 2) { // version 1 -> 2
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE student_table ADD COLUMN last_update INTEGER")
}
}
private val MIGRATION_2_3 = object : Migration(2, 3) { // version 2 -> 3
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE class_table ADD COLUMN last_update INTEGER")
}
}
○ UI와 연결
RoomDatabase객체에서 DAO 객체를 받아오고, 이 DAO객체의 메소드를 호출하여 데이터베이스를 접근함
myDao = MyDatabase.getDatabase(this).getMyDao()
runBlocking { // (주의) UI를 블록할 수 있는 DAO 메소드를 UI 스레드에서 바로 호출하면 안됨
myDao.insertStudent(Student(1, "james")) // suspend 지정되어 있음
}
val allStudents = myDao.getAllStudents() // LiveData는 Observer를 통해 비동기적으로 데이터를 가져옴
※ 안드로이드 아키텍처에 따라 Repository와 ViewModel 사용을 권장
○ LiveData
- 안드로이드 아키텍처 컴포넌트의 일부로, 관찰 가능한 데이터 홀더 클래스
- UI 컴포넌트(예: 액티비티, 프래그먼트)는 데이터의 변경 사항을 관찰하고 이에 반응
- 관찰자에게 알림 전송
- LiveData<> 타입으로 리턴되는 DAO 메소드 경우
- observe() 메소드를 이용하여 Observer를 지정
- 데이터가 변경될 때마다 자동으로 Observer의 onChanged()가 호출됨
- LiveData<>를 리턴하는 DAO 메소드는 Observer를 통해 비동기적으로 데이터를 받기 때문에, UI 스레드에서 직접 호출해도 문제 없음
val allStudents = myDao.getAllStudents()
allStudents.observe(this) { // Observer::onChanged() 는 SAM 이기 때문에 lambda로 대체
val str = StringBuilder().apply {
for ((id, name) in it) {
append(id)
append("-")
append(name)
append("\n")
}
}.toString()
binding.textStudentList.text = str
}
○ Room Database의 주요 어노테이션(Annotations)
- @Database
- 데이터베이스 클래스를 정의할 때 사용
- 데이터베이스에 포함될 엔티티와 버전을 명시
- @Entity
- 데이터베이스 내의 테이블을 정의할 때 사용
- 클래스 이름이 테이블 이름으로 사용되며, 필드는 컬럼으로 매핑
- @PrimaryKey
- 엔티티의 기본 키(primary key)를 정의할 때 사용
- 유니크한 값이어야 하며, 데이터베이스 내에서 각 엔티티를 구분하는 데 사용
- @ColumnInfo
- 테이블의 컬럼 정보를 세부적으로 정의할 때 사용
- 컬럼의 이름, 타입, 인덱스 등을 설정할 수 있음
- @Dao
- 데이터 접근 객체(Data Access Object)를 정의할 때 사용
- 데이터베이스의 CRUD(Create, Read, Update, Delete) 연산을 위한 메소드를 포함
- @Insert
- 데이터를 삽입하는 메소드에 사용
- 해당 메소드는 엔티티를 인자로 받아 데이터베이스에 추가
- @Query
- 복잡한 SQL 쿼리를 실행하는 메소드에 사용
- 메소드에 주어진 SQL 쿼리를 실행하여 결과를 반환
- @Update
- 데이터를 업데이트하는 메소드에 사용
- 인자로 받은 엔티티의 데이터로 기존 레코드를 갱신
- @Delete
- 데이터를 삭제하는 메소드에 사용
- 인자로 받은 엔티티를 데이터베이스에서 제거
- @Transaction
- 메소드가 하나의 트랜잭션으로 실행되어야 함을 나타냄
- 여러 연산을 하나의 작업으로 묶어 실행할 때 사용
- @ForeignKey
- 엔티티 간의 외래 키 관계를 정의할 때 사용
- 참조 무결성을 유지하는 데 도움을 줌
- @Index
- 특정 컬럼에 인덱스를 생성할 때 사용
- 쿼리 성능을 향상시키는 데 유용