이 글을 읽으면 알 수 있는 것
- Django
.all()이 대용량 테이블에서 OOM을 일으키는 이유와 keyset pagination 해결법 WHERE id = ANY(ARRAY[...])배치 UPDATE 패턴- 처리 완료 조건을 명시하지 않으면 무한루프가 되는 구조
- NULL FK 행이 배치 루프를 영원히 돌게 만드는 메커니즘
배경: 비정규화가 필요했던 이유
이전 글에서 검색 성능을 4.5초에서 0.05초로 줄이는 과정을 다뤘습니다. 그 핵심 조치 중 하나가 company_name과 ingredient_count를 Product 테이블에 직접 넣는 비정규화였습니다.
컬럼을 추가하는 마이그레이션은 간단합니다. 문제는 그 다음입니다.
기존 160만 건의 Product에
company_name과ingredient_count값을 채워 넣어야 합니다.
간단한 배치 스크립트라고 생각했습니다. 실제로는 3가지 함정을 차례로 밟았습니다.
함정 1: OOM — .all()은 대용량에서 금지어
첫 시도: 직관적이지만 치명적
# 직관적인 접근 — 160만 건에서는 위험
for product in Product.objects.all().iterator():
if product.company:
product.company_name = product.company.name
product.ingredient_count = product.ingredients.count()
product.save()
Django의 iterator()는 QuerySet 캐시를 비활성화해서 메모리를 절약한다고 알려져 있습니다. 하지만 여기에는 두 가지 문제가 있었습니다.
첫째, N+1 쿼리. 160만 건 각각에 대해 product.company.name과 product.ingredients.count()를 호출합니다. 제품 하나당 쿼리 2~3개. 160만 x 3 = 480만 쿼리.
둘째, iterator()도 완전하지 않습니다. Django의 iterator()는 DB 커서를 사용하지만, PostgreSQL의 서버사이드 커서는 기본적으로 전체 결과를 트랜잭션 내에서 유지합니다. 160만 건의 Product 객체를 Python 메모리에 하나씩 올리는 동안, 처리 속도가 DB 조회 속도를 따라가지 못하면 메모리 사용량이 계속 증가합니다.
결과: 프리티어 서버(1GB RAM)에서 메모리 초과. 프로세스가 OOM Killer에 의해 강제 종료.
해결: Keyset Pagination + 배치 UPDATE
전체를 한 번에 순회하는 대신, ID 기준으로 잘라서 처리합니다.
BATCH_SIZE = 5000
last_id = 0
while True:
# 배치 단위로 ID만 가져온다
batch = list(
Product.objects
.filter(id__gt=last_id, company_name='')
.order_by('id')
.values_list('id', 'company_id')[:BATCH_SIZE]
)
if not batch:
break
product_ids = [row[0] for row in batch]
company_ids = [row[1] for row in batch if row[1]]
# 제조사 이름을 한 번에 조회
company_map = dict(
Company.objects.filter(id__in=company_ids)
.values_list('id', 'name')
)
# 배치 UPDATE — 건건이 save() 대신 한 번에
updates = []
for pid, cid in batch:
name = company_map.get(cid, '')
updates.append((pid, name))
# WHERE id = ANY(ARRAY[...]) 패턴
with connection.cursor() as cursor:
cursor.execute("""
UPDATE product SET company_name = data.name
FROM (SELECT unnest(%s::int[]) AS id,
unnest(%s::text[]) AS name) AS data
WHERE product.id = data.id
""", [
[u[0] for u in updates],
[u[1] for u in updates],
])
last_id = product_ids[-1]
logger.info(f"Batch done: last_id={last_id}")
핵심 포인트:
| 기법 | 효과 |
|---|---|
WHERE id > last_id ORDER BY id LIMIT N | 매 배치마다 고정 메모리 사용. OFFSET과 달리 뒤로 갈수록 느려지지 않음 |
values_list() | 모델 인스턴스 생성 비용 제거. ID와 FK만 가져옴 |
WHERE id = ANY(ARRAY[...]) | 5,000건을 한 번의 UPDATE로 처리. 건건이 save()하면 5,000회 쿼리 |
unnest + FROM subquery | 여러 컬럼을 동시에 배치 업데이트할 때 유용한 PostgreSQL 패턴 |
메모리 사용량은 배치 크기에 비례해서 일정하게 유지. 160만 건 전체 처리에 성공했습니다.
— 라고 생각했습니다.
함정 2: 무한 skip 루프
배치 스크립트를 실행하고, 로그를 지켜봤습니다.
Batch done: last_id=5000
Batch done: last_id=10000
...
Batch done: last_id=1520000
Batch done: last_id=1525000
정상적으로 끝까지 갈 것 같았습니다. 그런데 한참 후에 다시 보니:
Batch done: last_id=5000
Batch done: last_id=10000
...
처음부터 다시 돌고 있었습니다.
원인: “처리 완료”란 무엇인가
문제는 배치 선택 조건이었습니다.
Product.objects.filter(id__gt=last_id, company_name='')
company_name=''인 제품을 찾아서 값을 채워 넣는 로직입니다. 정상적인 제품이라면 한번 처리되면 company_name에 값이 들어가므로, 다음 전체 순회에서는 이 조건에 걸리지 않습니다.
그런데 company_id가 NULL인 제품은?
FK가 없으므로 company_map.get(cid, '')에서 빈 문자열을 반환합니다. company_name = ''로 UPDATE되고, 다음 순회에서 다시 company_name='' 조건에 걸립니다.
순회 1: company_id=NULL인 제품 → company_name='' 으로 UPDATE
순회 2: company_name='' 조건 → 같은 제품이 다시 선택됨
순회 3: 같은 제품이 다시 선택됨
... (영원히)
루프가 끝나는 조건이 “company_name=''인 행이 0건이 될 때”인데, company가 없는 제품은 영원히 company_name=''입니다. 종료 조건을 만족할 수 없는 행이 존재하는 한, 루프는 끝나지 않습니다.
해결: 매칭 불가 행을 명시적으로 마킹
“처리할 수 없는 행”과 “아직 처리하지 않은 행”을 구분해야 합니다.
# 처리 불가 행을 먼저 마킹
Product.objects.filter(
company__isnull=True,
company_name=''
).update(company_name='—') # 또는 별도 플래그 컬럼
# 이후 배치 루프는 정상 종료
while True:
batch = list(
Product.objects
.filter(id__gt=last_id, company_name='')
.order_by('id')
.values_list('id', 'company_id')[:BATCH_SIZE]
)
if not batch:
break # 이제 진짜 끝남
...
company_id가 NULL인 행을 먼저 처리해서, 배치 루프의 선택 조건에서 제외합니다. 이제 company_name=''인 행은 “아직 처리하지 않았지만 처리 가능한 행”만 남게 되므로, 루프가 정상 종료됩니다.
함정 3: 묵시적 가정 — “모든 Product에는 company가 있다”
세 번째 함정은 코드가 아니라 사고방식에 있었습니다.
비정규화 스크립트를 설계할 때, 암묵적으로 이렇게 가정하고 있었습니다.
“Product에는 company FK가 있다. 당연히.”
실제 데이터를 확인해보니:
SELECT COUNT(*) FROM product WHERE company_id IS NULL;
-- 결과: 수만 건
160만 건 중 상당수가 company_id = NULL이었습니다.
이유는 여러 가지였습니다. 공공 API에서 제조사 정보 없이 등록된 제품, ETL 과정에서 제조사 매칭에 실패한 제품, 제조사가 폐업해서 참조할 수 없는 제품. 데이터의 현실은 스키마의 이상과 다릅니다.
이 가정이 함정 1에서는 드러나지 않았습니다. OOM은 데이터의 내용과 무관하게, 양 자체가 문제였으니까요. 하지만 함정 2에서는 이 가정이 직접적인 원인이었습니다. “모든 행이 처리 가능하다”는 가정이 “모든 행이 종료 조건을 만족시킬 수 있다”는 가정으로 이어졌고, 무한루프가 됐습니다.
최종 배치 처리 구조
세 가지 함정을 모두 거친 후의 최종 흐름입니다.
1. 전처리: company_id IS NULL인 행을 마킹 (처리 불가 명시)
↓
2. 배치 루프: keyset pagination (id > last_id)
├── 5,000건씩 ID + company_id 조회
├── company_id 기반으로 company_name 일괄 조회
├── ANY(ARRAY[...])로 배치 UPDATE
└── last_id 갱신
↓
3. 종료: company_name='' 인 행이 0건 → break
| 항목 | 첫 시도 | 최종 |
|---|---|---|
| 순회 방식 | .all().iterator() | keyset pagination |
| UPDATE 방식 | 건건이 .save() | WHERE id = ANY(ARRAY[...]) |
| 메모리 사용 | 무제한 증가 → OOM | 배치 크기에 비례 (고정) |
| NULL FK 처리 | 무시 (무한루프) | 사전 마킹 |
| 종료 조건 | 암묵적 | 명시적 (company_name='' 행 0건) |
교훈
-
.all()은 대용량에서 금지어. 160만 건을 한 번에 순회하는 코드는 개발 환경에서만 작동합니다. 프로덕션에서는 keyset pagination으로 배치 단위 처리가 기본입니다. -
종료 조건을 명시하지 않으면 무한루프가 됩니다. “처리할 행이 없을 때 끝난다”는 조건은, 처리 불가능한 행이 존재하면 영원히 만족되지 않습니다. “처리 완료”와 “처리 불가”를 구분해야 합니다.
-
엣지케이스는 데이터 규모에 비례합니다. 1,000건에서는 모든 제품에 제조사가 있을 수 있습니다. 160만 건에서는 반드시 예외가 존재합니다. 대용량 배치를 작성할 때는 “이 필드가 NULL이면?”을 항상 먼저 확인해야 합니다.
-
한 번에 하나씩 고쳐야 합니다. OOM을 고치고 나서야 무한루프가 보였고, 무한루프를 고치고 나서야 NULL FK 문제의 본질이 보였습니다. 양파 구조의 버그는 겹겹이 벗겨야 합니다.