운영 중인 플랫폼에서 PostgreSQL 컨테이너가 CPU 100%로 폭주하며 전체 서비스가 멈추는 현상이 반복적으로 발생했다.
| 컨테이너 | CPU | 메모리 | 제한 | 사용률 |
|---|---|---|---|---|
| postgresql | 100.9% | 323MB | 512MB | 63% |
| xgen-frontend | 0% | 5.77GB | 15GB | 38% |
| xgen-core | 2% | 405MB | 1GB | 40% |
pg_stat_activity로 활성 쿼리를 조회한 결과, 1분 넘게 실행 중인 AGE Cypher 쿼리가 원인이었다:
-- 1분 10초째 실행 중인 쿼리
SELECT pid, now() - query_start AS duration, state, left(query, 200) as q
FROM pg_stat_activity WHERE state = 'active';
-- pid 3498 | 00:01:10 | active
-- MATCH p=(n)-[*1..5]-(m)
-- WHERE label(n) IN ['조직','Organization','organization','OurBrandMatched']
-- AND label(m) IN ['조직','Organization','organization','OurBrandMatched']
-- RETURN p
LLM Agent가 생성한 Cypher 쿼리의 [*1..5] 가변 길이 경로(VLE) 가 그래프 전체에 대해 조합 폭발을 일으키고 있었다.
이로 인해서 아래와 같은 문제가 발생함을 확인할 수 있었다.
Connection reset by peer단순히 홉 수를 줄이면 해결될까?
그렇게 하면 그래프DB를 쓸 이유가 없어진다.
정확한 원인을 파악하기 위해 실제 그래프의 크기와 구조를 먼저 측정했다.
-- 노드 수
MATCH (n) RETURN count(n) AS node_count;
-- → 1,303
-- 엣지 수
MATCH ()-[r]->() RETURN count(r) AS edge_count;
-- → 10,233
-- 평균 degree = 10,233 × 2 / 1,303 ≈ 15.7
SELECT name, lbl, degree FROM cypher(..., $$
MATCH (n)-[r]-() RETURN n.name AS name, label(n) AS lbl, count(r) AS degree
$$) ORDER BY degree DESC LIMIT 10;
| 노드 | 라벨 | degree |
|---|---|---|
| 재고있음 | StockStatus | 540 |
| 80PLUS Bronze | EfficiencyCert | 361 |
| 80PLUS Gold | EfficiencyCert | 355 |
| 가격추적봇 | DataSource | 296 |
| 유료배송 | ShippingType | 288 |
| 공식스토어 | SellerType | 281 |
구조적 특성: Competitorpricemonitoring 1,087개 노드가 StockStatus, ShippingType 등 소수 허브에 별(star) 형태로 연결된 고밀도 허브 구조.
이것이 핵심이다. 같은 홉 수라도 시작 노드의 degree에 따라 성능이 완전히 다르다:
허브 노드 (재고있음, degree=540) 기준:
| 홉 수 | 경로 수 | 실행 시간 | 판정 |
|---|---|---|---|
| 1홉 | ~540 | 2.6ms | ✅ 안전 |
| 2홉 | 4,860 | 674ms | ✅ 안전 |
| 3홉 | 923,187 | 44초 (timeout) | ❌ 위험 |
비허브 노드 (품질관리팀, degree≈6) 기준:
| 홉 수 | 경로 수 | 실행 시간 | 판정 |
|---|---|---|---|
| 3홉 | 1 | < 1ms | ✅ 안전 |
| 4홉 | 1 | 167ms | ✅ 안전 |
| 5홉 | 1 | 135ms | ✅ 안전 |
핵심 발견: 문제는 홉 수 자체가 아니라 "허브 노드 + 높은 홉"의 조합이다.
AGE의 VLE 엔진은 모든 경로를 먼저 생성한 뒤 종단 필터링하므로, 경로 수가 degree^hops 로 폭발한다.
(참고: Apache AGE Issue #195)
이 벤치마크 결과를 바탕으로, 정적 홉 제한 대신 degree-aware 동적 정책을 설계했다.
가장 먼저 폭주 쿼리가 시스템 전체를 죽이지 못하도록 PG 레벨 안전장치를 적용했다.
-- 30초 넘는 쿼리 자동 kill (최후의 방어선)
ALTER SYSTEM SET statement_timeout = '30s';
-- idle 트랜잭션 60초 후 kill
ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';
-- 디스크 스필 100MB 초과 시 쿼리 취소
ALTER SYSTEM SET temp_file_limit = '100MB';
-- 512MB 컨테이너에 맞춘 메모리 튜닝
ALTER SYSTEM SET shared_buffers = '128MB';
ALTER SYSTEM SET work_mem = '4MB';
ALTER SYSTEM SET effective_cache_size = '384MB';
-- 느린 쿼리 로깅 (5초 이상)
ALTER SYSTEM SET log_min_duration_statement = 5000;
SELECT pg_reload_conf();
검증 — statement_timeout 동작 확인:
-- 일부러 35초 sleep 쿼리 실행
SELECT pg_sleep(35);
-- → ERROR: canceling statement due to statement timeout
30초 초과 즉시 자동 종료 확인 완료.
왜 이렇게 했는가? statement_timeout은 애플리케이션 레벨에서 어떤 실수가 있더라도 DB가 무한정 자원을 소모하지 않도록 하는 최후의 안전망이다.
단, 이것만으로는 부족하다 — 30초 동안 CPU 100%를 점유하는 것 자체가 문제이므로 쿼리 자체를 가볍게 만들어야 한다.
컨테이너 재시작 후에도 설정이 유지되도록 docker-compose.dev.yml에 영구 설정을 추가했다.
postgresql:
command: >
postgres
-c statement_timeout=30s
-c idle_in_transaction_session_timeout=60s
-c lock_timeout=10s
-c shared_buffers=128MB
-c work_mem=4MB
-c maintenance_work_mem=64MB
-c temp_file_limit=100MB
-c effective_cache_size=384MB
-c random_page_cost=1.1
-c log_min_duration_statement=5000
-c log_temp_files=0
mem_limit: 1g # 512MB → 1GB 증설
cpus: 2
shm_size: '256m'
왜 메모리를 1GB로 올렸는가? AGE의 VLE 연산은 중간 결과를 메모리에 유지하므로, 512MB에서는 shared_buffers + work_mem + AGE 중간 결과가 경합한다. 1GB로 여유를 확보하되, mem_limit으로 호스트를 보호한다.
이것이 이 최적화의 핵심이다. 정적으로 모든 쿼리에 홉 4를 강제하면 비허브 노드의 검색 성능이 불필요하게 제한된다. 벤치마크 데이터에서 도출한 degree 기반 동적 정책을 적용했다:
# 벤치마크 실측 데이터에서 도출한 정책 테이블
# degree^hops 가 경로 폭발의 원인이므로, degree가 높을수록 홉을 줄인다
_VLE_POLICY = [
# (degree 상한, 허용 최대 홉)
(10, 6), # 10^6 = 1M → LIMIT과 결합하면 안전. 실측 5홉 135ms
(50, 5), # 50^5 = 312M → statement_timeout으로 커버
(200, 3), # 200^3 = 8M → 실측 에너지옵티머스(214) 3홉 = 44초
]
_ABSOLUTE_MAX_HOPS = 6 # 어떤 경우든 이 이상은 금지
_FALLBACK_MAX_HOPS = 2 # degree > 200 일 때 (540^2 = 4,860 경로 / 674ms)
def _max_hops_for_degree(self, degree: int) -> int:
"""실측 벤치마크 기반 안전 홉 상한 결정"""
for threshold, max_hops in self._VLE_POLICY:
if degree <= threshold:
return max_hops
return self._FALLBACK_MAX_HOPS
동작 흐름:
[*1..N]) 감지name 추출def _sanitize_vle(self, cypher: str, graph_name: str) -> str:
"""VLE 패턴을 degree에 맞게 동적 클램핑"""
# 1) [*] 무제한 패턴 → [*1..max] 로 변환
# 2) [*1..N] 패턴에서 시작 노드 degree 조회
# 3) degree 기반 안전 상한과 비교하여 필요시에만 클램핑
start_name = self._extract_start_node_name(cypher)
if start_name:
degree = self._get_node_degree(graph_name, start_name)
safe_max = self._max_hops_for_degree(degree)
# 요청 홉이 안전 상한 이내면 원본 유지
if requested_hops <= safe_max:
return cypher # 클램핑 없음
왜 이렇게 했는가?
정적 홉 제한(예: 모든 쿼리에 4홉 강제)은 그래프DB의 존재 이유를 훼손한다.
비허브 노드에서 5홉이 135ms로 안전하게 동작하는데 굳이 제한할 이유가 없다. 반면 degree 540인 허브 노드에서 3홉은 92만 경로로 폭발하므로 반드시 제한해야 한다.
"똑같은 약을 모든 환자에게 주지 않는다"는 원칙.
LLM Agent가 생성하는 Cypher 쿼리에 LIMIT이 빠져있는 경우가 많다. LIMIT 없는 쿼리가 그래프 전체를 스캔하는 것을 방지한다.
def execute_cypher_query(self, graph_name, cypher, ...):
"""LIMIT 없는 쿼리에 LIMIT 100 자동 추가"""
upper = cypher.upper()
# RETURN 절이 있지만 LIMIT이 없으면 자동 추가
if 'RETURN' in upper and 'LIMIT' not in upper:
cypher = cypher.rstrip().rstrip(';') + ' LIMIT 100'
AGE에서 {name: 'X'} 인라인 매칭은 내부적으로 properties @> '{"name": "X"}' jsonb 비교로 변환된다. GIN 인덱스가 없으면 모든 vertex 라벨 테이블을 Seq Scan 해야 한다.
def ensure_indexes(self, graph_name):
"""vertex/edge 라벨 테이블에 GIN 인덱스 추가"""
# _ag_label_vertex 기본 테이블
CREATE INDEX IF NOT EXISTS idx_{graph}_vertex_props_gin
ON {schema}._ag_label_vertex USING GIN (properties);
# 개별 vertex 라벨 테이블 (Competitorpricemonitoring 등)
for label in vertex_labels:
CREATE INDEX IF NOT EXISTS idx_{graph}_{label}_props_gin
ON {schema}."{label}" USING GIN (properties);
# 개별 edge 라벨 테이블
for label in edge_labels:
CREATE INDEX IF NOT EXISTS idx_{graph}_{label}_props_gin
ON {schema}."{label}" USING GIN (properties);
왜 GIN인가? AGE의 properties 컬럼은 agtype (jsonb 기반)이다. GIN 인덱스는 jsonb 포함 연산(@>)에 최적화되어 있으며, B-tree와 달리 키-값 쌍의 임의 조합을 커버한다.
(참고: AGE Issues #1000, #1010)
LLM이 생성하는 쿼리 중 RETURN p (경로 전체 반환) 패턴은 경로 객체를 통째로 직렬화하므로 메모리를 급격히 소모한다.
# multi_turn_rag.py — _tool_cypher_query
def _tool_cypher_query(self, cypher, ...):
# RETURN p 패턴 감지 시 경고 로깅
if re.search(r'\bRETURN\s+p\b', cypher, re.IGNORECASE):
logger.warning(
"[CypherSafety] 'RETURN p' detected — "
"full path serialization may cause memory spike: %s",
cypher[:200]
)
| 테스트 케이스 | 조건 | 결과 |
|---|---|---|
| 비허브(품질관리팀) 5홉 | degree ≈ 6, 정책 허용 6홉 | 9ms ✅ 자유롭게 깊이 탐색 |
| 허브(재고있음) 2홉 + LIMIT 50 | degree = 540, 정책 허용 2홉 | 18ms ✅ 안전하게 동작 |
| 35초 sleep 쿼리 | statement_timeout 30s | 자동 kill ✅ 최후 방어선 동작 |
| 시나리오 | 이전 (무방비) | 이후 (6중 방어) |
|---|---|---|
[*1..5] 허브 쿼리 | CPU 100%, 수분간 hang, 서비스 전체 중단 | 30초 후 자동 kill + 홉 동적 클램핑 |
| LIMIT 없는 LLM 쿼리 | 전체 그래프 스캔 | LIMIT 100 자동 추가 |
[*] 무제한 경로 | 조합 폭발로 PG 다운 | [*1..N] (degree 기반 N) 으로 변환 |
| PG 메모리 | 512MB (AGE에 부족) | 1GB + shm 256m |
{name:'X'} 검색 | 31개 라벨 테이블 Seq Scan | GIN 인덱스 활용 |
이전: postgresql 100.90% 323.5MiB / 512MiB 63.19%
이후: postgresql 0.00% 176.5MiB / 512MiB 34.47%
CPU 100% → 0%, 메모리 사용률 63% → 34%로 안정화.
처음 접근은 정적 홉 제한 (모든 쿼리에 4홉 강제) 이었다.
하지만 이 방식은 그래프DB를 사용하는 이유 자체를 부정하는 것이다. 비허브 노드에서 5홉이 135ms로 안전하게 동작하는데, 4홉으로 제한하면 검색 품질이 떨어진다.
실측 벤치마크를 통해 "문제는 홉 수가 아니라 degree^hops 조합" 이라는 것을 발견한 뒤, degree-aware 동적 정책으로 전환했다.
결과적으로
statement_timeout 30s가 최후의 방어선으로 동작L6 LLM Cypher 위험 패턴 차단 ← 쿼리 생성 시점
L5 GIN 인덱스 ← 쿼리 실행 가속
L4 LIMIT 강제 주입 ← 결과 크기 제한
L3 Degree-Aware VLE 클램핑 ← 경로 폭발 방지 (핵심)
L2 Docker 리소스 제한 ← 컨테이너 격리
L1 statement_timeout 30s ← 최후의 안전망