개발을 하다보면 비슷한 속성을 지닌 객체들을 매핑해야 하는 경우들이 자주 있다.
주로 사용자의 요청을 받은 DTO와 Entity를 변경하는 과정에서 많이 발생하는데 그럼 아래와 같은 단점들이 생긴다.
공통적으로 사용한 Entity 및 DTO는 다음과 같다.
Post.kt
@Entity
class Post(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0,
var title: String,
@OneToMany
@JoinColumn(name = "post_id")
var images: List<Image>,
var date: LocalDate
)
Image.kt
@Entity
class Image(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id : Long = 0,
var url: String,
@ManyToOne
var post: Post? = null
)
PostForm.kt
data class PostForm(
@set:JsonIgnore
var id: Long = 0,
var title: String,
var images: List<ImageForm>,
var date: LocalDate
) {
data class ImageForm(
var url: String
)
}
직접 변환하는 메서드를 통해 변경하는 방식이다.
data class PostForm(
@set:JsonIgnore
var id: Long = 0,
var title: String,
var images: List<ImageForm>,
var date: LocalDate
) {
data class ImageForm(
var url: String
)
fun toEntity(): Post {
val images = images.map { Image(url = it.url) }
return Post(title = title, images = images, date = date)
}
companion object {
fun fromEntity(post: Post): PostForm {
return post.run {
val imageForms = images.map { ImageForm(url = it.url) }
PostForm(id = id, title = title, images = imageForms, date = date)
}
}
}
}
@SpringBootTest
class DemoApplicationTests {
private val title = "title"
private val date = LocalDate.of(2020, 4, 12)
private val imageUrls = listOf("image1", "image2")
@Test
fun testConvertByMethod() {
val post = Post(
title = title,
images = imageUrls.map { Image(url = it) },
date = date
)
assertForm(PostForm.fromEntity(post))
}
private fun assertForm(postForm: PostForm) {
assertAll(
{ assertEquals(title, postForm.title) },
{ assertEquals(date, postForm.date) },
{ assertIterableEquals(imageUrls, postForm.images.map { it.url }) }
)
}
}
당연히 잘 동작하지만 속성이 많아질경우 가독성이 떨어지는 코드가 생기게되며,
속성의 추가 / 삭제등의 변경에 취약하다.
Model Mapper라는 라이브러리를 사용하는 방식이다.
이처럼 반복적인 작업에서 발생할 수 있는 boiler plate코드를 줄여주는 라이브러리다.
적용을 위해서는 인자가 없는 생성자가 필요한데, plugin의 도움을 받으면 쉽게 생성할 수 있다.
@Test
fun testConvertByModelMapper() {
val post = Post(
title = title,
images = imageUrls.map { Image(url = it) },
date = date
)
val modelMapper = ModelMapper()
assertForm(modelMapper.map(post, PostForm::class.java))
}
mapping strategy를 설정할 수 있도록 되어있어서 다양한 매핑 전략을 사용할 수 있고, deep mapping등도 지원한다.
자세한 사용법은 메뉴얼을 참고하자.
검색하다보니 reflection을 이용하는 글을 발견했다.
reflection을 이용해서 property마다 직접 매핑해주던 단순 작업을 없애주는 방식이었다.
fun Post.toPostForm() : PostForm {
return with(::PostForm) {
val propertiesByName = Post::class.memberProperties.associateBy { it.name }
callBy(parameters.associateWith { parameter ->
when(parameter.name) {
PostForm::images.name -> images.map{ it.toImageForm() }
else -> propertiesByName[parameter.name]?.get(this@toPostForm)
}
})
}
}
fun Image.toImageForm() : ImageForm {
return with(::ImageForm) {
val propertiesByName = Image::class.memberProperties.associateBy { it.name }
callBy(parameters.associateWith { parameter -> propertiesByName[parameter.name]?.get(this@toImageForm) })
}
}
이렇게 테스트해보니 동작은 잘 하는데, 속성이 많은 경우 단순반복을 없애준다는 장점이 있긴하지만, 역시 모든 클래스에 대해 만들어줘야했다.(Version1에 비해 딱히 장점을 못 느꼈다)
더 잘 활용하면 범용적으로 사용할 수 있도록 만들 수 있겠지만, 라이브러리를 개발하는게 아니기 때문에, model mapper를 사용하면 될것같다.
mapStruct는 타입세이프하게 모델간 변환을 지원해준다.
modelMapper는 리플렉션 기반으로 동작하는 반면 mapStruct는 어노테이션 프로세서를 통해서 코드를 생성한다. 그렇기 때문에 속도가 빠르다고 한다.
접근은 좋지만, 한가지 아쉬운점은 모든 클래스에 직접 생성자를 만들어줘야하는 단점이 있었다.
val와 non-null타입의 속성을 가진 클래스들에 대해서는 생성자를 직접 선언하면서, 정상적이지 않은 상태의 객체를 만들게 되는 문제가 생긴다
https://kotlinlang.org/docs/no-arg-plugin.html#maven
http://modelmapper.org/getting-started/