본문 바로가기

프로젝트/고민

Elasticsearch 쿼리빌더로 리팩토링

글을 쓰게 된 이유

이번에 한 리팩토링은 객체지향의 특징을 잘 살렸다고 생각이 들었다. 이후 엘라스틱 서치 뿐만 아니라 다음에 코드를 작성할 때 또한 도움이 될 것이라 생각이 들어 기록을 남기고자 한다.

 


잠깐의 필수 지식

본문에 들어가기에 앞서 글의 주제와는 다소 벗어나지만 글의 이해를 위해 꼭 알고 있어야 할 사항이 있다.

 

자바의 인터페이스 메커니즘과 스프링 데이터를 상속받은 인터페이스의 메커니즘을 정확하게 이해해야한다.

 

자바에서는 일반적으로 new 키워드를 통해 인터페이스를 사용하지 못하고 구현체를 생성해서 인터페이스를 사용해야 한다.  예를들어 다음의 코드가 있다.

interface test extends test2, test3{
}

interface test2 {
    void method2();
}

class test2Impl implements test2 {
    @Override
    public void method2() {
        System.out.println("method2");
    }
}

interface test3 {
    void method3();
}

class test3Impl implements test3 {
    @Override
    public void method3() {
        System.out.println("method3");
    }
}

작성된 인터페이스를 이용하려면 다음과 같이 구현체를 만들어야 한다.

 

class main() {
        test test = new test() {
            @Override
            public void run() {

            }

            @Override
            public void walk() {

            }
        };
}

 

하지만 우리는 스프링을 사용하며 아래와 같은 코드를 당연하게 사용하고 있다.

public interface ItemsRepository extends JpaRepository<Items, Long> {
}

@Service
public class ItemsService {

    private final ItemsRepository itemsRepository;    

    @Transactional(readOnly = true)
    public ItemsResponseDto findById(Long id) {
        Items items = itemsRepository.findById(id).orElseThrow(() -> new ItemsException(BAD_REQUEST_ITEMS_READ));

        return ItemsResponseDto.from(items);
    }
 }

 

 이와 같은 코드 작성이 가능한 이유는 Spring Data에서 자공하는 인터페이스를 상속받으면 자동으로 프록시 객체를 생성해 구현체를 제공하기 때문이다.

 

심지어는 @Query를 적용해 커스텀한 쿼리를 작성할 경우 해당 추상 메소드에 대한 구현체도 Spring Data가 알아서 구현해준다. 

즉, 아래의 ItemsService에 있는 ItemsRepository 객체는 ItemsRepository 인터페이스가 아닌 이를 구현한 프록시 객체가 들어잇다는 것을 알고 있어야 한다.

 

위의 내용을 인지하고 리팩토링을 해보자.

 

 


기존의 코드

public interface ItemsSearchRepository extends ElasticsearchRepository<ItemsIndex, Long>, CrudRepository<ItemsIndex, Long> {

    @Query("{\"bool\": {\"must\": [{\"bool\": {\"should\": [{\"wildcard\": {\"name\":\"*?0*\"}},{\"wildcard\": {\"description\": \"*?0*\"}}]}}," +
            "{\"range\": {\"price\": {\"gte\": ?1,\"lte\": ?2}}}," +
            "{\"wildcard\": {\"parentCategory\": \"*?3*\"}}," +
            "{\"wildcard\": {\"childCategory\": \"*?4*\"}}]}}")
    List<ItemsIndex> searchItemsByNamePriceAndCategory(String name, Integer price1, Integer price2, String parentCategory, String childCategory, Pageable pageable);
}

꽤나 복잡한 쿼리다. 이렇게 복잡한 쿼리를 코드로 작성하다보니 가독성이 너무 떨어진다. JPA에서는 QueryDSL을 통해 보다 깨끗하게 코드를 작성할 수 있지만 아쉽게도 elasticsearch에서는 QueryDSL을 지원하지 않는다.

 

그렇다면 직접 QueryDSL과 유사한 클래스와 메소드를 만들면 된다고 생각했다. 

 

리팩토링 과정

1. 인터페이스 작성

public interface CustomItemsSearchRepository {
    List<ItemsIndex> findItems(String name, Integer price1, Integer price2, String parentCategory, String childCategory, Pageable pageable);
}

먼저 ItemsSearchRepository의 다중상속을 위해 다음과 같이 인터페이스를 작성한다.

 

2. 구현체 작성

 

추가적으로 Spring Data가 제공하는 프록시 객체에 내가 커스텀한 인터페이스의 구현체를 추가하고 싶다면 해당 구현체의 메소드명 뒤에 꼭 Impl이라는 명명 규칙을 지켜줘야 한다. 지키지 않으면 예외를 던진다. (@Repository라는 어노테이션은 안붙여도 상관없지만 리포지토리라는 것을 명시해주고 예외 발생시 DataAccessException을 던지기 때문에 명시해줬다.)

 

@Repository
@RequiredArgsConstructor
public class CustomItemsSearchRepositoryImpl implements CustomItemsSearchRepository {

    private final RestHighLevelClient client;
    private final ObjectMapper objectMapper;

    @Override
    public List<ItemsIndex> findItems(String name, Integer price1, Integer price2, String parentCategory, String childCategory, Pageable pageable) {
        BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery()
                .must(
                        QueryBuilders.boolQuery()
                                .should(QueryBuilders.wildcardQuery("name", "*" + name + "*"))
                                .should(QueryBuilders.wildcardQuery("description", "*" + name + "*"))
                )
                .must(QueryBuilders.rangeQuery("price").gte(price1).lte(price2))
                .must(QueryBuilders.wildcardQuery("parentCategory", "*" + parentCategory + "*"))
                .must(QueryBuilders.wildcardQuery("childCategory", "*" + childCategory + "*"));
        
        SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
        searchSourceBuilder.query(queryBuilder);
        searchSourceBuilder.from((int) pageable.getOffset());
        searchSourceBuilder.size(pageable.getPageSize());
        
        SearchRequest searchRequest = new SearchRequest();
        searchRequest.indices("items");
        searchRequest.source(searchSourceBuilder);

        SearchHits hits;
        try {
            hits = client.search(searchRequest, RequestOptions.DEFAULT).getHits();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        List<ItemsIndex> itemsIndices = new ArrayList<>();
        for (SearchHit hit : hits) {
            itemsIndices.add(objectMapper.convertValue(hit.getSourceAsMap(), ItemsIndex.class));
        }
        return itemsIndices;
    }
}

 

위의 코드는 elasticsearch에서 제공하는 QueryBuilder 클래스를 이용했다. 이렇게 이용해도 되지만 메소드가 하는 일이 너무 많아 보이고 코드의 가독성이 떨어진다. 

 

이때 나는 이 메소드를 QueryDSL과 유사한 방식으로 변경했다.

 

3. 구현체 리팩토링

 

@Service
@RequiredArgsConstructor
public class ElasticSearchService {

    private final RestHighLevelClient client;
    private final ObjectMapper objectMapper;

    public <T> List<T> performSearch(BoolQueryBuilder queryBuilder, String indexName, Pageable pageable, Class<T> resultClass) {
        SearchSourceBuilder searchSourceBuilder = createSearchSourceBuilder(queryBuilder, pageable);
        SearchRequest searchRequest = createSearchRequest(searchSourceBuilder, indexName);
        SearchHits hits = createSearchHits(searchRequest);

        return Arrays.stream(hits.getHits())
                .map(hit -> objectMapper.convertValue(hit.getSourceAsMap(), resultClass))
                .toList();
    }

    private SearchRequest createSearchRequest(SearchSourceBuilder searchSourceBuilder, String indexName) {
        SearchRequest searchRequest = new SearchRequest(indexName);
        searchRequest.source(searchSourceBuilder);

        return searchRequest;
    }

    private SearchHits createSearchHits(SearchRequest searchRequest)  {
        SearchHits hits = null;
        try {
            hits = client.search(searchRequest, RequestOptions.DEFAULT).getHits();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return hits;
    }

    private SearchSourceBuilder createSearchSourceBuilder(BoolQueryBuilder queryBuilder, Pageable pageable) {
        SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
        searchSourceBuilder.query(queryBuilder);
        searchSourceBuilder.from((int) pageable.getOffset());
        searchSourceBuilder.size(pageable.getPageSize());

        return searchSourceBuilder;
    }
}

 

메소드가 하는 역할을 분리하고, Genric을 이용해 Items 뿐만 아닌 다른 클래스도 해당 클래스를 이용할 수 있도록 재사용에 용이하게 작성했다. 

 

구현체 클래스는 아래와 같이 QueryDSL을 이용할 때와 유사하게 쿼리만 작성해도 되도록 깔끔하게 작성할 수 있다.

 

@Repository
@RequiredArgsConstructor
public class CustomItemsSearchRepositoryImpl implements CustomItemsSearchRepository {

    private final ElasticSearchService elasticSearchService;

    @Override
    public List<ItemsIndex> findItems(String name, Integer price1, Integer price2, String parentCategory, String childCategory, Pageable pageable) {
        BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery()
                .must(
                        QueryBuilders.boolQuery()
                                .should(QueryBuilders.wildcardQuery("name", "*" + name + "*"))
                                .should(QueryBuilders.wildcardQuery("description", "*" + name + "*"))
                )
                .must(QueryBuilders.rangeQuery("price").gte(price1).lte(price2))
                .must(QueryBuilders.wildcardQuery("parentCategory", "*" + parentCategory + "*"))
                .must(QueryBuilders.wildcardQuery("childCategory", "*" + childCategory + "*"));

        return elasticSearchService.performSearch(queryBuilder, "items", pageable, ItemsIndex.class);
    }
}

 

4. ItemsSearchRepository의 지저분한 쿼리 제거

 

이제 다중 상속을 통해 내가 구현한 구현체 클래스를 Spring에서 프록시로 생성할 수 있도록 하면 된다.

public interface ItemsSearchRepository extends ElasticsearchRepository<ItemsIndex, Long>, CrudRepository<ItemsIndex, Long>, CustomItemsSearchRepository {
}

이제 ItemsSearchRepository의 findItems라는 메소드는 구현체 클래스에 의해 캡슐화가 이루어지고 보다 객체지향적인 코드가 되었다.

 

마치며

복잡한 코드를 리팩토링했다. 객체지향적인 코드로 리팩토링 할 때마다 느껴지는 것인데 관리해야할 클래스가 많아진다는 단점이 느껴진다. 하지만 이런 단점에도 불구하고 특히 검색과 같이 요구사항이 계속 변경될 가능성이 높은 클래스에 OCP를 지키는 것은 이 단점을 상쇄하기에 충분하다고 생각한다.  

또한 객체지향에 대한 고민을 하면 할수록 자바의 특징 중 캡슐화를 얼마나 잘 구현하는가가 관건이라고 생각한다.