(Android/Room) Room Transaction — 원자적 DB 작업


1. 트랜잭션이란?

여러 DB 작업을 전부 성공하거나 전부 취소되도록 묶는 단위입니다 (원자성).

계좌 이체: 출금 + 입금

트랜잭션 없음:  출금 성공 → (앱 크래시) → 입금 실패  →  돈이 사라짐
트랜잭션 있음:  출금 성공 → 입금 실패 → 전체 롤백   →  원래 상태 유지

2. DAO의 @Transaction

여러 작업을 하나의 DAO 함수로 묶습니다.

@Dao
abstract class TransferDao {          // 본문이 있는 함수를 쓰려면 abstract class (또는 interface의 default)

    @Query("UPDATE accounts SET balance = balance - :amount WHERE id = :id")
    abstract suspend fun withdraw(id: Long, amount: Long)

    @Query("UPDATE accounts SET balance = balance + :amount WHERE id = :id")
    abstract suspend fun deposit(id: Long, amount: Long)

    @Transaction                       // 이 함수 전체가 하나의 트랜잭션
    open suspend fun transfer(from: Long, to: Long, amount: Long) {
        withdraw(from, amount)
        deposit(to, amount)            // 여기서 예외가 나면 withdraw도 롤백
    }
}

interface DAO에서는 본문 있는 함수도 선언할 수 있습니다.

@Dao
interface OrderDao {
    @Insert suspend fun insertOrder(order: Order): Long
    @Insert suspend fun insertItems(items: List<OrderItem>)

    @Transaction
    suspend fun placeOrder(order: Order, items: List<OrderItem>) {
        val orderId = insertOrder(order)
        insertItems(items.map { it.copy(orderId = orderId) })
    }
}

3. 여러 DAO를 묶는 — withTransaction

서로 다른 DAO의 작업을 하나로 묶을 때는 withTransaction을 사용합니다.

class OrderRepository @Inject constructor(
    private val db: AppDatabase
) {
    suspend fun checkout(order: Order, items: List<OrderItem>) {
        db.withTransaction {                         // room-ktx 필요
            val id = db.orderDao().insertOrder(order)
            db.orderItemDao().insertAll(items.map { it.copy(orderId = id) })
            db.stockDao().decrease(items)            // 재고 차감
            db.cartDao().clear()                     // 장바구니 비우기
        }
    }
}

4. 롤백 규칙

블록 안에서 예외가 밖으로 던져지면 롤백됩니다.

try {
    db.withTransaction {
        db.accountDao().withdraw(1, 1000)
        if (db.accountDao().balance(1) < 0) {
            throw InsufficientBalanceException()     // 예외 → 전체 롤백
        }
        db.accountDao().deposit(2, 1000)
    }
} catch (e: InsufficientBalanceException) {
    // 롤백 완료된 상태: DB는 변경 전
}

트랜잭션 안에서 예외를 try/catch로 삼키면 롤백되지 않고 그대로 커밋됩니다. 롤백하려면 예외를 다시 던져야 합니다.


5. 코루틴 사용 시 주의

db.withTransaction {
    val user = db.userDao().find(id)

    // ❌ 트랜잭션 안에서 네트워크 호출 — DB 연결을 오래 점유해 다른 쿼리가 대기
    val remote = api.fetchProfile(id)
    db.userDao().update(user.copy(name = remote.name))
}

// ✅ 네트워크 호출은 트랜잭션 밖에서 먼저
val remote = api.fetchProfile(id)
db.withTransaction {
    val user = db.userDao().find(id)
    db.userDao().update(user.copy(name = remote.name))
}

6. 성능 이점 — 대량 삽입

트랜잭션 없이 1000건을 하나씩 insert하면 매번 디스크에 커밋합니다. 하나로 묶으면 커밋이 한 번뿐이라 훨씬 빠릅니다.

// 느림: insert 호출마다 커밋
items.forEach { dao.insert(it) }

// 빠름: List insert는 내부적으로 하나의 트랜잭션
dao.insertAll(items)

// 여러 종류의 쓰기를 묶을 때
db.withTransaction { items.forEach { dao.insert(it) } }

7. @Transaction이 필요한 곳 — 읽기도 포함

@Relation을 쓰는 쿼리는 여러 쿼리가 실행되므로 읽기에도 @Transaction이 필요합니다.

@Transaction
@Query("SELECT * FROM users")
fun observeUsersWithPosts(): Flow<List<UserWithPosts>>

일관된 스냅샷으로 읽지 않으면, 부모를 읽은 뒤 자식을 읽는 사이에 데이터가 바뀌어 불일치한 결과가 나올 수 있습니다.


8. 정리



Related Posts