[Android] 권장 앱 아키텍처 구성 필수요소

1

Android

목록 보기
12/16

개요

학부 시절에는 로직에 관한 모든 코드를 액티비티나 프래그먼트에 작성하곤 했습니다. 이런 코드들은 각각의 앱 구성요소의 수명주기에 의존하고 있어 UI데이터가 초기화 되거나 앱이 리프레시되는 위험성이 존재합니다. 따라서 효율적인 아키텍처를 구성하기 위해선 데이터 모델에서 UI를 도출하도록 관심사를 분리하는 것이 효과적입니다. 이러한 관심사 분리는 모듈 단위의 효과적인 테스트도 가능하므로 더욱 안정성 있는 앱을 만들 수 있습니다.

권장 앱 아키텍쳐

앞서 언급한 앱 구성요소의 수명주기에 연관된 문제점을 피하기 위해서는 두 개 이상의 레이어를 구성하여 모듈간 결합도를 최소화 해야합니다.

  • 화면에 애플리케이션 데이터를 표시하는 UI레이어
  • 앱의 비스니스 로직을 포함하고 애플리케이션 데이터를 노출하는 데이터 레이어


위 그림은 클래스 간의 종속 항목을 나타냅니다. 예를 들어 도메인 레이어는 데이터 레이어에 의존합니다.

UI 레이어(Presentation Layer)

UI 레이어는 화면에 애플리케이션 데이터를 표시하는 것입니다. 사용자와의 상호작용을 통해 데이터의 변경사항을 즉각 반영하도록 UI가 업데이트되어야 합니다.

UI 레이어는 다음 두가지로 구성됩니다.

  • 화면에 데이터를 렌더링하는 UI 요소
  • 데이터를 저장하고 UI요소에 노출하며 로직을 처리하는 상태 홀더 ex) ViewModel 클래스

데이터 레이어

데이터 레이어에는 앱의 데이터 생성, 저장, 변경 방식을 결정하는 비즈니스 로직이 포함되어 있습니다.

저장소 클래스에서 담당하는 작업은 다음과 같습니다.

  • 앱의 나머지 부분에 데이터 노출
  • 데이터 변경사항을 한 곳에 집중
  • 여러 데이터 소스 간의 충돌 해결
  • 앱의 나머지 부분에서 데이터 소스 추상화
  • 비즈니스 로직 포함

다만 주의해야할 점도 있습니다.

  • 각 데이터 소스 클래스는 파일, 네트워크 소스, 로컬 데이터베이스와 같은 하나의 데이터 소스만 사용해야합니다. 데이터 소스 클래스는 데이터 작업을 위해 애플리케이션과 시스템 간의 가교 역할을 합니다.

  • 다른 레이어는 데이터 소스에 직접 액세스해서는 안됩니다. 데이터 영역의 진입점은 항상 저장소(repository)클래스여야 합니다.

  • ViewModel과 같은 상태 홀더 클래스와 Use Case 클래스에는 데이터 소스가 직접 종속 항목으로 있어서는 안됩니다. 저장소 클래스를 진입점으로 사용하여 다양한 레이어를 독립적으로 확장해야 합니다.

이름 지정 규칙

  • 저장소 클래스: 데이터 유형 + 저장소 ex) NewsRepository
  • 데이터 소스 클래스: 데이터 유형 + 소스 유형 + DataSource ex) NewsRemoteDataSource, NewsLocalDataSource

여러 수준의 저장소

더 복잡한 비스니스 요구사항이 포함된 일부 경우에는 저장소가 다른 저장소에 종속되어야 할 수 있습니다. 관련된 데이터가 여러 데이터 소스의 집계이거나 책임이 다른 저장소 클래에스에 캡슐화 되어야 하기 때문일 수도 있습니다.

일반적인 작업

일반적으로 사용되는 특정 작업을 실행하기 위해 데이터 레이어를 사용하고 설계하는 방법의 예입니다.

데이터 소스 만들기

데이터 소스는 최신 뉴스를 반환하는 함수, 즉 ArticleHeadline 인스턴스 목록을 노출해야합니다. 또한 네트워크에서 최신 뉴스를 가져오는 기본 안정성을 갖춘 방법을 제공해야 합니다. 이 경우 작업을 실행할 CoroutineDispatcher 또는 Excutor에 종속 항목을 가져와야 합니다.

class NewsRemoteDataSource(
  private val newsApi: NewsApi,
  private val ioDispatcher: CoroutineDispatcher
) {
    /**
     * Fetches the latest news from the network and returns the result.
     * This executes on an IO-optimized thread pool, the function is main-safe.
     */
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        // Move the execution to an IO-optimized thread since the ApiService
        // doesn't support coroutines and makes synchronous requests.
        withContext(ioDispatcher) {
            newsApi.fetchLatestNews()
        }
    }
}

// Makes news-related network synchronous requests.
interface NewsApi {
    fun fetchLatestNews(): List<ArticleHeadline>
}

네트워크 요청 결과 캐시

편의상 NewsRepository는 변경 가능한 변수를 사용하여 최신 뉴스를 캐시합니다. 여러 스레드에서 읽기 및 쓰기를 금지하기 위해 Mutex가 사용됩니다.

다음 구현은 Mutex로 쓰기가 금지된 저장소의 변수에 최신 뉴스 정보를 캐시합니다. 요청이 성공하면 데이터가 변수에 할당됩니다.

class NewsRepository(
  private val newsRemoteDataSource: NewsRemoteDataSource
) {
    // Mutex to make writes to cached values thread-safe.
    private val latestNewsMutex = Mutex()

    // Cache of the latest news got from the network.
    private var latestNews: List<ArticleHeadline> = emptyList()

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        if (refresh || latestNews.isEmpty()) {
            val networkResult = newsRemoteDataSource.fetchLatestNews()
            // Thread-safe write to latestNews
            latestNewsMutex.withLock {
                this.latestNews = networkResult
            }
        }

        return latestNewsMutex.withLock { this.latestNews }
    }
}

이 외에도 DataStore, Room, WorkManager와 같은 API도 데이터 레이어에서 사용됩니다. 자세한 데이터 레이어 설계 방법을 알고 싶다면 데이터 영역을 참고해주세요.

도메인 레이어

도메인 레이어는 UI 레이어와 데이터 레이어 사이에 있는 선택적 레이어입니다. 도메인 레이어는 복잡한 비즈니스 로직이나 여러 ViewModel에서 사용되는 간단한 비즈니스 로직의 캡슐화를 담당합니다.

도메인 레이어는 다음과 같은 이점이 있습니다.

  • 코드 중복 방지
  • 도메인 레이어 클래스를 상속받는 클래스의 가독성 개선
  • 테스트 가능성 향상
  • 책임을 분할하여 대형 클래스 방지

다만 주의해야할 점도 있습니다.

  • 각 사용 사례에서는 기능 하나만 담당해야 합니다.
  • 변경 가능한 데이터를 포함해서는 안됩니다.

이름 지정 규칙

현재 시제의 동사 + 명사/대상(선택사항) + UseCase

예를 들면 FormatDateUseCase, LogOutUserUseCase, GetLatestNewsWithAuthorsUseCase, MakeLoginRequestUseCase가 있습니다.

종속 항목

사용 사례 클래스는 UI 레이어의 ViewModel과 데이터 레이어의 저장소 사이에 적합합니다. 즉, 사용 사례 클래스는 일반적으로 저장소 클래스에 종속되며, 저장소와 동일한 방법으로 콜백 또는 코루틴을 사용하여 UI레이어와 통신합니다.

예를 들어 뉴스 저장소의 데이터와 작성자 저장소의 데이터를 가져와서 이를 결합하는 사용 사례 클래스가 앱에 있을 수 있습니다.

class GetLatestNewsWithAuthorsUseCase(
  private val newsRepository: NewsRepository,
  private val authorsRepository: AuthorsRepository
) { /* ... */ }

사용 사례는 재사용 가능한 로직을 포함하기 때문에 다른 사용 사례에 의해 사용될 수도 있습니다. 도메인 레이어에 여러 수준의 사용 사례가 있는 것은 정상입니다.

class GetLatestNewsWithAuthorsUseCase(
  private val newsRepository: NewsRepository,
  private val authorsRepository: AuthorsRepository,
  private val formatDateUseCase: FormatDateUseCase
) { /* ... */ }

Kotlin에서 사용 사례 호출

Kotlin에서 operator수정자와 함께 invoke() 함수를 정의하여 사용 사례 클래스 인스턴스를 함수처럼 호출 가능하게 만들 수 있습니다.

class FormatDateUseCase(userRepository: UserRepository) {

    private val formatter = SimpleDateFormat(
        userRepository.getPreferredDateFormat(),
        userRepository.getPreferredLocale()
    )

    operator fun invoke(date: Date): String {
        return formatter.format(date)
    }
}

위 예의 사용 사례를 호출하는 방법은 다음과 같습니다.

class MyViewModel(formatDateUseCase: FormatDateUseCase) : ViewModel() {
    init {
        val today = Calendar.getInstance()
        val todaysDate = formatDateUseCase(today)
        /* ... */
    }
}

스레딩

도메인 레이어의 사용 사례는 기본 안전성을 갖추어야 합니다. 즉, 기본 스레드에서 안전하게 호출되어야 합니다. 일반적으로 복잡한 계산은 재사용이나 캐싱을 유도하기 위해 데이터 레이어에서 이루어집니다. 따라서 작업 전에 개발자는 계층 구조에서 그러한 차단 작업이 더 잘 배치되는 다른 레이어가 있는지 확인해야합니다.

일반적인 작업

재사용 가능한 비즈니스 로직

UI 레이어에 있는 반복 가능한 비즈니스 로직은 사용 사례 클래스에 캡슐화되어야 합니다.

앞에서 설명한 FormatDateUseCase예를 생각해 보면, 향수에 날짜 형식과 관련된 비즈니스 요구사항이 변경되는 경우 코드를 중앙의 한 위치에서만 변경하면 됩니다.

저장소 결합

뉴스 앱에는 뉴스와 작성자 데이터 작업을 각각 처리하는 NewsRepository 클래스와 AuthorsRepository 클래스가 있을 수 있습니다. 만약 NewsRepository에서 노출되는 Article클래스에는 작성자 이름만 포함된다고 하면, 자세한 작성자 정보를 표시하고 싶을 때 AuthorsRepository에서 관련 데이터를 얻어서 사용할 수 있습니다.

로직은 여러 저장소와 관련되어 있고 복잡해질 수 있으므로 GetLatestNewsWithAuthorsUseCase 클래스를 만들어 ViewModel에서 로직을 추상화하고 가독성을 높일 수 있습니다. 또한 테스트 용이성과 재사용성에 대한 이점도 존재합니다.

/**
 * This use case fetches the latest news and the associated author.
 */
class GetLatestNewsWithAuthorsUseCase(
  private val newsRepository: NewsRepository,
  private val authorsRepository: AuthorsRepository,
  private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend operator fun invoke(): List<ArticleWithAuthor> =
        withContext(defaultDispatcher) {
            val news = newsRepository.fetchLatestNews()
            val result: MutableList<ArticleWithAuthor> = mutableListOf()
            // This is not parallelized, the use case is linearly slow.
            for (article in news) {
                // The repository exposes suspend functions
                val author = authorsRepository.getAuthor(article.authorId)
                result.add(ArticleWithAuthor(article, author))
            }
            result
        }
}

로직은 news 목록의 모든 항목을 매핑합니다. 따라서 데이터 레이어가 기본 안전성을 갖추고 있더라도 이 작업은 기본 스레드를 차단하지 않습니다. 데이터 레이어에서 처리되는 항목 수를 알 수 없기 때문입니다. 사용 사례에서 기본 디스패처를 사용하여 백그라운드 스레드로 작업을 옮기는 이유도 바로 여기에 있습니다.

References

https://developer.android.com/jetpack/guide?hl=ko#recommended-app-arch
https://developer.android.com/jetpack/guide/data-layer?hl=ko
https://developer.android.com/jetpack/guide/domain-layer?hl=ko

0개의 댓글