Clean Architecture — 2012년 로버트 마틴(Uncle Bob)이 제안한 다계층 아키텍처로, 애플리케이션을 독립적인 계층(Domain: Entities, Use Cases, Data: Repositories, DataSources, Presentation: ViewModels, Views)으로 분할합니다. 주요 원칙은 의존성 규칙(Dependency Rule)입니다. 의존성은 안쪽을 향하며, 외부 계층은 내부 계층에 의존하지만 그 반대는 아닙니다. Clean Architecture는 비즈니스 로직이 복잡한 모바일 개발 프로젝트에서 사용됩니다. 자세한 내용은 The Clean Architecture 책을 참조하세요.
핵심 포인트
Clean Architecture — 로버트 마틴(Uncle Bob)이 2012년에 공식화한 아키텍처 패턴입니다. 핵심 아이디어는 엄격한 의존성 규칙을 가진 계층으로 애플리케이션을 분할하는 것입니다. 계층 내부의 코드는 외부 코드에 대해 알지 못합니다. 외부 계층(UI, 프레임워크, DB)은 구현 세부 사항입니다. 내부 계층(비즈니스 로직, 엔터프라이즈 규칙)은 애플리케이션의 본질입니다.
모바일 개발의 Clean Architecture 계층: 1) Domain — 엔티티(비즈니스 객체) 및 유스 케이스(사용 시나리오); 2) Data — RepositoryImpl(리포지토리 구현), DataSources(네트워크, DB, 캐시); 3) Presentation — ViewModel, 뷰(Compose/SwiftUI). Domain은 의존성이 없는 가장 안쪽 계층입니다. Data는 Domain에 의존합니다(리포지토리 인터페이스 구현). Presentation은 Domain에 의존합니다(유스 케이스 호출, 결과 구독).
| 계층 | 포함 | 의존성 |
|---|---|---|
| Domain | 엔티티, 유스 케이스, 리포지토리 인터페이스 | 없음(순수 Kotlin/Swift) |
| Data | RepositoryImpl, DataSources(API, DB, 캐시) | Domain, Retrofit, Room, Ktor |
| Presentation | ViewModel, 뷰, Composable | Domain, Jetpack, SwiftUI |
의존성 규칙 — Clean Architecture의 유일한 엄격한 규칙입니다. 소스 코드는 자신의 내부 계층 또는 하위 계층(중심에 가까운)만 참조할 수 있습니다. Presentation은 Domain을 가져옵니다. Domain은 Data나 Presentation을 가져오지 않습니다. 이는 의존성 역전 원칙(Dependency Inversion Principle)을 통해 달성됩니다. Domain이 Repository 인터페이스를 정의하고 Data가 이를 구현합니다. Presentation은 특정 리포지토리가 아닌 UseCase 추상화에 의존합니다.
Domain — 애플리케이션의 가장 안정적인 계층입니다. 엔티티는 프레임워크에 독립적인 비즈니스 객체입니다: User, Product, Order. 유스 케이스는 하나의 invoke 메서드(또는 Kotlin의 operator fun invoke)를 가진 클래스로, 하나의 시나리오를 구현합니다: GetUserUseCase, PlaceOrderUseCase, CalculateTotalUseCase. 리포지토리 인터페이스는 Domain에서 정의되고 Data에서 구현되는 데이터 접근 추상화입니다. Domain에는 Android SDK, iOS UIKit, Retrofit, Room이 포함되지 않습니다 — 순수한 Kotlin 또는 Swift만 있습니다.
// Entity — 비즈니스 객체 (Domain)
struct User: Equatable {
let id: Int
let name: String
let email: String
}
// Repository Interface — 데이터 추상화 (Domain)
protocol UserRepository {
func getUser(id: Int) async throws -> User
func getUsers() async throws -> [User]
}
// Use Case — 하나의 시나리오 (Domain)
final class GetUserUseCase {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func execute(id: Int) async throws -> User {
return try await repository.getUser(id: id)
}
}
유스 케이스 — «하나의 메서드를 가진 클래스»는 교리가 아니라 실용적인 권장 사항입니다. 유스 케이스가 더 복잡해지면(유효성 검사 + 로깅 + 리포지토리 호출), 메서드가 의미별로 그룹화됩니다: UserUseCase.getUser, UserUseCase.searchUsers, UserUseCase.deleteUser. 중요한 것은 유스 케이스가 데이터의 출처(네트워크, DB, 캐시)나 표시 주체(Compose, SwiftUI)를 알지 않아야 한다는 것입니다. IT Sectr에서는 비즈니스 규칙, 유효성 검사 또는 두 소스의 데이터 결합이 있는 각 작업에 유스 케이스를 할당합니다.
Domain 순수성은 계층 경계에서 DTO 매핑을 통해 달성됩니다. Data 계층은 JSON 모델(DTO)을 받아 Domain 엔티티로 매핑합니다. Presentation은 Domain 엔티티를 받아 ViewModel(DisplayItem)로 매핑합니다. Domain 엔티티에는 Retrofit, Room 또는 Codable 애너테이션이 포함되지 않습니다 — 이는 DB를 Room에서 Realm으로 변경하거나 Retrofit을 Ktor로 교체할 때 계층을 변경할 필요가 없음을 보장합니다.
Data 계층 — Domain에 정의된 인터페이스의 구현입니다. RepositoryImpl(UserRepository를 구현하는 클래스)과 DataSources(RemoteDataSource — API, LocalDataSource — DB, CacheDataSource — SharedPreferences/NSUserDefaults)를 포함합니다. Data 계층은 Domain(리포지토리 인터페이스 및 엔티티 가져오기)과 프레임워크(Retrofit, Room, Ktor, CoreData)에 의존합니다. RepositoryImpl은 Domain에서 데이터 소스를 숨깁니다 — 유스 케이스는 데이터가 네트워크에서 왔는지 캐시에서 왔는지 알지 못합니다.
// DTO — 네트워크용 모델 (Data)
data class UserDto(
@SerializedName("id") val id: Int,
@SerializedName("first_name") val firstName: String,
@SerializedName("last_name") val lastName: String,
@SerializedName("email") val email: String
)
// RepositoryImpl — 구현 (Data)
class UserRepositoryImpl(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override suspend fun getUser(id: Int): User {
// 캐시에서 가져오기 시도
localDataSource.getUser(id)?.let { return it.toDomain() }
// 없으면 — 네트워크에서 로드
val dto = remoteDataSource.fetchUser(id)
val user = dto.toDomain()
localDataSource.saveUser(user)
return user
}
override suspend fun getUsers(): List<User> {
return remoteDataSource.fetchAllUsers().map { it.toDomain() }
}
}
// Mapper — DTO ↔ Domain 변환
fun UserDto.toDomain() = User(
id = id,
name = "$firstName $lastName",
email = email
)
Data 계층의 캐싱 전략: RepositoryImpl은 먼저 로컬 저장소를 확인하고, 데이터가 없으면 네트워크에서 로드하여 로컬에 저장합니다. 네트워크를 사용할 수 없으면 isStale 플래그와 함께 오래된 데이터를 반환합니다. Domain의 유스 케이스는 전략에 대해 알지 못합니다 — Repository.getUser(id)를 통해 User를 받습니다. 전략 변경(예: 15분마다 캐시 무효화)은 Domain과 Presentation에 영향을 미치지 않습니다.
Android의 모듈성 — Kotlin Multiplatform을 사용하면 Android SDK 종속성 없이 Domain을 별도의 KMP 모듈로 추출할 수 있습니다. Data는 Domain에 종속된 별도의 모듈입니다. Presentation은 Domain에 종속된 Android 모듈입니다. Gradle 종속성: domain(순수 Kotlin), data(domain + Retrofit + Room), app(domain + presentation + Hilt). 이러한 모듈성은 대규모 프로젝트에 필수적입니다 — CI는 Domain을 별도로 빌드하며, Domain 단위 테스트에는 Android 에뮬레이터가 필요하지 않습니다.
Presentation 계층 — Clean Architecture의 가장 바깥쪽 계층입니다. ViewModel(Android) / ObservableObject(iOS)와 뷰(Compose/SwiftUI)를 포함합니다. ViewModel은 유스 케이스를 호출하고 결과를 받아 UI 상태로 변환합니다. 뷰는 상태를 구독하고 렌더링합니다. Presentation은 Domain에 의존합니다 — 유스 케이스와 엔티티를 가져옵니다. Presentation은 Data 계층을 가져오지 않습니다 — 데이터는 내부적으로 리포지토리를 사용하는 유스 케이스를 통해 옵니다.
Clean Architecture의 ViewModel에는 비즈니스 로직이 없습니다 — 유스 케이스를 호출합니다. 유스 케이스가 User를 반환하면 ViewModel은 이를 UserDisplayItem(name, emailFormatted, avatarUrl) — 순수 프레젠테이션 모델로 변환합니다. 유스 케이스는 DisplayItem에 대해 알지 못합니다 — 엔티티를 반환합니다. 이러한 분리를 통해 UI 없이 유스 케이스를, UseCase 없이 ViewModel을(모킹을 통해) 테스트할 수 있습니다. IT Sectr에서는 엄격히 준수합니다: 유스 케이스 — 비즈니스 로직, ViewModel — 프레젠테이션만, 뷰 — 표시만.
// Use Case (Domain) — 순수 비즈니스 로직
class GetUserUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Int): User {
return repository.getUser(id)
}
}
// ViewModel (Presentation) — 프레젠테이션만
class UserViewModel(
private val getUserUseCase: GetUserUseCase
) : ViewModel() {
private val _state = MutableStateFlow<UserScreenState>(UserScreenState.Loading)
val state: StateFlow<UserScreenState> = _state.asStateFlow()
fun loadUser(id: Int) {
viewModelScope.launch {
_state.value = UserScreenState.Loading
val user = getUserUseCase(id)
val displayItem = UserDisplayItem(
name = user.name,
email = user.email,
initials = user.name.split(" ").joinToString("") { it.first().toString() }
)
_state.value = UserScreenState.Success(displayItem)
}
}
}
data class UserDisplayItem(
val name: String,
val email: String,
val initials: String
)
sealed interface UserScreenState {
data object Loading : UserScreenState
data class Success(val displayItem: UserDisplayItem) : UserScreenState
data class Error(val message: String) : UserScreenState
}
내비게이션은 Presentation 계층에서도 외부 링의 일부입니다. Clean Architecture는 내비게이션 메커니즘을 규정하지 않습니다 — NavController(Compose), NavigationStack(SwiftUI), Coordinator(UIKit) 또는 Router(VIPER)일 수 있습니다. 중요한 것은 내비게이션 결정은 Presentation이 하지만 내비게이션이 유스 케이스로 침투해서는 안 된다는 것입니다. 유스 케이스는 결과를 반환하고, ViewModel은 이동할 화면을 결정합니다. Clean Architecture에서 내비게이션은 Domain을 변경하지 않고 교체할 수 있는 세부 사항입니다.
Android의 Clean Architecture는 Gradle 모듈을 통해 구현됩니다: domain(순수 Kotlin), data(domain + Retrofit + Room), presentation(domain + Compose). 폴더 구조: domain/user/User.kt, GetUserUseCase.kt, UserRepository.kt; data/remote/UserRemoteDataSource.kt, local/UserDao.kt, repository/UserRepositoryImpl.kt; presentation/ui/user/UserViewModel.kt, UserScreen.kt. DI(Hilt)가 계층을 연결합니다: UserRepositoryImpl은 domain 모듈의 UserRepository 인터페이스에 바인딩됩니다.
iOS의 Clean Architecture는 Xcode 제한으로 인해 별도 모듈 없이 SPM 또는 Xcode 그룹을 사용합니다. Domain — UIKit이나 SwiftUI를 가져오지 않는 파일이 있는 폴더. Data — APIClient, CoreDataStack, RepositoryImpl이 있는 폴더. Presentation — ViewModel과 SwiftUI 뷰가 있는 폴더. DI는 생성자 또는 앱 내 어셈블리를 통해 이루어집니다. 주요 호출은 UI 업데이트를 위한 MainActor 확인과 함께 UseCase.execute()를 통한 async/await입니다.
// Data Layer: Remote DataSource (iOS)
final class UserRemoteDataSource {
private let apiClient: APIClient
func fetchUser(id: Int) async throws -> UserDTO {
return try await apiClient.get("/users/\(id)")
}
}
// Repository Implementation (Data)
final class UserRepositoryImpl: UserRepository {
private let remote: UserRemoteDataSource
private let local: UserLocalDataSource
func getUser(id: Int) async throws -> User {
if let cached = try await local.getUser(id) {
return cached
}
let dto = try await remote.fetchUser(id)
let user = dto.toDomain()
try await local.saveUser(user)
return user
}
}
// Presentation: ViewModel + SwiftUI View
@MainActor
final class UserViewModel: ObservableObject {
@Published private(set) var state: UserScreenState = .loading
private let getUserUseCase: GetUserUseCase
func loadUser(id: Int) {
Task {
state = .loading
if let user = try? await getUserUseCase.execute(id: id) {
state = .success(user)
} else {
state = .error("Failed to load")
}
}
}
}
IT Sectr 프로젝트의 Clean Architecture — 30일 이상의 프로젝트를 위한 당사의 표준입니다. 2022년부터 Android/iOS용 Kotlin Multiplatform을 사용한 3계층 아키텍처를 사용하고 있습니다. Domain — 공유 KMP 모듈, Data — 플랫폼 모듈(Android의 Retrofit, iOS의 URLSession), Presentation — 네이티브 UI. 이를 통해 iOS와 Android 간에 60~80%의 비즈니스 로직 코드를 공유하여 두 개의 개별 구현에 비해 개발 시간을 30~40% 단축합니다.
자주 묻는 질문
최소 3개: Domain, Data, Presentation. 대규모 프로젝트의 경우 Framework(Android SDK/iOS UIKit 종속성)와 Device(GPS, 카메라, 센서)가 추가됩니다. 계층 수는 엄격한 규칙이 아니라 편의의 문제입니다. 중요한 것은 의존성 규칙을 따르는 것입니다. 의존성은 안쪽, Domain을 향합니다. 3개로 시작하여 프로젝트가 성장함에 따라 계층을 추가할 수 있습니다.
예 — 리포지토리 인터페이스, 유스 케이스 및 매퍼 추출로 인해 MVVM에 비해 30~50% 증가합니다. 단순한 CRUD 애플리케이션의 경우 과도합니다. Clean Architecture는 테스트 가능성과 계층 분리가 개발 속도보다 중요한 복잡한 비즈니스 로직이 있는 프로젝트에 적합합니다. MVP 또는 프로토타입에는 MVVM을 사용하세요 — Clean Architecture는 시작을 지연시킵니다.
예, 일반적인 관행입니다. 유스 케이스는 Domain에 남아 있고, Presentation은 MVI 사이클(Intent → Reducer → State)을 사용합니다. Data 계층은 동일하게 유지되고, Domain도 동일하게 유지됩니다. Presentation의 MVI는 예측 가능한 화면 상태를 제공하고, Clean Architecture는 비즈니스 로직 분리를 제공합니다. 이 조합은 수십 명의 개발자가 있는 대규모 프로젝트에서 사용됩니다.
유스 케이스는 작업에 비즈니스 규칙(유효성 검사, 두 소스의 데이터 결합, 계산, 로깅, 접근 권한 확인)이 포함될 때 필요합니다. 추가 로직 없이 단순한 getUser(id) 요청은 ViewModel에서 직접 리포지토리를 호출할 수 있습니다. 그러나 아키텍처 일관성을 위해 많은 팀이 리포지토리의 모든 공개 메서드에 대해 유스 케이스를 만듭니다 — 이는 5~10%의 코드를 추가하지만 가독성을 높입니다.
Domain: 모의 Repository를 사용한 유스 케이스 단위 테스트 — Android SDK 없이 순수 Kotlin/Swift. Data: 모의/가짜 DataSource를 사용한 RepositoryImpl 통합 테스트. Presentation: 모의 UseCase를 사용한 ViewModel 테스트. 의존성 규칙 덕분에 각 계층이 격리되어 테스트됩니다. IT Sectr에서 Domain 커버리지는 95%, Data는 70~80%, Presentation은 60~70%에 도달합니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.