본문 바로가기

Android

RoomDB 활용 방법과 Coroutine 사용

이번 포스팅에서는 단어장앱-1(RecyclerView)의 후속으로 직접 데이터베이스를 관리하는 로직(RoomDB)과

데이터를 활용했을때 워커스레드에서 작업하는 방식으로 Coroutine을 통한 처리를 말씀드리겠습니다.

 

RoomDB를 먼저 알아보기 위해서 다음 일련의 과정을 진행할 것입니다.

1) build.gradle(app)에서 room.runtime, room.ktx와 같은 필요한 라이브러리를 추가합니다.

2) Model로 data class를 만드는데 객체 형태로 주고받을 수 있도록 Parcelable로 선언해둡니다.

3) Dao를 만들어 쿼리를 구현합니다.

4) Database를 만들고 Entities를 셋팅합니다.  (Entity는 테이블과 같은 느낌으로 data class랑 연결해둡니다.)

5) 데이터베이스 생성을 동기적으로 수행하고 싱글톤 형태로 구현해 작업 수행합니다.

6) AppDatabase를 호출하면서 인스턴스로 DB를 가지고와서 각 엔터티에(현재는 word 하나) 접근하고

wordDao에 구현된 쿼리문을 호출하여 데이터를 처리합니다.

 


1) build.gradle(app) 

plugins {
    alias(libs.plugins.androidApplication)
    alias(libs.plugins.jetbrainsKotlinAndroid)
    id("kotlin-kapt")
    id("kotlin-parcelize")
    id("com.google.devtools.ksp") version "1.8.10-1.0.9" apply false
}


dependencies {

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.appcompat)
    implementation(libs.material)
    implementation(libs.androidx.activity)
    implementation(libs.androidx.constraintlayout)
    implementation(libs.androidx.room.ktx)
    kapt(libs.androidx.room.compiler)
    implementation(libs.androidx.androidx.room.gradle.plugin)

    annotationProcessor(libs.androidx.room.runtime)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
}

 

[libs.versions.toml]

[versions]
agp = "8.3.0"
androidxRoomGradlePlugin = "2.6.1"
kotlin = "1.9.0"
coreKtx = "1.13.1"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
appcompat = "1.7.0"
material = "1.12.0"
activity = "1.8.0"
constraintlayout = "2.1.4"
roomDB ="2.6.1"
roomKtx = "2.6.1"


[libraries]
androidx-androidx-room-gradle-plugin = { module = "androidx.room:androidx.room.gradle.plugin", name="roomDB", version.ref = "androidxRoomGradlePlugin" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-room-compiler = { module = "androidx.room:room-compiler", name="roomDB", version.ref = "androidxRoomGradlePlugin" }
androidx-room-runtime = { module = "androidx.room:room-runtime", name="roomDB", version.ref = "androidxRoomGradlePlugin" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "roomKtx" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

 

이렇게 room과 관련된 라이브러리를 디펜던시에 추가하고, libs.versions.toml을 통해 버전 관리 등 처리가 가능합니다.

그 다음 sync를 진행해서 의존성 설치 등 작업을 수행한 다음 코드로 넘어가겠습니다.

참고로 Plugins에 kotlin-parcelize를 선언해두었는데, 이 부분도 중요합니다.

모델 셋팅할 때 설명을 하겠지만, 직렬화/역직렬화에 효율성을 높인 Parcelize는 @ 태그를 통해 data class에서 

선언되는 구조입니다. (Serialize & Parcelable 장점을 모았습니다.)

모델(data class)을 토대로 리스트 안에 하나의 객체를 만들때 하나의 소포 형태로 직렬적으로 객체 그 자체로 

입력받는 방법을 사용할 때 쓰며, 주생성자에 멤버변수를 두냐 안두냐의 차이에 의해 구현부가 달라지게 됩니다.

자세한 설명은 2번에서 진행하겠습니다.

 


2) Model로 사용할 data class 만들기 (+Parcelable)

package com.example.voca_book.models

import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Query
import kotlinx.parcelize.Parcelize

// data class -> 상속 불가능
// toString, hashcode, equals,

@Parcelize
@Entity(tableName = "word")
data class Word(
    val text : String,
    val mean : String,
    val type : String,
    // 주된 키
    @PrimaryKey(autoGenerate = true) val id: Int = 0, // 자동생성 -> id

) : Parcelable  // kotlin-parcelize : 직렬, 역직렬화

 

이렇게 data class 위에 @Parcelize를 선언하여 kotlin-parcelize를 활용합니다.

직렬/역직렬화 사용하는 방법으로 해당 객체를 하나의 소포 형태로 주고 받으면서 사용할 수 있습니다.

예를 들어, Intent로 객체를 보냈을 때 getParcelable로 받을때 활용됩니다.

Word라는 모델(data  class)은 Entity로 table로 불립니다.  이는 접근할 때  word라는 테이블 이름으로 접근합니다.

또한 PK로 id를 받으며 자동 생성 되도록 셋팅해두었습니다.

이 엔터티에 접근할 때 주생성자가 아닌 부생성자로 접근할 경우 companion object를 통해 Parceler를 상속받아

처리하면 됩니다.   주 생성자를 활용한다면 더 간단히 구현할 수 있는 부분이기에 이 정도로 이해해도 됩니다.

@Parcelize
@Entity(tableName = "word")
data class Word (
   val text: String,
   val mean: String,
   val type: String
   @PrimaryKet(autoGenerate = true) val id: Int = 0,
) : Parcelable {
   companion object : Parceler<Word> {
      override fun create(parcel: Parcel): Word {
         val text = parcel.readString() ?: ""
         val mean = parcel.readString() ?: ""
         val type = parcel.readString() ?: ""
         val id = parcel.readInt()
         return Word(text, mean, type, id)
      }
      
      override fun Word.write(parcel: Parcel, flags: Int){
         parcel.writeString(text)
         parcel.writeString(mean)
         parcel.writeString(type)
         parcel.writeInt(id)
      }
   }
}

 

앞서 주생성자만을 이용한 방식과 Parceler 인터페이스를 직접 구현하는 방식의 차이는 

@Parcelize를 통해 컴파일러가 알아서 Parcelable로 인식하여 처리가 되고 보다 간편하기에 주생성자만 사용한 방식이

일반적이고 많이 쓰이긴 합니다.

다만, Parceler 인터페이스를 직접 구현하는 방식은 전자보다 더 많은 제어를 할 수 있습니다.

후자의 경우 아래와 같은 것을 더 제어할 수 있습니다.

1) 복잡한 데이터 구조의 직렬화/역직렬화
- 중첩된 객체나 컬렉션(리스트, 맵 등)을 더 세밀하게 직렬화하고 역직렬화 할 수 있습니다.
2) 데이터 변환
- 데이터를 압축하거나 암호화하여 저장하고 복원할 때 활용할 수 있습니다.
3) 에러 처리
- 직렬화/역직렬화 과정에서 발생할 수 있는 예외 상황(예: Parceler에서 null 값이나 예상치 못한 데이터 형식 처리 로직)을 
  더 안전하게 처리할 수 있습니다.
4) 호환성 유지
- 데이터 구조가 변경되었을 때, 이전 버전과의 호환성을 유지하기 위해 조건부 로직을 추가할 수 있습니다.
5) 추가 메타데이터 저장
- 기본 데이터 외에 추가 메타데이터를 저장하고 복원할 수 있습니다. (예: 데이터의 출처, 생성시간 등을 저장/복원 가능)

 

* 복잡한 데이터 구조의 직렬화/역직렬화

@Parcelize
@Entity(tableName = "word")
data class Word (
   val text: String,
   val mean: String,
   val type: String,
   val synonyms: List<String>,
   @PrimaryKet(autoGenerate = true) val id: Int = 0,
) : Parcelable {
   companion object : Parceler<Word> {
      override fun create(parcel: Parcel): Word {
         val text = parcel.readString() ?: ""
         val mean = parcel.readString() ?: ""
         val type = parcel.readString() ?: ""
         val synonyms = mutableListOf<String>().apply {
            parcel.readStringList(this)
         }
         
         val id = parcel.readInt()
         return Word(text, mean, type, synonyms, id)
      }
      
      override fun Word.write(parcel: Parcel, flags: Int){
         parcel.writeString(text)
         parcel.writeString(mean)
         parcel.writeString(type)
         parcel.writeStringList(synonyms)
         parcel.writeInt(id)
      }
   }
}

 

* 에러처리

import android.os.Parcel
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
@Parcelize
@Entity(tableName = "word")
data class Word(
    val text: String,
    val mean: String,
    val type: String,
    @PrimaryKey(autoGenerate = true) val id: Int = 0
) : Parcelable {
    companion object : Parceler<Word> {
        override fun create(parcel: Parcel): Word {
            val text = parcel.readString() ?: throw IllegalArgumentException("Text cannot be null")
            val mean = parcel.readString() ?: throw IllegalArgumentException("Mean cannot be null")
            val type = parcel.readString() ?: throw IllegalArgumentException("Type cannot be null")
            val id = parcel.readInt()
            return Word(text, mean, type, id)
        }

        override fun Word.write(parcel: Parcel, flags: Int) {
            parcel.writeString(text)
            parcel.writeString(mean)
            parcel.writeString(type)
            parcel.writeInt(id)

        }
    }
}

 

이 정도로 Parcelize에 대해서 알아보고 이어서 더 진행하겠습니다.

 


3) Dao를 만들어 쿼리를 구현합니다.

Dao는 인터페이스로 내부에 구현될 메소드를 선언만 하는데, @Insert, @Delete, @Update, @Query 등을 활용하기에

이를 실제 구현하는 곳에서는 함수 호출만으로 처리가 가능하단 장점이 있습니다.

@Dao
interface WordDao {
   @Query("SELECT * from word ORDER BY id DESC")
   fun getAll(): List<Word>
   
   @Query("SELECT * from word ORDER BY id DESC LIMIT 1")
   fun getLatestWord(): List<Word>
   
   @Insert
   fun insert(word: Word)
   
   @Delete
   fun delete(word: Word)
   
   @Update
   fun update(word: Word)
}

 

Dao에서 쿼리문을 구현하도록 하며 이는 이제 AppDatabase라는 추상화된 클래스에서 실제로

데이터베이스가 생성되어 있다면 바로 WordDao에 접근해서 쿼리문이 수행하도록 할 것이며, 그게 아니라면

데이터베이스를 생성하고 나서 WordDao에 접근 후 쿼리문이 수행되도록 할 것입니다.

 

[AppDatabase.kt]

@Database(entities = [Word::class], version = 1)
abstract class AppDatabase: RoomDatabase() {
  abstract fun wordDao(): WordDao
  companion object {
     private var INSTANCE: AppDatabase? = null
     fun getInstance(context: Context): AppDatabase? {
        if(INSTANCE == null){
           synchronized(AppDatabase::class.java){
              INSTANCE = Room.databaseBuilder(
                 context.applicationContext,
                 AppDatabase::Class.java,
                 "app-database.db"
              ).build()
           }
        }
        return INSTANCE
     }
  }
}

 

우선 @Database에서부터 봐서 알겠지만 데이터베이스를 생성하는 작업으로 entites, 즉 테이블이 배열 형태로

들어가는데, 우선 우리가 만든건 Word.kt 파일 하나만 만들어뒀으므로 (Entity로 셋팅한 것) Word::class로 하나만 올려둡니다.

그리고 version으로 둘 수 있는 것으로 보아 데이터베이스 버전 관리가 가능한 구조입니다.

또한 여기서 WordDao를 가져왔기에 추후에 이를 활용할 때에는

AppDatabase.getInstance(context).wordDao().insert(word) 이런 형태로 구현될 것 입니다. 

싱글톤으로 인스턴스 생성을 하기 위해 companion object를 두는데, getInstance 함수를 정의하여

INSTANCE가 null이 아니면 이미 생성된 "app-database.db"를 가져와 처리하고 null이라면 Room.databaseBuilder를 통해

"app-database.db"를 생성합니다.

synchronized로 이를 한 번 더 묶은 이유는 동기적으로 처리하여 database가 여러개 생성되지 않도록 처리하기 위함입니다.

 

여기까지가 3번이지만 실상 4번, 5번에 대한 내용도 정리가 되었습니다.

엔터티를 데이터베이스에 배열 형태로 추가하고 동기적으로 인스턴스를 생성하되, 이미 존재한다면

싱글턴으로 그냥 리턴만 받는 형태를 추구하는 로직입니다. 


6) AppDatabase 인스턴스 호출 → wordDao 접근(AppDatabase.kt에 이미 선언해둠) → wordDao에 정의된 구현부쿼리 호출 → 데이터 처리 진행(RoomDB)

 

이제 진짜 쓰이는 프로세스에 대해 설명하자면 아래와 같습니다.

기본적으로 현재 단어장 앱이기 때문에 [추가/수정/삭제] 에 대해서 코드를 구현해보겠습니다.

앗! 그 전에 중요한 핵심!! 데이터를 처리하는 과정, 즉 IO에 대해서는 UI 스레드에서 구현하기 보다는

워커스레드에서 구현되는 것이 좋습니다.   마찬가지로 결과에 대해서 UI 랜더링 절차는 UI 스레드에서 구현되는 것이 좋습니다.

이에 대한 새로운 개념인 Coroutine에 대해 코드 설명 이후에 추가로 안내하도록 하겠습니다.

 

private fun add(){
   val text = binding.textInputEditText.text.toString()
   val mean = binding.meanTextInputEditText.text.toString()
   val chipId = binding.typeChipGroup.checkedChipId
   val type = findViewById<Chip>(chipId).text.toString()
   val word = Word(text, mean, type)
   
   CoroutineScope(Dispatchers.IO).launch {
      AppDatabase.getInstance(applicationContext)?.wordDao()?.insert(word)
      withContext(Dispatchers.Main){
         Toast.makeText(applicationContext, "저장을 완료했습니다.", Toast.LENGTH_SHORT).show()
         val aIntent = Intent().putExtra("isUpdated", true)
         setIntent(RESULT_OK, aIntent)
         finish()
      }
   }
}

private fun edit(){
   val text = binding.textInputEditText.text.toString()
   val mean = binding.meanTextInputEditText.text.toString()
   val chipId = binding.typeChipGroup.checkedChipId
   val type = findViewById<Chip>(chipId).text.toString()
   val editWord = originWord?.copy(text = text, mean = mean, type = type)
   CoroutineScope(Dispatchers.IO).launch {
      editWord?.let { word -> 
         AppDatabase.getInstance(applicationContext)?.wordDao()?.update(word)
      }
      withContext(Dispatchers.Main){
         Toast.makeText(applicationContext, "수정을 완료하였습니다.", Toast.LENGTH_SHOW).show()
         try {
            editWord?.let { it ->
               val eIntent = Intent().putExtra("editWord", it)
               setResult(RESULT_OK, eIntent)
               finish()
            } 
         } catch (e: Exception){
            Log.e("UIError", "Error updating UI $e")
         }
      }
   }
}

 

다음은 삽입/수정에 대해서만 구현해보았는데 여기서 또 하나 새로운 개념에 대해서 잡고자 합니다.

삭제는 삽입/수정 코드만 잘보더라도 충분히 가능할 것이라 생각합니다.

 

우선 앞서 설명한대로 데이터를 처리하는데 있어서 코루틴을 활용해서 IO에서 처리하고 

결과로 UI를 수정하기 위해선 Main으로 가져와 다시 처리하는 방식을 활용했습니다.

또한 삽입(추가)/수정 모두 인텐트를 활용해서 값을 putExtra로 넣고 setResult를 통해 함께 보냅니다.

이렇게 setResult로 인텐트와 RESULT_OK라는 응답코드를 셋팅해보내면, registerForActivityResult에서 받게 됩니다. 

 

현재 수정부 함수에서는 editWord라는 Word 객체 자체를 보냅니다.  그렇기 때문에 이걸 받기 위해서는 getParcelableExtra가 필요한 것이지요.   그렇기 때문에 초반에 설명한대로 kotlin-parcelize를 활용한 것입니다.  (설계..!!)

 

registerForActivityResult에 대해서 사용 방법을 정리하자면

1) registerForActivityResult를 생성하는데, 첫 번째 인자는 Contracts이고 두 번째 인자는 Callback이기 때문에

결과값 즉, setResult를 통해 오는 값은 해당 Callback에서 처리가 됩니다.

따라서 registerForActivityResult를 정의할 때 콜백값을 resultCode를 토대로 처리하면 됩니다.

 

 

registerForActivityResult 하는 부분! (▶ chapter 1)

// 현재 구조 (A 액티비티 -> B 액티비티 -> A 액티비티) 형태로 생각하면 됩니다.
// A 액티비티
private val updateEditWordResult = 
   registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
      when(result.resultCode){
         RESULT_OK -> {
            val updateWord = result.data?.getParcelableExtra<Word>("editWord")
            if(updateWord != null){
               updateEditWord(updateWord)
            }
         }
      }
   }
   
private val updateAddWordResult = 
   registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
      if(result.resultCode == RESULT_OK){
         val isUpdated = result.data?.getBooleanExtra("isUpdated", false) ?: false
         if(isUpdated) {
            updateAddWord()
         }
      }
   }

 

 

2) A 액티비티에서 B로 보내기 위해 셋팅한 registerForActivityResult의 객체 변수들을 launch로 처리하면 됩니다.

단, 처리할 때 Intent를 같이 보내주어 데이터를 보낼거라면 함께 보내서 처리할 수 있습니다.

 

launch하는 부분! (▶ chapter 2)

binding.addButton.setOnClickListener {
   Intent(this, AddActivity::class.java /* B 액티비티 */).let { // it = Intent
      updateAddWordResult.launch(it)
   }
}


private fun edit(){
   if(selectWord == null) return
   val eIntent = Intent(this, AddActivity::class.java).putExtra("originalData", selectWord)
   updateEditWordResult.launch(eIntent)
}

 

이렇게 인텐트를 정의해두고 registerForActivityResult의 객체를 launch할 때 같이 넣어줍니다.

이러면 B 액티비티로 전달되면서 B 액티비티에 인텐트 값을 받아서 처리할 수 있고 setResult를 통해 결과를 보낼때 

resultCode를 같이 보내어, registerForActivityResult의 콜백 부분에서 처리가 가능합니다.

마찬가지로 setResult때 인텐트를 같이 보내어 A 액티비티에서 해당 결과를 가지고 UI 변경 작업을 진행할 수 있습니다. 

 

setResult부분! (▶ chapter 3)

    private fun add() {
        val text = binding.textInputEditText.text.toString()
        val mean = binding.meanTextInputEditText.text.toString()
        val chipId = binding.typeChipGroup.checkedChipId
        val type = findViewById<Chip>(chipId).text.toString() // 칩그룹안에 있는 아이디로 칩을 받아옴
        val word = Word(text, mean, type)
        CoroutineScope(Dispatchers.IO).launch {
            AppDatabase.getInstance(applicationContext)?.wordDao()?.insert(word)
            withContext(Dispatchers.Main) {
                Toast.makeText(applicationContext, "저장을 완료했습니다.", Toast.LENGTH_SHORT).show()
                val aIntent = Intent().putExtra("isUpdated", true) // 그냥 추가만 해도 넘어감
                setResult(RESULT_OK, aIntent)
                finish()
            }
        }
    }

    private fun edit() {
        val text = binding.textInputEditText.text.toString()
        val mean = binding.meanTextInputEditText.text.toString()
        val chipId = binding.typeChipGroup.checkedChipId
        val type = findViewById<Chip>(chipId).text.toString() // 칩그룹안에 있는 아이디로 칩을 받아옴
        // 복사 기능 사용
        val editWord = originWord?.copy(text = text, mean = mean, type = type)
        CoroutineScope(Dispatchers.IO).launch {
            editWord?.let { AppDatabase.getInstance(applicationContext)?.wordDao()?.update(it) }
            withContext(Dispatchers.Main) {
                Toast.makeText(applicationContext, "수정을 완료했습니다.", Toast.LENGTH_SHORT).show()
                try {
                    editWord?.let {
                        val eIntent = Intent().putExtra("editWord", it)
                        setResult(RESULT_OK, eIntent)
                        finish()
                    }
                } catch (e: Exception) {
                    Log.e("UIError", "Error Updating UI $e")
                }

            }
        }
    }

 

(▶ chapter 4)는 Chapter 1에서 콜백이 이미 구현되었으니 제대로 응답이 왔을 경우 updateEditWord(word), updateAddWord()

함수를 구현하면 되기에, 다음 함수들이 구현된 부분을 참조해두겠습니다.

 

[updateEditWord]

    private fun updateEditWord(word: Word){
        val index = wordAdapter.list.indexOfFirst { it.id == word.id }
        wordAdapter.list[index] = word // 해당 인덱스에 word로 변경
        CoroutineScope(Dispatchers.IO).launch {
            selectWord = word
            withContext(Dispatchers.Main){
                wordAdapter.notifyItemChanged(index)
                binding.textTextView.text = word.text
                binding.meanTextView.text = word.mean
            }
        }
    }

 

[updateAddWord]

    private fun updateAddWord() {
        CoroutineScope(Dispatchers.IO).launch {
            val latestWord = AppDatabase.getInstance(applicationContext)?.wordDao()?.getLatestWord()
            withContext(Dispatchers.Main){
                latestWord?.let {
                    word ->
                    wordAdapter.list.add(0, word[0])
                    wordAdapter.notifyDataSetChanged()  // UI가 변경될 동작의 트리거가 되므로 DisPatchers.Main에서
                }
            }
        }
    }

 

지금까지가 AppDatabase를 호출하여 wordDao에 접근하고 쿼리 구현부를 호출하면서

데이터베이스에 데이터를 넣는 과정이었습니다.

또한 처리된 데이터는 Word라는 객체이기에 registerForActivityResult로 처리하려면 Parcelable 형태로 주고받아야 하므로

kotlin-parcelize를 통해 더 수월하게 처리했습니다.

끝으로 데이터를 관리하는 부분에서는 Dispatcher.IO 부분에서 입출력 커널에서 관리하기에 좀 더 효율적으로 처리가 가능하고

결과값을 처리할 때에는 Main으로 돌아와서 UI 스레드 처리를 하도록 했습니다.

 

※ 참고로 어댑터의 리스트에 값을 더하고 데이터 변화됨을 알리는 코드를 추가하여 메인 화면에서는 변경된 어댑터로

화면이 보이도록 구현해두었습니다.  

 

 

지금까지 단어장 앱 개발에 대한 필요 요소 및 개념에 대해서 정리하였고 관련 코드도 숙지해보았습니다. 

마지막으로 Coroutine에 대해서 설명하고 마무리 하도록 하겠습니다. 


 

코루틴(Coroutine)이란?

: 비선점형 멀티태스킹을 위한 서브루틴으로 실행의 지연과 재개를 허용한 프로그램 구성요소

 

이 정의에서 스레드와 차이가 있습니다.

스레드 코루틴(Coroutine)
선점형 처리방식
- 하나의 프로세스가 다른 프로세스 대신해서 프로세서(CPU)를 강제로 차지할 수 있음
> 병행성(O), 병렬성(O)
비선점형 처리방식
- 하나의 프로세스가 CPU 처리에 할당될 경우 그 작업이 종료되지 않는한 다른 프로세스가 프로세서(CPU)를 차지할 수 없음
> 병행성(O), 병렬성(X)

1) 선점형 방식이므로 여러 스레드가 비동기적으로 처리될 때,
CPU 차지하는 데 있어서 코드상 조치가 필요합니다.
(예: Mutex, Semaphore 등.. 복잡해짐..)

2) 멀티스레드인 경우 동일 힙영역이 동기적으로 활용할 수 있어
타 스레드에도 문제가 될 수 있고 멀티 프로세서인 경우
컨텍스트 스위칭으로 인한 오버헤드가 발생할 수 있습니다.

1) 코루틴 = 작업단위는 스레드가 아니라 스레드 안에서 작동하는 작업 단위 중 하나로 Context를 오버라이딩합니다.
-> 컨텍스트 스위칭이 적어 오버헤드 발생이 적고 실행 중단/재개 하는
상호작용을 통해 병행성을 가지므로 스레드 및 메모리 사용이 적습니다.

2) 코루틴에 진입 후 반환문이 없더라도 임의 지점에서 실행 중 동작을 중단할 수 있고 이후 해당 지점에서 재개할 수 있음
즉, 진입 시점과 반환 시점이 여러개이고 내부적으로 CPS(Continuation Passing Style) & State Machine을 처리하기에 상태를 연속적으로 전달할 수 있습니다.
또한 이를 통해 컨텍스트를 유지하고 실행 관리를 위한 state machine에 따라 코드 블록을 구분합니다.

 

코루틴과 관련해서 스위칭 하는 부분은 다른 예제 코드를 통해서 좀 더 숙지해보겠습니다.

우선은 현재 사용했던 코드를 통해 어떻게 처리가 되고 있는지 확인해보겠습니다.

    private fun updateAddWord() {
        CoroutineScope(Dispatchers.IO).launch {
            val latestWord = AppDatabase.getInstance(applicationContext)?.wordDao()?.getLatestWord()
            withContext(Dispatchers.Main){
                latestWord?.let {
                    word ->
                    wordAdapter.list.add(0, word[0])
                    wordAdapter.notifyDataSetChanged()  // UI가 변경될 동작의 트리거가 되므로 DisPatchers.Main에서
                }
            }
        }
    }

 

1) CoroutineScope

▷ 코루틴 범위가 수행되는 영역을 셋팅하는 곳으로 설정된 범위 내에서 코루틴 작업이 수행됨을 알립니다.

 

2) Dispatchers.IO 하고 launch

▷ IO 작업(네트워크 통신, 데이터베이스 접근 등..)을 위한 스레드 풀에서 launch 하겠다.  

코루틴을 실행하겠다는 것입니다.   이는 메인스레드의 부하를 줄이고자 IO 스레드를 별도로 만들어 수행한다는 뜻입니다. 

 

3) AppDatabase.getInstance(applicationContext)?.wordDao()?.getLatestWord()

▷ IO 스레드 안에서 데이터베이스 접근해서 데이터를 가져오는데, 앞서 설명한 RoomDatabase 접근과 엔터티 접근

그리고 Dao 쪽에서 구현부 호출 등을 통해 데이터 관리를 하는 부분입니다.  IO 스레드에서 처리되므로 메인의 부하를 줄입니다.

 

4) 아래 코드 참고

    withContext(Dispatchers.Main){
         wordAdapter.notifyItemChanged(index)
         binding.textTextView.text = word.text
         binding.meanTextView.text = word.mean
    }

 

UI 변경 요소가 있고 데이터를 adapter에 꽂아서 알리기 때문에 Main 스레드로 변경해 진행합니다.

withContext는 실행 컨텍스트를 변경한다는 것으로 IO에서 Main 스레드로 변경한다는 것을 의미합니다.

이 때 컨텍스트 스위칭이 발생합니다. 

이 과정을 좀 더 살펴본다면, Dispatchers.IO를 통해 코루틴에서 IO 관련 컨텍스트가 실행되고 있을때

withContext(Dispatcher.Main)으로 현재 실행중인 컨텍스트를 완료하고 메인 스레드로 스위칭이 이뤄집니다.

이 과정은 비동기적으로 처리되며, 코루틴의 스레드 풀에 의해 스레드가 전환됩니다.

 

 

기존 스레드 변경에 따른 컨텍스트 스위칭은 오버헤드가 많이 나올 수 있지만 코루틴은 자체 내 스레드 풀에 의해 전환이

되기 떄문에 효율적으로 처리가 가능하며 일반적으로는 발생 가능한 오버헤드도 무시할 정도입니다.

또한 withContext를 통해 변경할 경우, 코드의 가독성과 유지보수  측면에서 아주 좋으며, 스레드 간 데이터 공유와

동기화 측면에서 명확하게 처리가 가능합니다. 

스레드 변경 과정에서도 기존의 실행중인 스레드를 완전히 교체하는 것은 아니고, 코루틴 상태 관리 방법을 통해

컨텍스트를 변경하게 됩니다. 

 

 

즉, 코루틴에서 컨텍스트 스위칭은 스레드 변경이 아니라 작업 중인 코루틴의 중단과 재개의 일환(상태변경)이라고 생각하면 됩니다.

코루틴은 논리적인 실행단위이므로 실제 스레드의 스위칭과는 별도로 동작하게 됩니다.

withContext를 통해 컨텍스트를 IO에서 Main으로 변경한다고 한들, 이는 실제 스레드 간의 교체라 보기 힘들고

코루틴 스케줄러가 코루틴을 새로운 컨텍스트에서 실행하도록 지시하는 것과 같습니다.

코루틴의 스케줄링과 실행 컨텍스트를 변경하는 것으로 실제 스레드의 스위칭이 아니라 코루틴의 실행 환경(상태)을 변경하는 것입니다.

코루틴의 컨텍스트 스위칭은 스레드의 컨텍스트 스위칭과는 다르게 효율적이며, 독립적으로 관리됩니다.

실제 스레드 변경이 아닌, 코루틴의 상태를 변경해서 실행 컨텍스트를 변경하는 것이기 때문입니다. (중지/재개 <-> 재개/중지)

 


정리하면서 공부했을때 정말 이해하는데 시간이 걸렸습니다.   한 눈에 어떻게 하면 좀 더 쉽게 설명이 가능할까 싶어서 아래 이미지로 마무리 정리를 해보도록 하겠습니다.

프로세서(CPU) 내 여러 프로세스 처리

 

 

 

Process1 에서 Process2로 스위칭이 발생할 경우 컨텍스트 스위칭이 크게 발생하며 오버헤드가 큽니다. 

OS 커널에서 진행되며, CPU 처리 메모리가 크게 부하가 발생하게 됩니다. 

 

하나의 프로세스 내 스레드간 스위칭

 

 

하나의 프로세스 내 여러 스레드가 들어가 있는 경우로 프로세스 내 커널에서 수행되며 스레드간 교차는 컨텍스트 스위칭이

발생하긴 하지만 오버헤드는 앞서 프로세스 간 스위칭 보단 적습니다.

또한 이 경우 힙 영역을 공유하기 때문에 공유 데이터 관리 측면과 스레드 간 수행 스케줄링(뮤텍스, 세마포어 등..)처리가 

복잡하다는 단점이 있습니다.

 

스레드 내 코루틴 스케줄러 -> 코루틴 상태 변경 -> 컨텍스트 스위칭

 

코루틴은 기본적으로 상태를 관리한다고 하였기 때문에 실제 한 스레드에서 작업 수행을 할 때에 IO작업과 Main 작업을 나눠서 한다하면 기본적으로 Main에서 작업을 하다가 네트워크 또는 데이터베이스 작업 때문에 IO를 호출하고, 작업이 마치면 다시 Main으로 전환하는 과정을 거치게 됩니다.

 

이는 실제로는 코루틴1(메인)이 작업을 하고 있다가 중지 상태로 스케줄러가 처리해두고 (진짜 종료는 아님)

IO 상태로 변경하고자 코루틴2(IO)를 실행시킵니다.  이는 실제 스레드 간 스위칭이 아니라 상태에 따른 컨텍스트 스위칭, 코루틴 스위칭이라 생각하면 좀 더 편한 것 같습니다.

 

코루틴2(IO)가 작업을 마치고 withContext(Dispatcher.Main)으로 다시 컨텍스트 스위칭이 이루어 질 때, 이 또한

상태를 변경해서 기존 중지된 코루틴을 재개하는 방향으로 스위칭이 이루어집니다.

따라서 실질적으로는 스레드 스위칭 또는 프로세스 스위칭을 통한 오버헤드 발생이 아니고

상태에 따른 코루틴 변경; 컨텍스트 스위칭이기 때문에 오버헤드는 매우 적어 전반적인 영향도도 적고

효율적인 메모리 관리도 가능하게 됩니다.

 

 

이상 단어장 앱을 마무리하면서 RoomDB 활용 방법과 관련된 개념, registerForActivityResult 활용법,

그리고 Coroutine을 통한 구현 방식과 컨텍스트 스위칭 등 자세한 개념에 대해서 정리해보았습니다.

정리하면서 정말 시간이 오래 걸렸지만 많은 것을 배우고 숙지할 수 있는 아주 소중한 시간이었습니다. 

다음 포스팅에서도 더 좋은 주제와 정보로 찾아뵙도록 하겠습니다.    감사합니다.

 

 

 

[전체코드]

https://github.com/Haamseongho/Kotlin_Jetpack/tree/dev_haams_v2/chapter7

 

Kotlin_Jetpack/chapter7 at dev_haams_v2 · Haamseongho/Kotlin_Jetpack

Kotlin 기초부터 Jetpack Compose까지. Contribute to Haamseongho/Kotlin_Jetpack development by creating an account on GitHub.

github.com

 

반응형