์ฝ˜ํ…์ธ  ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ - ๐Ÿš€ ์•ˆ๋“œ๋กœ์ด๋“œ MVVM ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด ์™„๋ฒฝ ๊ตฌํ˜„ ๊ฐ€์ด๋“œ

๐Ÿš€ ์•ˆ๋“œ๋กœ์ด๋“œ MVVM ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด ์™„๋ฒฝ ๊ตฌํ˜„ ๊ฐ€์ด๋“œ

์นœ๊ตฌ์ฒ˜๋Ÿผ ์‰ฝ๊ฒŒ ๋ฐฐ์šฐ๋Š” ํ˜„๋Œ€์ ์ธ ์•ˆ๋“œ๋กœ์ด๋“œ ์•ฑ ์„ค๊ณ„ ๋ฐฉ๋ฒ•๋ก 

์•ˆ๋…•! ๐Ÿ˜Š ์˜ค๋Š˜์€ ์•ˆ๋“œ๋กœ์ด๋“œ ๊ฐœ๋ฐœ์ž๋ผ๋ฉด ๋ฐ˜๋“œ์‹œ ์•Œ์•„์•ผ ํ•  MVVM ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด์— ๋Œ€ํ•ด ์ด์•ผ๊ธฐํ•ด๋ณผ ๊ฑฐ์•ผ. ํ˜น์‹œ ์ฝ”๋“œ๊ฐ€ ์ŠคํŒŒ๊ฒŒํ‹ฐ์ฒ˜๋Ÿผ ์—‰์ผœ์„œ ์œ ์ง€๋ณด์ˆ˜๊ฐ€ ์ง€์˜ฅ ๊ฐ™์•˜๋˜ ๊ฒฝํ—˜ ์žˆ์–ด? ์•„๋‹ˆ๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ ํ•˜๋‚˜์— ์ˆ˜์ฒœ ์ค„์˜ ์ฝ”๋“œ๊ฐ€ ๋“ค์–ด๊ฐ€์„œ ์–ด๋””์„œ๋ถ€ํ„ฐ ์†๋Œ€์•ผ ํ• ์ง€ ๋ง‰๋ง‰ํ–ˆ๋˜ ์ ์€? ๊ทธ๋ ‡๋‹ค๋ฉด ์ด ๊ธ€์ด ๋„ˆ์—๊ฒŒ ๋”ฑ ๋งž๋Š” ํ•ด๊ฒฐ์ฑ…์ด ๋  ๊ฑฐ์•ผ! ๐ŸŽฏ

MVVM์€ Model-View-ViewModel์˜ ์•ฝ์ž๋กœ, ๊ตฌ๊ธ€์ด ๊ณต์‹์ ์œผ๋กœ ๊ถŒ์žฅํ•˜๋Š” ์•ˆ๋“œ๋กœ์ด๋“œ ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด์ด์•ผ. ์ด ํŒจํ„ด์„ ์ œ๋Œ€๋กœ ์ดํ•ดํ•˜๊ณ  ๊ตฌํ˜„ํ•˜๋ฉด, ์ฝ”๋“œ์˜ ๊ฐ€๋…์„ฑ๊ณผ ์œ ์ง€๋ณด์ˆ˜์„ฑ์ด ์—„์ฒญ๋‚˜๊ฒŒ ํ–ฅ์ƒ๋˜๊ณ , ํ…Œ์ŠคํŠธ๋„ ํ›จ์”ฌ ์‰ฌ์›Œ์ ธ. ๋งˆ์น˜ ์ •๋ฆฌ์ •๋ˆ์ด ์ž˜ ๋œ ๋ฐฉ์ฒ˜๋Ÿผ ๋ง์ด์•ผ! โœจ

View Activity Fragment XML Layout UI ํ‘œ์‹œ ์‚ฌ์šฉ์ž ์ž…๋ ฅ ViewModel LiveData StateFlow ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๋ฐ์ดํ„ฐ ๊ฐ€๊ณต ์ƒํƒœ ๊ด€๋ฆฌ Model Repository Database Network ๋ฐ์ดํ„ฐ ์†Œ์Šค ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ MVVM Architecture Pattern

๐Ÿค” MVVM์ด ๋ญ๊ธธ๋ž˜ ์ด๋ ‡๊ฒŒ ๋‚œ๋ฆฌ์•ผ?

๋จผ์ € MVVM์ด ์™œ ํ•„์š”ํ•œ์ง€๋ถ€ํ„ฐ ์ด์•ผ๊ธฐํ•ด๋ณผ๊ฒŒ. ์˜ˆ์ „์—๋Š” ์•ˆ๋“œ๋กœ์ด๋“œ ๊ฐœ๋ฐœํ•  ๋•Œ ๋Œ€๋ถ€๋ถ„ ์•กํ‹ฐ๋น„ํ‹ฐ๋‚˜ ํ”„๋ž˜๊ทธ๋จผํŠธ์— ๋ชจ๋“  ์ฝ”๋“œ๋ฅผ ๋•Œ๋ ค ๋„ฃ์—ˆ์–ด. UI ์—…๋ฐ์ดํŠธ๋„ ํ•˜๊ณ , ๋„คํŠธ์›Œํฌ ํ†ต์‹ ๋„ ํ•˜๊ณ , ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ ‘๊ทผ๋„ ํ•˜๊ณ ... ์ด๋Ÿฐ ์‹์œผ๋กœ ๊ฐœ๋ฐœํ•˜๋ฉด ์ฒ˜์Œ์—” ๋น ๋ฅด๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์ง€๋งŒ, ์‹œ๊ฐ„์ด ์ง€๋‚˜๋ฉด์„œ ๋ฌธ์ œ๊ฐ€ ์ƒ๊ฒจ.

์ฒซ์งธ, ์ฝ”๋“œ๊ฐ€ ๋„ˆ๋ฌด ๋ณต์žกํ•ด์ ธ์„œ ๋‚˜์ค‘์— ์ˆ˜์ •ํ•˜๋ ค๋ฉด ์–ด๋””๋ฅผ ๊ณ ์ณ์•ผ ํ• ์ง€ ์ฐพ๊ธฐ๊ฐ€ ํž˜๋“ค์–ด. ๋‘˜์งธ, ํ…Œ์ŠคํŠธ๊ฐ€ ๊ฑฐ์˜ ๋ถˆ๊ฐ€๋Šฅํ•ด์ ธ. UI์™€ ๋กœ์ง์ด ๋’ค์„ž์—ฌ ์žˆ์œผ๋‹ˆ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•  ์ˆ˜๊ฐ€ ์—†๊ฑฐ๋“ . ์…‹์งธ, ํ™”๋ฉด ํšŒ์ „ ๊ฐ™์€ ์ƒ๋ช…์ฃผ๊ธฐ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ๋ฐ์ดํ„ฐ๊ฐ€ ๋‚ ์•„๊ฐ€๋ฒ„๋ฆฌ๋Š” ๋ฌธ์ œ๋„ ์žˆ์–ด.

๐Ÿ’ก MVVM์˜ ํ•ต์‹ฌ ์ฒ ํ•™

๊ด€์‹ฌ์‚ฌ์˜ ๋ถ„๋ฆฌ(Separation of Concerns)
๊ฐ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ž๊ธฐ ์—ญํ• ๋งŒ ์ถฉ์‹คํžˆ ์ˆ˜ํ–‰ํ•˜๋„๋ก ์ฑ…์ž„์„ ๋ช…ํ™•ํžˆ ๋‚˜๋ˆ„๋Š” ๊ฑฐ์•ผ. View๋Š” ํ™”๋ฉด ํ‘œ์‹œ๋งŒ, ViewModel์€ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๋งŒ, Model์€ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ๋งŒ ๋‹ด๋‹นํ•˜๋Š” ์‹์œผ๋กœ ๋ง์ด์•ผ!

๐Ÿ—๏ธ MVVM์˜ ์„ธ ๊ฐ€์ง€ ํ•ต์‹ฌ ์ปดํฌ๋„ŒํŠธ

1๏ธโƒฃ Model - ๋ฐ์ดํ„ฐ์˜ ์™•๊ตญ

Model์€ ์•ฑ์˜ ๋ฐ์ดํ„ฐ์™€ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ๋‹ด๋‹นํ•˜๋Š” ๋ถ€๋ถ„์ด์•ผ. ์—ฌ๊ธฐ์„œ๋Š” ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค, ๋„คํŠธ์›Œํฌ API, SharedPreferences ๊ฐ™์€ ๋ฐ์ดํ„ฐ ์†Œ์Šค๋“ค๊ณผ ์ง์ ‘ ์†Œํ†ตํ•ด. ์ฃผ๋กœ Repository ํŒจํ„ด๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉ๋˜๋Š”๋ฐ, ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜๊ฐ€ ์–ด๋””๋“  ์ƒ๊ด€์—†์ด ์ผ๊ด€๋œ ์ธํ„ฐํŽ˜์ด์Šค๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ์–ด.

// ๋ฐ์ดํ„ฐ ํด๋ž˜์Šค ์ •์˜
data class User(
    val id: Int,
    val name: String,
    val email: String,
    val profileImageUrl: String
)

// Repository ์ธํ„ฐํŽ˜์ด์Šค
interface UserRepository {
    suspend fun getUser(userId: Int): Result<User>
    suspend fun updateUser(user: User): Result<Unit>
    fun observeUser(userId: Int): Flow<User>
}

// Repository ๊ตฌํ˜„์ฒด
class UserRepositoryImpl(
    private val apiService: ApiService,
    private val userDao: UserDao
) : UserRepository {
    
    override suspend fun getUser(userId: Int): Result<User> {
        return try {
            // ๋จผ์ € ๋กœ์ปฌ DB์—์„œ ํ™•์ธ
            val localUser = userDao.getUserById(userId)
            if (localUser != null) {
                Result.success(localUser)
            } else {
                // ์—†์œผ๋ฉด API์—์„œ ๊ฐ€์ ธ์˜ค๊ธฐ
                val response = apiService.getUser(userId)
                if (response.isSuccessful) {
                    response.body()?.let { user ->
                        userDao.insertUser(user)
                        Result.success(user)
                    } ?: Result.failure(Exception("Empty response"))
                } else {
                    Result.failure(Exception("API Error: ${response.code()}"))
                }
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    override fun observeUser(userId: Int): Flow<User> {
        return userDao.observeUser(userId)
    }
}

์œ„ ์ฝ”๋“œ๋ฅผ ๋ณด๋ฉด Repository๊ฐ€ API์™€ Database ๋‘ ๊ฐ€์ง€ ๋ฐ์ดํ„ฐ ์†Œ์Šค๋ฅผ ์ถ”์ƒํ™”ํ•˜๊ณ  ์žˆ์–ด. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋‚˜์ค‘์— ๋ฐ์ดํ„ฐ ์†Œ์Šค๋ฅผ ๋ณ€๊ฒฝํ•˜๊ฑฐ๋‚˜ ์ถ”๊ฐ€ํ•ด๋„ ViewModel์€ ์ „ํ˜€ ์ˆ˜์ •ํ•  ํ•„์š”๊ฐ€ ์—†์–ด! ๐ŸŽ‰

2๏ธโƒฃ View - ์‚ฌ์šฉ์ž๊ฐ€ ๋ณด๋Š” ์„ธ์ƒ

View๋Š” ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณด์ด๋Š” UI๋ฅผ ๋‹ด๋‹นํ•ด. ์•ˆ๋“œ๋กœ์ด๋“œ์—์„œ๋Š” ์ฃผ๋กœ Activity, Fragment, ๊ทธ๋ฆฌ๊ณ  XML ๋ ˆ์ด์•„์›ƒ์ด ์—ฌ๊ธฐ์— ํ•ด๋‹น๋ผ. View์˜ ๊ฐ€์žฅ ์ค‘์š”ํ•œ ์›์น™์€ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ํฌํ•จํ•˜์ง€ ์•Š๋Š”๋‹ค๋Š” ๊ฑฐ์•ผ. ์˜ค์ง ViewModel์—์„œ ์ œ๊ณตํ•˜๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ํ™”๋ฉด์— ํ‘œ์‹œํ•˜๊ณ , ์‚ฌ์šฉ์ž์˜ ์ž…๋ ฅ์„ ViewModel์— ์ „๋‹ฌํ•˜๋Š” ์—ญํ• ๋งŒ ํ•ด.

class UserProfileFragment : Fragment() {
    
    // ViewBinding ์‚ฌ์šฉ
    private var _binding: FragmentUserProfileBinding? = null
    private val binding get() = _binding!!
    
    // ViewModel ์ดˆ๊ธฐํ™”
    private val viewModel: UserProfileViewModel by viewModels()
    
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentUserProfileBinding.inflate(inflater, container, false)
        return binding.root
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        setupObservers()
        setupListeners()
    }
    
    private fun setupObservers() {
        // ViewModel์˜ ๋ฐ์ดํ„ฐ ๊ด€์ฐฐ
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                // ์‚ฌ์šฉ์ž ์ •๋ณด ๊ด€์ฐฐ
                launch {
                    viewModel.userState.collect { state ->
                        when (state) {
                            is UiState.Loading -> showLoading()
                            is UiState.Success -> showUserProfile(state.data)
                            is UiState.Error -> showError(state.message)
                        }
                    }
                }
                
                // ์ด๋ฒคํŠธ ๊ด€์ฐฐ
                launch {
                    viewModel.events.collect { event ->
                        when (event) {
                            is UserProfileEvent.ShowToast -> {
                                Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
                            }
                            is UserProfileEvent.NavigateToEdit -> {
                                // ํŽธ์ง‘ ํ™”๋ฉด์œผ๋กœ ์ด๋™
                                findNavController().navigate(
                                    R.id.action_profile_to_edit
                                )
                            }
                        }
                    }
                }
            }
        }
    }
    
    private fun setupListeners() {
        binding.editButton.setOnClickListener {
            viewModel.onEditClicked()
        }
        
        binding.refreshButton.setOnClickListener {
            viewModel.refreshUser()
        }
    }
    
    private fun showUserProfile(user: User) {
        binding.apply {
            nameTextView.text = user.name
            emailTextView.text = user.email
            Glide.with(this@UserProfileFragment)
                .load(user.profileImageUrl)
                .into(profileImageView)
        }
    }
    
    private fun showLoading() {
        binding.progressBar.visibility = View.VISIBLE
        binding.contentLayout.visibility = View.GONE
    }
    
    private fun showError(message: String) {
        binding.progressBar.visibility = View.GONE
        binding.errorTextView.text = message
        binding.errorTextView.visibility = View.VISIBLE
    }
    
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

View ์ฝ”๋“œ๋ฅผ ๋ณด๋ฉด ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด ์ „ํ˜€ ์—†์ง€? ๊ทธ๋ƒฅ ViewModel์—์„œ ์˜ค๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ํ™”๋ฉด์— ํ‘œ์‹œํ•˜๊ณ , ๋ฒ„ํŠผ ํด๋ฆญ ๊ฐ™์€ ์ด๋ฒคํŠธ๋ฅผ ViewModel์— ์ „๋‹ฌํ•˜๋Š” ์—ญํ• ๋งŒ ํ•ด. ์ด๊ฒŒ ๋ฐ”๋กœ ๊น”๋”ํ•œ View์˜ ๋ชจ์Šต์ด์•ผ! ๐Ÿ˜Ž

3๏ธโƒฃ ViewModel - ๋‘๋‡Œ์˜ ์ค‘์‹ฌ

ViewModel์€ MVVM์˜ ํ•ต์‹ฌ์ด์ž ๊ฐ€์žฅ ์ค‘์š”ํ•œ ๋ถ€๋ถ„์ด์•ผ. View์™€ Model ์‚ฌ์ด์˜ ์ค‘์žฌ์ž ์—ญํ• ์„ ํ•˜๋ฉด์„œ, UI ๋กœ์ง๊ณผ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ฒ˜๋ฆฌํ•ด. ๊ฐ€์žฅ ํฐ ์žฅ์ ์€ ์ƒ๋ช…์ฃผ๊ธฐ๋กœ๋ถ€ํ„ฐ ๋…๋ฆฝ์ ์ด๋ผ๋Š” ๊ฑฐ์•ผ. ํ™”๋ฉด์ด ํšŒ์ „๋˜๊ฑฐ๋‚˜ ๋‹ค์‹œ ์ƒ์„ฑ๋˜์–ด๋„ ViewModel์€ ์‚ด์•„์žˆ์–ด์„œ ๋ฐ์ดํ„ฐ๊ฐ€ ์œ ์ง€๋ผ.

class UserProfileViewModel(
    private val userRepository: UserRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    
    // UI ์ƒํƒœ๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” StateFlow
    private val _userState = MutableStateFlow<UiState<User>>(UiState.Loading)
    val userState: StateFlow<UiState<User>> = _userState.asStateFlow()
    
    // ์ผํšŒ์„ฑ ์ด๋ฒคํŠธ๋ฅผ ์œ„ํ•œ SharedFlow
    private val _events = MutableSharedFlow<UserProfileEvent>()
    val events: SharedFlow<UserProfileEvent> = _events.asSharedFlow()
    
    // ์‚ฌ์šฉ์ž ID (SavedStateHandle๋กœ ์ €์žฅ๋˜์–ด ํ”„๋กœ์„ธ์Šค ์ข…๋ฃŒ ํ›„์—๋„ ๋ณต์›)
    private val userId: Int = savedStateHandle.get<Int>("userId") ?: 0
    
    init {
        loadUser()
    }
    
    private fun loadUser() {
        viewModelScope.launch {
            _userState.value = UiState.Loading
            
            userRepository.getUser(userId)
                .onSuccess { user ->
                    _userState.value = UiState.Success(user)
                }
                .onFailure { exception ->
                    _userState.value = UiState.Error(
                        exception.message ?: "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค"
                    )
                    _events.emit(
                        UserProfileEvent.ShowToast("์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")
                    )
                }
        }
    }
    
    fun refreshUser() {
        viewModelScope.launch {
            _userState.value = UiState.Loading
            
            // ๊ฐ•์ œ๋กœ ์ƒˆ๋กœ๊ณ ์นจ (์บ์‹œ ๋ฌด์‹œ)
            userRepository.getUser(userId)
                .onSuccess { user ->
                    _userState.value = UiState.Success(user)
                    _events.emit(
                        UserProfileEvent.ShowToast("์ƒˆ๋กœ๊ณ ์นจ ์™„๋ฃŒ!")
                    )
                }
                .onFailure { exception ->
                    _userState.value = UiState.Error(
                        exception.message ?: "์ƒˆ๋กœ๊ณ ์นจ ์‹คํŒจ"
                    )
                }
        }
    }
    
    fun onEditClicked() {
        viewModelScope.launch {
            _events.emit(UserProfileEvent.NavigateToEdit)
        }
    }
    
    fun updateUserName(newName: String) {
        viewModelScope.launch {
            val currentState = _userState.value
            if (currentState is UiState.Success) {
                val updatedUser = currentState.data.copy(name = newName)
                
                userRepository.updateUser(updatedUser)
                    .onSuccess {
                        _userState.value = UiState.Success(updatedUser)
                        _events.emit(
                            UserProfileEvent.ShowToast("์ด๋ฆ„์ด ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค")
                        )
                    }
                    .onFailure {
                        _events.emit(
                            UserProfileEvent.ShowToast("์ด๋ฆ„ ๋ณ€๊ฒฝ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค")
                        )
                    }
            }
        }
    }
}

// UI ์ƒํƒœ๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” sealed class
sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String) : UiState<Nothing>()
}

// ์ผํšŒ์„ฑ ์ด๋ฒคํŠธ๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” sealed class
sealed class UserProfileEvent {
    data class ShowToast(val message: String) : UserProfileEvent()
    object NavigateToEdit : UserProfileEvent()
}

ViewModel ์ฝ”๋“œ๋ฅผ ๋ณด๋ฉด ๋ช‡ ๊ฐ€์ง€ ์ค‘์š”ํ•œ ํŒจํ„ด์ด ๋ณด์—ฌ. StateFlow๋ฅผ ์‚ฌ์šฉํ•ด์„œ UI ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๊ณ , SharedFlow๋กœ ์ผํšŒ์„ฑ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ  ์žˆ์–ด. ๋˜ํ•œ viewModelScope๋ฅผ ์‚ฌ์šฉํ•ด์„œ ์ฝ”๋ฃจํ‹ด์„ ๊ด€๋ฆฌํ•˜๋Š”๋ฐ, ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ViewModel์ด ์†Œ๋ฉธ๋  ๋•Œ ์ž๋™์œผ๋กœ ์ฝ”๋ฃจํ‹ด๋„ ์ทจ์†Œ๋ผ์„œ ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜๋ฅผ ๋ฐฉ์ง€ํ•  ์ˆ˜ ์žˆ์–ด! ๐Ÿ‘

๐Ÿ”„ ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ๊ณผ LiveData vs StateFlow

MVVM์„ ๊ตฌํ˜„ํ•  ๋•Œ ๊ฐ€์žฅ ๋งŽ์ด ๊ณ ๋ฏผํ•˜๋Š” ๋ถ€๋ถ„์ด ๋ฐ”๋กœ "์–ด๋–ป๊ฒŒ ๋ฐ์ดํ„ฐ๋ฅผ ๊ด€์ฐฐํ•˜๊ณ  ์—…๋ฐ์ดํŠธํ•  ๊ฒƒ์ธ๊ฐ€"์•ผ. ์•ˆ๋“œ๋กœ์ด๋“œ์—์„œ๋Š” ํฌ๊ฒŒ ์„ธ ๊ฐ€์ง€ ๋ฐฉ๋ฒ•์ด ์žˆ์–ด.

๐Ÿ“ฑ LiveData - ์ „ํ†ต์ ์ธ ๋ฐฉ์‹

LiveData๋Š” ๊ตฌ๊ธ€์ด ์ฒ˜์Œ MVVM์„ ์†Œ๊ฐœํ•  ๋•Œ ํ•จ๊ป˜ ๋‚˜์˜จ ๊ด€์ฐฐ ๊ฐ€๋Šฅํ•œ ๋ฐ์ดํ„ฐ ํ™€๋”์•ผ. ์ƒ๋ช…์ฃผ๊ธฐ๋ฅผ ์ธ์‹ํ•ด์„œ ํ™œ์„ฑ ์ƒํƒœ์˜ Observer๋งŒ ์—…๋ฐ์ดํŠธํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๊ฑฑ์ •์ด ์—†์–ด.

class UserViewModel : ViewModel() {
    
    private val _userName = MutableLiveData<String>()
    val userName: LiveData<String> = _userName
    
    private val _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean> = _isLoading
    
    // Transformations๋ฅผ ์‚ฌ์šฉํ•œ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜
    val userNameLength: LiveData<Int> = Transformations.map(userName) {
        it.length
    }
    
    // ์—ฌ๋Ÿฌ LiveData๋ฅผ ๊ฒฐํ•ฉ
    val userInfo: LiveData<String> = MediatorLiveData<String>().apply {
        addSource(userName) { name ->
            value = "์‚ฌ์šฉ์ž: $name"
        }
    }
    
    fun updateUserName(name: String) {
        _userName.value = name
    }
}

// Fragment์—์„œ ๊ด€์ฐฐ
class MyFragment : Fragment() {
    private val viewModel: UserViewModel by viewModels()
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        viewModel.userName.observe(viewLifecycleOwner) { name ->
            binding.nameTextView.text = name
        }
        
        viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
            binding.progressBar.isVisible = isLoading
        }
    }
}

๐Ÿ’ก LiveData์˜ ์žฅ์ 

โ€ข ์ƒ๋ช…์ฃผ๊ธฐ ์ธ์‹์œผ๋กœ ์ž๋™ ๊ตฌ๋… ํ•ด์ œ
โ€ข ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๋ฐฉ์ง€
โ€ข UI ์Šค๋ ˆ๋“œ์—์„œ ์ž๋™ ์—…๋ฐ์ดํŠธ
โ€ข ๊ฐ„๋‹จํ•œ API๋กœ ๋ฐฐ์šฐ๊ธฐ ์‰ฌ์›€

๐ŸŒŠ StateFlow & SharedFlow - ํ˜„๋Œ€์ ์ธ ๋ฐฉ์‹

Kotlin Coroutines์™€ ํ•จ๊ป˜ ๋“ฑ์žฅํ•œ Flow๋Š” ๋” ๊ฐ•๋ ฅํ•˜๊ณ  ์œ ์—ฐํ•œ ๋ฐฉ์‹์ด์•ผ. StateFlow๋Š” LiveData์™€ ๋น„์Šทํ•˜์ง€๋งŒ ์ฝ”๋ฃจํ‹ด ๊ธฐ๋ฐ˜์ด๊ณ , SharedFlow๋Š” ์ด๋ฒคํŠธ ์ŠคํŠธ๋ฆผ์— ์ ํ•ฉํ•ด.

class ModernViewModel : ViewModel() {
    
    // StateFlow - ์ƒํƒœ ๊ด€๋ฆฌ
    private val _userName = MutableStateFlow("")
    val userName: StateFlow<String> = _userName.asStateFlow()
    
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
    
    // SharedFlow - ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ
    private val _events = MutableSharedFlow<UiEvent>()
    val events: SharedFlow<UiEvent> = _events.asSharedFlow()
    
    // Flow ๋ณ€ํ™˜ ๋ฐ ๊ฒฐํ•ฉ
    val userNameLength: StateFlow<Int> = userName
        .map { it.length }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = 0
        )
    
    // ์—ฌ๋Ÿฌ Flow ๊ฒฐํ•ฉ
    val combinedState: StateFlow<CombinedState> = combine(
        userName,
        isLoading
    ) { name, loading ->
        CombinedState(name, loading)
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = CombinedState("", false)
    )
    
    fun updateUserName(name: String) {
        _userName.value = name
    }
    
    fun sendEvent() {
        viewModelScope.launch {
            _events.emit(UiEvent.ShowMessage("์ด๋ฒคํŠธ ๋ฐœ์ƒ!"))
        }
    }
}

// Fragment์—์„œ ์ˆ˜์ง‘
class ModernFragment : Fragment() {
    private val viewModel: ModernViewModel by viewModels()
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                // StateFlow ์ˆ˜์ง‘
                launch {
                    viewModel.userName.collect { name ->
                        binding.nameTextView.text = name
                    }
                }
                
                // SharedFlow ์ˆ˜์ง‘
                launch {
                    viewModel.events.collect { event ->
                        when (event) {
                            is UiEvent.ShowMessage -> {
                                Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
                            }
                        }
                    }
                }
            }
        }
    }
}

sealed class UiEvent {
    data class ShowMessage(val message: String) : UiEvent()
}

๐ŸŽฏ LiveData vs StateFlow ๋น„๊ต

ํŠน์ง• LiveData StateFlow
์ƒ๋ช…์ฃผ๊ธฐ ์ธ์‹ ์ž๋™ ์ˆ˜๋™ (repeatOnLifecycle ํ•„์š”)
์ดˆ๊ธฐ๊ฐ’ ์„ ํƒ์  ํ•„์ˆ˜
์Šค๋ ˆ๋“œ ์•ˆ์ „์„ฑ ๋ฉ”์ธ ์Šค๋ ˆ๋“œ๋งŒ ๋ชจ๋“  ์Šค๋ ˆ๋“œ
์—ฐ์‚ฐ์ž ์ œํ•œ์  (Transformations) ํ’๋ถ€ํ•œ Flow ์—ฐ์‚ฐ์ž
ํ…Œ์ŠคํŠธ ์–ด๋ ค์›€ ์‰ฌ์›€

๊ฐœ์ธ์ ์œผ๋กœ๋Š” ์ƒˆ๋กœ์šด ํ”„๋กœ์ ํŠธ๋ผ๋ฉด StateFlow๋ฅผ ์ถ”์ฒœํ•ด. ๋” ํ˜„๋Œ€์ ์ด๊ณ  ๊ฐ•๋ ฅํ•˜๊ฑฐ๋“ . ํ•˜์ง€๋งŒ ๊ธฐ์กด ํ”„๋กœ์ ํŠธ์—์„œ LiveData๋ฅผ ์ž˜ ์“ฐ๊ณ  ์žˆ๋‹ค๋ฉด ๊ตณ์ด ๋ฐ”๊ฟ€ ํ•„์š”๋Š” ์—†์–ด. ๋‘˜ ๋‹ค ํ›Œ๋ฅญํ•œ ๋„๊ตฌ๋‹ˆ๊นŒ! ๐Ÿ˜Š

๐Ÿ”— Data Binding - ์ž๋™ํ™”์˜ ๋งˆ๋ฒ•

Data Binding์„ ์‚ฌ์šฉํ•˜๋ฉด XML ๋ ˆ์ด์•„์›ƒ์—์„œ ์ง์ ‘ ViewModel์˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ”์ธ๋”ฉํ•  ์ˆ˜ ์žˆ์–ด. ์ฝ”๋“œ๊ฐ€ ํ›จ์”ฌ ๊ฐ„๊ฒฐํ•ด์ง€์ง€๋งŒ, ๋””๋ฒ„๊น…์ด ์–ด๋ ต๊ณ  ๋นŒ๋“œ ์‹œ๊ฐ„์ด ๋Š˜์–ด๋‚˜๋Š” ๋‹จ์ ๋„ ์žˆ์–ด.

<!-- layout/fragment_user.xml -->
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
            name="viewModel"
            type="com.example.UserViewModel" />
    </data>
    
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
        
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{viewModel.userName}" />
        
        <ProgressBar
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:visibility="@{viewModel.isLoading ? View.VISIBLE : View.GONE}" />
        
        <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="์ƒˆ๋กœ๊ณ ์นจ"
            android:onClick="@{() -> viewModel.refresh()}" />
    </LinearLayout>
</layout>

// Fragment
class UserFragment : Fragment() {
    private lateinit var binding: FragmentUserBinding
    private val viewModel: UserViewModel by viewModels()
    
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = DataBindingUtil.inflate(
            inflater,
            R.layout.fragment_user,
            container,
            false
        )
        binding.lifecycleOwner = viewLifecycleOwner
        binding.viewModel = viewModel
        return binding.root
    }
}

๐Ÿ› ๏ธ ์‹ค์ „ MVVM ๊ตฌํ˜„ - ์™„์ „ํ•œ ์˜ˆ์ œ

์ด์ œ ์‹ค์ œ๋กœ ๋™์ž‘ํ•˜๋Š” ์™„์ „ํ•œ MVVM ์•ฑ์„ ๋งŒ๋“ค์–ด๋ณผ๊ฒŒ. ๊ฐ„๋‹จํ•œ ํ•  ์ผ ๊ด€๋ฆฌ ์•ฑ์„ ๋งŒ๋“ค๋ฉด์„œ ๋ชจ๋“  ๊ฐœ๋…์„ ์ ์šฉํ•ด๋ณด์ž! ๐Ÿ“

1๋‹จ๊ณ„: ํ”„๋กœ์ ํŠธ ์„ค์ •

// build.gradle.kts (Module)
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("kotlin-kapt")
    id("com.google.dagger.hilt.android")
}

android {
    // ... ๊ธฐ๋ณธ ์„ค์ •
    
    buildFeatures {
        viewBinding = true
    }
}

dependencies {
    // Core
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.11.0")
    
    // ViewModel & LiveData
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
    
    // Fragment
    implementation("androidx.fragment:fragment-ktx:1.6.2")
    
    // Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
    
    // Room Database
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    kapt("androidx.room:room-compiler:2.6.1")
    
    // Hilt (Dependency Injection)
    implementation("com.google.dagger:hilt-android:2.50")
    kapt("com.google.dagger:hilt-compiler:2.50")
    
    // Testing
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
    testImplementation("androidx.arch.core:core-testing:2.2.0")
}

2๋‹จ๊ณ„: Model ๊ณ„์ธต ๊ตฌํ˜„

// ๋ฐ์ดํ„ฐ ํด๋ž˜์Šค
@Entity(tableName = "todos")
data class Todo(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val title: String,
    val description: String,
    val isCompleted: Boolean = false,
    val createdAt: Long = System.currentTimeMillis(),
    val priority: Priority = Priority.MEDIUM
)

enum class Priority {
    LOW, MEDIUM, HIGH
}

// DAO (Data Access Object)
@Dao
interface TodoDao {
    @Query("SELECT * FROM todos ORDER BY priority DESC, createdAt DESC")
    fun getAllTodos(): Flow<List<Todo>>
    
    @Query("SELECT * FROM todos WHERE id = :id")
    suspend fun getTodoById(id: Long): Todo?
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTodo(todo: Todo): Long
    
    @Update
    suspend fun updateTodo(todo: Todo)
    
    @Delete
    suspend fun deleteTodo(todo: Todo)
    
    @Query("SELECT * FROM todos WHERE isCompleted = :isCompleted")
    fun getTodosByStatus(isCompleted: Boolean): Flow<List<Todo>>
}

// Database
@Database(entities = [Todo::class], version = 1)
abstract class TodoDatabase : RoomDatabase() {
    abstract fun todoDao(): TodoDao
}

// Repository
interface TodoRepository {
    fun getAllTodos(): Flow<List<Todo>>
    suspend fun getTodoById(id: Long): Todo?
    suspend fun insertTodo(todo: Todo): Long
    suspend fun updateTodo(todo: Todo)
    suspend fun deleteTodo(todo: Todo)
    fun getActiveTodos(): Flow<List<Todo>>
    fun getCompletedTodos(): Flow<List<Todo>>
}

class TodoRepositoryImpl @Inject constructor(
    private val todoDao: TodoDao
) : TodoRepository {
    
    override fun getAllTodos(): Flow<List<Todo>> = todoDao.getAllTodos()
    
    override suspend fun getTodoById(id: Long): Todo? = todoDao.getTodoById(id)
    
    override suspend fun insertTodo(todo: Todo): Long = todoDao.insertTodo(todo)
    
    override suspend fun updateTodo(todo: Todo) = todoDao.updateTodo(todo)
    
    override suspend fun deleteTodo(todo: Todo) = todoDao.deleteTodo(todo)
    
    override fun getActiveTodos(): Flow<List<Todo>> = 
        todoDao.getTodosByStatus(false)
    
    override fun getCompletedTodos(): Flow<List<Todo>> = 
        todoDao.getTodosByStatus(true)
}

3๋‹จ๊ณ„: ViewModel ๊ตฌํ˜„

@HiltViewModel
class TodoListViewModel @Inject constructor(
    private val todoRepository: TodoRepository
) : ViewModel() {
    
    // UI ์ƒํƒœ
    private val _uiState = MutableStateFlow<TodoListUiState>(TodoListUiState.Loading)
    val uiState: StateFlow<TodoListUiState> = _uiState.asStateFlow()
    
    // ํ•„ํ„ฐ ์ƒํƒœ
    private val _filterType = MutableStateFlow(FilterType.ALL)
    val filterType: StateFlow<FilterType> = _filterType.asStateFlow()
    
    // ์ด๋ฒคํŠธ
    private val _events = MutableSharedFlow<TodoListEvent>()
    val events: SharedFlow<TodoListEvent> = _events.asSharedFlow()
    
    init {
        loadTodos()
    }
    
    private fun loadTodos() {
        viewModelScope.launch {
            combine(
                todoRepository.getAllTodos(),
                _filterType
            ) { todos, filter ->
                when (filter) {
                    FilterType.ALL -> todos
                    FilterType.ACTIVE -> todos.filter { !it.isCompleted }
                    FilterType.COMPLETED -> todos.filter { it.isCompleted }
                }
            }.catch { exception ->
                _uiState.value = TodoListUiState.Error(
                    exception.message ?: "ํ•  ์ผ์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"
                )
            }.collect { filteredTodos ->
                _uiState.value = if (filteredTodos.isEmpty()) {
                    TodoListUiState.Empty
                } else {
                    TodoListUiState.Success(filteredTodos)
                }
            }
        }
    }
    
    fun addTodo(title: String, description: String, priority: Priority) {
        if (title.isBlank()) {
            viewModelScope.launch {
                _events.emit(TodoListEvent.ShowError("์ œ๋ชฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”"))
            }
            return
        }
        
        viewModelScope.launch {
            try {
                val todo = Todo(
                    title = title,
                    description = description,
                    priority = priority
                )
                todoRepository.insertTodo(todo)
                _events.emit(TodoListEvent.ShowSuccess("ํ•  ์ผ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค"))
            } catch (e: Exception) {
                _events.emit(TodoListEvent.ShowError("ํ•  ์ผ ์ถ”๊ฐ€์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค"))
            }
        }
    }
    
    fun toggleTodoComplete(todo: Todo) {
        viewModelScope.launch {
            try {
                val updatedTodo = todo.copy(isCompleted = !todo.isCompleted)
                todoRepository.updateTodo(updatedTodo)
            } catch (e: Exception) {
                _events.emit(TodoListEvent.ShowError("์ƒํƒœ ๋ณ€๊ฒฝ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค"))
            }
        }
    }
    
    fun deleteTodo(todo: Todo) {
        viewModelScope.launch {
            try {
                todoRepository.deleteTodo(todo)
                _events.emit(TodoListEvent.ShowSuccess("ํ•  ์ผ์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค"))
            } catch (e: Exception) {
                _events.emit(TodoListEvent.ShowError("์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค"))
            }
        }
    }
    
    fun setFilter(filter: FilterType) {
        _filterType.value = filter
    }
    
    fun getStatistics(): TodoStatistics {
        val currentState = _uiState.value
        if (currentState is TodoListUiState.Success) {
            val todos = currentState.todos
            return TodoStatistics(
                total = todos.size,
                completed = todos.count { it.isCompleted },
                active = todos.count { !it.isCompleted }
            )
        }
        return TodoStatistics(0, 0, 0)
    }
}

// UI ์ƒํƒœ ์ •์˜
sealed class TodoListUiState {
    object Loading : TodoListUiState()
    object Empty : TodoListUiState()
    data class Success(val todos: List<Todo>) : TodoListUiState()
    data class Error(val message: String) : TodoListUiState()
}

// ์ด๋ฒคํŠธ ์ •์˜
sealed class TodoListEvent {
    data class ShowSuccess(val message: String) : TodoListEvent()
    data class ShowError(val message: String) : TodoListEvent()
    data class NavigateToDetail(val todoId: Long) : TodoListEvent()
}

// ํ•„ํ„ฐ ํƒ€์ž…
enum class FilterType {
    ALL, ACTIVE, COMPLETED
}

// ํ†ต๊ณ„ ๋ฐ์ดํ„ฐ
data class TodoStatistics(
    val total: Int,
    val completed: Int,
    val active: Int
)

4๋‹จ๊ณ„: View ๊ตฌํ˜„

@AndroidEntryPoint
class TodoListFragment : Fragment() {
    
    private var _binding: FragmentTodoListBinding? = null
    private val binding get() = _binding!!
    
    private val viewModel: TodoListViewModel by viewModels()
    private lateinit var todoAdapter: TodoAdapter
    
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentTodoListBinding.inflate(inflater, container, false)
        return binding.root
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        setupRecyclerView()
        setupListeners()
        observeViewModel()
    }
    
    private fun setupRecyclerView() {
        todoAdapter = TodoAdapter(
            onItemClick = { todo ->
                // ์ƒ์„ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™
            },
            onCheckClick = { todo ->
                viewModel.toggleTodoComplete(todo)
            },
            onDeleteClick = { todo ->
                showDeleteConfirmDialog(todo)
            }
        )
        
        binding.recyclerView.apply {
            adapter = todoAdapter
            layoutManager = LinearLayoutManager(context)
            addItemDecoration(
                DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
            )
        }
    }
    
    private fun setupListeners() {
        binding.fabAdd.setOnClickListener {
            showAddTodoDialog()
        }
        
        binding.chipAll.setOnClickListener {
            viewModel.setFilter(FilterType.ALL)
        }
        
        binding.chipActive.setOnClickListener {
            viewModel.setFilter(FilterType.ACTIVE)
        }
        
        binding.chipCompleted.setOnClickListener {
            viewModel.setFilter(FilterType.COMPLETED)
        }
    }
    
    private fun observeViewModel() {
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                // UI ์ƒํƒœ ๊ด€์ฐฐ
                launch {
                    viewModel.uiState.collect { state ->
                        when (state) {
                            is TodoListUiState.Loading -> showLoading()
                            is TodoListUiState.Empty -> showEmpty()
                            is TodoListUiState.Success -> showTodos(state.todos)
                            is TodoListUiState.Error -> showError(state.message)
                        }
                    }
                }
                
                // ํ•„ํ„ฐ ์ƒํƒœ ๊ด€์ฐฐ
                launch {
                    viewModel.filterType.collect { filter ->
                        updateFilterChips(filter)
                    }
                }
                
                // ์ด๋ฒคํŠธ ๊ด€์ฐฐ
                launch {
                    viewModel.events.collect { event ->
                        when (event) {
                            is TodoListEvent.ShowSuccess -> {
                                showSnackbar(event.message, isError = false)
                            }
                            is TodoListEvent.ShowError -> {
                                showSnackbar(event.message, isError = true)
                            }
                            is TodoListEvent.NavigateToDetail -> {
                                // ์ƒ์„ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™
                            }
                        }
                    }
                }
            }
        }
    }
    
    private fun showLoading() {
        binding.apply {
            progressBar.isVisible = true
            recyclerView.isVisible = false
            emptyView.isVisible = false
        }
    }
    
    private fun showEmpty() {
        binding.apply {
            progressBar.isVisible = false
            recyclerView.isVisible = false
            emptyView.isVisible = true
        }
    }
    
    private fun showTodos(todos: List<Todo>) {
        binding.apply {
            progressBar.isVisible = false
            recyclerView.isVisible = true
            emptyView.isVisible = false
        }
        todoAdapter.submitList(todos)
        updateStatistics()
    }
    
    private fun showError(message: String) {
        binding.apply {
            progressBar.isVisible = false
            recyclerView.isVisible = false
            emptyView.isVisible = true
            emptyTextView.text = message
        }
    }
    
    private fun updateFilterChips(filter: FilterType) {
        binding.apply {
            chipAll.isChecked = filter == FilterType.ALL
            chipActive.isChecked = filter == FilterType.ACTIVE
            chipCompleted.isChecked = filter == FilterType.COMPLETED
        }
    }
    
    private fun updateStatistics() {
        val stats = viewModel.getStatistics()
        binding.statisticsTextView.text = 
            "์ „์ฒด: ${stats.total} | ์™„๋ฃŒ: ${stats.completed} | ์ง„ํ–‰์ค‘: ${stats.active}"
    }
    
    private fun showAddTodoDialog() {
        val dialogView = layoutInflater.inflate(R.layout.dialog_add_todo, null)
        val titleEdit = dialogView.findViewById<EditText>(R.id.titleEditText)
        val descEdit = dialogView.findViewById<EditText>(R.id.descriptionEditText)
        val prioritySpinner = dialogView.findViewById<Spinner>(R.id.prioritySpinner)
        
        MaterialAlertDialogBuilder(requireContext())
            .setTitle("์ƒˆ ํ•  ์ผ ์ถ”๊ฐ€")
            .setView(dialogView)
            .setPositiveButton("์ถ”๊ฐ€") { _, _ ->
                val title = titleEdit.text.toString()
                val description = descEdit.text.toString()
                val priority = Priority.values()[prioritySpinner.selectedItemPosition]
                viewModel.addTodo(title, description, priority)
            }
            .setNegativeButton("์ทจ์†Œ", null)
            .show()
    }
    
    private fun showDeleteConfirmDialog(todo: Todo) {
        MaterialAlertDialogBuilder(requireContext())
            .setTitle("ํ•  ์ผ ์‚ญ์ œ")
            .setMessage("'${todo.title}'์„(๋ฅผ) ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?")
            .setPositiveButton("์‚ญ์ œ") { _, _ ->
                viewModel.deleteTodo(todo)
            }
            .setNegativeButton("์ทจ์†Œ", null)
            .show()
    }
    
    private fun showSnackbar(message: String, isError: Boolean) {
        Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT)
            .setBackgroundTint(
                if (isError) 
                    ContextCompat.getColor(requireContext(), R.color.error)
                else 
                    ContextCompat.getColor(requireContext(), R.color.success)
            )
            .show()
    }
    
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

// RecyclerView Adapter
class TodoAdapter(
    private val onItemClick: (Todo) -> Unit,
    private val onCheckClick: (Todo) -> Unit,
    private val onDeleteClick: (Todo) -> Unit
) : ListAdapter<Todo, TodoAdapter.TodoViewHolder>(TodoDiffCallback()) {
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoViewHolder {
        val binding = ItemTodoBinding.inflate(
            LayoutInflater.from(parent.context),
            parent,
            false
        )
        return TodoViewHolder(binding)
    }
    
    override fun onBindViewHolder(holder: TodoViewHolder, position: Int) {
        holder.bind(getItem(position))
    }
    
    inner class TodoViewHolder(
        private val binding: ItemTodoBinding
    ) : RecyclerView.ViewHolder(binding.root) {
        
        fun bind(todo: Todo) {
            binding.apply {
                titleTextView.text = todo.title
                descriptionTextView.text = todo.description
                checkBox.isChecked = todo.isCompleted
                
                // ์™„๋ฃŒ๋œ ํ•ญ๋ชฉ์€ ์ทจ์†Œ์„ 
                titleTextView.paintFlags = if (todo.isCompleted) {
                    titleTextView.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
                } else {
                    titleTextView.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
                }
                
                // ์šฐ์„ ์ˆœ์œ„ ํ‘œ์‹œ
                priorityIndicator.setBackgroundColor(
                    when (todo.priority) {
                        Priority.HIGH -> Color.RED
                        Priority.MEDIUM -> Color.YELLOW
                        Priority.LOW -> Color.GREEN
                    }
                )
                
                root.setOnClickListener { onItemClick(todo) }
                checkBox.setOnClickListener { onCheckClick(todo) }
                deleteButton.setOnClickListener { onDeleteClick(todo) }
            }
        }
    }
    
    class TodoDiffCallback : DiffUtil.ItemCallback<Todo>() {
        override fun areItemsTheSame(oldItem: Todo, newItem: Todo): Boolean {
            return oldItem.id == newItem.id
        }
        
        override fun areContentsTheSame(oldItem: Todo, newItem: Todo): Boolean {
            return oldItem == newItem
        }
    }
}

๐Ÿงช ํ…Œ์ŠคํŠธ ์ž‘์„ฑํ•˜๊ธฐ

MVVM์˜ ๊ฐ€์žฅ ํฐ ์žฅ์  ์ค‘ ํ•˜๋‚˜๊ฐ€ ๋ฐ”๋กœ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์‰ฝ๋‹ค๋Š” ๊ฑฐ์•ผ. ViewModel์€ ์•ˆ๋“œ๋กœ์ด๋“œ ํ”„๋ ˆ์ž„์›Œํฌ์— ์˜์กดํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ์ˆœ์ˆ˜ํ•œ JUnit ํ…Œ์ŠคํŠธ๋กœ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ์–ด!

class TodoListViewModelTest {
    
    // ํ…Œ์ŠคํŠธ์šฉ ์ฝ”๋ฃจํ‹ด ๋””์ŠคํŒจ์ฒ˜
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()
    
    // ํ…Œ์ŠคํŠธ์šฉ Repository (Fake ๊ตฌํ˜„)
    private lateinit var fakeRepository: FakeTodoRepository
    private lateinit var viewModel: TodoListViewModel
    
    @Before
    fun setup() {
        fakeRepository = FakeTodoRepository()
        viewModel = TodoListViewModel(fakeRepository)
    }
    
    @Test
    fun `์ดˆ๊ธฐ ์ƒํƒœ๋Š” Loading์ด์–ด์•ผ ํ•œ๋‹ค`() = runTest {
        // Given: ViewModel์ด ์ƒ์„ฑ๋จ
        
        // When: ์ดˆ๊ธฐ ์ƒํƒœ ํ™•์ธ
        val initialState = viewModel.uiState.value
        
        // Then: Loading ์ƒํƒœ์—ฌ์•ผ ํ•จ
        assertTrue(initialState is TodoListUiState.Loading)
    }
    
    @Test
    fun `ํ•  ์ผ์ด ์—†์œผ๋ฉด Empty ์ƒํƒœ๋ฅผ ๋ณด์—ฌ์ค€๋‹ค`() = runTest {
        // Given: ๋นˆ Repository
        fakeRepository.clearTodos()
        
        // When: ViewModel ์ƒ์„ฑ
        val viewModel = TodoListViewModel(fakeRepository)
        advanceUntilIdle()
        
        // Then: Empty ์ƒํƒœ
        assertTrue(viewModel.uiState.value is TodoListUiState.Empty)
    }
    
    @Test
    fun `ํ•  ์ผ ์ถ”๊ฐ€๊ฐ€ ์„ฑ๊ณตํ•˜๋ฉด Success ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค`() = runTest {
        // Given: ์œ ํšจํ•œ ํ•  ์ผ ์ •๋ณด
        val title = "ํ…Œ์ŠคํŠธ ํ•  ์ผ"
        val description = "ํ…Œ์ŠคํŠธ ์„ค๋ช…"
        
        // When: ํ•  ์ผ ์ถ”๊ฐ€
        val events = mutableListOf<TodoListEvent>()
        val job = launch {
            viewModel.events.collect { events.add(it) }
        }
        
        viewModel.addTodo(title, description, Priority.MEDIUM)
        advanceUntilIdle()
        
        // Then: Success ์ด๋ฒคํŠธ ๋ฐœ์ƒ
        assertTrue(events.any { it is TodoListEvent.ShowSuccess })
        job.cancel()
    }
    
    @Test
    fun `์ œ๋ชฉ์ด ๋น„์–ด์žˆ์œผ๋ฉด Error ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค`() = runTest {
        // Given: ๋นˆ ์ œ๋ชฉ
        val title = ""
        val description = "์„ค๋ช…"
        
        // When: ํ•  ์ผ ์ถ”๊ฐ€ ์‹œ๋„
        val events = mutableListOf<TodoListEvent>()
        val job = launch {
            viewModel.events.collect { events.add(it) }
        }
        
        viewModel.addTodo(title, description, Priority.MEDIUM)
        advanceUntilIdle()
        
        // Then: Error ์ด๋ฒคํŠธ ๋ฐœ์ƒ
        assertTrue(events.any { it is TodoListEvent.ShowError })
        job.cancel()
    }
    
    @Test
    fun `ํ•  ์ผ ์™„๋ฃŒ ํ† ๊ธ€์ด ์ •์ƒ ๋™์ž‘ํ•œ๋‹ค`() = runTest {
        // Given: ๋ฏธ์™„๋ฃŒ ํ•  ์ผ
        val todo = Todo(
            id = 1,
            title = "ํ…Œ์ŠคํŠธ",
            description = "์„ค๋ช…",
            isCompleted = false
        )
        fakeRepository.addTodo(todo)
        
        // When: ์™„๋ฃŒ ํ† ๊ธ€
        viewModel.toggleTodoComplete(todo)
        advanceUntilIdle()
        
        // Then: ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋จ
        val updatedTodo = fakeRepository.getTodoById(1)
        assertTrue(updatedTodo?.isCompleted == true)
    }
    
    @Test
    fun `ํ•„ํ„ฐ๋ง์ด ์ •์ƒ ๋™์ž‘ํ•œ๋‹ค`() = runTest {
        // Given: ์™„๋ฃŒ/๋ฏธ์™„๋ฃŒ ํ•  ์ผ๋“ค
        fakeRepository.addTodo(Todo(1, "ํ•  ์ผ 1", "", false))
        fakeRepository.addTodo(Todo(2, "ํ•  ์ผ 2", "", true))
        fakeRepository.addTodo(Todo(3, "ํ•  ์ผ 3", "", false))
        
        val viewModel = TodoListViewModel(fakeRepository)
        advanceUntilIdle()
        
        // When: ACTIVE ํ•„ํ„ฐ ์ ์šฉ
        viewModel.setFilter(FilterType.ACTIVE)
        advanceUntilIdle()
        
        // Then: ๋ฏธ์™„๋ฃŒ ํ•ญ๋ชฉ๋งŒ ํ‘œ์‹œ
        val state = viewModel.uiState.value
        assertTrue(state is TodoListUiState.Success)
        assertEquals(2, (state as TodoListUiState.Success).todos.size)
        assertTrue(state.todos.all { !it.isCompleted })
    }
    
    @Test
    fun `ํ†ต๊ณ„๊ฐ€ ์ •ํ™•ํ•˜๊ฒŒ ๊ณ„์‚ฐ๋œ๋‹ค`() = runTest {
        // Given: ์—ฌ๋Ÿฌ ํ•  ์ผ๋“ค
        fakeRepository.addTodo(Todo(1, "ํ•  ์ผ 1", "", false))
        fakeRepository.addTodo(Todo(2, "ํ•  ์ผ 2", "", true))
        fakeRepository.addTodo(Todo(3, "ํ•  ์ผ 3", "", true))
        
        val viewModel = TodoListViewModel(fakeRepository)
        advanceUntilIdle()
        
        // When: ํ†ต๊ณ„ ์กฐํšŒ
        val stats = viewModel.getStatistics()
        
        // Then: ์ •ํ™•ํ•œ ํ†ต๊ณ„
        assertEquals(3, stats.total)
        assertEquals(2, stats.completed)
        assertEquals(1, stats.active)
    }
}

// Fake Repository ๊ตฌํ˜„
class FakeTodoRepository : TodoRepository {
    private val todos = mutableListOf<Todo>()
    private val todosFlow = MutableStateFlow<List<Todo>>(emptyList())
    
    override fun getAllTodos(): Flow<List<Todo>> = todosFlow
    
    override suspend fun getTodoById(id: Long): Todo? {
        return todos.find { it.id == id }
    }
    
    override suspend fun insertTodo(todo: Todo): Long {
        val newTodo = todo.copy(id = todos.size.toLong() + 1)
        todos.add(newTodo)
        todosFlow.value = todos.toList()
        return newTodo.id
    }
    
    override suspend fun updateTodo(todo: Todo) {
        val index = todos.indexOfFirst { it.id == todo.id }
        if (index != -1) {
            todos[index] = todo
            todosFlow.value = todos.toList()
        }
    }
    
    override suspend fun deleteTodo(todo: Todo) {
        todos.removeIf { it.id == todo.id }
        todosFlow.value = todos.toList()
    }
    
    override fun getActiveTodos(): Flow<List<Todo>> {
        return todosFlow.map { it.filter { todo -> !todo.isCompleted } }
    }
    
    override fun getCompletedTodos(): Flow<List<Todo>> {
        return todosFlow.map { it.filter { todo -> todo.isCompleted } }
    }
    
    fun addTodo(todo: Todo) {
        todos.add(todo)
        todosFlow.value = todos.toList()
    }
    
    fun clearTodos() {
        todos.clear()
        todosFlow.value = emptyList()
    }
}

// ํ…Œ์ŠคํŠธ์šฉ Coroutine Rule
class MainDispatcherRule(
    private val dispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
    
    override fun starting(description: Description) {
        Dispatchers.setMain(dispatcher)
    }
    
    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

โœ… ํ…Œ์ŠคํŠธ ์ž‘์„ฑ ํŒ

โ€ข Given-When-Then ํŒจํ„ด์„ ์‚ฌ์šฉํ•˜๋ฉด ํ…Œ์ŠคํŠธ ์˜๋„๊ฐ€ ๋ช…ํ™•ํ•ด์ ธ
โ€ข Fake ๊ตฌํ˜„์„ ์‚ฌ์šฉํ•˜๋ฉด ์‹ค์ œ ์˜์กด์„ฑ ์—†์ด ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ
โ€ข ์ฝ”๋ฃจํ‹ด ํ…Œ์ŠคํŠธ๋Š” runTest์™€ advanceUntilIdle()์„ ํ™œ์šฉ
โ€ข Flow ํ…Œ์ŠคํŠธ๋Š” collect๋กœ ๊ฐ’์„ ์ˆ˜์ง‘ํ•ด์„œ ๊ฒ€์ฆ
โ€ข ๊ฐ ํ…Œ์ŠคํŠธ๋Š” ๋…๋ฆฝ์ ์ด์–ด์•ผ ํ•˜๊ณ  ์ˆœ์„œ์— ์˜์กดํ•˜๋ฉด ์•ˆ ๋ผ

โšก ์„ฑ๋Šฅ ์ตœ์ ํ™” ๋ฐ ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค

MVVM์„ ์ œ๋Œ€๋กœ ํ™œ์šฉํ•˜๋ ค๋ฉด ๋ช‡ ๊ฐ€์ง€ ์ค‘์š”ํ•œ ์ตœ์ ํ™” ๊ธฐ๋ฒ•๊ณผ ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค๋ฅผ ์•Œ์•„์•ผ ํ•ด. ์ด๊ฒƒ๋“ค์„ ์ž˜ ์ง€ํ‚ค๋ฉด ์•ฑ์˜ ์„ฑ๋Šฅ๊ณผ ์•ˆ์ •์„ฑ์ด ํฌ๊ฒŒ ํ–ฅ์ƒ๋ผ! ๐Ÿš€

1๏ธโƒฃ StateFlow์˜ ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ

class OptimizedViewModel : ViewModel() {
    
    // โŒ ๋‚˜์œ ์˜ˆ: ๋ถˆํ•„์š”ํ•œ ์žฌ๊ตฌ์„ฑ
    private val _state = MutableStateFlow(UiState())
    val state = _state.asStateFlow()
    
    // โœ… ์ข‹์€ ์˜ˆ: distinctUntilChanged๋กœ ์ค‘๋ณต ์ œ๊ฑฐ
    val optimizedState = _state
        .distinctUntilChanged()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = UiState()
        )
    
    // โœ… ์ข‹์€ ์˜ˆ: ์—ฌ๋Ÿฌ Flow๋ฅผ ํšจ์œจ์ ์œผ๋กœ ๊ฒฐํ•ฉ
    val combinedState = combine(
        userFlow,
        settingsFlow,
        notificationsFlow
    ) { user, settings, notifications ->
        CombinedState(user, settings, notifications)
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = CombinedState.Empty
    )
}

๐ŸŽฏ SharingStarted ์ „๋žต

WhileSubscribed(5000): ๊ตฌ๋…์ž๊ฐ€ ์—†์–ด์ง„ ํ›„ 5์ดˆ ๋’ค์— ์—…์ŠคํŠธ๋ฆผ Flow๋ฅผ ์ทจ์†Œ. ๋ฉ”๋ชจ๋ฆฌ ํšจ์œจ์ !
Eagerly: ์ฆ‰์‹œ ์‹œ์ž‘ํ•˜๊ณ  ViewModel์ด ์‚ด์•„์žˆ๋Š” ๋™์•ˆ ๊ณ„์† ์‹คํ–‰. ํ•ญ์ƒ ์ตœ์‹  ๋ฐ์ดํ„ฐ ํ•„์š”ํ•  ๋•Œ.
Lazily: ์ฒซ ๊ตฌ๋…์ž๊ฐ€ ๋‚˜ํƒ€๋‚  ๋•Œ ์‹œ์ž‘ํ•˜๊ณ  ๊ณ„์† ์‹คํ–‰. ์ดˆ๊ธฐํ™” ๋น„์šฉ์ด ํด ๋•Œ.

2๏ธโƒฃ ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๋ฐฉ์ง€

class SafeFragment : Fragment() {
    
    // โœ… ViewBinding ์•ˆ์ „ํ•˜๊ฒŒ ์‚ฌ์šฉ
    private var _binding: FragmentSafeBinding? = null
    private val binding get() = _binding!!
    
    // โœ… ViewModel์€ by viewModels()๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ์ƒ์„ฑ
    private val viewModel: SafeViewModel by viewModels()
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        // โœ… viewLifecycleOwner ์‚ฌ์šฉ (Fragment์˜ ์ƒ๋ช…์ฃผ๊ธฐ์™€ ์—ฐ๋™)
        viewLifecycleOwner.lifecycleScope.launch {
            // โœ… repeatOnLifecycle๋กœ ์ƒ๋ช…์ฃผ๊ธฐ ์ธ์‹
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.state.collect { state ->
                    updateUI(state)
                }
            }
        }
        
        // โŒ ๋‚˜์œ ์˜ˆ: lifecycleScope ์ง์ ‘ ์‚ฌ์šฉ
        // lifecycleScope.launch {
        //     viewModel.state.collect { state ->
        //         updateUI(state)
        //     }
        // }
    }
    
    override fun onDestroyView() {
        super.onDestroyView()
        // โœ… ViewBinding ํ•ด์ œ๋กœ ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๋ฐฉ์ง€
        _binding = null
    }
}

class SafeViewModel : ViewModel() {
    
    // โœ… viewModelScope ์‚ฌ์šฉ (์ž๋™ ์ทจ์†Œ)
    fun loadData() {
        viewModelScope.launch {
            // ViewModel์ด ์†Œ๋ฉธ๋˜๋ฉด ์ž๋™์œผ๋กœ ์ทจ์†Œ๋จ
            repository.getData().collect { data ->
                _state.value = data
            }
        }
    }
    
    // โŒ ๋‚˜์œ ์˜ˆ: GlobalScope ์‚ฌ์šฉ
    // fun loadDataBad() {
    //     GlobalScope.launch {
    //         // ์ ˆ๋Œ€ ์ทจ์†Œ๋˜์ง€ ์•Š์Œ!
    //     }
    // }
}

3๏ธโƒฃ ์ผํšŒ์„ฑ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ

ํ† ์ŠคํŠธ ๋ฉ”์‹œ์ง€๋‚˜ ๋„ค๋น„๊ฒŒ์ด์…˜ ๊ฐ™์€ ์ผํšŒ์„ฑ ์ด๋ฒคํŠธ๋Š” StateFlow๋กœ ์ฒ˜๋ฆฌํ•˜๋ฉด ์•ˆ ๋ผ. ํ™”๋ฉด ํšŒ์ „ ์‹œ ์ค‘๋ณต ์‹คํ–‰๋  ์ˆ˜ ์žˆ๊ฑฐ๋“ . SharedFlow๋ฅผ ์‚ฌ์šฉํ•˜์ž!

class EventViewModel : ViewModel() {
    
    // โŒ ๋‚˜์œ ์˜ˆ: StateFlow๋กœ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ
    private val _navigationEvent = MutableStateFlow<NavigationEvent?>(null)
    val navigationEvent = _navigationEvent.asStateFlow()
    // ๋ฌธ์ œ: ํ™”๋ฉด ํšŒ์ „ ์‹œ ์ด๋ฒคํŠธ๊ฐ€ ๋‹ค์‹œ ๋ฐœ์ƒํ•จ
    
    // โœ… ์ข‹์€ ์˜ˆ: SharedFlow๋กœ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ
    private val _events = MutableSharedFlow<UiEvent>()
    val events = _events.asSharedFlow()
    
    fun navigateToDetail() {
        viewModelScope.launch {
            _events.emit(UiEvent.NavigateToDetail)
        }
    }
    
    // โœ… ๋” ์ข‹์€ ์˜ˆ: Channel ์‚ฌ์šฉ (๋ฒ„ํผ๋ง ์ œ์–ด ๊ฐ€๋Šฅ)
    private val _eventChannel = Channel<UiEvent>(Channel.BUFFERED)
    val eventFlow = _eventChannel.receiveAsFlow()
    
    fun showMessage(message: String) {
        viewModelScope.launch {
            _eventChannel.send(UiEvent.ShowMessage(message))
        }
    }
}

// Fragment์—์„œ ์ด๋ฒคํŠธ ์ˆ˜์ง‘
class EventFragment : Fragment() {
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.events.collect { event ->
                    when (event) {
                        is UiEvent.ShowMessage -> {
                            Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
                        }
                        is UiEvent.NavigateToDetail -> {
                            findNavController().navigate(R.id.detailFragment)
                        }
                    }
                }
            }
        }
    }
}

4๏ธโƒฃ SavedStateHandle ํ™œ์šฉ

ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ข…๋ฃŒ๋˜์–ด๋„ ๋ฐ์ดํ„ฐ๋ฅผ ์œ ์ง€ํ•˜๋ ค๋ฉด SavedStateHandle์„ ์‚ฌ์šฉํ•ด์•ผ ํ•ด. ํŠนํžˆ ์‚ฌ์šฉ์ž ์ž…๋ ฅ์ด๋‚˜ ์ค‘์š”ํ•œ ์ƒํƒœ๋ฅผ ์ €์žฅํ•  ๋•Œ ํ•„์ˆ˜์•ผ!

class SavedStateViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    
    // โœ… SavedStateHandle๋กœ ์ƒํƒœ ์ €์žฅ
    var searchQuery: String
        get() = savedStateHandle.get<String>("search_query") ?: ""
        set(value) {
            savedStateHandle["search_query"] = value
        }
    
    // โœ… StateFlow์™€ ์—ฐ๋™
    val searchQueryFlow: StateFlow<String> = savedStateHandle
        .getStateFlow("search_query", "")
    
    // โœ… ๋ณต์žกํ•œ ๊ฐ์ฒด๋„ ์ €์žฅ ๊ฐ€๋Šฅ (Parcelable)
    var userProfile: UserProfile?
        get() = savedStateHandle.get<UserProfile>("user_profile")
        set(value) {
            savedStateHandle["user_profile"] = value
        }
    
    fun updateSearchQuery(query: String) {
        searchQuery = query
        // ์ž๋™์œผ๋กœ SavedStateHandle์— ์ €์žฅ๋จ
        performSearch(query)
    }
    
    private fun performSearch(query: String) {
        viewModelScope.launch {
            // ๊ฒ€์ƒ‰ ๋กœ์ง
        }
    }
}

5๏ธโƒฃ Repository ํŒจํ„ด ์ตœ์ ํ™”

class OptimizedRepository(
    private val apiService: ApiService,
    private val database: AppDatabase,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    
    // โœ… Single Source of Truth ํŒจํ„ด
    fun getUsers(): Flow<List<User>> = flow {
        // 1. ๋จผ์ € ๋กœ์ปฌ DB์—์„œ ๋ฐ์ดํ„ฐ emit
        val localUsers = database.userDao().getAllUsers()
        emit(localUsers)
        
        // 2. ๋„คํŠธ์›Œํฌ์—์„œ ์ตœ์‹  ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ
        try {
            val remoteUsers = apiService.getUsers()
            // 3. DB ์—…๋ฐ์ดํŠธ
            database.userDao().insertAll(remoteUsers)
            // 4. ์—…๋ฐ์ดํŠธ๋œ ๋ฐ์ดํ„ฐ emit
            emit(remoteUsers)
        } catch (e: Exception) {
            // ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜ ์‹œ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ ์œ ์ง€
        }
    }.flowOn(ioDispatcher)
    
    // โœ… ์บ์‹ฑ ์ „๋žต
    private val userCache = mutableMapOf<Int, User>()
    private val cacheTimeout = 5.minutes
    
    suspend fun getUserById(userId: Int): User {
        // ์บ์‹œ ํ™•์ธ
        userCache[userId]?.let { cachedUser ->
            if (System.currentTimeMillis() - cachedUser.cachedAt < cacheTimeout.inWholeMilliseconds) {
                return cachedUser
            }
        }
        
        // ์บ์‹œ ๋ฏธ์Šค - DB ํ™•์ธ
        database.userDao().getUserById(userId)?.let { return it }
        
        // DB ๋ฏธ์Šค - API ํ˜ธ์ถœ
        return apiService.getUser(userId).also { user ->
            userCache[userId] = user
            database.userDao().insert(user)
        }
    }
    
    // โœ… ์—๋Ÿฌ ํ•ธ๋“ค๋ง
    suspend fun updateUser(user: User): Result<Unit> = withContext(ioDispatcher) {
        try {
            // ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ
            database.userDao().update(user)
            
            // ์„œ๋ฒ„ ๋™๊ธฐํ™”
            val response = apiService.updateUser(user)
            if (response.isSuccessful) {
                Result.success(Unit)
            } else {
                // ์‹คํŒจ ์‹œ ๋กค๋ฐฑ
                database.userDao().update(user.copy(/* ์ด์ „ ์ƒํƒœ */))
                Result.failure(Exception("Update failed"))
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

โš ๏ธ ํ”ํ•œ ์‹ค์ˆ˜๋“ค

1. ViewModel์—์„œ Context ์ฐธ์กฐ - ์ ˆ๋Œ€ ํ•˜๋ฉด ์•ˆ ๋ผ! ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜์˜ ์ฃผ๋ฒ”
2. View์—์„œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์ฒ˜๋ฆฌ - ViewModel๋กœ ์˜ฎ๊ฒจ์•ผ ํ•ด
3. Repository๋ฅผ ๊ฑด๋„ˆ๋›ฐ๊ณ  ViewModel์—์„œ ์ง์ ‘ DB/API ์ ‘๊ทผ - ํ…Œ์ŠคํŠธ ๋ถˆ๊ฐ€๋Šฅ
4. LiveData/StateFlow๋ฅผ private์œผ๋กœ ๋…ธ์ถœํ•˜์ง€ ์•Š์Œ - ์™ธ๋ถ€์—์„œ ์ˆ˜์ • ๊ฐ€๋Šฅํ•ด์ง
5. ์ƒ๋ช…์ฃผ๊ธฐ๋ฅผ ๊ณ ๋ คํ•˜์ง€ ์•Š์€ ์ฝ”๋ฃจํ‹ด ์‚ฌ์šฉ - ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๋ฐœ์ƒ

๐ŸŽจ ๊ณ ๊ธ‰ ํŒจํ„ด๊ณผ ๊ธฐ๋ฒ•

๊ธฐ๋ณธ์ ์ธ MVVM์„ ๋„˜์–ด์„œ ๋” ๋ณต์žกํ•œ ์ƒํ™ฉ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๊ณ ๊ธ‰ ํŒจํ„ด๋“ค์„ ์•Œ์•„๋ณด์ž! ์ด๊ฒƒ๋“ค์„ ๋งˆ์Šคํ„ฐํ•˜๋ฉด ์ง„์ •ํ•œ ์•ˆ๋“œ๋กœ์ด๋“œ ์•„ํ‚คํ…์ฒ˜ ์ „๋ฌธ๊ฐ€๊ฐ€ ๋  ์ˆ˜ ์žˆ์–ด. ๐Ÿ’ช

๐Ÿ”„ MVI (Model-View-Intent) ํŒจํ„ด

MVI๋Š” MVVM์˜ ์ง„ํ™”๋œ ํ˜•ํƒœ๋กœ, ๋‹จ๋ฐฉํ–ฅ ๋ฐ์ดํ„ฐ ํ๋ฆ„์„ ๊ฐ•์กฐํ•ด. ์ƒํƒœ ๊ด€๋ฆฌ๊ฐ€ ๋” ์˜ˆ์ธก ๊ฐ€๋Šฅํ•˜๊ณ  ๋””๋ฒ„๊น…์ด ์‰ฌ์›Œ์ ธ.

// Intent: ์‚ฌ์šฉ์ž์˜ ์˜๋„๋ฅผ ๋‚˜ํƒ€๋ƒ„
sealed class TodoIntent {
    object LoadTodos : TodoIntent()
    data class AddTodo(val title: String, val description: String) : TodoIntent()
    data class ToggleTodo(val id: Long) : TodoIntent()
    data class DeleteTodo(val id: Long) : TodoIntent()
    data class FilterTodos(val filter: FilterType) : TodoIntent()
}

// State: UI์˜ ์ „์ฒด ์ƒํƒœ
data class TodoViewState(
    val todos: List<Todo> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null,
    val filter: FilterType = FilterType.ALL
) {
    val filteredTodos: List<Todo>
        get() = when (filter) {
            FilterType.ALL -> todos
            FilterType.ACTIVE -> todos.filter { !it.isCompleted }
            FilterType.COMPLETED -> todos.filter { it.isCompleted }
        }
}

// ViewModel with MVI
class MviTodoViewModel(
    private val repository: TodoRepository
) : ViewModel() {
    
    private val _state = MutableStateFlow(TodoViewState())
    val state: StateFlow<TodoViewState> = _state.asStateFlow()
    
    // Intent๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ๋‹จ์ผ ์ง„์ž…์ 
    fun processIntent(intent: TodoIntent) {
        when (intent) {
            is TodoIntent.LoadTodos -> loadTodos()
            is TodoIntent.AddTodo -> addTodo(intent.title, intent.description)
            is TodoIntent.ToggleTodo -> toggleTodo(intent.id)
            is TodoIntent.DeleteTodo -> deleteTodo(intent.id)
            is TodoIntent.FilterTodos -> filterTodos(intent.filter)
        }
    }
    
    private fun loadTodos() {
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true, error = null) }
            
            repository.getAllTodos()
                .catch { exception ->
                    _state.update { 
                        it.copy(
                            isLoading = false,
                            error = exception.message
                        )
                    }
                }
                .collect { todos ->
                    _state.update { 
                        it.copy(
                            todos = todos,
                            isLoading = false,
                            error = null
                        )
                    }
                }
        }
    }
    
    private fun addTodo(title: String, description: String) {
        viewModelScope.launch {
            try {
                val todo = Todo(title = title, description = description)
                repository.insertTodo(todo)
            } catch (e: Exception) {
                _state.update { it.copy(error = e.message) }
            }
        }
    }
    
    private fun toggleTodo(id: Long) {
        viewModelScope.launch {
            val todo = _state.value.todos.find { it.id == id } ?: return@launch
            repository.updateTodo(todo.copy(isCompleted = !todo.isCompleted))
        }
    }
    
    private fun deleteTodo(id: Long) {
        viewModelScope.launch {
            val todo = _state.value.todos.find { it.id == id } ?: return@launch
            repository.deleteTodo(todo)
        }
    }
    
    private fun filterTodos(filter: FilterType) {
        _state.update { it.copy(filter = filter) }
    }
}

// Fragment์—์„œ ์‚ฌ์šฉ
class MviTodoFragment : Fragment() {
    private val viewModel: MviTodoViewModel by viewModels()
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        // Intent ์ „์†ก
        binding.addButton.setOnClickListener {
            val title = binding.titleEdit.text.toString()
            val description = binding.descEdit.text.toString()
            viewModel.processIntent(
                TodoIntent.AddTodo(title, description)
            )
        }
        
        binding.filterAllChip.setOnClickListener {
            viewModel.processIntent(TodoIntent.FilterTodos(FilterType.ALL))
        }
        
        // State ๊ด€์ฐฐ
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.state.collect { state ->
                    render(state)
                }
            }
        }
        
        // ์ดˆ๊ธฐ ๋กœ๋“œ
        viewModel.processIntent(TodoIntent.LoadTodos)
    }
    
    private fun render(state: TodoViewState) {
        binding.progressBar.isVisible = state.isLoading
        binding.errorText.text = state.error
        binding.errorText.isVisible = state.error != null
        todoAdapter.submitList(state.filteredTodos)
    }
}

๐ŸŽญ UseCase ํŒจํ„ด (Clean Architecture)

๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ UseCase๋กœ ๋ถ„๋ฆฌํ•˜๋ฉด ์žฌ์‚ฌ์šฉ์„ฑ๊ณผ ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ์ด ๋†’์•„์ ธ. ํŠนํžˆ ์—ฌ๋Ÿฌ Repository๋ฅผ ์กฐํ•ฉํ•ด์•ผ ํ•  ๋•Œ ์œ ์šฉํ•ด!

// UseCase ์ธํ„ฐํŽ˜์ด์Šค
interface UseCase<in P, out R> {
    suspend operator fun invoke(params: P): Result<R>
}

// ๊ตฌ์ฒด์ ์ธ UseCase ๊ตฌํ˜„
class GetUserProfileUseCase(
    private val userRepository: UserRepository,
    private val settingsRepository: SettingsRepository,
    private val analyticsRepository: AnalyticsRepository
) : UseCase<GetUserProfileUseCase.Params, UserProfile> {
    
    data class Params(val userId: Int)
    
    override suspend fun invoke(params: Params): Result<UserProfile> {
        return try {
            // 1. ์‚ฌ์šฉ์ž ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
            val user = userRepository.getUser(params.userId)
                .getOrElse { return Result.failure(it) }
            
            // 2. ์„ค์ • ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
            val settings = settingsRepository.getUserSettings(params.userId)
                .getOrElse { return Result.failure(it) }
            
            // 3. ๋ถ„์„ ๋ฐ์ดํ„ฐ ๊ธฐ๋ก
            analyticsRepository.logProfileView(params.userId)
            
            // 4. ํ”„๋กœํ•„ ์กฐํ•ฉ
            val profile = UserProfile(
                user = user,
                settings = settings,
                lastViewedAt = System.currentTimeMillis()
            )
            
            Result.success(profile)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

// Flow๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” UseCase
abstract class FlowUseCase<in P, out R> {
    operator fun invoke(params: P): Flow<R> = execute(params)
        .catch { e -> emit(handleError(e)) }
    
    protected abstract fun execute(params: P): Flow<R>
    
    protected open fun handleError(throwable: Throwable): R {
        throw throwable
    }
}

class ObserveUserTodosUseCase(
    private val todoRepository: TodoRepository
) : FlowUseCase<ObserveUserTodosUseCase.Params, List<Todo>>() {
    
    data class Params(val userId: Int, val filter: FilterType)
    
    override fun execute(params: Params): Flow<List<Todo>> {
        return todoRepository.getUserTodos(params.userId)
            .map { todos ->
                when (params.filter) {
                    FilterType.ALL -> todos
                    FilterType.ACTIVE -> todos.filter { !it.isCompleted }
                    FilterType.COMPLETED -> todos.filter { it.isCompleted }
                }
            }
    }
}

// ViewModel์—์„œ UseCase ์‚ฌ์šฉ
class CleanArchitectureViewModel(
    private val getUserProfileUseCase: GetUserProfileUseCase,
    private val observeUserTodosUseCase: ObserveUserTodosUseCase,
    private val updateTodoUseCase: UpdateTodoUseCase
) : ViewModel() {
    
    private val _profileState = MutableStateFlow<UiState<UserProfile>>(UiState.Loading)
    val profileState = _profileState.asStateFlow()
    
    fun loadUserProfile(userId: Int) {
        viewModelScope.launch {
            _profileState.value = UiState.Loading
            
            getUserProfileUseCase(GetUserProfileUseCase.Params(userId))
                .onSuccess { profile ->
                    _profileState.value = UiState.Success(profile)
                }
                .onFailure { error ->
                    _profileState.value = UiState.Error(error.message ?: "Unknown error")
                }
        }
    }
    
    val todos: StateFlow<List<Todo>> = observeUserTodosUseCase(
        ObserveUserTodosUseCase.Params(
            userId = 1,
            filter = FilterType.ALL
        )
    ).stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = emptyList()
    )
}

๐Ÿ’ก UseCase๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•˜๋Š” ๊ฒฝ์šฐ

โ€ข ์—ฌ๋Ÿฌ Repository๋ฅผ ์กฐํ•ฉํ•ด์•ผ ํ•  ๋•Œ
โ€ข ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด ์žˆ์„ ๋•Œ
โ€ข ๊ฐ™์€ ๋กœ์ง์„ ์—ฌ๋Ÿฌ ViewModel์—์„œ ์žฌ์‚ฌ์šฉํ•  ๋•Œ
โ€ข ํŠธ๋žœ์žญ์…˜์ด๋‚˜ ์ˆœ์ฐจ์  ์ž‘์—…์ด ํ•„์š”ํ•  ๋•Œ
โ€ข ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ฒ ์ €ํžˆ ํ•ด์•ผ ํ•  ๋•Œ

๐Ÿ”Œ ์˜์กด์„ฑ ์ฃผ์ž… (Hilt)

Hilt๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์˜์กด์„ฑ ๊ด€๋ฆฌ๊ฐ€ ํ›จ์”ฌ ์‰ฌ์›Œ์ ธ. ํŠนํžˆ ViewModel์— Repository๋‚˜ UseCase๋ฅผ ์ฃผ์ž…ํ•  ๋•Œ ํ•„์ˆ˜์•ผ!

// Application ํด๋ž˜์Šค
@HiltAndroidApp
class TodoApplication : Application()

// Module ์ •์˜
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): TodoDatabase {
        return Room.databaseBuilder(
            context,
            TodoDatabase::class.java,
            "todo_database"
        ).build()
    }
    
    @Provides
    fun provideTodoDao(database: TodoDatabase): TodoDao {
        return database.todoDao()
    }
}

@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
    
    @Provides
    @Singleton
    fun provideTodoRepository(
        todoDao: TodoDao,
        apiService: ApiService
    ): TodoRepository {
        return TodoRepositoryImpl(todoDao, apiService)
    }
}

@Module
@InstallIn(ViewModelComponent::class)
object UseCaseModule {
    
    @Provides
    fun provideGetUserProfileUseCase(
        userRepository: UserRepository,
        settingsRepository: SettingsRepository,
        analyticsRepository: AnalyticsRepository
    ): GetUserProfileUseCase {
        return GetUserProfileUseCase(
            userRepository,
            settingsRepository,
            analyticsRepository
        )
    }
}

// ViewModel์— ์ฃผ์ž…
@HiltViewModel
class HiltTodoViewModel @Inject constructor(
    private val todoRepository: TodoRepository,
    private val getUserProfileUseCase: GetUserProfileUseCase,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : ViewModel() {
    // ViewModel ๊ตฌํ˜„
}

// Fragment์—์„œ ์‚ฌ์šฉ
@AndroidEntryPoint
class HiltTodoFragment : Fragment() {
    
    private val viewModel: HiltTodoViewModel by viewModels()
    
    // ์ถ”๊ฐ€ ์˜์กด์„ฑ๋„ ์ฃผ์ž… ๊ฐ€๋Šฅ
    @Inject
    lateinit var analytics: Analytics
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // ViewModel ์‚ฌ์šฉ
    }
}
MVVM Best Practices โœ“ ๊ด€์‹ฌ์‚ฌ ๋ถ„๋ฆฌ ๐Ÿ”„ ๋‹จ๋ฐฉํ–ฅ ๋ฐ์ดํ„ฐ ํ๋ฆ„ ๐Ÿงช ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ โ™ป๏ธ ์žฌ์‚ฌ์šฉ์„ฑ ๐Ÿ›ก๏ธ ์•ˆ์ •์„ฑ Clean Code, Clean Architecture ์œ ์ง€๋ณด์ˆ˜ ๊ฐ€๋Šฅํ•œ ์•ฑ์„ ๋งŒ๋“œ๋Š” ํ•ต์‹ฌ ์›์น™๋“ค

๐Ÿš€ ์‹ค๋ฌด์—์„œ์˜ MVVM ์ ์šฉ ์‚ฌ๋ก€

์ด๋ก ์€ ์ถฉ๋ถ„ํžˆ ๋ฐฐ์› ์œผ๋‹ˆ, ์ด์ œ ์‹ค์ œ ํ”„๋กœ์ ํŠธ์—์„œ ์–ด๋–ป๊ฒŒ ์ ์šฉํ•˜๋Š”์ง€ ์•Œ์•„๋ณด์ž. ๋‚ด๊ฐ€ ์‹ค๋ฌด์—์„œ ๊ฒช์—ˆ๋˜ ์ƒํ™ฉ๋“ค๊ณผ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์„ ๊ณต์œ ํ• ๊ฒŒ! ๐Ÿ˜Š

๐Ÿ“ฑ ๋Œ€๊ทœ๋ชจ ์•ฑ์—์„œ์˜ ๋ชจ๋“ˆํ™”

์•ฑ์ด ์ปค์ง€๋ฉด ํ•˜๋‚˜์˜ ๋ชจ๋“ˆ๋กœ ๊ด€๋ฆฌํ•˜๊ธฐ ํž˜๋“ค์–ด์ ธ. Feature ๋‹จ์œ„๋กœ ๋ชจ๋“ˆ์„ ๋‚˜๋ˆ„๊ณ , ๊ฐ ๋ชจ๋“ˆ๋งˆ๋‹ค MVVM์„ ์ ์šฉํ•˜๋Š” ๊ฒŒ ์ข‹์•„.

๐Ÿ—๏ธ ๋ชจ๋“ˆ ๊ตฌ์กฐ ์˜ˆ์‹œ

app/
โ”œโ”€โ”€ feature/
โ”‚   โ”œโ”€โ”€ auth/          (๋กœ๊ทธ์ธ/ํšŒ์›๊ฐ€์ž…)
โ”‚   โ”‚   โ”œโ”€โ”€ data/
โ”‚   โ”‚   โ”œโ”€โ”€ domain/
โ”‚   โ”‚   โ””โ”€โ”€ presentation/
โ”‚   โ”œโ”€โ”€ home/          (ํ™ˆ ํ™”๋ฉด)
โ”‚   โ”œโ”€โ”€ profile/       (ํ”„๋กœํ•„)
โ”‚   โ””โ”€โ”€ settings/      (์„ค์ •)
โ”œโ”€โ”€ core/
โ”‚   โ”œโ”€โ”€ data/          (๊ณตํ†ต Repository)
โ”‚   โ”œโ”€โ”€ domain/        (๊ณตํ†ต UseCase)
โ”‚   โ”œโ”€โ”€ ui/            (๊ณตํ†ต UI ์ปดํฌ๋„ŒํŠธ)
โ”‚   โ””โ”€โ”€ network/       (๋„คํŠธ์›Œํฌ ์„ค์ •)
โ””โ”€โ”€ shared/
    โ”œโ”€โ”€ model/         (๊ณตํ†ต ๋ฐ์ดํ„ฐ ๋ชจ๋ธ)
    โ””โ”€โ”€ util/          (์œ ํ‹ธ๋ฆฌํ‹ฐ)

๐Ÿ”„ ๊ธฐ์กด ํ”„๋กœ์ ํŠธ๋ฅผ MVVM์œผ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜

ํ•œ ๋ฒˆ์— ์ „์ฒด๋ฅผ ๋ฐ”๊พธ๋ ค๊ณ  ํ•˜์ง€ ๋งˆ! ์ ์ง„์ ์œผ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ํ•˜๋Š” ๊ฒŒ ์•ˆ์ „ํ•ด. ๋‹ค์Œ ์ˆœ์„œ๋ฅผ ์ถ”์ฒœํ•ด:

// 1๋‹จ๊ณ„: Repository ๊ณ„์ธต ์ถ”๊ฐ€
// ๊ธฐ์กด ์ฝ”๋“œ๋ฅผ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š๊ณ  Repository๋งŒ ๋จผ์ € ๋งŒ๋“ค๊ธฐ
class UserRepository {
    fun getUser(): User {
        // ๊ธฐ์กด ์ฝ”๋“œ๋ฅผ ์—ฌ๊ธฐ๋กœ ์˜ฎ๊น€
        return ApiClient.getUser()
    }
}

// 2๋‹จ๊ณ„: ViewModel ์ถ”๊ฐ€
// Activity/Fragment์˜ ๋กœ์ง์„ ViewModel๋กœ ์ด๋™
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {
    private val _user = MutableLiveData<User>()
    val user: LiveData<User> = _user
    
    fun loadUser() {
        viewModelScope.launch {
            _user.value = repository.getUser()
        }
    }
}

// 3๋‹จ๊ณ„: Activity/Fragment ๋ฆฌํŒฉํ† ๋ง
// ๊ธฐ์กด Activity๋ฅผ ์ ์ง„์ ์œผ๋กœ ์ˆ˜์ •
class UserActivity : AppCompatActivity() {
    
    // ๊ธฐ์กด ์ฝ”๋“œ๋Š” ์œ ์ง€ํ•˜๋ฉด์„œ ViewModel ์ถ”๊ฐ€
    private val viewModel: UserViewModel by viewModels()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // ์ƒˆ๋กœ์šด ๋ฐฉ์‹์œผ๋กœ ๋ฐ์ดํ„ฐ ๋กœ๋“œ
        viewModel.user.observe(this) { user ->
            updateUI(user)
        }
        viewModel.loadUser()
        
        // ๊ธฐ์กด ์ฝ”๋“œ๋Š” ์ฃผ์„ ์ฒ˜๋ฆฌํ•˜๊ณ  ๋‚˜์ค‘์— ์ œ๊ฑฐ
        // loadUserOldWay()
    }
}

// 4๋‹จ๊ณ„: ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€
// ์ƒˆ๋กœ ๋งŒ๋“  ViewModel๊ณผ Repository์— ํ…Œ์ŠคํŠธ ์ž‘์„ฑ

// 5๋‹จ๊ณ„: ๊ธฐ์กด ์ฝ”๋“œ ์ œ๊ฑฐ
// ๋ชจ๋“  ๊ฒŒ ์ž˜ ๋™์ž‘ํ•˜๋ฉด ๊ธฐ์กด ์ฝ”๋“œ ์‚ญ์ œ

๐Ÿ’ก ์‹ค๋ฌด ํŒ๊ณผ ๋…ธํ•˜์šฐ

๐ŸŽฏ ํ”„๋กœ์ ํŠธ ์‹œ์ž‘ ์ „ ์ฒดํฌ๋ฆฌ์ŠคํŠธ

1. ์•„ํ‚คํ…์ฒ˜ ๊ฒฐ์ •
โ€ข ์ˆœ์ˆ˜ MVVM vs MVI vs Clean Architecture
โ€ข ํ”„๋กœ์ ํŠธ ๊ทœ๋ชจ์™€ ํŒ€ ์—ญ๋Ÿ‰ ๊ณ ๋ ค

2. ๊ธฐ์ˆ  ์Šคํƒ ์„ ํƒ
โ€ข LiveData vs StateFlow (์ƒˆ ํ”„๋กœ์ ํŠธ๋Š” StateFlow ์ถ”์ฒœ)
โ€ข ViewBinding vs DataBinding (ViewBinding ์ถ”์ฒœ)
โ€ข Hilt vs Koin (Hilt๊ฐ€ ๊ณต์‹ ์ง€์›)

3. ํŒจํ‚ค์ง€ ๊ตฌ์กฐ ์„ค๊ณ„
โ€ข Feature ๊ธฐ๋ฐ˜ vs Layer ๊ธฐ๋ฐ˜
โ€ข ๋ชจ๋“ˆํ™” ์ „๋žต ์ˆ˜๋ฆฝ

4. ์ฝ”๋”ฉ ์ปจ๋ฒค์…˜ ์ •๋ฆฌ
โ€ข ๋„ค์ด๋ฐ ๊ทœ์น™ (ViewModel, Repository ๋“ฑ)
โ€ข ํŒŒ์ผ ๊ตฌ์กฐ ํ…œํ”Œ๋ฆฟ ์ž‘์„ฑ
โ€ข ์ฝ”๋“œ ๋ฆฌ๋ทฐ ๊ฐ€์ด๋“œ๋ผ์ธ

์‹ค๋ฌด์—์„œ MVVM์„ ์ ์šฉํ•˜๋‹ค ๋ณด๋ฉด ์—ฌ๋Ÿฌ ๊ฐ€์ง€ ์–ด๋ ค์›€์— ๋ถ€๋”ชํž ์ˆ˜ ์žˆ์–ด. ํ•˜์ง€๋งŒ ํฌ๊ธฐํ•˜์ง€ ๋ง๊ณ  ๊ณ„์† ์‹œ๋„ํ•ด๋ด. ์ฒ˜์Œ์—” ์˜คํžˆ๋ ค ์ฝ”๋“œ๊ฐ€ ๋” ๋ณต์žกํ•ด ๋ณด์ผ ์ˆ˜ ์žˆ์ง€๋งŒ, ํ”„๋กœ์ ํŠธ๊ฐ€ ์ปค์งˆ์ˆ˜๋ก MVVM์˜ ์ง„๊ฐ€๋ฅผ ๋А๋‚„ ์ˆ˜ ์žˆ์„ ๊ฑฐ์•ผ! ๐Ÿ’ช

ํ˜น์‹œ ์•ˆ๋“œ๋กœ์ด๋“œ ๊ฐœ๋ฐœ์ด๋‚˜ MVVM ํŒจํ„ด์— ๋Œ€ํ•ด ๋” ๋ฐฐ์šฐ๊ณ  ์‹ถ๋‹ค๋ฉด, ์žฌ๋Šฅ๋„ท์—์„œ ์ „๋ฌธ๊ฐ€๋“ค์˜ ๊ฐ•์˜๋‚˜ ๋ฉ˜ํ† ๋ง์„ ๋ฐ›์•„๋ณด๋Š” ๊ฒƒ๋„ ์ข‹์€ ๋ฐฉ๋ฒ•์ด์•ผ. ์‹ค๋ฌด ๊ฒฝํ—˜์ด ํ’๋ถ€ํ•œ ๊ฐœ๋ฐœ์ž๋“ค์ด ์ง์ ‘ ์ฝ”๋“œ ๋ฆฌ๋ทฐ๋„ ํ•ด์ฃผ๊ณ , ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ์„ค๊ณ„์— ๋Œ€ํ•œ ์กฐ์–ธ๋„ ๋ฐ›์„ ์ˆ˜ ์žˆ๊ฑฐ๋“ ! ๐ŸŽ“

๐ŸŽ“ ํ•™์Šต ๋กœ๋“œ๋งต๊ณผ ์ถ”๊ฐ€ ์ž๋ฃŒ

MVVM์„ ์™„์ „ํžˆ ๋งˆ์Šคํ„ฐํ•˜๋ ค๋ฉด ์ฒด๊ณ„์ ์ธ ํ•™์Šต์ด ํ•„์š”ํ•ด. ๋‹ค์Œ ๋กœ๋“œ๋งต์„ ๋”ฐ๋ผ๊ฐ€๋ฉด ์ดˆ๋ณด์ž๋„ ์ „๋ฌธ๊ฐ€๊ฐ€ ๋  ์ˆ˜ ์žˆ์–ด!

๐Ÿ“š ๋‹จ๊ณ„๋ณ„ ํ•™์Šต ๋กœ๋“œ๋งต

1๋‹จ๊ณ„: ๊ธฐ์ดˆ ๋‹ค์ง€๊ธฐ (1-2์ฃผ)
โ€ข Kotlin ๊ธฐ๋ณธ ๋ฌธ๋ฒ• ์™„๋ฒฝ ์ˆ™์ง€
โ€ข ์ฝ”๋ฃจํ‹ด๊ณผ Flow ์ดํ•ด
โ€ข ์•ˆ๋“œ๋กœ์ด๋“œ ์ƒ๋ช…์ฃผ๊ธฐ ํ•™์Šต
โ€ข ViewBinding ์‚ฌ์šฉ๋ฒ•

2๋‹จ๊ณ„: MVVM ํ•ต์‹ฌ ๊ฐœ๋… (2-3์ฃผ)
โ€ข ViewModel๊ณผ LiveData/StateFlow
โ€ข Repository ํŒจํ„ด
โ€ข Room Database ์—ฐ๋™
โ€ข Retrofit์œผ๋กœ API ํ†ต์‹ 

3๋‹จ๊ณ„: ์‹ค์ „ ํ”„๋กœ์ ํŠธ (3-4์ฃผ)
โ€ข ๊ฐ„๋‹จํ•œ CRUD ์•ฑ ๋งŒ๋“ค๊ธฐ
โ€ข ๋„คํŠธ์›Œํฌ + ๋กœ์ปฌ DB ์—ฐ๋™
โ€ข ์—๋Ÿฌ ํ•ธ๋“ค๋ง ๊ตฌํ˜„
โ€ข ๋กœ๋”ฉ ์ƒํƒœ ๊ด€๋ฆฌ

4๋‹จ๊ณ„: ๊ณ ๊ธ‰ ๊ธฐ๋ฒ• (4์ฃผ ์ด์ƒ)
โ€ข Clean Architecture ์ ์šฉ
โ€ข UseCase ํŒจํ„ด
โ€ข Hilt ์˜์กด์„ฑ ์ฃผ์ž…
โ€ข ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ์ž‘์„ฑ
โ€ข ๋ชจ๋“ˆํ™” ์ „๋žต

๐Ÿ“– ์ถ”์ฒœ ํ•™์Šต ์ž๋ฃŒ

// ๊ณต์‹ ๋ฌธ์„œ
โ€ข Android Developers - Guide to app architecture
  https://developer.android.com/topic/architecture

โ€ข Kotlin Coroutines Guide
  https://kotlinlang.org/docs/coroutines-guide.html

โ€ข Android Architecture Components
  https://developer.android.com/topic/libraries/architecture

// ์˜คํ”ˆ์†Œ์Šค ํ”„๋กœ์ ํŠธ
โ€ข Google์˜ Architecture Samples
  https://github.com/android/architecture-samples

โ€ข Now in Android (Google ๊ณต์‹ ์ƒ˜ํ”Œ ์•ฑ)
  https://github.com/android/nowinandroid

// ์œ ์šฉํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
โ€ข Hilt: https://dagger.dev/hilt/
โ€ข Room: https://developer.android.com/training/data-storage/room
โ€ข Retrofit: https://square.github.io/retrofit/
โ€ข Coil: https://coil-kt.github.io/coil/ (์ด๋ฏธ์ง€ ๋กœ๋”ฉ)

ํŠนํžˆ Google์˜ Architecture Samples ํ”„๋กœ์ ํŠธ๋Š” ์ •๋ง ์œ ์šฉํ•ด. ๋‹ค์–‘ํ•œ ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด์˜ ๊ตฌํ˜„ ์˜ˆ์ œ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๊ณ , ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค๋ฅผ ๋ฐฐ์šธ ์ˆ˜ ์žˆ๊ฑฐ๋“ . ์ฝ”๋“œ๋ฅผ ์ง์ ‘ ํด๋ก ํ•ด์„œ ์‹คํ–‰ํ•ด๋ณด๊ณ , ๋””๋ฒ„๊น…ํ•˜๋ฉด์„œ ๋™์ž‘ ์›๋ฆฌ๋ฅผ ์ดํ•ดํ•˜๋Š” ๊ฒŒ ๊ฐ€์žฅ ๋น ๋ฅธ ํ•™์Šต ๋ฐฉ๋ฒ•์ด์•ผ! ๐Ÿš€

๐ŸŽฌ ๋งˆ๋ฌด๋ฆฌํ•˜๋ฉฐ

์™€! ์—ฌ๊ธฐ๊นŒ์ง€ ์ฝ์—ˆ๋‹ค๋ฉด ์ •๋ง ๋Œ€๋‹จํ•ด! ๐Ÿ‘ MVVM ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด์— ๋Œ€ํ•ด ๊ฝค ๋งŽ์€ ๊ฑธ ๋ฐฐ์› ์„ ๊ฑฐ์•ผ. ์ฒ˜์Œ์—” ๋ณต์žกํ•ด ๋ณด์ด๊ณ  "์ด๊ฒŒ ์ •๋ง ํ•„์š”ํ•œ๊ฐ€?" ์‹ถ์„ ์ˆ˜๋„ ์žˆ์–ด. ๋‚˜๋„ ์ฒ˜์Œ์—” ๊ทธ๋žฌ๊ฑฐ๋“ . ํ•˜์ง€๋งŒ ์‹ค์ œ๋กœ ํ”„๋กœ์ ํŠธ์— ์ ์šฉํ•ด๋ณด๋ฉด, ์ฝ”๋“œ๊ฐ€ ์–ผ๋งˆ๋‚˜ ๊น”๋”ํ•ด์ง€๊ณ  ์œ ์ง€๋ณด์ˆ˜๊ฐ€ ์‰ฌ์›Œ์ง€๋Š”์ง€ ์ง์ ‘ ๋А๋‚„ ์ˆ˜ ์žˆ์„ ๊ฑฐ์•ผ.

MVVM์€ ๋‹จ์ˆœํžˆ ์ฝ”๋“œ๋ฅผ ์ •๋ฆฌํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ์•„๋‹ˆ์•ผ. ์•ฑ์˜ ๊ตฌ์กฐ๋ฅผ ์ฒด๊ณ„์ ์œผ๋กœ ์„ค๊ณ„ํ•˜๊ณ , ํŒ€์›๋“ค๊ณผ ํ˜‘์—…ํ•˜๊ธฐ ์‰ฝ๊ฒŒ ๋งŒ๋“ค๊ณ , ๋ฏธ๋ž˜์˜ ๋‚˜ ์ž์‹ ์„ ์œ„ํ•ด ์ดํ•ดํ•˜๊ธฐ ์‰ฌ์šด ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๋Š” ์ฒ ํ•™์ด์•ผ. ์ฒ˜์Œ์—” ์‹œ๊ฐ„์ด ๋” ๊ฑธ๋ฆด ์ˆ˜ ์žˆ์ง€๋งŒ, ์žฅ๊ธฐ์ ์œผ๋กœ ๋ณด๋ฉด ์—„์ฒญ๋‚œ ์‹œ๊ฐ„ ์ ˆ์•ฝ๊ณผ ์ƒ์‚ฐ์„ฑ ํ–ฅ์ƒ์„ ๊ฐ€์ ธ๋‹ค์ค˜.

๐ŸŽฏ ํ•ต์‹ฌ ์š”์•ฝ

1. MVVM์˜ ์„ธ ๊ฐ€์ง€ ํ•ต์‹ฌ
โ€ข Model: ๋ฐ์ดํ„ฐ์™€ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง
โ€ข View: UI ํ‘œ์‹œ์™€ ์‚ฌ์šฉ์ž ์ž…๋ ฅ
โ€ข ViewModel: ์ค‘์žฌ์ž ์—ญํ• , ์ƒ๋ช…์ฃผ๊ธฐ ๋…๋ฆฝ์ 

2. ๋ฐ˜๋“œ์‹œ ์ง€์ผœ์•ผ ํ•  ์›์น™
โ€ข View๋Š” ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ํฌํ•จํ•˜์ง€ ์•Š๋Š”๋‹ค
โ€ข ViewModel์€ Android ํ”„๋ ˆ์ž„์›Œํฌ์— ์˜์กดํ•˜์ง€ ์•Š๋Š”๋‹ค
โ€ข ๋‹จ๋ฐฉํ–ฅ ๋ฐ์ดํ„ฐ ํ๋ฆ„์„ ์œ ์ง€ํ•œ๋‹ค
โ€ข Repository๋กœ ๋ฐ์ดํ„ฐ ์†Œ์Šค๋ฅผ ์ถ”์ƒํ™”ํ•œ๋‹ค

3. ์‹ค๋ฌด ์ ์šฉ ํŒ
โ€ข ์ž‘์€ ๊ธฐ๋Šฅ๋ถ€ํ„ฐ ์‹œ์ž‘ํ•ด์„œ ์ ์ง„์ ์œผ๋กœ ํ™•์žฅ
โ€ข ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ํ•จ๊ป˜ ์ž‘์„ฑ
โ€ข ํŒ€ ์ปจ๋ฒค์…˜์„ ๋ช…ํ™•ํžˆ ์ •์˜
โ€ข ์˜คํ”ˆ์†Œ์Šค ํ”„๋กœ์ ํŠธ๋ฅผ ์ฐธ๊ณ ํ•˜๋ฉฐ ํ•™์Šต

๋งˆ์ง€๋ง‰์œผ๋กœ, ์™„๋ฒฝํ•œ ์•„ํ‚คํ…์ฒ˜๋Š” ์—†๋‹ค๋Š” ๊ฑธ ๊ธฐ์–ตํ•ด. MVVM๋„ ๋งŒ๋Šฅ์ด ์•„๋‹ˆ์•ผ. ํ”„๋กœ์ ํŠธ์˜ ๊ทœ๋ชจ, ํŒ€์˜ ์—ญ๋Ÿ‰, ์š”๊ตฌ์‚ฌํ•ญ์— ๋”ฐ๋ผ ์œ ์—ฐํ•˜๊ฒŒ ์ ์šฉํ•ด์•ผ ํ•ด. ๋•Œ๋กœ๋Š” ์ˆœ์ˆ˜ MVVM๋ณด๋‹ค MVI๊ฐ€ ๋” ์ ํ•ฉํ•  ์ˆ˜๋„ ์žˆ๊ณ , Clean Architecture์˜ ๋ชจ๋“  ๋ ˆ์ด์–ด๊ฐ€ ํ•„์š”ํ•˜์ง€ ์•Š์„ ์ˆ˜๋„ ์žˆ์–ด.

์ค‘์š”ํ•œ ๊ฑด ์™œ ์ด๋ ‡๊ฒŒ ์„ค๊ณ„ํ–ˆ๋Š”์ง€๋ฅผ ์„ค๋ช…ํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค๋Š” ๊ฑฐ์•ผ. ๊ทธ๋ƒฅ "๋‹ค๋“ค ์ด๋ ‡๊ฒŒ ํ•˜๋‹ˆ๊นŒ"๊ฐ€ ์•„๋‹ˆ๋ผ, "์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ์—๋Š” ์ด๋Ÿฐ ์ด์œ ๋กœ ์ด ํŒจํ„ด์ด ์ ํ•ฉํ•˜๋‹ค"๊ณ  ๋งํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ด. ๊ทธ๊ฒŒ ์ง„์งœ ์‹ค๋ ฅ์ด๊ฑฐ๋“ ! ๐Ÿ’ช

์•ˆ๋“œ๋กœ์ด๋“œ ๊ฐœ๋ฐœ์€ ๊ณ„์† ์ง„ํ™”ํ•˜๊ณ  ์žˆ์–ด. Jetpack Compose๊ฐ€ ๋‚˜์˜ค๋ฉด์„œ UI ๊ฐœ๋ฐœ ๋ฐฉ์‹์ด ์™„์ „ํžˆ ๋ฐ”๋€Œ์—ˆ๊ณ , ์•ž์œผ๋กœ๋„ ์ƒˆ๋กœ์šด ๊ธฐ์ˆ ๋“ค์ด ๊ณ„์† ๋‚˜์˜ฌ ๊ฑฐ์•ผ. ํ•˜์ง€๋งŒ MVVM ๊ฐ™์€ ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด์˜ ํ•ต์‹ฌ ์›์น™๋“ค์€ ๋ณ€ํ•˜์ง€ ์•Š์•„. ๊ด€์‹ฌ์‚ฌ์˜ ๋ถ„๋ฆฌ, ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ, ์œ ์ง€๋ณด์ˆ˜์„ฑ ๊ฐ™์€ ๊ฐ€์น˜๋“ค์€ ์–ด๋–ค ๊ธฐ์ˆ  ์Šคํƒ์„ ์‚ฌ์šฉํ•˜๋“  ์ค‘์š”ํ•˜๋‹ˆ๊นŒ.

ํ˜น์‹œ ํ˜ผ์ž ๊ณต๋ถ€ํ•˜๋‹ค๊ฐ€ ๋ง‰ํžˆ๋Š” ๋ถ€๋ถ„์ด ์žˆ๋‹ค๋ฉด, ์žฌ๋Šฅ๋„ท ๊ฐ™์€ ํ”Œ๋žซํผ์—์„œ ๋ฉ˜ํ† ๋ฅผ ์ฐพ์•„๋ณด๋Š” ๊ฒƒ๋„ ์ข‹์€ ๋ฐฉ๋ฒ•์ด์•ผ. ์‹ค๋ฌด ๊ฒฝํ—˜์ด ์žˆ๋Š” ๊ฐœ๋ฐœ์ž์—๊ฒŒ ์ง์ ‘ ์งˆ๋ฌธํ•˜๊ณ  ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›์œผ๋ฉด, ํ˜ผ์ž ๊ณ ๋ฏผํ•˜๋Š” ์‹œ๊ฐ„์„ ํฌ๊ฒŒ ์ค„์ผ ์ˆ˜ ์žˆ๊ฑฐ๋“ . ํŠนํžˆ ์ฝ”๋“œ ๋ฆฌ๋ทฐ๋‚˜ ์•„ํ‚คํ…์ฒ˜ ์„ค๊ณ„ ๊ฐ™์€ ๋ถ€๋ถ„์€ ๊ฒฝํ—˜์ž์˜ ์กฐ์–ธ์ด ์ •๋ง ํฐ ๋„์›€์ด ๋ผ!

๐Ÿš€ ์ž, ์ด์ œ ์ง์ ‘ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด๋ณผ ์‹œ๊ฐ„์ด์•ผ!
์ž‘์€ ํ”„๋กœ์ ํŠธ๋ถ€ํ„ฐ ์‹œ์ž‘ํ•ด์„œ MVVM์„ ์ ์šฉํ•ด๋ณด์ž!

ํ™”์ดํŒ…! ๋„ˆ๋ผ๋ฉด ์ถฉ๋ถ„ํžˆ ํ•  ์ˆ˜ ์žˆ์–ด. ์ฒ˜์Œ์—” ์–ด๋ ต๊ณ  ๋ณต์žกํ•ด ๋ณด์ด๊ฒ ์ง€๋งŒ, ํ•˜๋‚˜์”ฉ ์ฐจ๊ทผ์ฐจ๊ทผ ๋”ฐ๋ผํ•˜๋‹ค ๋ณด๋ฉด ์–ด๋А์ƒˆ MVVM์ด ์ž์—ฐ์Šค๋Ÿฌ์›Œ์งˆ ๊ฑฐ์•ผ. ๊ทธ๋ฆฌ๊ณ  ๋‚˜์ค‘์— ์ด ๊ธ€์„ ๋‹ค์‹œ ์ฝ์–ด๋ณด๋ฉด "์•„, ๊ทธ๋•Œ๋Š” ์ด๊ฒƒ๋„ ์–ด๋ ค์› ๊ตฌ๋‚˜" ํ•˜๋ฉด์„œ ์›ƒ์„ ์ˆ˜ ์žˆ์„ ๊ฑฐ์•ผ. ๐Ÿ˜Š

์งˆ๋ฌธ์ด๋‚˜ ํ”ผ๋“œ๋ฐฑ์ด ์žˆ๋‹ค๋ฉด ์–ธ์ œ๋“  ํ™˜์˜์ด์•ผ. ํ•จ๊ป˜ ์„ฑ์žฅํ•˜๋Š” ๊ฐœ๋ฐœ์ž ์ปค๋ฎค๋‹ˆํ‹ฐ๋ฅผ ๋งŒ๋“ค์–ด๊ฐ€์ž! Happy Coding! ๐Ÿ’ปโœจ

๋Œ“๊ธ€ ์ž‘์„ฑ

์ด ๊ธ€์— ๋Œ€ํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ƒ๊ฐ์„ ๋“ค๋ ค์ฃผ์„ธ์š”

๋Œ“๊ธ€ 0
๋Œ“๊ธ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.

์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.