Android

[Android] SQLite vs Room DB 비교 및 구현 - 2 -

참깨빵위에참깨빵_ 2025. 4. 28. 20:28
728x90
반응형

https://onlyfor-me-blog.tistory.com/1145

 

[Android] SQLite vs Room DB 비교 및 구현 - 1 -

간단한 데이터 저장은 쉐어드 프리퍼런스, dataStore로 할 수 있지만 좀 복잡한 데이터면 다른 방법을 쓰는 게 낫다.그 방법으로 떠오르는 게 SQLite, Room인데 안드로이드 디벨로퍼에선 Room DB 사용을

onlyfor-me-blog.tistory.com

 

1편에서 SQLite 예시를 확인했으니 같은 기능을 Room DB로도 구현해 본다. Room DB는 뷰모델을 써서 구현한다.

data class는 이전과 같은 Person을 사용한다.

 

data class Person(
    var id: Long = 0,
    var name: String,
    var age: Int,
    var height: Double,
    var address: String
)

 

이걸 바탕으로 PersonEntity를 만들어야 한다. Room DB를 사용하려면 테이블 형태로 데이터를 구성하기 위한 Entity 클래스가 필요하다.

Room을 써서 데이터를 저장하려면 저장하려는 객체를 정의해야 한다. 이 객체는 Room DB의 테이블에 상응하고 각 인스턴스는 테이블의 데이터 row 하나를 의미한다. 어떻게 이게 가능하냐면 Room DB는 SQL문 없이도 DB 스키마를 정의할 수 있기 때문이다.

@Entity를 사용하고 id에 @PrimaryKey를 설정해 준다.

 

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "persons")
data class PersonEntity(
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0,
    var name: String,
    var age: Int,
    var height: Double,
    var address: String
)

fun Person.toEntity(): PersonEntity = PersonEntity(
    id = this.id,
    name = this.name,
    age = this.age,
    height = this.height,
    address = this.address
)

fun PersonEntity.toPerson(): Person = Person(
    id = this.id,
    name = this.name,
    age = this.age,
    height = this.height,
    address = this.address
)

 

기본적으로 Room은 클래스명을 DB 테이블명으로 사용한다. 테이블명을 클래스명과 다르게 하고 싶다면 @Entity의 tableName 속성값을 바꾸면 된다. Column의 이름을 다르게 하려면 @ColumnInfo를 필드에 붙이고 name 속성에 원하는 값을 쓴다.

아래는 예시다.

 

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "persons")
data class PersonEntity(
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0,
    @ColumnInfo(name = "userName")
    var name: String,
    var age: Int,
    var height: Double,
    var address: String
)

fun Person.toEntity(): PersonEntity = PersonEntity(
    id = this.id,
    name = this.name,
    age = this.age,
    height = this.height,
    address = this.address
)

fun PersonEntity.toPerson(): Person = Person(
    id = this.id,
    name = this.name,
    age = this.age,
    height = this.height,
    address = this.address
)

 

@PrimaryKey는 DB 안의 각 row를 고유하게 식별할 수 있게 하는 값이다. 보통 id를 쓰니 여기서도 id를 사용했다.

다음 코드를 보기 전에 Room DB를 구성하는 3가지 구성요소를 확인한다.

 

  • Database 클래스 : DB를 보유하고 앱의 영구 데이터와 기본적인 연결을 만들기 위한 접근점 역할
  • 데이터 항목(@Entity를 사용한 data class) : DB의 테이블을 나타냄
  • 데이터 접근 객체(DAO) : 앱이 DB의 데이터를 쿼리, 업데이트, 삽입, 삭제하는 데 쓸 수 있는 메서드를 제공함

 

Database 클래스는 DB와 연결된 DAO 인스턴스를 제공한다. 그럼 앱은 DAO를 통해 DB 데이터를 연결된 데이터 항목 객체의 인스턴스로 검색할 수 있게 된다. 앱은 정의된 데이터 항목을 써서 상응하는 테이블의 row를 업데이트하거나 삽입할 새 row를 만들 수 있다.

이제 PersonEntity를 사용하는 PersonDao 인터페이스를 만든다.

 

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.example.regacyviewpractice.data.model.PersonEntity

@Dao
interface PersonDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertPerson(person: PersonEntity): Long

    @Query("SELECT * FROM persons")
    suspend fun getAllPersons(): List<PersonEntity>

    @Query("SELECT * FROM persons WHERE id = :personId")
    suspend fun getPerson(personId: Long): PersonEntity?

    @Update
    suspend fun updatePerson(person: PersonEntity): Int

    @Query("DELETE FROM persons WHERE id = :personId")
    suspend fun deletePersonById(personId: Long): Int

    @Query("SELECT COUNT(*) FROM persons")
    suspend fun getPersonCount(): Int
}

 

delete 메서드의 경우 SQL문을 만들어 사용했는데 @Delete를 사용할 수도 있음을 참고한다.

DAO는 인터페이스, 추상 클래스 중 하나로 정의할 수 있지만 개인적으로 인터페이스를 사용한 경험이 많아서 인터페이스를 사용했다. 어떤 구현을 사용하든 @Dao는 잊지 않고 반드시 써 준다.

 

@Insert, @Update는 편의 메서드라고 하며 내가 SQL문을 굳이 쓰지 않아도 알아서 삽입, 업데이트, 삭제하는 메서드를 제공하는 어노테이션을 말한다. 복잡한 쿼리가 필요하다면 @Query를 써서 원하는 쿼리를 작성한다.

다음은 PersonDatabase다.

 

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.example.regacyviewpractice.data.model.PersonEntity

@Database(entities = [PersonEntity::class], version = 1, exportSchema = false)
abstract class PersonDatabase : RoomDatabase() {

    abstract fun personDao(): PersonDao

    companion object {
        private const val DATABASE_NAME = "person_room_database"

        @Volatile
        private var INSTANCE: PersonDatabase? = null

        fun getInstance(context: Context): PersonDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    PersonDatabase::class.java,
                    DATABASE_NAME
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

 

이 클래스는 Room DB의 인스턴스를 갖고 있으며 DB 구성을 정의한다. 그리고 database 클래스는 RoomDatabase를 상속하는 추상 클래스로 구현돼야 한다. 또한 database와 연결된 DAO 클래스에서 database 클래스는 생성자 파라미터가 없고 DAO 클래스의 인스턴스를 리턴하는 추상 메서드를 정의해야 한다. 아래는 안드로이드 스튜디오에 게시된 예시다.

 

@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

 

이렇게 database를 구성하고 나면 DAO 인스턴스를 가져올 수 있다. 결과적으로 DAO 인스턴스를 통해 앞서 PersonDao 인터페이스에 정의한 여러 메서드들을 호출할 수 있게 되는 것이다.

여러 포스팅을 보면 알겠지만 getInstance()에서 대부분 싱글턴 형태로 인스턴스를 만들어 리턴하기 때문에 여기서도 이 구성을 가져왔다.

exportSchema가 false로 설정돼 있는데 이건 DB이 스키마 정보를 json 파일로 지정된 폴더에 내보낼(=저장할) 수 있도록 설정할지 여부를 의미한다. 기본값은 true고 DB 버전 변경 히스토리를 보기 위해 건드리지 않기도 한다. 예시기 때문에 false로 설정했지만 실제로 사용하겠다면 true 사용을 검토해보는 게 좋을 것이다. true로 설정할 경우 스키마 경로를 지정해 줘야 하는데 이와 관련해선 다른 블로그들에서 잘 설명하고 있기 때문에 생략한다. 안드로이드 디벨로퍼에선 필수는 아니지만 스키마의 버전 기록을 보관하는 게 좋다고 하니 참고한다.

 

https://developer.android.com/reference/kotlin/androidx/room/Database#exportSchema()

 

Database  |  API reference  |  Android Developers

androidx.compose.foundation.lazy

developer.android.com

 

이제 밑준비는 끝났으니 repository 인터페이스를 구현한다.

 

interface PersonRepository {
    suspend fun addPerson(person: Person): Long
    suspend fun getAllPersons(): List<Person>
    suspend fun getPerson(personId: Long): Person?
    suspend fun updatePerson(person: Person): Int
    suspend fun deletePerson(personId: Long): Int
    suspend fun getPersonCount(): Int
}

 

인터페이스를 만들었으니 구현 클래스도 만들어야 한다.

 

class PersonRepositoryImpl @Inject constructor(
    private val personDao: PersonDao
) : PersonRepository {

    override suspend fun addPerson(person: Person): Long {
        return personDao.insertPerson(person.toEntity())
    }

    override suspend fun getAllPersons(): List<Person> {
        return personDao.getAllPersons().map { it.toPerson() }
    }

    override suspend fun getPerson(personId: Long): Person? {
        return personDao.getPerson(personId)?.toPerson()
    }

    override suspend fun updatePerson(person: Person): Int {
        return personDao.updatePerson(person.toEntity())
    }

    override suspend fun deletePerson(personId: Long): Int {
        return personDao.deletePersonById(personId)
    }

    override suspend fun getPersonCount(): Int {
        return personDao.getPersonCount()
    }
}

 

그리고 이 함수들을 뷰모델에서 호출하기 위한 usecase들을 만든다. 아래는 usecase들의 예시고 이런 느낌으로 적당하게 만들어주면 된다.

 

import javax.inject.Inject

class AddPersonUseCase @Inject constructor(
    private val repository: PersonRepository
) {
    suspend operator fun invoke(person: Person): Long =
        repository.addPerson(person)
}
import javax.inject.Inject

class DeletePersonUseCase @Inject constructor(
    private val repository: PersonRepository
) {
    suspend operator fun invoke(id: Long): Int =
        repository.deletePerson(id)
}

 

뷰모델에선 만든 usecase들을 가져와 사용한다.

 

import androidx.lifecycle.ViewModel
import com.example.regacyviewpractice.data.model.Person
import com.example.regacyviewpractice.domain.usecase.AddPersonUseCase
import com.example.regacyviewpractice.domain.usecase.DeletePersonUseCase
import com.example.regacyviewpractice.domain.usecase.GetAllPersonUseCase
import com.example.regacyviewpractice.domain.usecase.GetPersonCountUseCase
import com.example.regacyviewpractice.domain.usecase.GetPersonUseCase
import com.example.regacyviewpractice.domain.usecase.UpdatePersonUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject

@HiltViewModel
class PersonViewModel @Inject constructor(
    private val addPersonUseCase: AddPersonUseCase,
    private val getPersonUseCase: GetPersonUseCase,
    private val getAllPersonsUseCase: GetAllPersonUseCase,
    private val updatePersonUseCase: UpdatePersonUseCase,
    private val deletePersonUseCase: DeletePersonUseCase,
    private val getPersonCountUseCase: GetPersonCountUseCase
): ViewModel() {

    private val _personList = MutableStateFlow<List<Person>>(emptyList())
    val personList: StateFlow<List<Person>> = _personList.asStateFlow()

    private val _personCount = MutableStateFlow(0)
    val personCount: StateFlow<Int> = _personCount.asStateFlow()

    suspend fun addPerson(person: Person): Long {
        val id = addPersonUseCase(person)
        refreshData()
        return id
    }

    suspend fun getPerson(id: Long): Person? {
        return getPersonUseCase(id)
    }

    suspend fun updatePerson(person: Person): Int {
        val result = updatePersonUseCase(person)
        refreshData()
        return result
    }

    suspend fun deletePerson(id: Long): Int {
        val result = deletePersonUseCase(id)
        refreshData()
        return result
    }

    suspend fun refreshData() {
        _personList.value = getAllPersonsUseCase()
        _personCount.value = getPersonCountUseCase()
    }

}

 

마지막으로 액티비티다. XML에 뭔가 만들진 않았기 때문에 액티비티 파일만 첨부한다.

 

import android.os.Bundle
import android.util.Log
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.regacyviewpractice.data.model.Person
import com.example.regacyviewpractice.databinding.ActivityDataPermanenceBinding
import com.example.regacyviewpractice.presentation.viewmodel.PersonViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch

@AndroidEntryPoint
class RoomDataActivity : AppCompatActivity() {

    private val TAG = this::class.simpleName
    private lateinit var binding: ActivityDataPermanenceBinding

    private val personViewModel: PersonViewModel by viewModels()
    
    private lateinit var person1: Person
    private lateinit var person2: Person

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityDataPermanenceBinding.inflate(layoutInflater)
        setContentView(binding.root)

        person1 = Person(
            name = "김철수",
            age = 20,
            height = 190.0,
            address = "OO시 OO구"
        )

        person2 = Person(
            name = "김영희",
            age = 21,
            height = 180.0,
            address = "ㅁㅁ시 ㅁㅁ구"
        )

        lifecycleScope.launch {
            personViewModel.personList.collect { persons ->
                Log.d(TAG, "## [Room] 현재 Person 목록 변경: $persons")
            }
        }

        lifecycleScope.launch {
            personViewModel.personCount.collect { count ->
                Log.d(TAG, "## [Room] 현재 Person 개수 변경: $count")
            }
        }

        lifecycleScope.launch {
            executeAddOperation()
            executeReadOperation()
            executeUpdateOperation()
            executeDeleteOperation()
        }
    }

    private suspend fun executeAddOperation() {
        person1.id = personViewModel.addPerson(person1)
        person2.id = personViewModel.addPerson(person2)

        Log.d(TAG, "## [Room] 새 person 추가됨. person1 : $person1")
        Log.d(TAG, "## [Room] 새 person 추가됨. person2 : $person2")
    }

    private suspend fun executeReadOperation() {
        val personId = person1.id
        val personFromDb: Person? = personViewModel.getPerson(personId)
        personFromDb?.let {
            Log.d(TAG, "## [Room] id가 ${personId}인 Person 조회 결과 : $it")
        }
    }

    private suspend fun executeUpdateOperation() {
        person1.apply {
            age = 30
            address = "ㅂㅂ시 ㅂㅂ구"
        }

        val updateResult = personViewModel.updatePerson(person1)

        Log.d(TAG, "## [Room] updateResult : $updateResult")
        Log.d(TAG, "## [Room] 업데이트 결과 : $person1")
    }

    private suspend fun executeDeleteOperation() {
        val deleteResult = personViewModel.deletePerson(person2.id)
        Log.d(TAG, "## [Room] person2 삭제 결과 : $deleteResult")
    }
}

 

최종적으로 앱을 실행해서 로그를 확인하면 아래처럼 보일 것이다. 버튼을 추가하고 클릭 리스너로 각 함수들을 호출하게 하면 좀 더 파악이 쉬울 것이다.

 

## [Room] 현재 Person 목록 변경: []
## [Room] 현재 Person 개수 변경: 0
## [Room] 현재 Person 목록 변경: [Person(id=1, name=김철수, age=20, height=190.0, address=OO시 OO구)]
## [Room] 현재 Person 개수 변경: 1
## [Room] 현재 Person 목록 변경: [Person(id=1, name=김철수, age=20, height=190.0, address=OO시 OO구), Person(id=2, name=김영희, age=21, height=180.0, address=ㅁㅁ시 ㅁㅁ구)]
## [Room] 현재 Person 개수 변경: 2
## [Room] 새 person 추가됨. person1 : Person(id=1, name=김철수, age=20, height=190.0, address=OO시 OO구)
## [Room] 새 person 추가됨. person2 : Person(id=2, name=김영희, age=21, height=180.0, address=ㅁㅁ시 ㅁㅁ구)
## [Room] id가 1인 Person 조회 결과 : Person(id=1, name=김철수, age=20, height=190.0, address=OO시 OO구)
## [Room] 현재 Person 목록 변경: [Person(id=1, name=김철수, age=30, height=190.0, address=ㅂㅂ시 ㅂㅂ구), Person(id=2, name=김영희, age=21, height=180.0, address=ㅁㅁ시 ㅁㅁ구)]
## [Room] updateResult : 1
## [Room] 업데이트 결과 : Person(id=1, name=김철수, age=30, height=190.0, address=ㅂㅂ시 ㅂㅂ구)
## [Room] 현재 Person 목록 변경: [Person(id=1, name=김철수, age=30, height=190.0, address=ㅂㅂ시 ㅂㅂ구)]
## [Room] 현재 Person 개수 변경: 1
## [Room] person2 삭제 결과 : 1

 

간단하게 구현을 확인했는데 Room DB 쪽이 클린 아키텍처를 적용한다고 usecase와 뷰모델을 만들어서 좀 더 구현할 게 많은 것 같다고 느낄 수 있다. 반대로 SQLite는 Room DB보다 상대적으로 적은 코드로도 기능을 구현할 수 있었다.

그러나 SQLite는 Create 쿼리문부터 시작해서 Drop 쿼리문까지 일일이 써줘야 하고 편의 메서드를 제공하지 않는단 단점이 있지만 Room DB는 @Insert, @Update 같은 편의 메서드를 제공해서 간단한 기능이면 내가 직접 쿼리를 쓰지 않아도 구현할 수 있다는 장점이 있다.

 

 

참고한 링크)

 

https://developer.android.com/training/data-storage/room?hl=ko

 

Room을 사용하여 로컬 데이터베이스에 데이터 저장  |  App data and files  |  Android Developers

Room 라이브러리를 사용하여 데이터를 유지하는 방법 알아보기

developer.android.com

 

https://developer.android.com/training/data-storage/room/defining-data?hl=ko

 

Room 항목을 사용하여 데이터 정의  |  App data and files  |  Android Developers

Room 라이브러리의 일부인 항목을 사용하여 데이터베이스 테이블을 생성하는 방법 알아보기

developer.android.com

 

https://developer.android.com/training/data-storage/room/accessing-data?hl=ko

 

Room DAO를 사용하여 데이터 액세스  |  App data and files  |  Android Developers

Room 라이브러리의 일부인 DAO(데이터 액세스 객체)를 사용하여 데이터베이스 테이블을 수정하는 방법 알아보기

developer.android.com

 

https://developer.android.com/training/data-storage/room/migrating-db-versions?hl=ko

 

Room 데이터베이스 이전  |  App data and files  |  Android Developers

Room 라이브러리를 사용하여 데이터베이스를 안전하게 이전하는 방법 알아보기

developer.android.com

 

반응형