[정독] https://github.com/android/nowinandroid/blob/main/docs/ArchitectureLearningJourney.md
Now in Android 앱 아키텍처에 대해 알아보자.
(레이어들, 주요 클래스과의 상호작용에 대해)
앱 아키텍처 목표
앱 아키텍처에는 세가지 레이어 (data, domain, UI) 가 있다.

아키텍처는 단방향 데이터 flow를 갖춘 반응형 프로그래밍 모델을 따른다.
data flow는 Kotlin Flows로 구현된 스트림을 사용하여 이뤄진다.
앱이 처음 실행되면 원격에서 뉴스 리소스 목록을 로드하려고 시도한다.
(prod build 버전 선택시 demo 빌드에서 로컬 데이터를 사용한다)
일단 로드되면 사용자가 선택한 관심분야에 따라 사용자에게 표시된다.
다음 다이어그램은 발생하는 이벤트와 이를 달성하기 위해 관련 개체에서 데이터 흐르는 방식을 보여준다.

앱 시작시 모든 저장소를 동기화하는 WorkManager job이 대기열에 추가된다
@HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
// Initialize Sync; the system responsible for keeping data in the app up to date.
Sync.initialize(context = this)
}
}
object Sync {
// This method is initializes sync, the process that keeps the app's data current.
// It is called from the app module's Application.onCreate() and should be only done once.
fun initialize(context: Context) {
WorkManager.getInstance(context).apply {
// Run sync on app startup and ensure only one sync worker runs at any time
enqueueUniqueWork(
SYNC_WORK_NAME,
ExistingWorkPolicy.KEEP,
SyncWorker.startUpSyncWork(),
)
}
}
}
ForYouViewModel 은 GetUserNewsResourcesUseCase 를 호출하여 북마크된/ 저장된 상태의 뉴스 리소스 스트림을 얻는다. 사용자와 뉴스 repositories 가 항목을 내보낼때까지 스트림으로 항목이 내보내지지 않는다. 기다리는 동안 feed 상태는 Loading 으로 설정된다.
@HiltViewModel
class ForYouViewModel @Inject constructor(
syncManager: SyncManager,
private val userDataRepository: UserDataRepository,
getUserNewsResources: GetUserNewsResourcesUseCase,
getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() {
val feedState: StateFlow<NewsFeedUiState> =
userDataRepository.getFollowedUserNewsResources(getUserNewsResources)
.map(NewsFeedUiState::Success)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsFeedUiState.Loading,
)
}
[최신 소스]
@HiltViewModel
class ForYouViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
syncManager: SyncManager,
private val analyticsHelper: AnalyticsHelper,
private val userDataRepository: UserDataRepository,
userNewsResourceRepository: UserNewsResourceRepository,
getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() {
val feedState: StateFlow<NewsFeedUiState> =
userNewsResourceRepository.observeAllForFollowedTopics()
.map(NewsFeedUiState::Success)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsFeedUiState.Loading,
)
}
interface UserNewsResourceRepository {
/**
* Returns available news resources for the user's followed topics as a stream.
*/
fun observeAllForFollowedTopics(): Flow<List<UserNewsResource>>
}
user data repository 는 Proto DataStore 가 지원하는 로컬 데이터 소스에서 UserData 객체의 스트림을 가져온다.
internal class OfflineFirstUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val analyticsHelper: AnalyticsHelper,
) : UserDataRepository {
override val userData: Flow<UserData> =
niaPreferencesDataSource.userData
}
class NiaPreferencesDataSource @Inject constructor(
private val userPreferences: DataStore<UserPreferences>,
) {
val userData = userPreferences.data
.map {
UserData(
...
)
}
}
WorkManager 는 OfflineFirstNewsRepository 를 호출하는 sync job 을 실행하여 원격 data source 와 데이터 동기화를 시작한다. (과거)
[현재 소스]
internal class OfflineFirstNewsRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao,
private val **network: NiaNetworkDataSource**,
private val notifier: Notifier,
) : NewsRepository {
override suspend fun syncWith(synchronizer: Synchronizer): Boolean {
var isFirstSync = false
return synchronizer.changeListSync(
versionReader = ChangeListVersions::newsResourceVersion,
changeListFetcher = { currentVersion ->
isFirstSync = currentVersion <= 0
network.getNewsResourceChangeList(after = currentVersion)
},
...
)
}
}
suspend fun Synchronizer.changeListSync(
versionReader: (ChangeListVersions) -> Int,
changeListFetcher: suspend (Int) -> List<NetworkChangeList>,
versionUpdater: ChangeListVersions.(Int) -> ChangeListVersions,
modelDeleter: suspend (List<String>) -> Unit,
modelUpdater: suspend (List<String>) -> Unit,
) = suspendRunCatching {
// Fetch the change list since last sync (akin to a git fetch)
val currentVersion = versionReader(getChangeListVersions())
val changeList = changeListFetcher(currentVersion)
if (changeList.isEmpty()) return@suspendRunCatching true
val (deleted, updated) = changeList.partition(NetworkChangeList::isDelete)
// Delete models that have been deleted server-side
modelDeleter(deleted.map(NetworkChangeList::id))
// Using the change list, pull down and save the changes (akin to a git pull)
modelUpdater(updated.map(NetworkChangeList::id))
// Update the last synced version (akin to updating local git HEAD)
val latestVersion = changeList.last().changeListVersion
updateChangeListVersions {
versionUpdater(latestVersion)
}
}.isSuccess
OfflineFirstNewsRepository 는 RetrofitNiaNetwork 를 호출하여 Retrofit 을 사용하여 현재 API 요청을 실행합니다.
internal class OfflineFirstNewsRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
){
override suspend fun syncWith(synchronizer: Synchronizer): Boolean {
var isFirstSync = false
return synchronizer.changeListSync(
modelUpdater = { changedIds ->
// Obtain the news resources which have changed from the network and upsert them locally
changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds ->
val networkNewsResources = network.getNewsResources(ids = chunkedIds)
}
...
}
)
}
}
RetrofitNiaNetwork 는 원격 서버에 있는 REST API 를 호출한다.
RetrofitNiaNetwork 는 원격 서버로 부터 네트워크 응답을 받는다.(RetrofitNiaNetwork.getNewsResources)
internal class RetrofitNiaNetwork @Inject constructor(
networkJson: Json,
okhttpCallFactory: Call.Factory,
) : NiaNetworkDataSource {
private val networkApi = Retrofit.Builder()
.baseUrl(NIA_BASE_URL)
.callFactory(okhttpCallFactory)
.addConverterFactory(
networkJson.asConverterFactory("application/json".toMediaType()),
)
.build()
.create(RetrofitNiaNetworkApi::class.java)
override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
networkApi.getNewsResources(ids = ids).data
}
private interface RetrofitNiaNetworkApi {
@GET(value = "newsresources")
suspend fun getNewsResources(
@Query("id") ids: List<String>?,
): NetworkResponse<List<NetworkNewsResource>>
}
OfflineFirstNewsRepository 는 로컬 Room DB 에 데이터를 삽입, 업데이트 또는 삭제하여 NewResourceDao 와 원격 데이터를 동기합니다.
NewsResourceDao 에서 데이터가 변경되면 이는 뉴스 리소스 데이터 스트림(flow) 으로 방출된다.
@Dao
interface NewsResourceDao {
@Transaction
@Query(
value = """
SELECT * FROM news_resources
WHERE
CASE WHEN :useFilterNewsIds
THEN id IN (:filterNewsIds)
ELSE 1
END
AND
CASE WHEN :useFilterTopicIds
THEN id IN
(
SELECT news_resource_id FROM news_resources_topics
WHERE topic_id IN (:filterTopicIds)
)
ELSE 1
END
ORDER BY publish_date DESC
""",
)
fun getNewsResources(
useFilterTopicIds: Boolean = false,
filterTopicIds: Set<String> = emptySet(),
useFilterNewsIds: Boolean = false,
filterNewsIds: Set<String> = emptySet(),
): Flow<List<PopulatedNewsResource>>
}
OfflineFirstNewsRepository 는 이 스트림에서 중간 연산자 역할을 하며, PopulatedNewsResource (a database model, internal to the data layer) 를 다른 계층에서 사용되는 public NewsResource 모델로 변환한다.
internal class OfflineFirstNewsRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
private val notifier: Notifier,
) : NewsRepository {
override fun getNewsResources(
query: NewsResourceQuery,
): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
useFilterTopicIds = query.filterTopicIds != null,
filterTopicIds = query.filterTopicIds ?: emptySet(),
useFilterNewsIds = query.filterNewsIds != null,
filterNewsIds = query.filterNewsIds ?: emptySet(),
)
.map { it.map(PopulatedNewsResource::asExternalModel) }
}
fun PopulatedNewsResource.asExternalModel() = NewsResource(
id = entity.id,
title = entity.title,
content = entity.content,
url = entity.url,
headerImageUrl = entity.headerImageUrl,
publishDate = entity.publishDate,
type = entity.type,
topics = topics.map(TopicEntity::asExternalModel),
)
GetUserNewsResourcesUseCase 는 뉴스 리소스목록과 사용자 데이터를 결합하여 UserNewResources 목록을 내보낸다.
[최신 소스]
/**
* Implements a [UserNewsResourceRepository] by combining a [NewsRepository] with a
* [UserDataRepository].
*/
class CompositeUserNewsResourceRepository @Inject constructor(
val newsRepository: NewsRepository,
val userDataRepository: UserDataRepository,
) : UserNewsResourceRepository {
/**
* Returns available news resources (joined with user data) for the followed topics.
*/
override fun observeAllForFollowedTopics(): Flow<List<UserNewsResource>> =
userDataRepository.userData.map { it.followedTopics }.distinctUntilChanged()
.flatMapLatest { followedTopics ->
when {
followedTopics.isEmpty() -> flowOf(emptyList())
else -> observeAll(NewsResourceQuery(filterTopicIds = followedTopics))
}
}
}
ForYouViewModel 는 저장 가능한 뉴스 리소스를 수신하면 피드 상태를 성공으로 업데이트한다. 그 다음 ForYouScreen 은 상태에 저장 가능한 뉴스 리소스를 사용하여 화면을 렌더링한다.
class ForYouViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
syncManager: SyncManager,
private val analyticsHelper: AnalyticsHelper,
private val userDataRepository: UserDataRepository,
userNewsResourceRepository: UserNewsResourceRepository,
getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() {
val feedState: StateFlow<NewsFeedUiState> =
userNewsResourceRepository.observeAllForFollowedTopics()
.map(NewsFeedUiState::Success)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsFeedUiState.Loading,
)
}
sealed interface NewsFeedUiState {
/**
* The feed is still loading.
*/
data object Loading : NewsFeedUiState
/**
* The feed is loaded with the given list of news resources.
*/
data class Success(
/**
* The list of news resources contained in this feed.
*/
val feed: List<UserNewsResource>,
) : NewsFeedUiState
}
데이터 레이어는 앱 데이터 및 비즈니스 로직의 오프라인 소스로 구현된다. 이는 앱의 모든 데이터에 대한 정보의 원천이다.

각 레파짓토리에는 자체 모델이 있다. 예를 들면 TopicsRepository 는 Topic model 이 있고 NewsRepository에는 NewsResource model이 있다.
레파짓토리는 다른 레이어의 공개 API 이며, 앱 데이터에 접근할 수 있는 유일한 방법을 제공한다.
레파짓토리는 일반적으로 데이터를 읽고 쓰는 한가지 이상의 방법을 제공한다.
데이터는 데이터 스트림으로 노출된다. 이는 저장소의 각 클라이언트가 데이터 변경에 대응할 준비가 되어 있어야 함을 의미한다. 데이터는 사용 시점까지 유효하다는 보장이 없기 때문에 스냅샷(e.g. getModel) 로 노출이 되지 않는다.
읽기는 source of truth로서 로컬 스토리지에서 수행되므로 레파짓토리 인스턴스에서 읽을 때 오류가 예상되지 않는다. 그러나 로컬 저장도의 데이터를 원격소스와 조정하려고 하면 오류가 발생할 수 있다. 오류 조정에 대한 자세한 내용은 아래 데이터 동기화 섹션을 체크하자.
List 을 내보내는 TopicsRepository::getTopics flow 를 구독하면 Topic 목록을 얻을 수 있다.
주제 목록이 변경될때마다 (ex. 새주제가 추가 될 때마다) 업데이트왠 토픽리스트는 스트림으로 내보내진다.
데이터를 쓰기 위해, 저장소는 일시 중지 기능을 제공한다. 실행 범위가 적절한지 확인하는 것은 호출자의 몫이다.
사용자가 팔로우하려는 topic의 ID 를 사용하여 UserDataRepository.toggleFollowedTopicId 를 호출하고 Followed=true 를 사용하여 해당 주제를 팔로우해야 함을 나타낸다.(topic 언팔로우하려면 false 사용)
repository 는 하나이상의 data sources 를 의존한다. 예를 들어 OfflineFirstTopicsRepository 는 다음의 data sources 에 의존한다.
TopicsDao
NiaPreferencesDataSource
NiaNetworkDataSource
레파짓토리는 로컬 스토리지의 데이터를 원격 소스와 조정하는 역할을 담당한다. 원격 데이터 소스에서 데이터를 얻으면, 즉시 로컬 스토리지에 기록이 된다. 업데이트된 데이터는 로컬 저장소 (Room) 에서
관련 데이터 스트림으로 보내지고 수신 클라이언트에서 수신된다.
이 접근 방식은 앱의 읽기와 쓰기 문제가 분리되어 서로 간섭하지 않는다.
데이터 동기화 중 오류가 발생하는 경우 지수 백오프 전략이 사용된다. 이는 Synchronizer interface 의 구현인 SyncWorker 를 통해 Workmanager에 위임된다.
데이터 동기화의 예는 OfflineFirstNewsRepository.syncWith 를 참조하기
[OfflineFirstNewsRepository]
override suspend fun syncWith(synchronizer: Synchronizer): Boolean {
var isFirstSync = false
return synchronizer.changeListSync(
...
)
}
suspend fun Synchronizer.changeListSync()
domain layer 는 use cased 가 포함된다. 이는 비즈니스 로직을 포함하는 호출 가능한 단일 메서드(연산자 fun invoke) 가 있는 클레스 이다.
이러한 사용 사례는 ViewModel에서 중복 로직을 단순화하고 제거하는 데 사용된다. 일반적으로 repository 의 데이터를 결합하고, 변환한다.
예시로, GetUserNewsResourcesUseCase 는 NewsRepository의 NewsResources 스트림(Flow 사용해 구현된) 을 UserDataRepository의 UserData 개체 스트림과 결합하여 UserNewsResources 스트림을 생성한다.
이 스트림은 다양한 VIewModel에서 북마크된 상태로 화면에 뉴스 리소스를 표시하는데 사용된다.
특히, Now in Android 의 도메인 레이어에서는(현재로는) 이벤트 처리에 대한 use cases 가 포함되어있지 않다. 이벤트는 리포지토리의 메서드를 직접 호출하는 UI 레이어에 의해 처리된다.
UI layer 는 다음과 같이 구성됨
ViewModel 은 use cases 및 리파짓토리에서 데이터 스트림을 수신하고 이를 UI 상태로 변환한다. UI 요소는 이 상태를 반영하고, 사용자가 앱과 상호작용을 할 수 있는 방법을 제공한다. 이러한 상호 작용은 처리되는 ViewModel 에 이벤트로 전달된다.

UI 상태는 인터페이스와 불변 데이터 클래스를 사용하여 계층 구조를 모델링 한다. 상태 Object 는 데이터의 스트림의 변환을 통해서만 방출된다. 이 접근방식은 다음을 보장한다.