본문으로 건너뛰기

· 약 11분
제이

안녕하세요~

우테코 카페인 팀의 제이입니다.

오늘은 필터링 기능 구현 및 인덱스를 이용한 조회 속도 개선하는 작업을 진행했습니다.

요구 사항과 기능 구현 목록

카페인 팀은 전기차 충전소 조회 및 통계 데이터를 제공해주는 서비스입니다.

사용자 입장에서 전기차 충전소를 조회할 때 본인 차에 맞는 충전기 타입과, 속도, 마지막으로 충전기를 제공하는 회사명 요금과 관련도 되어 있어서 중요할 수 있습니다.

그래서 무수히 많은 충전소를 보는 것이 아닌 자신에게 필요한 것만 보는 것이 사용자 경험에 있어서는 더 중요한데요.

저희 팀은 이를 위해 필터링 기능을 도입하고자 했습니다.

또한 조회가 많은 서비스인만큼 조회 속도 개선을 위해 인덱스를 적용하기로 했습니다.

필터링 뿐만 아니라 해당 작업을 하면서 어떤 고민을 했고 어떤 것을 했는지 적어보고자 합니다.

필터링 기능 구현하기

저희 팀은 빠르게 기능을 구현하는 단계에 있습니다.

따라서 일단 3개의 필터만 도입했고, 필터는 다음과 같습니다. [충전소 운영 회사 이름, 충전 타입, 충전 속도]

사용자는 필터를 클릭하면 현재 위치를 기준으로 주변에 해당 필터가 적용된 충전소를 볼 수 있습니다.

3개의 필터 중에서 모두 적용될 수도 있고, 모두 적용되지 않을 수도 있습니다.

그래서 2^3 = 8가지의 경우를 생각해야 했었습니다.

그래서 처음에 필터를 적용하기 위해서 다음과 같은 방법들을 생각했습니다.

  1. JPQL + 필터의 조합 (2^3)만큼 if문 사용하기

  2. 기존 좌표로 조회하는 findAllByLatitudeBetweenAndLongitudeBetween() 메서드를 사용 후 Stream을 이용해 자바 코드로 필터링하기

이렇게 두 가지 방법이 있었습니다.

1번의 경우 우테코 프로젝트에서 Querydsl을 사용해도 되는지 확실하지 않았고 정확한 필터 명세가 아직은 없고 3가지만 일단 도입하고자 해서 JPQL을 이용해서 상황마다 if문으로 해당 메서드를 실행시켜주는 방법이었습니다.

// 1. fetch join + 회사 이름만 조회
@Query("SELECT DISTINCT s FROM Station s " +
"LEFT JOIN FETCH s.chargers c " +
"LEFT JOIN FETCH c.chargerStatus " +
"WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
"AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
"AND s.companyName IN :companyNames")
List<Station> findAllByFilteringBeingCompanyNames(@Param("minLatitude") BigDecimal minLatitude,
@Param("maxLatitude") BigDecimal maxLatitude,
@Param("minLongitude") BigDecimal minLongitude,
@Param("maxLongitude") BigDecimal maxLongitude,
@Param("companyNames") List<String> companyNames);

// 2. fetch join + 충전 타입
@Query("SELECT DISTINCT s FROM Station s " +
"LEFT JOIN FETCH s.chargers c " +
"LEFT JOIN FETCH c.chargerStatus " +
"WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
"AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
"AND c.type IN :types")
List<Station> findAllByFilteringBeingTypes(@Param("minLatitude") BigDecimal minLatitude,
@Param("maxLatitude") BigDecimal maxLatitude,
@Param("minLongitude") BigDecimal minLongitude,
@Param("maxLongitude") BigDecimal maxLongitude,
@Param("types") List<ChargerType> types);

진행 했다면 이런 느낌이었겠네요!

2번의 경우 모두 조회를하고 자바 코드를 이용해서 필터링 해주는 방법이었습니다.

현재 저희 서비스는 좌표를 중심으로 주변 충전소를 조회합니다.

어차피 사용자가 화면을 축소해서 큰 범위의 지도를 보는 것은 어차피 막힐테니 사용자는 작은 범위에 대해서 조회하게 됩니다.

따라서 하나의 쿼리를 이용해서 자바 코드로 필터링 해주는 방법입니다.

이렇게만 봤을 땐 1번 방식인 필터 별로 조회해주는 것은 조회 효율은 더 좋을 것 같습니다.

하지만 1번의 방법은 '현재 구조'에서는 많은 쿼리문과 메서드를 작성해야하고, if문 범벅으로 보기 좋지 않은 코드가 완성 됐을 것 같습니다.

결국 2번 방식인 [전체 조회 + 코드로 필터링] 방식을 선택했습니다.

이 이유는 다음과 같습니다.
  1. 어차피 사용자는 작은 범위에서 조회를 한다.
  2. 인덱스를 걸었을 때 가장 효율적이다.

1번의 이유는 위에서 말했고, 2번에 대해 간단하게 설명 드리겠습니다.

저희 서비스는 조회가 굉장히 많지만, 충전소의 주기적인 업데이트를 위해 데이터 업데이트가 굉장히 빈번하게 일어납니다.

이 과정에서 많지는 않지만 데이터 삽입도 발생하고, 데이터 업데이트도 많아집니다.

JPQL로 조건을 나눠서 조회해준다면 해당하는 모든 필터에 인덱스를 걸어야할까요?

그럴 순 없었을 것 같습니다.

가장 효율적인 Column에 인덱스를 걸었겠죠, 그렇다면 조회마다 속도도 달라졌을 것이고 가령 해당하는 모든 Column에 인덱스를 설정해놔도 업데이트와 삽입이 느려졌을 것입니다.

이는 7분마다 데이터를 업데이트 하는 저희 서비스에서는 적절하지 않습니다.

반면에 한 개의 쿼리로 주변을 모두 조회하고 이를 자바 코드로 바꾸는 방법은 더 쉬웠습니다.

어차피 많지 않은 양의 데이터를 조회하고 필터링 하기 때문에 속도 면에서도 큰 차이가 나지 않았고, 인덱스 설정에도 유리했습니다.

조회시 이용하는 latitude와 longitude만 설정해주면 어떤 경우든 빠르게 조회를 할 수 있었습니다.

인덱스 적용으로 조회 속도 향상시키기

먼저 일단 현재 코드에서 조회시 다음과 같은 쿼리가 발생합니다.

Hibernate:
select
station0_.station_id as station_1_0_0_,
...
...
...
chargersta2_.latest_update_time as latest_u4_2_2_
from
charge_station station0_
left outer join
charger chargers1_
on station0_.station_id=chargers1_.station_id
left outer join
charger_status chargersta2_
on chargers1_.charger_id=chargersta2_.charger_id
and chargers1_.station_id=chargersta2_.station_id
where
(
station0_.latitude between ? and ?
)
and (
station0_.longitude between ? and ?
)

where 절에서 위도 경도를 바탕으로 주변만 가져오게 됩니다. 기존에 N+1 문제가 발생해서 EntityGraph로 바꿨고 실행시 쿼리입니다.

따라서 아래 글을 읽고 BETWEEN 쿼리에서 부등호를 이용하는 쿼리로 변경하였습니다. Mysql Query Between 과 >=, <= 성능 차이 비교 ( 더미데이터 50만 )

@Query("SELECT DISTINCT s FROM Station s " +
"LEFT JOIN FETCH s.chargers c " +
"LEFT JOIN FETCH c.chargerStatus " +
"WHERE s.latitude.value >= :minLatitude AND s.latitude.value <= :maxLatitude " +
"AND s.longitude.value >= :minLongitude AND s.longitude.value <= :maxLongitude")
List<Station> findAllByLatitudeBetweenAndLongitudeBetweenWithFetch(@Param("minLatitude") BigDecimal minLatitude,
@Param("maxLatitude") BigDecimal maxLatitude,
@Param("minLongitude") BigDecimal minLongitude,
@Param("maxLongitude") BigDecimal maxLongitude);

위와 같이 조회해주는 쿼리를 만들었고, 인덱스를 만들어주었습니다.

인덱스 설정 기준은 인덱스 정리 및 팁 위에 링크와 같이 동욱님의 블로그를 참조해서 기준을 세웠습니다.

무조건 카디널리티가 높은 것을 설정할 순 없었기 때문에 (업데이트와 삽입 작업이 많기 때문에) 쿼리에서 사용되는 column과 update 작업을 고려하고 성능을 비교해가면서 가장 효율적인 것을 설정해주었습니다.

그리고 속도를 비교해주었습니다.



먼저 속도 비교를 위해서 데이터 셋은 다음과 같이 진행하였습니다.
  • Charger (23만 건)
  • Station (6만 건)
  • ChargerStatus(23만 건)
  • 선릉역 근처 조회

Ver1. 인덱스 적용을 하지 않고 조회 및 필터링 했을 때 속도 (0.84초)

이미지 평균적으로 0.84초가 나왔습니다.

Ver2. 인덱스 적용 및 조회 및 필터링 했을 때 속도 (0.63초)

이미지 평균적으로 0.63초가 나왔습니다. 약 25 ~ 30%의 조회 속도가 개선되었습니다.

아직 이 부분은 개선이 더 필요해보입니다.

그래도 개선이 됐고, 삽입과 갱신에는 큰 지장이 없어서 일단 이정도로 마무리 하고, 추후에 개선을 해보도록 하겠습니다.

이미지 추가적으로 충전기 조회는 굉장히 빨라졌습니다!

배우는 단계이다보니 미숙하고 틀린 부분이 있을 수 있습니다.

긴 글 읽어주셔서 감사합니다 :)