EnjoyLife

mvvm 기반의 앱 폴더 구조 본문

안드로이드 개발/개발팁

mvvm 기반의 앱 폴더 구조

Aiden96 2025. 3. 20. 10:36

안드로이드앱에서 주로 사용하는 네트워크 통신,db  기능을 기반으로 공통적으로 제가 사용하는 패턴입니다. 

app/
├── data/
│   ├── local/          
│   │   ├── SampleEntity.kt      // Room 엔티티
│   │   ├── SampleDao.kt         // Room DAO
│   │   └── AppDatabase.kt       // Room Database (싱글톤)
│   └── remote/
│       ├── ApiService.kt        // Retrofit API 인터페이스 (독립적 구현)
│       └── SampleData.kt        // 네트워크 데이터 모델
├── repository/
│   └── SampleRepository.kt      // Repository (API + Room, safeApiCall 적용)
├── ui/
│   ├── viewmodel/
│   │   ├── SampleViewModel.kt   // ViewModel (Flow, LiveData 변환)
│   │   └── SampleViewModelFactory.kt // ViewModelProvider.Factory
│   └── MainActivity.kt          // 액티비티 예제 (ViewModel 사용)
├── di/
│   └── InjectUtil.kt            // 의존성 주입을 위한 유틸리티
└── utils/
    └── ApiHelper.kt             // safeApiCall 및 ResultWrapper 정의

 

1. API 호출 안전 처리 (ApiHelper.kt)

// utils/ApiHelper.kt
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext

suspend fun <T> safeApiCall(
    dispatcher: CoroutineDispatcher,
    apiCall: suspend () -> T
): ResultWrapper<T> {
    return withContext(dispatcher) {
        try {
            ResultWrapper.Success(apiCall.invoke())
        } catch (throwable: Throwable) {
            ResultWrapper.Error(throwable)
        }
    }
}

sealed class ResultWrapper<out T> {
    data class Success<out T>(val value: T): ResultWrapper<T>()
    data class Error(val value: Throwable): ResultWrapper<Nothing>()
}

 

2. 데이터 계층 (Room & Retrofit)

Room 구성

SampleEntity.kt

// data/local/SampleEntity.kt
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "sample_table")
data class SampleEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val name: String
)

 

SampleDao.kt

// data/local/SampleDao.kt
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface SampleDao {
    @Query("SELECT * FROM sample_table")
    fun getAllSamples(): Flow<List<SampleEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertSample(sample: SampleEntity)
}

 

AppDatabase.kt

// data/local/AppDatabase.kt
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [SampleEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun sampleDao(): SampleDao

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

        fun getInstance(context: Context): AppDatabase =
            INSTANCE ?: synchronized(this) {
                INSTANCE ?: Room.databaseBuilder(
                    context.applicationContext,AppDatabase::class.java,"app_database")
                    .build().also { INSTANCE = it }
            }
    }
}

 

3. Retrofit 구성 – ApiService 독립적 구현

ApiService.kt

// data/remote/ApiService.kt
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
import java.util.concurrent.TimeUnit
import timber.log.Timber

interface ApiService {

    companion object {
        @JvmStatic
        fun create(): ApiService {
            val httpClient = OkHttpClient.Builder().apply {
                connectTimeout(30, TimeUnit.SECONDS)
                readTimeout(30, TimeUnit.SECONDS)
                writeTimeout(30, TimeUnit.SECONDS)
                addInterceptor { chain ->
                    val request = chain.request().newBuilder()
                        .addHeader("x-api-key", "ZVJN9H5Z0JSUKDNMJ8JEITND4NPUS35H")
                        .build()
                    Log.e("ApiService").e("Request: ${request}")
                    chain.proceed(request)
                }
            }
            val retrofit = Retrofit.Builder()
                .baseUrl(AddrConstants.BASE) 
                .client(httpClient.build())
                .addConverterFactory(GsonConverterFactory.create())
                .build()
            return retrofit.create(ApiService::class.java)
        }
    }

    @FormUrlEncoded
    @POST(AddrConstants.LOGIN)
    suspend fun login(
        @Field("id") id: String,
        @Field("password") password: String        
    ): Login

    @FormUrlEncoded
    @POST(AddrConstants.LOGOUT)
    suspend fun logout(
        @Field("id") id: String,
        @Field("password") password: String,
        
    ): Logout
}

SampleData.kt

// data/remote/SampleData.kt
data class SampleData(
    val id: Int,
    val password: String
)

 

3.1 Repository (safeApiCall 적용)

SampleRepository.kt

// repository/SampleRepository.kt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow

class SampleRepository(
    private val sampleDao: SampleDao
) {
    // 외부에서 직접 접근 가능한 ApiService 인스턴스 (싱글톤 형태)
    private val apiService = ApiService.create()

    // Room 데이터 Flow 제공
    val samples: Flow<List<SampleEntity>> = sampleDao.getAllSamples()

    // 네트워크 데이터를 safeApiCall을 통해 가져와 DB에 저장
    suspend fun refreshSamples() {
        when (val result = safeApiCall(Dispatchers.IO) { apiService.login("value1", "value2", "value3") }) {
            is ResultWrapper.Success -> {
                // 성공 시 받아온 데이터를 활용 (예시: Login API 응답)
                // 실제 프로젝트에 맞게 데이터를 처리하세요.
            }
            is ResultWrapper.Error -> {
                // 오류 발생 시 로그 출력 또는 사용자 알림 등 적절한 처리를 합니다.
                // 예: Log.e("SampleRepository", "API 호출 오류", result.value)
            }
        }
    }
}

 

4. ViewModel 및 Factory

SampleViewModel.kt

// ui/viewmodel/SampleViewModel.kt
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class SampleViewModel(private val repository: SampleRepository) : ViewModel() {

    // Repository의 Flow를 LiveData로 변환하여 UI에 제공
    val samples: LiveData<List<SampleEntity>> = repository.samples.asLiveData()

    init {
        viewModelScope.launch {
            repository.refreshSamples()
        }
    }
}

 

SampleViewModelFactory.kt

// ui/viewmodel/SampleViewModelFactory.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider

class SampleViewModelFactory(private val repository: SampleRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(SampleViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return SampleViewModel(repository) as T
        }
        throw IllegalArgumentException("알 수 없는 ViewModel 클래스")
    }
}

 

5. 의존성 주입 (InjectUtil)

InjectUtil.kt

// di/InjectUtil.kt
import android.content.Context

object InjectUtil {

    // AppDatabase 인스턴스 제공
    private fun provideAppDatabase(context: Context) = AppDatabase.getInstance(context)

    // Repository 인스턴스 제공 (SampleRepository는 내부에서 ApiService를 생성함)
    fun provideSampleRepository(context: Context): SampleRepository {
        val database = provideAppDatabase(context)
        return SampleRepository(
            sampleDao = database.sampleDao()
        )
    }

    // ViewModel Factory 인스턴스 제공
    fun provideSampleViewModelFactory(context: Context): SampleViewModelFactory {
        return SampleViewModelFactory(provideSampleRepository(context))
    }
}

 

6. 액티비티에서 ViewModel 사용 예제

MainActivity.kt

// ui/MainActivity.kt
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider

class MainActivity : AppCompatActivity() {

    private lateinit var sampleViewModel: SampleViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        // InjectUtil을 통해 의존성 주입 및 ViewModel 초기화
        sampleViewModel = ViewModelProvider(
            this,
            InjectUtil.provideSampleViewModelFactory(this)
        ).get(SampleViewModel::class.java)

        // LiveData 관찰하여 UI 업데이트
        sampleViewModel.samples.observe(this, Observer { sampleList ->
            // RecyclerView 어댑터 등 UI 업데이트 처리
        })
    }
}

 

7. Gradle 의존성(모듈:app)

dependencies {
    // Room
    implementation "androidx.room:room-runtime:2.5.0"
    kapt "androidx.room:room-compiler:2.5.0"
    implementation "androidx.room:room-ktx:2.5.0"

    // Retrofit 및 Gson
    implementation "com.squareup.retrofit2:retrofit:2.9.0"
    implementation "com.squareup.retrofit2:converter-gson:2.9.0"

    // 코루틴 및 Flow
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"

    // Lifecycle (ViewModel, LiveData)
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.5.1"
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1"
}

 

8.기능 요약

사용 방법 요약

  1. 프로젝트 생성 및 패키지 구성
    위와 같은 폴더 구조대로 data, repository, ui, di, utils 등의 패키지를 생성하고 파일들을 추가
  2. 각 계층 구현
    • 데이터 계층: Room 엔티티/DAO/Database와 Retrofit ApiService 및 데이터 모델을 구현
    • 유틸: safeApiCall 및 ResultWrapper를 ApiHelper.kt에 작성
    • Repository: 네트워크 통신 시 safeApiCall을 활용하여 데이터를 가져오고 Room에 저장하는 로직 구현
    • ViewModel: Repository의 Flow를 LiveData로 변환하고, 초기 데이터 로딩 처리
    • DI: InjectUtil을 통해 필요한 의존성을 제공
  3. ViewModel 사용
    액티비티나 다이얼로그에서 InjectUtil로 제공받은 ViewModelFactory를 사용해 ViewModel을 초기화

이 템플릿을 기반으로 하면 어떤 코틀린 앱 프로젝트에서 네트워크, Room, MVVM, Flow, 의존성 주입, 그리고 API 오류 처리(safeApiCall)를 모두 갖춘 기본 구조를 빠르게 작성하면 좋을 듯 합니다.