
1편에서 데이터의 정합성(동시성 제어)을 다뤘고, 2편에서 데이터의 조회 속도(N+1 문제)를 최적화했습니다.
이제 시스템은 안전하고 빨라졌습니다. 하지만 마케팅 팀에서 "엑셀로 정리한 회원 10만 명 정보를 한 번에 업데이트해주세요"라는 요청이 온다면 어떨까요? 혹은 로그 데이터를 DB에 대량으로 쌓아야 한다면요?
반복문(for)을 돌며 한 땀 한 땀 save()를 호출하다가 타임아웃으로 서버가 뻗어버린 경험이 있다면, 이번 3편이 그 해결책이 될 것입니다.
대용량 데이터 처리: "for문 돌리지 말고 Bulk를 믿으세요"
1. 엑셀 업로드 버튼만 누르면 서버가 죽어요
백오피스(Admin) 개발을 하다 보면 가장 흔하게 받는 요청 중 하나가 대량 등록/수정 기능입니다.
"상품 5만 개 가격을 일괄 변경하고 싶어요.", "신규 회원 1만 명을 엑셀로 업로드할게요."
처음에는 별생각 없이 코드를 짭니다. 파일을 읽고, for 문을 돌면서 하나씩 저장하죠. 로컬에서 데이터 10개로 테스트할 땐 순식간에 끝납니다. 하지만 운영 서버에 배포하고 실제 데이터를 넣는 순간, 브라우저는 뱅글뱅글 돌기만 하다가 504 Gateway Timeout을 뱉으며 장렬히 전사합니다.
범인은 바로 데이터베이스 왕복 비용(Round Trip Time)입니다. 오늘은 10만 번 왔다 갔다 해야 할 작업을 단 한 번으로 줄여주는 Django의 강력한 무기, Bulk 연산에 대해 알아보겠습니다.
2. 최악의 시나리오: for 문 안의 save()
가장 직관적이지만 성능상 최악인 코드입니다.
# views.py (나쁜 예시)
def import_users(data_list):
# data_list: 엑셀에서 읽어온 10,000명의 데이터 딕셔너리 리스트
for row in data_list:
# 1. 객체 생성 (메모리)
user = User(
name=row['name'],
email=row['email'],
age=row['age']
)
# 2. DB 저장 (네트워크 통신 발생!)
user.save()
데이터가 10,000개라면?
- DB 연결 획득
INSERT INTO user ...쿼리 전송- DB 처리 및 응답 수신
- DB 연결 반환
이 과정을 10,000번 반복합니다. 쿼리 자체의 속도보다, 네트워크를 타고 DB에 다녀오는 시간이 훨씬 더 걸립니다. 마치 이삿짐을 나르는데 박스 하나 들고 트럭에 갔다가, 다시 올라와서 하나 들고 가는 꼴입니다.
3. 해결책 1: bulk_create (한 방에 넣기)
이삿짐을 트럭에 한 번에 싣고 가는 방법입니다. Django의 bulk_create는 여러 개의 객체를 리스트로 만들어 넘기면, 단 1번의 INSERT 쿼리로 모든 데이터를 저장합니다.
코드 적용
# views.py (개선된 예시 - 생성)
def import_users_bulk(data_list):
user_objects = []
# 1. 객체들을 메모리에 리스트로 쌓습니다. (DB 접근 X)
for row in data_list:
user = User(
name=row['name'],
email=row['email'],
age=row['age']
)
user_objects.append(user)
# 2. 한 번에 저장합니다. (쿼리 1방)
User.objects.bulk_create(user_objects)
동작 원리 & 성능 차이
Django는 내부적으로 다음과 같은 멀티 로우(Multi-row) INSERT SQL을 생성합니다.
INSERT INTO user (name, email, age) VALUES
('철수', 'a@a.com', 20),
('영희', 'b@b.com', 22),
...
('민수', 'z@z.com', 25);
실제 벤치마크를 돌려보면, 데이터 1만 개 기준 save() 반복이 30초 걸린다면 bulk_create는 0.5초 미만으로 끝납니다. 속도 차이가 수십 배에서 수백 배까지 납니다.
4. 해결책 2: bulk_update (한 방에 수정하기)
"생성은 알겠는데, 수정은 어떻게 하죠?"
과거 Django 버전(2.2 미만)에서는 bulk_update가 없어서 별별 꼼수(삭제 후 재생성 등)를 썼지만, 이제는 공식 지원합니다.
코드 적용
# views.py (개선된 예시 - 수정)
def increase_user_points(user_ids):
# 1. 수정할 객체들을 가져옵니다.
users = User.objects.filter(id__in=user_ids)
updates = []
for user in users:
# 2. 메모리 상에서 값을 변경합니다.
user.point += 100
updates.append(user)
# 3. 변경할 필드('point')를 명시하여 한 번에 업데이트합니다.
User.objects.bulk_update(updates, ['point'])
동작 원리
bulk_update는 SQL의 CASE ... WHEN 구문을 사용하여 마법을 부립니다.
UPDATE user
SET point = CASE
WHEN id = 1 THEN 1100
WHEN id = 2 THEN 2500
...
ELSE point
END
WHERE id IN (1, 2, ...);
단 한 번의 쿼리로 수천 명의 포인트가 각기 다른 값으로 업데이트됩니다.
5. 주의사항: Bulk의 치명적인 함정 (필독)
Bulk 연산은 빠르지만, ORM의 편리한 기능 일부를 포기해야 합니다. 이걸 모르고 썼다가 "데이터가 왜 이러지?" 하며 밤을 새우게 됩니다.
1) save() 메서드가 호출되지 않습니다.
모델의 save() 메서드를 오버라이딩해서 "저장될 때마다 로그를 남긴다"거나 "이미지를 리사이징한다"는 로직을 넣어뒀다면? Bulk 연산 시에는 이 로직이 무시됩니다. 순수 SQL 레벨에서 처리하기 때문입니다.
2) Signal(pre_save, post_save)이 발생하지 않습니다.
"회원 가입(post_save) 시 환영 이메일 발송" 같은 로직을 시그널(Signal)로 구현했다면? bulk_create로 만든 회원에게는 이메일이 가지 않습니다.
해결책으로는 Bulk 연산 후 필요한 로직을 수동으로 실행해주거나, 성능을 포기하고 save()를 써야 합니다. 트레이드오프를 잘 고려해야 합니다.
3) auto_now (updated_at) 필드 갱신 주의
bulk_update를 할 때 updated_at 같은 자동 갱신 필드가 DB 종류나 Django 버전에 따라 갱신되지 않을 수 있습니다. 안전하게 하려면 업데이트할 필드 목록에 updated_at을 포함하고, 파이썬 코드에서 timezone.now()로 직접 값을 넣어주는 것이 좋습니다.
6. 심화: 메모리 터짐 방지 (chunk_size)
"100만 개를 bulk_create 했더니 서버가 뻗었어요!"
한 번에 쿼리를 보내는 건 좋지만, 100만 개의 객체를 리스트([])에 담으면 파이썬 메모리(RAM)가 버티지 못합니다. 또한, SQL 패킷 크기 제한에 걸릴 수도 있습니다. 이때는 batch_size 옵션을 사용하여 데이터를 적절히 쪼개서 보내야 합니다.
# 100만 개 데이터라 가정
large_data_list = [...]
# 1000개씩 끊어서 INSERT 쿼리를 날립니다. (총 1000번의 쿼리)
# 100만 번(save) vs 1번(bulk) vs 1000번(batch) -> 안정성과 속도의 균형
User.objects.bulk_create(large_data_list, batch_size=1000)
7. 마치며: 속도와 안정성 사이에서
Bulk 연산은 대용량 데이터 처리의 구세주입니다. 하지만 save() 메서드와 시그널을 우회한다는 점을 반드시 기억해야 합니다.
오늘의 요약
- 반복문 속
save()는 죄악이다. (데이터가 많을 때) bulk_create,bulk_update를 쓰면 수십 배 빨라진다.- 단, Signal과 save() 오버라이딩 로직은 실행되지 않는다.
- 데이터가 너무 많으면
batch_size로 쪼개서 보내라.
데이터 정합성(1편), 조회 성능(2편), 그리고 대량 처리 성능(3편)까지 챙겼습니다. 이제 우리 서버는 꽤 튼튼해졌습니다.
하지만, "분명히 트랜잭션 안에서 데이터를 저장했는데, 비동기 작업(Celery)에서 데이터를 못 찾는 유령 같은 현상"을 겪어보신 적 있나요? 코드는 완벽한데 간헐적으로 에러가 나는 이 상황, 정말 미치게 만듭니다.
다음 포스팅 [ 트랜잭션과 Celery의 미묘한 관계: "왜 알림이 안 가죠?"] 에서는 트랜잭션 커밋 시점과 비동기 작업의 실행 타이밍이 어긋나서 생기는 문제와, transaction.on_commit을 이용한 우아한 해결책에 대해 알아보겠습니다.
긴 글 읽어주셔서 감사합니다!