본문 바로가기
아직 카테고리 미정

RDB의 FTS 를 적용하면서 부딪힌 부분들

by simplify-len 2024. 9. 16.

개요

기준 Postgres 기준으로 ts_vector, ts_query 를 활용하여 Full Text Search 을 실행할 수 있음.

함께 읽어보면 좋은 내용 - RDB 의 FTS(Full Text Search) 이해하기

 

RDB 의 FTS(Full Text Search) 이해하기

RDB 의 FTS(Full Text Search) 란?배경이 글의 목적은 FTS(Full Text Search) 로 RDB(Relational Database) 적당한지 판단합니다. 검색엔진으로서 대부분 ElasticSearch 를 사용하는데요. 간단한 검색엔진으로서는 다

happy-coding-day.tistory.com

 

 

배경

SpringBoot와 JPA 를 활용하여 개발하는 경우가 다수이다.

흔히 Repository 를 활용한 영속성을 활용한다. spring-boot-data-commons. 일단 해당 모듈에서는 FTS 를 지원하는 메서드가 일체 존재하지 않는다. 그렇기 때문에 @Query 와 같이 직접 쿼리를 작성해야 한다.

@Query 를 작성하지 않고 개발하는 방식도 있는데, 그 방법도 약간의 문제점이 발견되었다.

실제 코드로 문제를 이해해보자.

문제1 Tokenizer 한계

테스트를 위해 다음과 같은 클래스를 Json 으로 만들어 tokenizer 가 Parsing 하도록 설정하여 ts_vector('english', feature) 실행

@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Feature {

    private String path;
    private String method;
    private List<String> tags;
    private String operationId;
    private String summary;
    private String description;
}

List.of(new ApiDocument.Feature(
            "/api/example/foo",
            "GET",
            List.of("exampleTag1", "exampleTag2"),
            "getExampleOperation",
            "Example Summary",
            "Example feature description"
        )),

 Example Dummy Data 를 활용해 Parsing 한 결과(to_tsvector)

 

'/api/example/foo':6 'descript':3 'exampl':1,7 'exampletag1':9 'exampletag2':10 'featur':2 'get':4 'getexampleoper':5 'summari':8

그럼 이것이 왜 한계일까?

 흔히 우리가 Full Text Search 을 할 때 to_tsquery('keyword') 이렇게 작성하는데, 이 때 api path 를 검색하기 위해 to_tsquery('foo') 작성하게 되면 /api/example/foo 을 찾을 수 없다.

/api:* 라고 검색하면 prefix 기능을 활용하여 찾을 수 있지만, 찾고자한다면 /api/example/foo 전체를 입력해야만 한다.

 Full Text Search 의 의미 자체가 Text 에 맞는 단어를 찾는 것이기 때문이며 이를 해결하기 위해 정규표현식(RegExp) 또는 LIKE 연산자 또는 tri-gram matching 을 활용해야 하는데, 그렇다면 성능에 대한 고민을 다시해볼 수 밖에 없게 된다.

[참고자료]

1. how-to-make-middle-and-suffix-matching-using-full-text-search

2. ElasticSearch 에서 스탠다드 Tokenizer로 '/componets/schema/User'를 나눈 것. PostreSQL 에서는 위에서 설명한 것과 같이 이렇게 나누지 못한다. 

 

문제2 다이나믹한 작업시 JPQL 충돌

앞에서 언급한 @Query 방식을 활용할 경우 온전히 기능을 다 사용하기 어려울 수 있다. 

@Query(value = "SELECT d.* FROM documents d JOIN documents_feature f ON d.document_id = f.document_id WHERE f.search_vector @@ to_tsquery(:keyword);", nativeQuery = true)
Documents findDocumentsByFeaturesKeyword(@Param("keyword") String keyword);

이렇게 단순한 것 까지는 문제가 되지 않는데, to_tsquery 는 FTS 중심적인 역할로 몇가지 활용가능한 연산자가 있다.

and($), OR(|), NOT(!), 프리픽스(:*) / 구문 검색(↔) 여기서 프리픽스를 의외로 많이 사용될 것 같은데, 위 JPQL 에서는 충돌이 발생하여 파라미터를 추가로 입력해야 한다는 예외를 발생시킨다.

이 부분을 해결하기 위해 따로 @Query 를 작성하지 않는 방식으로 구현하면 문제가 해결될 수 있을까?

일단은 해결가능할 것으로 보인다. 단, 약간의 시행착오가 또 발생한다. 아래와 같이 sql 을 작성했는데, 이것이 올바르게 작성된 syntax 인지 시행착오가 필요하다.(삽질이 필요하다..)

@Override
public List<Documents> findDocumentsByPrefixKeyword(String keyword) {
	String sql = """
	SELECT d.*
		FROM documents d
		JOIN documents_feature f
		ON d.document_id = f.document_id
		WHERE f.search_vector @@ to_tsquery('""" + keyword
			+ """
		:*');""";
    return entityManager.createNativeQuery(sql, Documents.class).getResultList();
}

 

[참고자료]

엘라스틱서치에서는 다음과 같은 방식으로 처리한다.

ApiDocuments entity = new ApiDocuments(null, "foo", "bar");
        ApiDocuments save = sut.save(entity);
        assertThat(save.getId()).isNotNull();

        IndexRequest<ApiDocuments> request = IndexRequest.of(i -> i
                .index("api-documents")  // 인덱스 이름
                .id(save.getId())
                .document(save)
        );

        elasticsearchClient.index(request);

차이점이라한다면 엘라스틱서치는 체이닝 함수로 되어 있다는 점. 이것의 장점은 앞서 PostgresSQL 의 겪었던 시행착오를 줄여주는 효과가 있을 수 있다고 생각한다.


문제3 한글 형태소 분석기 설치

이건 주관적인 문제점일 수 있지만, 그럼에도 시간을 꽤 잡아먹었던 부분이라 이야기해본다.
결론부터 이야기하면 MacOS 의 도커를 활용한 PostgresSQL 개발 환경에서는 한글 형태소 분석기를 확장 형태로 설치하는게 쉽지 않다.

우리는 서비스 기능의 일부로 검색시 한글을 지원해야 하지만, PostgresSQL 의 FTS 는기본적으로 한글을 지원하지 않는다. (형태소 분석기가 필요한 이유는 조사어구를 제거하기 위해서) 이를 해결하기 위해서는 PostgresSQL 에서 확장 모듈 형태로 지원하는데,
이때 docker 가 아닌 local 에 설치된 환경에서 여러 절차를 통해 한글 분석기를 설치할 수 있다. local 에서는 어떻게 설치할 수 있다고 가정하더라도- docker 를 활용한 애플리케이션 구현하는 곳에서는 이것을 설치하는게 쉽지 않아 보인다.
예를들어 테스트 코드를 활용하여 testContainer 사용하는 환경이라면?


결론

하면서 느낀점은 PostgresSQL 로 검색을 시늉낼 수는 있지만, 조금만 복잡도가 높아졌을 때 자바 문법과 PostgresSQL 문법을 고민하면서 시행착오를 겪어야만 했다. 거기에 더불어 진짜 검색을 잘 되도록 만들기 위해서는 to_tsquery 뿐만 아니라, like, pg_tram 을 활용하여 한번 쿼리를 보낼때 이 3가지를 같이 조회해서 노출시켜야만 우리가 원하는 결과물을 만들 수 있지 않을까 싶다.

참고

  1. pg_tram 은 3글자부터 인덱스 스캔을 활용하기 때문에 효율이 좋고, 2글자 검색시 풀스캔을 한다. 이를 해결하기 위해 pg_bitram (2글자) 가 있지만, 이또한 2글자 이상 검색시 효율이 좋지 못하다는 단점이 있다.

댓글