이번 글에서는 쿼리 최적화를 다뤄보려고 한다.
쿼리 병목 지점을 확인하고 인덱스 튜닝, 실행 계획 분석, 통계 테이블, 반정규화 등 여러 기법들 중 가장 적합한 해결책을 적용하고 테스트한 결과를 공유하려고 한다.
병목 지점 파악하기, 아래 사진은 Swagger UI에서 테스트한 결과이다.
- 전체 통계, 1인당 평균 생성 qrcode event, qrcode event 1개당 평균 등록 guestbook 조회
- 문제가 발생한 부분은 Request duration이다. 25846 ms = 약 26초
- 실서비스에서 웹페이지당 API 여러 개가 있을 뿐더러, 하나의 API가 2~3초를 넘게 되면 사용자에게 큰 악영향을 끼칠 수 있다. 따라서 성능을 개선하고자 한다.
‼️ 시간이 오래 걸리는 이유 중 가장 큰 원인은 "데이터가 많아서"이다. 실서비스 전 문제 확인을 위해 mock데이터를 넣어놓은 상태이고, total 갯수를 확인해보면 각 테이블에 백만 개 이상 데이터가 들어있음을 볼 수 있다. 하지만 다른 API보다 몇배는 많은 시간을 소요한다는 점이 문제가 되는 지점이기에 개선을 하고자 한다.
기존 코드
- 아래와 같은 SQL을 날리고, Repository에서 Interface로 응답을 받고 그걸 DTO로 변환하여 클라이언트에게 반환한다.
- 통계 정보는 웹사이트에서 현재 서비스 이용 규모가 어떤지를 나타내기 위함으로, 실시간성과 정합성보다 많은 사람들이 접근하는 정보이기에 속도가 더 중요하다고 생각한다.
해결 방법
- Materialized View: DB view테이블을 활용하여 일정 주기마다 정보를 갱신
- 통계 테이블: 통계 테이블을 만들어, 매 API마다 COUNT를 계산하는 대신 통계 테이블을 조회
통계 테이블을 만들기로 결정
➡️ 실시간성과 정합성이 요구되지 않고 속도가 중요한 작업이고, 각 요청마다 계산하는 것이 계산된 정보를 가져오기에 빠르다는 장점이 있다.
➡️ View를 사용하면 DB 설정을 필요로 하고, DB 교체 또는 View 수정 시 DB에서 수정을 해야한다는 단점이 있다.
수정된 코드
- 아래와 같이 @Scheduled를 활용하여 Cron식으로 10분마다 통계 정보를 갱신
- 통계 조회 시 계산을 하지 않고, 갱신된 통계 데이터를 읽기만 하면 되기에 API 응답 시간이 빨라질 것으로 예상
- findFirstByOrderByCreatedAtDesc() 사용, CreatedAt Index 설정하고, Limit 1 설정하여 빠르게 조회, update 대신 insert하는 방식으로 통계를 갱신하여 통계의 변화를 추적
|
---|
|
---|
성능 비교
- 테이블별 총 데이터 개수:
- user: 백만 개
- qrcode event: 이백만 개
- guestbook: 천만 개
- 살펴봐야 할 지점
- 배치 작업 시 메모리 문제 (작업 주기 비교 ➡️ 1분 vs 10분)
- 개선된 조회 성능 (API 100번 실행 후 평균)
배치 작업 시 메모리 지표
- 주기: 1분 vs 10분
- 결과: 배치 작업이 실행되는 주기마다 CPU 사용량이 올라갔다가 배치 작업이 끝나면 다시 내려가는 모습을 볼 수 있다. 실시간성과 성능, 리소스는 지표로 확인할 수 있듯이 trade-off 관계에 있다. 실시간성을 조금 내려놓은 대신 성능을 위해 배치 작업을 도입하고, 리소스를 낭비를 막기 위해 10분이라는 살짝 긴 시간으로 설정하였다.
|
---|
|
---|
개선된 조회 성능
- API 횟수: 100번
- 데이터 갯수: 1,000,000개(백만 개)
- 분석: 가장 최신 1개 데이터만 가져오고, CreatedAt 컬럼에 Index를 설정하여 성능을 최적화하였다. 아래 그래프에서 볼 수 있듯이 빠른 성능을 보여준다.
- 결과: 25846 ms(약 26초) ➡️ 15 ms
- 평균: 15 ms
- p99: 112 ms
- p95: 33 ms
|
---|