| name | android-clean-architecture |
| description | 안드로이드 및 Kotlin Multiplatform 프로젝트를 위한 Clean Architecture 패턴 - 모듈 구조, 의존성 규칙, UseCase, Repository 및 데이터 레이어 패턴. |
| origin | ECC |
안드로이드 클린 아키텍처 (Android Clean Architecture)
안드로이드 및 KMP 프로젝트를 위한 클린 아키텍처 패턴입니다. 모듈 경계, 의존성 역전, UseCase/Repository 패턴, 그리고 Room, SQLDelight, Ktor를 이용한 데이터 레이어 설계를 다룹니다.
활성화 시점
- 안드로이드 또는 KMP 프로젝트 모듈 구조를 설계할 때
- UseCase, Repository 또는 DataSource를 구현할 때
- 레이어(domain, data, presentation) 간의 데이터 흐름을 설계할 때
- Koin 또는 Hilt를 이용한 의존성 주입을 설정할 때
- 계층화된 아키텍처에서 Room, SQLDelight 또는 Ktor로 작업할 때
모듈 구조
권장 레이아웃
project/
├── app/ # 안드로이드 진입점, DI 구성, Application 클래스
├── core/ # 공유 유틸리티, 베이스 클래스, 에러 타입
├── domain/ # UseCase, 도메인 모델, 레포지토리 인터페이스 (Pure Kotlin)
├── data/ # 레포지토리 구현체, DataSource, DB, 네트워크
├── presentation/ # 화면, ViewModel, UI 모델, 네비게이션
├── design-system/ # 재사용 가능한 Compose 컴포넌트, 테마, 타이포그래피
└── feature/ # 기능 모듈 (선택 사항, 대규모 프로젝트용)
├── auth/
├── settings/
└── profile/
의존성 규칙
app → presentation, domain, data, core
presentation → domain, design-system, core
data → domain, core
domain → core (또는 의존성 없음)
core → (없음)
중요: domain은 절대로 data, presentation 또는 프레임워크에 의존해서는 안 됩니다. 순수 Kotlin만 포함해야 합니다.
도메인 레이어 (Domain Layer)
UseCase 패턴
각 UseCase는 하나의 비즈니스 작업을 나타냅니다. 깔끔한 호출을 위해 operator fun invoke를 사용하세요.
class GetItemsByCategoryUseCase(
private val repository: ItemRepository
) {
suspend operator fun invoke(category: String): Result<List<Item>> {
return repository.getItemsByCategory(category)
}
}
class ObserveUserProgressUseCase(
private val repository: UserRepository
) {
operator fun invoke(userId: String): Flow<UserProgress> {
return repository.observeProgress(userId)
}
}
도메인 모델
도메인 모델은 프레임워크 어노테이션이 없는 순수 Kotlin 데이터 클래스여야 합니다.
data class Item(
val id: String,
val title: String,
val description: String,
val tags: List<String>,
val status: Status,
val category: String
)
enum class Status { DRAFT, ACTIVE, ARCHIVED }
레포지토리 인터페이스
도메인에 정의하고 데이터 레이어에서 구현합니다.
interface ItemRepository {
suspend fun getItemsByCategory(category: String): Result<List<Item>>
suspend fun saveItem(item: Item): Result<Unit>
fun observeItems(): Flow<List<Item>>
}
데이터 레이어 (Data Layer)
레포지토리 구현체
로컬 및 원격 데이터 소스 간의 조정을 담당합니다.
class ItemRepositoryImpl(
private val localDataSource: ItemLocalDataSource,
private val remoteDataSource: ItemRemoteDataSource
) : ItemRepository {
override suspend fun getItemsByCategory(category: String): Result<List<Item>> {
return runCatching {
val remote = remoteDataSource.fetchItems(category)
localDataSource.insertItems(remote.map { it.toEntity() })
localDataSource.getItemsByCategory(category).map { it.toDomain() }
}
}
override suspend fun saveItem(item: Item): Result<Unit> {
return runCatching {
localDataSource.insertItems(listOf(item.toEntity()))
}
}
override fun observeItems(): Flow<List<Item>> {
return localDataSource.observeAll().map { entities ->
entities.map { it.toDomain() }
}
}
}
매퍼(Mapper) 패턴
매퍼는 데이터 모델 근처에 확장 함수로 유지하세요.
fun ItemEntity.toDomain() = Item(
id = id,
title = title,
description = description,
tags = tags.split("|"),
status = Status.valueOf(status),
category = category
)
fun ItemDto.toEntity() = ItemEntity(
id = id,
title = title,
description = description,
tags = tags.joinToString("|"),
status = status,
category = category
)
Room 데이터베이스 (안드로이드)
@Entity(tableName = "items")
data class ItemEntity(
@PrimaryKey val id: String,
val title: String,
val description: String,
val tags: String,
val status: String,
val category: String
)
@Dao
interface ItemDao {
@Query("SELECT * FROM items WHERE category = :category")
suspend fun getByCategory(category: String): List<ItemEntity>
@Upsert
suspend fun upsert(items: List<ItemEntity>)
@Query("SELECT * FROM items")
fun observeAll(): Flow<List<ItemEntity>>
}
SQLDelight (KMP)
CREATE TABLE ItemEntity (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
tags TEXT NOT NULL,
status TEXT NOT NULL,
category TEXT NOT NULL
);
getByCategory:
SELECT * FROM ItemEntity WHERE category = ?;
upsert:
INSERT OR REPLACE INTO ItemEntity (id, title, description, tags, status, category)
VALUES (?, ?, ?, ?, ?, ?);
observeAll:
SELECT * FROM ItemEntity;
Ktor 네트워크 클라이언트 (KMP)
class ItemRemoteDataSource(private val client: HttpClient) {
suspend fun fetchItems(category: String): List<ItemDto> {
return client.get("api/items") {
parameter("category", category)
}.body()
}
}
val httpClient = HttpClient {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(Logging) { level = LogLevel.HEADERS }
defaultRequest { url("https://api.example.com/") }
}
의존성 주입 (Dependency Injection)
Koin (KMP 친화적)
val domainModule = module {
factory { GetItemsByCategoryUseCase(get()) }
factory { ObserveUserProgressUseCase(get()) }
}
val dataModule = module {
single<ItemRepository> { ItemRepositoryImpl(get(), get()) }
single { ItemLocalDataSource(get()) }
single { ItemRemoteDataSource(get()) }
}
val presentationModule = module {
viewModelOf(::ItemListViewModel)
viewModelOf(::DashboardViewModel)
}
Hilt (안드로이드 전용)
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository
}
@HiltViewModel
class ItemListViewModel @Inject constructor(
private val getItems: GetItemsByCategoryUseCase
) : ViewModel()
에러 처리 (Error Handling)
Result/Try 패턴
에러 전파를 위해 Result<T> 또는 커스텀 Sealed 타입을 사용하세요.
sealed interface Try<out T> {
data class Success<T>(val value: T) : Try<T>
data class Failure(val error: AppError) : Try<Nothing>
}
sealed interface AppError {
data class Network(val message: String) : AppError
data class Database(val message: String) : AppError
data object Unauthorized : AppError
}
viewModelScope.launch {
when (val result = getItems(category)) {
is Try.Success -> _state.update { it.copy(items = result.value, isLoading = false) }
is Try.Failure -> _state.update { it.copy(error = result.error.toMessage(), isLoading = false) }
}
}
컨벤션 플러그인 (Gradle)
KMP 프로젝트의 경우, 빌드 파일 중복을 줄이기 위해 컨벤션 플러그인을 사용하세요.
plugins {
id("org.jetbrains.kotlin.multiplatform")
}
kotlin {
androidTarget()
iosX64(); iosArm64(); iosSimulatorArm64()
sourceSets {
commonMain.dependencies { }
commonTest.dependencies { implementation(kotlin("test")) }
}
}
모듈에 적용:
plugins { id("kmp-library") }
피해야 할 안티 패턴
domain에 안드로이드 프레임워크 클래스 임포트 - 순수 Kotlin 유지
- UI 레이어에 데이터베이스 엔티티나 DTO 노출 - 항상 도메인 모델로 매핑
- ViewModel에 비즈니스 로직 작성 - UseCase로 추출
GlobalScope 또는 구조화되지 않은 코루틴 사용 - viewModelScope 또는 구조화된 동시성 사용
- 거대한 레포지토리 구현체 - 집중된 DataSource로 분할
- 순환 모듈 의존성 - A가 B에 의존하면 B는 A에 의존해서는 안 됨
참조
UI 패턴은 compose-multiplatform-patterns 스킬을 참조하세요.
비동기 패턴은 kotlin-coroutines-flows 스킬을 참조하세요.