Spring/Ticketing 프로젝트
공연 정보 조회 API 쿼리 분석하고 개선하기
minturtle
2024. 11. 15. 16:03
반응형
개요
- 티케팅 어플리케이션의 개발 및 배포가 완료되어서, 이제 성능 측정을 해보려 합니다.
- 이번에 성능 개선을 할 API는 공연 정보 API 입니다.
- 지금 아래와 같이 Performance가 검색 조차 되지 않는데, 쿼리 분석을 하고 정상적으로 검색이 되도록 수정해보고자 합니다.
[LdrxyZMp]|||-> PerformanceReader.searchPerformanceSummaryDto [LdrxyZMp]|||<X- PerformanceReader.searchPerformanceSummaryDto ended by exception DataAccessResourceFailureException [Sldi1Gc3]|||-> PerformanceReader.searchPerformanceSummaryDto [Sldi1Gc3]|||<X- PerformanceReader.searchPerformanceSummaryDto ended by exception DataAccessResourceFailureException [NztCOAr_]|||-> PerformanceReader.searchPerformanceSummaryDto [NztCOAr_]|||<X- PerformanceReader.searchPerformanceSummaryDto ended by exception DataAccessResourceFailureException [wnvuZP2a]|||-> PerformanceReader.searchPerformanceSummaryDto [wnvuZP2a]|||<- PerformanceReader.searchPerformanceSummaryDto ended at 2390513ms [6UfiDxGX]|||-> PerformanceReader.searchPerformanceSummaryDto [6UfiDxGX]|||<- PerformanceReader.searchPerformanceSummaryDto ended at 2511440ms
Performance : 약 150만개
PerformanceDatetimes : 약 330만개
테이블의 ERD는 다음과 같습니다.
현재 보고 계신 글은 공연 조회 API 최적화 시리즈 입니다!
1편 :공연 정보 조회 API 쿼리 분석하고 개선하기
2편 :공연 조회 API 성능 측정 및 개선 사안 찾아보기
3편 :공연 조회 API에 캐싱을 적용하고 성능 테스트하기
4편 :아키텍처 최적화, 로그 방식 변경을 통한 공연 정보 조회 API 최적화 하기
쿼리 보기
- 쿼리는 아래와 같습니다.
select
p1_0.uid,
p1_0.image,
p1_0.name,
r1_0.name,
min(pdt1_0.show_time),
max(pdt1_0.show_time)
from
performances p1_0
join
performance_datetimes pdt1_0
on pdt1_0.performance_id=p1_0.id
join
performance_places pp1_0
on p1_0.place_id=pp1_0.id
join
regions r1_0
on pp1_0.region_id=r1_0.id
where
1=1
group by
pdt1_0.performance_id
order by
p1_0.id desc
offset
? rows
fetch
first ? rows only
select
count(p1_0.id)
from
performances p1_0
join
performance_datetimes pdt1_0
on pdt1_0.performance_id=p1_0.id
join
performance_places pp1_0
on p1_0.place_id=pp1_0.id
join
regions r1_0
on pp1_0.region_id=r1_0.id
where
1=1
group by
pdt1_0.performance_id
- 일단 첫번째로 거슬리는건 불필요한 Count 조회 쿼리가 나간다는 것입니다.
- 찾아보니 kotlinJdslJpqlExecutor.findPage() 메서드가 내부적으로 카운트 쿼리를 사용한다고 합니다.
- 하지만 현재 API는 커서 페이지네이션이기 때문에 카운트 쿼리가 필요없습니다. 따라서 카운트 쿼리를 제거해보도록 하겠습니다.
Before
kotlinJdslJpqlExecutor.findPage(PageRequest.of(0, cursorInfoDto.limit)) {
// ...
}
After
kotlinJdslJpqlExecutor.findAll(PageRequest.of(0, cursorInfoDto.limit)) {
}
- 이렇게 변경하고 쿼리를 조회하면 아래와 같이 쿼리가 한번 나가도록 바뀌었음을 확인할 수 있었습니다.
select
p1_0.uid,
p1_0.image,
p1_0.name,
r1_0.name,
min(pdt1_0.show_time),
max(pdt1_0.show_time)
from
performances p1_0
join
performance_datetimes pdt1_0
on pdt1_0.performance_id=p1_0.id
join
performance_places pp1_0
on p1_0.place_id=pp1_0.id
join
regions r1_0
on pp1_0.region_id=r1_0.id
where
1=1
group by
pdt1_0.performance_id
order by
p1_0.id desc
offset
? rows
fetch
first ? rows only
- 이제 이 쿼리를 explain절을 한번 실행해 보았습니다.
explain select p.uid, p.image, p.name as 'title' , date(min(pd.show_time)) as start_date, date(max(pd.show_time)) as end_date, r.name as 'regionName' from performances p
join performance_datetimes pd on p.id = pd.performance_id
join performance_places pp on p.place_id = pp.id
join regions r on r.id = pp.region_id
where 1=1
group by pd.performance_id
order by p.id desc limit 0, 10;
table | type | possible_keys | key | ref | filtered | Extra |
p | ALL | PRIMARY,FK888vq870okmpvmvj6wg3kqt3k | 100.00 | Using temporary; Using filesort | ||
pp | eq_ref | PRIMARY,FKi51y339o8qfbb795gyko9cur2 | PRIMARY | ticketing.p.place_id | 100.00 | |
r | eq_ref | PRIMARY | PRIMARY | ticketing.pp.region_id | 100.00 | |
pd | ref | FK5mqulsthehqtjvn37qabaggeg | FK5mqulsthehqtjvn37qabaggeg | ticketing.p.id | 100.00 |
- explain절을 실행해 보았을 때 pp(performancePlace), r(Region), pd(PerformanceDateTime)은 문제가 안되지만, p(PerformancePlace)는 문제점이 존재합니다.
- Using filesort : Index Sort가 적용되어 있지 않기 때문에 효율적인 커서 페이지네이션이 적용되지 않습니다.
- Using temporary: 복잡한 Join문 때문인지, Group By 때문인지, 혹은 둘다 원인인지 알 수 없지만 임시 테이블을 사용합니다.
- 위 쿼리에서 커서 페이지 네이션을 올바르게 사용하기 위해선 indexsort가 필수적이기 때문에, indexsort의 적용을 목표로 했습니다.
쿼리 개선하기
- 일단 지금 Group By 절 때문에 Where 절에 아무 조건이 없고, Id로 정렬을 함에도 불구하고 인덱스를 타지 않기 때문에 인덱스를 타도록 SELECT 문을 두개로 분리해보기로 했습니다.
select
p1_0.id,
p1_0.created_at,
p1_0.description,
p1_0.image,
p1_0.name,
pp1_0.id,
pp1_0.created_at,
pp1_0.name,
r1_0.id,
r1_0.created_at,
r1_0.name,
r1_0.uid,
r1_0.updated_at,
pp1_0.updated_at,
p1_0.price,
p1_0.uid,
p1_0.updated_at
from performances p
join performance_places pp on p.place_id = pp.id
join regions r on r.id = pp.region_id
where 1=1
order by p.id desc limit 0, 10;
select pd.performance_id, date(min(pd.show_time)) as start_date, date(max(pd.show_time)) as end_date
from performance_datetimes pd
where pd.performance_id in (100123, 100122, 100121, 100120, 100119, 100118, 100117, 100116)
group by pd.performance_id;
- 쿼리를 간단하게 설명해 보자면, Group By를 해서 startDate와 endDate를 하는 쿼리와 Performance의 정보를 조회하는 쿼리를 분리하였습니다. 첫번째 쿼리인 Performance의 정보를 조회할 때 p.id를 받아 2번째 쿼리의 where절 IN 절에 id 리스트를 넣어줍니다.
- 먼저 첫번째 쿼리의 explain절은 다음과 같습니다.
table | type | possible_keys | key | ref | filtered | Extra |
pp | index | PRIMARY,FKi51y339o8qfbb795gyko9cur2 | FKi51y339o8qfbb795gyko9cur2 | 100.00 | Using index; Using temporary; Using filesort | |
r | eq_ref | PRIMARY | PRIMARY | ticketing.pp.region_id | 100.00 | |
p | ref | FK888vq870okmpvmvj6wg3kqt3k | FK888vq870okmpvmvj6wg3kqt3k | ticketing.pp.id | 100.00 |
- 이는 첫번째 쿼리의 explain 절인데, 옵티마이저가 performance가 아닌 performance_place를 드라이빙 테이블로 설정하기 때문에 이전보단 나을지 몰라도 아직 cursor paigination이 효율적으로 사용되는 것 같아 보이지는 않습니다.(물론 performance에 비해 performancePlace가 압도적으로 적기 때문에 PerformancePlace를 드라이빙 테이블로 설정하는게 더 빠를수도 있을 것 같습니다. )
table | type | possible_keys | key | ref | filtered | Extra |
pd | range | FK5mqulsthehqtjvn37qabaggeg | FK5mqulsthehqtjvn37qabaggeg | 100.00 | Using index condition |
- 두번째 쿼리는 performance_datetime에서의 performance.id가 FK로 걸려있기 때문에 FK 인덱스만을 사용해서 효율적인 조회를 하는 것으로 보입니다.
- 이제 두 쿼리를 실행하고, 두 쿼리의 결과를 어플리케이션에서 병합하여 제공하도록 해보도록 하겠습니다.
- 어플리케이션 코드는 다음과 같습니다.
class PerformanceService(...){
fun search(
cursorInfoDto: CursorInfoDto
): List<PerformanceSummarySearchResult> {
val performanceMap =
performanceReader.findPerformanceEntityWithPlaceAndRegion(cursorInfoDto)
.associateBy { it.id }
.toMap(LinkedHashMap())
val startEndDateResultMap = performanceReader.findPerformanceStartAndEndDate(performanceMap.keys.toList())
.associateBy { it.performanceId }
return performanceMap.entries.map {
val startEndDateResult = startEndDateResultMap[it.key]
val performance = it.value
PerformanceSummarySearchResult(
performance.uid,
performance.image,
performance.name,
performance.performancePlace.region.name,
startEndDateResult?.startDate,
startEndDateResult?.endDate
)
}
}
}
- 위 코드는 먼저 performance 리스트를 조회(첫번째 쿼리)한 후, 이를 id를 key, performance 엔티티를 Value로 설정한 Map으로 변환합니다.
- 이때 LinkedHashMap을 사용해 DB에서 이미 정렬된 결과를 유지해 추가적인 정렬이 필요없도록 합니다.
-
- 두 번째 쿼리도 id를 key, dto 객체를 value로 설정한 map을 생성합니다
- 그리고 두 map을 병합하여 해당 메서드의 최종 결과값인 PerformanceSummarySearchResult를 만듭니다.
- 위 코드 처럼 Map을 사용하면 O(N)으로 처리가 가능합니다.
- 이제 이 코드를 사용해서 배포환경에서 하기 전에, 실제로 빨라졌는지 확인하기 위해 로컬에서 성능 테스트를 해보도록 하겠습니다.
성능 테스트
- 먼저 로컬 컴퓨터의 성능과 테스트 데이터의 갯수는 다음과 같습니다.
12 논리 코어, 16GB 메모리
Performance 100120개
PerformanceDateTimes 301080개(Performance당 3개)
PerformancePlace 100개
Region 34개
테스트 시나리오는 다음과 같습니다.
- 10분동안 10명의 사용자가 1~10페이지까지 반복하며 조회
import http from 'k6/http';
import { sleep, check, group } from 'k6';
import { URL } from 'https://jslib.k6.io/url/1.0.0/index.js';
import { Trend } from 'k6/metrics';
export const options = {
vus: 10,
duration: "10m"
};
const pageDurations = {};
for (let i = 1; i <= 10; i++) {
pageDurations[`page_${i}_duration`] = new Trend(`page_${i}_duration`);
}
export default function () {
const baseUrl = 'http://localhost:8080/api/performances';
let cursor = null;
for (let page = 1; page <= 10; page++) {
group(`Page ${page}`, function () {
let url = new URL(baseUrl);
if (cursor) {
url.searchParams.append('cursor', cursor);
}
const res = http.get(url.toString());
pageDurations[`page_${page}_duration`].add(res.timings.duration);
check(res, {
'status is 200': (r) => r.status === 200
});
if (res.status !== 200) {
return;
}
const body = JSON.parse(res.body);
cursor = body.cursor;
});
sleep(1);
}
}
- 결과는 다음과 같습니다.
checks.........................: 100.00% 588 out of 588
http_req_failed................: 0.00% 0 out of 588
http_reqs......................: 588 0.933324/s
iteration_duration.............: avg=1m46s min=1m37s med=1m44s max=2m0s p(90)=2m0s p(95)=2m0s page_1_duration................: avg=10746.132313 min=8634.6932 med=11024.81795 max=12567.6161 p(90)=12280.62562 p(95)=12281.1019
page_10_duration...............: avg=9020.222608 min=6149.41 med=9073.95445 max=12680.2308 p(90)=9780.12223 p(95)=10123.065595
page_2_duration................: avg=10072.486313 min=6571.5855 med=10131.805 max=11970.2882 p(90)=11913.17346 p(95)=11939.131765
page_3_duration................: avg=9671.482362 min=6430.7444 med=9946.0489 max=11518.5006 p(90)=10638.56177 p(95)=10714.525395
page_4_duration................: avg=9722.537405 min=6380.331 med=9514.90905 max=12410.5782 p(90)=12379.5608 p(95)=12395.668025
page_5_duration................: avg=9659.557945 min=6723.4605 med=9791.44755 max=11440.0069 p(90)=11383.45661 p(95)=11421.089305
page_6_duration................: avg=9294.768578 min=7442.2658 med=9337.41215 max=11111.0523 p(90)=10434.56965 p(95)=10460.555065
page_7_duration................: avg=9257.418388 min=6674.565 med=9238.90805 max=11492.471 p(90)=11397.83355 p(95)=11461.91253
page_8_duration................: avg=9152.234917 min=6258.2182 med=9287.9181 max=10565.4895 p(90)=10055.31368 p(95)=10125.222115
page_9_duration................: avg=9311.415317 min=6492.7051 med=9431.0211 max=13055.4708 p(90)=12684.69708 p(95)=12942.98768
- 각 페이지당 평균적으로 10초의 조회시간이 걸렸으며, 백분위 95%의 사용자가 12초 안에 답변을 받음을 알 수 있습니다.
- 개선된 쿼리 테스트
- 테스트 조건은 동일합니다.
checks.........................: 100.00% 6000 out of 6000
http_req_failed................: 0.00% 0 out of 6000
http_reqs......................: 6000 9.869459/s
page_1_duration................: avg=17.380758 min=7.9309 med=10.6079 max=402.3716 p(90)=12.9373 p(95)=13.905
page_10_duration...............: avg=11.227898 min=8.0075 med=10.9666 max=17.1482 p(90)=12.9504 p(95)=14.3512
page_2_duration................: avg=12.165052 min=8.699 med=11.16505 max=53.9917 p(90)=13.3816 p(95)=15.7468
page_3_duration................: avg=11.57653 min=7.6035 med=11.1706 max=19.7809 p(90)=13.53253 p(95)=14.6724
page_4_duration................: avg=11.894249 min=8.011 med=11.52725 max=20.001 p(90)=14.30612 p(95)=16.863385
page_5_duration................: avg=11.08878 min=7.9513 med=10.8438 max=17.3473 p(90)=12.791 p(95)=13.566505
page_6_duration................: avg=11.518163 min=8.5884 med=11.0645 max=25.8538 p(90)=13.3144 p(95)=14.8053
page_7_duration................: avg=12.016215 min=8.7637 med=11.2217 max=45.4625 p(90)=13.71794 p(95)=15.5838
page_8_duration................: avg=11.510757 min=6.9655 med=11.13165 max=21.766 p(90)=12.9902 p(95)=16.36166
page_9_duration................: avg=11.689833 min=8.0945 med=11.0177 max=22.0022 p(90)=14.3027 p(95)=16.136915 vus............................: 10 min=10 max=10 vus_max........................: 10 min=10 max=10
- 각 페이지당 평균적으로 11ms 정도 소요됨을 알 수 있고, 95%의 사용자가 16ms안에 응답을 받을 수 있었습니다.(약 99.9%의 개선)
반정규화 하기
- 이미 충분히 개선된거 같긴 하지만, 조금 더 개선할 여지가 남아있습니다. 바로 반정규화를 사용해 regionName을 performance에 저장하면 JOIN 없이 첫번째 쿼리를 호출 할 수 있습니다.
ALTER TABLE performances
ADD COLUMN place_name VARCHAR(255),
ADD COLUMN region_name VARCHAR(255);
UPDATE performances p
JOIN performance_places pp ON p.place_id = pp.id
JOIN regions r ON pp.region_id = r.id
SET p.place_name = pp.name,
p.region_name = r.name;
select p.id, p.uid, p.image, p.name as 'title' , p.region_name as 'regionName'
from performances p
order by p.id
desc limit 10;
select p.id, p.uid, p.image, p.name as 'title' , p.region_name as 'regionName'
from performances p
where id > (select id from performances p1 where p1.uid = 'xx-eZeD7Kp')
order by p.id
desc limit 10;
- 첫번째 쿼리가 위와 같이 JOIN없이 클린하게 작성될 수 있습니다.
select_type | table | type | possible_keys | key | ref | filtered | Extra |
PRIMARY | p | range | PRIMARY | PRIMARY | 100.00 | Using where; Backward index scan | |
SUBQUERY | p1 | const | uk_performances_uid,UKt0sabc8cnk5otxowyqwsrlwhy | uk_performances_uid | const | 100.00 | Using index |
- 정상적으로 index sort도 적용됨을 확인할 수 있습니다.
- 그럼 반정규화를 적용한 채로 다시 동일한 테스트를 해보도록 하겠습니다.
checks.........................: 100.00% 6000 out of 6000 http_req_failed................: 0.00% 0 out of 6000 http_reqs......................: 6000 9.876259/s
page_1_duration................: avg=16.474105 min=6.8876 med=10.078 max=437.9993 p(90)=11.5671 p(95)=13.66465
page_10_duration...............: avg=10.948924 min=7.5174 med=10.5628 max=20.9899 p(90)=13.513 p(95)=14.151565
page_2_duration................: avg=11.322002 min=7.8967 med=10.421 max=50.2296 p(90)=12.6877 p(95)=14.3196
page_3_duration................: avg=10.891064 min=8.0072 med=10.37805 max=22.0607 p(90)=12.50935 p(95)=14.4519
page_4_duration................: avg=10.87548 min=7.9638 med=10.3528 max=18.659 p(90)=13.68954 p(95)=14.963265
page_5_duration................: avg=10.77193 min=8.0425 med=10.357 max=19.5613 p(90)=12.17873 p(95)=14.2962
page_6_duration................: avg=10.888312 min=7.567 med=10.3686 max=29.0662 p(90)=12.80885 p(95)=13.9582
page_7_duration................: avg=10.979036 min=7.4739 med=10.5193 max=20.5428 p(90)=12.9696 p(95)=14.90885
page_8_duration................: avg=10.83324 min=6.8544 med=10.4508 max=16.5683 p(90)=13.0025 p(95)=13.8474
page_9_duration................: avg=10.922153 min=8.3057 med=10.5629 max=19.6698 p(90)=12.8252 p(95)=13.99075 vus............................: 10 min=10 max=10 vus_max........................: 10 min=10 max=10
- 이미 개선한 코드에 반정규화까지 하니, 95 percentile 기준으로 6.4%가 평균적으로 개선되었습니다.
결론
- 기존의 10초 이상 걸리는 코드를 10ms 내외로 개선하였습니다.
- 다음에는 실제 배포를 해서 배포 환경에서 서버의 한계치를 측정해보고 병목지점이 어디인지 파악한 후 개선해보고자 합니다.
- 만약 추가적인 개선이 필요하다면 StartDate와 EndDate도 반정규화를 하면 쿼리를 한번으로 줄일 수 있습니다.
- 마지막으로, 혹시나 Region이나 PerformancePlace의 이름을 바꿀일이 있다면, 수정이상을 방지하기 위해 JOIN으로 Performance의 값까지 한꺼번에 바꾸어 줘야 합니다.
반응형