(Android/Room) Room Relation — one-to-many, many-to-many


1. Room은 객체 간 참조를 직접 저장하지 않는다

Room 엔티티는 다른 엔티티를 필드로 가질 수 없습니다. 대신 외래 키(ID) 로 연결하고, 조회 시 @Relation으로 합쳐서 가져옵니다.

// ❌ Room은 엔티티 안에 다른 엔티티 리스트를 저장할 수 없음
@Entity
data class User(val id: Long, val posts: List<Post>)

2. 일대다 (One-to-Many)

사용자 한 명이 여러 게시글을 가지는 경우입니다.

@Entity(tableName = "users")
data class User(
    @PrimaryKey val userId: Long,
    val name: String
)

@Entity(
    tableName = "posts",
    foreignKeys = [ForeignKey(
        entity = User::class,
        parentColumns = ["userId"],
        childColumns = ["authorId"],
        onDelete = ForeignKey.CASCADE      // 사용자 삭제 시 게시글도 삭제
    )],
    indices = [Index("authorId")]           // 외래 키에는 인덱스 권장 (조인 성능)
)
data class Post(
    @PrimaryKey val postId: Long,
    val authorId: Long,                     // User와 연결되는 외래 키
    val title: String
)

조회용 클래스(엔티티가 아닌 데이터 클래스)를 만듭니다.

data class UserWithPosts(
    @Embedded val user: User,               // 부모 컬럼을 그대로 포함
    @Relation(
        parentColumn = "userId",            // 부모의 키
        entityColumn = "authorId"           // 자식의 외래 키
    )
    val posts: List<Post>
)

@Dao
interface UserDao {
    @Transaction                            // 두 번의 쿼리를 일관되게 실행
    @Query("SELECT * FROM users")
    fun observeUsersWithPosts(): Flow<List<UserWithPosts>>
}

@Relation은 내부적으로 쿼리 두 번(부모 조회 → 자식 조회)을 실행하므로 @Transaction을 붙여 중간에 데이터가 바뀌어도 일관되게 읽게 합니다.


3. 일대일 (One-to-One)

data class UserWithProfile(
    @Embedded val user: User,
    @Relation(parentColumn = "userId", entityColumn = "ownerId")
    val profile: Profile?                   // 단일 객체 (없을 수 있으면 nullable)
)

4. 다대다 (Many-to-Many)

학생과 수업처럼 양쪽 모두 여러 개를 가지는 관계는 연결(교차) 테이블이 필요합니다.

@Entity(tableName = "students")
data class Student(@PrimaryKey val studentId: Long, val name: String)

@Entity(tableName = "courses")
data class Course(@PrimaryKey val courseId: Long, val title: String)

// 연결 테이블: 두 키를 복합 기본 키로
@Entity(
    tableName = "student_course",
    primaryKeys = ["studentId", "courseId"],
    indices = [Index("courseId")]
)
data class StudentCourseCrossRef(
    val studentId: Long,
    val courseId: Long
)
// 학생 → 수강 과목들
data class StudentWithCourses(
    @Embedded val student: Student,
    @Relation(
        parentColumn = "studentId",
        entityColumn = "courseId",
        associateBy = Junction(StudentCourseCrossRef::class)   // 연결 테이블 지정
    )
    val courses: List<Course>
)

// 반대 방향: 과목 → 수강 학생들
data class CourseWithStudents(
    @Embedded val course: Course,
    @Relation(
        parentColumn = "courseId",
        entityColumn = "studentId",
        associateBy = Junction(StudentCourseCrossRef::class)
    )
    val students: List<Student>
)

Junction에 연결 테이블을 지정하면 Room이 조인을 처리합니다. 양방향 조회는 각각 별도 클래스로 만듭니다.


5. 저장은 직접 — Room이 자동 삽입하지 않는다

관계 객체를 한 번에 insert하는 기능은 없습니다. 부모/자식/연결 테이블을 각각 저장합니다.

@Dao
interface SchoolDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertStudent(student: Student)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertCourse(course: Course)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun enroll(ref: StudentCourseCrossRef)

    @Transaction
    suspend fun enrollStudent(student: Student, courses: List<Course>) {
        insertStudent(student)
        courses.forEach {
            insertCourse(it)
            enroll(StudentCourseCrossRef(student.studentId, it.courseId))
        }
    }
}

6. 중첩 관계

@Relation의 대상 클래스가 다시 관계 클래스일 수 있습니다.

data class PostWithComments(
    @Embedded val post: Post,
    @Relation(parentColumn = "postId", entityColumn = "postOwnerId")
    val comments: List<Comment>
)

data class UserWithPostsAndComments(
    @Embedded val user: User,
    @Relation(
        entity = Post::class,               // 중간 엔티티를 명시
        parentColumn = "userId",
        entityColumn = "authorId"
    )
    val posts: List<PostWithComments>
)

7. 성능 주의


8. 정리



Related Posts