Android 개발/트러블슈팅

Room 느린 쿼리, Database Inspector로 풀스캔 잡은 기록

stackD 2026. 7. 23. 18:00
반응형

 

지난 봄에 채팅 리스트 화면을 한참 들여다보고 있었어요. 데이터가 1만 건쯤 쌓인 계정에서만 스크롤이 툭툭 끊기더라고요. 컴포즈 리컴포지션을 한 시간 넘게 뒤졌는데, 정작 범인은 Room 쿼리 한 줄이었습니다.

 

증상 분리, UI 지연인가 Room 쿼리 지연인가

처음엔 당연히 젯팩 컴포즈(Jetpack Compose) 쪽을 의심했어요. LazyColumn 의 key 가 빠진 건지, remember 자리를 잘못 잡은 건지부터 봤거든요. 근데 시스트레이스(Systrace, 현재는 Perfetto 가 후속) 를 따 보니 메인 스레드는 멀쩡한데 IO 디스패처에서 100ms 가 넘는 블록이 계속 잡히더라고요.

 

데이터 양에 비례해서 프레임이 떨어지면 사실 UI 쪽보다 쿼리 쪽일 확률이 높습니다. 저 같은 경우엔 DAO 의 Flow 가 collect 되는 지점에 시간을 찍어봤는데, emit 간격이 250~400ms 씩 벌어지고 있었어요. UI 가 게으른 게 아니라 데이터가 늦게 도착하고 있었던 거지요.

 

 

Database Inspector 로 느린 Room 쿼리 특정하기

안드로이드 스튜디오(Android Studio) 의 Database Inspector 가 이럴 때 진짜 든든합니다. 'Live updates' 를 켜두고 화면을 스크롤하면, 어떤 쿼리가 언제 얼마나 자주 도는지가 그대로 흐르거든요.

 

의심 가는 SQL 을 Inspector 콘솔에 직접 붙여넣고 실행해보니, 1만 건 기준으로 180~220ms 가 걸렸습니다. 같은 쿼리도 정렬 조건이 created_at DESC 로 바뀌는 순간에만 유독 느려지는 패턴이 있었어요. 범위를 좁히고 나니, 의심할 자리가 한 곳으로 모아지더라고요.

 

 

EXPLAIN QUERY PLAN 으로 Room 인덱스 적용 여부 확인

다음은 실행계획을 까보는 단계입니다. 의심 쿼리 앞에 EXPLAIN QUERY PLAN 을 붙여서 다시 실행하면 보통 짧게 나옵니다 (서브쿼리·JOIN 이 끼면 여러 줄로 늘어나기도 해요).

 

  • SCAN messages 가 보이면 풀 테이블 스캔, 인덱스가 안 잡힌 상태 (옛 포맷에서는 SCAN TABLE messages 로 찍히기도 합니다)
  • SEARCH messages USING INDEX ... 가 보이면 인덱스를 정상적으로 타는 상태 (옛 포맷은 SEARCH TABLE messages USING INDEX ...)

 

제 경우엔 SCAN messages 가 그대로 찍혀 있었습니다. 그래서 Entity 에 복합 인덱스를 박아줬어요.

 

@Entity(
    tableName = "messages",
    indices = [Index(value = ["room_id", "created_at"])]
)
data class MessageEntity(
    @PrimaryKey val id: Long,
    @ColumnInfo(name = "room_id") val roomId: Long,
    @ColumnInfo(name = "created_at") val createdAt: Long,
    val body: String,
)

 

WHERE 절의 room_id 와 ORDER BY 의 created_at 을 한 인덱스로 묶은 거예요. 다시 실행하니 220ms 짜리 쿼리가 9ms 로 떨어졌습니다. 220ms 가 감이 안 오신다면, 60fps 기준 한 프레임이 16ms 인데 그걸 14 프레임 가까이 통째로 잡아먹는 시간이라고 보시면 됩니다.

 

복합 인덱스에서는 왼쪽 접두사 규칙이 핵심입니다. Index("a", "b", "c")WHERE a=? / WHERE a=? AND b=? 까지는 인덱스를 타지만, WHERE b=? 만 던지면 그냥 풀스캔이에요. 범위 조건(<, >) 을 쓰는 컬럼은 순서상 가장 뒤로 빼는 게 유리합니다.

 

그리고 Room 이 컴파일 단계에서 MISSING_INDEX_ON_FOREIGN_KEY_CHILD 경고를 띄우는 경우가 있는데요, 외래키 자식 컬럼에 인덱스가 빠지면 부모 테이블이 바뀔 때마다 자식 쪽에서 풀스캔이 돕니다. 이 경고는 그냥 넘기지 마시고 바로 잡아두는 게 좋아요.

 

 

Room N+1 쿼리와 @Relation, @Transaction

또 하나 자주 만나는 함정이 N+1 입니다. 리스트 아이템 N 개를 그릴 때 SELECT 가 N+1 번 도는 패턴인데요, Database Inspector 로그에 같은 형태의 쿼리가 빠르게 반복되면 거의 이 경우라고 보시면 됩니다.

 

이때는 별도 POJO 클래스에 @Relation 을 달아주면 Room 이 내부적으로 IN 절로 묶어서 두 번에 끝냅니다. 그리고 여러 쿼리의 데이터 정합성을 맞춰야 한다면 @Transaction 어노테이션으로 한 트랜잭션 안에서 돌게 해야 해요. 안 그러면 첫 쿼리와 두 번째 쿼리 사이에 데이터가 끼어들어 불일치가 생길 여지가 있습니다.

 

Paging 3 를 쓰고 있다면 OFFSET 도 한 번 의심해보시는 게 좋습니다. OFFSET 값이 커질수록 앞에 있는 행들을 스킵하느라 스캔량이 늘어나서, 뒤로 갈수록 무거워지는 구조거든요. 가능하면 WHERE id < :lastId ORDER BY id DESC LIMIT 30 같은 키 기반 페이지네이션으로 바꿔두는 쪽이 안정적이라고 봅니다.

 

Room 쿼리 진단 순서와 인덱스 주의점

지금까지 흐름을 정리하면 이렇습니다.

 

  1. 시스트레이스(또는 후속인 Perfetto)로 UI 병목인지 IO 병목인지부터 가릅니다
  2. Database Inspector 로 어떤 쿼리가 언제 얼마나 도는지 봅니다
  3. 의심 쿼리에 EXPLAIN QUERY PLAN 을 붙여 풀스캔 여부를 확인합니다
  4. 인덱스 / @Relation / @Transaction 으로 손본 뒤 다시 실행계획을 봅니다

 

마지막으로 인덱스 자체에 대해 한 가지만 짚고 갈게요. 인덱스는 읽기를 빠르게 해주지만 INSERT / UPDATE / DELETE 때마다 B-Tree 를 같이 갱신해야 하니까, 쓰기 비용과 DB 파일 크기가 같이 늘어납니다. 그래서 "일단 다 박아두자" 보다는, 프로파일링으로 효과가 증명된 컬럼에만 박는 게 맞다고 봅니다.

 

운영 중인 DB 에 인덱스를 추가할 때는 Migration 클래스에서 CREATE INDEX IF NOT EXISTS index_messages_room_id_created_at ON messages(room_id, created_at) 같은 SQL 을 직접 실행해주셔야 해요. 그리고 LIKE '%keyword' 처럼 앞에 와일드카드가 붙는 검색은 어차피 인덱스를 못 타니까, 본문 검색이 본격적으로 필요해지면 FTS 테이블 쪽을 검토하시는 게 낫습니다.

 

저는 이번 일을 겪고 나서 새 Entity 를 만들 때마다 WHERE 와 ORDER BY 후보 컬럼부터 먼저 적어두고 시작하게 됐어요. 1만 건이 넘어가는 리스트 화면이라면, 컴포즈 리컴포지션보다 IO 디스패처가 어떤 시간을 쓰고 있는지부터 보는 쪽이 시간을 훨씬 아낀다고 봅니다.

 

반응형
LIST