본문 바로가기
Backend

SpringDataSolr 적용하기 1 (Spring,Kotlin)

by 우롱추출물 2023. 2. 4.

안녕하세요. 오늘은 프로젝트 내에서 자동완성 기능을 구현한 과정을 간략하게 공유해보고자 합니다.

1. SpringDataSolr 도입 이유와 문제 상황

[기획 요구사항]
1. 상품 검색시 (이름, 브랜드명(회사명), 성분명) 3가지중 어디서 검색 키워드가 걸렸는지 우선순위에 따라 검색이 되어야합니다.  
2. 예) 정관장 화이락 이라고 검색했을때, 정관장이 포함된건도, 화이락이 포함된 건도 나와야합니다.

위 2가지를 초점 맞추어서 진행할 수 있는 검색엔진을 찾아보다가 Solr 도입을 검토하게 되었습니다.

  • 특히 Solr에서 제공하는 Full text search 기능이 필요했고, 검색어 자동완성을 구현하고자 했습니다.
  • 한글 형태소 분석을 통해서 띄어쓰기를 하더라도 단어들이 모두 포함될 수 있도록 해야 했습니다.

 

2. 목차

크게 3가지 목차로 구성하여 글을 작성했습니다. solr에 대해서 모든 내용을 담지는 못했지만, 프로젝트에서 자동완성기능을 담을 때 어떻게 진행했는지 대략적인 골격을 공유하기 위해서 작성했습니다.

1) 환경세팅: spring, kotlin에서 solr 도입

2) 데이터를 solr에 넣고 확인: 예시코드

3) 검색결과 확인하기

 


1. 환경세팅

1-1. Solr 설치하기

저는 Mac에서 작업하고 있어 homebrew로 설치하였습니다.

window, linux 환경에서도 거의 동일하게 구성되어 있기 때문에 mac 기준으로 설명하겠습니다.

(설치한 뒤의 directory 구조, 환경설정 등) 

* mac 은 비교적 설치가 간단합니다. 아래 명령어로 설치를 했습니다.

brew update
brew install solr

solr --version #v8.11.1

1-2. Solr Admin에 접속하기

solr start
  • 위 명령어를 실행하면 localhost:8983/sorl (어드민)로 접속이 가능해집니다.
  • 왼편에 여러 Tap 이 있는데 Core Selector에서 Core를 선택해서 우리가 사용하려는 Core를 선택해야 합니다.

여기서 의미하는 Core

일반적으로 Lucene(루씬) Index를 실행하기 위한 구성 (solrconfig.xml, managed-schema.xml)을 가지고 서비스를 하는 인스턴스를 의미합니다. 코어의 구성은 일반적으로 conf 폴더에 존재합니다.

간단히 말해서 core란 DB의 테이블처럼 저장할 수 있는 공간을 의미합니다. core에 상품정보를 넣어서 사용할 예정입니다.

1-3. Core 생성하기

solr create -c product

이렇게 하면 product라는 이름으로 코어를 생성하게 됩니다.

이렇게 코어를 생성한 후 어드민을 들어가 보면 core selector에 생성된 코어(product)를 확인할 수가 있습니다.

 

저희 팀에서는 프로젝트에 spring을 사용 중이니 spring을 활용해서 데이터를 저장해 보기로 했습니다.

어드민에 화면에 나타나는 Query 탭은 후에 설명하겠지만, core에 저장된 내용들을 쿼리 할 때 사용이 됩니다.

1-4. Field 생성하기

DB에서 테이블의 칼럼들에 대해 타입, 크기 등을 지정하듯이 core에도 각 필드별로 타입, 인덱스여부, 소팅여부 등에 대해서 지정을 해야 합니다. 저희는 수동으로 해당 필드들을 작업하기로 했습니다.
(이유는 spring에서 자동으로 만들어주는 타입이 string 타입이 아닌 다른 타입(text_general)이 들어가졌기 때문입니다.)

참조했던 레퍼런스는 아래 첨부합니다.

 

[Solr 빠른 완성] 2. Schema 생성

Schema 생성 Schema[1]는 Lucene에 저장되는 문서의 구조입니다. Lucene만 단독으로 사용한다면 문서를 저장하고 꺼낼 때마다 설정해줘야 하지만 Solr는 미리 설정해두고 사용하도록 해줍니다. 덕분에 문

jetalog.net

환경구성 파일의 위치는 아래에 있습니다.

solr\product\conf

- 여기서 product은 아까 생성한 core 이름입니다.

- core 이름 하위에 conf 파일 안으로 가면 여러 구성파일들이 있습니다.

- managed-schema 파일을 열어서 저장하고 싶은 필드를 아래처럼 만들었습니다. 

<field name="name" type="string" indexed="true" stored="true" multiValued="false" />
<field name="brandName" type="string" indexed="true" stored="true" multiValued="false" />
<field name="nutritions" type="string" indexed="true" stored="true" multiValued="false" />

이렇게 해서 기본적인 코어, 필드 구성은 마무리가 되었습니다. 마지막으로 스프링에서 사용할 수 있는 환경을 구성해 보겠습니다.

1-5. Spring 프로젝트에 의존성 추가 (build.gradle.kt)

implementation("org.springframework.data:spring-data-solr:4.3.15")
implementation("org.codehaus.woodstox:stax2-api:4.2.1")

 

 

 

2. 데이터를 solr에 넣고 확인: 예시코드

기본적으로 SpringDataSolr을 활용해서 CRUD를 하는 작업 예시 레퍼런스입니다.

 

Spring Data Solr Tutorial: CRUD (Almost)

Spring Data Solr Tutorial: CRUD (Almost) In the previous part of my Spring Data Solr tutorial, we learned how we can configure Spring Data Solr. Now it is time to take a step forward and learn how we can manage the information stored in our Solr instance.

www.petrikainulainen.net

위 자료를 통해서 저희도 Solr에서 사용할 데이터 모델 먼저 만들었습니다.

 

2-1. Solr에 사용할 데이터 모델 (DTO) 만들기 

@SolrDocument(collection = "product")
class ProductSolr(
    @Id
    @Indexed(name = "id", type = "Long")
    val id: Long?,

    @Indexed(name = "name", type = "string")
    val name: String,

    @Indexed(name = "brandName", type = "string")
    val brandName: String?,

    @Indexed(name = "nutritions", type = "string")
    val nutritions: String?,
) {

    var score: Double? = null

    @PersistenceConstructor
    constructor(id: Long?, name: String, brandName: String?, nutritions: String?, score: Double?):this(
        id = id,
        name = name,
        brandName = brandName,
        nutritions = nutritions
    ){
        this.score = score
    }

    constructor(product: Product) : this(
        id = product.id,
        name = product.name,
        brandName = product.getBrandName(),
        nutritions = product.productNutritions.joinToString(SEPERATOR_PRODUCT) { it.regularName ?: it.name }
    )
}
  1. @SolrDocument: 사용하고자 하는 core 설정
  2. @Index: Solr에 색인 및 검색이 가능하도록 함. (type은 수동으로 설정했기 때문에 빼고 진행해도 동작가능함)
  3. @PersistenceConstructor: 매개변수가 존재하는 생성자가 여러 개인데, 기본생성자가 없기 때문에 MappingInstantiationException 이 발생했습니다. 따라서 여러 constructor를 인식하고 solr이 보내주는 데이터를 받기 위해 사용했습니다.
  4. id, name, brandName, nutritions: 주 생성자에 넣은 이유 → field에 사용되는 기본 필드를 property로 설정함. → score는 solr에서 제공하는 기능을 담았으므로 주생성자에 없어도 되기 때문에 제외함. 대신, 부생성자를 만들고 solr에서 받은 score를 할당해 주는 것으로 구현 진행함.

2-2. Repository Interface 정의

interface SolrRepository : SolrCrudRepository<ProductSolr, Long> {

@Query("*:*")
fun getAllProduct(): List<ProductSolr>

@Query("name:(*?0*)^=2 OR brandName:(*?0*)^=3 OR nutritions:(*?0*)^=1", fields = ["*", "score"])
@Highlight(fields = ["name", "brandName", "nutritions"])
fun getByName(keyword: String, pageable: Pageable): HighlightPage<ProductSolr>

}

기본 사용법은 JPARepository를 implement 받아서 사용하는 방법과 유사했다.

  • @Query 이 어노테이션 안에 Solr 어드민의 q 쿼리를 넣어서 검색하는 것과 유사하게 검색을 할 수 있다.
    (q 쿼리 작성하는 방법은 spring data solr 공식문서와 Apache Solr 공식문서를 참조하면 된다.)
  • getByName에서 name:(*? 0*)^=2 OR brandName:(*? 0*)^=3 OR nutritions:(*? 0*)^=1
    • ? 0: 0번째 매개변수 (여기서는 keyword)
    • *: 어떤 단어가 오든지
    • ^=2, ^=3: 우선순위 점수 부여
    • 종합해 보면, name에 keyword (앞뒤로 어떤 키워드든) 일치하면 2점, brandName keyword (앞뒤로 어떤 키워드든) 일치하면 3점, nutrition에 keyword (앞뒤로 어떤 키워드든) 일치하면 1점을 내고 합산한다.
  • fields = ["*", "score"]: 여기서 fields는 solr 어드민에서 fl를 의미한다.
    • “*”: 저장한 id, name, brandName, nutritions 필드 모두,
    • “score”: 부여한 점수 합산 결과

2-3. Solr Persistence Layer에 대한 구성환경

@Configuration
@EnableSolrRepositories(value = ["com.company.project.package"])
class SolrConfig : WebMvcConfigurer {

    @Bean
    fun solrClient(): SolrClient? {
        return HttpSolrClient.Builder("http://localhost:8983/solr").build()
    }

    @Bean
    @Throws(Exception::class)
    fun solrTemplate(client: SolrClient?): SolrTemplate? {
        return SolrTemplate(client!!)
    }
}

2-4. Service

@Service
@Transactional
class SolrService(
    @Resource
    val solrRepository: SolrRepository,
    val productRepository: ProductRepository,
) {
    fun findAllProduct(): List<ProductSolr> {
        val solrData = solrRepository.getAllProduct()
        return solrData.ifEmpty {
            saveAllProducts()
            solrRepository.getAllProduct()
        }
    }

    fun saveAllProducts() {
        val products = productRepository.findAll()
        solrRepository.saveAll(products.map { ProductSolr(it) })
    }

    fun deleteSolrProducts() {
        solrRepository.deleteAll()
    }

    fun getProductsByKeyword(keyword: String, pageRequestModel: PageRequestModel): HighlightPage<ProductSolr> {
        return solrRepository.getByName(keyword, pageRequestModel.getPageable())
    }
}

* pageRequestModel은 JPA에서 사용하는 pagination과 동일하게 구성했습니다. (어떤 offset, size, property, sorting기준 등을 설정할 수 있습니다.)

2-5.SorlConfig

@Configuration
@EnableSolrRepositories(value = ["com.company.project.package"])
class SolrConfig : WebMvcConfigurer {
    @Bean
    fun solrClient(): SolrClient? {
        return HttpSolrClient.Builder("http://localhost:8983/solr").build()
    }

    @Bean
    @Throws(Exception::class)
    fun solrTemplate(client: SolrClient?): SolrTemplate? {
        return SolrTemplate(client!!)
    }
}

 

 

 

 

3. Solr 검색결과

{
    "content": [
        {
            "id": 1,
            "name": "화애락 큐",
            "brandName": "화애락정관장 화애락정관장",
            "nutritions": "열량|탄수화물|단백질|지방|나트륨|진세노사이드 Rg1+Rb1+Rg3",
            "score": 5.0
        },
        {
            "id": 2,
            "name": "홍삼톤 마일드",
            "brandName": "정관장",
            "nutritions": "열량|탄수화물|당류|단백질|지방|나트륨|진세노사이드 Rg1+Rb1+Rg3",
            "score": 3.0
        }
    ],
    "pageable": {
        "sort": {
            "sorted": true,
            "unsorted": false,
            "empty": false
        },
        "pageNumber": 0,
        "pageSize": 10,
        "offset": 0,
        "paged": true,
        "unpaged": false
    },
    "facetResultPages": [],
    "facetQueryResult": {
        "content": [],
        "pageable": "INSTANCE",
        "totalPages": 1,
        "totalElements": 0,
        "last": true,
        "numberOfElements": 0,
        "size": 0,
        "number": 0,
        "sort": {
            "sorted": false,
            "unsorted": true,
            "empty": true
        },
        "first": true,
        "empty": true
    },
    "highlighted": [
        {
            "entity": {
                "id":1,
                "name": "화애락 큐",
                "brandName": "화애락정관장 화애락정관장",
                "nutritions": "열량|탄수화물|단백질|지방|나트륨|진세노사이드 Rg1+Rb1+Rg3",
                "score": 5.0
            },
            "highlights": [
                {
                    "field": {
                        "name": "brandName"
                    },
                    "snipplets": [
                        "<em>화애락정관장 화애락정관장</em>"
                    ]
                },
                {
                    "field": {
                        "name": "name"
                    },
                    "snipplets": [
                        "<em>화애락 큐</em>"
                    ]
                }
            ]
        },
        {
            "entity": {
                "id": 2,
                "name": "홍삼톤 마일드",
                "brandName": "정관장",
                "nutritions": "열량|탄수화물|당류|단백질|지방|나트륨|진세노사이드 Rg1+Rb1+Rg3",
                "score": 3.0
            },
            "highlights": [
                {
                    "field": {
                        "name": "brandName"
                    },
                    "snipplets": [
                        "<em>정관장</em>"
                    ]
                }
            ]
        }
    ],
    "maxScore": 5.0,
    "fieldStatsResults": {},
    "suggestions": [],
    "facetPivotFields": [],
    "allFacets": [
        null
    ],
    "alternatives": [],
    "facetFields": [],
    "totalPages": 7,
    "totalElements": 66,
    "numberOfElements": 10,
    "size": 10,
    "number": 0,
    "sort": {
        "sorted": true,
        "unsorted": false,
        "empty": false
    },
    "first": true,
    "last": false,
    "empty": false
}

* 콘텐츠 내용들 중 일부만 결과로 첨부합니다.

* 정관장 화애락을 검색한 경우 score가 위와 같이 나왔습니다. pageable 안에 pagination 정보를 넣어주면 소팅, 원하는 페이지만큼 가져올 수 있습니다.

 

 

 


이렇게 작업을 했음에도 문제가 조금 더 발생해서 저희는 이 문제를 개선하기 위해서 조금 더 했어야 했는데요.

글이 길어져서.. 해당 내용은 정리 후 다음 포스팅에 올리도록 하겠습니다 :-) 읽어주셔서 감사합니다.

 

주요 참조 링크