240627 Spring 심화 - 과제(3) - Fixture monkey, QueryDSL Join에 대한 고민

노재원·2024년 6월 27일
0

내일배움캠프

목록 보기
70/90

테스트 데이터를 Fixture monkey로 바꾸기

이전부터 테스트 코드를 깔짝깔짝 쓰다보면 튜터님께서 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 - Tag 단방향으로 바꾸기

코드의 수정 자체는 정말 짧았지만 사실 테스트 코드 작성 자체는 꽤 시간이 오래 걸렸다.

가장 문제는 테스트 데이터 자동화를 진행하면서 Post가 쥐고 있던 PostTag에 자꾸 Null이 들어가려고 해서 NPE가 발생했는데 이게 라이브러리에서 null을 제외한 값으로 선택이 가능한지 아닌지도 구체적으로 알 수가 없어서 이 참에 아예 단방향으로 바꾸게 됐다.

PostTag를 작성하려면 Post, Tag가 먼저 저장되어야 하는데 그 전에 먼저 설정을 시도하려고 하니 그냥 단순히 NPE가 무조건 붙는 걸 수도 있다. 데이터 범위를 emptySet() 등으로 미리 채워놓으려고 시도했음에도 계속 에러가 나는 걸로 보아 양방향이 원인이 되는 건 확실해보였다.

어쨌든 양방향을 끊고 PostTag만 @ManyToOne으로 Post, Tag를 쥐고 있는 구조가 되자 기존에 DTO를 생성할 때 Lazy loading으로 날로 먹던 부분들도 고생을 하게 됐다.

Tuple을 이용해 Pair로 묶어 반환하게 하기

// 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와의 통신을 우선시하는게 좋지 않을까 싶은데 이쪽에 관련해선 튜터님께 추후 피드백을 듣고자 한다.

0개의 댓글