본문 바로가기

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

GitHub Copilot 이 말아주는 MVVM 시작 가이드

320x100

 

Android MVVM 패턴 완벽 입문 가이드 📱

목차

  1. MVVM 패턴 이해하기
  2. 프로젝트 준비
  3. 단계별 구현
  4. 최종 프로젝트: 일기장 앱

1. MVVM 패턴 이해하기

1.1 MVVM이란?

MVVM (Model-View-ViewModel) 은 UI와 비즈니스 로직을 분리하는 아키텍처 패턴입니다.

┌──────────┐      ┌─────────────┐      ┌───────┐
│   View   │ ────▶│  ViewModel  │ ────▶│ Model │
│  (UI)    │ ◀──── │  (로직)      │ ◀──── │(데이터)│
└──────────┘      └─────────────┘      └───────┘

각 계층의 역할:

  • View (Activity/Fragment): UI 표시, 사용자 입력 전달만 담당
  • ViewModel: UI 로직, 데이터 가공, 상태 관리
  • Model: 데이터 저장/조회 (Room, Repository)

1.2 왜 MVVM을 사용할까?

기존 코드의 문제점:

// 모든 것이 Activity에 섞여 있음
class MainActivity : AppCompatActivity() {
    fun onCreate() {
        // UI 초기화
        // USB 연결
        // 데이터 전송
        // 로직 처리
    }
}

MVVM 적용 후:

// Activity: UI만 담당
class MainActivity : AppCompatActivity() {
    private val viewModel: MainViewModel by viewModels()

    fun onCreate() {
        observeData() // 데이터 관찰만
    }
}

// ViewModel: 로직 담당
class MainViewModel : ViewModel() {
    fun connectUsb() { /* 연결 로직 */ }
    fun sendData() { /* 전송 로직 */ }
}

2. 프로젝트 준비

2.1 필요한 의존성 추가

app/build.gradle.kts 파일을 열고 다음을 추가하세요:

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("kotlin-kapt") // Room과 DataBinding에 필요
}

android {
    // ...

    buildFeatures {
        viewBinding = true
        dataBinding = true // DataBinding 활성화
    }
}

dependencies {
    // ViewModel
    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")

    // Room Database
    implementation("androidx.room:room-runtime:2.6.0")
    implementation("androidx.room:room-ktx:2.6.0")
    kapt("androidx.room:room-compiler:2.6.0")

    // Coroutines (비동기 처리)
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}

Sync Now 버튼을 클릭하여 의존성을 동기화하세요.


3. 단계별 구현

📌 Step 1: LiveData 이해하기

LiveData는 관찰 가능한 데이터 홀더입니다. 데이터가 변경되면 자동으로 UI가 업데이트됩니다.

간단한 예제:

// ViewModel
class CounterViewModel : ViewModel() {
    // MutableLiveData: 읽기/쓰기 가능
    private val _count = MutableLiveData<Int>(0)

    // LiveData: 읽기만 가능 (외부에 노출)
    val count: LiveData<Int> = _count

    fun increment() {
        _count.value = (_count.value ?: 0) + 1
    }
}

// Activity
class CounterActivity : AppCompatActivity() {
    private lateinit var binding: ActivityCounterBinding
    private val viewModel: CounterViewModel by viewModels()

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

        // LiveData 관찰
        viewModel.count.observe(this) { count ->
            binding.textCount.text = count.toString()
        }

        // 버튼 클릭
        binding.buttonIncrement.setOnClickListener {
            viewModel.increment()
        }
    }
}

코틀린 문법 설명:

  • by viewModels(): 델리게이트 패턴으로 ViewModel 자동 생성
  • observe(this) { }: 람다식으로 데이터 변경 감지
  • _countcount: 캡슐화 (외부에서 직접 수정 방지)

📌 Step 2: 현재 코드를 MVVM으로 리팩토링

Before (현재 코드):

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // Activity에서 직접 처리 ❌
        val usbConnManager = UsbCommunicationManager(this)
        usbConnManager.connect()

        val packet = basicParameterPacket.createPacket(...)
        usbConnManager.sendData(packet)
    }
}

After (MVVM 적용):

MainViewModel.kt 생성

package com.elkaphoto.printertesttools.ui.main

import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.elkaphoto.printertesttools.usbcommunicator.UsbCommunicationManager
import com.elkaphoto.printertesttools.usbcommunicator.packets.BasicParameterPacket
import com.elkaphoto.printertesttools.usbcommunicator.packets.DataInterface

/**
 * MainActivity의 ViewModel
 * - USB 연결 및 데이터 전송 로직 담당
 * - UI 상태를 LiveData로 관리
 */
class MainViewModel(application: Application) : AndroidViewModel(application) {

    // USB 통신 매니저
    private val usbManager = UsbCommunicationManager(application)

    // 연결 상태 (UI에 노출)
    private val _connectionStatus = MutableLiveData<String>()
    val connectionStatus: LiveData<String> = _connectionStatus

    // 전송 데이터 로그 (UI에 노출)
    private val _sentData = MutableLiveData<String>()
    val sentData: LiveData<String> = _sentData

    /**
     * USB 연결
     */
    fun connectUsb() {
        try {
            usbManager.connect()
            _connectionStatus.value = "연결 성공"
        } catch (e: Exception) {
            _connectionStatus.value = "연결 실패: ${e.message}"
        }
    }

    /**
     * 기본 파라미터 쿼리 전송
     */
    fun sendBasicParameterQuery() {
        val basicParameterPacket = BasicParameterPacket()
        val packet = basicParameterPacket.createPacket(
            DataInterface.INTERFACE_BASIC_PARAMETER, 
            0x01, 
            0x11
        )

        val hexString = BasicParameterPacket.byteArrayToHexString(packet)
        Log.d("MainViewModel", "전송데이터: $hexString")

        usbManager.sendData(packet)
        _sentData.value = hexString
    }

    /**
     * ViewModel이 제거될 때 리소스 정리
     */
    override fun onCleared() {
        super.onCleared()
        // USB 연결 해제 등
    }
}

MainActivity.kt 수정

package com.elkaphoto.printertesttools.ui.main

import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.elkaphoto.printertesttools.databinding.ActivityMainBinding
import com.google.android.material.snackbar.Snackbar

/**
 * MVVM 패턴이 적용된 MainActivity
 * - View: UI 표시 및 사용자 입력 처리만 담당
 * - ViewModel의 데이터를 관찰하고 UI 업데이트
 */
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    // ViewModel 초기화 (by viewModels()로 자동 생성)
    private val viewModel: MainViewModel by viewModels()

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

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // ViewModel의 데이터 관찰
        observeViewModel()

        // USB 연결 시작
        viewModel.connectUsb()

        // 데이터 전송
        viewModel.sendBasicParameterQuery()
    }

    /**
     * ViewModel의 LiveData 관찰
     */
    private fun observeViewModel() {
        // 연결 상태 관찰
        viewModel.connectionStatus.observe(this) { status ->
            Snackbar.make(binding.root, status, Snackbar.LENGTH_SHORT).show()
        }

        // 전송 데이터 관찰
        viewModel.sentData.observe(this) { data ->
            // UI에 표시 (예: TextView에 출력)
            // binding.textSentData.text = data
        }
    }
}

개선 포인트:

  • ✅ UI 로직과 비즈니스 로직 분리
  • ✅ 테스트 가능한 구조
  • ✅ 화면 회전 시 데이터 유지 (ViewModel은 생명주기 독립적)

4. 최종 프로젝트: 일기장 앱

4.1 프로젝트 구조

app/src/main/java/com/elkaphoto/diary/
├── data/
│   ├── local/
│   │   ├── DiaryDatabase.kt        # Room Database
│   │   └── DiaryDao.kt             # 데이터 접근 객체
│   ├── model/
│   │   └── Diary.kt                # 일기 데이터 모델
│   └── repository/
│       └── DiaryRepository.kt      # 데이터 저장소
├── ui/
│   ├── list/
│   │   ├── DiaryListFragment.kt    # 일기 목록 (왼쪽)
│   │   └── DiaryListViewModel.kt
│   ├── detail/
│   │   ├── DiaryDetailFragment.kt  # 일기 뷰어 (오른쪽)
│   │   └── DiaryDetailViewModel.kt
│   └── MainActivity.kt
└── util/
    └── DateUtil.kt                 # 날짜 유틸리티

📌 Step 3: Room Database 설정

Diary.kt (데이터 모델)

package com.elkaphoto.diary.data.model

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

/**
 * 일기 데이터 모델
 * @Entity: Room 테이블로 사용됨을 표시
 */
@Entity(tableName = "diary_table")
data class Diary(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,

    val title: String,          // 일기 제목
    val content: String,        // 일기 내용
    val createdAt: Long,        // 작성 시간 (타임스탬프)
    val modifiedAt: Long        // 수정 시간
)

// 코틀린 문법 설명:
// - data class: equals, hashCode, toString 자동 생성
// - val: 읽기 전용 속성
// - 기본값 설정: id = 0

DiaryDao.kt (데이터 접근 객체)

package com.elkaphoto.diary.data.local

import androidx.lifecycle.LiveData
import androidx.room.*
import com.elkaphoto.diary.data.model.Diary

/**
 * Room DAO (Data Access Object)
 * - 데이터베이스 쿼리 메서드 정의
 */
@Dao
interface DiaryDao {

    /**
     * 모든 일기 조회 (최신순 정렬)
     * @return LiveData로 자동 업데이트
     */
    @Query("SELECT * FROM diary_table ORDER BY createdAt DESC")
    fun getAllDiaries(): LiveData<List<Diary>>

    /**
     * 특정 일기 조회
     */
    @Query("SELECT * FROM diary_table WHERE id = :diaryId")
    fun getDiaryById(diaryId: Long): LiveData<Diary?>

    /**
     * 일기 추가
     * @return 생성된 일기의 ID
     */
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(diary: Diary): Long

    /**
     * 일기 수정
     */
    @Update
    suspend fun update(diary: Diary)

    /**
     * 일기 삭제
     */
    @Delete
    suspend fun delete(diary: Diary)

    /**
     * 모든 일기 삭제
     */
    @Query("DELETE FROM diary_table")
    suspend fun deleteAll()
}

// 코틀린 문법 설명:
// - suspend: 코루틴에서 사용 가능한 중단 함수
// - interface: 구현은 Room이 자동 생성

DiaryDatabase.kt

package com.elkaphoto.diary.data.local

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.elkaphoto.diary.data.model.Diary

/**
 * Room Database
 * - 싱글톤 패턴으로 구현
 */
@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 getDatabase(context: Context): DiaryDatabase {
            // 이미 생성된 인스턴스가 있으면 반환
            return INSTANCE ?: synchronized(this) {
                // 없으면 새로 생성
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    DiaryDatabase::class.java,
                    "diary_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

// 코틀린 문법 설명:
// - companion object: 자바의 static과 유사
// - @Volatile: 멀티스레드 환경에서 안전
// - synchronized: 동시 접근 방지
// - ?: (엘비스 연산자): null이면 우측 실행

📌 Step 4: Repository 패턴

DiaryRepository.kt

package com.elkaphoto.diary.data.repository

import androidx.lifecycle.LiveData
import com.elkaphoto.diary.data.local.DiaryDao
import com.elkaphoto.diary.data.model.Diary

/**
 * Repository 패턴
 * - ViewModel과 데이터 소스 사이의 중재자
 * - 여러 데이터 소스를 하나로 통합 가능
 */
class DiaryRepository(private val diaryDao: DiaryDao) {

    // 모든 일기 (LiveData로 자동 업데이트)
    val allDiaries: LiveData<List<Diary>> = diaryDao.getAllDiaries()

    /**
     * 특정 일기 조회
     */
    fun getDiaryById(diaryId: Long): LiveData<Diary?> {
        return diaryDao.getDiaryById(diaryId)
    }

    /**
     * 일기 추가
     * suspend: 백그라운드 스레드에서 실행
     */
    suspend fun insert(diary: Diary): Long {
        return diaryDao.insert(diary)
    }

    /**
     * 일기 수정
     */
    suspend fun update(diary: Diary) {
        diaryDao.update(diary)
    }

    /**
     * 일기 삭제
     */
    suspend fun delete(diary: Diary) {
        diaryDao.delete(diary)
    }
}

// 왜 Repository가 필요할까?
// - ViewModel은 데이터 출처를 몰라도 됨
// - Room, 서버 API 등 여러 소스를 통합 관리
// - 테스트 시 Mock Repository로 교체 가능

📌 Step 5: ViewModel 구현

DiaryListViewModel.kt

package com.elkaphoto.diary.ui.list

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import com.elkaphoto.diary.data.local.DiaryDatabase
import com.elkaphoto.diary.data.model.Diary
import com.elkaphoto.diary.data.repository.DiaryRepository
import kotlinx.coroutines.launch

/**
 * 일기 목록 ViewModel
 */
class DiaryListViewModel(application: Application) : AndroidViewModel(application) {

    private val repository: DiaryRepository
    val allDiaries: LiveData<List<Diary>>

    init {
        val diaryDao = DiaryDatabase.getDatabase(application).diaryDao()
        repository = DiaryRepository(diaryDao)
        allDiaries = repository.allDiaries
    }

    /**
     * 새 일기 추가
     */
    fun insertDiary(title: String, content: String) {
        val diary = Diary(
            title = title,
            content = content,
            createdAt = System.currentTimeMillis(),
            modifiedAt = System.currentTimeMillis()
        )

        // viewModelScope: ViewModel 생명주기에 맞춘 코루틴 스코프
        viewModelScope.launch {
            repository.insert(diary)
        }
    }

    /**
     * 일기 삭제
     */
    fun deleteDiary(diary: Diary) {
        viewModelScope.launch {
            repository.delete(diary)
        }
    }
}

// 코틀린 문법 설명:
// - init: 클래스 생성 시 자동 실행
// - viewModelScope.launch: 백그라운드 작업 (코루틴)

DiaryDetailViewModel.kt

package com.elkaphoto.diary.ui.detail

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope
import com.elkaphoto.diary.data.local.DiaryDatabase
import com.elkaphoto.diary.data.model.Diary
import com.elkaphoto.diary.data.repository.DiaryRepository
import kotlinx.coroutines.launch

/**
 * 일기 상세보기/수정 ViewModel
 */
class DiaryDetailViewModel(application: Application) : AndroidViewModel(application) {

    private val repository: DiaryRepository

    // 현재 선택된 일기 ID
    private val _currentDiaryId = MutableLiveData<Long>()

    // 선택된 일기 (자동으로 업데이트)
    val currentDiary: LiveData<Diary?> = _currentDiaryId.switchMap { id ->
        repository.getDiaryById(id)
    }

    init {
        val diaryDao = DiaryDatabase.getDatabase(application).diaryDao()
        repository = DiaryRepository(diaryDao)
    }

    /**
     * 일기 선택
     */
    fun selectDiary(diaryId: Long) {
        _currentDiaryId.value = diaryId
    }

    /**
     * 일기 수정
     */
    fun updateDiary(diary: Diary, newTitle: String, newContent: String) {
        val updatedDiary = diary.copy(
            title = newTitle,
            content = newContent,
            modifiedAt = System.currentTimeMillis()
        )

        viewModelScope.launch {
            repository.update(updatedDiary)
        }
    }

    /**
     * 일기 삭제
     */
    fun deleteDiary(diary: Diary) {
        viewModelScope.launch {
            repository.delete(diary)
        }
    }
}

// 코틀린 문법 설명:
// - switchMap: LiveData 변환 (diaryId가 바뀌면 자동으로 새 일기 조회)
// - copy(): data class의 복사본 생성 (일부 속성만 변경)

📌 Step 6: UI 구현 (Fragment)

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"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- 왼쪽: 일기 목록 -->
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/fragment_list"
        android:name="com.elkaphoto.diary.ui.list.DiaryListFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/divider"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

    <!-- 구분선 -->
    <View
        android:id="@+id/divider"
        android:layout_width="1dp"
        android:layout_height="0dp"
        android:background="#CCCCCC"
        app:layout_constraintStart_toEndOf="@id/fragment_list"
        app:layout_constraintEnd_toStartOf="@id/fragment_detail"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintHorizontal_weight="0" />

    <!-- 오른쪽: 일기 상세보기 -->
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/fragment_detail"
        android:name="com.elkaphoto.diary.ui.detail.DiaryDetailFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintStart_toEndOf="@id/divider"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

DiaryListFragment.kt

package com.elkaphoto.diary.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.elkaphoto.diary.databinding.FragmentDiaryListBinding
import com.elkaphoto.diary.ui.detail.DiaryDetailViewModel

/**
 * 일기 목록 Fragment
 */
class DiaryListFragment : Fragment() {

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

    // 같은 Activity의 ViewModel 공유
    private val listViewModel: DiaryListViewModel by activityViewModels()
    private val detailViewModel: DiaryDetailViewModel by activityViewModels()

    private lateinit var adapter: DiaryListAdapter

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentDiaryListBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        setupRecyclerView()
        observeViewModel()
        setupAddButton()
    }

    /**
     * RecyclerView 설정
     */
    private fun setupRecyclerView() {
        adapter = DiaryListAdapter { diary ->
            // 일기 클릭 시 상세보기로 전달
            detailViewModel.selectDiary(diary.id)
        }

        binding.recyclerViewDiaries.apply {
            layoutManager = LinearLayoutManager(context)
            adapter = this@DiaryListFragment.adapter
        }
    }

    /**
     * ViewModel 데이터 관찰
     */
    private fun observeViewModel() {
        listViewModel.allDiaries.observe(viewLifecycleOwner) { diaries ->
            adapter.submitList(diaries)
        }
    }

    /**
     * 추가 버튼 설정
     */
    private fun setupAddButton() {
        binding.fabAdd.setOnClickListener {
            // 다이얼로그 또는 새 화면으로 이동
            showAddDiaryDialog()
        }
    }

    private fun showAddDiaryDialog() {
        // 다이얼로그 구현 (생략)
        listViewModel.insertDiary("새 일기", "내용을 입력하세요")
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null // 메모리 누수 방지
    }
}

// 코틀린 문법 설명:
// - by activityViewModels(): Activity 범위의 ViewModel (Fragment 간 공유)
// - _binding과 binding: null 안전성
// - apply { }: 객체에 여러 설정 적용

DiaryListAdapter.kt (RecyclerView 어댑터)

package com.elkaphoto.diary.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.elkaphoto.diary.data.model.Diary
import com.elkaphoto.diary.databinding.ItemDiaryBinding
import java.text.SimpleDateFormat
import java.util.*

/**
 * 일기 목록 어댑터
 */
class DiaryListAdapter(
    private val onItemClick: (Diary) -> Unit
) : ListAdapter<Diary, DiaryListAdapter.DiaryViewHolder>(DiaryDiffCallback()) {

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

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

    /**
     * ViewHolder
     */
    class DiaryViewHolder(
        private val binding: ItemDiaryBinding,
        private val onItemClick: (Diary) -> Unit
    ) : RecyclerView.ViewHolder(binding.root) {

        fun bind(diary: Diary) {
            binding.textTitle.text = diary.title
            binding.textPreview.text = diary.content.take(50) // 미리보기 50자
            binding.textDate.text = formatDate(diary.createdAt)

            binding.root.setOnClickListener {
                onItemClick(diary)
            }
        }

        private fun formatDate(timestamp: Long): String {
            val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
            return sdf.format(Date(timestamp))
        }
    }

    /**
     * DiffUtil: 리스트 변경 최적화
     */
    class DiaryDiffCallback : DiffUtil.ItemCallback<Diary>() {
        override fun areItemsTheSame(oldItem: Diary, newItem: Diary): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: Diary, newItem: Diary): Boolean {
            return oldItem == newItem
        }
    }
}

// 코틀린 문법 설명:
// - (Diary) -> Unit: 함수 타입 (파라미터로 함수 전달)
// - take(50): 문자열 앞 50자만 추출

📌 Step 7: 상세보기 화면

DiaryDetailFragment.kt

package com.elkaphoto.diary.ui.detail

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 com.elkaphoto.diary.databinding.FragmentDiaryDetailBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder

/**
 * 일기 상세보기 Fragment
 */
class DiaryDetailFragment : Fragment() {

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

    private val viewModel: DiaryDetailViewModel by activityViewModels()

    private var isEditMode = false

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentDiaryDetailBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        observeViewModel()
        setupButtons()
    }

    /**
     * ViewModel 관찰
     */
    private fun observeViewModel() {
        viewModel.currentDiary.observe(viewLifecycleOwner) { diary ->
            diary?.let {
                displayDiary(it)
            } ?: showEmptyState()
        }
    }

    /**
     * 일기 표시
     */
    private fun displayDiary(diary: com.elkaphoto.diary.data.model.Diary) {
        binding.editTitle.setText(diary.title)
        binding.editContent.setText(diary.content)
        setEditMode(false)
    }

    /**
     * 빈 상태 표시
     */
    private fun showEmptyState() {
        binding.editTitle.setText("")
        binding.editContent.setText("")
        binding.textEmptyState.visibility = View.VISIBLE
    }

    /**
     * 버튼 설정
     */
    private fun setupButtons() {
        // 수정 버튼
        binding.buttonEdit.setOnClickListener {
            if (isEditMode) {
                saveChanges()
            } else {
                setEditMode(true)
            }
        }

        // 삭제 버튼
        binding.buttonDelete.setOnClickListener {
            showDeleteConfirmation()
        }
    }

    /**
     * 수정 모드 전환
     */
    private fun setEditMode(enabled: Boolean) {
        isEditMode = enabled
        binding.editTitle.isEnabled = enabled
        binding.editContent.isEnabled = enabled
        binding.buttonEdit.text = if (enabled) "저장" else "수정"
    }

    /**
     * 변경사항 저장
     */
    private fun saveChanges() {
        val diary = viewModel.currentDiary.value ?: return

        val newTitle = binding.editTitle.text.toString()
        val newContent = binding.editContent.text.toString()

        viewModel.updateDiary(diary, newTitle, newContent)
        setEditMode(false)
    }

    /**
     * 삭제 확인 다이얼로그
     */
    private fun showDeleteConfirmation() {
        MaterialAlertDialogBuilder(requireContext())
            .setTitle("일기 삭제")
            .setMessage("정말로 삭제하시겠습니까?")
            .setPositiveButton("삭제") { _, _ ->
                deleteDiary()
            }
            .setNegativeButton("취소", null)
            .show()
    }

    /**
     * 일기 삭제
     */
    private fun deleteDiary() {
        val diary = viewModel.currentDiary.value ?: return
        viewModel.deleteDiary(diary)
    }

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

// 코틀린 문법 설명:
// - ?. (safe call): null이 아닐 때만 실행
// - ?: (엘비스): null이면 우측 실행
// - let { }: null이 아닐 때 블록 실행

5. 정리 및 학습 포인트

✅ MVVM 핵심 원칙

  1. View는 데이터를 관찰만 한다
  2. viewModel.data.observe(this) { data -> // UI 업데이트 }
  3. ViewModel은 View를 모른다
  4. class ViewModel { // ❌ binding.textView.text = "..." // ✅ _liveData.value = "..." }
  5. Repository가 데이터 출처를 숨긴다
  6. // ViewModel은 데이터가 Room인지, API인지 몰라도 됨 repository.getData()

📚 추가 학습 자료

  1. 코루틴: 비동기 처리
  2. Flow: LiveData의 진화형
  3. Hilt/Dagger: 의존성 주입
  4. Navigation Component: Fragment 전환 관리

🎯 연습 과제

  1. 일기 검색 기능 추가
  2. 카테고리별 분류
  3. 이미지 첨부 기능
  4. 백업/복원 기능

MVVM 패턴에 익숙해지면 더 큰 프로젝트도 체계적으로 관리할 수 있습니다! 🚀

반응형