SQLite 성능 권장사항 (뷰)

개념 및 Jetpack Compose 구현

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

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

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

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

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

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

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

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

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

동기화 모드 완화

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

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

Kotlin

// When opening the database
val paramsBuilder: SQLiteDatabase.OpenParams.Builder = SQLiteDatabase.OpenParams.Builder()
paramsBuilder.journalMode = SQLiteDatabase.SYNC_MODE_NORMAL

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

Java

// When opening the database
SQLiteDatabase.OpenParams.Builder paramsBuilder = new SQLiteDatabase.OpenParams.Builder();
paramsBuilder.setJournalMode(SQLiteDatabase.SYNC_MODE_NORMAL);

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

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

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

쿼리 성능 향상

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

필요한 행만 읽기

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

Kotlin

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

Java

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

필요한 열만 읽기

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

다음 예시에서는 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)
        // Further processing
    }
}

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);
    // Further processing
  }
}

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

Kotlin

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

Java

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

쿼리 매개변수화

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

Kotlin

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
        }
    }
}

Java

@Nullable
public String getNameById(long id) {
  try (Cursor cursor = db.rawQuery(
      "SELECT name FROM customers WHERE id=" + id, null)) {
    if (cursor.moveToFirst()) {
      return cursor.getString(0);
    } else {
      return null;
    }
  }
}

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

Kotlin

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
        }
    }
}

Java

@Nullable
public String getNameById(long id) {
  try (Cursor cursor = db.rawQuery("""
          SELECT name
          FROM customers
          WHERE id=?
      """, new String[] {String.valueOf(id)})) {
    if (cursor.moveToFirst()) {
      return cursor.getString(0);
    } else {
      return null;
    }
  }
}

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

고유한 값에 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
        // Process distinct name
    }
}

Java

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

가능한 경우 집계 함수 사용

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

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

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

첫 번째 행만 가져오려면 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
        // Handle found
    } else {
        // No customers from Paris
        // Handle not found
    }
}

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

앱 코드에서 다음의 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()
    // Use count
}

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

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

Kotlin

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

Java

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

코드 대신 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()) {
                // Process inner cursor data
            }
        }
    }
}

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()) {
          // Process inner cursor data
        }
    }
  }
}

이전 예에서 걸린 시간의 절반 정도의 시간으로 결과를 얻으려면 중첩된 문이 있는 단일 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()) {
        // Process cursor data
    }
}

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()) {
    // Process cursor data
  }
}

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, "customerValue")
        )
    }
} finally {
    db.endTransaction()
}

Java

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