Notice
Recent Posts
Recent Comments
Link
Tags
more
관리 메뉴

주완 님의 블로그

Android에서 Domain Layer와 UseCase: 언제 필요하고 언제 생략할까? 본문

Android

Android에서 Domain Layer와 UseCase: 언제 필요하고 언제 생략할까?

vvan2 2025. 12. 3. 17:32

 

아키텍처를 적용할 때 data, domain, UI layer를 각각 나눠서 패키지를 관리하는 경험을 했습니다..

특히 서버 통신을 진행해보며 Data layer, UI layer에 대한 개념은 어느 정도 잡혔었는데요

하지만 Domain Layer는 좀 애매합니다. Architecture를 공부하다 보면 Domain Layer와 UseCase를 반드시 사용해야 하는 것처럼 느껴지는데요, 실제 경험했던 앱잼 프로젝트들을 살펴보면:

  • 어떤 팀은 많은 기능을 UseCase로 만들고
  • 어떤 팀은 로그인/회원가입 정도만 UseCase로 분리하고
  • 또 어떤 팀은 아예 Domain Layer 없이 개발합니다

Domain Layer는 언제 필요할까요? Google 권장 아키텍처와 Clean Architecture의 차이를 비교하고, 어떤 기준으로 Domain을 설계해야 할지 알아보겠습니다.


Domain이란?

7차 세미나 중…

도메인(Domain)은 해당 시스템이 해결하고자 하는 문제 영역 그 자체입니다.

위와 같은 설명으론 잘 와닿지 않으실겁니다.

앱으로 빗대어 설명을 드리자면 도메인이란 앱의 기능, 기획의 요구사항을 개발하는 영역으로 볼 수 있습니다.

  • 쇼핑 앱이라면? → 상품, 장바구니, 결제, 쿠폰 등이 도메인
  • 블록체인 앱이라면? → 지갑, 전송, 잔액, 토큰 등이 도메인

즉, 도메인이란 사용자가 이용하는 서비스의 기능, 비즈니스 로직을 정의하고 있는 영역이라고 이해할 수 있겠죠?

이 영역의 코드를 우리는 “도메인 로직” 또는 “Entity” 라고 부르며, 외부(프레임워크, 데이터베이스 등)에 의존하지 않는 순수한 비즈니스 규칙만을 담도록 설계해야 합니다.

왜 도메인은 순수해야 할까?

밥아저씨의 설명에 따르면 기존 설계의 문제점은 다음과 같습니다.

  1. 프로젝트 초기에 구조를 결정하고 나면
    • 새로운 요구사항이 생겼을 때 이를 반영하기 위해 기존 코드를 대대적으로 수정해야 합니다.
  2. 이는 대부분
    • 프레임워크나 데이터베이스를 도구가 아니라 설계의 중심으로 삼았기 때문이고,
    • 비즈니스 로직이 여러 계층에 분산되어 있기 때문입니다.
    앱 개발 도중에 사용하던 DB가 바뀌거나 데이터를 가져오는 방식이 달라지면,
  3. 복잡하게 얽힌 비즈니스 로직을 전부 고쳐야 하거나 재사용이 거의 불가능해집니다.
  4. 또한
    • 순수하게 정의된 도메인은 프레임워크에 구애받지 않고 어디서든 재사용이 가능합니다.
      • 최근 KMP에서 그 장점이 가장 잘 나타나고 있어요. UI를 Android와 iOS 각각 만들어도 결국 도메인은 하나를 공유할 수 있으니까요.

그래서, 사실 이 내용을 봤을 때 Domain 계층에는 앱의 핵심적인 로직이 들어가야 한다고 생각했습니다.

예시처럼, 결제 관련 앱이라면 결제에 대한 로직이, 배달 앱이라면 배달이라는 명확한 domain이 필요하다고 생각했습니다.

또한 여러 계층에서 사용하는 로직이라면, 유지보수를 위해 필수적으로 만들어야하는 것 정도로 생각해 봤습니다.


Google 권장 아키텍처 vs Clean Architecture

아키텍처에 괸해 간단히 짚고 넘어가자면 , 저희 앱잼 팀에서는 구권아를 사용했는데

Google 권장 아키텍처

Google의 공식 Android 아키텍처는 다음과 같이 구성됩니다: 

UI Layer → Domain Layer (optional) → Data Layer

주목할 점은 Domain Layer가 선택적(optional)이라는 것입니다. 기본 구조는 다음과 같습니다:

UI → ViewModel → Repository → DataSource

그리고 필요한 경우에 UseCase를 추가하고

UI → ViewModel → UseCase → Repository → DataSource

도메인 계층은 꼭 필요한 경우에만 추가합니다.

  • 도메인 객체 간 규약이 적은 경우 → 생략 가능
  • 복잡한 비즈니스 규칙/행위가 많은 경우 → 도메인 모듈 도입
  • 도메인은 선택적 계층이며,
  • 필요한 경우에만 선택적으로 사용하면 된다

Clean Architecture와의 차이

Clean Architecture에서는 의존성 방향이 다음과 같습니다:

UI → Domain ← Data

Domain이 중심에 있고, UI와 Data 모두 Domain을 바라봅니다. 구권아의 단방향 흐름으로 단순한 구조를 나타내는 것과 차이가 있죠

UI → Domain → Data

또한, Domain layer 가 선택적인 것이 아니라, 필수적으로 들어가야 한다는 것입니다.


암튼, 그래서 저는 앱잼 프로젝트에서 회원가입 기능을 구현했던 적이 있는데, 회원가입 단계가 6,7개의 단계로 이루어졌습니다.

처음에는 해당과정을 screen과 viewmodel 을 단계마다 분리해서 구현해보았습니다.

하지만, flow 를 각 단계마다 나누어서 진행하다보니, 중간에 누락되는 값도 있고 관리하기가 힘들었습니다

그래서, 이번에는 하나의 viewmodel 을 사용해서 진행해 보았는데,

 

유효성 검사와 각각의 화면에서 필요한 data 들을 한 번에 관리하다 보니 거의 4~500 줄이 넘는 비대한 viewmodel 이 만들어졌습니다. 이 때 당시에는 구글 권장 아키텍쳐를 처음 사용했기에 , 비대한 viewmodel 을 해결하기 위해 usecase 를 쓰는구나 정도로 생각해 봤습니다.

 

그래서 이참에 비즈니스 로직, 비대해진 viewmodel 등의 근거로 저희팀 리드님께 궁금한 것을 여쭤보며 정리를 해봤습니다.

 

Domain Layer는 왜 "선택적"일까?

1. 대부분의 Android 앱은 "읽기 중심"

세미나 때 배운대로라면, Android 앱의 주요 역할은 서버에서 데이터를 받아 화면에 표시하는 것입니다:

val user = userApi.getUser(id)
 *// 서버가 비즈니스 로직 처리// 클라이언트는 받은 데이터를 UI에 렌더링만 함*

이런 구조에서는 다음과 같은 흐름이 중요합니다:

  • DTO → UI Model → 화면 렌더링

반면 "도메인 객체 간의 상호작용"은 거의 발생하지 않습니다.

2. 비즈니스 로직은 대부분 서버에서 처리

  • 유효성 검증
  • 복잡한 계산
  • 트랜잭션 처리
  • 상태 전환 규칙

이런 핵심 비즈니스 로직들은 서버에서 처리되고, 클라이언트는 그 결과만 받습니다. 예를 들어 "결제 완료 → 배송 준비 중" 같은 상태 전환도 서버 API가 처리하고, 클라이언트는 그 결과를 받아 표시만 합니다.

3. "빈약한 UseCase" 문제

저희 앱에서 사용하는 주요 기능을 생각해보았을때는

지도의 좌표를 받아 오는 것, 산책경로가 그려진 정보를 받아오는 것 정도로 생각해보았습니다.

앞서 말한 관점에서 생각하면, 산책경로를 그려진 정보를 usecase 에 넣는게 맞지 않을까? 생각했는데

막상 적용해보니, repository 를 usecase 로 한 번 더 래핑하는거 뿐이였습니다.

class GetUserListUseCase @Inject constructor(
    private val repository: UserRepository
) {
    suspend operator fun invoke() = repository.getUserList()
}

이런 UseCase는 단순히 Repository 함수를 호출해서 전달만 하는 껍데기입니다. 아무런 비즈니스 로직도 담고 있지 않죠.

이런 경우라면 차라리 기존대로 ViewModel에서 Repository를 직접 주입받는 게 낫습니다:

class UserViewModel @Inject constructor(
    private val userRepository: UserRepository
) : ViewModel() {
    fun loadUsers() {
        viewModelScope.launch {
            userRepository.getUserList()
                .collect { users -> _uiState.value = users }
        }
    }
}

그렇다면 UseCase는 언제 필요할까?

1. 복잡한 데이터 가공이 필요한 경우

저희와 같은 경우 좌표를 받아올 때 위도 경도를 복잡한 구조로 받아오기 때문에, 좌표를 평탄화 하는 과정이 필요합니다.

class ProcessLocationDataUseCase @Inject constructor(
    private val locationRepository: LocationRepository
) {
    suspend operator fun invoke(): List<Location> {
        *// 중첩된 리스트 평탄화*
        val nestedLocations = locationRepository.getLocations()
        return nestedLocations
            .flatten()
            .map { it.toLocation() }
            .filter { it.isValid() }
            .sortedBy { it.distance }
    }
}

이런 가공 로직을 ViewModel에 넣으면 ViewModel이 비대해집니다. UseCase로 분리하면:

  • ViewModel은 UI 이벤트 처리와 상태 관리에만 집중
  • 데이터 가공 책임은 UseCase가 담당

2. 여러 Repository를 조합해야 하는 경우

class GetUserProfileWithPostsUseCase @Inject constructor(
    private val userRepository: UserRepository,
    private val postRepository: PostRepository,
    private val imageRepository: ImageRepository
) {
    suspend operator fun invoke(userId: String): UserProfile {
        val user = userRepository.getUser(userId)
        val posts = postRepository.getUserPosts(userId)
        val profileImage = imageRepository.getProfileImage(userId)
        
        return UserProfile(
            user = user,
            posts = posts,
            profileImage = profileImage
        )
    }
}

3. 소셜 로그인 같은 추상화가 필요한 경우(예시입니다)

interface LoginUseCase {
    suspend fun login(): Result<User>
}

class GoogleLoginUseCase @Inject constructor(...) : LoginUseCase
class KakaoLoginUseCase @Inject constructor(...) : LoginUseCase
class AppleLoginUseCase @Inject constructor(...) : LoginUseCase

"로그인을 한다"는 비즈니스 로직은 동일하지만, 구현 방식(Google, Kakao, Apple)은 다른 경우입니다. UseCase로 추상화하면:

  • 로그인 제공자가 변경되어도 UseCase 인터페이스는 안정적으로 유지
  • ViewModel은 어떤 방식의 로그인인지 몰라도 됨

4. 명확한 비즈니스 규칙이 있는 경우

class CalculateBMIUseCase {
    operator fun invoke(weight: Double, height: Double): BMIResult {
        val bmi = weight / (height * height)
        val category = when {
            bmi < 18.5 -> BMICategory.UNDERWEIGHT
            bmi < 25.0 -> BMICategory.NORMAL
            bmi < 30.0 -> BMICategory.OVERWEIGHT
            else -> BMICategory.OBESE
        }
        return BMIResult(value = bmi, category = category)
    }
}
  • UI 로직이 아닙니다 (빨간색으로 표시하는 것은 UI의 책임)
  • DB 로직이 아닙니다 (저장하는 것은 Repository의 책임)
  • 앱의 핵심 규칙 그 자체입니다

ViewModel의 올바른 책임

좋은 ViewModel은 다음과 같은 특징을 가집니다:

class UserViewModel @Inject constructor(
    private val getUserProfileUseCase: GetUserProfileUseCase
) : ViewModel() {
    
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState = _uiState.asStateFlow()
    
    fun loadUserProfile(userId: String) {
        viewModelScope.launch {
            getUserProfileUseCase(userId)
                .onSuccess { profile -> 
                    _uiState.value = UiState.Success(profile)
                }
                .onFailure { error -> 
                    _uiState.value = UiState.Error(error.message)
                }
        }
    }
    
    fun onRetryClicked() {
        loadUserProfile(currentUserId)
    }
}

ViewModel의 책임:

  • UI 이벤트 처리 (onRetryClicked)
  • 상태 관리 및 UI에 전달 (_uiState)
  • 간단한 데이터 변환 (복잡한 가공은 UseCase로)

Domain Layer를 생략해도 되는 경우

  • 단순 CRUD 작업만 있는 앱
  • 서버 응답을 그대로 UI에 표시
  • 복잡한 비즈니스 규칙이 없음
  • 팀 규모가 작고 빠른 개발이 중요함
*// 이 정도면 충분합니다*
UI → ViewModel → Repository → DataSource

Domain Layer를 사용해야 하는 경우

  • 복잡한 데이터 가공이 필요함
  • 여러 Repository를 조합해야 함
  • 명확한 비즈니스 규칙이 존재함
  • 여러 ViewModel에서 재사용되는 로직이 있음
  • 테스트 가능성이 중요함
*// UseCase로 책임 분리*
UI → ViewModel → UseCase → Repository → DataSource

일관성의 원칙

만약 UseCase를 사용하기로 했다면, "빈약한 UseCase"는 만들지 말 것

*// 모든 Repository 호출을 UseCase로 감싸기*
GetUsersUseCase
GetUserDetailUseCase
DeleteUserUseCase
UpdateUserUseCase
*// ... 수십 개의 단순 wrapper*
*// 진짜 비즈니스 로직만 UseCase로*
LoginUseCase (여러 로그인 방식 추상화)
ProcessRecommendationUseCase (복잡한 추천 알고리즘)
SyncUserDataUseCase (여러 Repository 조합)

*// 단순 조회는 ViewModel에서 Repository 직접 호출*
viewModel.loadUsers() *// repository.getUsers()*

 

Domain Layer와 UseCase는 만능 해결책이 아니라 도구라고 생각합니다. 프로젝트의 복잡도, 팀의 규모, 유지보수 계획 등을 고려해서 선택해야 합니다.

  • 단순히 Repository를 감싸는 것은 UseCase가 아님
  • 진짜 비즈니스 로직이 있을 때만 UseCase를
  • 의심스럽다면 일단 생략하고, 필요할 때 추가할 것