Room이 모르는 타입을 저장 가능한 기본 타입으로 변환하는 함수 쌍.
class Converters {
@TypeConverter fun fromInstant(v: Instant?): Long? = v?.toEpochMilli()
@TypeConverter fun toInstant(v: Long?): Instant? = v?.let(Instant::ofEpochMilli)
@TypeConverter fun fromTags(v: List<String>): String = v.joinToString(",")
@TypeConverter fun toTags(v: String): List<String> =
if (v.isBlank()) emptyList() else v.split(",")
}
@Database(entities = [...], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()
SQLite 가 아는 타입은 다섯 개뿐이다
- NULL · INTEGER · REAL · TEXT · BLOB
그 외(Instant · Uri · enum · List) 는 전부 변환이 필요하다
리스트를 문자열로 넣는 것의 대가
"tag1,tag2,tag3" 로 넣으면
- 태그로 검색·필터링·조인을 할 수 없다 (LIKE '%tag2%' 는 오탐이 난다)
- 인덱스를 걸 수 없다
- 구분자가 값에 들어가면 깨진다
정규화가 정답이다
- users · tags · user_tags(교차 테이블)
"조회 조건이 되지 않는 부수 데이터" 일 때만 직렬화가 정당하다
@Embedded — 한 테이블에 펼친다
data class Address(val city: String, val street: String)
@Entity
data class UserEntity(
@PrimaryKey val id: Long,
val name: String,
@Embedded val address: Address // city · street 컬럼으로 펼쳐진다
)
@Relation — 여러 테이블을 묶어 읽는다
data class UserWithPosts(
@Embedded val user: UserEntity,
@Relation(parentColumn = "id", entityColumn = "userId")
val posts: List<PostEntity>
)
@Transaction // ★ 필수 — 내부적으로 쿼리가 둘로 쪼개진다
@Query("SELECT * FROM users")
fun observeUsersWithPosts(): Flow<List<UserWithPosts>>
@Transaction 을 빠뜨리면
- users 조회와 posts 조회 사이에 쓰기가 끼어들어 불일치한 결과가 나온다
- Room 이 경고를 내지만 빌드는 통과한다 — 놓치기 쉽다
외래키
@Entity(foreignKeys = [ForeignKey(
entity = UserEntity::class, parentColumns = ["id"],
childColumns = ["userId"], onDelete = ForeignKey.CASCADE
)], indices = [Index("userId")]) // ★ 인덱스를 안 걸면 삭제가 느려진다
data class PostEntity(...)
면접 함정
- ❌ "TypeConverter로 객체를 JSON으로 넣으면 편하다" → 그 필드로 쿼리할 수 없게 된다. 스키마 변경도 어려워진다.
- ❌ "@Relation이 JOIN을 만든다" → 별도 쿼리를 실행해 메모리에서 합친다. 그래서
@Transaction이 필요하다.