본문 바로가기

Trouble Shooting/Data API

SELECT + JOIN 개선에 대한 고찰

 

1. 문제 인식

단계 : 문제가 발생했음을 인식하고 해당 문제를 명확하게 기술한다.

 

 

 

1-1. API 설명

 

현재, 하나의 API를 제작 중에 있다.

API : 가게이름을 받고, 그 가게의 쉬는날을 반환한다.

 

Entity는 아래와 같다. 

1. StoreRestDaysEntity (부모 엔터티)

- Days Id (Identification)

- Store Name

- Set<StoreRestDaysMapEntity>

- Created At

- Updated At

 

2. StoreRestDaysMapEntity (자식 엔터티)

- Day Id (Identification)

- StoreRestDayMapEntity - (Days Id (FK))

- Date

 

위의 엔터티에서 부모 엔터티는 StoreRestDaysEntity이고 자식 엔터티는 StoreRestDaysMapEntity인 구조이다.

이 때, 부모의 특정 storeName에 맞는 자식 엔터티의 date를 찾고 싶다. 즉, 가게의 쉬는날을 반환하는 API이다.

 

 

기존의 코드

public List<String> getDayOff(String storeName) {
    logger.info("[StoreRestDayDAOImpl] get rest days(쉬는날 반환) 호출");
    List<StoreRestDaysEntity> check = queryFactory.select(storeRestDaysEntity).distinct()
        .from(storeRestDaysEntity).leftJoin(storeRestDaysEntity.childSet, storeRestDaysMapEntity)
        .fetchJoin().where(storeRestDaysEntity.storeName.eq(storeName)).fetch();
    List<String> result = new ArrayList<>();
    if (!check.isEmpty()) {
      for (StoreRestDaysEntity srde : check) {
        for (StoreRestDaysMapEntity srdme : srde.getChildSet()) {
          result.add(srdme.getDate());
        }
      }
    }
    return result;
}

 

 

1-2. 문제점

  • 1. 결과를 반환하기까지 시간이 너무 오래걸린다.
  • 2. 코드가 간결하지 않고 복잡하다.
  • 3. 불필요하게 가져오는 데이터가 많다 -> 쉬는날(date)만 필요한데 너무 많은 데이터를 가져온다.

 

 

2. 현상 분석

단계 :  문제가 발생한 상황을 자세히 분석하고, 문제의 범위와 영향을 확인한다.

- 위의 정의한 문제가 발생하기까지 상황을 정리해보고자 한다.

 

 

2-1. 시간 측정 (Apache Jmeter)

- Apache Jmeter를 활용하였다.

- 1000개의 Thread Requests가 기준이다.

- 1초안에 1000개의 요청으로 설정하였고 반복횟수는 1번이다.

- 1초안에 1000명의 유저가 해당 API를 접속했다는 의미이다.

 

 

2-2. 코드 로직 설명

- 1. Left Fetch Join을 통해 두 테이을 조인하고 자식의 결과를 부모에 담아서(fetch Join) 반환한다.

- 2. 받아온 결과가 null이 아니라면 부모테이블들의 자식테이블들의 데이터를 완전탐색하여 Date를 리스트에 담는다.

- 3. 결과 반환

 

 

2-3. 데이터 양

- 부모테이블의 엔터티 전체, 자식테이블의 엔터티 전체를 불러온다. 겹치는 칼럼(days_id)는 1회 불러온다.

(Parent.storeName, Parent.createdAt, Parent.updatedAt, Child.daysId, Child.dayId, Child.date)

- 쿼리문

Hibernate: 
    select
        distinct s1_0.days_id,
        c1_0.days_id,
        c1_0.day_id,
        c1_0.date,
        s1_0.created_at,
        s1_0.store_name,
        s1_0.updated_at 
    from
        store_rest_days s1_0 
    left join
        store_rest_days_map c1_0 
            on s1_0.days_id=c1_0.days_id 
    where
        s1_0.store_name=?

 

 

 

3. 원인 파악

단계 : 문제의 원인을 추정하고 가능한 원인을 찾기 위해 검토 및 실행을 진행한다.

 

 

3-1. 시간 문제의 원인

- 1. 정돈되지 않은 쿼리

- 2. 정돈되지 않은 쿼리로 인한 많은 데이터 양

- 3. 많은 데이터 양에 의한 많은 데이터 정제를 위한 로직

- 4. 정제되지 않은 로직으로 인한 많은 코드

 

"연속적으로 서로에게 영향을 끼치고 있다."

 

 

 

3-2. 코드 로직이 복잡한 원인

- 우선적으로 데이터를 너무 많이 가져오고 그 데이터를 정리하기 위한 로직이 또 소요되고 이러한 복합적인 결과로 코드가 길어지고 복잡해지고 있다. 

 

 

 

3-3. 데이터양이 많은 원인

- 잘못된 쿼리 설계가 가장 큰 원인이다. 분명히 Date만 가져올 수 있는 방법이 존재하는데 쿼리문을 통해 너무 많은 데이터를 가져온다.

- 조인의 주체또한 자식 테이블이 되어야하는데 부모테이블을 가져온다. 전반적으로 너무나 비효율적이고 코드를 짤 때, 올바른 설계가 아닌 구현에 목적에 둔것에 문제가 있다고 생각이 든다.

 

 

 

 

4. 해결 방법

단계 : 원인을 바탕으로 가능한 해결 방법을 도출한다.

 

 

4-1. 방법 (개념)

- 쿼리문을 다시 작성해 Date만 가져오는 쿼리문을 작성할 것

-> 쿼리문을 통해 Date만 가져온다면 데이터 양은 자동으로 줄어들게 됨. 

-> 또한 데이터 양이 줄어들면서 데이터 정제를 위한 로직은 필요가 없어짐.

-> 시간복잡도는 DB에만 요구되며, 서버내의 동작에서는 필요가 없어짐.

 

 

4-2. 방법 (적용)

- 쿼리문 작성시 우선, 부모테이블이 아닌 자식테이블이 조인의 주체가 되게 만든다.

- 이 후, 조인을 사용하되 Date 칼럼만 가져온다.

 

 

4-3. 방법 (고찰)

 

우선적으로, 위에서 설명한 방식을 여러가지 방식들로 작성해 비교해보려고 한다.

조건 : 로직을 사용할 필요가 없어야 하므로 한번에 쿼리문으로 원하는 결과를 한번에 반환한다.

 

 

1~4번의 과정에서 기본적인 쿼리문은 동일하다.

Hibernate: 
    select
        s1_0.date 
    from
        store_rest_days_map s1_0 
    join
        store_rest_days s2_0 
            on s2_0.days_id=s1_0.days_id 
    where
        s2_0.store_name=?

 

1. Spring JPA로 작성해보기 (기본 메서드만 이용하는 경우)

- Spring JPA는 기본적으로 반환을 Entity 단위로 반환을 한다.

- 만약 date만 받아아고 싶은 경우 Entity를 받아서 date를 얻기위한 정제 작업이 필요하다.

- 따라서, 조건을 충족시키지 못한다.

 

 

2. Query DSL로 작성해보기

- Left Join을 하되 date만 불러서 바로 반환한다.

- 장점 : 오타 발생 안함, 컴파일 시 오류 발견 가능

@Override
public List<String> getDayOff(String storeName) {
return queryFactory
    .select(storeRestDaysMapEntity.date)
    .from(storeRestDaysMapEntity)
    .leftJoin(storeRestDaysMapEntity.storeRestDaysEntity, storeRestDaysEntity)
    .where(storeRestDaysEntity.storeName.eq(storeName))
    .fetch();
}

 

 

3. @Query JPQL로 작성해보기

- 객체 지향적인 부분으로 작성하며 작성시 객체 지향적인 내용에 대해서는 컴파일시 오류 발견이 가능하다.

@Query("SELECT srdm.date FROM StoreRestDaysMapEntity srdm JOIN srdm.storeRestDaysEntity srd WHERE srd.storeName = :storeName")
List<String> findDateByStoreName(@Param("storeName") String storeName);

 

 

4. @Query Native Query로 작성해보기

- 직접적으로 DB 쿼리문을 사용하기에 오타 발견 불가능

@Query(value = "SELECT srdm.date FROM store_rest_days_map srdm JOIN store_rest_days srd ON srdm.days_id = srd.days_id WHERE srd.store_name = :storeName", nativeQuery = true)
List<String> findDateByStoreName(@Param("storeName") String storeName);

 

 

5. 2~4번 방벙의 속도 비교

- 위의 방법과 동일하게 1000번의 Thread Requests를 기준으로 한다.

- 즉, 1초안에 1000명이 해당 API에 요청을 보낸다.

- 모두 동일한 조건을 사용하게 하기 위해 캐시는 사용하지 않았다.

- 서버도 각각의 방식 실행에 대해서 좀 더 객관성을 주기 위해 서버를 끄고 킴을 반복하였다.

 

- 기존 

- Query DSL

- Spring JPA @Query with JPQL

- Spring JPA @Query with Native Query

 

 

 

5. 해결 적용

단계 : 도출된 해결 방법을 적용하여 문제를 해결한다.

 

상대적으로 Query DSL을 사용했을 때 속도가 가장 빨랐다.

따라서 Query DSL을 사용하기로 결정을 했다

선정 기준은 데이터 양이 많아졌다고 가정했을 때,

데이터 정제 로직 -> 좀 더 복잡해짐

데이터가 현재 많이 존재하지 않지만 양이 많아질수록 시간이 직선으로 증가한다고 가정한다.

 

 

Query DSL

@Override
public List<String> getDayOff(String storeName) {
return queryFactory
    .select(storeRestDaysMapEntity.date)
    .from(storeRestDaysMapEntity)
    .leftJoin(storeRestDaysMapEntity.storeRestDaysEntity, storeRestDaysEntity)
    .where(storeRestDaysEntity.storeName.eq(storeName))
    .fetch();
}

 

 

 

 

 

6. 결과 확인

단계 : 해결 방법이 문제를 해결했는지 확인하고, 필요한 조치를 취한다.

 

 

코드 양 : 확실히 줄었다.

가져오는 데이터 양 : 7개 -> 1개

시간 : 줄은것으로 확인 (평균 : 3297ms -> 2838ms)

 

 

 

추가적으로 고려하면 좋을 부분

 

결과적으로는 Query DSL이 가장 성능이 좋아 보였다. (속도, 쿼리양, 데이터 양)

하지만 이것이 완전히 신뢰할 수 있는 결과는 아니다.

다른 부분이 고려되지 못한 것이 존재하기 때문이다.

따라서 다음 블로그에서는 아래와 같은 사항들이 고려되어야 할 것이다.

 

1. DB 내부 최적화

-> 현재 쿼리문을 날릴 때, 한번에 모든 원하는 결과를 가져오지만 과연 fetch Join을해서 데이터를 가져와서 프로젝트 내에서 데이터를 처리하는 경우와 데이터베이스에게 이를 맡기는 경우 둘 중 어떤것이 효율적인지에 대한 고찰이 필요하다.

-> DB가 데이터에서 일부 칼럼을 뽑는데 사용되는 시간복잡도

2. 인덱스 등 설정

-> 인덱스는 DB 검색에 있어서 성능을 올리는 방식인데, 생성 비용이 들어가므로 생성 비용또한 같이 고려를 해야한다. 이를 참고해 인덱스를 설정시 성능에는 어떻게 차이가 있는지 참고를 해야하며 이를 위해서는 충분한 데이터 또한 필요할 것이다.

3. 충분한 데이터양을 가지고 실험 

-> 충분하지 않은 데이터로 인해 아무리 시간복잡도를 계산해도 큰 차이를 반영하지 않는다. 따라서, 최대한 충분한 데이터를 넣고 이를 비교하는 작업이 필요하다.

4. Thread 설정

-> 자바, 스프링은 기본적으로 스레드 방식으로 서버를 운영한다. 따라서, 스레드의 생성 및 소멸 등에 대한 동작을 깊이 이해하는 것이 필요하다. 이를 위해, 자료를 충분히 수집 후 이를 적용하는 과정도 필요할 것이다.

 

 

 

 

 

'Trouble Shooting > Data API' 카테고리의 다른 글

QueryDSL - 3중 Join N+1 해결  (0) 2023.06.27
Query DSL, Entity대신 DTO로 받기  (0) 2023.06.08
Querydsl 적용과 고찰  (0) 2023.06.05