Android

[깡쌤의 안드로이드 프로그래밍 with 코틀린][6. 앱에 다양한 기능 추가하기] 17. 저장소에 데이터 보관하기

나말지 2024. 12. 25. 22:06

1. 데이터베이스에 보관하기

 

안드로이드폰에서 이용하는 데이터베이스 관리 시스템은 오픈소스로 만들어진 SQLite 입니다. SQLite 는 테이블의 데이터를 앱의 저장소에 파일로 저장하며 외부 앱에서는 접근할 수 없습니다. 코드에서는 SQL 질의문만 작성하면 되고 실제 데이터는 SQLite 가 관리해 줍니다. 

질의문 작성하기

SQLite를 사용하려면 SQLiteDatabase 라는 API를 이용해야 합니다. SQLiteDatebase 객체는 openOrCreateDatabase() 함수를 호출해서 얻습니다.

val db = openOrCreateDatabase("testdb", Context.MODE_PRIVATE, null)

 

openOrCreateDatabase() 함수는 첫 번째 매개변수로 전달한 DB 파일을 열고 SQLiteDatabase 객체를 돌려줍니다. 만약 파일이 없으면 새로 만듭니다. SQLiteDatabase 객체에 정의된 다음 함수를 이용하면 질의문을 실행할 수 있습니다.

  • public void execSQL(String sql, Object[] bindArgs)
  • public Cursor rawQuery(String sql, String[] selectionArgs)

execSQL(), rawQuery() 함수의 첫 번째 매개변수에 질의문을 전달하고 두 번째 매개변수에는 질의문에서 ? 문자에 대응하는 값을 배열로 전달합니다. 다음 코드는 db 객체가 가리키는 DB 파일에 execSQL() 함수로 create 문을 실행해 테이블을 만드는 예입니다.

db.execSQL("create table USER_TB (" +
    "_id integer primary key autoincrement," +
    "name not null," +
    "phone)")

 

이렇게 만든 테이블에 데이터를 넣으려면 다음처럼 execSQL() 함수로 insert 문을 실행합니다.

db.execSQL("insert into USER_TB (name, phone) values (?,?)",
    arrayOf<String>("kkang", "0101111"))

 

테이블에 저장된 데이터를 조회할 때는 rawQuery() 함수로 select 문을 실행합니다. rawQuery() 함수의 반환값은 Cursor 객체입니다. 이 객체는 테이블에서 조회한 행의 집합 정도로 생각하면 됩니다.

val cursor = db.rawQuery("select * from USER_TB", null)

 

조회한 행의 열 데이터를 가져오려면 먼저 Cursor 객체로 행을 선택하고 그 행의 열 데이터를 가져옵니다. Cursor 객체로 행을 선택할 때는 moveTo~ 로 시작하는 다음 함수를 이용합니다. 이 함수는 선택한 행이 있으면 true 를 반환하고 없으면 false 를 반환합니다.

  • public abstract boolean moveToFirst() : 첫 번째 
  • public abstract boolean moveToLast() : 마지막 행을 선택합니다.
  • public abstract boolean moveToNext() : 다음 행을 선택합니다.
  • public abstract boolean moveToPosition(int position) : 매개변수로 지정한 위치의 행을 선택합니다.
  • public abstract boolean moveToPrevious() : 이전 행을 선택합니다.

Cursor 객체로 선택한 행의 열 데이터를 가져오려면 타입에 따라 getString(), getInt() 등의 함수를 이용합니다. 이 함수의 매개변수에는 가져올 데이터가 저장된 열의 인덱스를 전달합니다.

  • public abstract String getString(int columnIndex)
  • public abstract int getInt(int columnIndex)
  • public abstract double getDouble(int columnIndex)
while (cursor.moveToNext()) {
    val name = cursor.getString(0)
    val phone = cursor.getString(1)
}

데이터베이스 관리하기

SQLite 데이터베이스를 이용할 때는 질의문을 실행해야 하므로 반드시 SQLiteDatabase 객체를 이용해야 합니다. 그런데 추가로 SQLiteOpenHelper 클래스를 이용하면 데이터베이스 프로그램을 좀 더 구조적으로 작성할 수 있습니다.

 

SQLiteOpenHelper 클래스는 데이터베이스를 관리하는 코드를 추상화합니다. 여기서 데이터베이스를 관리하는 코드란 테이블을 생성하거나 변경, 제거하는 코드를 의미합니다. 이런 관리 코드는 SQLiteOpenHelper 클래스에 작성하고 데이터를 조작하는 코드는 실제 필요한 곳에 작성해 성격이 다른 두 코드를 분리할 수 있습니다.

 

SQLiteOpenHelper 는 추상 클래스이므로 이를 상속받아 하위 클래스를 작성해야 합니다.

class DBHelper(context: Context): SQLiteOpenHelper(context, "testdb", null, 1) {
    override fun onCreate(db: SQLiteDatabase?) {
    }
    override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
    }
}

 

SQLiteOpenHelper 클래스를 상속받을 때 상위 클래스의 생성자를 호출하면서 적절한 정보를 넘겨줘야 합니다. 위 코드에서 "testdb"는 DB 파일명이며 1은 개발자가 숫자로 지정하는 DB 버전 정보입니다.

 

그리고 onCreate(), onUpgrade() 함수는 SQLiteOpenHelper의 추상함수이므로 하위 클래스에서 반드시 재정의해야 합니다. 이 함수는 자동으로 호출되는데 그 시점을 알고 적절하게 활용해야 합니다.

 

  • onCreate() : 앱이 설치된 후 SQLiteOpenHelper 클래스가 이용되는 순간 한 번 호출됩니다.
  • onUpgrade() : 생성자에 지정한 DB 버전 정보가 변경될 때마다 호출합니다.

onCreate() 함수는 앱이 설치된 후 최초에 한 번만 호출되므로 이 함수에는 데이터베이스의 테이블을 생성하는 코드를 주로 작성합니다. onUpgrade() 함수는 DB 버전 정보가 변경될 때마다 호출되므로 이 함수에는 테이블의 스키마를 변경하는 코드를 주로 작성합니다. 이처럼 테이블을 생성하거나 스키마를 변경하는 코드는 SQLiteOpenHelper 에 작성하고 데이터를 조작하는 질의문은 다른 곳에서 작성하는 구조로 만듭니다.

 

SQLiteOpenHelper 클래스를 이용한다면 질의문을 실행하는 SQLiteDatabase 객체도 SQLiteOpenHelper 클래스를 이용해 생성합니다.

val db: SQLiteDatabase = DBHelper(this).writableDatabase

 

SQLiteOpenHelper 클래스의 readableDatabase나 writableDatabase 속성으로 데이터베이스 객체를 생성합니다.

 

2. 파일에 보관하기

 

안드로이드 앱에서 파일을 다룰 때는 대부분 java.io 패키지에서 제공하는 클래스를 이용하므로 파일을 읽거나 쓰는 코드는 일반 자바 프로그램과 차이가 없습니다.

 

안드로이드에서 파일 저장소는 내장 메모리와 외장 메모리 공간으로 구분되며 외장 메모리 공간은 다시 앱별 저장소와 공용 저장소로 나뉩니다. 앱별 저장소에는 다른 앱이 접근할 수 없지만 공용 저장소에는 다른 앱도 접근할 수 있습니다.

내장 메모리의 파일 이용하기

내장 메모리는 앱이 설치되면 시스템에서 자동으로 할당하는 공간입니다. 안드로이드 시스템은 앱에서 파일을 이용하지 않더라도 앱의 패키지명으로 디렉터리를 만들어 주는데, 이 디렉터리가 바로 앱의 내장 메모리 공간입니다. 이처럼 디렉터리명을 패키지명으로 만드는 것은 해당 앱에서만 접근할 수 있다는 의미이며 다른 앱에서는 이 디렉터리에 접근할 수 없습니다.

 

따라서 앱은 민감한 데이터를 대부분 내장 메모리에 저장합니다. 하지만 내장 메모리는 외장 메모리보다 용량이 작아서 크기가 큰 데이터는 외장 메모리를 이용해야 합니다. 파일은 내장 메모리에 저장하려면 java.io의 File 클래스를 이용합니다. 이때 File() 생성자의 첫 번째 매개변수는 Context 객체의 filesDir 속성을 지정하고 두 번째 매개변수에는 파일명을 전달합니다.

val file = File(filesDir, "test.txt")
val writeStream: OutputStreamWriter = file.writer()
writeStream.write("hello world")
writeStream.flush()

 

이렇게 저장한 데이터를 읽어서 가져오는 코드는 다음과 같습니다.

val readStream: BufferedReader = file.reader().buffered()
readStream.forEachLine {
    Log.d("kkang", "$it")
}

 

또는 java.io의 File 클래스를 이용하지 않고 Context 객체가 제공하는 openFileOutput() 과 openFileInput() 함수를 사용해 파일에 데이터를 쓰거나 읽을 수도 있습니다.

openFileOutput("test.txt", Context.MODE_PRIVATE).use {
    it.write("hello world!".toByteArray())
}
openFileInput("test.txt").bufferedReader().forEachLine {
    Log.d("kkang", "$it")
}

 

외장 메모리의 파일 이용하기

외장 메모리는 SD 카드와 같은 외부 저장 장치를 의미하지만 어떤 기기는 내부 저장소의 파티션을 나누어 외장 메모리로 제공할 수도 있습니다. 따라서 모든 기기가 외장 메모리를 제공한다고 보장할 수 없으므로 Environment.getExternalStorageState() 함수로 외장 메모리를 사용할 수 있는지부터 확인해야 합니다.

if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) {
    Log.d("kkang", "ExternalStorageState MOUNTED")
} else {
    Log.d("kkang", "ExternalStorageState UNMOUNTED")
}

 

매니페스트 설정

외장 메모리의 파일을 이용할 때는 매니페스트에 android.permission.READ_EXTERNAL_STORAGE, android.permission.WRITE_EXTERNAL_STORAGE 와 같은 퍼미션을 설정해줘야 합니다. 그런데 두 퍼미션은 때에 따라 필요할 수도 있고 그렇지 않을 수도 있습니다.

 

앱별 저장소든 공용 저장소든 파일 정보에 직접 접근하지 않고 ContentResolver에서 제공하는 InputStream 등을 이용한다면 두 퍼미션을 설정하지 않아도 됩니다. 하지만 File API 를 이용한다면 안드로이드 10 버전부터는 두 퍼미션과 함께 requestLegacyExternalSTorage 값도 추가로 설정해줘야 합니다.

 

결국 파일을 이용하는 방식과 안드로이드 버전에 따라 다르다는 것인데, 여기에 API 호환성까지 생각한다면 외장 메모리를 사용할 때는 될 수 있으면 다음처럼 선언하는 것이 좋습니다.

<manifest ...>
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <application
        ...
        android:requestLegacyExternalStorage="true">
        ...
    </application>
</manifest>

 

앱별 저장소 이용

외장 메모리 공간은 앱별 저장소와 공용 저장소로 구분됩니다. 앱별 저장소는 개별 앱에 해당된 공간으로, 기본적으로는 해당 앱에서만 접근할 수 있습니다. 만약 앱별 저장소의 파일을 외부 앱에서 접근하게 하려면 파일 프로바이더로 공개해야 합니다.

 

외장 메모리의 앱별 저장소 위치는 getExternalFilesDir() 함수로 구합니다.

val file: File? = getExternalFilesDir(null)
Log.d("kkang", "${file?.absolutePath}")

 

기기에 따라 다르겠지만 getExternalFilesDir(null) 함수가 반환하는 위치는 다음과 같습니다.

  • /storage/emulated/O/Android/data/패키지명/files

getExternalFilesDir() 함수의 매개변수는 파일의 종류를 나타내며 null이 아닌 다음과 같은 Environment 의 상수를 전달할 수도 있습니다.

  • Environment.DIRECTORY_PICTURES
  • Environment.DIRECTORY_DOCUMENTS
  • Environment.DIRECTORY_MUSIC
  • Environment.DIRECTORY_MOVIES

만약 Environment.DIRECTORY_PICTURES 를 전달했다면 파일이 저장되는 위치는 다음과 같습니다.

  • /storage/emulated/O/Android/data/패키지명/files/Pictures
// 파일 쓰기
val file: File = File(getExternalFilesDir(null), "test.txt")
val writeStream: OutputStreamWriter = file.writer()
writeStream.write("hello world")
writeStream.flush()

// 파일 읽기
val readStream: BufferedReader = file.reader().buffered()
readStream.forEachLine {
    Log.d("kkang", "$it")
}

 

공용 저장소 이용

카메라 앱에서 촬영한 사진 파일은 모든 앱에서 이용할 수 있습니다. 카메라 앱은 파일을 앱별 저장소가 아닌 공용 저장소에 만듭니다. 

 

지금껏 살펴본 내장 메모리, 외장 메모리의 앱별 저장소는 개별 앱을 위한 공간이므로 앱이 삭제되면 파일도 모두 삭제됩니다. 하지만 공용 저장소는 모든 앱을 위한 공간이므로 파일을 만든 앱을 삭제해도 파일은 삭제되지 않습니다.

 

공용 저장소는 안드로이드 시스템에서 파일 종류에 따라 지정한 폴더입니다. 즉, 사진, 음원, 문서 등 파일의 종류에 따라 저장하는 폴더가 지정되어 있습니다. 이 공용 저장소는 파일 경로로 직접 접근하지 않고 시스템이 제공하는 API 를 이용합니다.

val projection = arrayOf(
    MediaStore.Images.Media.ID,
    MediaStore.Images.Media.DISPLAY_NAME
)
val cursor = contentResolver.query(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    projection,
    null,
    null,
    null
)
cursor?.let {
    while(cursor.moveToNext()) {
        Log.d("kkang", "_id : ${cursor.getLong(0)}, name : ${cursor.getString(1)}"
    }
}

 

이 코드는 공용 저장소에 저장된 이미지 파일의 정보를 가져와 로그로 출력합니다. 외장 메모리의 파일 정보를 이용하지만 파일 경로를 직접 사용하지는 않았습니다. contentResolver.query() 함수의 첫 번째 매개변수에 Uri값을 지정할 때 MediaStore.Images를 이용했는데, 이는 안드로이드폰의 이미지 파일이 저장되는 공용 저장소인 DCIM과 Pictures 디렉터리를 가리킵니다. MediaStore.video는 DCIM, Movies, Pictures 디렉터리를 가리키며 Media.Store.Audito는 Alarms, audiobooks, Music, Notifications, Podcasts, Rigntones 디렉터리를 가리킵니다.

 

앱에서 이미지 데이터를 가져와 화면에 출력하는 코드는 다음과 같습니다.

val contentUri: Uri = ContentUris.withAppendedId(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    cursor.getLong(0)
)

 

ContentUris.withAppendedId() 함수의 두 번째 매개변수가 가져온 이미지의 식별값입니다. 이렇게 하면 이 이미지 파일을 이용할 수 있는 Uri 값이 반환되고, 이 Uri 값으로 이미지를 읽을 수 있는 InputStream 객체를 얻습니다.

val resolver = applicationContext.contentResolver
resolver.openInputStream(contentUri).use { stream -> 
    // stream 객체에서 작업 수행
    val option = BitmapFactory.Options()
    option.inSampleSize = 10
    val bitmap = BitmapFactory.decodeStream(stream, null, option)
    binding.resultImageView.setImageBitmap(bitmap)
}

 

resolver.openInputStream(contentUri)를 이용해 매개변수에 지정한 Uri값이 가리키는 파일을 읽을 수 있는 Stream 객체를 얻고 이 객체로 이미지 데이터를 가져옵니다.