Android 개발 방법론 - MVVM 아키텍처와 TDD 기반 개발 가이드

현대적인 Android 앱 개발 방법론에 대해 알아봅니다. MVVM 아키텍처를 기반으로 한 계층별 개발 순서, TDD 적용 방법, 그리고 실무에서 바로 사용할 수 있는 개발 팁을 제공합니다.
개발 과정 개요
Android 앱 개발은 크게 4개 레이어로 나누어 순차적으로 진행합니다.
API Layer -> Database Layer -> ViewModel Layer -> UI Layer
| | | |
v v v v
설계 -> 코드 정의 -> 테스트 작성 -> 구현 -> 테스팅
1. API Layer 개발
1.1 API 설계
서버와의 통신을 위한 Request/Response를 먼저 설계합니다.
// Request 모델
data class LoginRequest(
val email: String,
val password: String
)
// Response 모델
data class LoginResponse(
val userId: Long,
val accessToken: String,
val refreshToken: String,
val expiresIn: Long
)
// API 인터페이스 정의
interface AuthApi {
@POST("auth/login")
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
@POST("auth/refresh")
suspend fun refreshToken(@Body refreshToken: String): Response<LoginResponse>
}
1.2 데이터베이스 설계
앱 내 저장이 필요한 데이터의 테이블 구조를 설계합니다.
// Entity 정의
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey
val id: Long,
val email: String,
val name: String,
val avatarUrl: String?,
val createdAt: Long
)
// Response를 Entity로 변환
fun UserResponse.toEntity(): UserEntity {
return UserEntity(
id = this.id,
email = this.email,
name = this.name,
avatarUrl = this.avatar,
createdAt = System.currentTimeMillis()
)
}
1.3 POC (Proof of Concept)
실현 가능한지 검증합니다.
// 간단한 POC 테스트
class ApiPocTest {
@Test
fun `verify API endpoint is accessible`() = runTest {
val api = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(AuthApi::class.java)
val response = api.login(LoginRequest("test@test.com", "test"))
assertTrue(response.isSuccessful)
}
}
1.4 API Unit Test 작성
정의된 JSON으로 Mock 데이터를 만들고 파싱 테스트를 합니다.
class LoginResponseParsingTest {
private val gson = Gson()
@Test
fun `should parse login response correctly`() {
// Given: Mock JSON
val mockJson = """
{
"userId": 12345,
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "dGhpcyBpcyByZWZyZXNo...",
"expiresIn": 3600
}
""".trimIndent()
// When: 파싱
val response = gson.fromJson(mockJson, LoginResponse::class.java)
// Then: 검증
assertEquals(12345L, response.userId)
assertEquals("eyJhbGciOiJIUzI1NiIs...", response.accessToken)
assertEquals(3600L, response.expiresIn)
}
@Test
fun `should handle null fields gracefully`() {
val mockJson = """
{
"userId": 12345,
"accessToken": "token",
"refreshToken": null,
"expiresIn": 3600
}
""".trimIndent()
val response = gson.fromJson(mockJson, LoginResponse::class.java)
assertNull(response.refreshToken)
}
}
1.5 API 구현 및 테스팅
class AuthRepositoryImpl @Inject constructor(
private val api: AuthApi,
private val tokenStorage: TokenStorage
) : AuthRepository {
override suspend fun login(email: String, password: String): Result<User> {
return try {
val response = api.login(LoginRequest(email, password))
if (response.isSuccessful) {
val body = response.body()!!
tokenStorage.saveTokens(body.accessToken, body.refreshToken)
Result.success(User(body.userId))
} else {
Result.failure(ApiException(response.code(), response.message()))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
2. Database Layer 개발
2.1 Room 데이터 모델 정의
@Entity(tableName = "orders")
data class OrderEntity(
@PrimaryKey
val id: Long,
val userId: Long,
val totalAmount: Double,
val status: String,
@ColumnInfo(name = "created_at")
val createdAt: Long
)
@Dao
interface OrderDao {
@Query("SELECT * FROM orders WHERE userId = :userId ORDER BY created_at DESC")
fun getOrdersByUser(userId: Long): Flow<List<OrderEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrders(orders: List<OrderEntity>)
@Query("DELETE FROM orders WHERE id = :orderId")
suspend fun deleteOrder(orderId: Long)
}
2.2 Room Unit Test 구현
@RunWith(AndroidJUnit4::class)
class OrderDaoTest {
private lateinit var database: AppDatabase
private lateinit var orderDao: OrderDao
@Before
fun setup() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).allowMainThreadQueries().build()
orderDao = database.orderDao()
}
@After
fun teardown() {
database.close()
}
@Test
fun `insert and retrieve orders`() = runTest {
// Given
val orders = listOf(
OrderEntity(1, 100, 50000.0, "COMPLETED", System.currentTimeMillis()),
OrderEntity(2, 100, 30000.0, "PENDING", System.currentTimeMillis())
)
// When
orderDao.insertOrders(orders)
val retrieved = orderDao.getOrdersByUser(100).first()
// Then
assertEquals(2, retrieved.size)
assertEquals(50000.0, retrieved[0].totalAmount, 0.01)
}
}
3. ViewModel Layer 개발
3.1 ViewModel 인터페이스 정의
UI 데이터 및 이벤트 메서드를 정의합니다.
class OrderViewModel @Inject constructor(
private val orderRepository: OrderRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
// UI State
private val _uiState = MutableStateFlow(OrderUiState())
val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()
// Events
private val _events = Channel<OrderEvent>()
val events = _events.receiveAsFlow()
// Actions
fun loadOrders() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
orderRepository.getOrders()
.onSuccess { orders ->
_uiState.update {
it.copy(isLoading = false, orders = orders)
}
}
.onFailure { error ->
_uiState.update {
it.copy(isLoading = false, error = error.message)
}
}
}
}
fun cancelOrder(orderId: Long) {
viewModelScope.launch {
orderRepository.cancelOrder(orderId)
.onSuccess {
_events.send(OrderEvent.OrderCancelled)
loadOrders()
}
.onFailure { error ->
_events.send(OrderEvent.Error(error.message ?: "Unknown error"))
}
}
}
}
data class OrderUiState(
val isLoading: Boolean = false,
val orders: List<Order> = emptyList(),
val error: String? = null
)
sealed class OrderEvent {
object OrderCancelled : OrderEvent()
data class Error(val message: String) : OrderEvent()
}
3.2 ViewModel Unit Test 구현
기획서에 정의된 모든 케이스를 테스팅합니다.
@OptIn(ExperimentalCoroutinesApi::class)
class OrderViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: OrderViewModel
private lateinit var mockRepository: MockOrderRepository
@Before
fun setup() {
mockRepository = MockOrderRepository()
viewModel = OrderViewModel(mockRepository, SavedStateHandle())
}
@Test
fun `loadOrders success updates uiState with orders`() = runTest {
// Given
val expectedOrders = listOf(
Order(1, 50000.0, OrderStatus.COMPLETED),
Order(2, 30000.0, OrderStatus.PENDING)
)
mockRepository.setOrders(expectedOrders)
// When
viewModel.loadOrders()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertEquals(2, state.orders.size)
assertNull(state.error)
}
@Test
fun `loadOrders failure updates uiState with error`() = runTest {
// Given
mockRepository.setShouldFail(true)
// When
viewModel.loadOrders()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertTrue(state.orders.isEmpty())
assertNotNull(state.error)
}
@Test
fun `cancelOrder success emits OrderCancelled event`() = runTest {
// Given
val events = mutableListOf<OrderEvent>()
val job = launch { viewModel.events.toList(events) }
// When
viewModel.cancelOrder(1)
advanceUntilIdle()
// Then
assertTrue(events.any { it is OrderEvent.OrderCancelled })
job.cancel()
}
}
4. UI Layer 개발
4.1 UI 컴포넌트 정의
@Composable
fun OrderListScreen(
viewModel: OrderViewModel = hiltViewModel(),
onOrderClick: (Long) -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.loadOrders()
}
// Event 처리
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is OrderEvent.OrderCancelled -> {
// Show snackbar
}
is OrderEvent.Error -> {
// Show error dialog
}
}
}
}
OrderListContent(
uiState = uiState,
onOrderClick = onOrderClick,
onCancelClick = viewModel::cancelOrder,
onRetry = viewModel::loadOrders
)
}
@Composable
private fun OrderListContent(
uiState: OrderUiState,
onOrderClick: (Long) -> Unit,
onCancelClick: (Long) -> Unit,
onRetry: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
when {
uiState.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
uiState.error != null -> {
ErrorView(
message = uiState.error,
onRetry = onRetry,
modifier = Modifier.align(Alignment.Center)
)
}
uiState.orders.isEmpty() -> {
EmptyView(
message = "No orders yet",
modifier = Modifier.align(Alignment.Center)
)
}
else -> {
LazyColumn {
items(uiState.orders) { order ->
OrderItem(
order = order,
onClick = { onOrderClick(order.id) },
onCancelClick = { onCancelClick(order.id) }
)
}
}
}
}
}
}
4.2 UI Unit Test
각 케이스별 UI가 정상적으로 보이는지 스크린샷으로 확인합니다.
class OrderListScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `when loading, show progress indicator`() {
composeTestRule.setContent {
OrderListContent(
uiState = OrderUiState(isLoading = true),
onOrderClick = {},
onCancelClick = {},
onRetry = {}
)
}
composeTestRule.onNodeWithTag("loading_indicator").assertIsDisplayed()
}
@Test
fun `when orders exist, display order list`() {
val orders = listOf(
Order(1, 50000.0, OrderStatus.COMPLETED),
Order(2, 30000.0, OrderStatus.PENDING)
)
composeTestRule.setContent {
OrderListContent(
uiState = OrderUiState(orders = orders),
onOrderClick = {},
onCancelClick = {},
onRetry = {}
)
}
composeTestRule.onNodeWithText("50,000").assertIsDisplayed()
composeTestRule.onNodeWithText("30,000").assertIsDisplayed()
}
@Test
fun `when order clicked, callback is invoked`() {
var clickedOrderId: Long? = null
val orders = listOf(Order(1, 50000.0, OrderStatus.COMPLETED))
composeTestRule.setContent {
OrderListContent(
uiState = OrderUiState(orders = orders),
onOrderClick = { clickedOrderId = it },
onCancelClick = {},
onRetry = {}
)
}
composeTestRule.onNodeWithText("50,000").performClick()
assertEquals(1L, clickedOrderId)
}
}
개발 팁
1. 자신만의 TODO 코멘트 활용
빠진 부분이 없는지 체크할 수 있는 커스텀 TODO를 만듭니다.
// 개인 TODO 태그 정의
// TODO(hyun): 에러 핸들링 추가
// FIXME(hyun): 메모리 릭 확인 필요
// OPTIMIZE(hyun): 성능 개선 필요
// Android Studio에서 필터링 가능
2. Instant Run 활용
개발 중 빠른 빌드를 위해 Apply Changes를 활용합니다.
Apply Changes and Restart Activity: Ctrl+F10 (Cmd+F10)
Apply Code Changes: Ctrl+Shift+F10 (Cmd+Shift+F10)
3. 가상 디바이스 테스트
// 다양한 화면 크기 테스트
@Config(qualifiers = "w320dp-h480dp") // 소형 화면
@Config(qualifiers = "w600dp-h800dp") // 태블릿
@Config(qualifiers = "land") // 가로 모드
4. 테스트 디바이스 최적화
개발을 방해하지 않는 테스트 환경 구성:
- 개발자 옵션에서 “USB 디버깅 인증” 항상 허용
- 화면 꺼짐 방지 설정
- USB 연결 안정성 확인
5. UI 레이아웃 가이드
<!-- ConstraintLayout 베스트 프랙티스 -->
<androidx.constraintlayout.widget.ConstraintLayout>
<!-- 1. 먼저 Guideline 정의 -->
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline_start"
android:orientation="vertical"
app:layout_constraintGuide_begin="16dp" />
<!-- 2. Barrier로 동적 경계 설정 -->
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_bottom"
app:barrierDirection="bottom"
app:constraint_referenced_ids="title,subtitle" />
<!-- 3. Group으로 가시성 관리 -->
<androidx.constraintlayout.widget.Group
android:id="@+id/loading_group"
android:visibility="gone"
app:constraint_referenced_ids="progress,loading_text" />
<!-- 4. UI 컴포넌트 배치 -->
<TextView
android:id="@+id/title"
app:layout_constraintStart_toEndOf="@id/guideline_start"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
정리: 개발 체크리스트
## API Layer
- [ ] Request/Response 모델 정의
- [ ] API 인터페이스 정의
- [ ] POC 검증
- [ ] Unit Test 작성
- [ ] API 구현
- [ ] 실제 API 테스트
## Database Layer
- [ ] Entity 정의
- [ ] DAO 정의
- [ ] Unit Test 작성
- [ ] Repository 구현
- [ ] DB 테스트
## ViewModel Layer
- [ ] State 클래스 정의
- [ ] Event 클래스 정의
- [ ] ViewModel 구현
- [ ] Unit Test (기획서 케이스별)
- [ ] ViewModel 테스팅
## UI Layer
- [ ] Composable/Layout 정의
- [ ] Data Binding 연결
- [ ] UI Test 작성
- [ ] Screenshot Test
- [ ] 실제 UI 테스팅
결론
체계적인 Android 개발은:
- 계층별 순차 개발: API -> Database -> ViewModel -> UI
- TDD 적용: 테스트 먼저 작성
- 명확한 책임 분리: 각 레이어의 역할 명확히
- 지속적인 검증: 각 단계마다 테스트
이러한 접근 방식으로 유지보수가 쉽고 테스트 가능한 앱을 만들 수 있습니다.
Comments