> ## Content Index
> Fetch the complete content index at: https://mlog.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# PostgreSQL JOIN·GROUP BY와 분리 조회 성능 비교: 1:N SUM 중복부터 인덱스까지
- URL: https://mlog.me/postgresql-join-group-by-vs-split-query-performance/
- Published: 2026-08-19T15:30:01.000Z
- Updated: 2026-09-23T03:23:52.000Z
- Description: JOIN 한 번과 여러 번의 분리 조회 중 무엇이 빠를까? 1:N 조인의 SUM 중복, 선집계, N+1, 인덱스, EXPLAIN·pg_stat_statements 해석까지 실전 기준으로 정리합니다.
- Author: mLog
- Tags: 데이터베이스, PostgreSQL, SQL 튜닝

기준 데이터와 여러 하위 목록을 조회할 때는 한 번의 JOIN으로 가져올지, 고정된 여러 SQL로 나눠 애플리케이션에서 합칠지 결정해야 한다. 이 글은 두 방식의 합계 정확성, 페이지 처리, DB 왕복 횟수와 데이터 일관성을 비교한다. 본문 끝에는 같은 금액을 가진 이벤트에서 SUM(DISTINCT)가 실패하는 반례와, 선집계 결과를 3회 분리 조회 후 병합한 결과와 대조한 실행 검증을 담았다. 이 검증은 결과의 일치를 확인한 것이며, 어느 방식이 더 빠른지를 측정한 성능 비교는 아니다.

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

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

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

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

## 예제 데이터 구조

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

```text
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_log`와 `detail`이 각각 여러 건 존재한다. 이벤트 조회 조건은 다음과 같다.

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

## 문제가 될 수 있는 다중 조인

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

```sql
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)`도 이벤트 건수만큼 부풀 수 있다.

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

```

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

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

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

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

```sql
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_agg`와 `detail_agg`는 항목당 최대 한 행만 만든다. 따라서 마지막 조인에서 행의 곱이 발생하지 않고 합계도 중복되지 않는다.

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

```sql
COALESCE(e.total_amount, 0) AS total_amount

```

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

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

화면이 기준 데이터와 하위 목록을 계층형 DTO로 사용한다면 분리 조회도 실용적이다. 단, 기준 행마다 하위 쿼리를 실행하면 안 된다. 이 분리 조회 예시는 선택한 카테고리의 기준 항목을 먼저 가져오므로 조건에 맞는 이벤트가 없는 항목도 포함한다. 반면 앞의 선집계 예시는 이벤트가 있는 항목만 반환한다. 같은 결과로 성능을 비교하려면 앞의 예시를 LEFT JOIN event\_agg로 바꾸거나, 분리 조회의 첫 쿼리에 같은 이벤트 조건의 EXISTS를 넣어 페이지 처리 전에 조회 대상을 맞춰야 한다. 최근 10분 경계도 맞추려면 조회 시작 전에 기준 시각 asOf를 한 번 정하고 요청 전체에서 재사용한다. 아래 2단계의 $3에는 그 동일한 절대 시각을 timestamptz로 바인딩한다. 첫 쿼리에 EXISTS를 추가하는 경우나 앞의 단일 SQL과 결과를 비교하는 경우에도 같은 asOf를 사용한다.

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

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

```

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

```sql
-- $3: 조회 시작 전에 한 번 정해 모든 관련 쿼리에 공유하는 asOf
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 >=
                ($3::timestamptz AT TIME ZONE 'Asia/Seoul')
                - INTERVAL '10 minutes'
            AND e.event_at <=
                ($3::timestamptz AT TIME ZONE 'Asia/Seoul')
        )
      )
GROUP BY e.base_item_id;

```

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

```sql
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 문제가 된다.

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

```

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

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

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

둘째, 기본 격리 수준인 `READ COMMITTED`에서는 각 SQL이 서로 다른 시점의 스냅샷을 볼 수 있다. 세 결과가 같은 데이터 스냅샷을 봐야 한다면 짧은 읽기 전용 `REPEATABLE READ` 트랜잭션을 검토한다. 다만 스냅샷 고정과 시간 조건 고정은 별개다. 같은 트랜잭션 안에서도 statement\_timestamp()는 SQL마다 달라질 수 있으므로, 최근 10분의 경계까지 같아야 한다면 위 asOf처럼 한 번 정한 값을 모든 관련 쿼리에 공유해야 한다.

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

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

```sql
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_at`이 `timestamptz`라면 시간대를 제거하지 않고 절대 시각끼리 비교한다.

```sql
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`로 바인딩하는 편이 안전하다. 다음처럼 인덱스 대상 컬럼을 가공하는 조건은 피한다.

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

```

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

## 상태 조건의 최적화는 조회 방식과 나눠 비교한다

S와 W처럼 서로 겹치지 않는 상태 조건은 OR로 묶거나 UNION ALL로 나눌 수 있다. 이 선택은 한 SQL로 조회할지 여러 SQL로 나눌지와 별개의 문제다. 조회 구조와 상태 조건을 한꺼번에 바꾸면 무엇이 결과와 비용을 바꿨는지 판단하기 어려우므로, 먼저 같은 조회 범위와 기준 시각으로 결과가 일치하는지 확인한다.

상태별 SQL 분기와 부분 인덱스를 함께 구성하는 예시는 [노선·예약 조회의 선집계와 부분 인덱스](https://mlog.me/postgresql-query-optimization-pre-aggregation-partial-index/)에서 다룬다. 그 글에는 OR와 UNION ALL의 최근 10분 경계값을 대조한 실행 결과도 있다. 실제 성능은 데이터 분포와 실행계획에 따라 달라지므로, 이 글의 단일 조회·분리 조회 비교에서는 상태 조건을 같게 유지한 뒤 왕복 횟수와 전체 응답 시간을 비교한다.

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

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

```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`를 선두에 둔 후보를 비교한다.

```sql
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` 절에 넣을 수 없다.

```sql
-- 만들면 안 되는 부분 인덱스 조건
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에서 확인할 항목

```sql
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 수준이었다. 이 숫자 하나만으로 빠르거나 느리다고 결론내릴 수는 없다.

```sql
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 문서를 가리킨다. 실제 운영 버전이 다르면 동일 항목의 해당 버전 문서도 함께 확인한다.

- [PostgreSQL: EXPLAIN 사용법](https://www.postgresql.org/docs/current/using-explain.html?ref=mlog.me)
- [PostgreSQL: EXPLAIN 명령](https://www.postgresql.org/docs/current/sql-explain.html?ref=mlog.me)
- [PostgreSQL: JOIN·GROUP BY와 테이블 표현식](https://www.postgresql.org/docs/current/queries-table-expressions.html?ref=mlog.me)
- [PostgreSQL: pg\_stat\_statements](https://www.postgresql.org/docs/current/pgstatstatements.html?ref=mlog.me)
- [PostgreSQL: 누적 통계와 I/O 해석](https://www.postgresql.org/docs/current/monitoring-stats.html?ref=mlog.me)
- [PostgreSQL: 날짜와 시간 함수](https://www.postgresql.org/docs/current/functions-datetime.html?ref=mlog.me)
- [PostgreSQL: 트랜잭션 격리](https://www.postgresql.org/docs/current/transaction-iso.html?ref=mlog.me)
- [PostgreSQL: 복합 인덱스](https://www.postgresql.org/docs/current/indexes-multicolumn.html?ref=mlog.me)
- [PostgreSQL: 부분 인덱스](https://www.postgresql.org/docs/current/indexes-partial.html?ref=mlog.me)
- [PostgreSQL: 인덱스 결합과 Bitmap Scan](https://www.postgresql.org/docs/current/indexes-bitmap-scans.html?ref=mlog.me)
- [PostgreSQL: Index Only Scan과 INCLUDE](https://www.postgresql.org/docs/current/indexes-index-only-scans.html?ref=mlog.me)
- [PostgreSQL: CREATE INDEX](https://www.postgresql.org/docs/current/sql-createindex.html?ref=mlog.me)
- [PostgreSQL: 플래너 통계](https://www.postgresql.org/docs/current/planner-stats.html?ref=mlog.me)

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

## 추가 실행 검증: SUM(DISTINCT)가 우연히 맞는 사례와 틀리는 사례

2026-09-09에 본문의 `base_item`·`event_log`·`detail` 구조를 별도 PostgreSQL 엔진에서 실행해 합계를 대조했다. 환경은 Node.js 24.19.0과 PGlite 0.5.8의 PostgreSQL 18.3(WebAssembly·메모리 DB)이다. 각 항목에 이벤트 2건과 상세 3건을 넣고, 이벤트 금액만 두 가지로 바꿨다. 이벤트는 모두 동일 기준일의 S 상태로 고정해, 이 검증에서는 시간 경계가 합계에 영향을 주지 않도록 했다.

| 금액 입력값 | 정상 합계 | 본문 원본 다중 조인의 SUM | SUM(DISTINCT amount) | 선집계 | 3회 분리 조회 후 병합 |
| ------ | ----- | ---------------- | -------------------- | --- | ------------- |
| 10, 20 | 30    | 90               | 30                   | 30  | 30            |
| 10, 10 | 20    | 60               | 10                   | 20  | 20            |

두 경우 모두 원본 조인은 항목당 6행을 만들었고, 실제 3건인 상세의 `COUNT(d.id)`도 6으로 나왔다. 선집계에서는 이벤트 수 2와 상세 목록 3건이 보존되었다.

처음 입력값인 10과 20만 보면 `SUM(DISTINCT amount)`가 문제를 고친 것처럼 보인다. 하지만 서로 다른 이벤트에 같은 금액 10을 넣자 합계가 20이 아닌 10으로 줄었다. **이벤트의 중복을 제거해야 하는 상황에서 금액 값의 중복을 제거한 것이 원인**이다. 이 반례 때문에 금액의 유일성을 기대하는 보정 대신 이벤트·상세를 각각 집계하는 형태를 유지했다.

분리 조회는 본문의 기준 항목·이벤트 집계·상세 목록 SQL을 실제로 세 번 실행하고, 결과를 ID 기준 Map으로 병합했다. 비교한 두 항목에서 선집계 결과와 금액·이벤트 수·상세 내용·상세 순서가 모두 같았다. 이번 데이터는 두 항목 모두 조건에 맞는 이벤트가 있으므로, 이벤트가 없는 항목의 INNER/LEFT JOIN 차이까지 검증한 것은 아니다.

이번 병합은 JavaScript 검증 코드에서 수행했으며 Spring Boot 애플리케이션을 기동한 테스트는 아니다. 확인한 범위는 **이 데이터에서의 결과 정확성과 세 SQL 결과의 병합 일치**다. 네트워크 왕복·커넥션 대기·동시 수정·응답 시간은 측정하지 않았고, 기존 운영 평균 약 0.3초를 재현하거나 JOIN과 분리 조회 중 빠른 쪽을 결정한 실험도 아니다.