본문 바로가기

개발 이야기/Android (안드로이드)

Gemini가 말아주는 MVVM 시작 가이드

320x100

 

[MVVM + Room + Kotlin] 일기장 앱 개발 가이드

 

Phase 0: 개념 잡기 (Java 개발자를 위한 브리핑)

1. MVVM 패턴이란?

과거에는 Activity가 UI도 그리고, DB도 찌르고, 로직도 처리했죠? (God Activity). MVVM은 이를 철저히 분리합니다.

  • Model (Room DB): 데이터 그 자체입니다.
  • View (XML, Activity): 껍데기입니다. "화면에 무엇을 보여라"라는 명령을 받기만 하고, 사용자의 클릭을 ViewModel에 전달만 합니다. 절대 로직을 갖지 않습니다.
  • ViewModel: 화면을 위한 데이터를 들고 있는 상태 저장소입니다. 화면 회전(Rotation)이 일어나도 죽지 않습니다. View가 "나 뭐 보여줘?" 하고 관찰(Observe)하고 있는 대상입니다.

2. 코틀린 문법 리프레시 (3년 만의 복귀)

  • Null Safety (?, !!): 코틀린은 null이 될 수 있는 변수(String?)와 없는 변수(String)를 엄격히 구분합니다.
  • val vs var: final 변수는 val, 변경 가능하면 var.
  • Data Class: 자바의 POJO(Getter/Setter/toString/Equals)를 한 줄로 끝냅니다.
  • Coroutines (suspend): 자바의 AsyncTask나 Thread를 대체합니다. 비동기 코드를 동기 코드처럼 짭니다.

Phase 1: 프로젝트 설정 (Gradle)

최신 안드로이드 개발은 의존성 설정이 반입니다. build.gradle (Module: app)에 다음을 추가합니다.

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("kotlin-kapt") // 데이터 바인딩 및 Room 처리를 위한 어노테이션 프로세서
}

android {
    // ... 설정 유지

    // 데이터 바인딩 활성화
    buildFeatures {
        dataBinding = true
        viewBinding = true
    }
}

dependencies {
    // ViewModel & LiveData
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.2")
    implementation("androidx.activity:activity-ktx:1.8.0") // by viewModels() 사용 위함

    // Room DB
    val roomVersion = "2.6.1"
    implementation("androidx.room:room-runtime:$roomVersion")
    implementation("androidx.room:room-ktx:$roomVersion") // 코루틴 지원
    kapt("androidx.room:room-compiler:$roomVersion")
}

Phase 2: Model 설계 (Room DB)

DB 테이블과 쿼리를 만드는 단계입니다.

1. Entity (테이블)

자바의 Bean과 같습니다. data class를 사용합니다.

// Diary.kt
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "diary_table")
data class Diary(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0, // 기본값 0, DB 저장 시 자동 증가
    var title: String,
    var content: String,
    var date: Long = System.currentTimeMillis()
)

Kotlin Tip: 생성자 안에 val/var를 선언하면 멤버 변수 선언과 초기화가 동시에 됩니다.

2. DAO (Data Access Object)

실제 쿼리를 담당합니다. suspend 키워드에 주목하세요. (비동기 처리)

// DiaryDao.kt
import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface DiaryDao {
    // Flow: 데이터가 변경되면 실시간으로 데이터를 계속 뱉어주는 파이프라인 (RxJava와 비슷)
    @Query("SELECT * FROM diary_table ORDER BY date DESC")
    fun getAllDiaries(): Flow<List<Diary>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(diary: Diary)

    @Delete
    suspend fun delete(diary: Diary)

    @Update
    suspend fun update(diary: Diary)
}

3. Database

싱글톤 패턴으로 DB 인스턴스를 생성합니다.

// AppDatabase.kt
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import android.content.Context

@Database(entities = [Diary::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun diaryDao(): DiaryDao

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

        fun getDatabase(context: Context): AppDatabase {
            // 엘비스 연산자 (?:) - 왼쪽이 null이면 오른쪽을 실행
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "diary_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Phase 3: ViewModel (핵심 로직)

UI(Activity)와 데이터(DB) 사이의 중계자입니다.
요구사항: 목록 관리, 선택된 일기 관리(뷰어용), 수정/삭제 기능.

// DiaryViewModel.kt
import android.app.Application
import androidx.lifecycle.*
import kotlinx.coroutines.launch

// AndroidViewModel은 Application Context가 필요할 때 사용합니다 (DB 초기화 등)
class DiaryViewModel(application: Application) : AndroidViewModel(application) {

    private val dao = AppDatabase.getDatabase(application).diaryDao()

    // 1. 일기 목록 (DB에서 실시간으로 관찰)
    // asLiveData(): Flow를 UI에서 관찰하기 쉬운 LiveData로 변환
    val allDiaries: LiveData<List<Diary>> = dao.getAllDiaries().asLiveData()

    // 2. 현재 선택된 일기 (뷰어에 표시될 데이터)
    private val _selectedDiary = MutableLiveData<Diary?>()
    val selectedDiary: LiveData<Diary?> get() = _selectedDiary

    // 일기 선택 이벤트 처리
    fun selectDiary(diary: Diary) {
        _selectedDiary.value = diary
    }

    // 3. 기능 구현 (코루틴 사용)
    // viewModelScope: ViewModel이 죽으면 코루틴도 같이 취소됨 (메모리 누수 방지)
    fun insert(title: String, content: String) = viewModelScope.launch {
        val newDiary = Diary(title = title, content = content)
        dao.insert(newDiary)
    }

    fun update(diary: Diary) = viewModelScope.launch {
        dao.update(diary)
    }

    fun delete(diary: Diary) = viewModelScope.launch {
        dao.delete(diary)
        _selectedDiary.value = null // 삭제 후 뷰어 비우기
    }
}

Phase 4: UI & DataBinding (화면 구현)

요청하신 좌측 목록, 우측 뷰어 레이아웃을 XML에 작성합니다.
ConstraintLayout이나 LinearLayoutweight를 사용합니다.

1. activity_main.xml

DataBinding의 핵심: 최상위 태그가 <layout>이어야 합니다. <data> 태그 안에 ViewModel을 선언하여 XML이 직접 데이터를 바라보게 합니다.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="viewModel"
            type="com.example.diary.DiaryViewModel" />
        <variable
            name="currentDiary"
            type="com.example.diary.Diary" />
        <import type="android.view.View"/>
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal"
        android:weightSum="2">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:background="#EFEFEF"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

        <ScrollView
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:padding="16dp">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical">

                <EditText
                    android:id="@+id/etTitle"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:hint="제목"
                    android:text="@{viewModel.selectedDiary.title}" />

                <EditText
                    android:id="@+id/etContent"
                    android:layout_width="match_parent"
                    android:layout_height="200dp"
                    android:hint="내용"
                    android:gravity="top"
                    android:text="@{viewModel.selectedDiary.content}" />

                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:orientation="horizontal">

                    <Button
                        android:text="저장/수정"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:onClick="@{() -> viewModel.selectedDiary != null ? viewModel.update(viewModel.selectedDiary) : viewModel.insert(etTitle.getText().toString(), etContent.getText().toString())}" />

                    <Button
                        android:text="삭제"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:visibility="@{viewModel.selectedDiary != null ? View.VISIBLE : View.GONE}"
                        android:onClick="@{() -> viewModel.delete(viewModel.selectedDiary)}" />

                    <Button
                        android:text="새 글 쓰기"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:onClick="@{() -> viewModel.selectDiary(null)}" />
                </LinearLayout>
            </LinearLayout>
        </ScrollView>
    </LinearLayout>
</layout>

2. RecyclerView Adapter (목록 아이템)

DataBinding을 사용하면 findViewById가 사라집니다.

// DiaryAdapter.kt
class DiaryAdapter(private val onClick: (Diary) -> Unit) : 
    androidx.recyclerview.widget.ListAdapter<Diary, DiaryAdapter.ViewHolder>(DiffCallback) {

    // 뷰홀더 정의
    class ViewHolder(private val binding: ItemDiaryBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(diary: Diary, onClick: (Diary) -> Unit) {
            binding.diary = diary // XML 변수에 데이터 주입
            binding.root.setOnClickListener { onClick(diary) }
            binding.executePendingBindings() // 즉시 갱신 강제
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        // DataBindingUtil을 사용해 inflate
        val binding = ItemDiaryBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.bind(getItem(position), onClick)
    }

    // ListAdapter 필수 구현: 데이터 변경 감지 로직
    object DiffCallback : DiffUtil.ItemCallback<Diary>() {
        override fun areItemsTheSame(oldItem: Diary, newItem: Diary): Boolean = oldItem.id == newItem.id
        override fun areContentsTheSame(oldItem: Diary, newItem: Diary): Boolean = oldItem == newItem
    }
}

(참고: item_diary.xml<layout> 태그로 감싸고 <variable name="diary" type="...Diary"/>를 선언해야 합니다.)


Phase 5: Activity (연결)

이제 Activity 코드는 매우 짧아집니다. UI 로직만 남기 때문입니다.

// MainActivity.kt
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.activity.viewModels
import androidx.databinding.DataBindingUtil
import com.example.diary.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    // by viewModels() 대리자(delegate)를 사용하면 ViewModel 생성을 라이브러리가 알아서 해줍니다.
    private val viewModel: DiaryViewModel by viewModels()
    private lateinit var binding: ActivityMainBinding
    private lateinit var adapter: DiaryAdapter

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

        // 1. DataBinding 설정
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

        // 2. 생명주기 소유자 설정 (이게 있어야 LiveData 변경 시 XML이 자동 갱신됨) -> 매우 중요!
        binding.lifecycleOwner = this

        // 3. XML 변수에 ViewModel 연결
        binding.viewModel = viewModel

        // 4. 리사이클러뷰 설정
        setupRecyclerView()

        // 5. 관찰(Observe): ViewModel의 데이터가 변하면 UI 업데이트
        observeViewModel()
    }

    private fun setupRecyclerView() {
        adapter = DiaryAdapter { selectedDiary ->
            // 목록 클릭 시 ViewModel에 "이거 선택됐어"라고 알림
            viewModel.selectDiary(selectedDiary)
        }
        binding.recyclerView.adapter = adapter
    }

    private fun observeViewModel() {
        // DB 목록이 바뀌면 어댑터에 새 리스트 제출
        viewModel.allDiaries.observe(this) { diaries ->
            adapter.submitList(diaries)
        }

        // 선택된 일기가 바뀌면 제목/내용 EditText 갱신 (양방향 바인딩을 안 썼을 경우 수동 갱신 필요)
        viewModel.selectedDiary.observe(this) { diary ->
            if (diary != null) {
                binding.etTitle.setText(diary.title)
                binding.etContent.setText(diary.content)
            } else {
                // "새 글 쓰기" 모드
                binding.etTitle.setText("")
                binding.etContent.setText("")
            }
        }
    }
}

핵심 요약 및 동작 원리

  1. 앱 실행: MainActivity가 켜지고 DiaryViewModel이 로드됩니다.
  2. 데이터 로드: ViewModelRoom을 통해 DB를 구독(Flow)하고, allDiaries LiveData를 업데이트합니다.
  3. 목록 표시: MainActivityallDiaries를 관찰하다가 변경되면 Adapter에 데이터를 넘겨줍니다. (좌측 화면 갱신)
  4. 아이템 클릭: 사용자가 목록 아이템을 누르면 viewModel.selectDiary(diary)가 호출됩니다.
  5. 뷰어 표시: _selectedDiary 값이 변합니다. 이를 관찰하던 MainActivity나 XML 바인딩이 우측 에디트텍스트의 내용을 바꿉니다.
  6. 수정/삭제: 버튼을 누르면 viewModel.update() 또는 viewModel.delete()가 호출되고, DB가 바뀌면 다시 2번 과정이 자동으로 수행되어 목록이 갱신됩니다.

이 구조의 장점은 "데이터를 갱신하고 화면을 다시 그려라" 라는 코드를 짤 필요가 없다는 것입니다. 데이터만 바꾸면 화면은 알아서 따라옵니다.

반응형