PostgreSQL

PostgreSQL: work_mem

dewstream 2025. 5. 27. 08:00
728x90

※ PostgreSQL: work_mem.

 

안녕하세요. 듀스트림입니다.

 

오늘은 PostgreSQL 성능 관점에서 가장 중요한 메모리 파라미터 중 하나인 work_mem에 대해 자세히 알아보겠습니다.

 

동작 방식과 적용 방식에 대해 오해하시는 분들이 많은 거 같아서 작성해 봤습니다.


1. work_mem이란?

work_mem은 PostgreSQL에서 정렬(Sort), 해시 테이블 빌드(Hash Join, Hash Aggregate), 정렬 기반 연산(WindowAgg, SetOp) 등과 같은 Plan 노드 단위의 작업에 사용되는 세션 로컬 임시 메모리의 상한값입니다.

 

각 노드마다 독립적으로 할당되며, 초과 시 PostgreSQL은 자동으로 디스크 임시 파일로 스필(spill) 처리하지만, 일부 표현식 평가(ExprContext) 등은 spill을 지원하지 않아 OOM이 발생할 수 있습니다.

 

# postgresql.conf
work_mem = 4MB   # 기본값

# postgresql.conf 파일에서 work_mem 값을 변경한 후, pg_ctl reload 명령을 실행하면 설정 변경 사항이 반영됩니다.  
# 이 설정은 세션 단위로 적용되기 때문에 기존 연결 세션에는 적용되지 않으며, 이후 새롭게 연결되는 세션부터 적용됩니다.
  • 할당 단위: Plan 노드 1개당 1번씩
  • 적용 단위: 세션 (SET 명령으로 적용 가능)
  • 초과 시 동작: Sort, Hash는 Disk Spill → external sort, batch hash join 등으로 전환

2. Plan 노드 단위로 할당된다는 의미

공식 문서에 따르면, work_mem은 쿼리 전체에 대해 하나만 할당되는 것이 아니라, 실행 계획의 각 Plan 노드(Sort, Hash Join, Aggregate 등)마다 독립적인 별도 메모리로 할당됩니다.

 

PostgreSQL 공식 문서

Summary.
“This value limits the amount of memory used by internal sort operations and hash tables before writing to temporary disk files. Note that for a complex query, several sort or hash operations might be running in parallel; each one will be allowed to use as much memory as this value before it starts to write data into temporary files.”

3. 실제 메모리 사용량 계산식

이 계산식은 Plan 노드가 동시에 실행될 경우의 최대 메모리 사용량을 기준으로 합니다.
실제 peak는 이보다 작을 수 있으며, 복잡한 쿼리에서는 이 수치를 넘어서 OOM이 발생할 수도 있습니다.
최대 총 사용량 ≒ work_mem × 병렬 프로세스 수 × 메모리 사용 Plan 노드 수

# 예제
work_mem = 64MB
병렬 worker 수 = 2 (Leader 포함 총 3개 프로세스)
Plan 노드 수 = Sort, Aggregate, Hash Join = 3개
→ 64MB × 3 × 3 = 576MB
구성 요소 설명
work_mem Plan 노드 1개 기준의 최대 메모리
병렬 프로세스 수 leader + 병렬 worker 수
Plan 노드 수 Sort, Aggregate, Hash Join 등

관련 소스 코드

1. Sort 노드

estate->es_sort_mem = work_mem;
tuplesort_begin_heap(...)  // es_sort_mem 값 전달
  • 정렬 알고리즘에서 메모리 제한은 work_mem을 기반으로 tuplesort_begin_heap() 함수에 전달되어 적용됩니다.

 

2. Hash Join

hashtable->spaceAllowed = work_mem * 1024L;
  • Hash Join은 조인 대상 테이블의 해시 테이블을 work_mem을 기반으로 메모리에 생성하며, 입력 row가 많거나 해시 키 분포가 고르지 않아 work_mem을 초과하게 되면, PostgreSQL은 batching (배치 분할 처리) 기법을 사용하여 디스크에 일부 데이터를 spill하고 여러 번에 나누어 조인을 수행합니다.

 

3. Hash Aggregate

  • 파일: execGrouping.c
  • 핵심 함수: build_hash_table()
  • HashAggregate는 내부적으로 build_hash_table()에서 spaceAllowed = work_mem * 1024L을 설정하여 해시 테이블을 메모리에 생성합니다. 초과 시 배치 분할 처리로 전환됩니다. (Hash Join과 비슷하게 처리됩니다.)

 

4. 메모리 컨텍스트 할당 실패

  • 파일: aset.c
  • 함수: AllocSetAlloc()
  • PostgreSQL의 모든 메모리 요청은 내부적으로 AllocSetAlloc()을 통해 처리되며, 요청 크기가 너무 크거나 spill 불가능한 컨텍스트(예: ExprContext)에서는 OOM이 발생합니다.

 

정리

항목 소스 파일 함수 설명
Sort nodeSort.c ExecInitSort() work_mem 기반 메모리 정렬 설정
HashJoin nodeHash.c ExecHashJoin() 내부 해시 빌드 시 work_mem 사용
HashAggregate execGrouping.c build_hash_table() work_mem만큼 해시 메모리 제한
메모리 실패 aset.c AllocSetAlloc() ExprContext 등 spill 불가 컨텍스트에서 OOM 발생

4. OOM이 발생하는 이유

PostgreSQL의 work_mem은 Sort, HashAggregate, HashJoin 등 일부 Plan 노드에서는 spill-to-disk 기능이 제공되지만, 모든 Plan 노드가 spill을 지원하는 것은 아닙니다.

특히 ExprContext, FunctionScan, 사용자 정의 함수(UDF) 또는 복잡한 표현식 평가 중에는 메모리를 직접 할당하며, 이 메모리는 spill되지 않습니다.

이 경우 메모리 사용량이 시스템 제한을 초과하면 PostgreSQL 프로세스는 OOM (out of memory) 에러를 발생시킵니다.

ERROR:  out of memory
DETAIL:  Failed on request of size 782400677 in memory context "ExprContext"

관련 소스 코드

1. aset.c – PostgreSQL의 기본 메모리 할당기

if (chunk == NULL)
  elog(ERROR, "out of memory");
  • PostgreSQL의 모든 메모리 요청(palloc, MemoryContextAlloc)은 이 함수에서 처리됩니다.
  • 할당이 실패하면 여기서 "out of memory" 에러가 발생합니다.

 

2. execExpr.c – ExprContext가 사용되는 대표 위치

  • 파일: execExpr.c
  • 핵심 구조: ExprContext
  • 사용 예: ExecInterpExpr()
  • 설명:
    • SQL에서 CASE, WHERE, json_agg(...), fn(...) 등 복잡한 표현식들은 실행 시 ExprContext에 메모리를 할당합니다.
    • 이 컨텍스트는 ExprContext->ecxt_per_tuple_memory에 연결되며, 정렬처럼 spill이 불가능합니다.

5. 예제

테스트 환경

CPU: 4core
총 메모리: 8GB
work_mem = 20MB

 

Case 1: work_mem 내 처리

테스트 테이블 생성

DROP TABLE IF EXISTS test_table;

CREATE TABLE test_table (
    id serial PRIMARY KEY,
    category int,
    description text
);

 

테스트 데이터 삽입

-- 1억 건 삽입 (중복 카테고리 포함)
INSERT INTO test_table (category, description)
SELECT
    (random() * 100)::int,
    repeat('Sample Text ', (random() * 10)::int)
FROM generate_series(1, 100000000);

 

수행 SQL

EXPLAIN (ANALYZE, BUFFERS)
SELECT category, COUNT(*)
FROM test_table
GROUP BY category
ORDER BY COUNT(*) DESC;

 

실행계획

--- QUERY PLAN ---
 Sort  (cost=1920124.47..1920124.73 rows=101 width=12) (actual time=66918.403..66961.120 rows=101 loops=1)
   Sort Key: (count(*)) DESC
   Sort Method: quicksort  Memory: 28kB
   Buffers: shared hit=14 read=1253284 dirtied=1253284 written=100841
   ->  Finalize GroupAggregate  (cost=1920095.52..1920121.11 rows=101 width=12) (actual time=66918.280..66961.065 rows=101 loops=1)
         Group Key: category
         Buffers: shared hit=14 read=1253284 dirtied=1253284 written=100841
         ->  Gather Merge  (cost=1920095.52..1920119.09 rows=202 width=12) (actual time=66918.260..66961.013 rows=303 loops=1)
               Workers Planned: 2
               Workers Launched: 2
               Buffers: shared hit=14 read=1253284 dirtied=1253284 written=100841
               ->  Sort  (cost=1919095.50..1919095.75 rows=101 width=12) (actual time=66908.551..66908.556 rows=101 loops=3)
                     Sort Key: category
                     Sort Method: quicksort  Memory: 28kB
                     Buffers: shared hit=14 read=1253284 dirtied=1253284 written=100841
                     Worker 0:  Sort Method: quicksort  Memory: 28kB
                     Worker 1:  Sort Method: quicksort  Memory: 28kB
                     ->  Partial HashAggregate  (cost=1919091.13..1919092.14 rows=101 width=12) (actual time=66908.476..66908.486 rows=101 loops=3)
                           Group Key: category
                           Batches: 1  Memory Usage: 24kB
                           Buffers: shared read=1253284 dirtied=1253284 written=100841
                           Worker 0:  Batches: 1  Memory Usage: 24kB
                           Worker 1:  Batches: 1  Memory Usage: 24kB
                           ->  Parallel Seq Scan on test_table  (cost=0.00..1697155.42 rows=44387142 width=4) (actual time=0.664..62200.756 rows=33333333 loops=3)
                                 Buffers: shared read=1253284 dirtied=1253284 written=100841
 Planning:
   Buffers: shared hit=1 read=1
 Planning Time: 1.266 ms
 Execution Time: 66961.226 ms
(29 rows)

[그림 1] case1 실행계획

 

실행계획 분석

항목
처리된 행 수 약 1억 건 (33,333,333 rows × 3 workers)
실행 시간 약 66.9초
병렬 처리 사용 (Leader + 2 Workers)
디스크 I/O 매우 큼 (read: 1,253,284 blocks, write: 100,841 blocks)
스필 여부 Sort Method: quicksort, 메모리 내 처리 → 스필 없음
메모리 사용량 Sort: 28kB, HashAggregate: 24kB → 여유 있음

 

1. Parallel Seq Scan

  • 각 워커가 병렬로 테이블 전체 스캔
  • 총 33,333,333 rows × 3 = 1억 rows 처리
  • Buffers:
    • read=1,253,284 blocks ≒ 약 10.0GB (1 block = 8KB)
    • dirtied=1,253,284, written=100,841 → shared buffer write-back 존재

📌 I/O 부하 매우 큼, 전체 데이터를 full scan

 

2. Partial HashAggregate (병렬 집계)

  • 각 워커에서 category 기준 partial 집계
  • Memory Usage: 24kB, Batches: 1 → work_mem 내에서 hash table 처리 완료
  • Spill 없이 한 번에 처리됨

 

3. Sort (Leader + 각 워커)

  • Leader + 각 워커에서 정렬 (category, count(*))
  • Sort Method: quicksort → 메모리 내 처리
  • Memory: 28kB 사용 → work_mem 내 여유 있게 처리됨

 

4. Gather Merge + Finalize GroupAggregate

  • Gather Merge는 워커 결과를 병합 정렬
  • Finalize GroupAggregate는 Leader가 최종 집계 수행

📌 병렬 처리 구조가 잘 잡혀 있으나, 병렬 I/O 처리량이 너무 커서 실행 시간은 길어짐

 

work_mem 사용량 추정

요소 수량 per-instance 예상 사용량
Partial HashAggregate 3 (각 worker) 24kB
Sort (worker) 3 28kB
Sort (Leader) 1 28kB
Finalize Aggregate (Leader) 1 작음
24kB × 3 (Hash) + 28kB × 4 (Sorts) ≒ 약 200kB
  • 집계 대상은 많지만, 정렬 대상이 작아서 work_mem 설정값 대비 작은 메모리가 사용됨

Case 2: Disk Spill 유도

Truncate 후 테스트 데이터 삽입

TRUNCATE test_table;

-- 1억 건 삽입 (중복 카테고리 포함)
INSERT INTO test_table (category, description)
SELECT
    (random() * 10000)::int,
    repeat('Sample Text ', (random() * 100)::int)
FROM generate_series(1, 100000000);

 

수행 SQL

EXPLAIN (ANALYZE, BUFFERS)
WITH base AS (
  SELECT
    id,
    category,
    description,
    row_number() OVER (PARTITION BY category ORDER BY length(description) DESC) AS rn
  FROM test_table
  WHERE description ILIKE '%sample%'
),
filtered AS (
  SELECT *
  FROM base
  WHERE rn <= 1000
),
aggregated AS (
  SELECT
    category,
    count(*) AS cnt,
    array_agg(description ORDER BY id DESC) AS desc_arr,
    json_agg(json_build_object(
      'id', id,
      'desc', description,
      'len', length(description)
    )) AS desc_json
  FROM filtered
  GROUP BY category
)
SELECT *
FROM aggregated
ORDER BY cnt DESC
LIMIT 50;

 

실행계획

--- QUERY PLAN ---
 Limit  (cost=44751051.24..44751051.36 rows=50 width=76) (actual time=900462.259..900815.590 rows=50 loops=1)
   Buffers: shared hit=259225 read=7734973, temp read=18173848 written=21010388
   ->  Sort  (cost=44751051.24..44751076.23 rows=9995 width=76) (actual time=900456.844..900810.138 rows=50 loops=1)
         Sort Key: (count(*)) DESC
         Sort Method: external merge  Disk: 22811264kB
         Buffers: shared hit=259225 read=7734973, temp read=18173848 written=21010388
         ->  GroupAggregate  (cost=24402984.25..44750719.21 rows=9995 width=76) (actual time=248261.722..840631.845 rows=10001 loops=1)
               Group Key: base.category
               Buffers: shared hit=259225 read=7734973, temp read=15303252 written=15306486
               ->  Incremental Sort  (cost=24402984.25..43474711.12 rows=85057211 width=610) (actual time=248252.242..780240.585 rows=10001000 loops=1)
                     Sort Key: base.category, base.id DESC
                     Presorted Key: base.category
                     Full-sort Groups: 10001  Sort Method: quicksort  Average Memory: 102kB  Peak Memory: 102kB
                     Pre-sorted Groups: 10001  Sort Method: quicksort  Average Memory: 1151kB  Peak Memory: 1152kB
                     Buffers: shared hit=259225 read=7734973, temp read=15303252 written=15306486
                     ->  Subquery Scan on base  (cost=24401182.33..36859212.43 rows=85057211 width=610) (actual time=248249.825..776738.334 rows=10001000 loops=1)
                           Buffers: shared hit=259225 read=7734973, temp read=15303252 written=15306486
                           ->  WindowAgg  (cost=24401182.33..36008640.32 rows=85057211 width=622) (actual time=248249.823..775774.739 rows=10001000 loops=1)
                                 Run Condition: (row_number() OVER (?) <= 1000)
                                 Buffers: shared hit=259225 read=7734973, temp read=15303252 written=15306486
                                 ->  Gather Merge  (cost=24401182.20..34307496.10 rows=85057211 width=614) (actual time=248249.809..767864.359 rows=99499246 loops=1)
                                       Workers Planned: 2
                                       Workers Launched: 2
                                       Buffers: shared hit=259225 read=7734973, temp read=15303252 written=15306486
                                       ->  Sort  (cost=24400182.18..24488783.44 rows=35440505 width=614) (actual time=247924.084..425705.118 rows=33166415 loops=3)
                                             Sort Key: test_table.category, (length(test_table.description)) DESC
                                             Sort Method: external merge  Disk: 20415248kB
                                             Buffers: shared hit=259225 read=7734973, temp read=15303252 written=15306486
                                             Worker 0:  Sort Method: external merge  Disk: 20415360kB
                                             Worker 1:  Sort Method: external merge  Disk: 20382744kB
                                             ->  Parallel Seq Scan on test_table  (cost=0.00..8604099.51 rows=35440505 width=614) (actual time=0.623..118444.806 rows=33166415 loops=3)
                                                   Filter: (description ~~* '%sample%'::text)
                                                   Rows Removed by Filter: 166918
                                                   Buffers: shared hit=259145 read=7734973
 Planning:
   Buffers: shared hit=43
 Planning Time: 4.221 ms
 Execution Time: 901432.269 ms
(38 rows)

[그림 2] case2 실행 계획

 

실행계획 키워드

키워드 설명
external merge work_mem 초과 → 디스크 기반 정렬
Disk: ...kB 해당 Plan 노드의 임시 디스크 사용량
WindowAgg row_number() 등 순위 함수 처리
Incremental Sort 미리 정렬된 키를 기반으로 빠르게 정렬
Gather Merge 병렬로 정렬된 데이터를 Leader가 병합

 

실행계획 분석

항목
전체 소요 시간 약 15분 (901초)
총 처리 row 수 약 1억 row (~100,010,000)
병렬 처리 Leader + 2 Workers → 3개 프로세스
정렬 방식 external merge sort (디스크 스필 발생)
디스크 스필량 임시 파일 총 약 22GB 이상 (22811264kB)
temp read/write 1.8억 블록 read / 2.1억 블록 write
주요 I/O 병목 지점 정렬(Sort), WindowAgg, Incremental Sort

 

1. Parallel Seq Scan

Parallel Seq Scan on test_table ... rows=33,166,415 loops=3
  • 병렬로 테이블을 읽음 (총 약 1억 row)
  • description ILIKE '%sample%'는 인덱스를 타지 않으므로 Full Scan
  • Buffers read = 7,735,000 blocks (약 59GB)

📌 I/O 병목 시작 지점으로 병목을 줄이려면, description에 GIN/GiST 인덱스 + pg_trgm 확장 고려 또는 사전 전처리된 description_tsvector 활용

 

2. Sort (WindowAgg 내부 정렬)

Sort Method: external merge Disk: 20GB+ per worker
Worker 0 / 1: external merge
  • 이 정렬은 row_number() OVER (PARTITION BY category ORDER BY length(description) DESC) 실행을 위한 정렬
  • 디스크 스필 발생: work_mem이 부족했기 때문
  • 병렬 정렬 → 총 3개 정렬 노드 → 약 60GB 임시 디스크 사용

📌 row_number()의 정렬 기준으로 category, length(description)를 사용하므로 복잡한 sort key가 필요.
→ 정렬된 블록을 외부 정렬(merge)로 처리.

 

3. WindowAgg + Subquery Scan

WindowAgg → Subquery Scan base → Incremental Sort
  • row_number() <= 1000 조건 때문에 매 그룹별 1000개만 추림
  • 그 이전까지는 정렬된 전체 row가 메모리/디스크로 흐름
  • Subquery Scan 이후에 필터가 적용되므로 불필요한 정렬까지 실행됨 ← 성능 저하 포인트

📌 개선 여지: row_number() <= N 조건을 CTE 필터링보다 더 안쪽으로 넣기 어려움.

📌 PostgreSQL의 WindowAgg는 sort → evaluate → filter 순서.

 

4. GroupAggregate + Final Sort

GroupAggregate ... rows=10001
Sort (LIMIT 50) ... external merge, Disk: 22GB
  • 최종적으로 category별로 그룹화하여 집계
  • 그 결과를 ORDER BY count(*) DESC로 정렬
  • 이 정렬도 external merge 방식으로 디스크 스필 유발

📌 1만 개 그룹만 정렬하지만 입력 row가 너무 많음 (약 1억 row).

📌 각 그룹이 내부적으로 array_agg, json_agg 되었다면 더 큰 메모리 필요.

 

메모리 사용 및 work_mem 영향

Plan 노드 설명 work_mem 사용 Disk Spill
Sort (WindowAgg용) row_number()용 ✅ (20GB+ per worker)
Incremental Sort group by category + id
Final Sort count(*) DESC ✅ (22GB 이상)
HashAggregate or GroupAggregate category별 집계 대부분 spill 없이 처리됨
json_agg / array_agg 내부적으로 메모리 누적 ✅ (ExprContext 기반) ❌ (spill 불가, → OOM 위험 대상)

 

work_mem 사용량 추정

요소 수량 per-instance 예상 사용량
Sort (WindowAgg 정렬) 3 (각 worker) 20GB 이상 디스크 스필 발생
Incremental Sort (Leader) 1 약 1MB (Peak Memory: 1152kB)
Final Sort (LIMIT) 1 약 1MB (Disk: 22GB external sort 발생)
GroupAggregate 1 수 MB 내외 (정렬 후 집계)
ExprContext (json_agg, array_agg) 3+ (각 프로세스) 수백 MB까지 커질 수 있음 (spill 불가)
  • 총 예상 work_mem Plan 노드 수: 5~6개
Incremental Sort + Final Sort + GroupAggregate + ExprContext + 병렬 워커 메모리 복제
구성 요소 사용량 추정
정렬 (5~6개 노드) 20MB x 5~6 ≒ 120MB
ExprContext 누적 최대 수백 MB (spill 불가)
병렬 워커 오버헤드 200~400MB 수준
Executor/Plan 구조체 수 MB 수준
  • 최대 1.5GB 이상 사용 가능성이 있습니다.

요약 정리

work_mem이 쿼리 실행 중 어떤 Plan 노드에서, 어떤 방식으로 누적 소비되고, 디스크 스필이나 OOM에 어떤 영향을 주는지를 파악하기 위해 두 가 케이스를 비교해봤습니다.

 

Case 1 vs Case 2 비교

항목 Case 1 (work_mem 내) Case 2 (Disk Spill 발생)
쿼리 규모 1천만 row 1억 row
주요 연산 GROUP BY + ORDER BY row_number, json_agg 등
병렬성 3개 프로세스 3개 프로세스
Sort 대상 적음 큼 (description, 윈도 함수 등)
Spill 여부 ❌ 없음 ✔️ 있음 (22GB+)
ExprContext 크기 작음
총 메모리 사용량 ≈ 200kB 최대 ≈ 1.5GB 이상
  1. 같은 병렬 구조지만, 메모리 사용량이 전혀 다름.
    • 병렬 Plan이 동일하게 3개의 프로세스를 사용했지만, Plan 구조(WindowAgg, json_agg, Incremental Sort)에 따라 메모리 압박이 달라짐.
  2. work_mem 사용은 Plan 노드 수와 row 수에 따라 기하급수적으로 증가.
    • 단순 GroupAggregate는 28kB 내외로 끝났지만, 윈도 함수 + 다중 정렬 + 중첩 집계(json_agg)는 external merge sort 및 temp 디스크 사용 유발.
  3. spill이 발생하는 Plan 노드 vs 발생하지 않는 노드를 구분할 수 있음.
    • Sort, HashAggregate: spill 가능.
    • ExprContext, json_agg, FunctionScan: spill 불가 → OOM 위험.

 

+ 실행계획에서 반드시 봐야 할 메모리 사용 포인트

확인 포인트 의미 예시
Sort Method Spill 여부 (quicksort vs external) external merge
Memory Usage Plan 노드별 사용량 확인 Memory Usage: 24kB
Disk: ...kB 디스크 스필 크기 Disk: 22811264kB
ExprContext OOM Spill 불가 메모리 영역 json_agg, ST_AsGeoJSON
Plan 노드 수 × 병렬성 누적 work_mem 예상량 계산 Sort 3개 x 3 = 9 x work_mem
temp read/write I/O 병목 여부 파악 144GB / 160GB 수준

 

++ 실시간 메모리 사용 확인 쿼리

SELECT * FROM pg_backend_memory_contexts WHERE name LIKE '%Sort%' OR name LIKE '%Expr%';

요약하자면,

  • 단순 쿼리는 low work_mem으로도 충분히 처리 가능합니다.
  • 복잡 쿼리는 Plan 단위로 메모리 사용을 분산 분석해야 합니다.
  • work_mem은 단일 쿼리 설정이 아니라, Plan 노드와 병렬 구조에 따라 누적되어 사용됩니다.
  • Spill이 발생하는 Plan은 Sort, Aggregate, Hash Join입니다.
  • Spill이 발생하지 않는 Plan은 위험할 수도 있습니다. → OOM 발생 가능

PostgreSQL 쿼리의 메모리 사용은 단순히 work_mem만으로 결정되지 않으며, Plan 노드의 구조, 병렬성, 데이터량, 표현식의 복잡도까지 모두 반영해야 실질적인 메모리 사용량과 위험을 예측할 수 있습니다.

 

오늘은 여기까지~

 

728x90