운영에서 느린 쿼리를 마주치면 대부분 이렇게 시작합니다.
“EXPLAIN 한 번 돌려보셨나요?”
그런데 막상 EXPLAIN 결과를 열어 보면, Nested Loop, GroupAggregate, Sort, Buffers: shared hit=... 같은 말들만 가득해서 처음엔 막막합니다.
이번 글에서는 제가 실제로 테스트했던 아래 쿼리를 예제로 삼아서:
EXPLAIN (ANALYZE, BUFFERS)
SELECT dvc_id, AVG(data_val)
FROM h_raw
JOIN t_dvc_priority_manage USING (dvc_id)
WHERE data_dt >= NOW() - INTERVAL '10 minutes'
GROUP BY dvc_id
HAVING AVG(data_val) > 50;
를 하나씩 뜯어보겠습니다.
EXPLAIN은 “이 쿼리를 이렇게 실행할 생각이다”라는 실행 계획을 보여줍니다.
여기에 옵션을 더 붙이면 더 많은 정보를 볼 수 있습니다.
ANALYZE: 실제로 쿼리를 실행해서 예상이 아니라 실측 시간/row 수를 보여줌.BUFFERS: 이 쿼리가 읽은 페이지(블록)가 메모리(hit) 기준인지, 디스크(read)인지 알려줌.실무 튜닝할 땐 거의 항상:
EXPLAIN (ANALYZE, BUFFERS)
...
이 조합으로 보는 게 정석입니다.
실제 결과는 환경마다 다르겠지만, 이번 쿼리에서 눈에 띄는 노드들을 정리하면 대략 이런 느낌이었을 겁니다:
ChunkAppend (TimescaleDB 하이퍼테이블 청크 선택)Index Scan 또는 Index Only Scan on t_dvc_priority_manageGroupAggregateSortPlanning Time, Execution Time, Buffers: shared hit=...이 각각이 뭘 의미하는지, 그리고 튜닝 포인트가 어디인지가 핵심입니다.
t_dvc_priority_manage 쪽에서 Index Only Scan이 나왔다면 아주 좋은 상태입니다.
Index Scan: 인덱스로 위치를 찾은 뒤 테이블 페이지까지 가서 실제 값을 읽음Index Only Scan: 필요한 값을 인덱스에 이미 다 가지고 있어서 테이블을 안 타도 됨.이 쿼리에서 JOIN이 USING (dvc_id) 기준으로 걸려 있으니,
t_dvc_priority_manage에 PRIMARY KEY(dvc_id) 혹은 UNIQUE INDEX(dvc_id)가 잘 잡혀 있고,
통계/visibility map 상태가 좋다면 Index Only Scan까지 떠주는 그림이 나옵니다.
이 단계에서 확인할 수 있는 것:
이 쿼리에서는 dvc_id 기준 JOIN, data_dt 기준 시간 필터가 핵심이기 때문에
h_raw(data_dt, dvc_id)orh_raw(dvc_id, data_dt)같은 인덱스를 어떻게 줄지 고민할 포인트가 됩니다.
실행 계획에 Nested Loop가 보이면 겁부터 나는 경우가 많은데, 항상 나쁜 건 아닙니다.
h_raw에서 시간 조건으로 걸러진 결과에 대해dvc_id마다 t_dvc_priority_manage를 인덱스로 찔러보는 패턴이라다만 데이터가 커졌을 때를 대비해서 봐야 할 지표는:
만약 장기적으로 h_raw에서 시간 조건으로 걸러지는 데이터가 매우 많아진다면,
Hash Join으로 바뀌도록 유도하거나h_raw의 시간 조건 필터링을 더 강하게 만들거나이 쿼리의 핵심은:
GROUP BY dvc_id
HAVING AVG(data_val) > 50;
실행 계획에서는 보통 이런 구조로 나타납니다:
GroupAggregate 혹은 HashAggregateSort (GroupAggregate가 필요로 하는 정렬)GroupAggregate 위에 Sort 노드가 있다면, DB가 이렇게 하고 있다는 뜻입니다:
dvc_id 기준으로 정렬AVG() 계산데이터가 적을 때는 이 Sort 비용이 미미하지만,
수백만 건 단위로 가면 Sort가 메모리를 많이 쓰거나 디스크까지 쓰는 병목이 됩니다.
실행 계획에서 체크할 부분:
Sort Method: quicksort Memory: xxxkBdisk가 보이면 위험 신호입니다.Buffers에서 특정 Sort/Group 노드에 read가 많이 붙는지 확인GROUP BY가 느릴 때 쓸 수 있는 대표적인 전략은:
GROUP BY 컬럼 순서에 맞는 인덱스를 만들어 정렬 비용을 줄인다.
예를 들어 이 쿼리는 dvc_id 기준으로 그룹핑을 합니다.
h_raw에 (dvc_id, data_dt) 순서의 인덱스가 있다면,실제 전략 예시:
-- 시간 + dvc_id 기준으로 자주 조회한다면
CREATE INDEX idx_h_raw_dvcdt ON h_raw (dvc_id, data_dt);
-- 혹은 시간 범위가 항상 강하게 걸린다면
CREATE INDEX idx_h_raw_dtdvc ON h_raw (data_dt, dvc_id);
어느 쪽이 더 유리한지는 실제 쿼리 패턴과 카디널리티에 따라 달라집니다.
WHERE data_dt ... GROUP BY dvc_id 패턴이 많으면 (data_dt, dvc_id)도 좋은 선택입니다.
BUFFERS 옵션은 “이 쿼리가 어디서 얼마나 IO를 쓰고 있는지”를 보여줍니다.
예를 들어 이런 식으로 나올 수 있습니다:
Buffers: shared hit=29
...
Buffers: shared hit=20 read=5
shared hit: 이미 메모리에 있던 페이지를 읽은 경우 → 상대적으로 가벼움.read: 디스크에서 읽어 온 경우 → IO 비용이 더 큼.튜닝할 때는:
read가 많이 뜨는지를 중심으로 보면, “이 쿼리가 어느 단계에서 디스크를 많이 긁고 있는지” 감이 잡힙니다.
실행 계획 맨 아래를 보면 보통 이런 정보가 딸려옵니다.
Planning Time: 1.332 ms
Execution Time: 0.273 ms
Planning Time: 옵티마이저가 계획 세우는 데 걸린 시간.Execution Time: 실제 쿼리를 실행한 시간.보통은 Execution이 압도적으로 중요하지만,
하이퍼테이블 청크가 아주 많거나, 복잡한 JOIN/서브쿼리가 많으면 Planning Time이 수백 ms~수 초까지 튈 수 있습니다.
TimescaleDB 환경에서:
시간 조건 인덱스 최적화
WHERE data_dt >= NOW() - INTERVAL '10 minutes'h_raw(data_dt) 또는 (data_dt, dvc_id) 인덱스로 시간 범위 스캔을 싸게 만들기.GROUP BY dvc_id 최적화
(dvc_id, data_dt) 인덱스로 정렬 비용을 줄이거나 Incremental Sort 유도.JOIN 키 인덱스 확인
JOIN t_dvc_priority_manage USING (dvc_id)t_dvc_priority_manage 쪽의 PRIMARY KEY (dvc_id) 또는 적절한 인덱스 유무 확인.데이터가 쌓였을 때를 위한 체크 포인트
EXPLAIN (ANALYZE, BUFFERS)를 찍어서:EXPLAIN은 처음 보면 복잡하지만, 패턴을 몇 개만 잡고 보면 훨씬 쉬워집니다.
이번 글에서 다룬 쿼리 기준으로 요약하면:
같은 테이블에 대해서 다양한 튜닝 쿼리들의 실행계획을 통해 EXPLAIN 결과가 어떻게 달라지는지도 비교해보면 더 재미있게 파볼 수 있습니다.