안드로이드 개발에서 Room은 SQLite를 기반으로 한 강력한 ORM(Object-Relational Mapping) 라이브러리입니다. 관계형 데이터베이스의 특성을 잘 활용하면서도 안드로이드 환경에 최적화된 설계를 제공하죠. 그런데 Room을 처음 사용하는 개발자라면 한 가지 의문이 생길 수 있습니다: "왜 Room은 객체 간 상호 참조를 금지할까?" 이번 포스트에서는 Room이 객체 참조를 허용하지 않는 기술적 이유와, 이를 대신하는 올바른 사용법을 예제 코드와 함께 알아보겠습니다.
Room은 SQLite의 관계형 데이터베이스 철학을 충실히 따르며, 안드로이드의 리소스 제약과 성능을 고려한 설계를 채택했습니다. 객체 참조를 허용하지 않는 이유는 크게 네 가지로 요약됩니다.
메모리랑 성능 문제
안드로이드는 메모리가 넉넉한 환경이 아니잖아요. 객체가 서로를 참조하다 보면 User가 Pet을, Pet이 다시 User를 가리키는 식으로 순환 참조가 생길 수 있어요. 이게 쌓이면 메모리 누수나 성능 저하로 이어질 가능성이 크죠. Room은 이런 위험을 없애려고 객체 참조를 아예 차단한 거예요.
데이터가 꼬일 위험
객체 참조를 허용하면 메모리에 떠 있는 데이터와 실제 데이터베이스가 달라질 수 있어요. 예를 들어, Pet이 가리키는 User가 데이터베이스에서 삭제됐는데 메모리엔 남아 있으면? 혼란이 오겠죠. Room은 이런 문제를 피하려고 "최신 데이터는 쿼리로 가져와"라고 강제하는 식이에요.
쿼리가 예측 불가능해지는 걸 막으려고
객체 참조가 되면 ORM이 알아서 관계를 탐색하면서 쿼리를 뿌릴 텐데, 그럼 언제 무슨 쿼리가 날아갈지 개발자가 파악하기 힘들어져요. Room은 "내가 쿼리 직접 쓸 테니 걱정 마"라는 스타일이라, 예측 가능성을 높인 거죠.
SQLite랑 맞춘 철학
SQLite는 외래 키로 테이블 관계를 정의하는 관계형 데이터베이스잖아요. Room도 이걸 그대로 가져와서, 객체 지향 스타일의 참조 대신 관계형 방식으로 풀라고 한 거예요. 깔끔하죠.
Room에서 객체 참조를 잘못 사용한 예시를 보겠습니다. 아래 코드는 User와 Pet이 서로를 직접 참조하는 구조입니다.
@Entity
data class User(
@PrimaryKey val userId: Long,
val name: String,
var pet: Pet? = null // 잘못됨: 다른 엔티티 참조
)
@Entity
data class Pet(
@PrimaryKey val petId: Long,
val petName: String,
var owner: User? = null // 잘못됨: 다른 엔티티 참조
)
@Dao
interface UserPetDao {
@Query("SELECT * FROM User WHERE userId = :id")
fun getUserWithPet(id: Long): User
}
문제점
순환 참조로 인해 메모리 누수 가능성 증가.
Room은 @Entity 내에 다른 @Entity를 포함하는 것을 지원하지 않음.
데이터베이스와 메모리 간 동기화 문제 발생.
컴파일 오류 또는 런타임에 예상치 못한 결과 초래.
Room에서는 외래 키와 @Relation을 사용하거나 조인 쿼리로 관계를 정의해야 합니다. 올바른 예시를 보겠습니다.
@Entity
data class User(
@PrimaryKey val userId: Long,
val name: String
)
@Entity(
foreignKeys = [
ForeignKey(
entity = User::class,
parentColumns = ["userId"],
childColumns = ["ownerId"],
onDelete = ForeignKey.CASCADE
)
]
)
data class Pet(
@PrimaryKey val petId: Long,
val petName: String,
val ownerId: Long // 외래 키로 관계 정의
)
data class UserWithPets(
@Embedded val user: User,
@Relation(
parentColumn = "userId",
entityColumn = "ownerId"
)
val pets: List<Pet>
)
@Dao
interface UserPetDao {
@Transaction
@Query("SELECT * FROM User WHERE userId = :id")
fun getUserWithPets(id: Long): UserWithPets
}
val userWithPets = userPetDao.getUserWithPets(1L)
println("User: ${userWithPets.user.name}")
userWithPets.pets.forEach { pet ->
println("Pet: ${pet.petName}")
}
잘 된 점
ownerId 외래 키로 관계를 명확히 정의.
@Relation으로 1:N 관계를 안전하게 표현.
@Transaction으로 데이터 일관성 보장.
객체 참조 없이도 필요한 데이터를 가져옴.
@Relation 대신 직접 조인 쿼리를 작성할 수도 있습니다.
@Dao
interface UserPetDao {
@Query("""
SELECT User.*, Pet.*
FROM User
INNER JOIN Pet ON User.userId = Pet.ownerId
WHERE User.userId = :id
""")
fun getUserAndPetsWithJoin(id: Long): List<UserAndPet>
}
data class UserAndPet(
@Embedded val user: User,
@Embedded(prefix = "pet_") val pet: Pet
)
@Embedded는 Room에서 데이터 클래스의 필드를 테이블에 "펼쳐서" 넣을 때 쓰는 어노테이션이에요. 예를 들어, User 객체를 다른 클래스 안에 넣고 @Embedded를 붙이면, 그 User의 필드들이 마치 한 테이블 안에 같이 있는 것처럼 매핑됩니다. 객체를 통째로 저장하는 게 아니라, 필드를 풀어서 테이블에 맞춰주는 거죠.
장점
SQL로 관계를 명시적으로 제어.
Room이 결과를 자동으로 매핑.
유연한 쿼리 작성 가능.
Room이 객체 참조를 금지하는 이유는 성능, 데이터 일관성, 예측 가능성, 그리고 SQLite와의 철학적 일치를 지키기 위함입니다. 잘못된 예시처럼 객체를 직접 참조하면 문제를 일으킬 수 있지만, 외래 키와 @Relation 또는 조인 쿼리를 사용하면 안전하고 효율적으로 관계를 정의할 수 있습니다.