RestHighLevelClient를 사용하여 search after 기능 구현하기

web/Spring|2019. 11. 14. 17:55

https://wedul.site/541에서 search after 기능을 사용해서 검색을 하는 이유를 알아봤었다.

그럼 spring boot에서 RestHighLevelClient를 이용해서 search after를 구현을 해보자.

 

1. Mapping

우선 index가 필요한데 간단하게 상품명과 지역 가격정보들을 가지고 있는 wedul_product 인덱스를 만들어 사용한다.

{
    "settings": {
        "index": {
            "analysis": {
                "tokenizer": {
                    "nori_user_dict": {
                        "type": "nori_tokenizer",
                        "decompound_mode": "mixed",
                        "user_dictionary": "analysis/userdict_ko.txt"
                    }
                },
                "analyzer": {
                    "wedul_analyzer": {
                        "tokenizer": "nori_user_dict",
                        "filter": [
                            "synonym"
                        ]
                    }
                },
                "filter": {
                    "synonym": {
                        "type": "synonym",
                        "synonyms_path": "analysis/synonyms.txt"
                    }
                }
            }
        }
    },
    "mappings": {
        "dynamic": "false",
        "properties": {
            "productId": {
                "type": "keyword"
            },
            "place": {
                "type": "text",
                "fields": {
                    "keyword": {
                        "type": "keyword"
                    }
                }
            },
            "message": {
                "type": "text"
            },
            "query": {
                "type": "percolator"
            },
            "name": {
                "type": "text",
                "analyzer": "wedul_analyzer",
                "fields": {
                    "keyword": {
                        "type": "keyword"
                    }
                }
            },
            "price": {
                "type": "integer"
            },
            "updateAt": {
                "type": "date",
                "format": "epoch_second"
            },
            "createAt": {
                "type": "date",
                "format": "epoch_second"
            }
        }
    }
}

값은 적당하게 3개정도 삽입하였다.

저장되어 있는 초기값.

 

2. 라이브러리 

사용에 필요한 라이브러리들을 gradle을 사용해서 추가한다. 

plugins {
    id 'org.springframework.boot' version '2.2.0.RELEASE'
    id 'io.spring.dependency-management' version '1.0.8.RELEASE'
    id 'java'
}

ext {
    set('elasticsearch.version', '7.4.2')
}

group = 'com.wedul'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
    mavenCentral()
    maven { url "https://plugins.gradle.org/m2/" }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    compile group: 'org.apache.commons', name: 'commons-lang3', version: '3.9'
    compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.10.0'
    annotationProcessor 'org.projectlombok:lombok'
    testCompile group: 'org.mockito', name: 'mockito-all', version:'1.9.5'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // gson
    compile group: 'com.google.code.gson', name: 'gson', version: '2.8.6'

    // elasticsearch
    compile 'org.elasticsearch.client:elasticsearch-rest-high-level-client:7.4.2'
    compile group: 'org.elasticsearch', name: 'elasticsearch', version: '7.4.2'
}

 

 

3.RestHighLevelClient configuration

restHighLevelClient 사용을 위한 Configuration 파일을 만들어주는데 id와 pw는 AppConfig라는 별도 properties를 관리하는 bean에서 받아서 사용하는데 base64로 인코딩되어있어서 이를 decoding후 사용한다. (부족한 코드는 글 맨 아래있는 github 링크 참조)

package com.wedul.study.common.config;

import com.wedul.study.common.util.EncodingUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestClientBuilder;
import org.elasticsearch.client.RestHighLevelClient;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;

/**
 * study
 *
 * @author wedul
 * @since 2019-11-07
 **/
@Configuration
@Slf4j
public class ElasticsearchClientConfig implements FactoryBean<RestHighLevelClient>, InitializingBean, DisposableBean {

    @Autowired
    AppConfig appConfig;

    private RestHighLevelClient restHighLevelClient;

    @Override
    public RestHighLevelClient getObject() {
        return restHighLevelClient;
    }

    @Override
    public Class<?> getObjectType() {
        return RestHighLevelClient.class;
    }

    @Override
    public void destroy() {
        try {
            if (null != restHighLevelClient) {
                restHighLevelClient.close();
            }
        } catch (Exception e) {
            log.error("Error closing ElasticSearch client: ", e);
        }
    }

    @Override
    public boolean isSingleton() {
        return false;
    }

    @Override
    public void afterPropertiesSet() {
        restHighLevelClient = buildClient();
    }

    private RestHighLevelClient buildClient() {
        try {
            String id = EncodingUtil.decodingBase64(appConfig.getElasticsearchConfig().getId());
            String pw = EncodingUtil.decodingBase64(appConfig.getElasticsearchConfig().getPw());

            // 계정 설정
            final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
            credentialsProvider.setCredentials(AuthScope.ANY,
                new UsernamePasswordCredentials(id, pw));

            // client 설정
            RestClientBuilder builder = RestClient.builder(
                new HttpHost(appConfig.getElasticsearchConfig().getIp(),
                    appConfig.getElasticsearchConfig().getPort(), "http"))
                .setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));

            restHighLevelClient = new RestHighLevelClient(builder);
        } catch (Exception e) {
            log.error(e.getMessage());
        }
        return restHighLevelClient;
    }

}

 

 

4. Handler 추가

자주 사용되는 Elasticsearch 문법을 처리하기 위해서 만들어 놓은 ElasticsearchHandler에 search after에 사용 될 메소드를 추가한다. search after는 sort 필드가 없으면 사용이 불가능 하기 때문에 sort 필드가 없는 경우 에러를 전달한다.

public static SearchSourceBuilder searchAfter(Map<String, SortOrder> sortFields, QueryBuilder query, Object[] searchAfter, int size) {
    return searchAfterBuilder(sortFields, query, searchAfter,  size);
}

public static SearchSourceBuilder searchAfter(Map<String, SortOrder> sortFields, QueryBuilder query, Object[] searchAfter) {
    return searchAfterBuilder(sortFields, query, searchAfter, 20);
}

private static SearchSourceBuilder searchAfterBuilder(Map<String, SortOrder> sortFields, QueryBuilder query, Object[] searchAfter, int size) {
    SearchSourceBuilder builder = new SearchSourceBuilder();

    if (CollectionUtils.isEmpty(sortFields)) {
        throw new InternalServerException("잘못된 필드 요청입니다.");
    }

    sortFields.forEach((field, sort) -> {
        builder.sort(field, sort);
    });
    builder.size(size);
    builder.query(query);

    if (ArrayUtils.isNotEmpty(searchAfter)) {
        builder.searchAfter(searchAfter);
    }

    return builder;
}

 

 

5. 기능 구현

위의 기능들을 이용해서 실제로 구현해보자. productService와 productRepository 클래스를 통해서 구현하였다. 자세한 설명없이 간단하기 때문에 소스를 보면 알 수 있다. 

 

우선 최종 결과물로 사용될 클래스는 ElasticResult인데 다음과 같이 현재 요청이 마지막인지 표시하는 isLast와 다음 요청을 위해 보내줘야 하는 cursor값과 결과값 전체 total과 결과 리스트 list 필드가 존재한다.

@Builder
@Data
public class ElasticResult<T extends ElasticsearchDto> {

    private boolean isLast;
    private long total;
    private List<T> list;
    private Object[] cursor;

}

 

그 다음 service로직을 통해 결과를 얻어서 위 ElasticResult에 결과를 담아보자. products 메서드는 요청을 받아서 elasticsearch에 실제 조작요청을 하는 productRepository에 동작을 요청하고 값을 받아서 처리하는 메서드이다. 그리고 extractProductList는 결과값에서 ProductDto 값을 뽑아내는 메서드이다.

public ElasticResult<ProductDto> products(String index, Object[] searchAfter, int size) throws IOException {
    SearchResponse searchResponse = productRepository.products(index, searchAfter, size);
    SearchHits searchHits = searchResponse.getHits();
    int hitCnt = searchHits.getHits().length;
    boolean isLast = 0 == hitCnt || size > hitCnt;

    return ElasticResult.<ProductDto>builder()
        .cursor(isLast ? null : searchHits.getHits()[hitCnt - 1].getSortValues())
        .isLast(isLast)
        .list(extractProductList(searchHits))
        .total(searchHits.getTotalHits().value)
        .build();
}

private List<ProductDto> extractProductList(SearchHits searchHits) {
    List<ProductDto> productList = new ArrayList<>();

    searchHits.forEach(hit -> {
        Map<String, Object> result = hit.getSourceAsMap();

        productList.add(ProductDto.builder()
            .name(String.valueOf(result.get("name")))
            .productId(String.valueOf(result.get("productId")))
            .place(String.valueOf(result.get("place")))
            .price(Integer.valueOf(result.get("price").toString()))
            .updateAt(Long.valueOf(result.get("updateAt").toString()))
            .createAt(Long.valueOf(result.get("createAt").toString())).build());
    });

    return productList;
}

 

그리고 마지막으로 es에 직접적으로 콜을 하는 productRepository 이다. 여기서 정렬 키워드는 name과 place를 사용한다.

public SearchResponse products(String index, Object[] searchAfter, int size) throws IOException {
    SearchRequest searchRequest = new SearchRequest(index);
    Map<String, SortOrder> sorts = new HashMap<String, SortOrder>() {
        {
            put("name.keyword", SortOrder.DESC);
            put("place.keyword", SortOrder.DESC);
        }
    };

    SearchSourceBuilder searchSourceBuilder = ElasticsearchHandler.searchAfter(sorts, QueryBuilders.matchAllQuery(), searchAfter, size);
    searchRequest.source(searchSourceBuilder);
    return restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
}

 

 

6. 테스트

그럼 위에 내용이 잘 구현되었는지 테스트를 해보자. 총 3개의 데이터가 있는데 이 데이터를 1개씩 search after를 통해서 값을 받아서 저장하고 한번에 출력하도록 해보자.

@Test
@DisplayName("search after")
public void searchAfter() throws IOException {
    ElasticResult<ProductDto> result = productService.products(PRODUCT_INDEX, new Object[]{}, 1);
    List<ProductDto> productDtos = new ArrayList<>();

    while(result != null && !result.isLast()) {
        productDtos.addAll(result.getList());
        result = productService.products(PRODUCT_INDEX, result.getCursor(), 1);
    }
    productDtos.addAll(result.getList());

    productDtos.forEach(productDto -> {
        System.out.println("이름 : " + productDto.getName());
        System.out.println("장소 : " + productDto.getPlace());
    });
}

결과는 정상적으로 3가지 모두 잘 출력되는 걸 알 수있다.

 

우선 기능 구현을 해보기 위해서 진행하였는데 더 다듬어야 할 것같다.

자세한 소스는 github참조

댓글()

객체지향의 사실과 오해 3 ~ 4장

Book Review|2019. 11. 5. 18:12
객체지향의 사실과 오해
국내도서
저자 : 조영호
출판 : 위키북스 2015.06.17
상세보기

3. 타입과 추상화

추상화는 어떤 내용을 명확하게 이해하기 위해 특정 절차나 물체를 의도적으로 생략하거나 감춤으로써 복잡도를 극복하는 방법이다. 그 방법으로 사물간의 차이점을 빼고 공통점을 일반화하여 단순하게 만들고 불필요한 세부사항을 제거하여 단순화 하는 것이다. 즉 복잡성을 이해하기 쉬운 수준으로 단순화하는 것이다.

 

이런 추상화 개념을 이용해서 객체지향에 대입해서 생각해보자.

 

그룹화

각 객체는 명확한 경계를 가지고 서로 구별할 수 있는 구체적인 사람이나 사물을 말한다.

여러 객체에서 공통적인 요소를 뽑아서 그룹화 하면 복잡성을 효과적으로 감소 시킬 수 있다. 예를 들어 토끼, 강아지 등을 동물이라는 공통의 그룹으로 묶고 사과, 딸기 등을 과일이라는 공통의 그룹으로 묶으면 차이점을 빼고 공통점만을 취해 단순화 할 수 있는 추상화의 일종이다.

 

객체지향에서 객체 개개별로 구별하면 좋지만 그렇기에는 너무 이해하기 어려워지기 때문에 공통적인 특성을 뽑아서 하나로 묶는데 이를 묶기위한 공통적인 기반을 개념(concept)라고 한다. 이런 개념을 이용해서 객체를 여러 그룹으로 분류(classification) 할 수 있다. 이렇게 분류가 된 객체를 그룹의 일원이 되었다고 하여 인스턴스(instance)라고 한다.

 

객체 분류의 개념

그럼 객체에서 그룹으로 분류하기 위해 사용되는 개념은 symbol (이름이나 명칭), intension (개념의 정의), extension (개념에 속한 객체들의 집합) 3가지의 관점을 가지고 있다.

 

토끼라는 객체는 동물이라는 개념의 symbol을 사용하였고 4개의 다리와 척추동물이라는 intension을 사용하였고 같이 소속된 돼지나 강아지들을 보면서 extension을 확인하였다. 이로써 토끼는 동물이라는 그룹으로 분류될 수 있게 되었다.

 

객체 분류의 단점

잘못된 객체 분류를 통해 그룹에 이상한 객체가 소속되면 유지보수가 어렵고 변화에 대처하기가 어려워진다.

위의 내용을 종합해보면 객체를 분류하는 건 공통의 요소를 뽑아서 일반화하고 단순화하여 복잡성을 극복하는 작업이다.

 

 

타입

타입은 개념(concept)와 동일한 의미로써 공통점을 기반으로 객체를 묶기위한 틀이다. 객체에서 가장 중요한건 어떠한 행동을 하느냐가 가장 중요하고 그에 따라 타입이 결정된다. 내부에서 표현하는 방식이 서로 다르더라도 행동이 같다면 둘의 타입은 같을 수 있다.

Animal animal1 = new Dog(); 
Animal animal2 = new Cat(); 

animal1.eat(); 
animal2.eat();

위 두개의 객체의 내부 행동은 다르지만 먹는다는 행동이 같기에 같은 타입을 가질 수 있다. 이를 다형성이라고 한다. 이를 통해서 외부의 행동만 제공하고 내부적으로 상세 내용을 숨기는 캡슐화 원칙을 수행 가능하다.

 

이처럼 객체에 타입을 지정할 때는 데이터가 아니라 행동에 따라서 지정 해야 하는 책임 주도 설계를 해야 한다.

 

일반화/특수화 (supertype/subtype)

객체 타입에는 일반적으로 포함되는 타입과 특수한 타입 두 가지로 나뉜다. 강아지로 예를 들어보면 강아지가 짖는건 일반적인 강아지 타입의 특징이다. 하지만 훈련된 애완견의 경우 일반 강아지와 다른 행동이 가능하다. 여기서 강아지라는 특징은 일반적인 것이고 애완견은 특수화된 타입을 의미한다. 여기서 알 수 있듯이 특수화와 일반화의 차이는 행동을 통해서 기반 된다. 특수화된 타입은 일반화 타입의 특징을 포함하고 있으며 일반적인 특징보다 더 적은 모집군을 가지고 있다.

 

이를 슈퍼타입, 서브타입이라고도 하는데 서브타입은 슈퍼티입의 행위에 추가적으로 특수한 행위를 추가한 것이기 때문에 슈퍼타입의 행동은 서브 타입에게 자동으로 상속된다.

 

3장 결론

  • 타입을 통해서 객체를 분류하는데 이 타입은 객체의 행동을 통해서 정해진다. 이렇게 분류한 타입을 이용하여 클래스를 이용하여 프로그램화 한다.

  • 추상화는 객체의 특징을 보고 일반화/특수화하여 불필요한 부분을 제거하고 단순하게 만들어서 분류하는 것이다.

 

4. 역할, 책임, 협력

객체지향에서 협력은 가장 중요한 프레임이다. 협력에 초점을 맞춰서 설계를 하게 되면 저절로 객체들의 행동이 드러나고 그에따라 상태가 결정된다. 객체의 모양을 빚는 것은 객체가 참여하는 협력으로 어떤 협력에 참여하는지가 객체에 필요한 행동을 결정하고, 필요한 행동이 객체의 상태를 결정한다.

 

책임

객체들간의 협력에서 각 객체가 적절한 행동을 해줄 책임을 가지고 있다. 책임은 객체지향의 중요한 재료로써 눙숙한 프로그램 설계는 객체에게 적절하게 책임을 부여해주는 것을 의미한다.

 

이런 책임을 분류하는 기준은 외부에 제공해줄 수 있는 정보와 외부에 제공해 줄 수 있는 서비스에 대한 정의이다. 이런 책임을 바탕으로 객체지향에서 객체는 어떤 객체에 요청 메시지에 따라 어떤 책임 행동을 해줄 수 있는지 결정하는 것이 포인트이다.

 

역할

역할은 객체지향에서 재사용이 가능하고 유연한 설계를 가지는데 중요한 구성요소이다. 동일한 역할을 동일한 책임을 수행할 수 있다는 뜻이다. 또한 동일한 역할을 하는 객체를 추상화하여 유연성을 가지게 하고 재사용성과 단순하게 객체지향을 설계할 수 있다.

 

이 처럼 역할이 추상화되면 상황에 따라 여러 객체를 대체하면서 사용할 수 있어 유연성이 증가한다.

 

협력에 따라 흐르는 객체의 책임

- 설계 시 객체들 사이에 주고받을 요청과 응답의 흐름을 깔끔하게 만들고 (협력 설계) 그 객체들이 협력에 참여하여 자신의 역할을 책임있게 수행해야 한다.

- 즉 객체간의 관계를 먼저 만들고 그 다음 역할과 책임을 설계하라.

 

 

출처 : 객체지향의 사실과 오해 (조영호)

댓글()

JPA 다양한 Join 방법 정리 (N+1, queryDSL, fetch join)

web/Spring|2019. 11. 4. 20:31

JPA를 사용하다 보면 join을 할 때가 많아진다. join을 어떠한 방법으로 하느냐에 따라서 수행되는 쿼리가 달라지고 성능에 문제가 발생하는 경우도 종종있다.

 

그래서 다양한 방식의 join 방식을 알아보고 방식에 따라 작업을 진행해 보자.

우선 사용될 entity 두 개를 설명하면 다음과 같다.

@Getter
@Entity
@Table(name = "wedul_classes")
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor
@Builder
public class WedulClasses extends CommonEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long wedulClassesId;

    @OneToMany(mappedBy = "wedulClasses", fetch = FetchType.LAZY)
    private Set<WedulStudent> wedulStudentList = new LinkedHashSet<>();

    private String classesName;

    private String classesAddr;

}

@Getter
@Entity
@Table(name = "wedul_student")
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor
@Builder
public class WedulStudent extends CommonEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long wedulStudentId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "wedul_classes_id")
    @JsonBackReference
    private WedulClasses wedulClasses;

    private String studentName;

    private int studentAge;

    @Enumerated(value = EnumType.STRING)
    private StudentType studentType;

}

이 두 엔티티는 class와 student의 관계로 1대 N의 관계를 가지고 있다.

이 과정에서 사용될 데이터는 임의로 추가했고 다음과 같다.

 

wedul_classe 테이블의 데이터

wedul_student 테이블의 데이터

사용 쿼리 툴) tadpole docker version

 

 

그럼 이 entity를 이용해서 데이터를 조인하여 조회해보자.

 

1. 단순 조회

우선 첫 번째로 JpaRepository 인터페이스 사용 시 기본적으로 제공하는 findAll을 사용해보자.

@Repository
public interface WedulClassesRepository extends JpaRepository<WedulClasses, Long> {

    Optional<WedulClasses> findByClassesName(String classesName);

}

이를 사용하여 데이터를 조회해보면 사용 되는 쿼리는 다음과 같다.

-- classes 목록을 조회하는 쿼리
select
    wedulclass0_.wedul_classes_id as wedul_cl1_0_
    ,wedulclass0_.create_at as create_a2_0_
    ,wedulclass0_.update_at as update_a3_0_
    ,wedulclass0_.classes_addr as classes_4_0_
    ,wedulclass0_.classes_name as classes_5_0_
  from
    wedul_classes wedulclass0_
;


-- 아래 쿼리들은 wedul_classes_id 개수별로 조회되는 쿼리
select
    wedulstude0_.wedul_classes_id as wedul_cl7_1_0_
    ,wedulstude0_.wedul_student_id as wedul_st1_1_0_
    ,wedulstude0_.wedul_student_id as wedul_st1_1_1_
    ,wedulstude0_.create_at as create_a2_1_1_
    ,wedulstude0_.update_at as update_a3_1_1_
    ,wedulstude0_.student_age as student_4_1_1_
    ,wedulstude0_.student_name as student_5_1_1_
    ,wedulstude0_.student_type as student_6_1_1_
    ,wedulstude0_.wedul_classes_id as wedul_cl7_1_1_
  from
    wedul_student wedulstude0_
  where
    wedulstude0_.wedul_classes_id = ?
;
select
    wedulstude0_.wedul_classes_id as wedul_cl7_1_0_
    ,wedulstude0_.wedul_student_id as wedul_st1_1_0_
    ,wedulstude0_.wedul_student_id as wedul_st1_1_1_
    ,wedulstude0_.create_at as create_a2_1_1_
    ,wedulstude0_.update_at as update_a3_1_1_
    ,wedulstude0_.student_age as student_4_1_1_
    ,wedulstude0_.student_name as student_5_1_1_
    ,wedulstude0_.student_type as student_6_1_1_
    ,wedulstude0_.wedul_classes_id as wedul_cl7_1_1_
  from
    wedul_student wedulstude0_
  where
    wedulstude0_.wedul_classes_id = ?
;
select
    wedulstude0_.wedul_classes_id as wedul_cl7_1_0_
    ,wedulstude0_.wedul_student_id as wedul_st1_1_0_
    ,wedulstude0_.wedul_student_id as wedul_st1_1_1_
    ,wedulstude0_.create_at as create_a2_1_1_
    ,wedulstude0_.update_at as update_a3_1_1_
    ,wedulstude0_.student_age as student_4_1_1_
    ,wedulstude0_.student_name as student_5_1_1_
    ,wedulstude0_.student_type as student_6_1_1_
    ,wedulstude0_.wedul_classes_id as wedul_cl7_1_1_
  from
    wedul_student wedulstude0_
  where
    wedulstude0_.wedul_classes_id = ?
;

쿼리를 자세히 보면 알겠지만 wedul_classes를 조회하는 쿼리와 그 wedul_classes 개수만큼 쿼리가 실행되는것을 볼 수 있다.

많이 들어 봤을 법한 N+1 문제가 발생한 것이다.

이 방식으로 쿼리 수행 시 N번의 쿼리가 발생해야 하기에 데이터 수만큼 쿼리가 실행되는 안좋은 부담을 안고 가야해서 좋지 않다.

 

2. left fetch join

위의 1번의 N+1 문제 해결로 고안된 방법 중 하나가 fetch join이다. 나는 left join을 하고자 하기에 left fetch join을 시도해보자. 우선 사용된 코드는 다음과 같다.

@Repository
public interface WedulClassesRepository extends JpaRepository<WedulClasses, Long> {
    @Query(value = "select DISTINCT c from WedulClasses c left join fetch c.wedulStudentList")
    List<WedulClasses> findAllWithStudent();
}

distinct가 붙은 이유는 카티션곱에 의해서 여러개의 결과값이 발생해 버리기 때문에 추가하였다.

그럼 사용된 쿼리도 확인해보자.

select
    distinct wedulclass0_.wedul_classes_id as wedul_cl1_0_0_
    ,wedulstude1_.wedul_student_id as wedul_st1_1_1_
    ,wedulclass0_.create_at as create_a2_0_0_
    ,wedulclass0_.update_at as update_a3_0_0_
    ,wedulclass0_.classes_addr as classes_4_0_0_
    ,wedulclass0_.classes_name as classes_5_0_0_
    ,wedulstude1_.create_at as create_a2_1_1_
    ,wedulstude1_.update_at as update_a3_1_1_
    ,wedulstude1_.student_age as student_4_1_1_
    ,wedulstude1_.student_name as student_5_1_1_
    ,wedulstude1_.student_type as student_6_1_1_
    ,wedulstude1_.wedul_classes_id as wedul_cl7_1_1_
    ,wedulstude1_.wedul_classes_id as wedul_cl7_1_0__
    ,wedulstude1_.wedul_student_id as wedul_st1_1_0__
  from
    wedul_classes wedulclass0_
      left outer join wedul_student wedulstude1_
        on wedulclass0_.wedul_classes_id = wedulstude1_.wedul_classes_id

left join을 해서 한번에 데이터를 가져올 수 있는 걸 확인 할 수 있지만 아쉽게도 Lazy로 데이터를 가져오지 못하고 Eager로 가져와야 한다.

 

3. EntityGraph

이제 3번째 방식으로 entity graph를 사용하여 실행시켜보자. 코드는 아래와 같다.

@EntityGraph(attributePaths = "wedulStudentList")
@Query("select c from WedulClasses c")
Page<WedulClasses> findEntityGraph(Pageable pageable);

실행되는 쿼리는 다음과 같아서 2번과 동일하다. (page를 사용한 것만 차이)

select
    wedulclass0_.wedul_classes_id as wedul_cl1_0_0_
    ,wedulstude1_.wedul_student_id as wedul_st1_1_1_
    ,wedulclass0_.create_at as create_a2_0_0_
    ,wedulclass0_.update_at as update_a3_0_0_
    ,wedulclass0_.classes_addr as classes_4_0_0_
    ,wedulclass0_.classes_name as classes_5_0_0_
    ,wedulstude1_.create_at as create_a2_1_1_
    ,wedulstude1_.update_at as update_a3_1_1_
    ,wedulstude1_.student_age as student_4_1_1_
    ,wedulstude1_.student_name as student_5_1_1_
    ,wedulstude1_.student_type as student_6_1_1_
    ,wedulstude1_.wedul_classes_id as wedul_cl7_1_1_
    ,wedulstude1_.wedul_classes_id as wedul_cl7_1_0__
    ,wedulstude1_.wedul_student_id as wedul_st1_1_0__
  from
    wedul_classes wedulclass0_
      left outer join wedul_student wedulstude1_
        on wedulclass0_.wedul_classes_id = wedulstude1_.wedul_classes_id
  order by
    wedulclass0_.update_at desc;

 

4. QueryDSL

Querydsl은 정적 타입을 이용해서 SQL과 같은 쿼리를 사용할 수 있도록 해주는 프레임워크로 HQL쿼리를 실행하게 도와준다.

설정 방식은 gradle 5 기준으로 다음과 같다.

plugins {
    id 'org.springframework.boot' version '2.2.0.RELEASE'
    id 'io.spring.dependency-management' version '1.0.8.RELEASE'
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
    id 'java'
}

group = 'com.wedul'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
    maven { url "https://plugins.gradle.org/m2/" }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compile group: "org.flywaydb", name: "flyway-core", version: '5.2.4'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'mysql:mysql-connector-java'
    compile group: 'org.apache.commons', name: 'commons-lang3', version: '3.9'
    compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.10.0'
    annotationProcessor 'org.projectlombok:lombok'
    testCompile group: 'org.mockito', name: 'mockito-all', version:'1.9.5'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // query dsl
    compile("com.querydsl:querydsl-apt")
    compile("com.querydsl:querydsl-jpa")
}

// querydsl 적용
def querydslSrcDir = 'src/main/generated'

querydsl {
    library = "com.querydsl:querydsl-apt"
    jpa = true
    querydslSourcesDir = querydslSrcDir
}

compileQuerydsl{
    options.annotationProcessorPath = configurations.querydsl
}

configurations {
    querydsl.extendsFrom compileClasspath
}

sourceSets {
    main {
        java {
            srcDirs = ['src/main/java', querydslSrcDir]
        }
    }
}

그리고 QueryDsl 사용을 위해 QueryDslRepositorySupport를 상속받아서 사용할 수 있는데 마지막에 distinct를 사용한 것은 2번 fetch 조인의 이유와 동일하다.

@Repository
public class WedulClassesQueryDsl extends QuerydslRepositorySupport {

    public WedulClassesQueryDsl() {
        super(WedulClasses.class);
    }

    public List<WedulClasses> findAllWithStudent() {
        QWedulClasses wedulClasses = QWedulClasses.wedulClasses;
        QWedulStudent wedulStudent = QWedulStudent.wedulStudent;

        return from(wedulClasses)
            .leftJoin(wedulClasses.wedulStudentList, wedulStudent)
            .fetchJoin()
            .distinct()
            .fetch();
    }

}

그럼 마찬가지로 실행되는 쿼리를 확인해보자.

select
    distinct wedulclass0_.wedul_classes_id as wedul_cl1_0_0_
    ,wedulstude1_.wedul_student_id as wedul_st1_1_1_
    ,wedulclass0_.create_at as create_a2_0_0_
    ,wedulclass0_.update_at as update_a3_0_0_
    ,wedulclass0_.classes_addr as classes_4_0_0_
    ,wedulclass0_.classes_name as classes_5_0_0_
    ,wedulstude1_.create_at as create_a2_1_1_
    ,wedulstude1_.update_at as update_a3_1_1_
    ,wedulstude1_.student_age as student_4_1_1_
    ,wedulstude1_.student_name as student_5_1_1_
    ,wedulstude1_.student_type as student_6_1_1_
    ,wedulstude1_.wedul_classes_id as wedul_cl7_1_1_
    ,wedulstude1_.wedul_classes_id as wedul_cl7_1_0__
    ,wedulstude1_.wedul_student_id as wedul_st1_1_0__
  from
    wedul_classes wedulclass0_
      left outer join wedul_student wedulstude1_
        on wedulclass0_.wedul_classes_id = wedulstude1_.wedul_classes_id

애도 2번, 3번과 동일한 쿼리가 작성되는 걸 확인할 수 있다.

기본적으로 단순하게 다대일 데이터를 가져오려고 하면 N+1 문제가 발생할 수 있기 때문에 조심해야하고 이를 해결하기 위해서는 다양한 방식의 문제 해결 방식이 있는걸 확인할 수 있었다.

무엇이 가장 좋은지는 본인이 판단하거나 상황에 맞게 사용하면 좋을 거 같다.

댓글()