안드로이드 Android

안드로이드 개발 (44) Room @Transaction 에 대해 알아보자

SEOBI서비 2025. 4. 20. 22:51

로컬 데이터베이스에 대해 마스터를 해보자

@Upsert로 INSERT 또는 UPDATE 한 줄에 사용법

@Upsert               // Room 2.7.0+
suspend fun save(user: User): Long

 


위와 같이 편리하게 사용하는 법이 있습니다. 

    • 동작 원리:  Room이 생성한 EntityUpsertionAdapter가 내부적으로 기본 키(PK)나 고유 제약조건(unique)  예외가 없으면 INSERT, 예외 발생 시 (SQLiteConstraintException) UPDATE를 실행합니다.  그래서 같은 트랜잭션 안에서 UPDATE.. WHERE pk = ? 실행 합니다.

Insert 가 실행된 경우 리턴 값은 row id ,  Update 가 실행된 경우 -1 입니다. 성공 여부나 업데이트된 컬럼 수를 알기 위해서는 추가 쿼리가 필요합니다.

  
    @Upsert
    suspend fun save(user: User): Long

 
    @Query("SELECT changes()")
    suspend fun changes(): Int

    /**
     *  하나의 트랜잭션으로 묶어야 같은 커넥션에서 실행됨
     *  changes() 결과가 save()의 영향 행 수를 정확히 반환
     */
    @Transaction
    suspend fun saveAndGetAffected(user: User): Int {
        save(user)
        // INSERT면 1, UPDATE면 1, WHERE 조건 불일치면 0
        return changes()     
    }

 

Room DataBase 인스턴스는 기본적으로 커넥션 풀을 들고 있고, DAO 메서드를 호출할 때마다 거기서 빈 커넥션 하나를 빌려 쿼리를 실행합니다. 그래서 위와 같이 트랜잭션으로 하나의 커넥션으로 묶어서 안전하게 사용할 수 있습니다.
(추가 설명으로 changes() 는 현재 커넥션에서 직전에 실행된 삽입/수정/삭제가 영향을 준 행 수를 돌려줍니다. )

묶다.

트랜잭션(Transaction) 이란?

- 여러 SQL 작업을 “한 덩어리”로 묶어  전부 성공 → 모두 반영, 하나라도 실패 → 전부 취소(rollback) 되도록 보장하는 기능입니다.

 

이해를 쉽게 하기 위해서 SQL로 예시를 들었습니다.


예시. 주문과 재고를 동시에 수정

[트랜잭션 X 일 경우]

-- 트랜잭션 없이 실행
INSERT INTO orders(id, item_id, qty) VALUES (1001, 42, 1);

-- 어플리 케이션 예외!!!
-- 아래 UPDATE는 실행되지 못함
UPDATE stock SET qty = qty - 1 WHERE item_id = 42;

 

orders 테이블에 insert 한 구문만 실행되서 DB에 반영 됩니다.

 

[트랜잭션 O 일 경우]

-- BEGIN 이 트랜잭션 사용 입니다.
BEGIN;                                       
INSERT INTO orders(id, item_id, qty) VALUES (1001, 42, 1);

-- 어플리케이션 에러 발생!!
UPDATE stock SET qty = qty - 1 WHERE item_id = 42;

-- 성공시 반영, 에러 일땐 자동 ROLLBACK
COMMIT;

 

중간에 어플리케이션이 발생 했기 때문에 BEGIN; 아래에 있던 모든 구문은 실행이 되지 않고 DataBase에 변경사항이 없습니다. 즉 order 테이블에 값을 Insert  하는 구문이 실행되지 않습니다.

 

핵심 속성은 4가지가 있습니다. 첫번째 Atomicity(전부 성공 또는 전부 실패한다.), 두번째 Consistency(트랜잭션 전후에 DB 제약조건(무결성)이 유지된다.), 세번째 lsolation(동시에 실행되는 트랜잭션끼리 간섭 최소화), 네번째 Durability (커밋된 내용은 OFF 후에도 유지된다.)

안드로이드의 SQLite 특성으로 인해 주의할 점이 있다.

안드로이드 Room 트랜 잭션 (Transaction)

db.beginTransaction()
try {
    dao.insertOrder(order)
    dao.insertOrderItems(items)

    // 트랜 잭션 성공
    db.setTransactionSuccessful() 
} finally {
	// 성공 플래그 있으면 COMMIT, 없으면 ROLLBACK
    db.endTransaction()             
}

 

안드로이드 Room 에서 메서드에 @Transaction 을 사용하면 사실상 SQLite 에서 위와 같이 이루어집니다. Room이 생성한 구현체가db.beginTransaction() → 메서드 본문(또는 SELECT 쿼리) 실행 → 예외 없으면 setTransactionSuccessful() → endTransaction() 순서로 처리합니다. 메서드 안에서 발생한 예외가 전파되면 전부 롤백됩니다. Room에서는 위 패턴을 @Transaction 애너테이션 하나로 감싸서 자동으로 처리합니다.

 

하나의 커넥션?


"트랜잭션을 사용하면 하나의 커넥션을 잡는다. " 라는 뜻

//androidx.sqlite:sqlite 
public interface SQLiteConnection

 

커넥션(connection)은 SQLite 엔진 사이의 통신 채널을 뜻합니다. 안드로이드 내부 클래스는 SQLiteConnection(C 레벨 핸들 + 잠금 정보)로 사용 됩니다. Room DataBase 인터페이스는 커넥션 풀을 들고 있습니다. DAO 메서드를 호출 할때 마다 거기서 빈 커넥션 하나를 빌려서 쿼리를 실행합니다.

 

즉, 트랜잭션(@Transaction) 을 선언하면

  1. Room이 db.beginTransaction()을 호출하며 하나의 커넥션을 잡습니다.
  2. 메서드 본문(여러 DAO 호출 포함)을 모두 그 커넥션에서 실행됩니다.
  3. 끝나면 위에서 언급한대로 setTransactionSuccessful() → endTransaction() → 커밋 또는 롤백
    따라서 쿼리·DML이 한 커넥션·한 트랜잭션 안에서 원자적으로 실행되는 것 입니다.

요약하자면 

  • 커넥션 = DB와 대화하는 파이프(여러 개 존재 가능).
  • @Transaction = 한 파이프를 잠그고 그 안에서 모든 쿼리를 처리 → 원자성 + 동일 커넥션 보장

 

안드로이드 SQLite는 하나의 DB 파일에 대해 여러 커넥션을 동시에 열수 있습니다. 

Writer INSERT/UPDATE/DELETE 실행 시 단일로 lock 획득 1개
Reader SELECT 전용, 병행 읽기 최대 3개 (구버전은 2)
합계 한 앱 프로세스 안에서 1 ~ N개 4 개 내외

 

아래와 같이 Write는 커넥션을 1개, Reader 는 최대 3개까지 가지고 있습니다. 하나의 앱 프로세스 안에서 4개 내외로 여러 커넥션이 동시에 연결 될 수 있습니다.

@Dao
interface PersonDao {
    @Query("UPDATE person SET age = age + 1 WHERE id = :id")
    suspend fun inc(id: Long)

    @Query("SELECT changes()")
    suspend fun chg(): Int

    /** 트랜잭션 없음 */
    suspend fun incAndGetWrong(id: Long): Int {
    	// 커넥션 A
        inc(id)          
        
        // 커넥션 A or B (커넥션이 달라질 가능성 있음 결과 1 or 0)
        return chg()     
    }
}

 

위와 같이 트랜잭션을 안붙이고  "Select changes()"   테스트를 하게 되면 처음 Update 할때 Writer 가 A 커넥션에서 실행됬지만 곧바로 이어진 "Select changes()" 가 커넥션 B에서 실행 될 수 있어서 개발자가 예측한 결과랑 다를 수 있습니다.

그래서 Room에서 트랜잭션을 썼다고 해서 DB 접근을 '싱글스레드'로 하는 것이 아니고, 트랜잭션 구간 동안에는 다른 write가 잠시 대기한다 정도로 이해하면 됩니다.

 

두 군데에서 동시에 트랙잭션(Transaction)을 사용한다면 일어나는 일

위 설명 기반으로 안드로이드 SQLite 큐칙상 write 1개, read 3개 커넥션 연결이 가능 함으로 아래 표와 같은 결과가 생깁니다.  

① 두 트랜잭션 모두 쓰기
(INSERT·UPDATE·DELETE 포함)
-실제 동작-
SQLite는 “동시 writer 1명” 규칙.
첫 번째 트랜잭션이 writer 락을 잡고 진행 → 두 번째 트랜잭션은 대기.
락 대기 시간이 busy_timeout(기본 5 s, Room에서 0 s) 를 넘기면 SQLiteBusyException(database is locked) 발생.
-보이는 현상-
• 첫 번째 트랜잭션이 금방 끝나면 문제없음.
• 길어지면 두 번째 호출 스레드/코루틴이 멈칫하거나 BUSY 예외가 터짐.
② 읽기(SELECT) × 쓰기 -실제 동작-
WAL 모드(안드 9+ Room 기본)에서는 동시 읽기 OK.
쓰기 트랜잭션이 커밋 직전 WAL → DB 파일 합치기 단계에서 잠깐 읽기도 BLOCK.
-보이는 현상-
SELECT가 아주 짧게 지연될 수 있지만 대부분 체감 안 됨.
③ 둘 다 읽기 전용 트랜잭션 -실제 동작-
제한 없음. reader 커넥션이 3개라면 최대 3곳에서 병렬 SELECT.
-보이는 현상-
정상 동시 실행.
④ 같은 스레드·코루틴 안에서 중첩(@Transaction ➜ 내부 @Transaction) -실제 동작-
Room이 “이미 열린 트랜잭션에 합류” → 실제로는 하나의 트랜잭션으로 취급되어 원자성 보장.
-보이는 현상-
중첩이라도 BUSY 문제 없음.

 

핵심: SQLite는 원래 “한 순간에 writer 1명” 모델이므로, 트랜잭션을 두 군데서 동시에 열어도 직렬(Queue) 처리될 뿐입니다. 만약 버벅임이 있다면, 버벅임 여부는 첫 트랜잭션이 얼마나 오래 writer 락을 잡느냐에 달려 있습니다.

트랜잭션 (Transaction) 사용시 주의할 점 

트랜잭션 장기간 점유

안드로이드 SQLite 는 동시 write 1명 규칙으로 인해서 트랜잭션이 write 커넥션을 오래 점유하면 그 시간 동안 새로 들어온 write 작업들은 락이 풀릴때까지 대기합니다. 
- Update 작업용 코루틴이 길게 블로킹 될 수 있습니다.

- "database is locked" 로그가 발생하기도 합니다.

 

트랜잭션 주의사항

1. 트랜작션은 짦고 굵게 쓰는것이 중요합니다. DB I/O or 네트워크 or Cpu가 무거울만한 작업 등 이러한 작업들은 @Transaction 사용을 고려하는것이 중요해보입니다.

2. 잦은 단건 INSERT를 메모리 큐에 모아서 50~100건씩 한 트랜잭션 처리 (또한, Room 에서 "@Insert fun insert(list: List<Order>)" 을 만들어도 자동으로 트랜잭션 처리가 되긴 합니다.)

3. busy_timeout 설정을 통해서 한 트랜잭션이 writer 락을 오래 잡고 있어도 SQLiteBusyException 가 곧바로 발생하지 않고 대기 할 수 있도록 시간을 설정 해두는 것도 하나의 방법 입니다. 

- busy_timeout 이란? SQLite 가 lcok 에 걸려서 SQLITE_BUSY("database is locked) 를 내보내려 할 때, 그 예외를 던지지 않고 최대한 설정한 시간(ms) 동안 lock 해제를 기다렸다가, 그래도 풀리지 않으면 그때 예외를 던지게 할 수 있습니다.

@Database(entities = [Order::class, Stock::class], version = 1)
abstract class AppDb : RoomDatabase() {

    companion object {
        fun build(context: Context) =
            Room.databaseBuilder(context, AppDb::class.java, "app.db")
                .addCallback(object : Callback() {
                    override fun onConfigure(db: SupportSQLiteDatabase) {
                        // 3초 동안 BUSY 대기
                        db.execSQL("PRAGMA busy_timeout = 3000;")
                    }
                })
                .build()
    }
}



이상 Android 트랜잭션에 대해서 상세히 알아봤습니다~