데이터 저장

하이솝·2026년 5월 17일

학습 목표

  • 안드로이드에서 데이터 저장 방법에 대해 설명할 수 있다.
  • 앱 전용 데이터 저장 방법
    File, DataStore, Room에 대해 차이를 이해하고 설명할 수 있다.
  • File에서 내/외부 저장소 폴더 경로를 알아내어 사용할 수 있다.
  • DataStore의 Preference를 사용하여 key-value를 읽고 저장할 수 있다.
  • Room을 이용해서 로컬 데이터베이스(SQLite)를 사용할 수 있다.
  • Repository와 ViewModel의 관계를 이해하고 사용할 수 있다.
  • CoroutineScope와 Flow를 이용하여 비동기 연산을 할 수 있다.

데이터 저장 방법

앱 전용 데이터 저장

  • 해당 앱만 사용 가능, 앱 제거하면 같이 삭제됨
  • File: 자바의 file과 동일한 방식으로 사용
  • DataStore: key-value 저장, Proto 저장
  • Room: 로컬 데이터베이스 SQLite

공유 데이터 저장

  • 다른 앱과 공유 가능, 앱 제거해도 남아 있음
  • Media: ContentResolver 이용
  • Documents: DocumentsProvider를 통해 Intent를 사용하여 접근
  • Datasets: (BolbStoreManager를 통해 대형 파일에 접근)

데이터 레이어(Data Layer)

  • UI Layer
    UI 담당

  • Data Layer
    비즈니스 로직, 데이터 저장/읽기 담당

UI와 Data Layer를 분리하여

  • 데이터 레이어를 여러 화면에서 사용
  • 앱의 여러 부분 간에 정보를 공유
  • UI 없이 데이터 레이어 단위테스트 가능
  • Domain Layer
    여러 데이터 레이어 중 일부 선택하여 추상화 레이어 제공
  • 저장소(Repository)
    데이터 제공, 비즈니스 로직, 여러 데이터 소스 충돌 방지
  • 데이터 소스
    로컬 파일, 네트워크 소스, 데이터베이스 소스 등

데이터 저장소

내부 저장소(Internal Storage)

  • 파일을 소유한 앱에서만 액세스, 다른 앱은 접근 불가
  • 상대적으로 크기가 작음

외부 저장소(External Storage)

  • 여러 파티션으로 나누어져 있기도 함
  • SD카드도 여기에 포함됨
  • 내부 저장소에 비해 공간이 큼
  • 보통 다른 앱과 공유 가능한 미디어나 문서 파일 저장
  • 앱 전용 파일도 저장 가능

앱 전용 데이터 저장-File

  • Context의 저장소 경로 메소드 사용
  • 내부 저장소를 사용할 때
    fileDir 속성(getFilesDir())
    cacheDir 속성(getCacheDir()), 임시 파일용
  • 외부 저장소를 사용할 때
  • getExternalFilesDir(null)
  • externalCacheDir 속성(getExternalCacheDir()), 임시 파일용
  • 안드로이드 4.4(API 19) 이상에서는 별도의 권한 없이 사용 가능

권한(permission)
안드로이드 보안 기능으로, 앱이 시스템의 민감한 리소스를 사용하려고 할 때, 권한이 필요하며 이 권한은 사용자가 명시적으로 승인해야 함

DataStore

Preference DataStore

  • key-value 형태로 저장
  • 사전에 정의된 스키마 형태 없음

Proto DataStore

  • 사전에 정의된 스키마(Protocol Buffers)에 따라 데이터를 저장하고 읽음
  • 구조화된 데이터를 일련의 바이너리로 변환 또는 그 반대로 변환하는 것
    변환된 바이너리를 파일로 저장하거나 네트워크를 통해 전송하는 용도

Preference DataStore

  • Preference DataStore를 이용하여 설정 읽고 저장하기
  • DataStore 객체 생성
val Context.dataStore: DataStore<Preferences> 
by preferencesDataStore(name = "settings")

DataStore는 하나의 인스턴스만 생성해야 함

Repository-ViewModel-View(Compose)

  • Repository
    데이터를 저장하고 읽는 것, View와 무관

  • ViewModel
    데이터를 View에 표시하기 위한 것

class MyViewModel(private val myRepository: MyRepository) : ViewModel() {
    val myPref = myRepository.myPref
    fun setPref(key: Preferences.Key<String>, value: String) {
		viewModelScope.launch { // viewmodel라이프사이클에 맞춘 coroutine scope
        myRepository.setPref(key, value)
    	}
	}
}
  • Coroutine
    코틀린에서 병행 수행을 위한 작은 실행 단위
    블록될 가능성이 있는 연산(파일, 네트워크 연산 등)은 코루틴에서 수행을 권장
    Coroutine scope는 코루틴을 만들기 위한 scope

Flow

  • Producer는 데이터를 생성하여 Flow에 넣고
class MyRepository(private val context: Context) {
	val myPref : Flow<Preferences> = context.dataStore.data
  • Consumer는 Flow에서 데이터를 가져다 사용
    Consumer는 데이터를 언제 받을지 모르기 때문에 코루틴 내에서 수행되야 함

  • 앞의 예에서 collect를 호출한 것이 Consumer
    Flow에서 데이터를 가져와 State로 나타냄

val myPref by viewModel.myPref.collectAsStateWithLifecycle(initialValue = null)

Room 개요

Room

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

SQLite보다 Room 사용을 권장

Room 3요소

@Database

클래스를 데이터베이스로 지정하는 annotation,
RoomDatabase를 상속받은 클래스이어야 함

@Entity

클래스를 테이블 스키마로 지정하는 annotation

@Dao

클래스를 DAO(Data Access Object)로 지정해주는 annotation
기본적인 insert, delete, update SQL은 자동으로 생성,
복잡한 SQL은 직접 만들 수 있음

annotaion: 코드에 메타데이터(추가 정보)를 붙이는 문법

Entity 생성

  • Entity는 테이블 스키마 정의
CREATE TABLE student_table (
	student_id INTEGER PRIMARY KEY, 
    name TEXT NOT NULL
    )
@Entity(tableName = "student_table")    
data class Student (
	@PrimaryKey @ColumnInfo(name = "student_id") val id: Int,
	val name: String
)
  • @ColumnInfo: 실제 테이블 스키마에서 사용할 이름 지정
@Entity(tableName = "class_table")
data class ClassInfo (
	@PrimaryKey val id: Int,
	val name: String,
	val day_time: String,
	val room: String,
	val teacher_id: Int
)

@Entity(tableName = ""): 실제 테이블 이름을 지정
@PrimaryKey(autoGenerate = true): 키 자동으로 생성

@Entity(tableName = "enrollment",
    primaryKeys = ["sid", "cid"],
    foreignKeys = [
        ForeignKey(entity = Student::class, parentColumns = ["student_id"], childColumns = ["sid"]),
        // sid는 Student 테이블의 student_id를 참조
        ForeignKey(entity = ClassInfo::class, parentColumns = ["id"], childColumns = ["cid"])
        // cid는 ClassInfo 테이블의 id를 참조
    ]
)
data class Enrollment (
    val sid: Int,
    val cid: Int,
    val grade: String? = null
)

DAO 생성

  • DAO는 interfaceabstract class로 정의되어야 함
  • Annotation에 SQL 쿼리를 정의하고 그 쿼리를 위한 메소드 선언
@Query ("SELECT * from table") fun getAllData() : List<Data>
  • @Insert, @Update, @Delete는 SQL 쿼리를 작성하지 않아도
    컴파일러가 자동으로 생성

  • @Insert, @Updatekey가 중복되는 경우 처리를 위해 onConflict를 지정
    OnConflictStrategy.ABORT: key 충돌 시 종료
    OnConflictStrategy.IGNORE: key 충돌 시 무시
    OnConflictStrategy.REPLACE: key 충돌 시 새로운 데이터로 변경

  • @Query로 리턴되는 데이터의 타입을 Flow<>로 하면,
    데이터 업데이트 시 Collect/State를 통해 알 수 있음

@Query("SELECT * from table") fun getAllDate() : Flow<List<Data>>
  • @Query에 SQL을 정의할 때, 메소드의 인자를 사용할 수 있음
@Query("SELECT * from student_table WHERE name= :sname")
// 인자 sname을 SQL에서 :sname으로 사용
suspend fun getStudentByName(sname: String) : List<Student>
// 해당 메소드는 block될 수 있으므로 코루틴 내에서 호출
  • Flow<>를 리턴할 때에는 비동기적으로 동작하기 때문에
    coroutine으로 할 필요 없음
@Dao
interface MyDAO {
    @Insert(onConflict = OnConflictStrategy.REPLACE)  // INSERT, key 충돌이 나면 새 데이터로 교체
    suspend fun insertStudent(student: Student)
    
    @Query("SELECT * FROM student_table")
    fun getAllStudentsFlow(): Flow<List<Student>>        // Flow<>사용
    
    @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
    
    @Transaction
    @Query("SELECT * FROM student_table WHERE student_id = :id")
    suspend fun getStudentsWithEnrollment(id: Int): List<StudentWithEnrollments>
    // ...
}

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],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) {
        // 버전 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

  • 데이터베이스 버전이 바뀔 때, 기존 데이터를 유지하면서 구조를 변경하는 것
private val MIGRATION_1_2 = object : Migration(1, 2) {  // version 1 -> 2
    override fun migrate(db: SupportSQLiteDatabase) {
        db.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(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE student_table ADD COLUMN last_update INTEGER")
    }
}
  • 스키마를 변경하면 데이터베이스 버전을 올리고 Migration을 추가해야 함
@Entity
data class Item(
    @PrimaryKey val id: Int,
    val name: String        // 컬럼 1개
)
@Entity
data class Item(
    @PrimaryKey val id: Int,
    val name: String,
    val description: String  // 컬럼 추가 = 스키마 변경
)
// 문제는 앱이 이미 설치된 사용자의 폰에는 기존 DB가 남아있는데, 
// 새 코드는 새로운 구조를 기대하기 때문에 충돌이 나게 됨

// @Database(version = 2) → 버전을 올리고
// Migration(1, 2) → "버전 1짜리 DB를 버전 2로 어떻게 바꿀지" 를 알려줘야 함
  • In-memory database로 만들어서 테스트 하는 방법도 가능
    단, 앱을 다시 시작할 때 마다 저장된 데이터가 초기화됨
    Room.inMemoryDatabaseBuilder()

  • Room 2.4.0-alpha 버전부터 Auto Migration을 지원함

UI와 연결

  • RepositoryViewModel의 사용 권장

  • RoomDatabase 객체에서 DAO 객체를 받아오고,
    이 DAO객체의 메소드 호출하여 데이터베이스 접근

class MyRepository(private val context: Context) {
	private val myDao = MyDatabase.getDatabase(context).getMyDao()
	val allStudents = myDao.getAllStudentsFlow()
	suspend fun insertStudent(student: Student) = myDao.insertStudent(student)
class MyViewModel(private val myRepository: MyRepository) : ViewModel() {
	val allStudents = myRepository.allStudents
	fun insertStudent(id: Int, name: String) = viewModelScope.launch {
	myRepository.insertStudent(Student(id, name))
}
  • Flow<> 타입으로 리턴되는 DAO 메소드 경우
    collectAsStateWithLifecycle() 메소드를 이용하여 State로 변환
    데이터가 변경될 때마다 State가 변경되어 컴포저블이 리컴포지션됨

N:1 관계 쿼리

@Entity
data class Enrollment(
    val sid: Int,   // 학생 ID (Student 테이블의 student_id 참조)
    val cid: Int    // 수업 ID (ClassInfo 테이블의 id 참조)
)
  • 앞에서 정의한 Student와 Enrollment 테이블에서
    Student 1당 N개의 Enrollment가 관계되어 있음
data class StudentWithEnrollments(  // 1:N 관계
	@Embedded val student: Student,
    // 다른 클래스의 속성을 포함시키고 싶을 때 사용
	
    @Relation(parentColumn = "student_id", entityColumn = "sid")
    // 연관된 속성값 탐색
	val enrollments: List<Enrollment>
)

DAO 생성

@Dao
interface MyDAO {
	// 생략
    @Transaction
    @Query("SELECT * FROM student_table WHERE student_id = :id")
    suspend fun getStudentsWithEnrollment(id: Int): List<StudentWithEnrollments>
	// ...
}
  • SELECT * from student_table WHERE student_id = :id
    SELECT * from enrollment WHERE sid = :id
    의 두개의 쿼리문이 작동하므로 @Transaction annotation이 필요함
    두 작업을 하나로 묶어 내부적으로 일관성을 유지하기 위함

@Transaction
DB 작업을 하나의 단위로 묶는 것

Database Inspector

  • 안드로이드 스튜디오 앱에서 사용하는 로컬 DB를 쉽게 볼 수 있음

0개의 댓글