SQLite 성능 권장사항

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

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

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

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

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

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

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

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

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

미리 쓰기 로깅 사용 설정

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

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

동기화 모드 완화

WAL을 사용하는 경우 기본적으로 모든 커밋은 데이터가 디스크에 도달하도록 하기 위해 fsync를 발행합니다. 그러나 이렇게 하면 데이터 내구성은 향상되지만 커밋이 느려집니다.

SQLite에는 동기화 모드를 제어할 수 있는 옵션이 있습니다. WAL을 사용하고 있다면 동기화 모드를 NORMAL로 설정하세요.

Kotlin

db.execSQL("PRAGMA synchronous = NORMAL")

Java

db.execSQL("PRAGMA synchronous = NORMAL");

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

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

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

성능을 최적화하고 데이터 소비를 최소화하려면 테이블 스키마를 효율적으로 정의하세요. 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의 쿼리 성능을 개선하려면 다음 권장사항을 따르세요.

필요한 행만 읽기

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

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT name
    FROM Customers
    LIMIT 10;
    """, null)) {
  while (cursor.moveToNext()) {
    ...
  }
}

필요한 열만 읽기

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

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

Kotlin

// 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)
        // ...
    }
}

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT id, name, phone
    FROM customers;
    """, null)) {
  while (cursor.moveToNext()) {
    String name = cursor.getString(1);
    ...
  }
}

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

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT name
    FROM Customers;
    """, null)) {
  while (cursor.moveToNext()) {
    String name = cursor.getString(0);
    ...
  }
}

고유한 값에 DISTINCT 사용

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

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT DISTINCT name
    FROM Customers;
    """, null)) {
  while (cursor.moveToNext()) {
    // Only iterate over distinct names in Java
    ...
  }
}

가능한 경우 집계 함수 사용

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

Kotlin

// 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
        ...
    } else {
        // No customers from Paris
        ...
}

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT id, name
    FROM Customers
    WHERE city = 'Paris';
    """, null)) {
  if (cursor.moveToFirst()) {
    // At least one customer from Paris
    ...
  } else {
    // No customers from Paris
    ...
  }
}

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

Kotlin

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
        ...
    } else {
        // No customers from Paris
        ...
    }
}

Java

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

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

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

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

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

Kotlin

// 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()
}

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT id
    FROM Customers;
    """, null)) {
  int count = cursor.getCount();
  ...
}

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

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT COUNT(*)
    FROM Customers;
    """, null)) {
  cursor.moveToFirst();
  int count = cursor.getInt(0);
  ...
}

코드 대신 Nest 쿼리 사용

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

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

Kotlin

// 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()) {
                ...
            }
        }
    }
}

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT city
    FROM Customers
    GROUP BY city
    ORDER BY COUNT(*) DESC
    LIMIT 1;
    """, null)) {
  if (cursor.moveToFirst()) {
    String topCity = cursor.getString(0);
    try (Cursor innerCursor = db.rawQuery("""
        SELECT name, city
        FROM Customers
        WHERE city = ?;
        """, new String[] {topCity})) {
        while (innerCursor.moveToNext()) {
          ...
        }
    }
  }
}

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

Kotlin

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()) {
        ...
    }
}

Java

try (Cursor cursor = db.rawQuery("""
    SELECT name, city
    FROM Customers
    WHERE city IN (
      SELECT city
      FROM Customers
      GROUP BY city
      ORDER BY COUNT(*) DESC
      LIMIT 1
    );
    """, null)) {
  while(cursor.moveToNext()) {
    ...
  }
}

SQL에서 고유성 확인

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

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

Kotlin

// 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
    )
)

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT EXISTS (
      SELECT null
      FROM customers
      WHERE username = ?
    );
    """, new String[] { customer.username })) {
  if (cursor.moveToFirst() && cursor.getInt(0) == 1) {
    throw new AddCustomerException(customer);
  }
}
db.execSQL(
    "INSERT INTO customers VALUES (?, ?, ?)",
    new String[] {
      String.valueOf(customer.id),
      customer.name,
      customer.username,
    });

Kotlin이나 Java에서 고유한 제약조건을 확인하는 대신 테이블을 정의할 때 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가 제약조건을 확인하도록 할 수 있습니다.

Kotlin

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

Java

try {
  db.execSQL(
      "INSERT INTO Customers VALUES (?, ?, ?)",
      new String[] {
        String.valueOf(customer.id),
        customer.name,
        customer.username,
      });
} catch (SQLiteConstraintException e) {
  throw new AddCustomerException(customer, e);
}

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

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

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

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

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

Kotlin

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

Java

db.beginTransaction();
try {
  for (customer : Customers) {
    db.execSQL(
        "INSERT INTO Customers VALUES (?, ?, ...)",
        new String[] {
          String.valueOf(customer.id),
          customer.name,
          ...
        });
  }
} finally {
  db.endTransaction()
}

문제 해결 도구 사용

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
```### Perfetto tracing

### Perfetto tracing {:#perfetto-tracing}

When [configuring Perfetto](https://perfetto.dev/docs/concepts/config), you may
add the following to include tracks for individual queries:

```protobuf
data_sources {
  config {
    name: "linux.ftrace"
    ftrace_config {
      atrace_categories: "database"
    }
  }
}