
초보자 눈높이로 개념 → 설계 → 구현 → 확장 순서로, 중간중간 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 사용.
준비 지식 (사전 읽기)
- Kotlin 기본: 변수(val/var), 함수, 클래스, 데이터 클래스, nullable (?), 확장함수, 람다.
- 중간중간 예제에서 간단 설명 추가할게요.
- Android 기초: Activity/Fragment, RecyclerView 기본 사용법.
- 코루틴 기초(권장): viewModelScope.launch { ... } 형태로 비동기 처리.
- 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() > 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() > 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) 동작 플로우 (요약)
- 앱 시작 → MainActivity가 DiaryListFragment와 DiaryViewerFragment를 붙임.
- DiaryViewModel은 Room(Repository)을 통해 diaries: LiveData<List<Diary>>를 노출.
- DiaryListFragment의 RecyclerView는 diaries를 관찰하여 목록 갱신.
- 목록 항목 클릭 → vm.selectDiary(id) 호출 → selectedDiary가 갱신.
- DiaryViewerFragment는 selectedDiary를 관찰하여 내용 표시.
- 수정/삭제는 vm.updateDiary, vm.deleteDiary를 호출(코루틴 비동기). 삭제 후 선택 해제.
8) 단계별 학습/구현 로드맵 (실전 권장)
- 환경 세팅
- Android Studio 최신 버전 설치
- Kotlin과 Gradle 설정(위의 build.gradle 반영)
- Room만 먼저 테스트
- 간단한 Entity/DAO/Database 작성 후, Activity에서 DB에 데이터 삽입/조회 테스트
- ViewModel + LiveData 학습
- 값을 LiveData로 만들고 Activity에서 관찰하는 연습
- Repository 패턴 적용
- Repository를 통해 데이터 접근 추상화
- DataBinding 적용
- <layout> 태그와 Binding 사용해 UI와 데이터 연결
- RecyclerView & Adapter
- ListAdapter & DiffUtil로 효율적인 리스트 업데이트
- Fragment 간 통신
- Activity-scoped ViewModel( by activityViewModels() )으로 공유
- 추가 기능
- 검색, 정렬, 이미지 첨부(파일 저장/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 주입을 권장합니다.
'개발 이야기 > Android (안드로이드)' 카테고리의 다른 글
| [Android] 확 바뀐 안드로이드 스튜디오 프로파일러로 메모리 릭 & CPU 점유율 잡는 법 (0) | 2026.01.22 |
|---|---|
| Gemini가 말아주는 MVVM 시작 가이드 (0) | 2025.12.08 |
| GitHub Copilot 이 말아주는 MVVM 시작 가이드 (0) | 2025.12.08 |
| ViewModel, DataBinding 으로 ViewPager2/RecyclerView 제대로 쓰는 방법 정리 (1) | 2025.11.19 |
| [Android] MVVM 다음 스텝: Flow, StateFlow, Hilt 초보자 완벽 가이드 (1) | 2025.11.18 |
| [Android] 3년 만에 복귀한 개발자를 위한 MVVM, ViewModel, DataBinding 완벽 정복 가이드 (0) | 2025.11.18 |
| Jetpack Compose 초보자 가이드 (0) | 2024.12.23 |
| Android DataBinding과 ViewModel 적용하기 (6) | 2024.12.12 |