본문으로 건너뛰기
김신건의 로그

정렬·해시는 메모리가 부족하면 어디로 새는가, PGA, work_mem, Workspace Memory

"SQL 레벨업"이 한 문장으로 압축한 정렬·해시의 메모리 spill을, 알고리즘부터 Oracle·PostgreSQL·SQL Server의 실제 동작까지 단계별로 풀어본다.

한 문장에서 시작된 의문

“SQL 레벨업”을 읽다가 다음 문단이 눈에 들어왔다.

Oracle에서는 정렬 또는 해시를 위해 PGA라는 메모리 영역을 사용합니다. 이때 PGA 크기가 집약 대상 데이터양에 비해 부족하면, 일시 영역(저장소)을 사용해 부족한 만큼 채웁니다.

저자주: PostgreSQL에서는 work_mem, Microsoft SQL Server에서는 Workspace Memory라는 메모리 영역이 정렬 또는 해시에 사용됩니다. 모두 메모리 영역이 부족해지면 부족한 만큼 보충하고자 일시 영역(물리적으로는 저장소의 파일)을 사용합니다.

세 개의 DBMS, 세 개의 다른 이름. 책은 “부족한 만큼 채운다”는 결과만 말하고 그 사이의 메커니즘은 다루지 않는다.

이 글에서는 다음을 차례로 풀어본다.

  1. 정렬·해시는 그렇게 메모리에 욕심을 부리나? → 알고리즘
  2. 메모리가 부족할 때 정확히 무엇이 어떻게 디스크로 흘러가나? → 3-tier 모델
  3. Oracle·PostgreSQL·SQL Server는 이 문제를 각각 어떻게 다르게 다루나? → 구현 비교

한 층씩 내려가자.


정렬·해시·Spill의 알고리즘적 본질

대부분의 SQL 연산은 스트리밍으로 동작한다. WHERE 필터, 단순 Index Scan, Nested Loop Join은 한 번에 한 행씩 흘려보내며 처리한다. 입력이 1억 건이든 한 행씩만 메모리에 두면 끝난다.

정렬과 해시는 다르다. 블로킹(blocking) 연산자라고 부른다. 입력 한 쪽(또는 양쪽)을 전부 모은 뒤에야 첫 출력을 낼 수 있다.

왜 그런지 직관적으로 보자.

  • ORDER BY value, 마지막 행이 들어오기 전까진 어떤 행이 첫 번째인지 알 수 없다.
  • GROUP BY user_id, 같은 user_id가 흩어져 있을 수 있어 다 모아야 그룹이 완성된다.
  • Hash Join의 빌드 쪽, 작은 테이블 전체를 해시 테이블로 만들어둬야 큰 테이블 행이 들어올 때 즉시 매칭 검색이 된다.

이 “전체를 모아야 한다”는 요구가 메모리 압박의 근원이다. 그리고 메모리가 부족할 때 두 알고리즘이 어떻게 변신하는지가 이 글의 핵심 주제다.

정렬: Quicksort → External Merge Sort

메모리에 다 들어가는 작은 데이터라면 RDBMS는 Quicksort (또는 LIMIT N 최적화 시 Heapsort) 를 쓴다. O(n log n) 시간, 디스크 I/O 0. (정렬 알고리즘 전반의 비교는 정렬 알고리즘 참고.)

문제는 메모리에 안 들어가는 큰 데이터다. 이때 External Merge Sort 가 등장한다. 직역하면 “외부(=디스크) 머지 정렬” 이다.

알고리즘은 두 단계다.

1단계, Run 생성(Run Generation): 데이터를 메모리 크기만큼 청크로 잘라 각 청크를 메모리에서 Quicksort한 뒤 디스크에 “Run”이라는 이름의 정렬된 파일로 쓴다. 메모리는 비우고 다음 청크를 받는다.

2단계, K-way Merge: 모든 Run이 만들어지면, 각 Run의 첫 행만 메모리에 둔 채(이게 핵심이다) 가장 작은 값을 골라 출력한다. 출력한 Run의 다음 행을 가져와서 다시 비교. 입력 Run이 K개라면 K개의 작은 “head 버퍼”만 메모리에 두면 된다.

이 두 단계가 외부 정렬의 정체다. 메모리는 청크 정렬용으로 한 번, 머지 head 버퍼용으로 또 한 번 재사용된다.

해시: In-memory Hash → Grace Hash → Hybrid Hash

Hash Join도 비슷하다. 메모리에 들어가는 작은 빌드 테이블이면 그냥 메모리 해시 테이블 하나 만들고 끝. 큰 빌드 테이블이면 어떻게 할까?

답이 Grace Hash Join이다.

핵심 아이디어: 두 테이블을 같은 해시 함수로 N개 파티션으로 나눠 디스크에 쓴다. 같은 키는 같은 번호의 파티션에 들어간다. 그러면 R의 i번째 파티션은 S의 i번째 파티션과만 조인해야 한다. R의 i번째 파티션이 메모리에 들어가는 크기가 되도록 N을 정한다. 그 뒤 i=1,2,…,N에 대해 (Ri, Si) 쌍을 메모리에서 in-memory Hash Join한다.

큰 빌드 전체를 메모리에 못 올려도, N등분된 조각은 메모리에 올라간다.

Hybrid Hash Join은 한 가지 최적화다. 첫 번째 파티션(R1)을 디스크에 쓰지 않고 메모리에 그대로 보관한다. S 행이 들어올 때 첫 번째 파티션이면 메모리의 R1과 즉시 조인, 나머지 파티션이면 디스크로 spill. 빌드가 메모리에 살짝만 초과하는 경우 디스크 I/O를 절반 가까이 절약한다. PostgreSQL과 SQL Server가 모두 이 방식을 쓴다.

두 알고리즘의 공통 메시지

연산충분할 때약간 부족할 때심하게 부족할 때
SortQuicksort (I/O 0)External Merge Sort, 1 머지 패스External Merge Sort, 다중 머지 패스
Hash단일 메모리 해시Hybrid Hash (일부만 spill)Hash Recursion (파티션을 또 분할)

핵심: 메모리가 부족하면 단순히 느려지는 게 아니라 알고리즘이 통째로 바뀐다. 이 표가 다음 절의 3-tier 비용 모델의 본질이다.


3-tier 모델: Optimal · One-pass · Multi-pass

알고리즘 본 절 뒤끝에 본 세 단계, “충분/약간 부족/심하게 부족”, 에 이름을 붙여보자. Oracle은 명세적으로 이 세 모드를 정의했고, 우리는 그 용어를 보편 모델로 빌려 쓸 수 있다.

Optimal, “그냥 메모리에 들어간다”

가장 빠른 모드. 디스크 I/O가 0이다.

  • 정렬: Quicksort, O(n log n) CPU만 사용
  • 해시: 단일 해시 테이블, Probe는 평균 O(1)
  • 요건: Work Area ≥ 입력 데이터 + 보조 구조(해시 버킷 헤더, 정렬 비교 버퍼 등)

OLTP의 거의 모든 쿼리가 여기 해당한다.

One-pass, “한 번만 디스크 왕복”

이 모드의 비용을 정확히 따져보면 왜 “꽤 저렴하다”는 평이 가능한지 보인다.

  • 정렬: 청크를 메모리에서 정렬해 Run으로 디스크에 쓴다(데이터 1× write). 마지막에 모든 Run을 한 번의 머지 패스로 읽어 출력한다(데이터 1× read). 추가 I/O = 2 × 데이터 크기.
  • 해시: Hybrid Hash로 빌드와 프로브를 한 번씩 디스크 왕복한다.

이게 One-pass라는 이름의 의미다, “한 번의 머지 패스로 끝남”.

그런데 왜 1 GB 정렬이 22 MB 메모리로 충분한가

Oracle 공식 문서가 든 예시가 처음 보면 비현실적으로 들린다. 1 GB 정렬에 22 MB 메모리만 있어도 One-pass로 처리 가능하다고 말한다. 1/45 크기로 어떻게? 외부 정렬의 머지 단계가 무엇을 필요로 하는지 한 번 더 들여다보면 답이 나온다.

K-way Merge 단계의 메모리 요구를 식으로 적어보자.

입력 데이터 크기 = D
가용 메모리 = M
1단계에서 만들어지는 Run 개수 = K = D / M (각 Run 크기는 M)

2단계 머지 시 필요한 메모리:
  각 Run 당 한 페이지 분량의 "head 버퍼" + 출력 버퍼 1개
  머지 메모리 = (K + 1) × 페이지 크기
              ≈ (D / M) × 8 KB

One-pass 가능 조건은 “이 머지 메모리가 가용 메모리 M에 들어맞아야 한다”이다.

(D / M) × 8 KB  ≤  M
→ M² ≥ D × 8 KB
→ M  ≥ √(D × 8 KB)

D = 1 GB로 넣으면 M ≥ √(1 GB × 8 KB) = √(8 × 10¹²) ≈ 약 2.8 MB. 수학적 하한은 3 MB도 안 된다.

Oracle이 보수적으로 22 MB라고 말하는 건 보조 구조와 안전 마진을 합친 실용값일 뿐이다. 같은 식에 D = 10 GB를 넣으면 √(10 GB × 8 KB) ≈ 9 MB → Oracle은 40 MB를 권장한다.

TIP

K-way Merge의 메모리는 데이터 크기에 비례하지 않는다. 데이터 크기 D, 페이지 크기 P일 때 √(D × P)에 비례한다. 그래서 한 자릿수 GB의 정렬도 수십 MB로 충분히 처리된다. 외부 머지 정렬의 가장 우아한 성질.

Multi-pass, “여러 번 디스크 왕복”

메모리가 너무 작아 머지 단계조차 한 번에 못 끝낼 때다.

  • 정렬: Run의 일부만 머지해 더 큰 중간 Run을 만들고 다시 디스크에 → 그걸 또 머지 → … 패스 수 K번.
    • 추가 I/O = K × 2 × 데이터 크기
  • 해시: Hash Recursion. 파티션 하나가 여전히 메모리에 안 들어가면 그 파티션을 더 잘게 나눠 다시 spill.

10 GB를 5 MB 메모리로 정렬한다고 가정해보자. Run은 √(D × P) 식에서 벗어나 머지 패스가 둘 이상 필요해진다. 패스 수가 K일 때 비용은 K × 2 × 데이터 크기, 즉 K가 2만 돼도 디스크 I/O가 4배다. K=3이면 6배. 응답 시간이 메모리 부족량에 대해 거의 지수적으로 증가한다.

그래서 왜 “Optimal > 90%, One-pass < 10%, Multi-pass = 0%“인가

비용 비대칭을 보면 답이 나온다.

모드추가 디스크 I/O운영 정책
Optimal0표준 케이스로 만들어야 함
One-pass2 × 데이터가끔(대용량 보고서 등) 허용 가능
Multi-passK × 2 × 데이터 (K ≥ 2)거의 모든 경우 SLO 파괴, 절대 피해야 함

One-pass는 평소 0이던 디스크 I/O가 2배로 갑자기 늘어나니까 한 자릿수 초로 끝날 쿼리가 두 자릿수 초로 늘어난다. 가끔이라면 수용 가능하다. Multi-pass는 K가 늘어날 때마다 2배씩 곱해진다. 응답 시간 SLO를 즉시 깨뜨린다.

운영의 안전 목표 “Optimal > 90%, One-pass < 10%, Multi-pass = 0%“는 이 비용 함수에서 자연스럽게 도출된 임계값이다. 가끔 비싼 건 받아들이고, 통제 불능으로 비싼 건 금지한다.


세 DBMS는 같은 문제를 다르게 푼다

여기까지가 보편 이론이다. 이제 각 DBMS가 이 보편 모델을 어떻게 구체화했는지 본다. 세 가지 결정 축이 있다.

  1. 메모리 할당 단위: 연산자당? 노드당? 쿼리당?
  2. 예약 시점: 실행 중 동적으로? 컴파일 시점에 정적으로?
  3. 모니터링 인터페이스: 전용 뷰? 실행 계획 출력? 이벤트?

Oracle, PostgreSQL, SQL Server는 이 세 축에서 모두 다른 답을 선택했다. 그 차이가 실무에서 만나는 함정의 차이로 이어진다.

먼저 가장 성숙한 자동 관리를 가진 Oracle부터.


Oracle, PGA와 자동 관리

왜 SGA가 아니라 PGA인가

Oracle의 메모리는 두 영역으로 깔끔하게 나뉜다.

  • SGA (System Global Area): 인스턴스 시작 시 잡히는 공유 메모리. Buffer Cache, Shared Pool, Redo Log Buffer 등.
  • PGA (Program Global Area): 사용자가 접속할 때마다 spawn되는 서버 프로세스마다 사적인 메모리.

정렬·해시 같은 메모리 집약 연산이 SGA를 쓰지 않는 이유는 단순하다. SGA는 모든 세션이 공유하는 자원이라 한 세션이 거대한 정렬을 돌리면 나머지 모든 세션에 영향이 간다. PGA는 사적이라 한 세션이 욕심을 부려도 격리된다. 가격, 더 정확히는 메모리 할당량 관리, 을 분리하기에 자연스럽다.

Work Area의 4개 영역

PGA 안의 Work Area는 메모리 집약 연산자별로 다음 하위 영역을 가진다.

영역사용 연산자
Sort AreaORDER BY, DISTINCT, sort-based GROUP BY, ROLLUP, 윈도우 함수
Hash AreaHash Join
Bitmap Merge Area여러 비트맵 인덱스 결합 (BitmapAnd, BitmapOr)
Bulk Load BufferINSERT /*+ APPEND */ 같은 Direct Path Load

각 영역이 독립적으로 Optimal/One-pass/Multi-pass 상태를 가진다는 점이 중요하다. 같은 쿼리 안에서 Hash Join은 One-pass인데 Sort는 Optimal일 수 있다.

Auto PGA Management, 옛 SORT_AREA_SIZE의 졸업

Oracle 10g 이전엔 SORT_AREA_SIZE, HASH_AREA_SIZE를 DBA가 직접 잡았다. 너무 크게 잡으면 동시 세션이 많을 때 OS 메모리 고갈, 너무 작게 잡으면 모든 쿼리가 Multi-pass.

10g 이후 PGA_AGGREGATE_TARGET 파라미터 하나로 자동 관리된다. 옵티마이저가:

  1. 활성 Work Area의 메모리 수요를 추적
  2. 개별 Work Area 최대치(global memory bound)를 실행 중 동적으로 조정
  3. 가능한 한 많은 작업을 Optimal로 실행

DBA의 일은 이제 “PGA에 얼마를 줄 것인지” 하나로 축소됐다. 이게 Oracle이 가장 성숙한 자동 관리라고 평가받는 이유다.

Spill의 물리적 위치

Optimal을 못 맞춰 spill이 발생하면 데이터는 TEMP 테이블스페이스의 sort segment로 간다. Sort segment는 임시 표공간 안에 자동으로 만들어지고 세션이 끝나면 자동으로 회수된다. DBA가 별도로 정리할 필요가 없다.

그래서 실제로 확인하려면, V$ 뷰

자동 관리는 좋지만 “지금 잘 동작하는지”는 별도로 확인해야 한다. Oracle은 풍부한 동적 성능 뷰를 제공한다.

V$PGASTAT, 인스턴스 전체의 PGA 상황:

지표의미
aggregate PGA target parameterPGA_AGGREGATE_TARGET 현재 값
global memory bound개별 Work Area 최대 크기, < 1 MB이면 적신호
over allocation countPGA가 target을 초과 할당한 횟수 (target 부족 신호)
cache hit percentageOptimal 비율의 종합 지표

cache hit percentage의 계산식이 흥미롭다.

BP  = 처리한 전체 바이트
EBP = 추가 패스에서 다시 읽고 쓴 바이트 (One-pass + Multi-pass의 extra I/O)

cache hit % = BP × 100 / (BP + EBP)

100%면 모든 Work Area가 Optimal. Multi-pass가 많아지면 EBP가 BP를 압도하면서 비율이 급락한다. 앞 절의 비용 모델이 그대로 메트릭에 박혀있다.

V$WORKAREA_ACTIVE, 지금 실행 중인 Work Area들:

실행 중 Work Area 모니터링
조회
SELECT sid,
     operation_type,
     expected_size/1024  AS esize,
     actual_mem_used/1024 AS mem,
     max_mem_used/1024   AS max_mem,
     number_passes       AS pass,
     tempseg_size/1024   AS tsize
FROM V$SQL_WORKAREA_ACTIVE
ORDER BY sid;
결과 (Oracle 공식 문서 예시)
SID  OPERATION          ESIZE   MEM     MAX MEM  PASS  TSIZE
---  -----------------  -----   -----   -------  ----  -------
8  GROUP BY (SORT)      315     280       904   0
8  HASH-JOIN           2995    2377      2430   1     20000  ← One-pass
9  GROUP BY (SORT)    34300   22688     22688   0
11  HASH-JOIN          18044   54482     54482   0
12  HASH-JOIN          18044   11406     21406   1    120000  ← One-pass, 120MB temp seg

PASS 컬럼이 3-tier 모델 그 자체다: 0이면 Optimal, 1이면 One-pass, ≥ 2면 Multi-pass. TSIZE는 temp segment 사용량.

운영 룰: PASS ≥ 2가 하나라도 보이면 PGA_AGGREGATE_TARGET을 다시 보자.


PostgreSQL, work_mem과 단위의 함정

Oracle은 자동 관리가 매력이었다. PostgreSQL은 정반대 철학이다, 단순하고 예측 가능하지만, 단위가 작아서 위험하다.

”쿼리당”이 아니라 “노드당”이다

PostgreSQL 공식 문서의 한 문장:

Sets the base maximum amount of memory to be used by a query operation (such as a sort or hash table) before writing to temporary disk files.

“per query operation”, 쿼리 하나가 아니라 실행 계획의 연산 노드 하나가 사용할 수 있는 양이다.

예를 들어 다음 쿼리:

SELECT u.name, COUNT(*) FROM users u
JOIN orders o ON u.id = o.user_id
JOIN payments p ON o.id = p.order_id
WHERE u.region = 'KR'
GROUP BY u.name
ORDER BY 2 DESC;

실행 계획에는 보통 Sort 1개 + HashAggregate 1개 + Hash Join 2개 = 노드 4개가 등장한다. 각각이 따로 work_mem을 받아 쓸 수 있다. 즉 한 쿼리가 최대 4 × work_mem까지 메모리를 차지한다. 거기에 parallel worker마다 같은 양이 곱해진다.

기본값 work_mem = 4 MB. 별것 아닌 듯하지만 동시 세션 100개 × 노드 4개 = 1.6 GB. parallel worker 4개라면 6.4 GB가 정렬·해시에만 잡힌다.

CAUTION

흔한 실수: “쿼리가 느리니 work_mem을 256 MB로 올리자.” 동시 세션 100 × 노드 4 × parallel 4 = 400 GB. OOM 직행.

Oracle이 PGA 총량 하나만 잡으면 옵티마이저가 알아서 분배하는 방식이라면, PostgreSQL은 노드 하나 노드 하나에 정적인 상한을 거는 방식이다. 그래서 work_mem 값 자체보다 **“동시에 얼마나 많은 노드가 활성화되는가”**가 실제 메모리 압력을 결정한다.

hash_mem_multiplier, 해시만 따로 챙기기

해시는 보통 정렬보다 메모리에 더 민감하다. 한 번 한도를 넘으면 Hash Recursion으로 비용이 급증하기 때문이다. PostgreSQL 13부터 별도 곱셈을 도입했다.

Sort 한도   = work_mem
Hash 한도   = work_mem × hash_mem_multiplier  (default 2.0)

해시 한도가 정렬 한도의 2배. 공식 문서는 spill이 빈번하면 multiplier를 2.0 ~ 8.0 범위로 올리라고 권장한다.

EXPLAIN ANALYZE에서 spill 신호 읽기

Oracle은 별도 뷰가 있었지만, PostgreSQL은 모든 정보를 EXPLAIN ANALYZE 출력에 박아둔다. 다른 곳을 볼 필요가 없는 대신, 출력을 정확히 읽을 줄 알아야 한다.

Sort 노드:

Sort  (cost=713.05..713.30 rows=100 width=488)
  Sort Key: t1.fivethous
  Sort Method: quicksort  Memory: 74kB

Sort Method가 3-tier의 어느 모드인지 알려준다.

  • quicksort Memory: NN kBOptimal (메모리 내 정렬)
  • top-N heapsort Memory: NN kBLIMIT N 최적화, 여전히 Optimal
  • external merge Disk: NN kBSpill 발생! 디스크에 NN kB 사용 (One-pass 또는 Multi-pass)

Hash 노드:

Hash  (cost=224.98..224.98 rows=100 width=244)
  Buckets: 1024  Batches: 1  Memory Usage: 35kB

Batches가 Grace/Hybrid Hash Join의 파티션 개수다.

  • Batches: 1 → 빌드 전체가 메모리 (Optimal)
  • Batches: 2 이상 → Hybrid Hash로 spill (One-pass에 해당)
  • 실행 중 Batches가 power-of-2로 점점 커지는 EXPLAIN → 빌드 추정이 틀려 runtime에 추가 분할 (Multi-pass의 일종)

IMPORTANT

PG 공식 문서가 짚는 디테일: “If the number of batches exceeds one, there will also be disk space usage involved, but that is not shown.” 즉 디스크 사용량은 EXPLAIN 기본 출력에 안 찍힌다. EXPLAIN (ANALYZE, BUFFERS)를 쓰면 temp read=NN written=MM 카운터로 간접 확인 가능하다.

시스템 차원의 spill 추적

-- 모든 임시 파일 생성을 로그에 기록
SET log_temp_files = 0;

-- 데이터베이스별 누적 통계
SELECT datname, temp_files, pg_size_pretty(temp_bytes) AS temp
FROM pg_stat_database
WHERE temp_files > 0;

물리적 spill 파일은 $PGDATA/base/pgsql_tmp/에 만들어진다. temp_file_limit(기본 -1 = 무제한)으로 프로세스당 한도를 줄 수 있다.

PG 13의 결정적 변화

PostgreSQL 13 이전까지 HashAggregate는 work_mem을 초과해도 메모리에서 끝까지 처리하려 했고, 종종 OOM을 유발했다. 13부터 disk-based hash aggregation이 도입되어 정상적으로 spill한다. 13으로 올린 직후 “메모리 사용량이 줄어든 것 같은데 EXPLAIN에 temp가 보인다”고 놀라는 경우가 많은데, 이게 정상 동작이다.


SQL Server, 정적 예약과 적응형 학습

Oracle도 PostgreSQL도 실행 중에 메모리를 동적으로 할당한다. SQL Server는 이 부분에서 근본적으로 다른 길을 택했다, 쿼리 시작 전에 필요량을 다 예약한다.

컴파일 타임에 모든 메모리를 예약한다

쿼리가 컴파일될 때 옵티마이저가 다음을 추정한다.

  • 정렬에 NN MB 필요
  • 해시 빌드에 MM MB 필요
  • Memory Grant(요청 메모리)로 합산

쿼리 실행이 시작되려면 먼저 Resource Semaphore라는 게이트에서 grant를 받아야 한다. grant가 부족하면 RESOURCE_SEMAPHORE 대기 상태로 대기열에 줄을 선다.

이 메모리는 buffer pool 내 한 영역, Workspace Memory에서 떼어진다. max server memory로 제어되는 동일한 풀이다.

Row mode vs Batch mode, Spill의 결정적 분기

이 정적 예약이 만든 한 가지 잔혹한 규칙이 있다.

Row mode (전통적 실행)

Microsoft 공식 문서:

For row mode execution, the initial memory grant can’t be exceeded under any condition. If more memory than the initial grant is needed to execute hash or sort operations, then the operations spill to disk.

initial grant는 절대 초과할 수 없다. 컴파일 타임 추정이 틀려서 실행 중 메모리가 모자라도 즉시 tempdb로 spill한다. Oracle/PG처럼 “조금만 더”가 불가능하다. SQL Server에서 통계가 낡았을 때 spill이 폭주하는 이유다.

Batch mode (컬럼스토어 + 2019부터 rowstore도 가능)

For batch mode execution, the initial memory grant can dynamically increase up to a certain internal threshold by default.

Batch mode에서는 grant가 실행 중 동적으로 확장될 수 있다. Row/Batch mode의 차이가 SQL Server를 다른 두 DBMS와 결정적으로 갈라놓는다.

Spill의 두 종류

Microsoft는 정렬과 해시의 spill 저장 형태를 따로 부른다.

  • Sort Spill, Worktable in tempdb (Sort Warning 이벤트)
  • Hash Spill, Workfile in tempdb (Hash Warning 이벤트)

Hash spill에는 두 하위 케이스가 있다.

  • Hash Recursion: 빌드가 메모리에 안 들어가서 파티션으로 쪼개고, 그 파티션이 또 안 들어가면 더 잘게 쪼개고… (앞 절의 Hash Recursion과 동일)
  • Hash Bailout: 최대 재귀 깊이에 도달했을 때 다른 plan으로 폴백. 이때부터는 거의 sequential 스캔에 가까운 비용으로 떨어진다.

Memory Grant Feedback, SQL Server만의 적응형 학습 (2017+)

SQL Server는 정적 예약의 약점을 자체 학습 메커니즘으로 보완했다. 같은 쿼리가 반복 실행될 때 실제 사용량을 plan cache에 기록해서 다음 컴파일 시 grant를 조정한다.

Row mode(2019+)에도 도입되어, “통계가 틀려서 spill하던 쿼리가 두세 번 실행 후 spill이 사라지는” 자동 학습이 가능해졌다. 정적 예약 + 사후 피드백이라는 하이브리드 전략이다.

DMV로 grant 추적

-- 진행 중인 grant들
SELECT session_id,
       requested_memory_kb,
       granted_memory_kb,
       used_memory_kb,
       ideal_memory_kb,
       wait_time_ms
FROM sys.dm_exec_query_memory_grants
ORDER BY requested_memory_kb DESC;

해석:

  • requested >> used → grant 과대 추정 (통계 부정확 또는 grant feedback이 학습 중)
  • granted < requested로 대기 누적 → 동시성에 비해 grant가 너무 후함
  • RESOURCE_SEMAPHORE 대기 비율 상승 → 큰 쿼리들이 grant 가뭄으로 줄 서는 중

Extended Events에서 sort_warning, hash_warning을 활성화하면 spill이 발생할 때마다 이벤트가 떨어진다.


같은 그림, 다른 이름, 한 장 비교표

세 DBMS가 같은 3-tier 비용 모델을 어떻게 다르게 구체화했는지 한 표로:

측면OraclePostgreSQLSQL Server
메모리 영역 이름PGA Work Areawork_memWorkspace Memory (Memory Grant)
할당 단위연산자당 (Work Area)노드당 (per plan node)쿼리당 (사전 합산)
예약 시점동적 (실행 중 조정)동적 (실행 중 조정)정적 (Row mode) / 동적 (Batch mode)
임시 영역 위치TEMP의 sort segmentbase/pgsql_tmp/tempdb의 Worktable(Sort)·Workfile(Hash)
3-tier 신호V$WORKAREA_ACTIVE.PASS (0/1/≥2)Sort Method: external merge, Batches: NSpill 경고, XEvent (sort_warning, hash_warning)
적응형 학습Auto PGA Management,Memory Grant Feedback (2017+)
주요 튜닝 파라미터PGA_AGGREGATE_TARGET, PGA_AGGREGATE_LIMITwork_mem, hash_mem_multiplier, maintenance_work_memmin memory per query, max server memory

세 가지 인사이트:

  • PostgreSQL은 단위가 작아 (per node × per worker) 곱이 무서움. work_mem을 함부로 못 올리는 이유.
  • SQL Server는 컴파일 타임 예약이라 통계 부정확이 spill로 직결됨. Grant Feedback이 이 약점을 보완.
  • Oracle은 자동 관리가 가장 성숙하지만 PGA_AGGREGATE_TARGET이 너무 작으면 multi-pass 전체로 무너짐.

정리

책의 한 문장 “메모리가 부족하면 일시 영역을 쓴다”의 안쪽에는 알고리즘 차원, 모드 차원, 구현 차원의 세 층이 숨어 있었다.

알고리즘 차원: 정렬·해시는 본래 메모리 내 알고리즘(Quicksort, In-memory Hash)으로 설계되었지만, 메모리가 부족해지면 External Merge Sort · Hybrid Hash Join 같은 다른 알고리즘으로 통째로 전환된다. 단순히 “조금 느려진다”가 아니라 다른 알고리즘이 돌아간다.

모드 차원: 그 전환의 비용은 메모리가 얼마나 부족하냐에 따라 단계가 있다. Optimal → One-pass → Multi-pass. K-way Merge의 메모리 요구는 √(D × P)에 비례하므로, 1 GB 정렬에 22 MB 정도면 One-pass로 충분히 처리된다. 하지만 Multi-pass에 빠지면 비용이 K배수로 곱해진다.

구현 차원: 세 DBMS가 같은 문제를 다른 방식으로 해결한다.

  • Oracle: 자동 관리, V$ 뷰로 명시적 추적, PGA per-process 격리.
  • PostgreSQL: 노드당 메모리, EXPLAIN ANALYZE에 모든 정보, parallel 곱셈의 함정.
  • SQL Server: 정적 예약 + 적응형 피드백, Row/Batch mode 분기, tempdb spill.

IMPORTANT

가장 위험한 실수는 셋 다 공통이다, “안 보이니 없는 줄 안다.”

EXPLAIN / 동적 뷰 / XEvent에 external merge Disk, Batches: N (N>1), PASS >= 1, Sort Warning 같은 신호가 보이면 메모리 부족이 발생한 것이고, 이건 단순한 느려짐이 아니라 알고리즘 모드 전환이라는 점을 기억하자.

익숙한 DB일수록 EXPLAIN 한 줄을 더 자세히 읽을 가치가 있다.


ℹ️ 이 글은 “SQL 레벨업”의 한 문단을 출발점으로 삼아, 공식 문서(Oracle Database Performance Tuning Guide, PostgreSQL Docs, Microsoft Learn)를 참조하여 AI(Claude)와 함께 작성되었습니다. 알고리즘 설명과 비교표의 사실 관계는 아래 참고 자료 기준으로 검증했으나, 운영 환경에서 적용할 때는 각 DBMS의 해당 버전 문서를 다시 확인하길 권합니다.


참고 자료

이 글의 용어 (5개)
정렬 알고리즘algorithm
정의 정렬 (sort) 은 원소들의 컬렉션을 어떤 전순서 (total order) 기준으로 재배열하는 것. 알고리즘 입문의 정석 주제이자, 데이터베이스·검색·통계 등 모든 시스템…
External Merge Sortalgorithm
정의 External Merge Sort (외부 머지 정렬) 는 데이터가 메모리에 들어가지 않을 때 사용하는 변형. 두 단계로 동작한다. 1. Run 생성: 메모리 크기만큼 청크…
Heap Sortalgorithm
정의 Heap Sort (힙 정렬) 는 힙 (heap) 자료구조의 최댓값 / 최솟값을 O(log n) 에 추출 하는 성질을 이용한 정렬. In-place 이고 최악 O(n log…
Merge Sortalgorithm
정의 Merge Sort (병합 정렬) 는 분할 정복 (Divide & Conquer) 으로 동작하는 비교 정렬. 배열을 반으로 나눠 각각 정렬한 뒤, 두 정렬된 부분을 병합 (…
Quick Sortalgorithm
정의 Quick Sort (퀵 정렬) 는 분할 정복 (Divide & Conquer) 기반 비교 정렬. 1959 년 Tony Hoare 가 고안. 배열에서 pivot 을 하나 골…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기