스프링

spring jpa+mysql fulltext search+testcontainer 사용+테스트만들기

rkrkrr0101 2024. 2. 14. 09:45

한 3일동안 이거랑만 싸웠다..힘들었다

모든코드는 코틀린임

 

 

일단 mysql fulltext search(전문검색)를 jpa 네이티브쿼리 없이 사용하려면 하이버네이트의 펑션컨트리뷰터를 상속받은 커스텀펑션컨트리뷰터(이름은 알아서지으셈)를 만들어줘야함

 

class CustomFunctionsContributor:FunctionContributor {
    override fun contributeFunctions(functionContributions: FunctionContributions?) {
        if(functionContributions ==null){
            throw IllegalAccessException()
        }
        functionContributions.functionRegistry.registerPattern(
            "match_against",
            "match (?1) against (?2 in boolean mode)",
            functionContributions.typeConfiguration.basicTypeRegistry.resolve(StandardBasicTypes.BOOLEAN)
        )
    }
}

이러면 jpql에서 ?1과 ?2에

match_against(p.title,:title)

이런식으로 사용할수 있어짐

그리고 하이버네이트에 해당클래스를 만들었다는걸 알려줘야함

이건 메인의 resources에서

 

resources/META-INF/services/

에다가

org.hibernate.boot.model.FunctionContributor

라는 파일을(저거 전체가 파일이름맞음) 만들어줘야함

인텔리제이에선 이런식으로 표시되면됨

그리고나서 그 파일안에 우리가 만든 클래스의 경로를 적어주면 됨

com.shop.shop.config.hibernate.CustomFunctionsContributor

난 config라는 폴더를 만들고 hibernate폴더를 만든다음에 거기다 클래스파일을 만들어서 그대로 적었음

이러면 jpql에서 해당 함수를 사용할수있어짐(테스트할때도 따로 테스트쪽에서 뭐해줄필요도없음)

@Query("select p from Post p where match_against(p.title,:title)")
fun findByTitleContaining(@Param("title") title:String,pageable: Pageable): Page<Post>

그리고나서 해당 풀텍스트로 사용할 컬럼을 풀텍스트 인덱스 키로 등록해줘야함

엔티티에서

@Entity
class Post(
    @Column(columnDefinition = "varchar(255) not null, fulltext key fx_title (title) with parser ngram")
    var title:String,
    ...

저렇게 넣으면됨,앞에 varchar같은 타입을 꼭 넣어야하니 빼고적으면안됨

저런 하드코딩 싫고 어짜피 ddl db에서 직접하지않냐고 할수있는데 그러면 테스트가 머리아파짐..

 

이러면 n그램으로(고기국밥이면 고기 기국 국밥 이런식으로 인덱스만드는식) 풀텍스트인덱스까지 생성되고

 

이러면 일단 구현은 된거임,그리고 기본적인 불용어가 있어서 ab같은건 검색안되니 가능하면 한글로 테스트하는게좋음

 

여기까진 글케 오래 안걸리는데 테스트가 너무힘들었음

 

 

이제 테스트인데

일단 알아둬야할건,

1.당연히 h2는 사용할수없음(mysql모드를 켜도 저런 db밴더별 펑션까진 지원못해줌)

2.fulltext 서치는 fulltext index가 꼭 필요함

3.테스트에서 트랙잭션은 ddl이 섞일경우 정상동작하지않고 커밋을 쳐버림(ddl을 섞으면 안된다는건 알았는데,아예 ddl이 있어서 그시점에서 커밋된지 아니면 처음부터 트랜잭션 시작을 안했는진 모르겠음,ddl을 어플리케이션영역에서 사용할일이 보통없으니..)

4.풀텍스트서치는 트랜잭션 커밋시점에 인덱스가 생성되기때문에 일반적인 테스트같은 임시트랜잭션중간중엔 검색할수없음

5.테스트컨테이너의 동작시점은 jpa이전이라,ddl-auto가 켜져있을경우 무조건 덮어쓰여짐

 

h2를 사용할수없으니 직접 db를 띄워야하는데,그렇다고 진짜로컬디비 쓰기는 싫으니(테스트때 외부의존성있는게 싫음)

테스트 컨테이너를 사용했음

일단 컨테이너니까 도커부터 검색해서 깔아야함 검색 ㄱㄱ(도커데스크탑깔면될거임)

이건

testImplementation 'org.testcontainers:testcontainers:1.19.4' // TC 의존성
testImplementation 'org.testcontainers:junit-jupiter:1.19.4'  // TC 의존성
testImplementation 'org.testcontainers:mysql:1.19.4'     // mySQL 컨테이너 사용
testImplementation 'org.testcontainers:jdbc:1.19.4'           // DB와의 JDBC connection

이4개를 받고,

테스트쪽 resources의 application.yml에서(나는 전문검색에서만 쓸거라 분리해서 application-testContainer.yml를 만들었음)

spring:
  datasource:
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
    url: jdbc:tc:mysql:8.0.36:///testdb
    username: test
    password: 1234
  jpa:
    hibernate:
      ddl-auto: update

를 추가or 변경하고

해당 테스트 클래스의 머리에

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = [ "spring.config.location = classpath:application-testContainer.yml"])
@Testcontainers
class PostRepositoryTest(@Autowired val postRepository: PostRepository,
                         @Autowired val memberRepository: MemberRepository,
                        ) {

를 붙이면됨

여기서

@DataJpaTest는 레포지토리쪽 빈 불러오려고 쓴거니 필요없으면 제외해도되고(물론 그러면 트랜잭셔널 달아야함)

@AutoConfigureTestDatabase는,DataJpaTest가 저 어노테이션이 없으면 메인h2설정이였나를 가져와서 그걸 받아오지마라고 하는거

@TestPropertySource는 내가 작성한 application.yml에 연결하는거고

@Testcontainers는 테스트컨테이너를 사용하겠다는거

 

이제 테스트컨테이너를 만들어야함

미리 말해두는데 테스트컨테이너를 이방식대로 만들면,만약 여러군데서 테스트컨테이너를 똑같이 쓸경우 여러대가 뜰수있으니 다른블로그 참고해서 공유하게 만드는게 좋을수있음

난 여기서만쓸거라 간단하게 만들었음

 

테스트컨테이너는

companion object{
    @JvmStatic
    @Container
    val mysqlContainer=MySQLContainer("mysql:8.0.33")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("1234")
        .withEnv("MYSQL_TCP_PORT","3307")
        //.withInitScript("init_data/fulltext_index_create.sql")
}

이렇게 해당클래스의 컴패니온오브젝트로 만들면 됨,따로 뭐 안해도 저거까지만 하면 알아서 스프링인지 junit인지가 연결해줌

그리고 withInitScript로 테스트쪽의 resources에 있는 sql파일을 실행할수있는데(sql파일은 정적파일이라 아마 resources쪽에만 둘수있을거임),문제는 테스트컨테이너가 먼저 실행되고 스크립트가 실행된다음 jpa가 실행돼서,jpa ddl-auto가 켜져있으면 거기에 다 덮어씌워지니까 create같은거면 안에있는거 다날아가니까 조심,그래서 난 저거 건드리다 포기하고 안썼음

안써도됨 그냥 없이 application.yml에만 설정하면 컨테이너 하나만띄워서 동작함

initScript가 필요할떄만 쓰면되는듯

 

 

 

그리고 데이터를 넣어야하는데,위에서도 말했던거처럼,전문검색을 하려면 무조건 커밋이 되어야 인덱스가 생기고,그걸바탕으로 검색할수있음

그래서 @BeforeEach는 사용할수없음

@BeforeEach를 가지고 별짓다해봤는데(jdbc로 커넥션가져와서 트랜잭션시작후 커밋,em으로 트랜잭션시작후 커밋,@Rollback(false),@Commit,@Transactional(propagation = Propagation.REQUIRES_NEW))구조가 외부메서드가 beforeEach가 붙은 메서드와,현재실행할 메서드 두개를 자기안에서 실행하는식으로 되는건지,어떻게해도 외부트랜잭션에 합류하는느낌으로 실행돼서 인덱스생성이 안됐음

 

그래서 @BeforeTransaction을 사용했음

이건 테스트메서드의 트랜잭션 전에 실행되어서 테스트코드와 별개의 트랜잭션영역으로 실행되게 해주는 어노테이션임

이걸

@BeforeTransaction
@Rollback(false)
fun init(){

이런식으로 롤백false해주고(커밋해야하니까)

 

@BeforeTransaction
@Rollback(false)
fun init(){

        postRepository.deleteAll()
        memberRepository.deleteAll()

        val postList = mutableListOf<Post>()
        postList.add(Post("고기 라면 볶음", 3, 10, 1, "da", "none", 3, "aa.img"))
        postList.add(Post("고기 피자 세트", 4, 10, 1, "da", "none", 3, "aa.img"))
        postList.add(Post("라면 제육 정식", 1, 10, 1, "da", "none", 3, "aa.img"))

        val member = Member("username111", "password111", "name111", "qqq@aqw.com")
        memberRepository.save(member)
        val findMember = memberRepository.findByUsername("username111")!!
        for (post in postList) {
            post.member = findMember
            postRepository.save(post)
        }


}

이런식으로 먼저 삭제하고나서 테스트데이터를 삽입하는 형태로 하면됨

이런 특성상 나는 별로 좋아하지않는 테스트끼리의 의존성(한 테스트데이터를 변경하면 모든곳에서 영향받는)이 생기긴하는데 따로 해결할방법을 아직 못찾았음

 

그리고 테스트는 그냥 평범하게 짜면됨

@Test
fun 정상적으로_전문검색을_할수있다(){
    //g
    val pageRequest= PageRequest.of(
        0,5, Sort.by("price")
    )

    //w
    val findPostPage = postRepository.findByTitleContaining("고기", pageRequest)
    //t
    Assertions.assertThat(findPostPage.content.size).isEqualTo(3)
    Assertions.assertThat(findPostPage.content[0].price).isEqualTo(2)
    Assertions.assertThat(findPostPage.content[2].price).isEqualTo(4)
}

 

 

테스트코드 전체

package com.shop.shop.post.repository

import com.shop.shop.member.domain.Member
import com.shop.shop.member.repository.MemberRepository
import com.shop.shop.post.domain.Post
import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.test.annotation.Rollback
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.transaction.BeforeTransaction
import org.testcontainers.containers.MySQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers


@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = [ "spring.config.location = classpath:application-testContainer.yml"])
@Testcontainers
class PostRepositoryTest(@Autowired val postRepository: PostRepository,
                         @Autowired val memberRepository: MemberRepository,
                        ) {
//    companion object{
//        @JvmStatic
//        @Container
//        val mysqlContainer=MySQLContainer("mysql:8.0.33")
//            .withDatabaseName("testdb")
//            .withUsername("test")
//            .withPassword("1234")
//            .withEnv("MYSQL_TCP_PORT","3307")
//            //.withInitScript("init_data/fulltext_index_create.sql")
//    }

    @BeforeTransaction
    @Rollback(false)
    fun init(){

            postRepository.deleteAll()
            memberRepository.deleteAll()

            val postList = mutableListOf<Post>()
            postList.add(Post("고기 라면 볶음", 3, 10, 1, "da", "none", 3, "aa.img"))
            postList.add(Post("고기 피자 세트", 4, 10, 1, "da", "none", 3, "aa.img"))
            postList.add(Post("라면 제육 정식", 1, 10, 1, "da", "none", 3, "aa.img"))
            postList.add(Post("고기 고기 고기", 2, 10, 1, "da", "none", 3, "aa.img"))
            postList.add(Post("제육 라면 세트", 7, 10, 1, "da", "none", 3, "aa.img"))
            postList.add(Post("야채 피자 세트", 6, 10, 1, "da", "none", 3, "aa.img"))
            postList.add(Post("야채 제육 정식", 5, 10, 1, "da", "none", 3, "aa.img"))
            postList.add(Post("호빵 붕어빵 식빵", 5, 10, 1, "da", "none", 3, "aa.img"))
            postList.add(Post("라면 라면 라면", 8, 10, 1, "da", "none", 3, "aa.img"))
            postList.add(Post("피자 피자 피자", 9, 10, 1, "da", "none", 3, "aa.img"))
            postList.add(Post("rocking and rolling", 9, 10, 1, "da", "none", 3, "aa.img"))

            val member = Member("username111", "password111", "name111", "qqq@aqw.com")
            memberRepository.save(member)
            val findMember = memberRepository.findByUsername("username111")!!
            for (post in postList) {
                post.member = findMember
                postRepository.save(post)
            }


    }

    @Test
    fun 정상적으로_전문검색을_할수있다(){
        //g

        val pageRequest= PageRequest.of(
            0,5, Sort.by("price")
        )

        //w
        val findPostPage = postRepository.findByTitleContaining("고기", pageRequest)
        //t
        Assertions.assertThat(findPostPage.content.size).isEqualTo(3)
        Assertions.assertThat(findPostPage.content[0].price).isEqualTo(2)
        Assertions.assertThat(findPostPage.content[2].price).isEqualTo(4)
    }
    @Test
    fun 전문검색을_하면서_높은가격순_정렬을_할수있다(){
        //g

        val pageRequest= PageRequest.of(
            0,5, Sort.by("price").descending()
        )

        //w
        val findPostPage = postRepository.findByTitleContaining("고기", pageRequest)

        //t
        Assertions.assertThat(findPostPage.content.size).isEqualTo(3)
        Assertions.assertThat(findPostPage.content[0].price).isEqualTo(4)
        Assertions.assertThat(findPostPage.content[2].price).isEqualTo(2)
    }
}

깃 링크(계속 건드리는중이라 변할수있음)

https://github.com/rkrkrr0101/Kotlin-Spring-Shop