PostgreSQL

PostgreSQL: ExprContext 기반 GeoJSON 조회 쿼리로 인한 OOM(Out of Memory) 발생 사례 분석

dewstream 2025. 5. 24. 08:00

※ Analyzing OOM (Out of Memory) Occurrence Due to GeoJSON Query Using PostgreSQL's ExprContext.

 

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

 

오늘은 실제 현업에서 발생한 PostgreSQL의 OOM(Out-Of-Memory) 사례를 기반으로 원인, 로그 분석, 쿼리 구조 분석, 최적화 방안을 정리해봤습니다.

 

※ 실제 내용을 바탕으로 재구성하여 재현한 내용입니다.


1. 개요

  • 발생 증상: 특정 JSON 조회 쿼리 실행 중 OOM 오류 발생으로 세션 강제 종료 및 PostgreSQL Recovery 모드 진입 후 복원.
  • 시스템 환경:
    • PostgreSQL 버전: 14.2.
    • 시스템 총 메모리: 16GB.

2. 로그 분석 (PostgreSQL 표준 로그)

주 원인 로그

ERROR:  out of memory
DETAIL:  Failed on request of size 782400677 in memory context "ExprContext".
ExprContext: 874856856 total in 8857 blocks; 871896200 used
Grand total: 6943701200 bytes in 9366 blocks; 6937594536 used
  • ExprContext는 PostgreSQL의 쿼리 실행 중 표현식 평가를 위해 사용되는 메모리 컨텍스트입니다.
  • 해당 로그는 JSON 생성 및 공간 함수 등 복잡한 표현식으로 인해 ExprContext 단일 컨텍스트에서 약 831MB 메모리를 소비했음을 보여줍니다.
  • 전체 세션 기준 사용 메모리는 약 6.9GB로, 시스템 여유 메모리를 초과해 OOM이 발생했습니다.

연관 로그

TopMemoryContext: 178480 total in 8 blocks; 25568 free (25 chunks); 152912 used
  pgstat TabStatusArray lookup hash table: 8192 total in 1 blocks; 1448 free (0 chunks); 6744 used
  TopTransactionContext: 8192 total in 1 blocks; 7712 free (2 chunks); 480 used
  Record information cache: 8192 total in 1 blocks; 1576 free (0 chunks); 6616 used
  HandleParallelMessages: 8192 total in 1 blocks; 7928 free (0 chunks); 264 used
  CFuncHash: 8192 total in 1 blocks; 552 free (0 chunks); 7640 used
  Prepared Queries: 16384 total in 2 blocks; 6656 free (3 chunks); 9728 used
  TableSpace cache: 8192 total in 1 blocks; 2088 free (0 chunks); 6104 used
  Type information cache: 24376 total in 2 blocks; 2616 free (0 chunks); 21760 used
  Operator lookup cache: 24576 total in 2 blocks; 10752 free (3 chunks); 13824 used
  RowDescriptionContext: 24576 total in 2 blocks; 16064 free (6 chunks); 8512 used
  MessageContext: 8192 total in 1 blocks; 6744 free (0 chunks); 1448 used
  Operator class cache: 8192 total in 1 blocks; 552 free (0 chunks); 7640 used
  smgr relation table: 32768 total in 3 blocks; 12712 free (8 chunks); 20056 used
  TransactionAbortContext: 32768 total in 1 blocks; 32504 free (0 chunks); 264 used
  Portal hash: 8192 total in 1 blocks; 552 free (0 chunks); 7640 used
  TopPortalContext: 8192 total in 1 blocks; 7656 free (0 chunks); 536 used
    PortalContext: 9216 total in 2 blocks; 4392 free (2 chunks); 4824 used: <unnamed>
      ExecutorState: 2108472 total in 10 blocks; 1934544 free (687 chunks); 173928 used
        SQL function: 81976 total in 5 blocks; 5424 free (5 chunks); 76552 used: fn_enchar
        HashTableContext: 8192 total in 1 blocks; 7560 free (1 chunks); 632 used
          HashBatchContext: 2767448 total in 78 blocks; 7928 free (0 chunks); 2759520 used
        HashTableContext: 8192 total in 1 blocks; 6664 free (10 chunks); 1528 used
TopMemoryContext: 137432 total in 7 blocks; 26208 free (12 chunks); 111224 used
...
        ExprContext: 8192 total in 1 blocks; 7928 free (0 chunks); 264 used
        ExprContext: 8192 total in 1 blocks; 7928 free (0 chunks); 264 used
        ExprContext: 8192 total in 1 blocks; 7928 free (0 chunks); 264 used
        ExprContext: 8192 total in 1 blocks; 7928 free (0 chunks); 264 used
...
Grand total: 3522441504 bytes in 9299 blocks; 6149144 free (1024 chunks); 3516292360 used
  • 약 14초 전 같은 세션에서 실행된 쿼리도 약 3.3GB의 메모리를 사용한 것으로 로그에 나타납니다.
  • ExecutorState, ExprContext, HashBatchContext 등의 메모리 컨텍스트가 유지된 채, 다음 쿼리로 이어졌으며 해제가 지연된 상태에서 추가 메모리 요청이 OOM으로 이어졌습니다.
  • 주요 컨텍스트:
    • ExecutorState: ~2.1MB
    • HashBatchContext: ~2.1MB × 여러 개
    • ExprContext: 반복적으로 8KB씩 다수 생성, 일부는 수 GB까지 유지
      → 해당 메모리는 커넥션 풀(JDBC) 기반에서 세션 종료 없이 다음 쿼리가 실행되며 누적된 것으로 추정됩니다.

3. 쿼리 구조 분석

문제 발생 쿼리

SELECT row_to_json(fc) AS geojson
FROM (
  SELECT 'FeatureCollection' AS type, array_to_json(array_agg(f)) AS features
  FROM (
    SELECT 'Feature' AS type,
           ST_AsGeoJSON(ST_SetSRID(s.geom, 4326))::json AS geometry,
           json_build_object(
             'id', s.id,
             'name', s.name,
             'center', ST_AsGeoJSON(ST_Transform(ST_SetSRID(ST_Centroid(s.geom), 4326), 5179))::json
           ) AS properties
    FROM (
      SELECT a.*, b.*, ROW_NUMBER() OVER() - 1 AS num
      FROM table_a a
      JOIN (
        SELECT * FROM table_b b
        LEFT JOIN (
          SELECT * FROM table_c WHERE type = '4'
          UNION ALL
          SELECT * FROM table_d
        ) g ON b.id = g.id
      ) b ON a.key = b.key
      WHERE a.col1 ILIKE '%' || fn_enchar($1) || '%'
         OR a.col2 ILIKE '%' || fn_enchar($2) || '%'
         OR b.col3 ILIKE '%' || fn_enchar($3) || '%'
         OR b.col4 ILIKE '%' || fn_enchar($4) || '%'
    ) s
  ) f
) fc;
  • array_agg, row_to_json, ST_AsGeoJSON, ST_Centroid, ST_Transform, json_build_object 등 고비용 연산자가 중첩.
  • 서브쿼리 내 ROW_NUMBER()와 JOIN으로 인한 정렬 및 해시 연산 발생.
  • 결과적으로 하나의 쿼리에서 10개 이상의 연산자 인스턴스가 work_mem을 개별로 사용하는 구조.

4. 공간 함수의 메모리 사용 특성

PostGIS 함수는 대부분 C 레벨에서 구현되며, Geometry → JSON, Projection → SRID 변환 등 연산 비용이 크고 spill-to-disk 처리가 불가능한 ExprContext 내에서 수행됩니다.

 

ExprContext는 PostgreSQL executor의 표현식 평가 결과를 저장하는 메모리 컨텍스트로, MemoryContextReset() 이전까지는 모든 데이터를 메모리에 유지합니다. 이 컨텍스트는 일반적인 정렬/해시 연산과 달리 디스크로 spill(overflow)되는 메커니즘이 없습니다.


기술 근거

  • PostgreSQL 소스 코드에서 ExprContext는 CreateExprContext()로 생성되며, 내부적으로 AllocSetContextCreate()를 통해 AllocSet 메모리 관리자 하위에 위치합니다. 이는 모두 in-memory 기반입니다.
  • ExprContext는 ExecEvalExpr() 와 같은 표현식 평가 함수에서 쓰이며, 반복적인 호출 시에도 메모리는 계속 할당되며, 명시적으로 ResetExprContext() 호출 전까지는 누적됩니다.
  • work_mem은 nodeSort.c, nodeHash.c 등에서만 사용되며, ExprContext 내 표현식 평가에는 적용되지 않습니다.
  • 관련 소스:

 

 


공간 함수 내부 동작 구조

  • ST_AsGeoJSON(geometry)
    • 내부적으로 PostGIS의 C 소스 코드 lwgeom_out_geojson.c 파일 내 lwgeom_to_geojson() 함수를 호출함.
    • geometry 객체의 모든 vertex 좌표를 문자열로 직렬화하며, 이 결과는 힙에 할당되어 ExprContext에 저장됨.
    • 메모리 소비는 geometry의 복잡도(정점 수, 다중 폴리곤 여부 등)에 비례함.
    • 관련 소스: lwout_geojson.c
    • 함수 흐름: ST_AsGeoJSON → 내부적으로 lwgeom_to_geojson() 사용 → JSON 문자열 할당
  • ST_Centroid(geometry)
    • 내부적으로 lwgeom_centroid_recursive() (파일: lwgeom_geos.c)를 사용하여 geometry의 중심 좌표를 계산함.
    • 모든 vertex를 순회하면서 평균 좌표를 계산하는 방식이며, GEOS 라이브러리를 사용하는 경우도 있음.
    • 결과 geometry는 다시 메모리에 생성되며, 이후 JSON으로 직렬화되면 이중으로 메모리 사용이 발생.
    • 관련 소스: lwgeom_geos.c
    • 함수 흐름: ST_Centroid → lwgeom_centroid() → geometry scan + 연산 + result allocation → ExprContext에 유지

따라서 이러한 공간 함수들은 결과를 메모리에서만 처리하며, PostgreSQL executor 내부에서 해당 결과가 담기는 컨텍스트는 ExprContext입니다.

이 컨텍스트는 디스크 spill이 불가능한 구조로 인해 큰 geometry 객체가 많을 경우 OOM 위험이 급격히 증가합니다.


따라서, ExprContext 기반 연산은 디스크 spill 기능이 없고, 표현식 결과가 클수록 메모리 소비가 기하급수적으로 증가하므로, 공간 함수나 JSON 직렬화 같은 고용량 표현식이 포함된 쿼리는 OOM 위험이 높습니다.


PostGIS 함수는 대부분 C 레벨에서 구현되며, Geometry → JSON, Projection → SRID 변환 등 연산 비용이 크고 spill-to-disk 처리가 불가능한 ExprContext 내에서 수행됩니다.

  • ST_AsGeoJSON(geometry)는 geometry vertex 수에 따라 수십 KB~수백 KB 메모리를 일시적으로 할당.
  • ST_Centroid(geometry)는 geometry 내 좌표 평균 계산이므로 vertex가 많을수록 부하 증가.
 

ST_AsGeoJSON

Name ST_AsGeoJSON — Return a geometry or feature in GeoJSON format. Synopsis text ST_AsGeoJSON(record feature, text geom_column="", integer maxdecimaldigits=9, boolean pretty_bool=false, text id_column=''); text ST_AsGeoJSON(geometry geom, integer maxdec

postgis.net


5. 누적 메모리 사용의 영향

OOM 발생 시점 이전 쿼리로 인해 약 3.3GB 메모리가 이미 사용된 상태에서 추가 쿼리 실행이 되었습니다.

 

PostgreSQL은 세션 단위로 여러 메모리 컨텍스트를 유지하며, ExprContext, ExecutorState, PortalContext, CachedPlanSource 등은 명시적 해제 없이 재사용되거나 쿼리 실행 종료 후에도 남아 있을 수 있습니다.

 

실제 PostgreSQL 소스코드에서 MemoryContextReset()이나 MemoryContextDelete()가 호출되지 않으면 해당 context는 해제되지 않으며, 특히 ExprContext는 per-tuple memory로 분류되어 tuple 단위로 계속 재사용될 수 있습니다.


소스 코드 기반 분석

  • PostgreSQL의 executor는 각 쿼리마다 ExecutorState를 생성하며, 이 안에 여러 context들이 포함됩니다.
    (executor.h, execMain.c):
    • estate->es_query_cxt = CreateMemoryContext(...);
    • estate->es_per_tuple_exprcontext = CreateExprContext(estate);
  • 특히 ExprContext는 반복적으로 생성되며, 루프 내에서 큰 JSON 객체를 생성할 경우 누적 메모리가 줄지 않고 증가할 수 있습니다.
  • 메모리 해제는 FreeExprContext()나 MemoryContextReset()을 통해 일어나지만, JDBC 또는 unnamed prepared statement 환경에서는 명시적으로 호출되지 않는 경우가 많습니다.

로그 기반 분석

  • 이전 쿼리 실행 직후 ExecutorState, HashBatchContext, ExprContext 등 다수의 context에서 각각 수 MB~수 GB 단위의 메모리가 사용되었으며, 일부는 다음 쿼리로 이어질 때까지 해제되지 않았습니다.
  • PostgreSQL의 TopTransactionContext는 트랜잭션 단위로 유지되며, 세션이 끊기지 않으면 메모리 누수처럼 작용할 수 있습니다.

결과적으로, PostgreSQL의 메모리 사용 구조는 쿼리 간 명확한 해제가 없는 경우 누적 메모리가 다음 쿼리에 영향을 미치며, 복잡한 연산이 포함된 쿼리에서는 단독 쿼리가 아닌 누적 환경을 고려한 튜닝과 모니터링이 필수입니다.


6. work_mem 작동 방식 및 주의사항

work_mem은 PostgreSQL에서 정렬(Sort), 해시 조인(Hash Join), 해시 집계(Hash Aggregate) 등 임시 데이터 구조를 메모리에 유지해야 할 때 사용되는 파라미터입니다.

 

이 설정값은 세션 단위가 아닌, 쿼리 실행 시 각 연산자 인스턴스에 독립적으로 적용된다는 점에서 오해의 소지가 있습니다.


기본 작동 원리

  • PostgreSQL 쿼리 실행 중 각 정렬, 해시 연산자는 독립적인 메모리 컨텍스트(SortState, HashJoinState)를 갖고 있으며, work_mem은 이들 컨텍스트의 메모리 한도로 작동합니다.
  • 하나의 쿼리에 Sort, Hash Join, Aggregate 등의 연산자가 여러 개 포함되면, 각 연산자마다 최대 work_mem만큼 사용 가능하므로 쿼리 1개가 work_mem × 연산자 수만큼 메모리를 사용하는 구조가 됩니다.
-- 예시
SELECT ...
FROM a
JOIN b ON a.id = b.id  -- Hash Join
GROUP BY a.category     -- Group Aggregate
ORDER BY a.created_at   -- Sort

→ 위 쿼리는 3개의 연산자 인스턴스를 사용하며, 총 3 × work_mem이 할당될 수 있습니다.


소스 코드 기반 분석

  • 정렬 연산은 tuplesort_begin_heap() 내부에서 work_mem을 파라미터로 사용하며, 내부에서 할당되는 메모리를 관리합니다.
  • 해시 조인은 ExecHashTableCreate() 함수에서 work_mem을 기준으로 해시 버킷 크기를 결정하고 spill 여부를 판정합니다.

공식 문서 기반 요약

요약
work_mem은 하나의 정렬(SORT) 또는 해시(HASH) 연산이 디스크에 임시 파일을 쓰기 전에 사용할 수 있는 메모리의 최대 크기를 설정하는 파라미터다. 값에 단위를 명시하지 않으면 킬로바이트(KB)로 간주되며, 기본값은 4MB이다.
하나의 복잡한 쿼리는 여러 정렬 및 해시 연산을 동시에 수행할 수 있고, 각각은 독립적으로 work_mem만큼의 메모리를 사용할 수 있다. 또한 여러 세션이 동시에 실행 중일 수 있으므로 시스템 전체에서 사용되는 메모리 양은 work_mem보다 훨씬 클 수 있다. work_mem은 ORDER BY, DISTINCT, merge join, hash join, hash aggregation, memoize, IN 서브쿼리 처리 등에서 사용된다.

 

 

19.4. Resource Consumption

19.4. Resource Consumption # 19.4.1. Memory 19.4.2. Disk 19.4.3. Kernel Resource Usage 19.4.4. Cost-based Vacuum Delay 19.4.5. Background Writer 19.4.6. Asynchronous …

www.postgresql.org


주의사항

  • 복잡한 쿼리에서 10개 이상의 연산자가 동시에 실행될 경우, 단일 세션에서 10 × work_mem 이상의 메모리를 사용할 수 있습니다.
  • 동시 접속 세션이 많을 경우, max_connections × 예상 최대 연산자 수 × work_mem 계산을 통해 시스템 총 메모리 한계 대비 설정이 필요합니다.
  • work_mem은 설정을 낮게 유지하되, 복잡 쿼리에는 SET LOCAL work_mem으로 개별 조정하는 전략이 효과적입니다.

7. 최적화 및 리팩토링

튜닝 후 쿼리

SET LOCAL work_mem = '256MB';

WITH filtered AS (
  SELECT a.id, a.name, b.geom, b.col4
  FROM table_a a
  JOIN table_b b ON a.key = b.key
  WHERE a.col1 ILIKE '%' || fn_enchar($1) || '%'
     OR b.col4 ILIKE '%' || fn_enchar($4) || '%'
),
geo_processed AS (
  SELECT id, name, ST_AsGeoJSON(ST_SetSRID(geom, 4326))::json AS geom_json
  FROM filtered
),
final AS (
  SELECT 'Feature' AS type,
         geom_json AS geometry,
         json_build_object('id', id, 'name', name) AS properties
  FROM geo_processed
)
SELECT row_to_json(fc)
FROM (
  SELECT 'FeatureCollection' AS type, array_to_json(array_agg(final)) AS features
  FROM final
) fc;

개선 내용

  • CTE 기반 단계 분리와 메모리 최적화 전략
    • 복잡한 표현식과 중첩된 연산이 단일 쿼리 내에서 동시에 실행될 경우, ExprContext나 ExecutorState와 같은 메모리 컨텍스트에 메모리 사용량이 집중되어 OOM 위험이 커집니다.
    • WITH 구문을 활용한 CTE(Common Table Expression) 구조로 쿼리를 나누면, 각 단계마다 별도 메모리 컨텍스트가 할당되므로 쿼리 실행 중 발생하는 메모리 peak를 효과적으로 분산시킬 수 있습니다.
    • 특히 문자열 필터링 → 공간 연산 → JSON 직렬화 순으로 구분하면, 고비용 연산 단계별로 메모리 부담을 분리하고 PostgreSQL executor의 메모리 해제 타이밍 제어가 쉬워집니다.
    • 각 CTE는 독립적인 실행 계획 노드를 가지므로, PostgreSQL의 EXPLAIN, auto_explain, pg_stat_statements 등의 도구에서 분석 단위가 명확해집니다.
    • PostgreSQL 13 이상에서는 CTE도 병렬 쿼리 최적화 대상이 될 수 있으며, 쿼리 성능 자체도 개선되는 효과가 있습니다.
  • 중복 공간 연산 제거
    • 동일한 geometry 필드에 대해 ST_AsGeoJSON, ST_Centroid, ST_Transform 등이 반복적으로 호출되면 geometry 구조가 반복적으로 메모리에 생성됩니다.
    • 이를 CTE 또는 인라인 서브쿼리를 통해 사전 처리하고 재사용할 경우, 불필요한 메모리 증가를 방지하고 ExprContext 내 사용량을 줄일 수 있습니다.
    • 특히 JSON serialization 함수(row_to_json, json_build_object)는 geometry + 속성 통합 시 복합적인 문자열 객체를 생성하므로 사전 계산이 유리합니다.
  • 쿼리별 work_mem 관리
    • 쿼리 실행 직전 SET LOCAL work_mem = '256MB';으로 조정하였습니다.
    • global 설정은 세션 수와 연산자 수 곱 연산 기준으로 최소값 설정을 권장 드립니다.

 

+ PostgreSQL 내부 JSON 직렬화 오버헤드 최소화 (부가적 권장사항)

  • json_build_object, row_to_json 등은 대규모 문자열 버퍼를 메모리에 생성합니다.
  • 복잡한 구조의 JSON을 서버에서 모두 처리할 경우 ExprContext에 할당되는 메모리 사용량이 급격히 증가하며, 특히 geometry처럼 용량이 큰 데이터를 포함할 경우 OOM 위험이 높아집니다.
  • 가능한 경우 raw 데이터를 반환하고 JSON 포맷팅은 클라이언트 애플리케이션(Node.js, Python 등)에서 처리하는 것이 메모리 분산 측면에서 유리합니다.

8. 결론 및 요약

  • OOM은 단일 쿼리의 복잡성과 함께, 동일 세션에서의 누적 메모리 사용량이 복합적으로 작용하여 발생합니다.
  • 공간 연산과 JSON 직렬화를 함께 수행할 경우, 반드시 결과 분할 및 사전 계산 전략을 병행해야 합니다.
  • work_mem은 단일값으로 끝나는 설정이 아니며, 연산자 수와 동시 세션 수를 고려한 제한적 설정이 필수입니다.
  • JDBC 커넥션 재사용 환경에서는 쿼리 간 누적 메모리를 반드시 고려해야 합니다.

오늘은 여기까지~