Tiny Star
본문 바로가기
Woowa Tech Course/Level 2

Room의 suspend vs Flow - 어느 스레드에서 실행될까?

by 별터 2026. 5. 19.

서론

지난 주 디노🦖 코치의 수업을 듣고 파생된 주제입니다.

Room 은 복잡한 쿼리 작성 없이 컴파일 타임에 오류를 잡아내고 로컬 데이터베이스를 안전하게 관리할 수 있게 해주는 라이브러리입니다.

일을 시킬 때 혼자서 다 하는 대신 남한테 나눠 시키는 것을 비동기 작업이라고 하는데요. 데이터베이스의 접근을 안전하게 하기 위해 비동기 처리를 할 때 Room 에서는 크게 suspend 방식과 Flow 방식이 있습니다.

 

그래서 저는 궁금했습니다.

"suspend 와 Flow 는 따로 처리를 해주지 않아도 이 키워드만 붙인다면 Room이 알아서 메인 스레드가 아닌 다른 스레드에서 해줄까? "

이번 글에서는 이 궁금증을 해결합니다.

 

본론

어떤 스레드에서 하는지 알기 전에 Dispatcher 의 개념을 조금 알아야 합니다.

Dispatcher 란 코루틴이 어떤 스레드(작업 공간)에서 일할지 결정하고 배정해 주는 작업 반장입니다.

즉 부서를 지정해준다고도 할 수 있겠습니다.

 

package woowacourse.shopping

import android.content.Context
import android.util.Log
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

// 1. 실험용 Entity
@Entity(tableName = "cart_items")
data class CartEntity(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val name: String = "우테코 미션 상품",
    val quantity: Int = 1
)

// 2. 실험용 Dao
@Dao
interface CartDao {
    @Query("SELECT * FROM cart_items")
    suspend fun getAll(): List<CartEntity>

    @Query("SELECT * FROM cart_items")
    fun observeAll(): Flow<List<CartEntity>>

    @Insert
    suspend fun insert(item: CartEntity)
}

// 3. 실험용 Database (In-Memory)
@Database(entities = [CartEntity::class], version = 1, exportSchema = false)
abstract class TestDatabase : RoomDatabase() {
    abstract fun cartDao(): CartDao
}

// 4. 검증을 위한 테스트 클래스 (한 파일 안에서 모두 실행)
@RunWith(AndroidJUnit4::class)
class RoomThreadTest {
    private lateinit var db: TestDatabase
    private lateinit var dao: CartDao

    // 실제 앱의 viewModelScope와 완전히 동일한 환경(Main 스레드) 구축
    private val mockViewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

    @Before
    fun setup() {
        // 테스트가 실행될 때마다 메모리에만 존재하는 가짜 DB를 생성 (앱 종료 시 증발)
        val context = ApplicationProvider.getApplicationContext<Context>()
        db = Room.inMemoryDatabaseBuilder(context, TestDatabase::class.java).build()
        dao = db.cartDao()
    }

    @After
    fun tearDown() {
        db.close()
    }

    @Test
    fun experiment1_suspend() {
        runBlocking {
            val latch = CountDownLatch(1) // 코루틴 대기용
            log("호출 시작 (메인 스레드 예상)")

            mockViewModelScope.launch {
                log("코루틴 시작")

                // suspend 함수 호출
                val items = dao.getAll()

                log("DAO.getAll() 반환 받음 (아이템 수: ${items.size})")
                log("uiState 업데이트 완료")
                latch.countDown()
            }

            log("launch 직후 (코루틴 시작과 무관하게 즉시 도달)")
            latch.await(3, TimeUnit.SECONDS)
        }
    }

    @Test
    fun experiment2_and_3_flow() {
        runBlocking {
            val latch = CountDownLatch(2) // Flow 초기 로드 1번 + Insert 후 감지 1번 = 총 2번 대기
            log("Flow 객체 받기 직전")

            val flow = dao.observeAll()
            log("Flow 객체 받음 — 스레드: ${currentThread()}")

            // Flow 구독 (onEach)
            flow.onEach { items ->
                log("[Flow emit] onEach 람다 실행 — 스레드: ${currentThread()}, 아이템 수: ${items.size}")
                latch.countDown()
            }.launchIn(mockViewModelScope)

            log("launchIn 직후 (collect는 별도 코루틴에서 시작됨)")

            // Flow가 구독될 시간을 잠깐 벌어줌
            delay(500)

            // DB 데이터 변경 트리거 (experiment 3 대응)
            mockViewModelScope.launch {
                log("[Insert 호출 전 - 스레드: ${currentThread()}]")
                dao.insert(CartEntity(name = "새로운 상품 추가"))
                log("[Insert 호출 후 - 스레드: ${currentThread()}]")
            }

            latch.await(5, TimeUnit.SECONDS)
        }
    }

    // ===== 헬퍼 함수 =====
    private fun log(msg: String) {
        // Logcat에서 'RoomThreadTest'로 검색하세요!
        Log.d("RoomThreadTest", "[${currentThread()}] $msg")
    }

    private fun currentThread(): String = Thread.currentThread().name
}

우선 테스트에 사용된 코드 블럭입니다. (제미나이가 도와줬습니다.)
여러분들도 build.gradle.kts 에 의존성을 추가하시고 androidTest 에 코드를 추가하신다면 확인 가능하십니다.

androidTestImplementation("androidx.test:core-ktx:1.5.0")

// JUnit과 안드로이드 테스트
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test:runner:1.5.2")

// @Test, @Before 등 JUnit4 어노테이션
androidTestImplementation("junit:junit:4.13.2")



위의 코드들을 간단하게 설명해보자면, 

같은 코드들을 suspend 로 했을 때와 Flow 로 받을 때의 스레드 확인입니다.

 

코드를 실행해보면 위와 같은 로그를 받을 수 있는데요.

Room이 알아서 IO 스레드에서 일함을 확인할 수 있습니다.

 

의문. 근데 `DAO.getAll()`은 왜 main 으로 찍혔을까요?

  • 뷰모델은 main 스레드에서 코루틴을 시작합니다. (viewModelScope)
  • `dao.getAll()`은 suspend 함수를 만나는 순간 IO 스레드로 넘어갑니다.
  • 작업을 다 끝내고 데이터를 가져오면, 그때 다시 main 스레드로 복귀하여 'uiState 업데이트 완료' 로그를 찍습니다.

만약 dao.getAll() 이 IO 스레드에서 작업을 하지 않았다면, 앱은 크래시가 났을 것입니다.

왜냐하면 안드로이드 시스템은 메인 스레드가 약 5초 이상 멈추면 ANR 에러를 내보내기 때문입니다. (데이터베이스를 뒤지거나 인터넷에서 데이터를 받아오는 작업은 언제 끝날지 모르는 무거운 작업입니다)

 

결론

1. Room 의 함수를 호출할 때 ViewModel 에서 굳이 Dispatchers.IO 로 스레드를 바꿀 필요가 없다.
Room이 알아서 백그라운드에서 작업하고, 결과를 받을 때는 다시 원래의 스레드로 돌아오기 때문입니다. (Main-Safe)

 

2. Room에서 반환하는 Flow를 메인 스레드(viewModelScope)에서 구독(collect)하면, UI 업데이트를 안전하게 할 수 있다.

 

3. suspend 는 한 번의 비동기, Flow 는 지속적인 비동기
캡처본을 보면 Flow 를 썼던 로직은 Insert 로 데이터의 값이 바꼈을 때 자동으로 변경된 데이터를 받아옵니다.

 


 

제가 AI 설명을 듣고 정리한 suspend 와 Flow 의 비유는

 

suspend = 우물에서 물 한 바가지 떠오기

- 부탁하면 백그라운드에서 물을 떠오고 물을 다 뜬다음에야 전달해줍니다.

- 한 번 부탁하면 끝입니다. 물론 또 부탁할 수 있습니다.

 

Flow = 물 없는 수도꼭지 받아오기 + 트는 행위

- 받아오기 자체는 바로 진행됩니다. (물은 없습니다)

- collect 로 꼭지를 열어두면 그때부터 흐를 준비를 합니다. (이때도 물이 없으면 흐르지 않습니다.)

- 위에서 누가 물을 부으면 꼭지를 열어뒀으니 물이 바로 흘러 들어옵니다.

- 이는 잠그기 전까지 계속 됩니다.