의존성이 항상 안쪽을 향하게 만드는 구조.
┌──────────────────────────┐
│ Presentation (UI) │
│ ┌───────────────────┐ │
│ │ Domain │ │ ← 아무것도 의존하지 않는다
│ │ Entity · UseCase │ │
│ └───────────────────┘ │
│ Data (Repository 구현) │
└──────────────────────────┘
화살표는 전부 안쪽으로
어떻게 화살표를 뒤집나
// domain 모듈 — 인터페이스를 '도메인이' 소유한다
package com.example.domain
interface UserRepository { suspend fun getUser(id: Long): User }
// data 모듈 — 도메인의 인터페이스를 구현한다
package com.example.data
class UserRepositoryImpl @Inject constructor(
private val api: UserApi,
private val dao: UserDao
) : UserRepository { ... }
인터페이스를 어느 쪽이 소유하느냐가 전부다. 도메인이 가지면 화살표가 뒤집힌다.
모델이 계층마다 다른 이유
- UserDto — 네트워크 응답 그대로 (서버 스키마에 묶인다)
- UserEntity — Room 테이블 (@Entity 애노테이션 · DB 스키마에 묶인다)
- User — 도메인 모델 (순수 코틀린. 아무것도 안 묻는다)
- UserUiModel — 화면 표시용 (포맷된 날짜 · 표시 문자열)
// Mapper 가 경계에서 변환한다
fun UserDto.toDomain() = User(id, name, email)
fun User.toEntity() = UserEntity(id, name, email)
왜 나누나 서버가 필드를 바꿔도 도메인이 안 바뀐다 DB 스키마를 바꿔도 도메인이 안 바뀐다
- 변경의 파급을 경계에서 끊는다
트레이드오프를 정직하게
얻는 것
- 도메인 테스트에 안드로이드도 DB 도 필요 없다 (순수 JUnit)
- 프레임워크·라이브러리를 바꿔도 도메인이 산다
- 모듈 경계가 명확해 팀이 나눠 일하기 좋다
내는 것
- 클래스와 매퍼 코드가 2~3배로 는다
- 단순 CRUD 화면 하나에 파일이 6개 생긴다
- 팀이 규칙을 이해하지 못하면 형식만 남는다
작은 앱에서는 과설계다 — 도메인 규칙이 얇으면 3계층으로 충분하다. "공식 가이드가 클린 아키텍처를 강제하지 않는다"는 점을 기억한다.
면접 함정
- ❌ "클린 아키텍처가 안드로이드 표준" → 공식 권장은 3계층이다. 클린은 그보다 엄격한 선택지다.
- ❌ "모델을 나누면 무조건 좋다" → 필드가 같은 매퍼가 수십 개면 비용만 남는다.
어디까지 나눌지 — 실무의 절충
-
흔한 절충 1 — DTO 와 Entity 만 나누고 도메인 모델은 Entity 를 재사용
- 매퍼가 절반으로 준다. Room 애노테이션이 도메인에 남는 것을 감수
-
흔한 절충 2 — 도메인 모델은 두되 UiModel 은 안 만든다
- 포맷팅은 Composable 안에서 한다
-
흔한 절충 3 — UseCase 는 조합이 필요한 곳에만
- 단순 위임은 ViewModel 이 Repository 를 직접 부른다
어느 쪽이든 '왜 그렇게 했는지' 를 말할 수 있으면 된다 면접에서 나쁜 답은 절충 자체가 아니라 "원래 그렇게 배웠어요" 다
테스트가 실제로 어떻게 쉬워지나
// 도메인 테스트 — 안드로이드도 DB 도 네트워크도 없다. 순수 JUnit
class GetUserFeedUseCaseTest {
private val userRepo = FakeUserRepository()
private val postRepo = FakePostRepository()
private val useCase = GetUserFeedUseCase(userRepo, postRepo)
@Test fun `차단된 글은 피드에서 제외된다`() = runTest {
postRepo.setPosts(listOf(post(id = 1, blocked = true), post(id = 2)))
val feed = useCase(userId = 1)
assertEquals(listOf(2L), feed.posts.map { it.id })
}
}
이 테스트는 밀리초 단위로 돈다 Robolectric 도 에뮬레이터도 필요 없다 — 이게 격리의 실제 이득이다