SQLite 성능 권장사항

Android는 효율적인 SQL 데이터베이스인 SQLite를 기본적으로 지원합니다. 다음 권장사항을 따라 앱 성능을 최적화하여 데이터가 증가하더라도 앱 속도를 빠르게 유지하세요. 이러한 권장사항을 활용하면 재현 및 문제 해결이 어려운 성능 문제가 발생할 가능성도 줄일 수 있습니다.

성능을 향상하려면 다음의 성능 원칙을 따르세요.

  • 행과 열 읽기 줄이기: 필요한 데이터만 검색하도록 쿼리를 최적화하세요. 데이터베이스에서 읽어 오는 데이터 양을 최소화하세요. 지나친 데이터 검색은 성능에 영향을 줄 수 있습니다.

  • SQLite 엔진으로 작업 푸시: SQL 쿼리 내에서 계산, 필터링, 정렬 작업을 수행하세요. SQLite의 쿼리 엔진을 사용하면 성능을 크게 개선할 수 있습니다.

  • 데이터베이스 스키마 수정: SQLite가 효율적인 쿼리 계획과 데이터 표현을 구성하는 데 도움이 되도록 데이터베이스 스키마를 설계하세요. 테이블의 색인을 적절하게 생성하고 테이블 구조를 최적화하여 성능을 개선하세요.

가용한 문제 해결 도구를 사용하여 SQLite 데이터베이스의 성능을 측정하고 최적화가 필요한 영역이 어디인지 확인해 볼 수도 있습니다.

Jetpack Room 라이브러리를 사용하는 것이 좋습니다.

성능 향상을 위한 데이터베이스 구성

SQLite에서 최적의 성능을 경험하기 위해 데이터베이스를 구성하려면 이 섹션에서 소개하는 단계를 따르세요.

미리 쓰기 로깅 사용 설정

SQLite는 로그에 변형을 추가하는 방식으로 변형을 구현하는데, 이 로그는 가끔 데이터베이스로 압축됩니다. 이를 미리 쓰기 로깅 (WAL)이라고 합니다.

ATTACH DATABASE를 사용하지 않는다면 WAL을 사용 설정하세요.

PRAGMA journal_mode=WAL를 직접 실행하는 대신 프레임워크를 통해 WAL을 사용 설정합니다. Room은 RAM이 적은 기기가 아닌 기기에서 기본적으로 WAL을 사용 설정합니다. Room이 없는 경우 다음 중 하나를 사용합니다.

프레임워크는 데이터베이스가 WAL을 사용한다는 것을 알면 읽기가 쓰기와 동시에 실행될 수 있도록 연결 풀을 열고 다음 섹션에서 설명하는 WAL 동기 모드를 적용합니다. PRAGMA journal_mode=WAL를 직접 실행하는 경우 프레임워크는 데이터베이스를 여전히 롤백 저널 데이터베이스로 취급합니다. 단일 연결을 사용하며 커밋은 기본 FULL 동기 모드를 유지합니다.

동기화 모드 완화

SQLite에는 동기화 모드를 제어할 수 있는 옵션이 있습니다. FULL 모드에서는 모든 커밋이 데이터가 디스크에 도달하도록 하기 위해 fsync를 발행합니다. 그러나 이렇게 하면 데이터 내구성은 향상되지만 커밋이 느려집니다. WAL을 사용하면 NORMAL 모드는 모든 커밋에서 동기화되지 않으며 데이터베이스가 손상되지 않도록 보호합니다.

프레임워크를 통해 WAL을 사용 설정하면 Android는 기기의 기본 WAL 동기 모드를 적용합니다. 이 모드는 Android 9 이상의 AOSP에서 NORMAL입니다. 기기 제조업체는 이 기본값을 변경할 수 있으며 WAL을 사용하지 않는 데이터베이스는 기본적으로 FULL로 설정됩니다. 데이터베이스에서 NORMAL를 사용하도록 하려면 데이터베이스를 열 때 설정합니다.

// When opening the database
val params = SQLiteDatabase.OpenParams.Builder()
    .addOpenFlags(SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING)
    // SYNC_MODE_NORMAL is available in API level 33 and higher.
    // On API levels 28 to 32, pass "NORMAL" instead.
    .setSynchronousMode(SQLiteDatabase.SYNC_MODE_NORMAL)
    .build()
// With SQLiteOpenHelper, call setOpenParams(params) in your constructor.

// Or: after having opened the database
db.execSQL("PRAGMA synchronous = NORMAL")

이 설정에서는 데이터가 디스크에 저장되기 전에 커밋이 반환될 수 있습니다. 전원 중단이나 커널 패닉 등 기기 종료가 발생하면 커밋된 데이터가 손실될 수 있습니다. 그러나 로깅이 실행되므로 데이터베이스는 손상되지 않습니다.

앱이 비정상 종료되더라도 데이터는 디스크에 계속 전달됩니다. 대부분의 앱에서 이 설정을 사용하면 머티리얼 비용 없이 성능이 향상됩니다.

연결 및 메모리 관리

열려 있는 모든 데이터베이스 연결은 SQLite 메모리(페이지 캐시, 룩어사이드 할당자, 컴파일된 문 캐시)를 보유합니다. 데이터베이스 파일 페이지도 커널 페이지 캐시에 유지됩니다. 이 섹션의 권장사항에 따라 쿼리 속도를 늦추지 않고 이 메모리를 확인하세요.

각 데이터베이스를 한 번씩 엽니다.

전체 프로세스에서 데이터베이스 파일당 단일 SQLiteOpenHelper 또는 RoomDatabase 인스턴스를 사용합니다(예: 싱글톤으로 설정). 각 인스턴스에는 자체 연결 풀, 페이지 캐시, 문 캐시가 있으며 동일한 파일을 여는 두 인스턴스는 이를 공유할 수 없습니다. 한 벤치마크에서는 동일한 파일을 여는 두 도우미를 하나로 통합하여 SQLite 페이지 캐시 메모리를 거의 절반으로 줄이고 p95 쿼리 지연 시간을 절반 이상 줄였습니다.

중복 인스턴스를 찾으려면 dumpsys dbinfo에서 Connection pool for 제목 아래에 동일한 데이터베이스 경로가 있는지 확인합니다.

열린 콜백에서 스키마 변경사항 제외

SQLiteOpenHelper의 onOpen 콜백은 데이터베이스가 열릴 때마다 실행되며 RoomDatabase.Callback의 onOpen 콜백도 마찬가지입니다. CREATE TABLE IF NOT EXISTS 및 CREATE INDEX IF NOT EXISTS와 같은 Put 문을 onCreate 및 onUpgrade 또는 Room 이전에서 사용합니다. SQLiteOpenHelper는 이미 onCreate 및 onUpgrade 뒤에 버전을 설정하므로 onOpen에서 setVersion를 호출하지 마세요. 스키마가 이미 있는 경우에도 이러한 문은 열 때마다 실행되며 setVersion는 데이터베이스 파일에 씁니다.

준비된 문 캐시 크기 조정

Android는 각 연결에 대해 컴파일된 문을 캐시합니다. 캐시는 기본적으로 25개의 문을 보유하며 setMaxSqlCacheSize를 사용하여 최대 MAX_SQL_CACHE_SIZE (100)까지 늘릴 수 있습니다. 데이터베이스가 핫 경로에서 25개가 넘는 고유한 문을 실행하면 캐시 스래싱이 발생하고 SQLite는 동일한 문을 반복해서 컴파일합니다.

먼저 쿼리를 매개변수화합니다. 모든 문자열이 새 문이므로 문자열 연결로 빌드된 SQL에는 캐시 크기가 도움이 되지 않습니다. 그런 다음 dumpsys meminfo의 POOL STATS에서 문 캐시 적중 및 부적중을 확인합니다. 예를 들어 해당 섹션의 샘플 출력에 있는 phenotype.db 데이터베이스에는 147,921개의 적중과 89,616개의 부적중이 있으며, 이는 38% 의 부적중률입니다. 이와 같은 데이터베이스에서 캐시 크기를 늘리면 거의 모든 누락을 제거할 수 있습니다. 각 캐시된 문은 몇 킬로바이트의 메모리를 사용합니다.

캐시 크기를 늘리려면 SQLiteOpenHelper 서브클래스에서 onConfigure를 재정의하세요.

override fun onConfigure(db: SQLiteDatabase) {
    db.setMaxSqlCacheSize(SQLiteDatabase.MAX_SQL_CACHE_SIZE)
}

Room을 사용하는 경우 RoomDatabase.Callback의 onOpen 콜백에서 SupportSQLiteDatabase에 동일한 메서드를 호출합니다.

유휴 상태인 데이터베이스는 닫고 사용 중인 데이터베이스는 열어 두기

열려 있는 데이터베이스는 유휴 상태에서도 연결, 캐시, 파일 설명자를 유지합니다. 한 벤치마크에서 열려 있는 WAL 모드의 유휴 Room 데이터베이스는 약 678KB의 SQLite 페이지 캐시 메모리와 5개의 파일 설명자를 보유했습니다. 데이터베이스를 가끔만 사용하는 경우(예: 일일 동기화 중) 사용을 마쳤으면 데이터베이스를 닫습니다.

유휴 데이터베이스를 안전하게 닫으려면 다음 가이드라인을 따르세요.

  • Room은 실험용 API인 setAutoCloseTimeout를 사용하여 유휴 데이터베이스를 닫을 수 있습니다. Room은 다음 액세스 시 데이터베이스를 다시 열고 onOpen 콜백을 다시 실행하므로 여기에 연결 구성을 넣습니다.
  • SQLiteOpenHelper 및 SQLiteDatabase.OpenParams.Builder의 setIdleConnectionTimeout 메서드를 사용하지 않습니다. PRAGMA 문으로 적용한 설정을 복원하지 않고 연결을 닫기 때문에 API 수준 29에서 지원 중단되었습니다.
  • 몇 초마다 쿼리하는 데이터베이스는 닫지 마세요. 쿼리 간격보다 짧은 제한 시간으로 인해 데이터베이스가 반복적으로 닫히고 다시 열리므로 매번 페이지 캐시와 문 캐시가 삭제되고 다음 쿼리가 느려집니다.

SQLite 메모리 고려

SQLite 메모리는 발견하기 어려울 수 있습니다. SQLite 페이지 캐시와 문 캐시는 네이티브 힙 할당입니다. 하지만 Android는 기본적으로 SQLite의 메모리 매핑 I/O를 사용 설정하지 않으므로 SQLite는 일반 파일 읽기로 데이터베이스 파일을 읽고 커널이 캐시하는 파일 콘텐츠는 매핑되지 않은 파일 페이지입니다. dumpsys meminfo에서 앱의 PSS에 포함되지 않습니다. cgroup v2 메모리 계정을 사용하는 기기에서 커널은 여전히 이러한 페이지를 앱의 메모리 cgroup에 청구합니다. 이 숨겨진 사용량을 줄이려면 색인을 사용하고 필요한 행과 열만 읽어 더 적은 페이지를 읽습니다. 필요하지 않은 데이터베이스를 닫으면 SQLite 캐시도 확보됩니다.

효율적인 테이블 스키마 정의

성능을 최적화하고 데이터 소비를 최소화하려면 테이블 스키마를 효율적으로 정의하세요. SQLite는 효율적인 쿼리 계획과 데이터를 생성해 주므로 데이터를 더 빠르게 검색할 수 있습니다. 이 섹션에서는 테이블 스키마 만들기에 관한 권장사항을 제공합니다.

INTEGER PRIMARY KEY 사용

이 예에서는 다음과 같이 테이블을 정의하고 채웁니다.

CREATE TABLE Customers(
  id INTEGER,
  name TEXT,
  city TEXT
);
INSERT INTO Customers Values(456, 'John Lennon', 'Liverpool, England');
INSERT INTO Customers Values(123, 'Michael Jackson', 'Gary, IN');
INSERT INTO Customers Values(789, 'Dolly Parton', 'Sevier County, TN');

테이블은 다음과 같이 출력됩니다.

rowid id name city
1 456 존 레논 영국 리버풀
2 123 마이클 잭슨 인디애나주 게리
3 789 돌리 파튼 테네시주 세비어 카운티

rowid 열은 삽입 순서를 보존하는 색인입니다. rowid로 필터링하는 쿼리는 빠른 B-트리 검색으로 구현되지만 id로 필터링하는 쿼리는 느린 테이블 스캔입니다.

id로 조회하면 rowid 열을 저장하지 않을 수 있고, 스토리지에서 데이터 사용량이 줄어들며, 전반적인 데이터베이스 속도도 빨라집니다.

CREATE TABLE Customers(
  id INTEGER PRIMARY KEY,
  name TEXT,
  city TEXT
);

이제 테이블이 다음과 같이 표시됩니다.

id name city
123 마이클 잭슨 인디애나주 게리
456 존 레논 영국 리버풀
789 돌리 파튼 테네시주 세비어 카운티

id 쿼리에서는 rowid 열을 저장하지 않아도 되기 때문에 속도가 빠릅니다. 이제 테이블이 삽입 순서가 아닌 id를 기준으로 정렬됩니다.

색인으로 쿼리 가속화

SQLite에서는 쿼리 속도를 높이기 위해 색인을 사용합니다. 열을 필터링(WHERE), 정렬(ORDER BY), 집계(GROUP BY)할 때 테이블에 열 색인이 있으면 쿼리 속도가 빨라집니다.

이전 예시에서 city로 필터링하려면 전체 테이블을 스캔해야 합니다.

SELECT id, name
WHERE city = 'London, England';

도시 쿼리가 많은 앱의 경우 색인을 사용하여 쿼리 속도를 높일 수 있습니다.

CREATE INDEX city_index ON Customers(city);

색인은 색인 열로 정렬되고 rowid에 매핑되는 추가 테이블로 구현됩니다.

city rowid
인디애나주 게리 2
영국 리버풀 1
테네시주 세비어 카운티 3

이제 city 열의 스토리지 비용이 두 배가 됩니다. 이제 원본 테이블과 색인 모두에 포함되기 때문입니다. 색인을 사용하면 스토리지 비용이 추가되더라도 쿼리 속도가 개선된다는 이점이 있습니다. 그러나 사용하지 않는 색인을 계속 놔두지는 마세요. 쿼리 성능이 향상되지 않는데 스토리지 비용은 그대로 지불해야 합니다.

다중 열 색인 만들기

쿼리가 여러 열을 결합하는 경우 다중 열 색인을 만들어 쿼리 속도를 높일 수 있습니다. 또한 외부 열에 색인을 사용하고 내부 검색을 선형 스캔으로 수행할 수 있습니다.

예를 들어 다음과 같은 쿼리가 있다고 가정해 보겠습니다.

SELECT id, name
WHERE city = 'London, England'
ORDER BY city, name

다중 열 색인을 사용하면 쿼리에 지정한 순서와 동일한 순서로 쿼리 속도를 높일 수 있습니다.

CREATE INDEX city_name_index ON Customers(city, name);

하지만 city에만 색인이 있다면 외부 정렬의 속도는 높아지지만 내부 정렬에는 선형 스캔이 필요합니다.

이 다중 열 색인은 접두사 문의에도 사용할 수 있습니다. 예를 들어 ON Customers (city, name)라는 색인을 사용하면 city에 대한 필터링, 순서, 그룹화 속도가 높아집니다. 다중 열 색인의 색인 테이블은 주어진 색인의 주어진 순서대로 정렬되기 때문입니다.

WITHOUT ROWID 사용

기본적으로 SQLite는 테이블의 rowid 열을 만듭니다. 여기서 rowid는 암시적 INTEGER PRIMARY KEY AUTOINCREMENT입니다. INTEGER PRIMARY KEY 열이 이미 있으면 이 열은 rowid의 별칭이 됩니다.

INTEGER 외의 기본 키 또는 열 복합이 있는 테이블의 경우 WITHOUT ROWID를 사용하는 것이 좋습니다.

작은 데이터는 BLOB으로, 큰 데이터는 파일로 저장

이미지 썸네일이나 연락처 사진 같은 대용량 데이터를 행과 연결하려면 BLOB 열이나 파일에 데이터를 저장하고 해당 열에 경로를 저장하면 됩니다.

파일은 일반적으로 4KB 단위로 반올림됩니다. 크기가 매우 작아서 반올림 오류가 큰 파일의 경우 데이터베이스에 BLOB으로 저장하는 것이 더 효율적입니다. SQLite는 파일 시스템 호출을 최소화하며 일부 경우에는 기본 파일 시스템보다 빠릅니다.

쿼리 성능 향상

응답 시간을 최소화하고 처리 효율성을 극대화하여 SQLite의 쿼리 성능을 개선하려면 다음 권장사항을 따르세요.

필요한 행만 읽기

필터를 사용하면 기간, 위치, 이름과 같은 특정 기준을 지정하여 결과 범위를 좁힐 수 있습니다. 한도를 사용하면 표시되는 결과 수를 제어할 수 있습니다.

db.rawQuery("""
    SELECT name
    FROM Customers
    LIMIT 10;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        // Process cursor data
    }
}

필요한 열만 읽기

불필요한 열을 선택하지 마세요. 불필요한 열을 선택하면 쿼리 속도가 느려지고 리소스가 낭비됩니다. 대신 사용되는 열만 선택하세요.

다음 예시에서는 id, name, phone이 선택됩니다.

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery(
    """
    SELECT id, name, phone
    FROM customers;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        val name = cursor.getString(1)
        // Further processing
    }
}

하지만 필요한 열은 name뿐입니다.

db.rawQuery("""
    SELECT name
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        val name = cursor.getString(0)
        // Further processing
    }
}

쿼리 매개변수화

쿼리 문자열에는 다음과 같이 런타임에만 알려진 매개변수가 포함될 수 있습니다.

fun getNameById(id: Long): String?
    db.rawQuery(
        "SELECT name FROM customers WHERE id=$id", null
    ).use { cursor ->
        return if (cursor.moveToFirst()) {
            cursor.getString(0)
        } else {
            null
        }
    }
}

위의 코드에서 모든 쿼리는 다른 문자열을 구성하므로 문 캐시의 이점을 누리지 못합니다. 각 호출은 실행되기 전에 SQLite에서 컴파일해야 합니다. 대신 id 인수를 매개변수로 바꾸고 값을 selectionArgs에 바인딩할 수 있습니다.

fun getNameById(id: Long): String? {
    db.rawQuery(
        """
          SELECT name
          FROM customers
          WHERE id=?
        """.trimIndent(), arrayOf(id.toString())
    ).use { cursor ->
        return if (cursor.moveToFirst()) {
            cursor.getString(0)
        } else {
            null
        }
    }
}

이제 쿼리를 한 번 컴파일하고 캐시할 수 있습니다. 컴파일된 쿼리는 getNameById(long)의 여러 호출 간에 재사용됩니다.

코드가 아닌 SQL에서 반복

SQL 쿼리를 반복하여 개별 결과를 반환하는 프로그래매틱 루프 대신 모든 타겟팅된 결과를 반환하는 단일 쿼리를 사용합니다. 프로그래매틱 루프는 단일 SQL 쿼리보다 약 1,000배 느립니다.

고유한 값에 DISTINCT 사용

DISTINCT 키워드를 사용하면 처리해야 하는 데이터의 양을 줄여 쿼리 성능을 개선할 수 있습니다. 예를 들어 열에서 고유한 값만 반환하려면 DISTINCT를 사용하세요.

db.rawQuery("""
    SELECT DISTINCT name
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        // Only iterate over distinct names in Kotlin
        // Process distinct name
    }
}

가능한 경우 집계 함수 사용

행 데이터가 없는 집계 결과에 집계 함수를 사용하세요. 예를 들어 다음 코드는 일치하는 행이 하나 이상 있는지 확인합니다.

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery("""
    SELECT id, name
    FROM Customers
    WHERE city = 'Paris';
    """.trimIndent(),
    null
).use { cursor ->
    if (cursor.moveToFirst()) {
        // At least one customer from Paris
        // Handle found
    } else {
        // No customers from Paris
        // Handle not found
}

첫 번째 행만 가져오려면 EXISTS()를 사용하세요. 일치하는 행이 없으면 0을 반환하고 하나 이상의 행이 일치하면 1을 반환하게 할 수 있습니다.

db.rawQuery("""
    SELECT EXISTS (
        SELECT null
        FROM Customers
        WHERE city = 'Paris';
    );
    """.trimIndent(),
    null
).use { cursor ->
    if (cursor.moveToFirst() && cursor.getInt(0) == 1) {
        // At least one customer from Paris
        // Handle found
    } else {
        // No customers from Paris
        // Handle not found
    }
}

앱 코드에서 다음의 SQLite 집계 함수를 사용하세요.

  • COUNT: 열에 있는 행 수를 계산합니다.
  • SUM: 열에 모든 숫자 값을 더합니다.
  • MIN 또는 MAX: 가장 낮은 값 또는 가장 높은 값을 결정합니다. 숫자 열, DATE 유형, 텍스트 유형에 사용할 수 있습니다.
  • AVG: 평균 숫자 값을 찾습니다.
  • GROUP_CONCAT: 선택적 구분자를 사용하여 문자열을 연결합니다.

Cursor.getCount() 대신 COUNT() 사용

다음 예에서 Cursor.getCount() 함수는 데이터베이스의 모든 행을 읽고 모든 행 값을 반환합니다.

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery("""
    SELECT id
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    val count = cursor.getCount()
    // Use count
}

그러나 COUNT()를 사용하면 데이터베이스에서 개수만 반환됩니다.

db.rawQuery("""
    SELECT COUNT(*)
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    cursor.moveToFirst()
    val count = cursor.getInt(0)
    // Use count
}

코드 대신 Nest 쿼리 사용

SQL은 구성 가능하며 하위 쿼리, 조인, 외래 키 제약조건을 지원합니다. 따라서 앱 코드를 사용하지 않고도 한 쿼리의 결과를 다른 쿼리에서 사용할 수 있습니다. 이렇게 하면 SQLite에서 데이터를 복사할 필요성이 줄어들고 데이터베이스 엔진이 쿼리를 최적화할 수 있습니다.

다음 예에서는 쿼리를 실행하여 가장 고객이 많은 도시를 찾은 후 다른 쿼리의 결과를 사용하여 해당 도시의 모든 고객을 찾을 수 있습니다.

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery("""
    SELECT city
    FROM Customers
    GROUP BY city
    ORDER BY COUNT(*) DESC
    LIMIT 1;
    """.trimIndent(),
    null
).use { cursor ->
    if (cursor.moveToFirst()) {
        val topCity = cursor.getString(0)
        db.rawQuery("""
            SELECT name, city
            FROM Customers
            WHERE city = ?;
        """.trimIndent(),
        arrayOf(topCity)).use { innerCursor ->
            while (innerCursor.moveToNext()) {
                // Process inner cursor data
            }
        }
    }
}

이전 예에서 걸린 시간의 절반 정도의 시간으로 결과를 얻으려면 중첩된 문이 있는 단일 SQL 쿼리를 사용합니다.

db.rawQuery("""
    SELECT name, city
    FROM Customers
    WHERE city IN (
        SELECT city
        FROM Customers
        GROUP BY city
        ORDER BY COUNT (*) DESC
        LIMIT 1;
    );
    """.trimIndent(),
    null
).use { cursor ->
    if (cursor.moveToNext()) {
        // Process cursor data
    }
}

SQL에서 고유성 확인

테이블의 특정 열 값이 고유하지 않은 경우 행을 삽입해서는 안 된다면 고유함을 열 제약조건으로 실행하는 것이 효율적입니다.

다음 예시에서는 하나의 쿼리는 삽입된 행의 유효성을 검사하기 위해 실행되며, 다른 쿼리는 실제로 삽입합니다.

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery(
    """
    SELECT EXISTS (
        SELECT null
        FROM customers
        WHERE username = ?
    );
    """.trimIndent(),
    arrayOf(customer.username)
).use { cursor ->
    if (cursor.moveToFirst() && cursor.getInt(0) == 1) {
        throw AddCustomerException(customer)
    }
}
db.execSQL(
    "INSERT INTO customers VALUES (?, ?, ?)",
    arrayOf(
        customer.id.toString(),
        customer.name,
        customer.username
    )
)

Kotlin에서 고유한 제약 조건을 확인하는 대신 테이블을 정의할 때 SQL에서 제약 조건을 확인할 수 있습니다.

CREATE TABLE Customers(
  id INTEGER PRIMARY KEY,
  name TEXT,
  username TEXT UNIQUE
);

SQLite는 다음과 같이 제약조건을 확인합니다.

CREATE TABLE Customers(...);
CREATE UNIQUE INDEX CustomersUsername ON Customers(username);

이제 행을 삽입하고 SQLite가 제약조건을 확인하도록 할 수 있습니다.

try {
    db.execSQL(
        "INSERT INTO Customers VALUES (?, ?, ?)",
        arrayOf(customer.id.toString(), customer.name, customer.username)
    )
} catch(e: SQLiteConstraintException) {
    throw AddCustomerException(customer, e)
}

SQLite는 여러 열이 있는 고유 색인을 지원합니다.

CREATE TABLE table(...);
CREATE UNIQUE INDEX unique_table ON table(column1, column2, ...);

SQLite는 제약 조건을 더 빠르게 검증하며 Kotlin 코드보다 오버헤드도 적습니다. 앱 코드 대신 SQLite를 사용하는 것이 가장 좋습니다.

단일 트랜잭션에서 여러 삽입 일괄 처리

트랜잭션은 여러 작업을 커밋하므로 효율성뿐만 아니라 정확성도 향상됩니다. 데이터 일관성을 개선하고 성능을 가속화하려면 다음과 같이 삽입을 일괄 처리하세요.

db.beginTransaction()
try {
    customers.forEach { customer ->
        db.execSQL(
            "INSERT INTO Customers VALUES (?, ?, ?)",
            arrayOf(customer.id.toString(), customer.name, "customerValue")
        )
    }
    db.setTransactionSuccessful()
} finally {
    db.endTransaction()
}

명시적 트랜잭션 외부에서 각 쓰기는 자체 암시적 트랜잭션에서 실행되며 FULL 동기 모드의 fsync를 포함하여 커밋의 전체 비용을 지불합니다. 일괄 처리는 이 비용을 한 번만 지불하므로 일괄 쓰기 속도가 한 자릿수 이상 빨라질 수 있습니다.

문제 해결 도구 사용

SQLite는 성능을 측정하는 데 도움이 되는 다음과 같은 문제 해결 도구를 제공합니다.

SQLite의 대화형 프롬프트 사용

머신에서 SQLite를 실행하여 쿼리를 실행하고 학습하세요. Android 플랫폼 버전에 따라 SQLite 버전이 다릅니다. Android 지원 기기와 동일한 엔진을 사용하려면 adb shell을 사용하여 대상 기기에서 sqlite3를 실행하세요.

SQLite에 쿼리 시간을 측정하라고 요청할 수 있습니다.

sqlite> .timer on
sqlite> SELECT ...
Run Time: real ... user ... sys ...

EXPLAIN QUERY PLAN

EXPLAIN QUERY PLAN을 사용하여 SQLite에 쿼리에 어떻게 답변해야 하는지 설명할 수 있습니다.

sqlite> EXPLAIN QUERY PLAN
SELECT id, name
FROM Customers
WHERE city = 'Paris';
QUERY PLAN
`--SCAN Customers

이전 예에서는 파리의 모든 고객을 찾으려면 색인이 없는 전체 테이블 스캔이 필요했습니다. 이를 선형 복잡도라고 합니다. SQLite에서는 모든 행을 읽고 파리의 고객과 일치하는 행만 유지해야 하는 것입니다. 이 문제를 해결하려면 색인을 추가하면 됩니다.

sqlite> CREATE INDEX Idx1 ON Customers(city);
sqlite> EXPLAIN QUERY PLAN
SELECT id, name
FROM Customers
WHERE city = 'Paris';
QUERY PLAN
`--SEARCH test USING INDEX Idx1 (city=?

대화형 셸을 사용하는 경우 SQLite에 항상 쿼리 계획을 설명하라고 요청할 수 있습니다.

sqlite> .eqp on

자세한 내용은 쿼리 계획을 참고하세요.

SQLite 분석기

SQLite는 성능 문제를 해결하는 데 사용할 수 있는 추가 정보를 덤프하는 sqlite3_analyzer 명령줄 인터페이스 (CLI)를 제공합니다. 이를 설치하려면 SQLite 다운로드 페이지를 방문하세요.

adb pull을 사용하여 대상 기기에서 워크스테이션으로 데이터베이스 파일을 다운로드하여 분석할 수 있습니다.

adb pull /data/data/<app_package_name>/databases/<db_name>.db

SQLite 브라우저

SQLite 다운로드 페이지에 GUI 도구 SQLite 브라우저를 설치할 수도 있습니다.

Android 로깅

Android는 SQLite 쿼리를 실행하고 자동으로 기록합니다.

# Enable query time logging
$ adb shell setprop log.tag.SQLiteTime VERBOSE
# Disable query time logging
$ adb shell setprop log.tag.SQLiteTime ERROR

Android 13 이상에서는 느린 작업만 로깅할 수도 있습니다. 기준점을 밀리초 단위로 설정한 다음 앱을 다시 시작하여 로그 태그를 읽도록 합니다.

$ adb shell setprop log.tag.SQLiteSlowQueries VERBOSE
$ adb shell setprop db.log.slow_query_threshold 100
$ adb shell am force-stop <package-name>

느린 작업은 logcat에서 SQLiteConnection 태그 아래에 기간, SQL, 데이터베이스 경로와 함께 표시됩니다. 단일 앱의 기준점을 설정하려면 db.log.slow_query_threshold.<uid>를 사용합니다. 여기서 <uid>는 앱의 UID입니다.

Perfetto 추적

Perfetto를 구성할 때 database atrace 카테고리를 추가하여 트레이스에 SQLite 작업을 포함합니다. 각 스레드가 SQL을 실행하는 동안 수행하는 작업을 보려면 스케줄러 이벤트도 추가합니다.

data_sources {
  config {
    name: "linux.ftrace"
    ftrace_config {
      ftrace_events: "sched/sched_switch"
      ftrace_events: "sched/sched_waking"
      ftrace_events: "sched/sched_blocked_reason"
      atrace_categories: "database"
    }
  }
}

Android는 각 데이터베이스 작업을 작업 및 SQL의 이름을 딴 슬라이스로 추적합니다(예: executeForCursorWindow SELECT * FROM message WHERE body LIKE ?). SQL은 ? 자리표시자를 유지하고 256자로 잘립니다. 바인딩 인수는 포함되지 않습니다. 작업에는 open, close, prepare, execute, executeFor 변형이 포함됩니다. prepare 슬라이스는 문 캐시가 누락되었음을 의미하지 않습니다.

트랙 레이아웃은 Android 버전에 따라 다릅니다.

  • Android 17 이상에서는 각 연결에 앱 프로세스에 SQLiteConnection-이라는 이름의 비동기 트랙이 있고 그 뒤에 연결 번호가 있습니다. 트랜잭션은 TRANSACTION-EXCLUSIVE, TRANSACTION-IMMEDIATE 또는 TRANSACTION-DEFERRED 슬라이스로 표시되며, 트랜잭션에서 실행된 문이 아래에 중첩됩니다. 작업을 실행하는 스레드는 모든 트랜잭션과 트랜잭션 외부의 모든 문에 대해 스레드 트랙에서 START- 및 END- 인스턴트 이벤트도 가져옵니다. 이러한 이벤트의 이름은 연결 트랙 이름(예: -SQLiteConnection-0)으로 끝납니다.
  • Android 16 이하에서는 각 SQL 문이 앱 프로세스에서 자체 비동기 트랙을 가지며 트랜잭션은 추적되지 않습니다.

연결 번호는 데이터베이스마다 0에서 다시 시작하므로 프로세스의 모든 데이터베이스의 기본 연결은 SQLiteConnection-0 트랙을 공유합니다. 앱에서 동시에 두 개 이상의 데이터베이스를 사용하는 경우 스레드 트랙의 START- 및 END- 이벤트를 사용하여 지속 시간을 측정합니다.

Perfetto의 기본 스레드 및 SQLiteConnection-0 트랙으로, 기본 스레드에서 12밀리초 동안 전체 테이블을 스캔하는 LIKE 쿼리를 보여줍니다.

그림 1. Android 17의 기본 스레드에서 전체 테이블 스캔

Perfetto UI에서 PerfettoSQL을 사용하거나 trace_processor를 사용하여 이러한 슬라이스를 분석할 수 있습니다. 다음 쿼리는 각 프로세스에서 가장 빈번한 SQLite 작업을 찾습니다. 이 쿼리는 모든 Android 버전에서 작동합니다.

INCLUDE PERFETTO MODULE slices.with_context;

SELECT process_name, name AS operation, COUNT(*) AS count
FROM process_slice
WHERE name GLOB 'open *' OR name GLOB 'close *'
  OR name GLOB 'prepare *' OR name GLOB 'execute*'
  OR name GLOB 'TRANSACTION-*'
GROUP BY process_name, name
ORDER BY count DESC
LIMIT 10;

다음 쿼리는 Android 17 이상에서 기본 스레드에서 1ms 이상 걸린 SQLite 작업을 나열합니다.

INCLUDE PERFETTO MODULE slices.with_context;

WITH marks AS (
  SELECT process_name, utid, is_main_thread, ts, name,
    LEAD(ts) OVER (PARTITION BY utid ORDER BY ts) AS end_ts,
    LEAD(name) OVER (PARTITION BY utid ORDER BY ts) AS end_name
  FROM thread_slice
  WHERE name GLOB 'START-*' OR name GLOB 'END-*'
)
SELECT process_name, ts, ROUND((end_ts - ts) / 1e6, 1) AS dur_ms,
  substr(name, 7) AS operation
FROM marks
WHERE name GLOB 'START-*'
  AND end_name = 'END-' || substr(name, 7)
  AND is_main_thread
  AND end_ts - ts > 1e6
ORDER BY dur_ms DESC
LIMIT 10;

테스트 앱의 기본 스레드에서 1밀리초보다 긴 SQLite 작업을 나열하는 Perfetto 쿼리 결과

그림 2. 기본 스레드에서 1ms를 초과하는 SQLite 작업

다음 쿼리는 Android 17 이상에서 명시적 트랜잭션 외부에서 쓰기를 수행하는 스레드를 찾습니다. 여기서 모든 쓰기는 자체 커밋에 대한 비용을 지불합니다.

INCLUDE PERFETTO MODULE slices.with_context;

WITH ops AS (
  SELECT process_name, thread_name, utid, name,
    upper(ltrim(substr(name, instr(name, ' ') + 1))) AS sql
  FROM thread_slice
  WHERE name GLOB 'START-*'
)
SELECT process_name, thread_name,
  SUM(name GLOB 'START-TRANSACTION-*') AS transactions,
  SUM(name GLOB 'START-execute*' AND (sql GLOB 'INSERT *'
    OR sql GLOB 'UPDATE *' OR sql GLOB 'DELETE *' OR sql GLOB 'REPLACE *'))
    AS autocommit_writes
FROM ops
GROUP BY utid
HAVING autocommit_writes > 0
ORDER BY autocommit_writes DESC
LIMIT 10;

dumpsys meminfo

adb shell dumpsys meminfo <package-name>는 DATABASES 섹션의 SQLite에 관한 세부정보를 포함하여 앱의 메모리 사용량과 관련된 통계를 출력합니다. 예를 들어 다음은 개발자 기기에서 adb shell dumpsys meminfo com.google.android.gms.persistent의 출력에서 가져온 것입니다.

DATABASES
      pgsz     dbsz   Lookaside(b) cache hits cache misses cache size  Dbname
PER CONNECTION STATS
         4       52             45     8    41     6  /data/user/10/com.google.android.gms/databases/gaia-discovery
         4        8                    0     0     0    (attached) temp
         4       52             56     5    23     6  /data/user/10/com.google.android.gms/databases/gaia-discovery (1)
         4      252             95   233   124    12  /data/user_de/10/com.google.android.gms/databases/phenotype.db
         4        8                    0     0     0    (attached) temp
         4      252             17     0    17     1  /data/user_de/10/com.google.android.gms/databases/phenotype.db (1)
         4     9280            105 103169 69805    25  /data/user/10/com.google.android.gms/databases/phenotype.db
         4       20                    0     0     0    (attached) temp
         4     9280            108 13877  6394    25  /data/user/10/com.google.android.gms/databases/phenotype.db (2)
         4        8                    0     0     0    (attached) temp
         4     9280            105 12548  5519    25  /data/user/10/com.google.android.gms/databases/phenotype.db (3)
         4        8                    0     0     0    (attached) temp
         4     9280            107 18328  7886    25  /data/user/10/com.google.android.gms/databases/phenotype.db (1)
         4        8                    0     0     0    (attached) temp
         4       36             51   156    29     5  /data/user/10/com.google.android.gms/databases/mobstore_gc_db_v0
         4       36             97    47    27    10  /data/user/10/com.google.android.gms/databases/context_feature_default.db
         4       36             56     3    16     4  /data/user/10/com.google.android.gms/databases/context_feature_default.db (2)
         4      300             40  2111    24     5  /data/user/10/com.google.android.gms/databases/gservices.db
         4      300             39     3    17     4  /data/user/10/com.google.android.gms/databases/gservices.db (1)
         4       20             17     0    14     1  /data/user/10/com.google.android.gms/databases/gms.notifications.db
         4       20             33     1    15     2  /data/user/10/com.google.android.gms/databases/gms.notifications.db (1)
         4      120             40   143   163     4  /data/user/10/com.google.android.gms/databases/android_pay
         4      120            123    86    32    19  /data/user/10/com.google.android.gms/databases/android_pay (1)
         4       28             33     4    17     3  /data/user/10/com.google.android.gms/databases/googlesettings.db
POOL STATS
     cache hits  cache misses    cache size  Dbname
             13            68            81  /data/user/10/com.google.android.gms/databases/gaia-discovery
            233           145           378  /data/user_de/10/com.google.android.gms/databases/phenotype.db
         147921         89616        237537  /data/user/10/com.google.android.gms/databases/phenotype.db
            156            30           186  /data/user/10/com.google.android.gms/databases/mobstore_gc_db_v0
             50            57           107  /data/user/10/com.google.android.gms/databases/context_feature_default.db
           2114            43          2157  /data/user/10/com.google.android.gms/databases/gservices.db
              1            31            32  /data/user/10/com.google.android.gms/databases/gms.notifications.db
            229           197           426  /data/user/10/com.google.android.gms/databases/android_pay
              4            18            22  /data/user/10/com.google.android.gms/databases/googlesettings.db

Android 14 이상에서는 예에 표시된 PER CONNECTION STATS 및 POOL STATS 테이블을 출력합니다. Android 13 및 이전 버전에서는 연결별 테이블만 출력하며 캐시 통계는 단일 cache 열에 hits/misses/size로 결합됩니다.

PER CONNECTION STATS에서 확인할 수 있는 내용은 다음과 같습니다.

  • pgsz: 데이터베이스 페이지 하나의 크기(KB)입니다.
  • dbsz: 전체 데이터베이스 파일의 크기(KB)입니다.
  • Lookaside(b): 연결에서 사용하는 룩어사이드 메모리 슬롯 수입니다. 헤더에 (b)가 있지만 이는 바이트가 아닌 슬롯 수입니다.
  • cache hits, cache misses, cache size: 연결의 준비된 문 캐시에 대한 통계입니다. 이 값은 데이터베이스 페이지가 아닌 컴파일된 SQL 문을 계산합니다.
  • Dbname: 데이터베이스 파일의 경로입니다. 동일한 데이터베이스에 대한 추가 연결에는 (1)과 같이 괄호 안에 숫자가 추가됩니다. WAL을 사용하면 데이터베이스에 여러 연결이 있을 수 있으므로 읽기를 동시에 실행할 수 있습니다.

POOL STATS에서 각 연결 풀의 준비된 문 캐시 통계를 찾을 수 있습니다.

  • cache hits: SQLite는 준비된 문을 캐시하고 쿼리를 실행할 때 이를 재사용하려고 시도하여 SQL 문을 컴파일하는 데 드는 노력과 메모리를 절약합니다. 문 캐시 적중 수입니다.
  • cache misses: 문 캐시 부적중 수입니다.
  • cache size: Android 16 QPR2 이상에서 캐시의 준비된 문 수로, 풀의 유휴 연결 전체에서 합산됩니다. Android 14~Android 16 QPR1에서 이 값은 적중과 부적중의 합계이며 캐시 크기를 나타내지 않습니다.

다음 통계를 사용하여 수정할 사항을 결정하세요.

  • 부적중률이 높으면 SQLite가 동일한 문을 반복적으로 컴파일한다는 의미입니다. 먼저 문자열 연결로 빌드된 SQL을 찾아 매개변수화합니다. 쿼리가 이미 매개변수화되어 있고 연결당 cache size가 한도인 25인 경우 캐시 크기를 늘립니다.
  • (1) 스타일의 서픽스 없이 두 번 이상 표시되는 데이터베이스 경로는 앱이 동일한 파일을 두 번 열었음을 의미합니다. 각 데이터베이스를 한 번씩 엽니다.
  • 앱에서 거의 사용하지 않는 데이터베이스가 열려 있는 동안에도 메모리를 계속 차지합니다. 완료되면 닫습니다.

dumpsys dbinfo

adb shell dumpsys dbinfo <package-name>는 앱의 모든 연결 풀 상태를 출력합니다. -v를 추가하여 각 연결의 준비된 문 캐시에 있는 문도 나열합니다. 다음 출력 예시는 Android 17에서 가져온 것으로, 경로와 긴 줄이 단축되어 있습니다.

** Database info for pid 2705 [com.example.app] **
Connection pool for /data/user/0/com.example.app/databases/settings.db:
  Open: true
  Max connections: 4
  Total execution time (ms): 4963
  Total statements executed: 231239
  Average time per statement (ms): 0
  Configuration: openFlags=805306368, ..., journalMode=WAL, syncMode=NORMAL
  IsReadOnlyDatabase: false
  Available primary connection:
    <none>
  Available non-primary connections:
    <none>
  Acquired connections:
    <none>
  Connection waiters:
    <none>
Connection pool for /data/user/0/com.example.app/databases/events.db:
  Open: true
  Max connections: 1
  Total execution time (ms): 842
  Total statements executed: 840
  Average time per statement (ms): 1
  Configuration: openFlags=268435456, ..., journalMode=TRUNCATE, syncMode=FULL
  IsReadOnlyDatabase: false
  Available primary connection:
    Connection #0:
      isPrimaryConnection: true
      onlyAllowReadOnlyOperations: false
      totalLongOperations: 0
      Most recently executed operations:
        0: [10-26 04:22:32.133] executeForChangedRowCount took 0ms -
           succeeded, sql="DELETE FROM params WHERE app_id=?", path=...
        1: [10-26 04:22:32.133] prepare took 0ms - succeeded,
           sql="DELETE FROM params WHERE app_id=?", path=...
        ...

다음을 확인하세요.

  • Configuration: 풀의 저널 모드 및 동기 모드입니다. Android 13 이상에서는 이 줄에 플랫폼 기본값을 포함하여 프레임워크에서 적용한 모드가 표시됩니다. 이 예에서 events.db는 FULL 동기 모드와 함께 롤백 저널을 사용하므로 모든 커밋은 전체 동기화를 기다립니다. 이 줄은 직접 실행하는 PRAGMA 문을 반영하지 않습니다.
  • Max connections: 쓰기 가능한 데이터베이스의 값이 1이면 프레임워크가 데이터베이스를 WAL 모드로 간주하지 않으므로 읽기 및 쓰기가 단일 연결을 공유합니다.
  • Total execution time, Total statements executed, Average time per statement: Android 13 이상에서 사용할 수 있습니다. 이를 사용하여 가장 사용량이 많은 데이터베이스를 찾습니다.
  • Most recently executed operations: 각 연결의 마지막 20개 작업과 해당 기간 및 SQL입니다. 루프에서 실행되는 동일한 쿼리 또는 트랜잭션 외부에서 연속으로 여러 쓰기와 같은 패턴을 찾습니다. Android 15 QPR1 이상에서는 이 목록에 명시적 트랜잭션도 포함됩니다.
  • totalLongOperations 및 Operations exceeding 2000ms: Android 15 QPR1 이상에서 사용할 수 있습니다. 2초 이상 걸린 작업 및 트랜잭션 수를 표시하고 마지막 10개를 나열합니다.
  • Acquired connections 및 Connection waiters: 사용 중인 연결 및 연결을 기다리는 스레드입니다. 대기자는 다른 쓰기를 차단하는 긴 쓰기 트랜잭션과 같은 경합을 나타냅니다.
  • Prepared statement cache: -v로 출력됩니다. 리터럴 값만 다른 많은 항목은 문자열 연결로 빌드된 SQL을 가리킵니다.
  • 두 개 이상의 Connection pool for 제목 아래에 동일한 경로가 있으면 앱이 동일한 파일을 두 번 열었다는 의미입니다.

추가 리소스

콘텐츠 보기