Android 앱 검색 기능 고도화 — SQLite FTS5, 한글 초성 검색, 자동완성과 성능 최적화
Android 앱에서 검색 기능은 가장 자주 사용하는 기능 중 하나입니다.
생물도감 앱에서는 국명과 학명을 검색하고, 일정 관리 앱에서는 등록한 일정을 검색하며, 지역 정보 앱에서는 장소 이름을 검색합니다.
처음에는 SQLite의 LIKE 문법만으로도 충분합니다.
SELECT *
FROM korean_birds
WHERE SpeciesKor LIKE '%참새%';하지만 데이터가 많아지고 검색 조건이 복잡해지면 문제가 발생합니다.
검색어를 입력할 때마다 전체 데이터를 조회하면서 화면이 끊기거나, 사용자가 'ㅊㅅ'처럼 초성만 입력했을 때 원하는 결과가 나오지 않을 수 있습니다.
또한 자동완성, 검색어 강조 표시, 여러 필드 동시 검색 등을 구현하려면 검색 구조를 체계적으로 설계해야 합니다.
이번 글에서는 SQLite를 직접 사용하는 Android 앱에서 검색 기능을 단계적으로 개선하는 방법을 알아보겠습니다.
작성 기준: 2026년 10월. 예제는 Kotlin + XML, SQLiteOpenHelper, Coroutine, RecyclerView를 사용하는 앱을 전제로 합니다. FTS5는 기기의 SQLite 빌드에 따라 지원 여부가 달라질 수 있으므로 실제 지원 기기에서 확인해야 합니다.
1. SQLite LIKE 검색의 기본 구조
SQLite에서 가장 간단한 검색 방법은 LIKE를 사용하는 것입니다.
예를 들어 다음과 같은 테이블이 있다고 가정하겠습니다.
CREATE TABLE korean_birds (
KTSN INTEGER PRIMARY KEY,
SpeciesKor TEXT,
Species TEXT,
OrderKor TEXT,
FamilyKor TEXT,
GenusKor TEXT
);국명으로 검색하려면 다음과 같이 작성할 수 있습니다.
SELECT
KTSN,
SpeciesKor,
Species
FROM korean_birds
WHERE SpeciesKor LIKE '%참새%';이 방식은 구현이 간단하고 작은 데이터베이스에서는 충분히 실용적입니다.
하지만 검색어 앞에 %를 사용하는 패턴은 일반적인 B-tree 인덱스를 효율적으로 활용하기 어렵습니다.
따라서 데이터가 증가하면 검색 속도가 느려질 수 있습니다.
2. LIKE 검색의 대표적인 문제
| 문제 | 원인 |
|---|---|
| 검색 속도 저하 | 앞쪽 와일드카드로 인덱스 활용 제한 |
| 초성 검색 불가 | 초성과 완성형 한글이 직접 일치하지 않음 |
| 복잡한 검색 조건 | 여러 컬럼에 OR 조건 반복 |
| 자동완성 지연 | 입력할 때마다 DB 조회 |
| 검색 결과 깜빡임 | RecyclerView 전체 갱신 |
| 검색어 강조 표시 | 문자열 위치를 별도로 계산해야 함 |
검색 기능을 개선할 때는 이 문제들을 한 번에 해결하려 하기보다 단계별로 접근하는 것이 좋습니다.
3. 검색 기능의 권장 구조
Kotlin + MVVM 기반 앱이라면 검색 로직을 다음과 같이 분리할 수 있습니다.
SearchEditText
│
▼
TextWatcher
│
▼
ViewModel
│
▼
SearchRepository
│
▼
SQLite Search DAO
│
▼
Search Results
│
▼
RecyclerView Adapter이 구조에서 각 계층의 역할은 다음과 같습니다.
Activity 또는 Fragment: 검색어 입력과 결과 표시
ViewModel: 검색어 상태, Debounce, 이전 검색 취소
Repository: 검색 조건과 데이터 접근 조정
SQLite DAO: 실제 SQL 실행
RecyclerView Adapter: 검색 결과와 하이라이트 표시
검색 관련 처리를 Fragment에 모두 작성하면 코드가 복잡해지고 테스트하기 어려워집니다.
4. LIKE 검색부터 안전하게 개선하기
먼저 기존 LIKE 검색을 개선해 보겠습니다.
검색어를 SQL 문자열에 직접 연결하는 방식은 피해야 합니다.
// 권장하지 않는 방식
val sql =
"SELECT * FROM korean_birds " +
"WHERE SpeciesKor LIKE '%$keyword%'"이 방식은 입력값에 따라 SQL 구문이 잘못 해석될 수 있습니다.
대신 바인딩 인자를 사용합니다.
fun searchBirds(
db: SQLiteDatabase,
keyword: String
): List<Bird> {
val result = mutableListOf<Bird>()
val sql = """
SELECT
KTSN,
SpeciesKor,
Species
FROM korean_birds
WHERE SpeciesKor LIKE ?
ORDER BY SpeciesKor
LIMIT 100
""".trimIndent()
db.rawQuery(
sql,
arrayOf("%$keyword%")
).use { cursor ->
while (cursor.moveToNext()) {
result.add(
Bird(
id = cursor.getLong(0),
nameKo = cursor.getString(1),
scientificName = cursor.getString(2)
)
)
}
}
return result
}LIMIT 100은 한 번에 너무 많은 결과를 반환하지 않도록 하는 예제입니다.
실제 앱에서는 검색 결과 개수와 페이지 처리 정책을 함께 설계하는 것이 좋습니다.
5. 여러 컬럼을 동시에 검색하기
조류 정보 앱에서는 국명뿐 아니라 학명, 과명, 속명 등으로 검색할 수 있습니다.
예를 들어 다음과 같은 SQL을 사용할 수 있습니다.
SELECT
KTSN,
SpeciesKor,
Species,
FamilyKor,
GenusKor
FROM korean_birds
WHERE
SpeciesKor LIKE ?
OR Species LIKE ?
OR FamilyKor LIKE ?
OR GenusKor LIKE ?
ORDER BY SpeciesKor
LIMIT 100;Kotlin에서는 동일한 검색 패턴을 여러 인자에 전달합니다.
val pattern = "%$keyword%"
val args = arrayOf(
pattern,
pattern,
pattern,
pattern
)다만 여러 컬럼에 OR 조건을 사용하면 데이터가 많을 때 성능이 떨어질 수 있습니다.
이 경우 검색 전용 인덱스나 FTS를 검토할 수 있습니다.
6. SQLite 인덱스와 LIKE의 관계
SQLite의 일반 인덱스는 검색 조건에 따라 성능을 개선할 수 있습니다.
예를 들어 다음과 같이 인덱스를 생성합니다.
CREATE INDEX idx_birds_species_kor
ON korean_birds(SpeciesKor);하지만 다음 두 검색은 성능 특성이 다릅니다.
-- 접두어 검색
WHERE SpeciesKor LIKE '참새%';-- 부분 문자열 검색
WHERE SpeciesKor LIKE '%참새%';접두어 검색은 조건과 Collation, SQLite 설정이 맞으면 인덱스 최적화가 가능할 수 있습니다.
반면 앞에 %가 있는 부분 문자열 검색은 일반적인 B-tree 인덱스만으로 효율적으로 처리하기 어렵습니다.
따라서 자동완성에는 접두어 검색을, 자유로운 검색에는 FTS 또는 별도의 검색 인덱스를 고려하는 것이 좋습니다.
7. EXPLAIN QUERY PLAN으로 검색 성능 확인하기
SQL 성능을 확인할 때는 EXPLAIN QUERY PLAN을 사용할 수 있습니다.
EXPLAIN QUERY PLAN
SELECT
KTSN,
SpeciesKor
FROM korean_birds
WHERE SpeciesKor LIKE '참새%';실행 계획에서 테이블 전체 스캔 여부와 인덱스 사용 여부를 확인할 수 있습니다.
예를 들어 SCAN이나 SEARCH 등의 정보를 참고할 수 있습니다.
다만 실제 실행 계획은 SQLite 버전, 데이터 분포, 인덱스, Collation에 따라 달라질 수 있습니다.
검색 속도를 비교할 때는 실제 데이터와 기기 환경에서 측정하는 것이 중요합니다.
8. FTS란 무엇인가?
FTS는 Full-Text Search의 약자입니다.
SQLite에서 텍스트 검색을 효율적으로 처리하기 위한 기능입니다.
일반적인 B-tree 인덱스가 값의 정렬과 비교에 적합하다면 FTS는 텍스트를 토큰 단위로 색인해 검색하도록 설계되어 있습니다.
SQLite에는 FTS3, FTS4, FTS5 등의 기능이 있습니다.
이 중 FTS5는 다양한 검색 기능을 제공하지만, Android에서 사용할 때는 지원 여부를 확인해야 합니다.
Android의 모든 버전과 모든 기기에서 FTS5를 사용할 수 있다고 가정해서는 안 됩니다.
특히 오래된 Android 버전을 지원하는 앱에서는 FTS4 또는 기존 LIKE 검색을 대안으로 준비하는 것이 안전합니다.
9. FTS5 지원 여부 확인하기
SQLite에서 FTS5를 사용할 수 있는지 확인하려면 테스트용 가상 테이블 생성을 시도할 수 있습니다.
fun isFts5Available(
db: SQLiteDatabase
): Boolean {
return try {
db.execSQL(
"""
CREATE VIRTUAL TABLE IF NOT EXISTS
temp.fts5_test
USING fts5(content)
""".trimIndent()
)
db.execSQL(
"DROP TABLE IF EXISTS temp.fts5_test"
)
true
} catch (e: SQLiteException) {
false
}
}이 코드는 FTS5 모듈을 사용할 수 있는지 간단하게 확인하는 예제입니다.
실제 운영에서는 데이터베이스 초기화 시 한 번 확인하고, 결과에 따라 검색 방식을 선택할 수 있습니다.
다만 테스트용 테이블 생성과 삭제도 DB 작업이므로 메인 스레드에서 실행하지 않는 것이 좋습니다.
10. FTS5 가상 테이블 생성하기
FTS5를 사용할 수 있는 환경이라면 다음과 같이 검색 전용 테이블을 생성할 수 있습니다.
CREATE VIRTUAL TABLE birds_fts
USING fts5(
SpeciesKor,
Species,
FamilyKor,
GenusKor,
tokenize = 'unicode61'
);unicode61은 Unicode 문자를 처리하는 기본적인 토크나이저입니다.
하지만 한글 형태소 분석이나 초성 검색을 자동으로 제공하는 것은 아닙니다.
예를 들어 참새라는 문자열이 있다고 해서 ㅊㅅ 검색이 자동으로 지원되지는 않습니다.
따라서 한글 초성 검색은 별도의 검색 키를 만들어야 합니다.
11. FTS5 데이터 삽입하기
기존 조류 데이터를 검색 테이블에 넣는 예제입니다.
INSERT INTO birds_fts (
rowid,
SpeciesKor,
Species,
FamilyKor,
GenusKor
)
SELECT
KTSN,
SpeciesKor,
Species,
FamilyKor,
GenusKor
FROM korean_birds;여기서는 FTS 테이블의 rowid를 기존 조류 테이블의 KTSN과 연결했습니다.
이렇게 하면 검색 결과에서 원본 데이터의 ID를 쉽게 찾을 수 있습니다.
다만 FTS 테이블과 원본 테이블의 데이터가 서로 다르지 않도록 관리해야 합니다.
원본 데이터가 수정되면 FTS 인덱스도 갱신해야 합니다.
12. FTS5 MATCH 검색하기
FTS5에서는 MATCH를 사용해 검색할 수 있습니다.
SELECT
rowid,
SpeciesKor,
Species
FROM birds_fts
WHERE birds_fts MATCH '참새';검색어를 접두어 방식으로 검색하려면 다음과 같이 사용할 수 있습니다.
SELECT
rowid,
SpeciesKor
FROM birds_fts
WHERE birds_fts MATCH '참*';다만 FTS의 *는 일반적인 LIKE의 %와 다릅니다.
FTS5의 접두어 검색은 토큰의 시작 부분을 기준으로 동작합니다.
따라서 임의의 위치에 포함된 문자열을 검색하는 기능과 동일하지 않습니다.
13. FTS5 검색어 입력값 처리하기
사용자가 입력한 문자열을 FTS 쿼리에 그대로 넣으면 예상하지 못한 오류가 발생할 수 있습니다.
FTS5에는 AND, OR, NOT, 따옴표 등 별도의 쿼리 문법이 있기 때문입니다.
따라서 사용자 입력을 검색 표현식으로 안전하게 변환해야 합니다.
예를 들어 단순한 검색어만 허용하는 경우 다음과 같이 처리할 수 있습니다.
fun buildFtsPrefixQuery(
keyword: String
): String? {
val tokens = keyword
.trim()
.split(Regex("\\s+"))
.filter { it.isNotBlank() }
if (tokens.isEmpty()) {
return null
}
return tokens.joinToString(" AND ") { token ->
val escaped =
token.replace("\"", "\"\"")
"\"$escaped\"*"
}
}예를 들어 참새는 다음과 같은 FTS 표현식으로 변환됩니다.
"참새"*큰 참새처럼 공백이 있는 입력은 각 토큰의 접두어 검색을 AND로 결합합니다.
이 함수는 단순한 접두어 검색을 위한 예제이며, 복잡한 검색 연산자를 지원하는 완전한 쿼리 파서는 아닙니다.
14. FTS5와 원본 테이블 JOIN하기
FTS 테이블에는 검색용 컬럼만 저장하고, 상세 정보는 원본 테이블에서 조회할 수 있습니다.
SELECT
b.KTSN,
b.SpeciesKor,
b.Species,
b.OrderKor,
b.FamilyKor
FROM birds_fts AS f
JOIN korean_birds AS b
ON b.KTSN = f.rowid
WHERE birds_fts MATCH ?
LIMIT 100;다만 FTS5의 MATCH 대상은 가상 테이블 이름을 기준으로 사용하는 방식이 안전합니다.
위 예제처럼 별칭을 함께 사용할 때는 SQLite 구문과 버전에 따라 주의가 필요하므로 다음과 같이 원래 테이블 이름을 직접 사용하는 형태를 권장합니다.
SELECT
b.KTSN,
b.SpeciesKor,
b.Species
FROM birds_fts
JOIN korean_birds AS b
ON b.KTSN = birds_fts.rowid
WHERE birds_fts MATCH ?
LIMIT 100;이 방식은 검색 결과를 원본 데이터와 연결할 때 유용합니다.
15. FTS5 검색 결과 정렬하기
FTS5에서는 검색 관련도를 기준으로 정렬할 수 있습니다.
예를 들어 다음과 같은 쿼리를 사용할 수 있습니다.
SELECT
rowid,
SpeciesKor,
bm25(birds_fts) AS score
FROM birds_fts
WHERE birds_fts MATCH ?
ORDER BY score
LIMIT 100;bm25()는 FTS5에서 제공하는 관련도 점수 계산 함수입니다.
점수가 낮은 결과가 더 높은 관련도를 나타내는 방식으로 사용됩니다.
다만 실제 검색 품질은 토큰화 방식과 데이터 구성에 따라 달라집니다.
국명과 학명처럼 검색 대상이 짧은 데이터에서는 단순 관련도 점수보다 정확히 일치하는 이름을 우선하는 정렬 정책이 더 적합할 수도 있습니다.
16. 한글 초성 검색이 필요한 이유
한국어 사용자들은 검색할 때 초성만 입력하는 경우가 있습니다.
예를 들어 다음과 같습니다.
| 검색어 | 검색 결과 예시 |
|---|---|
ㅊㅅ | 참새 |
ㄲㅊ | 까치 |
ㅈㅂㄱㄹ | 직박구리 |
ㅎㄷㄹㅁ | 흰뺨검둥오리와는 일치하지 않음 |
한글 초성 검색은 완성형 한글에서 초성만 추출한 뒤 별도의 검색 키로 저장하는 방식으로 구현할 수 있습니다.
단, 초성 검색은 한글의 음절 구조를 이용하는 기능이므로 영문이나 숫자 등은 별도로 처리해야 합니다.
17. 한글 초성 추출 원리
완성형 한글 음절은 Unicode에서 일정한 범위에 배치되어 있습니다.
대표적인 범위는 다음과 같습니다.
가: U+AC00
힣: U+D7A3완성형 한글의 초성은 다음 계산으로 구할 수 있습니다.
초성 인덱스 =
(문자 코드 - 0xAC00) / 588초성은 총 19개입니다.
ㄱ ㄲ ㄴ ㄷ ㄸ ㄹ ㅁ ㅂ ㅃ
ㅅ ㅆ ㅇ ㅈ ㅉ ㅊ ㅋ ㅌ ㅍ ㅎ18. Kotlin 한글 초성 추출 함수
다음은 문자열에서 한글 초성을 추출하는 예제입니다.
object HangulUtils {
private val CHOSEONG = charArrayOf(
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ',
'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ',
'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
)
fun extractChoseong(
text: String
): String {
val result = StringBuilder()
for (char in text) {
if (char in '가'..'힣') {
val index =
(char.code - 0xAC00) / 588
result.append(
CHOSEONG[index]
)
} else {
result.append(char)
}
}
return result.toString()
}
}사용 예제입니다.
val result =
HangulUtils.extractChoseong(
"직박구리"
)
println(result)결과는 다음과 같습니다.
ㅈㅂㄱㄹ이 함수는 완성형 한글 음절을 대상으로 합니다.
분해된 자모나 특수한 Unicode 조합까지 모두 정규화하는 함수는 아니므로, 필요하다면 Unicode 정규화 처리를 추가해야 합니다.
19. SQLite에 초성 검색 컬럼 추가하기
초성 검색을 지원하려면 검색 전용 컬럼을 추가할 수 있습니다.
ALTER TABLE korean_birds
ADD COLUMN choseong TEXT;이후 각 조류의 국명에서 초성을 추출해 저장합니다.
예를 들어 다음과 같은 데이터가 됩니다.
| SpeciesKor | choseong |
|---|---|
| 참새 | ㅊㅅ |
| 까치 | ㄲㅊ |
| 직박구리 | ㅈㅂㄱㄹ |
| 박새 | ㅂㅅ |
검색은 다음과 같이 수행할 수 있습니다.
SELECT
KTSN,
SpeciesKor
FROM korean_birds
WHERE choseong LIKE 'ㅈㅂ%'
ORDER BY SpeciesKor;이렇게 하면 ㅈㅂ 입력으로 직박구리를 검색할 수 있습니다.
단, 기존 데이터베이스에 컬럼을 추가했다면 기존 행의 초성 값도 채워야 합니다.
또한 새 데이터를 삽입하거나 국명을 수정할 때 초성 컬럼도 함께 갱신해야 합니다.
20. 초성 검색 인덱스 추가하기
초성 검색 성능을 개선하려면 인덱스를 생성할 수 있습니다.
CREATE INDEX idx_birds_choseong
ON korean_birds(choseong);다만 LIKE 'ㅈㅂ%' 검색이 인덱스를 사용하는지는 SQLite의 Collation과 설정에 영향을 받습니다.
따라서 인덱스를 생성한 뒤 실제 실행 계획을 확인해야 합니다.
정확한 접두어 범위를 계산해 비교하는 방법도 있지만, 한글 자모 정렬과 Collation을 정확히 이해한 뒤 적용해야 합니다.
21. FTS5와 초성 검색을 함께 사용하기
FTS5와 초성 검색은 서로 다른 역할을 담당하도록 구성할 수 있습니다.
예를 들어 다음과 같습니다.
검색어 입력
│
▼
검색어 분석
│
├── 초성 입력
│ └── choseong 컬럼 검색
│
└── 일반 문자열
├── FTS5 검색
└── LIKE 보조 검색이 구조는 검색어의 특성에 따라 적절한 검색 방법을 선택할 수 있다는 장점이 있습니다.
예를 들어 ㅈㅂㄱㄹ은 초성 검색으로 처리하고, Passer는 학명 검색으로 처리할 수 있습니다.
다만 검색 결과를 합칠 때는 동일한 조류가 중복으로 표시되지 않도록 ID를 기준으로 중복 제거해야 합니다.
22. 초성 검색어 판별하기
다음은 입력값이 초성으로만 구성되어 있는지 확인하는 예제입니다.
fun isChoseongQuery(
keyword: String
): Boolean {
val choseongChars =
"ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ"
val normalized =
keyword.trim()
return normalized.isNotEmpty() &&
normalized.all {
it in choseongChars
}
}이 함수는 호환 자모 형태의 초성 입력을 대상으로 합니다.
실제 키보드 입력에서는 조합 중인 문자나 Unicode 정규화 상태에 따라 입력값이 달라질 수 있으므로 테스트가 필요합니다.
23. 자동완성 기능 구현하기
자동완성은 사용자가 검색어를 모두 입력하기 전에 관련 결과를 제안하는 기능입니다.
예를 들어 다음과 같습니다.
검색어: 참
자동완성
├── 참새
├── 참매
└── 참수리자동완성을 구현할 때 가장 중요한 것은 검색 요청을 지나치게 자주 실행하지 않는 것입니다.
사용자가 한 글자를 입력할 때마다 SQLite를 조회하면 불필요한 작업이 증가합니다.
이때 Debounce를 사용할 수 있습니다.
24. Debounce란?
Debounce는 입력이 발생할 때마다 즉시 작업을 실행하지 않고 일정 시간 동안 추가 입력이 없을 때 실행하는 방식입니다.
예를 들어 300ms Debounce를 적용하면 다음과 같이 동작합니다.
사용자 입력
ㅊ → 차 → 참 → 참ㅅ → 참새
│
300ms 대기
│
▼
검색 실행입력할 때마다 검색하지 않으므로 데이터베이스 조회 횟수를 줄일 수 있습니다.
25. StateFlow로 검색어 Debounce 처리하기
ViewModel에서 다음과 같이 구현할 수 있습니다.
class SearchViewModel(
private val repository: BirdRepository
) : ViewModel() {
private val keyword =
MutableStateFlow("")
private val _results =
MutableStateFlow<List<Bird>>(
emptyList()
)
val results =
_results.asStateFlow()
init {
viewModelScope.launch {
keyword
.debounce(300)
.distinctUntilChanged()
.collectLatest { query ->
_results.value =
repository.search(
query
)
}
}
}
fun updateKeyword(
value: String
) {
keyword.value = value
}
}collectLatest는 새 검색어가 들어오면 이전 처리 작업을 취소하는 데 도움이 됩니다.
다만 SQLite의 동기식 쿼리 자체가 Coroutine 취소에 즉시 반응하지 않을 수 있으므로, 실제 취소 동작은 DB 접근 방식에 따라 달라질 수 있습니다.
또한 검색 결과를 Dispatchers.IO에서 조회하도록 Repository를 구성해야 합니다.
26. EditText와 ViewModel 연결하기
XML에서는 다음과 같이 검색 입력창을 구성할 수 있습니다.
<EditText
android:id="@+id/editSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/search_hint"
android:inputType="text"
android:imeOptions="actionSearch" />Fragment에서는 doAfterTextChanged를 사용할 수 있습니다.
binding.editSearch.doAfterTextChanged {
editable ->
viewModel.updateKeyword(
editable?.toString().orEmpty()
)
}이 방식은 검색어 변경을 ViewModel에 전달하는 역할만 담당합니다.
실제 검색 실행과 결과 관리는 ViewModel과 Repository에서 처리합니다.
27. 검색어가 비어 있을 때 처리하기
검색어가 비어 있을 때 모든 데이터를 반환할지, 추천 목록을 표시할지 결정해야 합니다.
예를 들어 다음과 같은 정책을 사용할 수 있습니다.
| 검색어 상태 | 처리 |
|---|---|
| 빈 문자열 | 인기 항목 또는 최근 검색 |
| 1글자 | 제한된 자동완성 |
| 2글자 이상 | 일반 검색 |
| 초성만 입력 | 초성 검색 |
| 공백만 입력 | 빈 검색어로 처리 |
검색어가 비어 있을 때 수만 건의 데이터를 모두 조회하면 화면 성능이 저하될 수 있습니다.
따라서 검색 화면의 목적에 맞는 기본 상태를 제공하는 것이 좋습니다.
28. RecyclerView 검색 결과 최적화
검색 결과를 RecyclerView에 표시할 때는 ListAdapter와 DiffUtil을 사용하는 것이 좋습니다.
class BirdAdapter :
ListAdapter<Bird, BirdViewHolder>(
DIFF_CALLBACK
) {
companion object {
private val DIFF_CALLBACK =
object : DiffUtil.ItemCallback<Bird>() {
override fun areItemsTheSame(
oldItem: Bird,
newItem: Bird
): Boolean {
return oldItem.id ==
newItem.id
}
override fun areContentsTheSame(
oldItem: Bird,
newItem: Bird
): Boolean {
return oldItem == newItem
}
}
}
// onCreateViewHolder / onBindViewHolder 구현
}notifyDataSetChanged()를 반복적으로 호출하는 것보다 변경된 항목을 효율적으로 반영할 수 있습니다.
다만 검색어 하이라이트가 변경되는 경우에는 검색 결과 데이터뿐 아니라 현재 검색어도 ViewHolder 바인딩에 반영해야 합니다.
29. 검색어 하이라이트 구현하기
검색 결과에서 사용자가 입력한 부분을 강조하면 검색 결과를 이해하기 쉬워집니다.
예를 들어 다음과 같은 표시입니다.
검색어: 참
[참]새
[참]매
[참]수리Kotlin에서는 SpannableString을 사용할 수 있습니다.
fun highlightKeyword(
text: String,
keyword: String,
color: Int
): SpannableString {
val spannable =
SpannableString(text)
if (keyword.isBlank()) {
return spannable
}
val start = text.indexOf(
keyword,
ignoreCase = true
)
if (start >= 0) {
spannable.setSpan(
ForegroundColorSpan(color),
start,
start + keyword.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
return spannable
}이 코드는 첫 번째 일치 부분만 강조하는 기본 예제입니다.
실제 앱에서는 여러 번 등장하는 검색어를 모두 강조하거나, 초성 검색 결과에서 대응되는 완성형 글자를 강조하는 기능을 추가할 수 있습니다.
30. 초성 검색 결과 하이라이트
초성 검색에서는 입력한 문자열이 원본 국명에 직접 포함되어 있지 않습니다.
예를 들어 다음과 같습니다.
검색어: ㅈㅂ
검색 결과: 직박구리이때 ㅈㅂ이라는 문자열을 직접 찾아서는 하이라이트할 수 없습니다.
대신 각 음절의 초성을 비교해 일치하는 글자 범위를 계산해야 합니다.
예를 들어 직박구리의 초성은 ㅈㅂㄱㄹ입니다.
입력한 ㅈㅂ이 초성 문자열의 시작 부분과 일치한다면 원본 문자열의 앞 두 글자인 직박을 강조할 수 있습니다.
다만 공백, 영문, 특수문자가 포함된 문자열에서는 초성 인덱스와 원본 문자열 인덱스가 단순히 일치하지 않을 수 있으므로 별도의 매핑 로직이 필요합니다.
31. 검색 결과 우선순위 정하기
검색 결과는 단순히 가나다순으로 정렬하는 것보다 사용자의 검색 의도에 맞게 정렬하는 것이 좋습니다.
예를 들어 다음과 같은 우선순위를 사용할 수 있습니다.
국명이 검색어와 정확히 일치
국명이 검색어로 시작
국명에 검색어 포함
학명이 검색어로 시작
과명이나 속명에 검색어 포함
초성 검색 결과
SQLite에서는 CASE 문으로 우선순위를 지정할 수 있습니다.
SELECT
KTSN,
SpeciesKor,
Species
FROM korean_birds
WHERE
SpeciesKor LIKE ?
OR Species LIKE ?
ORDER BY
CASE
WHEN SpeciesKor = ? THEN 0
WHEN SpeciesKor LIKE ? THEN 1
WHEN Species LIKE ? THEN 2
ELSE 3
END,
SpeciesKor
LIMIT 100;이 방식은 데이터 규모가 크지 않은 앱에서 검색 품질을 개선하는 데 유용합니다.
다만 검색 조건이 복잡해질수록 SQL 실행 비용도 증가할 수 있으므로 실제 성능 측정이 필요합니다.
32. 검색 기록 저장하기
검색 기록은 사용자 편의성을 높일 수 있는 기능입니다.
예를 들어 다음과 같은 테이블을 사용할 수 있습니다.
CREATE TABLE search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL UNIQUE,
searched_at INTEGER NOT NULL
);검색어를 저장할 때는 다음과 같은 정책을 고려할 수 있습니다.
동일한 검색어 중복 저장 방지
최근 검색어 순서 정렬
최대 저장 개수 제한
개별 검색어 삭제
전체 검색 기록 삭제
개인정보가 포함될 수 있는 검색어 관리
검색 기록은 사용자의 입력 데이터이므로 앱 업데이트나 데이터베이스 교체 과정에서 임의로 삭제되지 않도록 해야 합니다.
33. 검색 기록과 자동완성 구분하기
검색 기록과 자동완성은 서로 다른 기능입니다.
검색 기록은 사용자가 과거에 검색한 문자열을 보여줍니다.
자동완성은 현재 입력 중인 검색어와 관련된 데이터를 제안합니다.
예를 들어 다음과 같이 구분할 수 있습니다.
검색어 입력 전
└── 최근 검색어
검색어 입력 중
└── 데이터베이스 자동완성
검색 버튼 선택
└── 전체 검색 결과이렇게 구분하면 검색 화면의 동작을 이해하기 쉬워집니다.
34. 대량 데이터 검색 성능 측정하기
검색 기능을 최적화할 때는 실제 실행 시간을 측정해야 합니다.
예를 들어 다음과 같은 방식으로 측정할 수 있습니다.
val start =
SystemClock.elapsedRealtimeNanos()
val results =
repository.search(keyword)
val elapsedMs =
(
SystemClock.elapsedRealtimeNanos() -
start
) / 1_000_000.0
Log.d(
"SearchPerformance",
"검색 시간: $elapsedMs ms"
)다만 이 코드는 repository.search()가 suspend 함수라면 Coroutine 내부에서 호출해야 합니다.
또한 로그에 검색어 자체를 기록하면 개인정보가 노출될 수 있으므로 주의해야 합니다.
검색 성능은 다음과 같은 조건에서 비교하는 것이 좋습니다.
동일한 기기
동일한 데이터베이스
동일한 검색어
동일한 결과 개수 제한
초기 검색과 반복 검색 구분
디버그 및 릴리스 빌드 차이 확인
35. 검색 성능 비교 기준
검색 성능을 비교할 때는 단순히 한 번의 실행 결과만으로 판단하지 않는 것이 좋습니다.
예를 들어 다음과 같은 테스트를 구성할 수 있습니다.
| 테스트 | 검색 방식 | 확인 항목 |
|---|---|---|
| A | LIKE 부분 검색 | 전체 스캔 비용 |
| B | LIKE 접두어 검색 | 인덱스 활용 여부 |
| C | FTS5 MATCH | 토큰 검색 성능 |
| D | 초성 컬럼 검색 | 초성 검색 속도 |
| E | 복합 검색 | 결과 병합 비용 |
| F | 자동완성 | 입력 중 UI 반응성 |
결과는 평균뿐 아니라 최악의 실행 시간과 UI 프레임 지연 여부도 함께 확인하는 것이 좋습니다.
36. FTS5를 무조건 사용해야 할까?
FTS5는 강력한 검색 기능이지만 모든 앱에서 필요한 것은 아닙니다.
예를 들어 데이터가 2,000~3,000건 정도이고 검색 조건이 단순하다면 기존 LIKE 검색만으로도 충분할 수 있습니다.
반대로 다음과 같은 경우에는 FTS를 검토할 가치가 있습니다.
검색 대상 데이터가 많음
여러 텍스트 컬럼을 검색함
검색 결과 관련도 정렬이 필요함
복잡한 검색 표현식이 필요함
검색 성능이 실제로 문제가 되고 있음
다만 FTS5는 한글 초성 검색이나 형태소 검색을 자동으로 해결해 주는 도구가 아닙니다.
한국어 검색 품질을 높이려면 별도의 정규화와 검색 키 설계가 필요합니다.
37. Android 버전 호환성 고려하기
Android에서 SQLite를 사용할 때는 OS에 포함된 SQLite 버전과 빌드 옵션을 고려해야 합니다.
특히 minSdk 24처럼 오래된 버전을 지원한다면 다음 기능의 호환성을 확인해야 합니다.
FTS5 가상 테이블
UPSERT 문법
특정 SQLite 함수
Collation 동작
Unicode 처리 방식
지원 여부가 불확실한 기능은 런타임에서 확인하거나 호환 가능한 대체 구현을 준비하는 것이 좋습니다.
예를 들어 다음과 같은 전략을 사용할 수 있습니다.
FTS5 지원
└── FTS5 검색
FTS5 미지원
└── LIKE + 초성 컬럼 검색이렇게 하면 최신 Android 기기에서는 고급 검색을 활용하면서 오래된 기기에서도 기본 검색 기능을 제공할 수 있습니다.
38. 개인 개발자에게 추천하는 검색 고도화 순서
처음부터 FTS5와 복잡한 검색 엔진을 모두 구현할 필요는 없습니다.
다음 순서로 개선하는 것이 현실적입니다.
1단계: LIKE 검색 정리
SQL 바인딩 인자를 사용하고 검색 결과 개수를 제한합니다.
2단계: Coroutine 비동기 처리
SQLite 조회를 메인 스레드에서 분리합니다.
3단계: Debounce 적용
입력 중 불필요한 검색 요청을 줄입니다.
4단계: 초성 검색 추가
국명에서 초성을 추출해 별도 컬럼에 저장합니다.
5단계: 자동완성 구현
접두어 검색과 검색 기록을 제공합니다.
6단계: RecyclerView 최적화
ListAdapter와 DiffUtil을 적용합니다.
7단계: FTS5 검토
데이터 규모와 실제 성능 문제를 기준으로 적용 여부를 결정합니다.
8단계: 검색 품질 개선
정확히 일치하는 결과와 관련도가 높은 결과를 우선 표시합니다.
39. Android 검색 기능 체크리스트
검색 SQL에 바인딩 인자 사용
검색 결과 개수 제한
SQLite 조회를 Dispatchers.IO에서 실행
여러 컬럼 검색 조건 정리
인덱스 생성 및 실행 계획 확인
LIKE 접두어 검색과 부분 검색 구분
FTS5 지원 여부 확인
FTS5 검색어 안전하게 처리
FTS 테이블과 원본 데이터 동기화
한글 초성 추출 함수 구현
초성 검색 컬럼 생성
초성 검색 인덱스 검토
Debounce 적용
이전 검색 작업 취소
검색어가 비어 있을 때 처리
자동완성 결과 개수 제한
RecyclerView DiffUtil 적용
검색어 하이라이트 구현
검색 결과 우선순위 설정
검색 기록 관리
검색어 개인정보 노출 방지
실제 기기에서 성능 측정
오래된 Android 버전 호환성 확인
40. 마무리
Android 앱 검색 기능은 단순히 SQLite에서 문자열을 조회하는 기능이 아닙니다.
사용자의 입력 방식, 데이터 구조, 검색 속도, 화면 반응성, 결과 정렬까지 함께 고려해야 합니다.
특히 Kotlin + SQLite 기반 앱에서는 다음 원칙이 중요합니다.
LIKE 검색을 먼저 안전하게 구현합니다.
검색은 메인 스레드에서 실행하지 않습니다.
Debounce로 불필요한 검색 요청을 줄입니다.
한글 초성 검색은 별도의 검색 키로 처리합니다.
FTS5는 지원 여부와 실제 성능을 확인한 뒤 적용합니다.
RecyclerView는 DiffUtil로 변경된 결과만 반영합니다.
검색 결과는 정확히 일치하는 항목을 우선 표시합니다.
검색 성능은 실제 데이터와 기기에서 측정합니다.
조류·어류 도감처럼 국명, 학명, 과명, 속명 등 다양한 검색 조건을 제공하는 앱이라면 초성 검색과 자동완성만 추가해도 검색 편의성이 크게 개선될 수 있습니다.
이후 데이터가 증가하고 검색 성능이 문제가 되는 시점에 FTS5를 검토하는 것이 좋습니다.
검색 기능 최적화의 핵심은 가장 복잡한 검색 엔진을 사용하는 것이 아니라, 사용자가 원하는 데이터를 빠르고 정확하게 찾을 수 있도록 만드는 것입니다.
참고 자료
Android Developers — SQLiteOpenHelper
https://developer.android.com/reference/android/database/sqlite/SQLiteOpenHelperAndroid Developers — SQLiteDatabase
https://developer.android.com/reference/android/database/sqlite/SQLiteDatabaseSQLite — LIKE 연산자
https://www.sqlite.org/lang_expr.html#the_like_glob_regexp_match_and_extract_operatorsSQLite — Query Planner
https://www.sqlite.org/queryplanner.htmlSQLite — EXPLAIN QUERY PLAN
https://www.sqlite.org/eqp.htmlSQLite — FTS5
https://www.sqlite.org/fts5.htmlAndroid Developers — StateFlow와 SharedFlow
https://developer.android.com/kotlin/flow/stateflow-and-sharedflowAndroid Developers — RecyclerView
https://developer.android.com/develop/ui/views/layout/recyclerviewAndroid Developers — DiffUtil
https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil
태그: Android, Kotlin, SQLite, SQLiteOpenHelper, FTS5, LIKE, 한글 초성 검색, 자동완성, Debounce, StateFlow, Coroutine, RecyclerView, DiffUtil, 검색 성능 최적화, MVVM