PostgreSQL 단일 조인·집계와 반복 분리 조회의 실행 경로를 비교하는 3D 일러스트

PostgreSQL JOIN·GROUP BY와 분리 조회 성능 비교: 1:N SUM 중복부터 인덱스까지

PostgreSQL 2026년 8월 20일

PostgreSQL에서 기준 테이블과 두 개의 하위 테이블을 조회할 때 자주 나오는 질문이 있다.

JOINGROUP BY로 한 번에 조회하는 방식과, 기준 데이터를 먼저 조회한 뒤 합계와 상세 데이터를 별도로 조회하는 방식 중 무엇이 더 빠를까?

결론부터 말하면 쿼리 횟수만으로 판단할 수 없다. 단일 쿼리가 항상 빠른 것도 아니고, 분리 조회가 항상 유리한 것도 아니다. 먼저 1:N 조인으로 중간 행이 얼마나 증가하는지, 집계 결과가 정확한지, 애플리케이션과 DB 사이의 왕복이 몇 번 발생하는지를 함께 봐야 한다.

이번 글에서는 실제 테이블명과 업무값을 일반화해 다음 내용을 정리한다.

  • 1:N × 1:N 조인에서 SUM이 중복되는 이유
  • 하위 테이블을 먼저 집계한 뒤 조인하는 방법
  • Spring Boot에서 N+1 없이 고정 횟수로 분리 조회하는 방법
  • 상태·날짜·최근 10분 조건과 시간대 처리
  • Seq Scan, Bitmap Scan, 버퍼 캐시 해석
  • 평균 약 0.3초라는 수치를 판단하는 방법
  • 복합 인덱스와 부분 인덱스 후보

예제 데이터 구조

예제는 다음 관계를 가정한다.

base_item
 ├─ id
 ├─ code
 ├─ name
 └─ category

event_log
 ├─ id
 ├─ base_item_id
 ├─ status
 ├─ event_date
 ├─ event_at
 └─ amount

detail
 ├─ id
 ├─ base_item_id
 ├─ detail_code
 ├─ detail_value
 └─ sort_no

base_item 한 건에 event_logdetail이 각각 여러 건 존재한다. 이벤트 조회 조건은 다음과 같다.

  • event_date는 기준일과 같아야 한다.
  • 상태가 S면 기준일의 데이터를 포함한다.
  • 상태가 W면 현재 시각 기준 최근 10분 데이터만 포함한다.
  • 업무 시각은 Asia/Seoul 기준이다.

문제가 될 수 있는 다중 조인

처음에는 다음처럼 모든 테이블을 조인하고 한 번에 합계를 계산하기 쉽다.

SELECT
    i.id,
    i.code,
    i.name,
    i.category,
    SUM(e.amount) AS total_amount,
    COUNT(d.id) AS detail_count
FROM base_item i
JOIN event_log e
  ON e.base_item_id = i.id
LEFT JOIN detail d
  ON d.base_item_id = i.id
WHERE e.event_date = $1::date
  AND (
        e.status = 'S'
        OR (
            e.status = 'W'
            AND e.event_at >=
                (statement_timestamp() AT TIME ZONE 'Asia/Seoul')
                - INTERVAL '10 minutes'
            AND e.event_at <=
                (statement_timestamp() AT TIME ZONE 'Asia/Seoul')
        )
      )
GROUP BY
    i.id,
    i.code,
    i.name,
    i.category;

이 쿼리는 실행 속도를 보기 전에 결과가 정확한지부터 확인해야 한다.

한 항목에 이벤트가 2건이고 상세가 3건이라면 조인 중간 결과는 6건이 된다. 이벤트 한 건이 상세 건수만큼 반복되므로 이벤트 합계가 원래 30이어도 SUM(e.amount)는 90이 될 수 있다. COUNT(d.id)도 이벤트 건수만큼 부풀 수 있다.

이벤트 2건 × 상세 3건 = 조인 결과 6건

SUM(DISTINCT e.amount)는 해결책이 아니다. 서로 다른 두 이벤트의 금액이 우연히 같다면 정상 데이터까지 한 번만 합산하기 때문이다.

상세 데이터가 단지 존재하는지만 확인하려는 목적이라면 LEFT JOIN보다 EXISTS가 자연스럽다. 상세 집계가 필요하다면 하위 테이블을 각각 한 행으로 줄인 뒤 조인해야 한다.

해결책 1: 각각 선집계한 뒤 조인한다

가장 먼저 검토할 방식은 event_logdetailbase_item_id별로 집계한 뒤 기준 테이블에 조인하는 것이다.

WITH event_agg AS (
    SELECT
        e.base_item_id,
        SUM(e.amount) AS total_amount,
        COUNT(*) AS event_count
    FROM event_log e
    WHERE e.event_date = $1::date
      AND (
            e.status = 'S'
            OR (
                e.status = 'W'
                AND e.event_at >=
                    (statement_timestamp() AT TIME ZONE 'Asia/Seoul')
                    - INTERVAL '10 minutes'
                AND e.event_at <=
                    (statement_timestamp() AT TIME ZONE 'Asia/Seoul')
            )
          )
    GROUP BY e.base_item_id
),
detail_agg AS (
    SELECT
        d.base_item_id,
        JSONB_AGG(
            JSONB_BUILD_OBJECT(
                'code', d.detail_code,
                'value', d.detail_value
            )
            ORDER BY d.sort_no
        ) AS details
    FROM detail d
    GROUP BY d.base_item_id
)
SELECT
    i.id,
    i.code,
    i.name,
    i.category,
    e.total_amount,
    e.event_count,
    COALESCE(d.details, '[]'::jsonb) AS details
FROM base_item i
JOIN event_agg e
  ON e.base_item_id = i.id
LEFT JOIN detail_agg d
  ON d.base_item_id = i.id;

event_aggdetail_agg는 항목당 최대 한 행만 만든다. 따라서 마지막 조인에서 행의 곱이 발생하지 않고 합계도 중복되지 않는다.

이벤트가 없는 항목까지 보여줘야 한다면 JOIN event_aggLEFT JOIN으로 바꾸고 다음처럼 기본값을 처리한다.

COALESCE(e.total_amount, 0) AS total_amount

조회 대상 항목이 전체 중 일부에 불과하고 detail이 매우 크다면 모든 상세를 먼저 집계하지 않도록 대상 ID를 별도 CTE로 제한하는 편이 낫다. 핵심은 집계 전에 불필요한 행을 줄이고, 조인 전에 1:N 관계를 1:1 형태로 만드는 것이다.

해결책 2: Spring Boot에서 고정 횟수로 분리 조회한다

화면이 기준 데이터와 하위 목록을 계층형 DTO로 사용한다면 분리 조회도 실용적이다. 단, 기준 행마다 하위 쿼리를 실행하면 안 된다.

1단계: 화면에 표시할 기준 항목 조회

SELECT
    id,
    code,
    name,
    category
FROM base_item
WHERE category = $1
ORDER BY id
LIMIT $2
OFFSET $3;

2단계: 조회된 ID의 이벤트를 한 번에 집계

SELECT
    e.base_item_id,
    SUM(e.amount) AS total_amount,
    COUNT(*) AS event_count
FROM event_log e
WHERE e.base_item_id = ANY($1::bigint[])
  AND e.event_date = $2::date
  AND (
        e.status = 'S'
        OR (
            e.status = 'W'
            AND e.event_at >=
                (statement_timestamp() AT TIME ZONE 'Asia/Seoul')
                - INTERVAL '10 minutes'
            AND e.event_at <=
                (statement_timestamp() AT TIME ZONE 'Asia/Seoul')
        )
      )
GROUP BY e.base_item_id;

3단계: 조회된 ID의 상세를 한 번에 조회

SELECT
    d.base_item_id,
    d.detail_code,
    d.detail_value
FROM detail d
WHERE d.base_item_id = ANY($1::bigint[])
ORDER BY d.base_item_id, d.sort_no;

Spring Boot에서는 합계와 상세 결과를 base_item_id 기준 Map으로 만든 뒤 기준 목록에 합친다. 페이지에 10건이 있든 100건이 있든 쿼리는 기본·합계·상세의 고정된 세 번이다.

반대로 다음 구조는 N+1 문제가 된다.

기준 목록 1회
각 항목의 합계 N회
각 항목의 상세 N회

네트워크 왕복, SQL 파싱·계획·실행, 커넥션 점유가 항목 수에 비례해 증가하므로 데이터가 늘수록 급격히 불리해진다.

분리 조회에도 두 가지 주의점이 있다.

첫째, 합계로 정렬하거나 합계를 조건으로 필터링한다면 기준 항목을 먼저 페이지 처리해서는 안 된다. 집계 결과에 따라 페이지 대상 자체가 바뀌기 때문이다. 이 경우 집계 후 정렬·페이지 처리하는 단일 SQL이나, 대상 ID를 먼저 계산하는 2단계 구조가 필요하다.

둘째, 기본 격리 수준인 READ COMMITTED에서는 각 SQL이 서로 다른 시점의 스냅샷을 볼 수 있다. 세 결과가 반드시 같은 시점이어야 한다면 짧은 읽기 전용 REPEATABLE READ 트랜잭션을 검토한다.

시간 자료형에 따라 최근 10분 조건이 달라진다

event_at이 서울 현지 시각을 저장한 timestamp without time zone이라면 다음 비교가 맞다.

e.event_at >=
    (statement_timestamp() AT TIME ZONE 'Asia/Seoul')
    - INTERVAL '10 minutes'
AND e.event_at <=
    (statement_timestamp() AT TIME ZONE 'Asia/Seoul')

event_attimestamptz라면 시간대를 제거하지 않고 절대 시각끼리 비교한다.

e.event_at >= statement_timestamp() - INTERVAL '10 minutes'
AND e.event_at <= statement_timestamp()

두 방식을 섞으면 세션 시간대와 암묵적 변환 때문에 경계 시각이 달라질 수 있다. 새 설계라면 이벤트 시각을 timestamptz로 저장하고 화면에 표시할 때 원하는 시간대로 변환하는 방법이 관리하기 쉽다.

하한만 두면 서버 시각보다 미래로 잘못 저장된 데이터도 포함될 수 있다. 정확한 “최근 10분” 범위라면 예제처럼 같은 statement_timestamp()를 사용한 상한도 함께 두는 편이 안전하다.

statement_timestamp()는 현재 SQL 문이 시작된 시각을 반환한다. 반면 CURRENT_TIMESTAMP는 현재 트랜잭션이 시작된 시각이다. 트랜잭션이 길어질 수 있다면 최근 10분의 기준이 무엇인지 명확히 선택해야 한다.

Spring에서 기준일은 문자열보다 LocalDate로 바인딩하는 편이 안전하다. 다음처럼 인덱스 대상 컬럼을 가공하는 조건은 피한다.

-- 일반 B-tree 인덱스를 그대로 활용하기 어려운 예
TO_CHAR(e.event_date, 'YYYYMMDD') = $1

함수는 컬럼이 아니라 파라미터 쪽에 적용하거나, 처음부터 날짜 타입으로 전달한다.

OR 조건은 UNION ALL과 함께 비교한다

SW가 서로 배타적인 상태라면 두 조건을 나눌 수 있다.

WITH candidate_event AS (
    SELECT base_item_id, amount
    FROM event_log
    WHERE event_date = $1::date
      AND status = 'S'

    UNION ALL

    SELECT base_item_id, amount
    FROM event_log
    WHERE event_date = $1::date
      AND status = 'W'
      AND event_at >=
          (statement_timestamp() AT TIME ZONE 'Asia/Seoul')
          - INTERVAL '10 minutes'
      AND event_at <=
          (statement_timestamp() AT TIME ZONE 'Asia/Seoul')
)
SELECT
    base_item_id,
    SUM(amount) AS total_amount
FROM candidate_event
GROUP BY base_item_id;

이 형태는 상태별 부분 인덱스를 각각 사용하기 쉬울 수 있다. 다만 UNION ALL이 무조건 빠른 것은 아니다. PostgreSQL은 원래의 OR 조건도 여러 인덱스를 BitmapOr로 결합할 수 있다. 동일한 파라미터와 데이터 상태에서 두 실행계획을 비교해야 한다.

인덱스는 조회 시작점에 맞춰 선택한다

날짜 조건으로 전체 대상 이벤트를 찾은 뒤 항목별로 집계하는 단일 SQL이 주된 방식이라면 다음 부분 인덱스를 후보로 볼 수 있다.

CREATE INDEX CONCURRENTLY idx_event_s_date_item
ON event_log (event_date, base_item_id)
INCLUDE (amount)
WHERE status = 'S';

CREATE INDEX CONCURRENTLY idx_event_w_date_time_item
ON event_log (event_date, event_at, base_item_id)
INCLUDE (amount)
WHERE status = 'W';

기준 항목 ID 목록을 먼저 확보하는 분리 조회가 주된 방식이라면 base_item_id를 선두에 둔 후보를 비교한다.

CREATE INDEX CONCURRENTLY idx_event_s_item_date
ON event_log (base_item_id, event_date)
INCLUDE (amount)
WHERE status = 'S';

CREATE INDEX CONCURRENTLY idx_event_w_item_date_time
ON event_log (base_item_id, event_date, event_at)
INCLUDE (amount)
WHERE status = 'W';

CREATE INDEX CONCURRENTLY idx_detail_item_sort
ON detail (base_item_id, sort_no);

두 인덱스 세트를 모두 만들라는 의미가 아니다. B-tree 복합 인덱스는 선두 컬럼의 영향을 크게 받으므로 실제 조회 시작점과 선택도에 맞는 최소 구성을 선택한다. 인덱스가 많아지면 저장 공간뿐 아니라 INSERT, UPDATE, VACUUM 비용도 증가한다.

최근 10분처럼 계속 움직이는 조건은 부분 인덱스의 WHERE 절에 넣을 수 없다.

-- 만들면 안 되는 부분 인덱스 조건
WHERE event_at >= now() - INTERVAL '10 minutes'

인덱스 정의의 표현식과 조건에는 불변성이 필요하다. 부분 인덱스에는 고정 상태값인 S, W를 사용하고 현재 시각 조건은 조회 시점에 적용한다.

부분 인덱스는 쿼리 조건이 인덱스의 WHERE 조건을 포함한다는 사실을 계획 시점에 증명할 수 있어야 한다. 상태까지 status = $2처럼 매개변수화하면 일반화된 실행계획에서 status = 'S' 또는 status = 'W' 부분 인덱스를 선택하지 못할 수 있으므로 실제 prepared statement 계획도 확인한다.

INCLUDE (amount)도 항상 이득은 아니다. 인덱스가 커지고 쓰기 비용이 증가한다. 테이블 페이지의 visibility map 상태에 따라 Index Only Scan의 실제 효과도 달라지므로 실행계획으로 확인한다.

운영 서버에서 CREATE INDEX CONCURRENTLY를 사용하면 일반 인덱스 생성보다 쓰기 차단을 줄일 수 있지만, 더 오래 걸리고 트랜잭션 블록 안에서 실행할 수 없다. 적용 전에 용량·부하·배포 방식을 별도로 검토한다.

Seq Scan은 실패가 아니다

실행계획에 Seq Scan이 보인다는 이유만으로 인덱스가 동작하지 않는다고 결론내리면 안 된다.

  • 테이블이 작을 때
  • 조건에 해당하는 행 비율이 높을 때
  • 대부분의 데이터 페이지를 결국 읽어야 할 때

이런 경우에는 순차 읽기가 인덱스를 따라 여러 페이지를 방문하는 것보다 저렴할 수 있다.

Bitmap Index Scan과 Bitmap Heap Scan은 인덱스로 대상 위치를 모은 뒤 테이블 페이지 단위로 읽는다. 여러 위치에 흩어진 중간 규모의 행을 조회하거나 복수 인덱스를 결합할 때 합리적인 선택이 될 수 있다.

스캔 이름보다 실제 처리 행, 반복 횟수, 버퍼 사용량과 총 실행 시간을 봐야 한다.

EXPLAIN ANALYZE에서 확인할 항목

EXPLAIN (
    ANALYZE,
    BUFFERS,
    SETTINGS,
    VERBOSE
)
SELECT ...;

ANALYZE는 쿼리를 실제로 실행한다. 조회 쿼리도 운영 부하를 만들 수 있고, 변경 쿼리는 실제 데이터를 변경하므로 운영 환경에서는 실행 범위를 주의한다.

중요한 확인 순서는 다음과 같다.

  1. 예상 rows와 실제 rows 차이가 큰가
  2. 조인 직후 행 수가 원본보다 몇 배 증가하는가
  3. Nested Loop의 loops가 예상보다 큰가
  4. Rows Removed by Filter가 지나치게 많은가
  5. Hash와 Sort가 메모리를 넘겨 디스크를 사용했는가
  6. shared hit, shared read, 임시 블록은 얼마나 발생했는가
  7. Planning Time과 Execution Time은 각각 얼마인가
  8. 최종 반환 행 수와 네트워크 전송량은 적절한가

actual rows는 해당 노드가 한 번 실행될 때 출력한 평균 행 수이고 loops는 실행 횟수다. 실제 총 처리량을 볼 때는 두 값을 함께 해석한다. 상위 노드의 버퍼 수에는 하위 노드 사용량이 포함되므로 부모와 자식의 shared hit/read를 단순 합산해서도 안 된다.

추정 행과 실제 행이 크게 다르면 통계가 오래됐거나 컬럼 간 상관관계를 기본 통계가 충분히 표현하지 못했을 수 있다. 이때는 ANALYZE, 통계 대상 수 조정, 확장 통계를 검토한다.

shared hit이 높고 read가 거의 0이면 빠른 쿼리일까

EXPLAIN (ANALYZE, BUFFERS)pg_stat_statements에서 shared_blks_hit가 증가하고 shared_blks_read가 거의 없다면 PostgreSQL shared buffer에서 블록을 찾은 비중이 높았다는 뜻이다.

하지만 디스크를 읽지 않았다는 뜻만으로 쿼리가 효율적이라고 볼 수는 없다.

  • 잘못된 다중 조인으로 많은 캐시 블록을 반복 방문할 수 있다.
  • 중간 행이 커져 CPU 집계 비용이 증가할 수 있다.
  • 애플리케이션 객체 변환과 네트워크 시간은 DB 실행 시간에 포함되지 않을 수 있다.
  • 운영체제 페이지 캐시에서 읽은 블록은 PostgreSQL 관점에서 read로 집계될 수 있다.

따라서 캐시 적중률 하나보다 호출당 블록 수와 처리 행 수를 같이 비교해야 한다.

또한 EXPLAIN ANALYZE의 Execution Time에는 클라이언트로 결과를 전송하는 전체 네트워크 비용이 포함되지 않는다. 분리 조회와 단일 조회를 비교할 때는 Spring Boot에서 본 전체 응답 시간도 반드시 함께 측정한다.

pg_stat_statements의 평균 약 0.3초를 해석하는 법

당시 관찰한 평균 실행 시간은 약 0.3초, 즉 약 300ms 수준이었다. 이 숫자 하나만으로 빠르거나 느리다고 결론내릴 수는 없다.

SELECT
    queryid,
    calls,
    rows,
    ROUND(mean_exec_time::numeric, 2) AS mean_exec_ms,
    ROUND(total_exec_time::numeric, 2) AS total_exec_ms,
    ROUND(rows::numeric / NULLIF(calls, 0), 1) AS rows_per_call,
    ROUND(
        shared_blks_hit::numeric / NULLIF(calls, 0),
        1
    ) AS hit_blocks_per_call,
    ROUND(
        shared_blks_read::numeric / NULLIF(calls, 0),
        1
    ) AS read_blocks_per_call,
    temp_blks_read,
    temp_blks_written
FROM pg_stat_statements
WHERE query ILIKE '%event_log%'
ORDER BY total_exec_time DESC;

해석할 때는 다음을 함께 본다.

  • calls: 누적 호출 수
  • mean_exec_time: 평균 DB 실행 시간
  • total_exec_time: 누적 DB 점유 시간
  • rows: 반환하거나 영향을 준 행의 누계
  • shared_blks_hit: shared buffer에서 찾은 블록 수
  • shared_blks_read: shared buffer에 없어서 읽은 블록 수
  • temp_blks_read, temp_blks_written: 정렬·해시 등의 임시 파일 사용 신호

rows는 조인 중간 행 수가 아니라 최종 반환·영향 행의 누계다. 조인 폭증 여부는 EXPLAIN ANALYZE의 각 노드에서 확인해야 한다.

또한 평균은 순간적인 지연을 숨긴다. 호출 빈도가 높으면 평균이 작아도 total_exec_time이 커질 수 있고, 호출이 적다면 평균 300ms가 서비스에 미치는 영향은 작을 수 있다. 서로 다른 통계 수집 기간이나 pg_stat_statements_reset() 전후의 값을 직접 비교해서도 안 된다.

최종 판단에는 Spring Boot에서 측정한 전체 응답 시간, 커넥션 대기, 네트워크 왕복, JSON 변환 시간도 포함한다.

어떤 방식을 선택하면 될까

방식 장점 주의점
원본 다중 조인 DB 왕복 1회 행 곱과 집계 중복 위험
선집계 후 조인 정확한 집계, 한 시점의 결과 SQL과 JSON 집계가 복잡해질 수 있음
고정 횟수 분리 조회 계층형 DTO 조립이 쉬움, 행 곱 방지 왕복과 애플리케이션 병합 비용
행별 개별 조회 처음 구현은 단순해 보임 N+1 발생, 데이터 증가 시 급격히 악화

단일 관계형 결과가 필요하다면 하위 테이블을 먼저 선집계한 뒤 조인하는 방법을 우선 검토한다. 화면에서 하위 목록을 별도 구조로 조립해야 한다면 기준 항목을 먼저 페이지 조회하고, 해당 ID의 합계와 상세를 각각 한 번씩 가져오는 고정 횟수 분리 조회가 실용적이다.

어느 방식이든 다음 조건을 맞춘 뒤 비교해야 한다.

  • 같은 파라미터와 같은 데이터 범위
  • 같은 결과와 같은 정렬·페이지 조건
  • warm cache와 cold cache 구분
  • EXPLAIN (ANALYZE, BUFFERS) 결과
  • 여러 번 실행한 분포와 동시 부하
  • Spring Boot 전체 응답 시간

쿼리 개수만 줄이는 것이 목표가 아니다. 정확한 결과를 가장 작은 중간 데이터와 예측 가능한 비용으로 만드는 것이 목표다.

참고 문서

2026년 8월 20일 현재 아래 current 링크는 PostgreSQL 18 문서를 가리킨다. 실제 운영 버전이 다르면 동일 항목의 해당 버전 문서도 함께 확인한다.

공식 문서 확인일: 2026-08-20

태그