계층 경계에서 모델을 변환하는 함수.
왜 모델이 계층마다 다른가
- UserDto — 서버 응답 그대로. created_at 이 "2026-08-15T09:00:00Z" 문자열
- UserEntity — Room 테이블. @PrimaryKey · 인덱스 · 컬럼명이 붙는다
- User — 도메인. createdAt 이 Instant. 아무 프레임워크도 안 묻는다
- UserUiModel — 화면용. createdAt 이 "3시간 전" 문자열
fun UserDto.toDomain() = User(
id = id,
name = name.trim(),
createdAt = Instant.parse(createdAt) // 파싱 실패 책임이 경계에 모인다
)
fun User.toEntity() = UserEntity(id, name, createdAt.toEpochMilli())
fun UserEntity.toDomain() = User(id, name, Instant.ofEpochMilli(createdAtMillis))
무엇을 얻나
서버가 name → full_name 으로 바꿨다
매퍼가 있다 → toDomain() 한 줄만 고친다. 나머지 전부 그대로
매퍼가 없다 → 도메인·UI·테스트 전부에서 name 을 찾아 고친다
서버가 필드를 추가했다
- DTO 에만 추가하고 매퍼에서 무시하면 끝이다
어디에 두나
확장 함수로 data 모듈 안에 둔다 data/mapper/UserMapper.kt
도메인 모듈에 두면 안 된다
- 도메인이 DTO 를 알게 되고 의존성 규칙이 깨진다
방향도 중요하다
DTO.toDomain() ✅ (data 가 domain 을 안다 — 허용된 방향)
User.toDto() ✅ (같은 이유로 data 모듈 안)
비용을 정직하게
필드가 똑같은 매퍼가 30개면 순수한 비용이다
- 그럴 땐 계층을 나누지 않는 편이 낫다
매퍼가 값을 하는 순간
- 타입이 바뀐다 (String → Instant, Int → enum)
- 이름이 다르다
- 검증·기본값·널 처리가 들어간다
- 여러 DTO 를 하나의 도메인 모델로 합친다
널 처리를 어디서 하나
// DTO 는 서버를 믿지 않는다 — 전부 nullable
data class UserDto(val id: Long?, val name: String?)
// 도메인은 항상 유효하다 — 매퍼가 관문 역할을 한다
fun UserDto.toDomainOrNull(): User? {
val id = id ?: return null
val name = name?.takeIf { it.isNotBlank() } ?: return null
return User(id, name)
}
이 한 겹 덕분에 도메인과 UI 전체에서 user.name?.let { } 이 사라진다.
면접 함정
- ❌ "매퍼는 자동 생성 도구로 만들면 된다" → 필드가 같을 때만이다. 변환 규칙이 있으면 직접 쓴다.
- ❌ "매퍼가 많으면 잘 설계한 것" → 값을 안 하는 매퍼는 그냥 비용이다.
컬렉션 변환을 어디서 하나
fun List<UserDto>.toDomain(): List<User> = mapNotNull { it.toDomainOrNull() }
mapNotNull 을 쓰면 잘못된 항목 하나가 전체를 무너뜨리지 않는다 다만 조용히 사라지므로 개수가 다르면 로그를 남기는 편이 좋다
- val parsed = dtos.toDomain()
- if (parsed.size != dtos.size) log.w("유효하지 않은 항목 ${dtos.size - parsed.size}개 제외")
열거형 변환 — 모르는 값이 오면
enum class Status { ACTIVE, SUSPENDED, UNKNOWN }
fun String?.toStatus(): Status = when (this) {
"active" -> Status.ACTIVE
"suspended" -> Status.SUSPENDED
else -> Status.UNKNOWN // 서버가 새 값을 추가해도 크래시하지 않는다
}
- @Serializable enum 을 그대로 쓰면
- 서버가 "deleted" 를 추가한 순간 구버전 앱이 파싱 예외로 죽는다
- UNKNOWN 을 두는 것이 하위 호환의 기본기다
테스트
@Test fun `널 이름은 도메인으로 변환되지 않는다`() {
assertNull(UserDto(id = 1, name = null).toDomainOrNull())
}