본문 바로가기
Jetpack Library

[Android JetPack] Room을 이용해 로컬 데이터베이스 사용하기 - 회원가입 예제

by JongSeok 2023. 1. 11.

안드로이드의 Room은 Android JetPack 라이브러리의 AAC(Android Architecture Component) 중 하나입니다.

이때, Room은 SQLite를 활용해서 기기 자체의 내부 저장소(로컬 데이터베이스)에 접근하는 방식입니다. 또한, Room은 기기가 네트워크에 엑세스할 수 없는 오프라인 상태에서도 콘텐츠를 탐색할 수 있으며 다시 기기가 온라인 상태가 되면 변경사항이 DB에 동기화된다는 특징이 있습니다.

AAC의 다른 컴포넌트들(ViewModel, LiveData, Databinding...)과 함께 많이 사용됩니다.

 

구글 공식 문서에서도 SQLite 대신 Room의 사용을 적극 권장하고 있는데 그 이유는 다음과 같습니다.

  • SQL 쿼리의 컴파일 시간 확인 가능
  • 반복적이고 오류가 발생하기 쉬운 상용구 코드를 최소화하는 편의 주석
  • 간소화된 데이터베이스 이전 경로

Room의 구조

Room의 아키텍쳐 다이어그램

Room 라이브러리는 DataBase, Entity, Dao 세 가지 요소로 구성되어 있습니다.

  • DataBase : 데이터베이스를 보유하고 앱의 영구 데이터와의 기본 연결을 위한 기본 액세스 지점
  • Entity : 데이터베이스의 테이블을 클래스로 표현한 것
  • DAO(Database Access Object) : 데이터베이스에 접근하는데 사용되는 메소드(CRUD)들을 정의하는 부분

Room을 이용해 간단한 회원가입 및 로그인 예제를 작성해 보겠습니다.


Room 예제

먼저 app/build.gradle에 room 종속성과 kapt-plugin을 추가합니다.

plugins {
    ...
    id 'kotlin-kapt'
}

android {
    ...
    buildFeatures {
        viewBinding true
    }
}

dependencies {
    // Room
    implementation 'androidx.room:room-runtime:2.4.2'
    implementation 'androidx.room:room-ktx:2.4.2'
    kapt 'androidx.room:room-compiler:2.4.2'
}

이때, kapt플러그인을 추가하는 이유는 코틀린 프로젝트를 컴파일할 javac가 아닌 kotlinc로 컴파일하기 때문에 Java로 작성한 어노테이션(@) 프로세서가 동작하지 않습니다. 따라서 기존의 어노테이션 프로세서를 kapt(Kotlin Annotation Processing Tool)로 변환하는 과정이 필요하기 때문입니다.

그리고 뷰 바인딩 속성도 추가합니다.


Entity

Entity는 데이터베이스 테이블 정보를 클래스로 표현한 것입니다.

@Entity(tableName = "UserInfo")
data class UserEntity(
    @PrimaryKey(autoGenerate = true)
    val id : Long = 0L,
    @ColumnInfo
    val email: String,
    @ColumnInfo
    val password: String
)

data class를 생성하고 상단에 @Entity 어노테이션을 선언합니다.

 

저는 data class의 테이블명을 userInfo로 설정했고, 따로 명시하지 않으면 data class의 클래스명으로 설정됩니다.

테이블에는 반드시 1개 이상의 PrimaryKey가 필요한데 PrimaryKey의 속성 중 autoGenerate속성을 true로 설정하면 PrimaryKey를 자동으로 생성해 줍니다.

 

테이블의 칼럼(열)으로 설정할 변수는 @ColumnInfo 어노테이션을 명시합니다.

칼럼에서 사용할 변수 이름을 별도로 지정하고 싶다면 name속성(@ColumnInfo(name = "변수명"))을 사용합니다.

 

DAO

질의문을 통해 DB에 접근할 DAO 인터페이스를 정의합니다.

@Dao
interface LoginDAO {
    @Query("SELECT email FROM userInfo")
    fun getEmailList(): List<String>    // 등록된 회원인지 확인

    @Query("SELECT password FROM userinfo WHERE email = :email")    // 이메일에 따른 비밀번호 반환
    fun getPasswordByEmail(email: String): String

    @Insert
    fun insertUser(userInfo: UserEntity)    // 회원 등록

    @Query("DELETE FROM userinfo WHERE email = :email AND password = :password")
    fun deleteUser(email: String, password: String)    // 회원 삭제
}

Room을 사용하면 데이터베이스에 직접 접근하지 않고 DAO 객체를 생성해 간접적으로 DB에 접근합니다.

인터페이스 상단에 @Dao 어노테이션을 명시합니다. 

Dao 인터페이스에서 테이블에 접근할 CRUD 메소드(Create, Read, Update, Delete)를 @Insert, @Query, @Update, @Delete 어노테이션을 사용해 정의합니다.

 

@Query : SQL 질의문을 통해 데이터베이스에 접근

@Insert : 데이터베이스에 추가할 때 사용 (onConflict 속성은 칼럼이 충돌할 경우 어떻게 처리할지에 관한 속성)

@Update : 데이터베이스 내용을 갱신할 때 사용

@Delete : 데이터베이스 내용을 삭제할 때 사용

 

삭제는 @Delete 어노테이션만으로 Room이 파라미터로 받은 객체를 칼럼에서 추적해 알아서 삭제해 주지만 저는 PrimaryKey를 자동생성해 주었기 때문에 추적을 못하는 것 같아 쿼리문을 수동으로 작성해 삭제해 주었습니다.

Database

Database는 실제 데이터베이스와 연결하기 위한 엑세스 지점입니다.

@Database(entities = [UserEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun getLoginDAO(): LoginDAO

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        private fun buildDatabase(context: Context): AppDatabase =
            Room.databaseBuilder(
                context.applicationContext,
                AppDatabase::class.java,
                "favorite-books"
            ).build()

        fun getInstance(context: Context): AppDatabase =
            INSTANCE ?: synchronized(this) {
                INSTANCE ?: buildDatabase(context).also { INSTANCE = it }
            }
    }
}

Database는 RoomDatabase를 상속받는 추상 클래스여야 합니다.

상단에 @Dabase 어노테이션을 명시합니다. entities 속성에 DB와 연결된 항목의 목록을 배열 형식으로 포함하고 DB 버전을 관리합니다. exportSchema 속성으로 DB의 스키마를 외부에서 파일 형태로 저장할지 여부를 설정합니다.

 

클래시 내부에서는 Dao클래스를 반환하는 추상 메소드를 포함해야 합니다.

Database 객체는 생성하는데 비용이 많이 드는 작업이기 때문에 companion object(싱글톤)으로 생성합니다.

또한, 서로 다른 유저(스레드)가 동시에 데이터베이스에 접근하는 것을 막기 위해 syschronized 속성을 사용해 동기화를 시켜줍니다.

 

companion object 블럭 하단에 @Volatile 어노테이션을 볼 수 있습니다.

@Volatile 어노테이션을 명시함으로써 싱글톤을 구현할 때 가장 중요한 조건인 '단 하나의 인스턴스만을 생성' 할 수 있도록 보장하는 것입니다.


이제 메인액티비티를 보겠습니다.

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="300dp"
        android:layout_height="300dp"
        android:layout_marginTop="48dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:srcCompat="@drawable/ic_baseline_person_24" />

    <EditText
        android:id="@+id/et_email"
        android:layout_width="320dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="60dp"
        android:ems="10"
        android:hint="이메일"
        android:inputType="textEmailAddress"
        android:textColor="#000000"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/imageView" />

    <EditText
        android:id="@+id/et_password"
        android:layout_width="320dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="12dp"
        android:ems="10"
        android:hint="비밀번호"
        android:inputType="textPassword"
        android:textColor="#000000"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/et_email" />

    <Button
        android:id="@+id/btn_login"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:text="회원가입 및 로그인"
        android:textSize="18sp"
        app:layout_constraintEnd_toEndOf="@+id/et_password"
        app:layout_constraintStart_toStartOf="@+id/et_password"
        app:layout_constraintTop_toBottomOf="@+id/et_password" />

    <Button
        android:id="@+id/btn_delete"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:text="회원탈퇴"
        android:textSize="18sp"
        app:layout_constraintEnd_toEndOf="@+id/btn_login"
        app:layout_constraintStart_toStartOf="@+id/btn_login"
        app:layout_constraintTop_toBottomOf="@+id/btn_login" />

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity

class MainActivity : AppCompatActivity() {
    private val binding: ActivityMainBinding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }
    private lateinit var db: AppDatabase
    private lateinit var email: String
    private lateinit var password: String

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        // Database 객체 생성
        db = AppDatabase.getInstance(this)

        // 회원가입 및 로그인 버튼 클릭
        binding.btnLogin.setOnClickListener {
            email = binding.etEmail.text.toString()
            password = binding.etPassword.text.toString()

            if (email.isNotEmpty() && password.isNotEmpty()) {
                CoroutineScope(Dispatchers.IO).launch {
                    if (db.getLoginDAO().getEmailList().contains(email)) {
                        if (db.getLoginDAO().getPasswordByEmail(email) == password) {
                            withContext(Dispatchers.Main) {
                                Toast.makeText(applicationContext, "이미 가입된 계정입니다. 로그인을 진행합니다.", Toast.LENGTH_SHORT).show()
                            }
                        } else {
                            withContext(Dispatchers.Main) {
                                Toast.makeText(applicationContext, "비밀번호를 확인해주세요.", Toast.LENGTH_SHORT).show()
                            }
                        }
                    } else {
                        db.getLoginDAO().insertUser(UserEntity(email = email, password = password))
                        withContext(Dispatchers.Main) {
                            Toast.makeText(applicationContext, "회원가입이 완료되었습니다.", Toast.LENGTH_SHORT).show()
                        }
                    }
                }
            }
        }
        // 회원탈퇴 버튼 클릭
        binding.btnDelete.setOnClickListener {
            email = binding.etEmail.text.toString()
            password = binding.etPassword.text.toString()

            CoroutineScope(Dispatchers.Default).launch {
                if (db.getLoginDAO().getEmailList().contains(email)) {
                    db.getLoginDAO().deleteUser(email, password)
                    withContext(Dispatchers.Main) {
                        Toast.makeText(applicationContext, "회원탈퇴가 완료되었습니다.", Toast.LENGTH_SHORT).show()
                    }
                } else {
                    withContext(Dispatchers.Main) {
                        Toast.makeText(applicationContext, "등록된 계정이 없습니다.", Toast.LENGTH_SHORT).show()
                    }
                }
            }
        }
    }
}

먼저 Database를 지연 생성합니다.

 

액티비티에서 Room에 접근할 때, 메인 쓰레드에서 Room DB에 접근하려 하면 IllegalStateException이 발생합니다.

즉, DB에 접근해 Room과 관련된 작업을 하기 위해서는 Thread, Coroutine 등을 이용해 백그라운드에서 작업해야 합니다.

 

그래서 저는 코루틴을 이용해 Dispacher를 전환하며 DB의 입출력 작업과 UI 작업을 수행했습니다.

 

getEmailList()를 통해 등록된 계정인지 확인하고, getPassWordByEmail()을 통해 동일한 계정인지 확인하는 과정을 거쳤습니다.


실행결과

상황에 따른 실행 결과

cf. 하단의 App Inspection에서 실시간 DB 테이블의 상황을 확인할 수 있습니다.

728x90
반응형