Ver3.0 ๐ ์๋๋ก์ด๋ MVVM ์ํคํ ์ฒ ํจํด ์๋ฒฝ ๊ตฌํ ๊ฐ์ด๋

๐ ์๋๋ก์ด๋ MVVM ์ํคํ ์ฒ ํจํด ์๋ฒฝ ๊ตฌํ ๊ฐ์ด๋
์น๊ตฌ์ฒ๋ผ ์ฝ๊ฒ ๋ฐฐ์ฐ๋ ํ๋์ ์ธ ์๋๋ก์ด๋ ์ฑ ์ค๊ณ ๋ฐฉ๋ฒ๋ก
์๋ ! ๐ ์ค๋์ ์๋๋ก์ด๋ ๊ฐ๋ฐ์๋ผ๋ฉด ๋ฐ๋์ ์์์ผ ํ MVVM ์ํคํ ์ฒ ํจํด์ ๋ํด ์ด์ผ๊ธฐํด๋ณผ ๊ฑฐ์ผ. ํน์ ์ฝ๋๊ฐ ์คํ๊ฒํฐ์ฒ๋ผ ์์ผ์ ์ ์ง๋ณด์๊ฐ ์ง์ฅ ๊ฐ์๋ ๊ฒฝํ ์์ด? ์๋๋ฉด ์กํฐ๋นํฐ ํ๋์ ์์ฒ ์ค์ ์ฝ๋๊ฐ ๋ค์ด๊ฐ์ ์ด๋์๋ถํฐ ์๋์ผ ํ ์ง ๋ง๋งํ๋ ์ ์? ๊ทธ๋ ๋ค๋ฉด ์ด ๊ธ์ด ๋์๊ฒ ๋ฑ ๋ง๋ ํด๊ฒฐ์ฑ ์ด ๋ ๊ฑฐ์ผ! ๐ฏ
MVVM์ Model-View-ViewModel์ ์ฝ์๋ก, ๊ตฌ๊ธ์ด ๊ณต์์ ์ผ๋ก ๊ถ์ฅํ๋ ์๋๋ก์ด๋ ์ํคํ ์ฒ ํจํด์ด์ผ. ์ด ํจํด์ ์ ๋๋ก ์ดํดํ๊ณ ๊ตฌํํ๋ฉด, ์ฝ๋์ ๊ฐ๋ ์ฑ๊ณผ ์ ์ง๋ณด์์ฑ์ด ์์ฒญ๋๊ฒ ํฅ์๋๊ณ , ํ ์คํธ๋ ํจ์ฌ ์ฌ์์ ธ. ๋ง์น ์ ๋ฆฌ์ ๋์ด ์ ๋ ๋ฐฉ์ฒ๋ผ ๋ง์ด์ผ! โจ
๐ค 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 ์ ์ฉ ์ฌ๋ก
์ด๋ก ์ ์ถฉ๋ถํ ๋ฐฐ์ ์ผ๋, ์ด์ ์ค์ ํ๋ก์ ํธ์์ ์ด๋ป๊ฒ ์ ์ฉํ๋์ง ์์๋ณด์. ๋ด๊ฐ ์ค๋ฌด์์ ๊ฒช์๋ ์ํฉ๋ค๊ณผ ํด๊ฒฐ ๋ฐฉ๋ฒ์ ๊ณต์ ํ ๊ฒ! ๐
๐ฑ ๋๊ท๋ชจ ์ฑ์์์ ๋ชจ๋ํ
์ฑ์ด ์ปค์ง๋ฉด ํ๋์ ๋ชจ๋๋ก ๊ด๋ฆฌํ๊ธฐ ํ๋ค์ด์ ธ. 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
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.