Room에서 CASCADE ON DELETE + REPLACE(=DELETE + INSERT) 의 충돌시 전략 정리
Room 데이터베이스를 사용하다 보면, 두 테이블을 Foreign Key 로 연결해 참조 무결성(referential integrity) 을 유지하고 싶고, 동시에 Row 업데이트 시 CASCADE 를 통해 데이터 일관성을 유지하고 싶은 상황이 자주 생긴다.
하지만 실제로 구현해보면 다음과 같은 문제가 생긴다.
문제: CASCADE ON DELETE + REPLACE(=DELETE + INSERT) 의 충돌
Room에서 @Insert(onConflict = REPLACE) 는 내부적으로 "DELETE → INSERT" 로 동작한다.
이 때문에:
- 부모 테이블에서 REPLACE 로 데이터를 삽입
- 내부적으로 기존 row DELETE
- CASCADE DELETE 발동 → 자식 테이블 row 모두 삭제됨
- 이후 INSERT 로 새로운 부모 row 삽입
- 하지만 자식 row는 이미 날아감
즉, 업데이트하려고 했을 뿐인데 자식 테이블까지 의도치 않게 삭제되는 문제가 발생한다.
CASCADE 를 없애면 해결될까? → Dangling Reference 발생 가능
CASCADE 를 제거하면 REPLACE 시 자식 row 삭제 문제는 해결되지만 이번엔 다른 문제가 생긴다.
부모 row 삭제 시 자식 row가 그대로 남아 있어 dangling reference(존재하지 않는 부모를 참조하는 상태) 가 될 수 있다는 것.
예:
Impression.popupId = 10 // parent
CmsPopupData.id = 10 삭제됨
이 경우 자식 데이터는 더 이상 유효하지 않은 부모를 가리키는 “유령 참조” 상태가 된다.
그렇다면 최종적으로 가장 많이 사용하는 해결 전략은?
실무에서 가장 안전하고 많이 사용하는 방식은 다음과 같다.
방법 1) CASCADE 삭제 + UPDATE 기반 UPSERT 사용 (가장 안정적)
핵심은:
- CASCADE 는 유지해서 dangling reference 를 방지
- REPLACE 는 쓰지 않고
- UPDATE + INSERT 조합으로 직접 UPSERT 구현
이 방식은 Jetpack 패턴과 많은 Android 팀이 실제로 사용하는 패턴이다.
장점
- CASCADE 정상 동작 (부모 삭제 시 자식 자동 삭제)
- REPLACE 로 인한 unintended 삭제 문제를 해결
- 부모 업데이트 시 DELETE 없으므로 CASCADE 발동 없음
- 데이터 무결성 + 참조 무결성 모두 보장
단점
- 코드가 조금 늘어남
- upsert 로직 직접 구현해야 함
예시 코드
@Dao
interface CmsPopupDataDao {
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(data: CmsPopupData)
@Update
suspend fun update(data: CmsPopupData): Int
@Transaction
suspend fun upsert(data: CmsPopupData) {
val updated = update(data)
if (updated == 0) {
insert(data)
}
}
}
방법 2) CASCADE 제거 + REPLACE 사용, 단 자식 무결성은 앱 로직에서 보장
(캐시/임시 테이블에서 많이 씀)
특정 도메인에서는 dangling reference 가 ‘문제가 안 되는 경우’도 있다.
예:
- 로그 테이블
- 캐시 테이블
- 임시 데이터
이 경우는 CASCADE 없이 REPLACE 써도 괜찮다.
즉, 앱 로직에서 dangling reference가 생기지 않도록 parent db에서 REPLACE가 생기는 경우 child db의 값을 삭제해준다.
결론
- 가장 많이 쓰는 방법은 1번 “CASCADE 유지 + REPLACE 제거 + UPDATE 기반 UPSERT”이지만 이 방법 자체의 구현 코스트도 무시하기 어렵기때문에 실제 DB의 사용되는 데이터의 경중에 따라 2번도 자주 사용한다.
- 부모 row 삭제 시 자식 row도 같이 삭제해주는 방식도 많이 사용한다.