본문 바로가기

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

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

320x100

 

초보자 눈높이로 개념 → 설계 → 구현 → 확장 순서로, 중간중간 Kotlin 설명도 넣었습니다. 예제 코드는 Android 프로젝트에서 바로 붙여 쓸 수 있도록 구성했어요.


요약(한눈에)

  • 목표: Room 기반 일기장 앱 (왼쪽: 일기 목록, 오른쪽: 일기 뷰어). 뷰어에서 수정/삭제 가능.
  • 아키텍처: MVVM (UI: Fragment/Activity — ViewModel — Repository — Room)
  • 핵심 기술: Kotlin, AndroidX ViewModel, LiveData, DataBinding, Coroutines, Room, RecyclerView
  • 샘플 구조: MainActivity(호스트) → DiaryListFragment(왼쪽) + DiaryViewerFragment(오른쪽). 두 Fragment는 같은 Activity-scoped ViewModel 사용.

준비 지식 (사전 읽기)

  1. Kotlin 기본: 변수(val/var), 함수, 클래스, 데이터 클래스, nullable (?), 확장함수, 람다.
    • 중간중간 예제에서 간단 설명 추가할게요.
  2. Android 기초: Activity/Fragment, RecyclerView 기본 사용법.
  3. 코루틴 기초(권장): viewModelScope.launch { ... } 형태로 비동기 처리.
  4. Gradle 의존성 추가법: app 모듈 build.gradle 수정.

프로젝트 설정 (build.gradle)

app/build.gradle에 필요한 의존성 (중요한 것만):

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-kapt'
}

android {
    compileSdk 34
    defaultConfig {
        applicationId "com.example.diaryapp"
        minSdk 21
        targetSdk 34
        versionCode 1
        versionName "1.0"
    }
    buildFeatures {
        dataBinding true
    }
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.10"

    // AndroidX
    implementation 'androidx.core:core-ktx:1.12.0'
    implementation 'androidx.appcompat:appcompat:1.7.0'
    implementation 'com.google.android.material:material:1.9.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.2.0'

    // Lifecycle & ViewModel & LiveData
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
    implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'

    // Room
    implementation "androidx.room:room-runtime:2.6.1"
    kapt "androidx.room:room-compiler:2.6.1"
    implementation "androidx.room:room-ktx:2.6.1"

    // RecyclerView
    implementation 'androidx.recyclerview:recyclerview:1.3.1'

    // Coroutines
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
}

Kotlin 설명 — kapt는 Kotlin 컴파일 타임 annotation 처리기(리플렉션)로, Room의 annotation 프로세서가 필요해요.


앱 구조(폴더/클래스)

/app/src/main/java/com/example/diaryapp/
  data/
    Diary.kt            // Entity
    DiaryDao.kt
    DiaryDatabase.kt
    DiaryRepository.kt
  ui/
    MainActivity.kt
    list/
      DiaryListFragment.kt
      DiaryAdapter.kt
    viewer/
      DiaryViewerFragment.kt
  viewmodel/
    DiaryViewModel.kt

1) 데이터 계층 — Room (Entity, DAO, Database)

data/Diary.kt (Entity)

package com.example.diaryapp.data

import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.*

@Entity(tableName = "diaries")
data class Diary(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    var title: String,
    var content: String,
    val createdAt: Long = System.currentTimeMillis()
)

Kotlin 팁 — data class는 자동으로 equals, hashCode, toString, copy를 만들어줘요.

data/DiaryDao.kt

package com.example.diaryapp.data

import androidx.lifecycle.LiveData
import androidx.room.*

@Dao
interface DiaryDao {
    @Query("SELECT * FROM diaries ORDER BY createdAt DESC")
    fun getAll(): LiveData<List<Diary>>

    @Query("SELECT * FROM diaries WHERE id = :id")
    fun getById(id: Long): LiveData<Diary?>

    @Insert
    suspend fun insert(diary: Diary): Long

    @Update
    suspend fun update(diary: Diary)

    @Delete
    suspend fun delete(diary: Diary)
}

data/DiaryDatabase.kt

package com.example.diaryapp.data

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

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

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

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

코루틴 관련: DAO에 suspend 표시된 함수는 코루틴에서 호출해야 해요.


2) Repository — 데이터 추상화

data/DiaryRepository.kt

package com.example.diaryapp.data

import androidx.lifecycle.LiveData

class DiaryRepository(private val dao: DiaryDao) {

    fun getAllDiaries(): LiveData<List<Diary>> = dao.getAll()

    fun getDiary(id: Long): LiveData<Diary?> = dao.getById(id)

    suspend fun insert(diary: Diary): Long = dao.insert(diary)

    suspend fun update(diary: Diary) = dao.update(diary)

    suspend fun delete(diary: Diary) = dao.delete(diary)
}

Repository는 데이터 출처(Room, 네트워크 등)을 앱 나머지 부분으로부터 분리합니다.


3) ViewModel — UI와 데이터 연결

viewmodel/DiaryViewModel.kt

package com.example.diaryapp.viewmodel

import android.app.Application
import androidx.lifecycle.*
import com.example.diaryapp.data.Diary
import com.example.diaryapp.data.DiaryDatabase
import com.example.diaryapp.data.DiaryRepository
import kotlinx.coroutines.launch

class DiaryViewModel(application: Application) : AndroidViewModel(application) {

    private val repo: DiaryRepository

    init {
        val db = DiaryDatabase.getInstance(application)
        repo = DiaryRepository(db.diaryDao())
    }

    // 전체 목록 관찰
    val diaries: LiveData<List<Diary>> = repo.getAllDiaries()

    // 선택된 일기 id를 저장 (List-Viewer 간 공유)
    private val _selectedId = MutableLiveData<Long?>()
    val selectedId: LiveData<Long?> = _selectedId

    // 선택된 일기 자체를 관찰 (selectedId 변경 시 자동으로 diary LiveData가 갱신됨)
    val selectedDiary: LiveData<Diary?> = Transformations.switchMap(_selectedId) { id ->
        if (id == null) MutableLiveData(null) else repo.getDiary(id)
    }

    fun selectDiary(id: Long?) {
        _selectedId.value = id
    }

    // CRUD operations (coroutine)
    fun addDiary(title: String, content: String, onComplete: (Long) -> Unit = {}) {
        viewModelScope.launch {
            val id = repo.insert(Diary(title = title, content = content))
            onComplete(id)
        }
    }

    fun updateDiary(diary: Diary, onComplete: () -> Unit = {}) {
        viewModelScope.launch {
            repo.update(diary)
            onComplete()
        }
    }

    fun deleteDiary(diary: Diary, onComplete: () -> Unit = {}) {
        viewModelScope.launch {
            repo.delete(diary)
            // 선택 해제
            selectDiary(null)
            onComplete()
        }
    }
}

Kotlin 설명 — viewModelScope는 ViewModel이 살아있는 동안 자동으로 취소되는 CoroutineScope예요. UI 스레드를 막지 않고 DB 작업을 백그라운드에서 수행할 수 있습니다.

 

ViewModel 팩토리 필요 시 만들 수 있지만 AndroidViewModel로 Application 의존성을 직접 사용했고 위 방식으로 간단 구현했습니다.


4) UI: 레이아웃 구조 (DataBinding 사용)

res/layout/activity_main.xml (가로로 분할되는 화면 — 왼쪽 목록, 오른쪽 뷰어)

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
  <data/>
  <LinearLayout
      android:orientation="horizontal"
      android:layout_width="match_parent"
      android:layout_height="match_parent">

      <FrameLayout
          android:id="@+id/list_container"
          android:layout_width="0dp"
          android:layout_weight="1"
          android:layout_height="match_parent"/>

      <FrameLayout
          android:id="@+id/viewer_container"
          android:layout_width="0dp"
          android:layout_weight="2"
          android:layout_height="match_parent"/>

  </LinearLayout>
</layout>

비율: 왼쪽 1, 오른쪽 2로 설정(화면 크기에 따라 조절 가능). 작은 화면(폰)에서는 Fragment 전환으로 바꿔 쓸 수도 있어요(응답형 레이아웃).

 

res/layout/fragment_list.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
  <data>
    <variable name="vm" type="com.example.diaryapp.viewmodel.DiaryViewModel"/>
  </data>
  <LinearLayout
      android:orientation="vertical"
      android:layout_width="match_parent"
      android:layout_height="match_parent">

      <Button
          android:id="@+id/btn_add"
          android:layout_width="match_parent"
          android:layout_height="wrap_content"
          android:text="새 일기"/>

      <androidx.recyclerview.widget.RecyclerView
          android:id="@+id/rv_diaries"
          android:layout_width="match_parent"
          android:layout_height="0dp"
          android:layout_weight="1"/>
  </LinearLayout>
</layout>

res/layout/item_diary.xml (리스트 아이템)

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
  <data>
    <variable name="diary" type="com.example.diaryapp.data.Diary"/>
  </data>

  <androidx.cardview.widget.CardView
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_margin="8dp"
      android:padding="8dp">

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

          <TextView
              android:id="@+id/tv_title"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:text="@{diary.title}"
              android:textStyle="bold"
              android:textSize="16sp"/>

          <TextView
              android:id="@+id/tv_preview"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:text='@{diary.content.length() &gt; 50 ? diary.content.substring(0,50) + "..." : diary.content}'
              android:maxLines="2"/>
      </LinearLayout>

  </androidx.cardview.widget.CardView>
</layout>

res/layout/fragment_viewer.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
  <data>
    <variable name="diary" type="com.example.diaryapp.data.Diary"/>
  </data>

  <androidx.cardview.widget.CardView
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_margin="8dp"
      android:padding="8dp">

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

          <TextView
              android:id="@+id/tv_title"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:text="@{diary.title}"
              android:textStyle="bold"
              android:textSize="16sp"/>

          <TextView
              android:id="@+id/tv_preview"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:text='@{diary.content.length() &gt; 50 ? diary.content.substring(0,50) + "..." : diary.content}'
              android:maxLines="2"/>
      </LinearLayout>

  </androidx.cardview.widget.CardView>
</layout>

5) Adapter & Fragments (UI 동작)

ui/list/DiaryAdapter.kt

package com.example.diaryapp.ui.list

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.diaryapp.data.Diary
import com.example.diaryapp.databinding.ItemDiaryBinding

class DiaryAdapter(private val onClick: (Diary) -> Unit) :
    ListAdapter<Diary, DiaryAdapter.VH>(DIFF) {

    companion object {
        val DIFF = object : DiffUtil.ItemCallback<Diary>() {
            override fun areItemsTheSame(oldItem: Diary, newItem: Diary) = oldItem.id == newItem.id
            override fun areContentsTheSame(oldItem: Diary, newItem: Diary) = oldItem == newItem
        }
    }

    inner class VH(private val binding: ItemDiaryBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(item: Diary) {
            binding.diary = item
            binding.root.setOnClickListener { onClick(item) }
            binding.executePendingBindings()
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        val inflater = LayoutInflater.from(parent.context)
        val binding = ItemDiaryBinding.inflate(inflater, parent, false)
        return VH(binding)
    }

    override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(getItem(position))
}

DataBinding tip: ItemDiaryBinding은 item_diary.xml의 <layout> 태그 때문에 자동 생성됩니다.

ui/list/DiaryListFragment.kt

package com.example.diaryapp.ui.list

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.diaryapp.databinding.FragmentListBinding
import com.example.diaryapp.viewmodel.DiaryViewModel

class DiaryListFragment : Fragment() {

    private var _binding: FragmentListBinding? = null
    private val binding get() = _binding!!

    private val vm: DiaryViewModel by activityViewModels()
    private lateinit var adapter: DiaryAdapter

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        _binding = FragmentListBinding.inflate(inflater, container, false)
        binding.vm = vm
        binding.lifecycleOwner = viewLifecycleOwner

        adapter = DiaryAdapter { diary ->
            vm.selectDiary(diary.id)
        }

        binding.rvDiaries.layoutManager = LinearLayoutManager(requireContext())
        binding.rvDiaries.adapter = adapter

        binding.btnAdd.setOnClickListener {
            // 단순한 다이얼로그로 추가 (실전에선 새 Fragment/Activity 추천)
            AddDiaryDialog.show(parentFragmentManager) { title, content ->
                vm.addDiary(title, content) { newId ->
                    vm.selectDiary(newId)
                }
            }
        }

        vm.diaries.observe(viewLifecycleOwner) { list ->
            adapter.submitList(list)
        }

        return binding.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

AddDiaryDialog는 간단한 AlertDialog 형태로 제목/내용 입력을 받도록 별도 구현(간단히 구현 예시만 언급).

ui/viewer/DiaryViewerFragment.kt

package com.example.diaryapp.ui.viewer

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.example.diaryapp.databinding.FragmentViewerBinding
import com.example.diaryapp.viewmodel.DiaryViewModel

class DiaryViewerFragment : Fragment() {

    private var _binding: FragmentViewerBinding? = null
    private val binding get() = _binding!!
    private val vm: DiaryViewModel by activityViewModels()

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        _binding = FragmentViewerBinding.inflate(inflater, container, false)
        binding.lifecycleOwner = viewLifecycleOwner

        vm.selectedDiary.observe(viewLifecycleOwner) { diary ->
            binding.diary = diary
            // 비어있으면 안내 문구 보이게 처리 가능
        }

        binding.btnEdit.setOnClickListener {
            val diary = binding.diary
            if (diary != null) {
                EditDiaryDialog.show(parentFragmentManager, diary) { updatedTitle, updatedContent ->
                    diary.title = updatedTitle
                    diary.content = updatedContent
                    vm.updateDiary(diary) {
                        Toast.makeText(requireContext(), "수정되었습니다", Toast.LENGTH_SHORT).show()
                    }
                }
            }
        }

        binding.btnDelete.setOnClickListener {
            val diary = binding.diary
            if (diary != null) {
                ConfirmDialog.show(parentFragmentManager, "삭제하시겠습니까?") {
                    vm.deleteDiary(diary) {
                        Toast.makeText(requireContext(), "삭제되었습니다", Toast.LENGTH_SHORT).show()
                    }
                }
            }
        }

        return binding.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

Dialog 구현은 AlertDialog.Builder로 제목/내용 EditText를 넣어 만들면 됩니다. (간단 구현 생략; 원하시면 추가 제공 가능)


6) MainActivity — Fragment 호스트 & ViewModel 연결

ui/MainActivity.kt

package com.example.diaryapp.ui

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.example.diaryapp.R
import com.example.diaryapp.ui.list.DiaryListFragment
import com.example.diaryapp.ui.viewer.DiaryViewerFragment

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        if (savedInstanceState == null) {
            supportFragmentManager.beginTransaction()
                .replace(R.id.list_container, DiaryListFragment())
                .replace(R.id.viewer_container, DiaryViewerFragment())
                .commit()
        }
    }
}

7) 동작 플로우 (요약)

  1. 앱 시작 → MainActivity가 DiaryListFragment와 DiaryViewerFragment를 붙임.
  2. DiaryViewModel은 Room(Repository)을 통해 diaries: LiveData<List<Diary>>를 노출.
  3. DiaryListFragment의 RecyclerView는 diaries를 관찰하여 목록 갱신.
  4. 목록 항목 클릭 → vm.selectDiary(id) 호출 → selectedDiary가 갱신.
  5. DiaryViewerFragment는 selectedDiary를 관찰하여 내용 표시.
  6. 수정/삭제는 vm.updateDiary, vm.deleteDiary를 호출(코루틴 비동기). 삭제 후 선택 해제.

8) 단계별 학습/구현 로드맵 (실전 권장)

  1. 환경 세팅
    • Android Studio 최신 버전 설치
    • Kotlin과 Gradle 설정(위의 build.gradle 반영)
  2. Room만 먼저 테스트
    • 간단한 Entity/DAO/Database 작성 후, Activity에서 DB에 데이터 삽입/조회 테스트
  3. ViewModel + LiveData 학습
    • 값을 LiveData로 만들고 Activity에서 관찰하는 연습
  4. Repository 패턴 적용
    • Repository를 통해 데이터 접근 추상화
  5. DataBinding 적용
    • <layout> 태그와 Binding 사용해 UI와 데이터 연결
  6. RecyclerView & Adapter
    • ListAdapter & DiffUtil로 효율적인 리스트 업데이트
  7. Fragment 간 통신
    • Activity-scoped ViewModel( by activityViewModels() )으로 공유
  8. 추가 기능
    • 검색, 정렬, 이미지 첨부(파일 저장/URI 관리), 백업/복구 등

9) 팁 & 흔한 실수

  • DB 작업은 절대 메인 스레드에서 하지 말 것(앱이 뻗습니다). DAO는 suspend 혹은 Flow 사용.
  • ViewModel에서 Context 직접 사용 금지: AndroidViewModel을 제외하곤 Context 참조는 피하세요. 대신 Application이나 DI 사용.
  • LiveData vs StateFlow: 둘 다 좋습니다. 초보자에게는 LiveData가 관찰하기 쉬움.
  • DataBinding에서 뷰 업데이트를 binding.executePendingBindings()로 강제할 수 있음(어댑터 성능 이슈 해결에 사용).
  • 테스트: DB는 Room.inMemoryDatabaseBuilder로 단위 테스트 가능.

10) 확장 아이디어 (다음 단계)

  • 일기에 사진 첨부(이미지 저장/URI), 태그/카테고리 추가
  • Sync: 원격 서버와 동기화
  • 검색/필터/달력 뷰
  • UI: Master-Detail을 Navigation Component로 구현 (작은 화면도 자연스럽게 처리)

마무리 — 핵심 정리

  • MVVM의 핵심은 UI는 오직 렌더링과 사용자 이벤트 처리, ViewModel은 UI 상태 관리, Repository는 데이터 소스 추상화, Room은 안전한 로컬 저장 입니다.
  • 위 예제는 Activity-scoped ViewModel을 사용해 List와 Viewer가 같은 상태(shared state)를 보게 했습니다. 실무에서는 DI(Hilt 등)로 ViewModel/Repository 주입을 권장합니다.
반응형