Room에서 KSP가 필요한 이유: Room Compiler와 코드 생성 원리

Room에서 DAO와 Database 구현체가 자동 생성되는 과정을 KSP, Room Compiler, Room Runtime의 역할을 중심으로 설명

Room을 처음 설정하다 보면 ksp라는 의존성이 갑자기 등장한다.

implementation(libs.androidx.room3.runtime)
ksp(libs.androidx.room3.compiler)

Room으로 로컬 데이터베이스를 만들려는 것인데, 왜 별도의 KSP가 필요한 걸까?
핵심은 Room의 동작 방식에 있다.

개발자는 DAO와 Database의 형태를 선언하지만, 실제 SQLite를 다루는 구현 코드는 직접 작성하지 않는다. Room Compiler가 빌드 과정에서 이 구현 코드를 생성하며, Room 3.x에서는 이 코드 생성 과정에 KSP가 필요하다.

이 글에서는 Ariadne의 파일 Metadata 캐시를 예제로 다음 관계를 정리한다.

KSP
Room Compiler
Room Runtime
SQLiteDriver
SQLite

이 다섯 가지의 역할을 구분하면 Room 설정에서 왜 ksp(...)가 필요한지도 자연스럽게 이해할 수 있다.

이 글은 Room 3.x를 기준으로 설명한다. Room 3.x는 androidx.room3 패키지를 사용하며 KSP를 필수로 사용한다.

Room에서는 DAO의 구현 코드를 직접 작성하지 않는다

먼저 Room으로 파일 Metadata를 저장한다고 가정해 보자.
Ariadne에서는 Google Drive에서 가져온 파일 정보를 로컬에 캐시하기 위해 다음과 같은 Entity를 만들 수 있다.

@Entity(tableName = "cached_files")
data class CachedFileEntity(
    @PrimaryKey
    val externalId: String,
    val name: String
)

그리고 저장된 파일을 조회하는 DAO를 정의한다.

@Dao
interface StorageCacheDao {

    @Query("SELECT * FROM cached_files")
    fun observeFiles(): Flow<List<CachedFileEntity>>
}

여기서 이상한 점이 하나 있다.
우리가 작성한 것은 어디까지나 인터페이스다.

interface StorageCacheDao

observeFiles() 내부에는 다음과 같은 코드가 없다.

SQLite에 SQL 전달
        ↓
조회 결과 읽기
        ↓
CachedFileEntity로 변환
        ↓
Flow<List<CachedFileEntity>> 반환

그런데 애플리케이션에서는 DAO를 가져와 그대로 사용할 수 있다.

val files = storageCacheDao.observeFiles()

그렇다면 실제 구현은 어디에 있을까?
빌드할 때 Room Compiler가 DAO의 구현 코드를 생성한다.
Android 공식 문서에서도 Room의 주요 구성요소를 Database, Entity, DAO로 설명하며, DAO를 통해 데이터베이스의 데이터를 조회하고 변경하도록 구성한다. Room은 이 선언을 바탕으로 필요한 데이터 접근 코드를 생성한다.

KSP는 Room이 Kotlin 코드를 분석할 수 있게 하는 빌드 도구다

KSP는 Kotlin Symbol Processing의 약자다.
Kotlin 공식 문서에서는 KSP를 Kotlin 소스 코드를 분석하고, 그 정보를 기반으로 코드를 생성할 수 있도록 하는 프레임워크로 설명한다. 클래스, 함수, 프로퍼티, Annotation과 같은 Kotlin의 구조를 Symbol 형태로 Processor에 제공한다.
중요한 점은 KSP 자체가 데이터베이스 기술은 아니라는 것이다.

KSP
≠ SQLite
≠ Room Runtime
≠ Cache

KSP의 위치는 앱 실행 과정이 아니라 빌드 과정이다.

Kotlin 코드 작성
       ↓
      빌드
       ↓
      KSP
       ↓
Room Compiler
       ↓
구현 코드 생성
       ↓
Kotlin Compiler
       ↓
앱에 포함

따라서 KSP가 파일 Metadata를 저장하거나 SQLite에 직접 쿼리를 보내는 것은 아니다. KSP는 Room Compiler 같은 Processor가 Kotlin 코드의 구조를 읽고 처리할 수 있는 기반을 제공한다.

실제 코드를 생성하는 것은 Room Compiler다

KSP와 Room Compiler는 같은 기술이 아니다.
둘의 역할을 구분하는 것이 중요하다.

KSP
 ↓
Kotlin 코드의 Symbol을 Processor에 제공
 ↓
Room Compiler
 ↓
@Entity, @Dao, @Database 분석
 ↓
Room 구현 코드 생성

즉, KSP는 코드 분석과 생성을 위한 처리 기반이고, Room Compiler는 그 기반 위에서 실제 Room 코드를 처리하는 Processor다.
예를 들어 다음 DAO가 있다고 하자.

@Dao
interface StorageCacheDao {

    @Query("SELECT * FROM cached_files")
    fun observeFiles(): Flow<List<CachedFileEntity>>
}

Room Compiler는 @Dao, @Query, 반환 타입과 Entity 정보를 분석해 이 DAO를 실행하기 위한 코드를 생성한다.
개념적으로 보면 다음과 같다.

StorageCacheDao
       ↓
KSP를 통해 코드 구조 확인
       ↓
Room Compiler가 분석
       ↓
DAO 구현 코드 생성
       ↓
SQLite Query 실행 가능

생성된 구현체에서는 개발자가 직접 작성하지 않은 다음 작업들이 처리된다.

SQL 실행
   ↓
조회 결과 처리
   ↓
CachedFileEntity로 매핑
   ↓
DAO 반환 타입으로 전달

Room이 제공하는 장점 중 하나인 SQL Query의 빌드 시점 검증도 이 처리 과정과 연결된다. Android 공식 문서는 Room의 주요 이점으로 SQL Query의 컴파일 시간 확인과 반복적인 데이터베이스 처리 코드 감소를 명시하고 있다.

Room을 사용하는 이유 자체가 궁금하다면 Android에서 SQLite 대신 Room을 권장하는 이유에서 자세히 살펴본다.

Database가 abstract class여도 생성할 수 있는 이유도 같다

DAO뿐만 아니라 RoomDatabase도 비슷한 구조를 가진다.
Ariadne의 데이터베이스를 다음처럼 정의했다고 하자.

@Database(
    entities = [
        CachedStorageEntity::class,
        CachedFileEntity::class
    ],
    version = 1
)
abstract class AriadneDatabase : RoomDatabase() {

    abstract fun storageCacheDao(): StorageCacheDao
}

여기에서도 개발자가 작성한 클래스는 abstract class다.
storageCacheDao() 역시 구현되어 있지 않다.

AriadneDatabase
 └─ storageCacheDao()
       구현 없음

그런데 실제 앱에서는 데이터베이스를 생성할 수 있다.

val database = Room.databaseBuilder<AriadneDatabase>(
    context,
    "ariadne.db"
)
    .setDriver(AndroidSQLiteDriver())
    .build()

이것이 가능한 이유 역시 Room이 빌드 과정에서 데이터베이스의 실제 구현 코드를 생성하기 때문이다.
개념적으로 보면 다음과 같다.

AriadneDatabase
       ↓
KSP
       ↓
Room Compiler
       ↓
Database 구현 코드 생성

DAO도 마찬가지다.

StorageCacheDao
       ↓
KSP
       ↓
Room Compiler
       ↓
DAO 구현 코드 생성

빌드 산출물에서는 _Impl 형태의 생성 클래스를 확인할 수 있지만, 이 클래스들은 Room의 내부 생성 코드다.
애플리케이션 코드가 생성 클래스의 이름이나 구현 구조에 직접 의존할 필요는 없다.

빌드 시점과 실행 시점을 분리하면 구조가 명확해진다

여기까지 보면 KSP, Room Compiler, Room Runtime이 비슷한 역할처럼 보일 수 있다.
하지만 동작하는 시점부터 다르다.

빌드할 때

@Entity
@Dao
@Database
@Query
    ↓
   KSP
    ↓
Room Compiler
    ↓
Room 구현 코드 생성
    ↓
Kotlin Compiler

앱이 실행될 때

앱
 ↓
Room Runtime + 생성된 구현 코드
 ↓
SQLiteDriver
 ↓
SQLite
 ↓
DB 파일

이 차이를 표로 정리하면 다음과 같다.

구성요소 동작 시점 역할
KSP 빌드 Kotlin 코드의 Symbol을 Processor가 분석할 수 있도록 제공
Room Compiler 빌드 Room Annotation과 Query를 분석하고 구현 코드 생성
Room Runtime 실행 생성된 Room 코드와 함께 Database와 DAO 동작에 필요한 Runtime API 제공
SQLiteDriver 실행 Room과 실제 SQLite 구현 사이의 데이터베이스 연결 제공
SQLite 실행 실제 데이터를 저장하고 SQL을 실행하는 데이터베이스 엔진

따라서 KSP → Room → SQLite를 하나의 런타임 호출 흐름으로 이해하면 안 된다.
KSP와 Room Compiler는 앱이 실행되기 전에 역할을 끝낸다.

ksp(libs.androidx.room3.compiler)는 무엇을 설정하는 것일까?

이제 Gradle 설정을 다시 보면 의미가 명확해진다.
예를 들어 Version Catalog에 KSP Plugin을 등록했다고 하자.

[versions]
ksp = "..."

[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

이 설정은 프로젝트에서 KSP Gradle Plugin을 사용할 수 있도록 정의하는 것이다.
그리고 Android 모듈에서 다음과 같이 Plugin을 적용한다.

plugins {
    alias(libs.plugins.ksp)
}

여기까지는 아직 Room Compiler를 연결하지 않았다.
KSP를 사용할 수 있는 빌드 환경만 준비한 것이다.
실제로 Room Compiler를 KSP Processor로 등록하는 부분은 다음이다.

dependencies {
    ksp(libs.androidx.room3.compiler)
}

구조로 보면 다음과 같다.

KSP Gradle Plugin
        ↓
KSP 처리 과정 활성화

Room Compiler
        ↓
KSP에서 실행할 Processor 등록

KSP 공식 Quick Start에서도 KSP Plugin을 적용한 뒤 ksp(...) configuration에 사용할 Processor를 의존성으로 추가하는 구조를 사용한다.
즉,

ksp(libs.androidx.room3.compiler)

의 의미는 단순히 “KSP를 추가한다”가 아니다.
Room Compiler를 KSP 처리 과정에서 실행할 Processor로 등록한다는 의미에 가깝다.

Room Runtime은 생성된 코드를 실제 앱에서 사용하기 위해 필요하다

그렇다면 다음 의존성은 왜 필요할까?

implementation(libs.androidx.room3.runtime)

Room Compiler와 가장 큰 차이는 사용되는 시점이다.

Room Compiler
→ 빌드할 때

Room Runtime
→ 앱이 실행될 때

빌드 과정에서 생성된 DAO와 Database 구현 코드는 앱 실행 중 Room Runtime과 함께 동작한다.
예를 들어 앱에서는 다음 흐름이 발생한다.

앱 실행
   ↓
AriadneDatabase 생성
   ↓
StorageCacheDao 획득
   ↓
observeFiles() 호출
   ↓
Local Database 조회

따라서 다음 두 의존성은 대체 관계가 아니다.

implementation(libs.androidx.room3.runtime)
ksp(libs.androidx.room3.compiler)

하나는 실행을 위한 라이브러리이고, 다른 하나는 실행에 필요한 코드를 빌드할 때 생성하기 위한 Compiler다.

Room 3에서는 SQLiteDriver도 필요하다

Room은 SQLite 위에서 동작하지만 Room 3.x에서는 SQLite에 접근하는 방식을 한 단계 더 구분해서 봐야 한다.
Room 3.x는 SQLiteDriver API를 기반으로 동작하며, RoomDatabase를 생성할 때 Driver를 지정해야 한다.
Android에서는 다음과 같이 AndroidSQLiteDriver를 사용할 수 있다.

Room.databaseBuilder<AriadneDatabase>(
    context,
    "ariadne.db"
)
    .setDriver(AndroidSQLiteDriver())
    .build()

AndroidSQLiteDriver는 Android SDK가 제공하는 SQLite API를 이용해 SQLiteDriver를 구현한다. 이 Driver는 androidx.sqlite:sqlite-framework Artifact에서 제공된다. 따라서 다음 의존성의 역할도 KSP와는 완전히 다르다.

implementation(libs.androidx.sqlite.framework)

구조를 단순화하면 다음과 같다.

Room
 ↓
SQLiteDriver
 ↓
Android SQLite API
 ↓
SQLite

sqlite-framework코드를 생성하기 위한 라이브러리가 아니라 앱 실행 중 사용할 SQLiteDriver 구현을 제공하는 라이브러리다.

Room 3에서는 KSP가 선택사항이 아니라 필수다

예전 Room 관련 자료를 보면 kapt 설정을 사용하는 예제를 자주 볼 수 있다.

kapt
= Kotlin Annotation Processing Tool

Room 2.x에서는 kapt를 사용하는 설정도 가능했다. 하지만 Room 3.x에서는 상황이 달라졌다.
Room 3.0부터는 Kotlin 코드만 생성하며, Annotation Processing 방식으로 KSP를 필수로 사용한다. kapt나 Java Annotation Processor 방식으로 Room Compiler를 설정할 수 없다.
따라서 새 Room 3.x 프로젝트에서 다음을 고민할 필요는 없다.

Room Compiler를
kapt로 사용할까?
KSP로 사용할까?

Room 3.x라면 답은 KSP다. KSP 자체도 kapt와 구조적인 차이가 있다.
kapt는 Java Annotation Processor를 사용하기 위해 Kotlin 코드를 Java Stub 형태로 변환하는 과정이 필요한 반면, KSP는 Kotlin Symbol을 Processor에 직접 제공한다. Kotlin 공식 문서는 이를 KSP가 kapt보다 빌드 처리 비용을 줄일 수 있는 이유 중 하나로 설명한다. 다만 Ariadne에서 KSP를 사용하는 가장 직접적인 이유는 단순히 “KSP가 더 빠르기 때문”**이 아니다.
현재 사용하는 Room 3.x 자체가 KSP를 요구하기 때문이다.

Ariadne 전체 구조에서 KSP는 어디에 있을까?

Ariadne에서는 Google Drive에서 가져온 Metadata를 Room에 캐시한다.
런타임 아키텍처는 개념적으로 다음과 같다.

                 ┌→ StorageClient
                 │      ↓
StorageRepository┤  Google Drive
                 │
                 └→ StorageCacheDao
                         ↓
                       Room
                         ↓
                    SQLiteDriver
                         ↓
                       SQLite

각 계층의 책임은 다음과 같다.

StorageClient
→ Google Drive 같은 Remote Storage 접근

StorageRepository
→ Remote와 Local 데이터 사용 정책 조정

StorageCacheDao / Room
→ Local Database 접근

SQLiteDriver
→ SQLite 연결 구현

SQLite
→ 실제 Local 데이터 저장

여기에는 KSP가 없다. 이것이 중요하다.
KSP는 다음과 같은 런타임 Architecture 구성요소가 아니다.

StorageViewModel
      ↓
     KSP
      ↓
     Room

이 구조는 잘못된 표현이다.
KSP를 포함한 흐름은 빌드 구조에서 따로 봐야 한다.

Ariadne Source Code
      ↓
     KSP
      ↓
Room Compiler
      ↓
Room 구현 코드 생성
      ↓
애플리케이션 빌드

실행 시점에는 이미 KSP의 역할이 끝나 있다.

KSP와 Room Compiler를 하나로 생각하면 안 된다

Room 설정을 처음 보면 다음 두 개를 하나의 기술처럼 생각하기 쉽다.

KSP
Room Compiler

하지만 역할은 명확하게 다르다. KSP는 Room 전용 도구가 아니다.
Room 외에도 KSP를 지원하는 다른 라이브러리가 Kotlin 코드를 분석하고 코드를 생성하는 데 사용할 수 있다. Kotlin 공식 문서에서도 Room을 KSP를 지원하는 라이브러리 중 하나로 소개한다.
반대로 Room Compiler는 Room을 위한 코드 생성기다. 따라서 관계는 다음과 같이 이해하는 것이 가장 정확하다.

KSP
= 코드 처리 기반

Room Compiler
= KSP를 사용하는 Room 전용 Processor

KSP만 설치한다고 DAO 구현체가 만들어지는 것은 아니다.

KSP
+
Room Compiler

가 연결되어야 Room의 코드 생성 과정이 동작한다.

결국 KSP는 개발자가 작성하지 않은 코드를 빌드할 때 만들어주는 과정에 필요하다

Room을 사용하면서 개발자가 직접 작성하는 코드는 주로 선언에 가깝다.

@Entity
→ 어떤 데이터를 테이블로 저장할지 정의

@Dao
→ 어떤 데이터 접근 기능이 필요한지 정의

@Query
→ 어떤 Query를 실행할지 정의

@Database
→ 어떤 Entity와 DAO로 Database를 구성할지 정의

하지만 이 선언만으로 SQLite가 실행되는 것은 아니다.
그 사이에 필요한 실제 구현을 Room이 생성한다.

개발자가 작성한 Room 선언
          ↓
         KSP
          ↓
    Room Compiler
          ↓
   구현 코드 생성
          ↓
         빌드
          ↓
       앱 실행
          ↓
    Room Runtime
          ↓
    SQLiteDriver
          ↓
        SQLite

그래서 Room 프로젝트에서 KSP가 등장하는 이유는 단순히 Gradle 설정 하나를 더 추가하기 위해서가 아니다.
Room의 선언적인 API와 실제 SQLite 실행 코드 사이를 연결하는 구현 코드를 빌드할 수 있도록 하기 위해서다.
KSP를 Room의 일부로 외우기보다는 빌드 시점의 코드 생성 기반, Room Runtime과 SQLiteDriver를 실행 시점의 구성요소로 나누어 이해하면 역할이 훨씬 명확해진다. Room을 사용하는 개발자가 DAO 구현 코드를 작성하지 않아도 되는 이유는 그 코드가 존재하지 않기 때문이 아니다. 우리가 직접 작성하는 대신, Room Compiler가 빌드 과정에서 생성하고 있기 때문이다.

참고