티스토리 뷰

반응형

클린아키텍처에서 Entity 와ViewModel을 명확한 구분을 주고 있습니다. 여기서 ViewModel 는 MVVM 의 ViewModel이 아니라 View의 Data 즉, UI Data를 의미합니다.
클린아키텍처에서 이 두 데이터의 차이를 혼재되어 사용 할 수 있는데 이 두 데이터의 차이와 Android 에서는 클린아키텍처를 어떻게 사용하면 좋을지 공유하겠습니다.
 

클린아키텍처의 Mapper 의 역할

클린아키텍처에서는 두 개의 Mapper 가 존재합니다. 

1. DTO → Entity Mapper
데이터를 네트워크, 데이터베이스 등 외부 소스에서 받아와 비즈니스 계층(Entity)로 변환합니다.
이 단계는 보통 Repository 계층에서 수행됩니다.

2. Entity → ViewModel (또는 UI 모델) Mapper
비즈니스 로직 처리 후, UI 계층에 맞는 데이터 구조(ViewModel 또는 화면 모델)로 변환합니다.
이 단계는 보통 Use Case 계층에서 수행됩니다.

여기서 말하는 ViewModel은 MVVM의 ViewModel과 다른 개념입니다.
 
 

 
 


 

두 개의 Mapper가 필요한 이유


클린 아키텍처의 핵심은 계층 간 독립성을 보장하는 것입니다. 각 계층의 책임을 분리하기 위해 데이터 변환이 계층 간에서 수행됩니다.

1. DTO → Entity Mapper
DTO(Data Transfer Object)는 외부 시스템(API, DB 등)에서 데이터를 받아오는 데 사용됩니다.
이 데이터를 도메인(Entity)에서 활용할 수 있도록 변환해야 합니다.
이런 작업이 필요한 이유는 DTO는 외부 시스템의 데이터 형식은 내부 비즈니스 로직과 다를 수 있습니다.  

예를 들어서 API 에서 처리하는 비즈니스 로직은 DB 에 있는 데이터를 API 로 내리기 위한 작업만 한다면 Entity 에서 가져갈 비즈니스 로직은 달라질 수 있습니다.
Entity 는 외부 데이터에 대한 의존성을 줄이고, 비즈니스 로직의 독립성을 유지합니다. 여기서 비즈니스 로직에 UI 부분은 제외 됩니다. 

2. Entity → ViewModel Mapper
Entity는 비즈니스 로직을 처리하기 위한 구조를 가지고 있습니다.
UI(ViewModel)는 화면에 적합한 구조와 데이터를 요구합니다.
Mapper를 한번 더 하여 ViewModel를 생성하는 이유는 Entity는 비즈니스 중심이므로, UI와 직접 연결되면 변경이 어려워질 수 있습니다.
화면에 맞는 형식으로 데이터를 가공해야 하므로 변환이 필요합니다.
 




어떻게 사용해야할까?

앱에서의 클린아키텍처는 독립적으로 구성하여 1:1 매핑을 가져가는 것보다 UI 의 속도가 중요하게 됩니다. 속도를 생각한다면 다음 두가지를 고려해볼 필요가 있습니다.


 1. Entity와 ViewModel의 결합

모든 프로젝트에서 "완벽한 클린 아키텍처"를 강요할 필요는 없습니다. 다음과 같은 절충안을 고려할 수 있습니다:
Entity와 ViewModel 통합비즈니스 로직이 단순하다면, UI에 맞춘 데이터 구조를 Entity로 사용하는 것도 가능합니다.

하지만 이를 도메인 독립성이 필요 없는 간단한 애플리케이션에서만 적용해야 합니다.

클린아키텍처를 구성하며 도메인의 의존성을 가져갈 구조였다면 이 과정의 도입은 힘들 수 있으나 프로젝트 규모에 따라서 고려해 볼 수 있습니다.
 

2. Entity의 존재성 제거
 
API 가 UI 에 맞춰 설계되어 내려 온다면 Entity 로 변환은 필요 없어질 것입니다. 

API 에서 받은 DTO 를 바로 사용하는 구조라면 entity에서 ViewModel의 mapper의 역할도 고려해 볼 필요가 있습니다. 프로젝트에 API 구조에 따라서 DTO를 바로 사용할 수도 있고,  Mapper를 활용해서 DTO에 대한 비즈니스를 녹인 Entity 로 사용할 수 있습니다.

프로젝트 상황에 맞춰서 Entity의 존재는 생략될 수 있습니다.


 

Mapper의 가이드 라인

앞써 설명 드렸듯이 클린아키텍처에서의 Mapper 는 2번 이뤄지게 됩니다. 각 Mapper 에서 추가해야할 값들이 있습니다.
 

1. DTO → Entity 변환 시 값 추가가 적절한 경우

1.1. Entity에 필요한 필드가 DTO에 없을 때
 
API에서 제공하지 않는 데이터가 있지만, 
Entity의 비즈니스 로직이나 데이터 구조상 반드시 필요한 경우 값을 추가할 수 있습니다.

예를들면 다음 세 가지의 필드를 추가 할 수 있습니다.

기본값을 설정해야 하는 필드 (예 : 기본 카운트 값, 기본 설정 값, UDID 같은 식별 값)
계산을 통해 도출해야 하는 값 (예: 은행 잔액)
시스템 내부에서 관리해야 하는 데이터(예: 생성 시점, 유효성 상태)

 

data class AccountEntity(
    val id: String,
    val email: String,
    val isDeleted: Boolean = false,
    val isVerified: Boolean = false,
    val id: String = UUID.randomUUID().toString(),
    val createdAt: LocalDateTime = LocalDateTime.now(),
    val updatedAt: LocalDateTime = LocalDateTime.now()
)



2. 비즈니스 로직을 지원하기 위해
Entity는 비즈니스 로직을 포함하므로, DTO에 없는 값이라도 비즈니스 로직을 수행하기 위해 추가할 수 있습니다.
예를 들면 사용자의 이메일 인증 여부를 저장하기 위해, 이메일 존재 여부로 isEmailVerified 값을 추가 할 수 있습니다. 

 

data class AccountEntity(
    val id: String,
    val email: String,
    val isEmailVerified: Boolean = false
)

 

이처럼 Entity의 추가 필드는 비즈니스 로직이나 데이터 구조상 필요한 데이터를 구성할 수 있으며, UI 구성 시 필요한 데이터는 구성하지는 않습니다.
 

2. Entity → ViewModel

2.1. UI 를 위한 ViewModel
Entity의 비즈니스 로직이나 데이터 구조 상 필요 값이 존재하나 UI 구성 시 반드시 필요한 값 한 경우 추가할 수 있습니다.

리스트로 구성된 화면에서 데이터 타입별로 화면을 구성한다고 가정해 봅시다. 이때, UI 복잡도에 따른 리스트의 최적화를 위해 하나의 데이터 타입을 여러 개의 데이터(n개)로 분리하는 작업을 수행할 수 있습니다.

예시로 온라인 쇼핑 앱에서 상품 리스트 화면을 구성한다고 가정합니다.

API로부터 상품 데이터를 받아오지만, UI에서 각 상품을 일반 상품할인 상품으로 나누어 표시해야 합니다.

일반 상품은 이름과 가격만 표시하고,
할인 상품은 이름, 원래 가격, 할인된 가격, 할인율을 표시합니다.

이를 위해 하나의 상품 데이터를 일반 상품과 할인 상품으로 나누어 표시하는 작업을 `Mapper`로 처리할 수 습니다.


API Response (Entity)

{
  "id": "001",
  "name": "Wireless Mouse",
  "price": 25000,
  "discountPrice": 20000,
  "category": "Electronics"
}



ViewModel 구조

- RegularProductViewModel : 일반 상품
- DiscountedProductViewModel : 할인 상품

sealed class ProductViewModel {
    data class RegularProductViewModel(
        val id: String,
        val name: String,
        val price: Int
    ) : ProductViewModel()

    data class DiscountedProductViewModel(
        val id: String,
        val name: String,
        val originalPrice: Int,
        val discountPrice: Int,
        val discountRate: Int
    ) : ProductViewModel()
}



Mapper 구현

fun ProductEntity.toViewModels(): List<ProductViewModel> {
    val viewModels = mutableListOf<ProductViewModel>()

    // 일반 상품으로 추가
    viewModels.add(
        ProductViewModel.RegularProductViewModel(
            id = this.id,
            name = this.name,
            price = this.price
        )
    )

    // 할인 상품이 있는 경우 추가
    if (this.discountPrice < this.price) {
        val discountRate = ((this.price - this.discountPrice) * 100) / this.price
        viewModels.add(
            ProductViewModel.DiscountedProductViewModel(
                id = this.id,
                name = this.name,
                originalPrice = this.price,
                discountPrice = this.discountPrice,
                discountRate = discountRate
            )
        )
    }

    return viewModels
}



2.2. 화면 구성에 따른 UI 데이터

API 에서 내려온 데이터를 UI 최적화를 위한 재구성 할 수 있습니다.

앱 개발에서는 API에서 내려온 데이터를 그대로 UI에 사용할 수 없는 경우가 많습니다. 특히, 화면 최적화를 위해 데이터를 재구성해야 하는 경우가 발생합니다.

예를 들어, API에서 다음과 같은 형태의 게시글 데이터가 내려온다고 가정해 봅시다.

API Response (Entity)

{
  "id": "123",
  "title": "클린 아키텍처란?",
  "content": "클린 아키텍처는...",
  "author": {
    "name": "홍길동",
    "profileImage": "https://example.com/profile.jpg"
  },
  "tags": ["Android", "Architecture", "Kotlin"],
  "createdAt": "2025-01-30T12:00:00Z"
}



하지만 UI에서는 이를 그대로 표시하지 않고, 필요에 따라 데이터를 가공해야 합니다. 

예를 들어:

- 날짜 형식을 `yyyy.MM.dd` 형태로 변환
- 태그를 `#Android #Architecture #Kotlin` 형태로 변환
- 작성자의 이름과 프로필 이미지를 별도로 표시

이를 위해 `Mapper`를 사용하여 데이터를 Entity → ViewModel로 변환합니다.

ViewModel (UI에서 사용하는 데이터)

data class PostViewModel(
    val id: String,
    val title: String,
    val content: String,
    val authorName: String,
    val authorProfileImage: String,
    val formattedTags: String,
    val formattedDate: String
)



Mapper 구현

fun PostEntity.toViewModel(): PostViewModel {
    return PostViewModel(
        id = this.id,
        title = this.title,
        content = this.content,
        authorName = this.author.name,
        authorProfileImage = this.author.profileImage,
        formattedTags = this.tags.joinToString(" ") { "#$it" },
        formattedDate = formatDate(this.createdAt)
    )
}

fun formatDate(isoDate: String): String {
    val formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd")
    return LocalDateTime.parse(isoDate, DateTimeFormatter.ISO_DATE_TIME).format(formatter)
}


마무리

 
클린 아키텍처의 유지보수성과 효율성 사이에서 균형을 맞추는 것이 핵심입니다. 프로젝트 규모와 요구사항에 맞게 설계를 조정하면 불필요한 작업을 최소화할 수 있습니다.

다음 세가지을 참고하여 프로젝트의 규모와 특성에 맞춰 클린아키텍처를 구성하는데 도움이 되면 좋겠습니다.

1. 비즈니스 로직에 부합하는 값만 추가

추가하는 값이 도메인 로직을 명확히 지원하거나, 시스템 요구사항을 충족하는지 확인합니다. 프로젝트 특성에 맞춰 DTO → Entity의 변화는 생략 될 수 있습니다.

2. Mapper에서 변환 책임 분리

DTO → Entity 변환 시, 값을 추가하거나 가공하는 책임을 Mapper에 분리하여 관리합니다.

Entity →  View의 변환 시 Use Case를 활용 합니다. 

값 추가가 단순한 변환 이상의 비즈니스 로직을 포함한다면, Use Case 계층에서 처리하도록 설계합니다.
 
3. 추가된 값의 의미 명확히 문서화

코드에 주석이나 문서를 통해, 왜 특정 값이 추가 되었는지 설명합니다.

반응형
댓글