Android 콘텐트 프로바이더 - SQLite

woga·2022년 9월 4일
0

Android 공부

목록 보기
36/49
post-thumbnail

콘텐트 프로바이더는 외부 프로세스에 데이터를 제공하는 표준 인터페이스다.
콘텐트 프로바이더에서 주로 데이터 소스로 사용하는 SQLite를 살펴보고 세부 내용을 살펴보자

SQLite

SQLite는 로컬 DB지만 속도가 그렇게 빠르지 않다. 딱 개인 프로젝트 혼자서 할때 쓸 수 있는 정도다. 이는 네이티브 라이브러리에 포함되어있고 프레임워크를 거쳐서 접근하고 사용한다

db 내용 확인

SQLite db 파일은 /data/date/패키지/database에 저장된다. 일반적으로 db 파일에 직접 접근하거나 쿼리를 실행할 수 없다(루팅한 단말기가 아닌 이상)

개발 시에 에뮬레이터에서 db를 확인하는 방법은 2가지다.

  • sqlite shell에서 쿼리를 실행한다

  • adb pull를 통해 모든 db 파일을 가져와서, SQLite Database Browser 같은 툴로 데이터를 확인하고 쿼리를 실행한다.

adb shell에서 아래와 같은 명령어로 모든 db 파일 목록을 확인할 수도 있다.
ls -R /data/data/*/databases
별거 아닌 명령어지만 단말에 어떤 db 파일들이 있는지 한번쯤 알아보기 위해 실행해 볼 필요는 있다

sqlite shell

시스템 설정의 경우 테이블이 어떻게 구성되었는지 확인하기 위해 sqlite shell에 접근해보자

닷 커맨드

sqlite shell에는 SQLite 닷 커맨드라고 불리는 명령어 모음이 있다.
말 그대로 닷(.)으로 시작하고 다른 명령어처럼 세미콜론(;)을 쓰지 않는다.

  • 테이블 목록보기

sqlite> .tables

  • 스키마 확인

sqlite> .schema system

  • 조회할 때 칼럼명 헤더를 보는 옵션

on/off 옵션을 쓸 수 있고 디폴트는 off다

sqlite> .headers on

PRAGMA 명령어

많이 쓰이진 않지만 알면 유용한 게 바로 PRAGMA 명령어이다. PRAGMA는 DB의 환경 변수나 상태 플래그를 가져오거나 변경할 때 사용한다

SQLite에서 지원하는 언어 중에서 C API 등을 보면 다양한 함수가 있는데, 안드로이드의 SQLiteDatabase 클래스에서는 메서드 개수가 많지 않다. 그나마 여기에 도움을 주는 것이 PRAGMA 명령어라고 보면 된다.

DB 락 문제

앱에서 SQLite를 사용할 때 가장 문제가 되는 것은 DB 락이다.
간단한 key-value 스키마라면 메인 스레드에서 쿼리를 해도 별 문제가 없지만, 일반적으로 DB 명령은 백그라운드 스레드에서 실행하는 것이 권장된다. DB 락 문제는 스레드 간(또는 프로세스 간) 명령을 실행할 때 락을 잡는 시점이 겹치면서 발생한다.

5가지 락 상태

락의 기본 원칙은 DB에 쓸 때는 배타 락(exclusive lock)을 잡고, 읽을 때는 공유 락(shared lock)을 잡는다는 것이다. 배타 락은 말 그대로 다른 락을 허용하지 않고, 공유 락은 다른 공유 락과 함께 공존할 수 있다.

락 상태에는 아래의 5가지가 있따. 아래 설명에서는 여러 프로세스에서 락이 발생하는 경우를 얘기했는데 프로세스를 스레드로 바꾸어도 내용은 동일하다.

  • UNLOCKED

기본 상태. 읽기와 쓰기가 안 된다.

  • SHARED

읽기만 되고 쓰기는 안 된다. 여러 프로세스가 동시에 공유 락을 가질 수 있다.
하나 이상의 공유 락이 활성화되어 있다면, 다른 프로세스에서 쓰기를 할 수 없다. 쓰기를 위해서는 공유 락이 모두 해제될 대까지 대기한다.

  • RESERVED

프로세스가 미래 어느 시점에 쓰기를 한다는 일종의 플래그 락이다. 예약 락은 하나만 있을 수 있으며, 여러 공유 락과 공존할 수 있다. 예약 락 상태에서는 새로운 공유 락을 더 잡을 수도 있다.

  • PENDING

락을 잡고 있는 프로세스가 가능한 한 빨리 쓰기를 하려고 한다. 현재의 모든 공유 락이 해제될 때까지 기다려서 배타 락을 가지려고 한다. 펜딩 락 상태에서는 새로운 공유 락을 잡을 수 없다.

  • EXCLUSIVE

파일에 쓰기 위해서 필요하며, 오직 하나의 배타 락만 허용된다. 다른 락과 공존할 수 없다. SQLite에서는 동시성을 높이기 위해 배타 락을 잡는 시간을 최소화하고 있는데, 우리가 만드는 코드 내에서도 배타 락 구간을 줄이도록 노력해야 한다.

이것은 스레드 프로그래밍에서 동기화(synchronized) 블록을 넓게 잡지 않도록 권장하는 것과 비슷한다.

DB 락의 발생 원인

결국 DB 락이 발생하는 원인은 CRUD(create, read, update, delete) 가운데 CUD에서 쓰기를 하면서 배타 락을 잡는 것 때문이다.

쿼리 문장이 단순한 CUD에서는 짧은 시간만 락이 잡히기 때문에 문제가 빈번하게 발생하지는 않는다. 가장 배타 락을 오래 잡을 수 있는 케이스는 쓰기를 한꺼번에 하는 트랜잭션이다.

트랜잭션 동작 방식

SQLite에서 트랜잭션은 지연(deferrd), 즉시(immediate), 배타(exclusive)의 3가지 동작 방식(behavior)를 사용한다. 트랜잭션 방식의 디폴트는 지연(deferrd)이다.

  • deferred

말 그대로 락을 가능한 한 뒤로 미룬다. 트랜잭션을 시작할 때는 락을 잡지 않는다. 첫 읽기 작업이 있을 때 공유 락을 잡고 첫 쓰기 작업이 있ㅇ을 때 예약 락을 잡는다. 락이 최대한 뒤로 미뤄지기 때문에 다른 프로세스나 스레드에서 DB 작업을 할 수 있다.

  • immediate

트랜잭션을 시작할 때 예약 락이 잡힌다. 예약 락은 2개 이상 잡힐 수 없으므로, 다른 즉시 방식 트랜잭션을 시작할 수는 없다. 그래도 다른 프로세스나 스레드에서 읽기를 할 수는 있다.

  • exclusive

트랜잭션을 시작할 때부터 배타 락이 잡힌다. 따라서 트랜잭션의 시작부터 끝까지 다른 프로세스나 스레드에서 DB 작업을 전혀 할 수 없다.

그러나 안드로이드에서 지원하는 것은 배타와 즉시 방식 2가지뿐이다.

코드에서 트랜잭션 사용 방법

SQLiteDatabase에서 트랜잭션을 쓰는 패턴은 아래와 같다. 기본적으로 트랜잭션을 배타 방식으로 시작한다.

db.beginTransaction();

try {
	...
    db.setTransactionSuccessful();
} catch (Exception e) {
	...
} finally {
	db.endTransaction();
}

허니콤부터 beginTransaction() 외에 beginTransactionNonExclusive() 메서드도 사용 가능하며, 즉시 방식으로 트랜잭션을 시작한다. 결과적으로 트랜잭션에서 DB 락 문제를 조금이라도 회피하기 위해 아래와 같이 짤 수 있다.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
	db.beginTransactionNonExclusive();
} else {
	db.beginTransaction();
}

try {
	...
    db.setTransactionSuccessful();
} catch (Exception e) {
	...
} finally {
	db.endTransaction();
}

DB 락 문제 테스트의 어려움

사용자 단말에서 DB 락 문제가 발생하면 DB 락을 재현하는 것이 쉽지 않다. 여러 단말을 가지고 테스트해도 마찬가지다.

파일 IO 성능이 좋지 않았던 초기 안드로이드 단말에서는 문제가 쉽게 드러났지만, 최신 단말에서는 눈에 금방 띄지 않는다. QA 테스트에서는 나타나지 않던게 배포 후에 다량으로 나타난 경우도 있었다.

읽기 전용 DB 고려

여러 스레드에서 DB 명령을 실행할 떄 SQLiteDatabase 인스턴스를 1개만 사용하는 방식은 인스턴스를 여러개 사용하는 것에 비해 속도 면에서 좋지 않다.

락 문제 때문에 굳이 1개의 인스터를 유지해야 할까?
여러 스레드에서 읽기만 한다면 여러 인스턴스를 사용해도 된다.

캘린더 앱으로 예를 들면 공휴일 데이터는 읽기 쓰기와 필요한 데이터와 동일한 DB에 두지 말고, 별도의 읽기 전용 DB에 두는 것이 좋다.

여러 스레드에서 읽기 전용 DB에 접근할 때 각각 별도의 SQLiteDatabase 인스턴스를 가지고 읽기 명령을 실행해도 DB 락 문제없이 동시 실행이 가능하다.

SQLiteOpenHelper 클래스

SQLiteDatabase는 SQLite에 접근하는 클래스로, SQL 명령어를 실행하고 DB를 관리하는 메서드를 가지고 있다. SQLite를 사용하기 위해서는 꼭 거쳐야 하는 클래스이지만 실제 앱에서 SQLiteDatabase를 직접 생성하고 접근해서 사용하는 경우는 드물다. 바로 헬퍼 클래스인 SQLiteOpenHelper를 상속해서 아용하는데, SQLite에 접근할 때 SQLiteOpenHelper에서 DB 생성이나 DB 버전 관리를 알아서 해준다.

이 외에도 DB마다 별도의 DB 헬퍼가 필요한 점, Cursor 구현체는 주로 SQLiteCursor을 사용하기도 한다.

DB 생성 시점

DB는 어느 시점에 생성될까? SQLiteOpenHelper 생성자에서 한다고 생각할 수 있지만 그렇지 않다. 실제로 DB 열기/생성(openOrCreate) 시점은 SQLiteOpenHelper의 getReadableDatabase()나 getWritableDatabase() 메서드를 호출할 때다.

정확하게는 SQLiteOpenHelper에는 SQLiteDatabase 인스턴스를 1개 가지고 있는데 이 인스턴스가 앞에 이미 생성되었으면 그것을 사용한다. 인스턴스가 생성된 게 없을 경우에는 인스턴스를 새로 생성하고서 onCreate()나 onUpgrade() 메서드를 실행한다.

버전 업그레이드

테이블 변경 시에는 DatabaseHelper 생성자에 새로운 버전을 전달하고, SQLiteOpenHelper의 onUpgrade() 메서드에 변경 내용을 적용하면 된다.

참고로, onCreate(), onUpgrade() 메서드는 둘 중 하나만 실행한다
개발하다보면 순차적으로 호출된다고 착각할 수 있다. 그러나 둘 중 하나만 실행되기 때문에 최신 DB 스키마와 데이터를 반영할 수 있도록 버전이 올라갈 때마다 onCreate() 메서드를 수정해야 한다.

DB 헬퍼는 싱글톤으로 유지

DB 헬퍼는 앱 전체에 걸쳐 단일 인스턴스를 가지고 있어야 DB 락 문제에서 자유롭다.
그래서 일반적으로 싱글톤 패턴을 만들어서 사용한다

public class DatabaseHelper extends SQLiteOpenHelper {

	//singleton pattern
}

대신 context를 전달할 땐 context.getApplicationContext()를 사용한다

close() 메서드는 거의 사용하지 않음

close() 메서드는 호출할 필요가 거의 없다. SQLiteOpenHelper의 close() 메서드는 SQLiteDatabase 인스턴스의 close()를 호출하고, SQLiteDatabase 인스턴스를 null로 만든다.
SQLiteDatabase를 닫지 않고 인스턴스를 계속 사용해도 문제가 없다.

close() 메서드를 사용하지 않는 또 다른 이유는 close() 실행 시점 때문에 문제가 발생할 수 있기 때문이다.

A 스레드에서 데베 작업을 하고 있는데 B 스레드에서 close()를 실행했따고 보자. 그럼 시점에 따라 close가 실행되고 A 스레드에서 쿼리를 실행한다면 NullPointerException이 발생한다.

onConfigure()와 onOpen() 메서드로 DB 기능 변경

DB 기능을 변경할 수 있는 메서드에는 onConfigure(SQLiteDatabase db) 메서드와 onOpen(SQLiteDatabse db) 메서드가 있따. onConfigure() 메서드는 SQLiteDatabase 생성/열기 이후, onCreate()와 onUpgrade() 메서드 전에 실행되는 것으로 WAL(write-ahead logging)이나 외래 키(foregin key) 지원 같은 기능을 활성화할 수 있다. onOpen() 메서드는 onCreate()와 onUpgrade() 이후에 DB 연결 설정을 변경할 때 사용한다.

Reference

  • 안드로이드 프로그래밍 Next Step
profile
와니와니와니와니 당근당근

0개의 댓글