[Android] Room에서 객체 참조를 허용하지 않는 이유

이주형·2025년 3월 13일

안드로이드 개발에서 Room은 SQLite를 기반으로 한 강력한 ORM(Object-Relational Mapping) 라이브러리입니다. 관계형 데이터베이스의 특성을 잘 활용하면서도 안드로이드 환경에 최적화된 설계를 제공하죠. 그런데 Room을 처음 사용하는 개발자라면 한 가지 의문이 생길 수 있습니다: "왜 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을 사용하거나 조인 쿼리로 관계를 정의해야 합니다. 올바른 예시를 보겠습니다.

  1. 엔티티 정의
@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 // 외래 키로 관계 정의
)
  1. 관계 클래스 정의
data class UserWithPets(
    @Embedded val user: User,
    @Relation(
        parentColumn = "userId",
        entityColumn = "ownerId"
    )
    val pets: List<Pet>
)
  1. DAO 정의
@Dao
interface UserPetDao {
    @Transaction
    @Query("SELECT * FROM User WHERE userId = :id")
    fun getUserWithPets(id: Long): UserWithPets
}
  1. 사용법
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가 뭐예요?

@Embedded는 Room에서 데이터 클래스의 필드를 테이블에 "펼쳐서" 넣을 때 쓰는 어노테이션이에요. 예를 들어, User 객체를 다른 클래스 안에 넣고 @Embedded를 붙이면, 그 User의 필드들이 마치 한 테이블 안에 같이 있는 것처럼 매핑됩니다. 객체를 통째로 저장하는 게 아니라, 필드를 풀어서 테이블에 맞춰주는 거죠.

장점

  • SQL로 관계를 명시적으로 제어.

  • Room이 결과를 자동으로 매핑.

  • 유연한 쿼리 작성 가능.

마무리

Room이 객체 참조를 금지하는 이유는 성능, 데이터 일관성, 예측 가능성, 그리고 SQLite와의 철학적 일치를 지키기 위함입니다. 잘못된 예시처럼 객체를 직접 참조하면 문제를 일으킬 수 있지만, 외래 키와 @Relation 또는 조인 쿼리를 사용하면 안전하고 효율적으로 관계를 정의할 수 있습니다.

0개의 댓글