이전부터 테스트 코드를 깔짝깔짝 쓰다보면 튜터님께서 Test arrange를 다양하게 처리할 수 있게 AutoParams, Fixture monkey같은 걸 추천해주셨었다.
그래서 이번 과제는 테스트 코드 공부를 확실히 하기 위해 먼저 AutoParams를 써봤는데 AutoParams가 자동으로 설정해주는 Data가 어떻게 돌아가는지 제대로 작성하기 어려웠고
Kotlin 특성상 내가 만든 데이터가 Nullable이 아닐 때가 더 많아서 자동화를 위한 공부를 너무 많이 필요로 하기에 어느정도 수동 설정이 가능한 Fixture monkey로 넘어가게 되었다. 사실 라이브러리의 문제는 아닐 거고 내가 공부가 미숙해서 기초 설정을 하기 너무 어려웠다.
// 기존 데이터 설정
@BeforeEach
fun setUp() {
val category = categoryRepository.saveAndFlush(DEFAULT_CATEGORY)
val tag = tagRepository.saveAndFlush(DEFAULT_TAG)
val userList = userRepository.saveAllAndFlush(DEFAULT_USER_LIST)
defaultPostList = postRepository.saveAllAndFlush((1..10).map { index ->
Post(
title = "sample${index}Title",
content = "sample${index}Content",
user = if (index % 2 == 0) userList[0] else userList[1],
category = category
)
})
defaultPostList.forEach { post ->
postTagRepository.saveAndFlush(PostTag(post = post, tag = tag))
}
}
// Fixture monkey로 수정
@BeforeEach
fun setUp() {
val fixtureMonkey = FixtureMonkey.builder()
.plugin(KotlinPlugin())
.build()
val category = categoryRepository.saveAndFlush(fixtureMonkey.giveMeOne(Category::class.java))
val tag = tagRepository.saveAndFlush(fixtureMonkey.giveMeOne(Tag::class.java))
val user = userRepository.saveAndFlush(fixtureMonkey.giveMeOne(User::class.java))
defaultPostList = postRepository.saveAllAndFlush(
fixtureMonkey.giveMeBuilder(Post::class.java)
.set("category", category)
.set("user", user)
.sampleList(10)
)
postTagRepository.saveAllAndFlush(defaultPostList.map {
PostTag(post = it, tag = tag)
})
}
사실 막 코드가 크게 줄었다기 보다는 복잡한 상수나 고정 String 값들이 사라진 느낌이다.
상수나 고정된 값이 사라진 만큼 테스트 시나리오도 여러모로 수정이 됐다.
// 기존
@Test
fun `SearchType 이 NONE 이 아닌 경우 Keyword 에 의해 검색되는지 결과 확인`() {
// WHEN
val result = postRepository.searchByKeyword(PostSearchType.TITLE_CONTENT, "sample1", Pageable.ofSize(10))
// THEN
result.content.size shouldBe 2
@Test
fun `SearchType 이 NONE 이 아닌 경우 Keyword 에 의해 검색되는지 결과 확인`() {
// WHEN
val result = postRepository.searchByKeyword(PostSearchType.TITLE_CONTENT, "sample", Pageable.ofSize(10))
// THEN
result.content.size shouldBe defaultPostList.count { it.title.contains("sample") || it.content.contains("sample") }
}
원래 대충 고정값 끼워넣고 말았던 검사를 몇개인지 무슨내용인지도 모를 데이터로 검사하니 이게 맞나 싶긴 한데 어쨌든 해결되긴 했다.
아마 sample
이라는 값이 랜덤생성되다가 들어갈 일은 없을테니 a
같은 단순 글자를 쓰거나 Fixture monkey로 데이터를 생성할때 어느정도 값의 범위를 제한할 필요가 있어 보인다.
코드의 수정 자체는 정말 짧았지만 사실 테스트 코드 작성 자체는 꽤 시간이 오래 걸렸다.
가장 문제는 테스트 데이터 자동화를 진행하면서 Post가 쥐고 있던 PostTag에 자꾸 Null이 들어가려고 해서 NPE가 발생했는데 이게 라이브러리에서 null을 제외한 값으로 선택이 가능한지 아닌지도 구체적으로 알 수가 없어서 이 참에 아예 단방향으로 바꾸게 됐다.
PostTag를 작성하려면 Post, Tag가 먼저 저장되어야 하는데 그 전에 먼저 설정을 시도하려고 하니 그냥 단순히 NPE가 무조건 붙는 걸 수도 있다. 데이터 범위를 emptySet()
등으로 미리 채워놓으려고 시도했음에도 계속 에러가 나는 걸로 보아 양방향이 원인이 되는 건 확실해보였다.
어쨌든 양방향을 끊고 PostTag만 @ManyToOne으로 Post, Tag를 쥐고 있는 구조가 되자 기존에 DTO를 생성할 때 Lazy loading으로 날로 먹던 부분들도 고생을 하게 됐다.
// Dto
data class PostResponse(
val id: Long,
val title: String,
val content: String,
val status: String,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
val user: UserResponse,
val category: CategoryResponse? = null,
val tagList: List<TagResponse> = emptyList(),
)
// Service
override fun getPostList(pageable: Pageable): Page<PostResponse> {
return postRepository.findByPageableWithUser(pageable)
.map { it.first to it.second.map { postTag -> postTag.tag }.toSet() }
.map { PostResponse.from(it.first, it.second) }
}
// Repository
override fun findByPageableWithUser(pageable: Pageable): Page<Pair<Post, List<PostTag>>> {
val (paginatedPostIds, totalCount) = basePagingIds(pageable)
if (paginatedPostIds.isEmpty()) {
return PageImpl(emptyList(), pageable, 0L)
}
return PageImpl(joinedPostListWithTagByIds(paginatedPostIds), pageable, totalCount)
}
private fun basePagingIds(
pageable: Pageable,
whereClause: BooleanBuilder? = null
): Pair<List<Long>, Long> {
val result = queryFactory.select(post.id)
.from(post)
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.where(whereClause)
.fetch()
if (result.isEmpty()) {
return Pair(emptyList(), 0L)
}
val totalCount = queryFactory.select(post.count())
.from(post)
.where(whereClause)
.fetchOne()
?: 0L
return Pair(result, totalCount)
}
private fun joinedPostListWithTagByIds(paginatedPostIds: List<Long>): List<Pair<Post, List<PostTag>>> {
return queryFactory
.select(post, postTag, tag)
.from(post)
.leftJoin(post.user, user).fetchJoin()
.leftJoin(post.category, category).fetchJoin()
.leftJoin(postTag).on(post.id.eq(postTag.post.id))
.leftJoin(postTag.tag, tag).fetchJoin()
.where(post.id.`in`(paginatedPostIds))
.fetch()
.groupBy { it.get(post) }
.mapValues { it.value.map { tuple -> tuple.get(postTag)!! } }
.map { Pair(it.key!!, it.value) }
}
Post에 User, Category, Post까지 전부 반환화는 좀 큰 Response였는데 이렇게 되니 쿼리가 아주 길어졌다.
원래 내용이 많아진 만큼 Projection을 통해 해결하고자 했으나 Nested 된 구조에 Tag는 List이기까지 해서 도무지 @QueryProjection을 통한 설계가 제대로 먹히질 않아 포기했고 Join을 잘 처리해서 이를 해결하고자 했다.
그랬더니 이꼴 이모양이 됐다. 일단 기본 Pagination 적용부터 시작해서 Join을 통해 온갖 데이터를 한번에 끌어와 Page<Pair<Post, List<PostTag>>>
형태로 나온 값을 서비스에서 Dto로 변환한다.
Projection으로 삽질한 시간도 길었지만 이 구조를 잡는 것도 시간이 꽤 오래 걸렸다. 솔직히 이정도 복잡도면 나중에 유지보수도 엄하게 어려워질 것이라 생각이 들었다.
다만 쿼리는 단 1개의 쿼리로 깔끔하게 처리가 되긴 했다.
// DTO 동일
// Service
override fun searchPostList(searchType: PostSearchType, keyword: String, pageable: Pageable): Page<PostResponse> {
return postRepository.searchByKeyword(searchType, keyword, pageable)
.let { postPage ->
val postIds = postPage.mapNotNull { it.id }
val postTagList = postTagRepository
.findTagByPostIdIn(postIds)
.groupBy { it.post.id }
postPage.map { post ->
PostResponse.from(post, postTagList[post.id!!]?.map { postTag -> postTag.tag }?.toSet()!!)
}
}
}
// Post Repository
override fun searchByKeyword(
searchType: PostSearchType,
keyword: String,
pageable: Pageable
): Page<Post> {
val whereClause = BooleanBuilder().and(
when (searchType) {
PostSearchType.TITLE_CONTENT -> post.title.contains(keyword).or(post.content.contai
PostSearchType.TITLE -> post.title.contains(keyword)
PostSearchType.CONTENT -> post.content.contains(keyword)
PostSearchType.NONE -> null
}
)
val (paginatedPostIds, totalCount) = basePagingIds(pageable, whereClause)
if (paginatedPostIds.isEmpty()) {
return PageImpl(emptyList(), pageable, 0L)
}
val postList = queryFactory
.select(post)
.from(post)
.join(post.user, user).fetchJoin()
.join(post.category, category).fetchJoin()
.where(post.id.`in`(paginatedPostIds))
.fetch()
return PageImpl(postList, pageable, totalCount)
}
// PostTag Repository
override fun findTagByPostIdIn(postIds: List<Long>): List<PostTag> {
return queryFactory
.selectFrom(postTag)
.join(postTag.tag, tag).fetchJoin()
.where(postTag.post.id.`in`(postIds))
.fetch()
}
그래서 2단계로 분리해봤다. 비연관관계 Join을 하는 순간 QueryDSL은 Tuple을 뱉어내게 되고 Null safety에 대한 안정성을 높이고 코드의 복잡도를 줄이는 방향으로 설계해본 것이다.
Query 횟수는 증가했지만 충분히 묶어서 잘 처리가 진행됐고 복잡도도 줄었기에 다음부터 설계할 일이 있으면 Fetch Join을 쓰지 못하는 비연관관계 Join은 가급적 지양하고 각 Repository와의 통신을 우선시하는게 좋지 않을까 싶은데 이쪽에 관련해선 튜터님께 추후 피드백을 듣고자 한다.