모르지 않다는 것은 아는것과 다르다.

Database

PostgreSQL 실행계획 분석하기 6편 (서브쿼리)

채마스 2023. 11. 25. 22:55

개요

  • 실무에서 종종 서브쿼리를 사용한다. 하지만 서브쿼리는 잘 알고 사용하지 않으면 비효율적으로 동작할 확률이 높다.
  • 그래서 이번 글에서는 PostgreSQL 옵티마이저가 서브쿼리를 처리하는 방법에 대해서 알아보려고 한다.
  • 옵티마이저는 크게 아래 3가지 방법으로 서브쿼리를 처리한다.
    • 필터 방식 (Subplan)
    • 세미 조인
    • 서브쿼리 Collapse (서브쿼리 병합)
  • 이제 본격적으로 위에서 언급한 3가지 처리방식을 알아보자.



필수 사전 지식



테이블 세팅

create table team
(
    team_id    bigint,
    team_name  varchar(100),
    created_at timestamp with time zone
);

create table member
(
    member_id   bigint,
    member_name varchar(100),
    team_id     integer,
    join_date   timestamp with time zone
);



필터 방식 (Subplan)

  • Subplan은 서브쿼리를 별도의 쿼리 계획(Plan)으로 처리하는 방식이다.
  • 필터 방식은 세미 조인이 개발되기 전에 사용된 방식이다.
  • 서브쿼리가 메인 쿼리의 각 행에 대해 반복적으로 실행되어야 할 때 주로 사용한다.
  • 그렇기 때문에, 필터 방식은 세미 조 인 중에서 성능적으로 NL 세미 조인과 비슷하다. 따라서 옵티마이저는 보통의 경우 필터 방식보단 세미 조인 방식을 더 선호한다.
  • 일반적으로 서브쿼리가 상관 서브쿼리(correlated subquery)일 때 이 방식이 사용된다.
  • 이제 실행계획에서 Subplan을 확인해 보자. 데이터는 team 테이블에 15건, member 테이블에 100건의 데이터를 넣었다.
INSERT INTO team (team_id, team_name, created_at)
SELECT i,
       'Team ' || i,
       CURRENT_TIMESTAMP - INTERVAL '1 day' * (i % 365)
FROM generate_series(1, 15) AS i;

INSERT INTO member (member_id, member_name, team_id, join_date)
SELECT i,
       'Member ' || i,
       (i % 15) + 1,
       CURRENT_TIMESTAMP - INTERVAL '1 day' * (i % 365)
FROM generate_series(1, 100) AS i;

 

 

  • 위 결과를 보면, loops가 15인 것을 확인할 수 있다. 이는 team 테이블의 건수만큼 member 테이블을 순회하는 것이다.
  • Rows Removed by Filter: 7는 15번의 반복에서 평균 7개씩의 레코드만큼을 필터링했다는 것이다.
    • 따라서, 대략 15 * 7 인 105개의 레코드가 제거됐다는 의미로 생각할 수 있다.



세미 조인

  • 세미 조인은 서브쿼리의 결과가 메인 쿼리의 행 선택에만 영향을 미치고, 결과 집합에 직접 포함되지 않는 경우 사용된다.
  • EXISTS 또는 IN 연산자와 함께 사용되는 서브쿼리에 주로 사용된다.
  • 세미 조인은 서브쿼리의 결과가 필요한 메인 쿼리의 행을 효율적으로 필터링하는 데 사용된다.
  • 이제 실행계획을 확인해 보자. 데이터는 team 테이블에 15건, member 테이블에 10000건의 데이터를 넣었다.
truncate member;
truncate team;

INSERT INTO team (team_id, team_name, created_at)
SELECT i,
       'Team ' || i,
       CURRENT_TIMESTAMP - INTERVAL '1 day' * (i % 365)
FROM generate_series(1, 15) AS i;

INSERT INTO member (member_id, member_name, team_id, join_date)
SELECT i,
       'Member ' || i,
       (i % 15) + 1,
       CURRENT_TIMESTAMP - INTERVAL '1 day' * (i % 365)
FROM generate_series(1, 10000) AS i;

  • NL Semi Join은 첫 번째 테이블의 행이 두 번째 테이블과 매칭되면 반복을 중단하는 방식으로 동작한다.
  • (actual time=0.001..0.002 rows=8 loops=15) 이 부분을 해석해보면, 15번에 반복에서 평균 8개의 행을 검사했다는 의미이다.
    • member가 10000건인데 평균 8밖에 되지 않은 이유는 위에서 언급했듯이 NL Semi Join은 조건에 만족하는 순간 검사를 중단하기 때문이다.
  • 이번에는 member를 기준 테이블로 조인을 걸어서 실행계획을 살펴보자.

  • Hash Semi Join은 보통 서브쿼리의 집합이 메인쿼리의 집합보다 작은 경우에 동작한다.
  • Hash Join과의 차이점은 Hash Semi Join 경우에 Probe 테이블의 행이 Build 테이블의 해시 테이블과 매칭되면 반복을 중단한다.
  • 정리하면, NL Semi JoinHash Semi Join 모두 테이블의 행을 효율적으로 필터링하는데 유용한 조인방식이다. 그렇기 때문에 EXISTS, IN에서 사용되는 서브쿼리를 처리하는데 주로 사용된다,



서브쿼리 Collapse (서브쿼리 병합)

  • Subquery Collapse는 서브쿼리를 메인 쿼리와 병합하여 하나의 쿼리로 처리하는 최적화 기법이다.
  • 병합으로 인해 성능상의 이점이 있을 때 사용된다.
  • 이번에는 데이터를 team 테이블에 1000건, member 테이블에 100000건의 데이터를 넣었다.
  • 이제 실행계획을 살펴보자.

  • 서브쿼리를 사용했음에도 불구하고, 옵티마이저가 판단하기에 Hash Join으로 처리하는 것이 더 효율적이라고 판단한 경우에는, 서브쿼리 대신 Hash Join을 사용하여 쿼리를 최적화한 것을 확인할 수 있다.
  • 서브쿼리를 메인 쿼리와 병합함으로써, 옵티마이저는 Hash Join과 같은 더 유용한 조인 방식을 사용할 수 있기 때문에 옵티마이져는 Collapse를 선택한 것 같다.



References