
시스템을 마비시킨 두 가지 안티패턴

Nested Loop Join(NLJ) 방식은 Driving Table의 각 데이터를 순회할 때마다 Driven Table 전체를 반복해서 스캔합니다.
가장 큰 문제는 이 과정이 무려 9단계로 중첩(9-depth) 되었다는 점입니다. 드라이빙 테이블의 행 하나당 매번 새로운 테이블을 풀 스캔해야 하므로, 데이터베이스가 계산해야 할 경우의 수(Cardinality)가 기하급수적으로 폭증하게 됩니다.
데이터베이스 옵티마이저(Optimizer)는 적절한 커버링 인덱스가 없거나 통계 정보가 부정확할 때 이런 비효율적인 실행 계획을 선택해 버립니다. 대량의 데이터 처리에는 해시 조인(Hash Join)이나 소트 머지 조인(Sort Merge Join) 같은 더 효율적인 방식이 있음에도 불구하고, 최악의 시간 복잡도일 수 있는 N의 9제곱을 하게 만든 것입니다.
결과적으로 인덱스를 타지 못하고 눈덩이처럼 불어난 이 거대한 연산은 AWS Aurora DB 엔진이 감당할 수 있는 임계치를 넘어섰고, 결국 ACU를 한계치까지 폭증시킨 첫 번째 원인이 되었습니다.
Sort (cost=1600.61..1600.95 rows=134 width=398)
Sort Key: chain.depth, c.id, cr.id, tk.address, ... (엄청나게 긴 키)
Sort Method: quicksort Memory: 25kB
- 일반적인 ID 정렬: 보통 8바이트 크기의 ID값 100만 개를 정렬하는 데는 약 8MB의 메모리면 충분합니다.
- 문제의 쿼리: 행 하나가 398바이트라면, 단 10만 개의 행을 정렬하는 데 약 40MB에 육박하는 메모리가 필요합니다.
문제는 PostgreSQL이 단일 쿼리의 정렬 작업에 허용하는 기본 메모리 임계치(work_mem)가 보통 4MB에 불과하다는 점입니다. 이처럼 무거운 데이터를 정렬하려는 시도는 곧바로 허용된 RAM 용량을 초과해 버립니다.
이 두 가지 안티패턴(과도한 JOIN과 무거운 GROUP BY)이 결합하여 PostgreSQL 내부에서 어떤 연쇄 작용을 일으켰는지 아래에서 설명해 드리겠습니다.
GroupAggregate (cost=2456.56..2463.93 rows=134 width=492) (actual time=0.082..0.085 rows=0 loops=1)
Output: c.id, c.value, c."prevId", c."parentId", c."createdAt", c."updatedAt", c."imageSrc", c."jsonValue", COALESCE(jsonb_agg(DISTINCT (jsonb_build_object('user', jsonb_build_object('id', ru.id, 'userName', ru."userName")))) FILTER (WHERE (ru.id IS NOT NULL)), '[]'::jsonb), jsonb_build_object('id', cr.id, 'profileImageUrl', cr."profileImageUrl", 'userName', cr."userName", 'userNameTag', cr."userNameTag", 'displayName', cr."displayName", 'userType', cr."userType", 'isPreOrdered', cr."isPreOrdered", 'Token', jsonb_build_object('address', tk.address, 'isCexListed', tk."isCexListed")), COALESCE(jsonb_agg(DISTINCT jsonb_build_object('text', ht.text)) FILTER (WHERE (ht.text IS NOT NULL)), '[]'::jsonb), jsonb_build_object('RePost', count(DISTINCT rp.id), 'Like', count(DISTINCT l.id), 'children', count(DISTINCT ch.id)), chain.depth, cr.id, cr."profileImageUrl", cr."userName", cr."userNameTag", cr."displayName", cr."userType", cr."isPreOrdered", tk.address, tk."isCexListed"
Group Key: chain.depth, c.id, cr.id, tk.address
Buffers: shared hit=16
CTE chain
-> Recursive Union (cost=0.42..855.95 rows=101 width=12) (actual time=0.008..0.015 rows=1 loops=1)
Buffers: shared hit=4
-> Index Scan using "Content_id_duplicateCheck_contentsType_isBlocked_value_idx" on public."Content" c_1 (cost=0.42..8.44 rows=1 width=12) (actual time=0.007..0.007 rows=1 loops=1)
Output: c_1.id, c_1."prevId", 0
Index Cond: (c_1.id = 317282)
Buffers: shared hit=4
-> Nested Loop (cost=0.42..84.65 rows=10 width=12) (actual time=0.006..0.006 rows=0 loops=1)
Output: p.id, p."prevId", (chain_1.depth + 1)
Inner Unique: true
-> WorkTable Scan on chain chain_1 (cost=0.00..0.20 rows=10 width=8) (actual time=0.001..0.001 rows=1 loops=1)
Output: chain_1.id, chain_1."prevId", chain_1.depth
-> Index Scan using "Content_pkey" on public."Content" p (cost=0.42..8.44 rows=1 width=8) (actual time=0.005..0.005 rows=0 loops=1)
Output: p.id, p."prevId"
Index Cond: (p.id = chain_1."prevId")
Filter: (NOT p."isBlocked")
-> Sort (cost=1600.61..1600.95 rows=134 width=398) (actual time=0.081..0.083 rows=0 loops=1)
Output: c.id, chain.depth, cr.id, tk.address, c.value, c."prevId", c."parentId", c."createdAt", c."updatedAt", c."imageSrc", c."jsonValue", ru.id, ru."userName", cr."profileImageUrl", cr."userName", cr."userNameTag", cr."displayName", cr."userType", cr."isPreOrdered", tk."isCexListed", ht.text, rp.id, l.id, ch.id, (jsonb_build_object('user', jsonb_build_object('id', ru.id, 'userName', ru."userName")))
Sort Key: chain.depth, c.id, cr.id, tk.address, (jsonb_build_object('user', jsonb_build_object('id', ru.id, 'userName', ru."userName")))
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=16
-> Nested Loop Left Join (cost=3.41..1595.88 rows=134 width=398) (actual time=0.040..0.042 rows=0 loops=1)
Output: c.id, chain.depth, cr.id, tk.address, c.value, c."prevId", c."parentId", c."createdAt", c."updatedAt", c."imageSrc", c."jsonValue", ru.id, ru."userName", cr."profileImageUrl", cr."userName", cr."userNameTag", cr."displayName", cr."userType", cr."isPreOrdered", tk."isCexListed", ht.text, rp.id, l.id, ch.id, jsonb_build_object('user', jsonb_build_object('id', ru.id, 'userName', ru."userName"))
Buffers: shared hit=8
-> Nested Loop Left Join (cost=2.98..1362.39 rows=134 width=394) (actual time=0.040..0.041 rows=0 loops=1)
Output: chain.depth, c.id, c.value, c."prevId", c."parentId", c."createdAt", c."updatedAt", c."imageSrc", c."jsonValue", rp.id, ru.id, ru."userName", cr.id, cr."profileImageUrl", cr."userName", cr."userNameTag", cr."displayName", cr."userType", cr."isPreOrdered", tk.address, tk."isCexListed", l.id, ht.text
Inner Unique: true
Buffers: shared hit=8
-> Nested Loop Left Join (cost=2.70..1321.18 rows=134 width=389) (actual time=0.040..0.041 rows=0 loops=1)
Output: chain.depth, c.id, c.value, c."prevId", c."parentId", c."createdAt", c."updatedAt", c."imageSrc", c."jsonValue", rp.id, ru.id, ru."userName", cr.id, cr."profileImageUrl", cr."userName", cr."userNameTag", cr."displayName", cr."userType", cr."isPreOrdered", tk.address, tk."isCexListed", l.id, ct."hashTagId"
Buffers: shared hit=8
-> Nested Loop Left Join (cost=2.28..1221.64 rows=97 width=385) (actual time=0.039..0.040 rows=0 loops=1)
Output: chain.depth, c.id, c.value, c."prevId", c."parentId", c."createdAt", c."updatedAt", c."imageSrc", c."jsonValue", rp.id, ru.id, ru."userName", cr.id, cr."profileImageUrl", cr."userName", cr."userNameTag", cr."displayName", cr."userType", cr."isPreOrdered", tk.address, tk."isCexListed", ct."hashTagId"
Buffers: shared hit=8
-> Nested Loop Left Join (cost=1.99..1188.59 rows=97 width=381) (actual time=0.039..0.040 rows=0 loops=1)
Output: chain.depth, c.id, c.value, c."prevId", c."parentId", c."createdAt", c."updatedAt", c."imageSrc", c."jsonValue", rp.id, ru.id, ru."userName", cr.id, cr."profileImageUrl", cr."userName", cr."userNameTag", cr."displayName", cr."userType", cr."isPreOrdered", tk.address, tk."isCexListed"
Inner Unique: true
Buffers: shared hit=8
-> Nested Loop Left Join (cost=1.57..1134.65 rows=97 width=337) (actual time=0.039..0.040 rows=0 loops=1)
Output: chain.depth, c.id, c.value, c."prevId", c."parentId", c."createdAt", c."updatedAt", c."imageSrc", c."jsonValue", rp.id, ru.id, ru."userName", cr.id, cr."profileImageUrl", cr."userName", cr."userNameTag", cr."displayName", cr."userType", cr."isPreOrdered"
Inner Unique: true
Buffers: shared hit=8
-> Nested Loop Left Join (cost=1.14..1070.61 rows=97 width=227) (actual time=0.039..0.039 rows=0 loops=1)
Output: chain.depth, c.id, c.value, c."prevId", c."parentId", c."createdAt", c."updatedAt", c."imageSrc", c."jsonValue", c."creatorId", rp.id, ru.id, ru."userName"
Inner Unique: true
Buffers: shared hit=8
-> Nested Loop Left Join (cost=0.72..890.86 rows=97 width=218) (actual time=0.039..0.039 rows=0 loops=1)
Output: chain.depth, c.id, c.value, c."prevId", c."parentId", c."createdAt", c."updatedAt", c."imageSrc", c."jsonValue", c."creatorId", rp.id, rp."reposterId"
Buffers: shared hit=8
-> Nested Loop (cost=0.42..850.97 rows=97 width=210) (actual time=0.038..0.039 rows=0 loops=1)
Output: chain.depth, c.id, c.value, c."prevId", c."parentId", c."createdAt", c."updatedAt", c."imageSrc", c."jsonValue", c."creatorId"
Inner Unique: true
Buffers: shared hit=8
-> CTE Scan on chain (cost=0.00..2.02 rows=101 width=8) (actual time=0.009..0.016 rows=1 loops=1)
Output: chain.id, chain."prevId", chain.depth
Buffers: shared hit=4
-> Index Scan using "Content_pkey" on public."Content" c (cost=0.42..8.41 rows=1 width=206) (actual time=0.021..0.021 rows=0 loops=1)
Output: c.id, c.value, c."prevId", c."parentId", c."createdAt", c."updatedAt", c."imageSrc", c."jsonValue", c."creatorId"
Index Cond: (c.id = chain.id)
Filter: ((NOT c."isBlocked") AND (c.id <> 317282))
Rows Removed by Filter: 1
Buffers: shared hit=4
-> Index Scan using "RePost_contentId_idx" on public."RePost" rp (cost=0.29..0.37 rows=4 width=12) (never executed)
Output: rp.id, rp."contentId", rp."reposterId", rp."createdAt", rp."updatedAt"
Index Cond: (rp."contentId" = c.id)
-> Index Scan using "Users_pkey" on public."Users" ru (cost=0.42..1.85 rows=1 width=13) (never executed)
Output: ru.id, ru."userName"
Index Cond: (ru.id = rp."reposterId")
-> Index Scan using "Users_pkey" on public."Users" cr (cost=0.42..0.66 rows=1 width=114) (never executed)
Output: cr.id, cr."profileImageUrl", cr."userName", cr."userNameTag", cr."displayName", cr."userType", cr."isPreOrdered"
Index Cond: (cr.id = c."creatorId")
-> Index Scan using "Token_creatorId_key" on public."Token" tk (cost=0.42..0.56 rows=1 width=48) (never executed)
Output: tk.address, tk."isCexListed", tk."creatorId"
Index Cond: (tk."creatorId" = cr.id)
-> Index Scan using "contentId_idx" on public."ContentTags" ct (cost=0.29..0.32 rows=2 width=8) (never executed)
Output: ct.id, ct."hashTagId", ct."contentId"
Index Cond: (ct."contentId" = c.id)
-> Index Scan using "Like_contentsId_idx" on public."Like" l (cost=0.42..0.84 rows=19 width=8) (never executed)
Output: l.id, l."userId", l."contentsId", l."createdAt", l."updatedAt"
Index Cond: (l."contentsId" = c.id)
-> Index Scan using "HashTags_pkey" on public."HashTags" ht (cost=0.28..0.31 rows=1 width=13) (never executed)
Output: ht.id, ht.text, ht.activate, ht."createdAt", ht."updatedAt", ht.count
Index Cond: (ht.id = ct."hashTagId")
-> Index Scan using content_parent_idx on public."Content" ch (cost=0.42..1.67 rows=7 width=8) (never executed)
Output: ch.id, ch."parentId"
Index Cond: (ch."parentId" = c.id)
Filter: ((NOT ch."isBlocked") AND (ch."contentsType" = 'REPLY'::"ContentType"))
Query Identifier: -575830414252295307
Planning:
Buffers: shared hit=728 read=10
I/O Timings: shared read=9.769
Planning Time: 15.786 ms
Execution Time: 0.357 ms
메모리와 디스크 사이의 병목 현상

- 특정 JOIN 연산에 필요한 데이터 페이지를 메모리에 로드합니다.
- 하지만 곧바로 다음 단계의 JOIN을 위해 새로운 데이터 페이지가 필요해집니다.
- 새로운 페이지를 위한 공간을 확보하기 위해, 방금 올렸던 데이터 페이지가 메모리에서 방출(evict)됩니다.
- 쓰기 단계: 메모리에 다 담지 못하는 거대한 데이터를 여러 조각으로 나누어 디스크의 임시 파일에 씁니다.
- 병합 단계: 디스크에 저장했던 조각들을 다시 읽어와 병합하며 최종 정렬 결과를 만듭니다.
블로킹 오퍼레이터란? 데이터베이스 연산은 보통 파이프라인(Streaming)처럼 앞 단계의 결과가 나오면 바로 다음 단계로 흘려보냅니다. 하지만 정렬과 같은 블로킹 오퍼레이터는 모든 입력 데이터가 도착할 때까지 다음 단계로 단 한 줄의 결과도 전달하지 않고 작업을 막아버립니다.
해결 전략: 데이터 처리 전환
/* 개선 후: CTE와 서브쿼리를 조합한 전략적 선택 */
WITH filtered_content AS (
-- [1] Early Filtering: 최소한의 ID 목록(Driving Table)을 먼저 확정
SELECT * FROM content
WHERE status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 10 -- 페이징 적용
)
SELECT
content.*,
-- [2] 지연 로딩: 확정된 10건에 대해서만 관련 데이터 조회
(SELECT ROW(u.id, u.name) FROM users u WHERE u.id = content.user_id) AS creator,
(SELECT COUNT(*) FROM likes l WHERE l.content_id = content.id) AS like_count
FROM filtered_content AS content;
SubPlan ... (never executed)
Index Only Scan using idx_content_composite ... > Heap Fetches: 0
- Index Only Scan: 쿼리 처리에 필요한 모든 컬럼이 인덱스에 포함되어 있어, 실제 테이블 데이터(Heap)를 조회하지 않고 인덱스만으로 모든 연산을 끝냈다는 뜻입니다.
- Heap Fetches: 0: 실제 테이블 접근이 단 한 번도 발생하지 않았음을 나타내는 명확한 증거입니다.
결과 및 영향

- 시스템 안정성: 동일한 자원으로 이전보다 훨씬 많은 트래픽을 안정적으로 감당할 수 있는 견고한 체력을 확보했습니다. 예기치 않은 트래픽에도 시스템이 마비될 것이라는 불안감에서 벗어날 수 있었습니다.
- 비즈니스 가치: 장애 대응을 위해 불필요하게 인프라를 확장하는 데 드는 막대한 비용을 획기적으로 절감했습니다. 자원을 효율적으로 사용하여 기술 부채를 줄이고 경제적 이익을 창출했습니다.
- 사용자 경험: 서비스 응답 불능 상태에서 밀리초(ms) 단위의 쾌적한 응답 속도로 전환되었습니다. 이는 직접적으로 사용자 만족도 향상과 이탈률 감소로 이어졌습니다.
하지만, 시스템이 안정화된 후 우리는 근본적인 질문을 던져야 했습니다. "왜 애초에 9단계 JOIN이 필요했을까?" 이번 장애의 진정한 원인은 단순한 SQL 작성 실수가 아니라, 데이터 모델링 단계에서의 '반정규화(Denormalization)'에 대한 고려가 없었기 때문입니다. 엄격한 정규화 원칙에만 얽매여 읽기 성능을 위한 모델을 분리하지 않았고, 그 결과 런타임에 모든 릴레이션을 엮어야 하는 아키텍처의 기술 부채가 버퍼 캐시 스레싱과 ACU 24 폭주라는 장애로 발현된 것입니다. 향후 유사한 도메인에서는 철저한 읽기/쓰기 모델 분리(CQRS)와 통계 데이터의 비정규화를 아키텍처 초기 단계부터 반영해야 함을 배웠습니다.
'Project > 기록' 카테고리의 다른 글
| 10분마다 치는 spike 평탄화 작업기(feat. ACU 45% 절감) (0) | 2026.02.08 |
|---|---|
| CS 이론으로 풀어낸 EKS 소켓 서버 최적화: DNS, 커널, 그리고 오토스케일링의 함정 (0) | 2026.02.01 |
| Log 영속성을 위한 MQ 도입 (1) | 2025.06.10 |
| MySQL에서의 Lock 경합 문제 해결과 성능 개선 (2) | 2025.06.09 |
| Bullmq Document + CS 관점으로 다시 생각 (1) | 2025.06.05 |