코틀린(Kotlin)/TIL

[코틀린(Kotlin)] 데이터 저장 - SharedPreference, Room

초보왕보초 2024. 1. 24. 20:33
728x90
  • SharedPreference
  • Room

 

 

안드로이드의 데이터를 영구적으로 저장하는 방법

  1. SharedPreference
  2. 데이터베이스
  3. 파일 형태로 저장

 

 

1. Preference란?

  • 프로그램의 설정 정보 (사용자의 옵션 선택 사항이나 프로그램의 구성 정보)를 영구적으로 저장하는 용도로 사용
  • XML 포맷의 텍스트 파일에 키-값 세트로 정보를 저장
  • SharedPreference 클래스
    - Preference의 데이터(키-값 세트)를 관리하는 클래스
    - 응용 프로그램 내의 액티비티 간에 공유하며, 한쪽 액티비티에서 수정 시 다른 액티비티에서도 수정된 값을 읽을 수 있다
    - 응용 프로그램의 고유한 정보이므로 외부에서는 읽을 수 없다
    (현재 앱에서만, 다른 앱에서는 x)

 

1-1. 공유 환경설정의 핸들 가져오기

  • getSharedPreference (name, mode)
    - 여러 개의 Shared Preference 파일들을 사용하는 경우 사용
    - name : Preference 데이터를 저장할 XML 파일의 이름
    - mode : 파일의 공유 모드
    → mode에는 MODE_PRIVATE와 추가로, MODE_WORLD_READABLE, MODE_WORLD_WRITEABLE이 있지만 보안상 이유로 API level 17에서 deprecated 됨
// MODE_PRIVATE : 생성된 XML 파일은 호출한 애플리케이션 내에서만 읽기 쓰기 가능
val sharedPref = activity?.getSharedPreference(
	getString(R.string.preference_file_key), Context.MODE_PRIVATE)
  • getPreference
    - 한 개의 Shared Preference 파일을 사용하는 경우 사용
    - Activity 클래스에 정의된 메서드이므로, Activity 인스턴스를 통해 접근 가능
    - 생성한 액티비티 전용이므로 같은 패키지의 다른 액티비티는 읽을 수 없다
    - 액티비티와 동일한 이름의 XML 파일 생성
val sharedPref = activity?.getPreference(Context.MODE_PRIVATE)

 

 

 

2. Room이란?

  • SQLite를 쉽게 사용할 수 있는 데이터베이스 객체 매핑 라이브러리
  • 쉽게 Query를 사용할 수 있는 API를 제공
  • Query를 컴파일 시간에 검증함
  • Query 결과를 LiveData로 하여 데이터베이스가 변경될 때마다 쉽게 UI를 변경할 수 있음

 

2-1. Room의 주요 3요소

  • @Database
    - 클래스를 데이터베이스로 지정하는 annotation, RoomDatabase를 상속받은 클래스여야 함
    - Room.databaseBuilder를 이용하여 인스턴스를 생성함
  • @Entity
    - 클래스를 테이블 스키마로 지정하는 annotation
  • @Dao
    - 클래스를 DAO(Data Access Object)로 지정하는 annotation
    - 기본적인 insert, delete, update SQL은 자동으로 만들어주며, 복잡한 SQL은 직접 만들 수 있다

 

2-2. Gradle 파일 설정

  • Room은 안드로이드 아키텍처에 포함되어 있다
  • 사용하기 위해 build.gradle 파일의 dependencies에 아래 내용을 추가해야 한다
    - Androidx 사용의 경우를 가정, Android Studio와 SDK는 최신 버전으로 사용
    - "kotlin-kapt" 플러그인 추가
    - dependencies 추가
plugins {
	...
    id("kotlin-kapt")
}
...

dependencies {
	...
    val room_version = "2.6.1"
    implementation("androidx.room:room-runtime:$room_version")
    annotationProcessor("androidx.room:room-compiler:$room_version")
    kapt("androidx.room:room-compiler:$room_version")
    // optional - Kotlin Exteisions and Coroutiones support for Room
    implementation("androidx.room:room-ktx:$room_version")
    // optional1 - Test helpers
    testImplementation("androidx.room:room-testing:$room_version")
}

 

2-3. 예제

activity_main.xml

더보기
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="5dp"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/edit_student_id"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ems="10"
        android:hint="ID"
        android:inputType="number"
        app:layout_constraintEnd_toStartOf="@+id/query_student"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintHorizontal_chainStyle="spread_inside"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <EditText
        android:id="@+id/edit_student_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ems="10"
        android:hint="student name"
        android:inputType="textPersonName"
        app:layout_constraintEnd_toStartOf="@+id/add_student"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintHorizontal_chainStyle="spread_inside"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/edit_student_id" />

    <Button
        android:id="@+id/add_student"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Add Student"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toEndOf="@+id/edit_student_name"
        app:layout_constraintTop_toBottomOf="@+id/query_student" />

    <Button
        android:id="@+id/query_student"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Query Student"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toEndOf="@+id/edit_student_id"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="Result of Query Student"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/edit_student_name" />

    <TextView
        android:id="@+id/textView2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:text="Student List"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/text_query_student" />

    <TextView
        android:id="@+id/text_query_student"
        android:layout_width="0dp"
        android:layout_height="100sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView" />

    <TextView
        android:id="@+id/text_student_list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginBottom="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView2" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

MyEntity.kt

더보기
// 테이블 이름을 student_table로 지정함
@Entity(tableName = "student_table")
data class Student (
    @PrimaryKey @ColumnInfo(name = "student_id")
    val id: Int,
    val name : String
)
  • Entity는 테이블 스키마를 정의
  • CREATE TABLE student_table(student_id INTEGER PRIMART KEY, name TEXT NOT NULL)

 

MyDao.kt

더보기
@Dao
interface MyDAO {
    // INSERT, key 충돌이 나면 새 데이터로 교체
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertStudent(student: Student)

    @Query("SELECT * FROM student_table")
    // LiveData<> 사용
    fun getAllStudents(): LiveData<List<Student>>

    // 메서드 인자를 SQL문에서 :를 붙여 사용
    @Query("SELECT * FROM student_table WHERE name = :sname")
    suspend fun getStudentByName(sname: String): List<Student>

    @Delete
    // primary key is used to find the student
    suspend fun deleteStudent(student: Student)
}
  • DAO는 interface나 abstract class로 정의되어야 한다
  • Annotation에 SQL Query를 정의하고 그 Query를 위한 메서드를 선언한다
  • 가능한 annotation으로는 @Insert, @Update, @Delete, @Query가 있다
  • @Insert, @Update, @Delete는 SQL Query를 작성하지 않아도 컴파일러가 자동으로 생성한다
    - @Insert나 @Update는 key가 중복되는 경우 처리를 위해 onConflict를 지정할 수 있다
    → OnConflictStrategy.ABORT: key 충돌 시 종료
    → OnConflictStrategy.IGNORE: key 충돌 무시
    → OnConflictStrategy.REPLACE: key 충돌 시 새로운 데이터로 변경
    - @Update나 @Delete는 primary key에 해당되는 튜플을 찾아서 변경/삭제한다
  • @Query로 리턴되는 데이터의 타입을 LiveData<>로 하면, 나중에 이 데이터가 업데이트될 때 Observer를 통해 할 수 있다
  • @Query에 SQL을 정의할 때 메서드의 인자를 사용할 수 있다
    - 인자 sname을 SQL에서 :sname으로 사용
  • fun 앞에 suspend는 Kotlin coroutine을 사용하는 것이다. 나중에 이 메서드를 부를 때는 runBlocking{} 내에 호출해야 한다
  • LiveData는 비동기적으로 동작하기 때문에 coroutine으로 할 필요가 없다

 

MyDatabase.kt

더보기
@Database(entities = [Student::class],
    exportSchema = false, 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) {
                TODO("Not yet implemented")
            }
        }

        fun getDatabase(context: Context) : MyDatabase {
            if (INSTANCE == null) {
                INSTANCE = Room.databaseBuilder(
                    context, MyDatabase::class.java, "school_database")
                    .addMigrations(MIGRATION_1_2)
                    .build()
                // for in-memory database
                /*
                Instance = Room.inMemoryDatabaseBuilder(
                    context, MyDatabase::clase.java
                ).build()
                 */
            }
            return INSTANCE as MyDatabase
        }
    }
}
  • RoomDatabase를 상속하여 자신의 Room 클래스를 만들어야 한다
  • 포함되는 Entity들과 데이터베이스 버전(version)을 @Database annotation에 지정한다
    - version이 기존에 저장되어 있는 데이터베이스보다 높으면, 데이터베이스를 open 할 때 migration을 수행하게 된다
    - *Migration 수행 방법은 Room Database 객체의 addMigration() 메서드를 통해 알려준다
  • DAO를 가져올 수 있는 getter 메서드를 만든다
    - 실제 메서드 정의는 자동으로 생성된다
  • Room 클래스의 인스턴스는 하나만 있으면 되므로 Singleton 패턴을 사용한다
  • Room 클래스의 객체 생성은 Room.databaseBuilder()를 이용한다

*Migration

// MyDatabase.kt 중

Room.databaseBuilder(...).addMigrations(MIGRATION_1_2)

private val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                TODO("Not yet implemented")
            }
        }
  • 앞에서 MyRoomDatabase 객체 생성 후 addMigrations() 메서드를 호출하여 Migration 방법을 지정했음
    - 여러 개의 Migration을 지정 가능하다

 

MainActivity.kt

더보기
class MainActivity : AppCompatActivity() {
    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
    lateinit var myDao : MyDAO

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        myDao = MyDatabase.getDatabase(this).getMyDao()

        val allStudents = myDao.getAllStudents()
        allStudents.observe(this){
            val str = StringBuilder().apply {
                for ((id, name) in it) {
                    append(id)
                    append("-")
                    append(name)
                    append("\n")
                }
            }.toString()
            binding.textStudentList.text = str
        }

        binding.addStudent.setOnClickListener {
            val id = binding.editStudentId.text.toString().toInt()
            val name = binding.editStudentName.text.toString()
            if (id > 0 && name.isNotEmpty()) {
                CoroutineScope(Dispatchers.IO).launch {
                    myDao.insertStudent(Student(id, name))
                }
            }

            binding.editStudentId.text = null
            binding.editStudentName.text = null
        }

        binding.queryStudent.setOnClickListener {
            val name = binding.editStudentName.text.toString()
            CoroutineScope(Dispatchers.IO).launch {
                val results = myDao.getStudentByName(name)

                if (results.isNotEmpty()){
                    val str = StringBuilder().apply {
                        results.forEach { student ->
                            append(student.id)
                            append("-")
                            append(student.name)
                        }
                    }
                    withContext(Dispatchers.Main) {
                        binding.textQueryStudent.text = str
                    }
                } else {
                    withContext(Dispatchers.Main) {
                        binding.textQueryStudent.text = ""
                    }
                }
            }
        }
    }
}

 

 

*UI와 연결

  • RoomDatabase 객체에서 DAO 객체를 받아오고, 이 DAO 객체의 메서드를 호출하여 데이터베이스를 접근한다
// MainActivity.kt 중

myDao = MyDatabase.getDatabase(this).getMyDao()
// (주의) UI를 블록할 수 있는 DAO 메소드를 UI 스레드에서 바로 호출하면 안됨
runBlocking {
	// suspend 지정되어 있음
    myDao.insertStudent(Student(1, "james")) 
}
// LiveData는 Observer를 통해 비동기적으로 데이터를 가져옴
val allStudents = myDao.getAllStudents()

 

*UI와 연결 - *LiveData

  • LiveData<> 타입으로 리턴되는 DAO 메서드 경우
    - observer() 메서드를 이용하여 Observer를 지정한다
    - 데이터가 변경될 때마다 자동으로 Observer의 onChanged()가 호출된다
  • LiveData<>를 리턴하는 DAO 메서드는 Observer를 통해 비동기적으로 데이터를 받기 때문에, UI 스레드에서 직접 호출해도 문제가 없다
// MainActivity.kt 중

val allStudents = myDao.getAllStudents()
// Observer::onChanged() 는 SAM 이기 때문에 lambda로 대체
allStudents.observe(this) {
    val str = StringBuilder().apply {
            for ((id, name) in it) {
                append(id)
                append("-")
                append(name)
                append("\n")
            }
        }.toString()
    binding.textStudentList.text = str
}

 

 

2-4.  Room Database의 주요 어노테이션(Annotations)

복습)

더보기
  • @Database
    - 데이터베이스 클래스를 정의할 때 사용한다
    - 데이터베이스에 포함될 Entity의 버전을 명시한다
  • @Entity
    - 데이터베이스 내의 테이블을 정의할 때 사용한다
    - 클래스 이름이 테이블 이름으로 사용되며, 필드는 column으로 매핑된다.
  • @PrimaryKey
    - Entity의 기본 키(primary key)를 정의할 때 사용한다
    - 유니크한 값이어야 하며, 데이터베이스 내에서 각 Entity를 구분하는데 사용된다
  • @ColumnInfo
    - 테이블의 column 정보를 세부적으로 정의할 때 사용된다
    - column의 이름, 타입, 인덱스 등을 설정할 수 있다
  • @Dao
    - 데이터 접근 객체(Data Access Object)를 정의할 때 사용한다
    - 데이터베이스의 CRUD(Creat, Read, Update, Delete) 연산을 위한 메서드를 퐇마한다
  • @Insert
    - 데이터를 삽입하는 메서드에 사용한다
    - 해당 메서드는 Entity를 인자로 받아 데이터베이스에 추가한다
  • @Query
    - 복잡한 SQL Query를 실행하는 메서드에 사용한다
    - 메서드에 주어진 SQL Query를 실행하여 결과를 반환한다
  • @Update
    - 데이터를 업데이트하는 메서드에 사용된다
    - 인자로 받은 Entity의 데이터로 기존 레코드를 갱신한다
  • @Delete
    - 데이터를 삭제하는 메서드에 사용한다
    - 인자로 받은 Entity를 데이터베이스에서 제거한다
  • @Transaction
    - 메서드가 하나의 트랜잭션으로 실행되어야 함을 나타낸다
    - 여러 연산을 하나의 작업으로 묶어 실행할 때 사용한다
  • @ForeignKey
    - Entity 간의 외래 키 관계를 정의할 때 사용한다
    - 참조 무결성을 유지하는데 도움을 준다
  • @Index
    - 특정 column에 인덱스를 생성할 때 사용한다
    - Query 성능을 향상시키는 데 유용하다

 

 

*LiveData란?

안드로이드의 아키텍처 컴포넌트의 일부로, 관찰 가능한 데이터 홀더 클래스이다. 이를 통해 UI 컴포넌트(액티비티, 프래그먼트 등)는 데이터의 변경 사항을 관찰하고 이에 반응할 수 있다. 데이터가 변경될 때마다 LiveData는 관찰자에게 알림을 보낸다 

 

LiveData의 핵심 특징

  1. 수명주기 인식
    : LiveData는 안드로이드의 수명주기를 인식한다. 즉, 액티비티나 프래그먼트의 수명주기 상태에 따라 알림을 자동으로 관리한다. 이로 인해 메모리 누수 및 액티비티가 종료된 상태에서 발생할 수 있는 크래시를 방지할 수 있다
  2. UI와 데이터 상태의 일관성 유지
    : LiveData를 사용하면 UI가 데이터와 일관되게 유지된다. 데이터가 변경될 때 UI가 자동으로 갱신되기 때문에, 사용자에게 최신의 정보를 제공할 수 있다
  3. 중앙 집중적인 데이터 관리
    : LiveData는 뷰모델(ViewModel)과 함께 사용되어 앱의 데이터를 중앙에서 관리할 수 있게 한다. 이는 데이터 관리를 효율적으로 만들어준다
  4. 데이터 변경에 따른 자동 업데이트
    : LiveData의 관찰자는 오직 활성 수명주기 상태(active lifecycle state)의 컴포넌트에만 알림을 보낸다. 이는 데이터가 변경될 때 활성 상태의 UI만 업데이트되어, 불필요한 리소스 사용을 줄여준다
728x90