본문 바로가기
프로그래밍

[django] `bulk_update` 파헤치기: 실제 구현체와 SQL 변환, 성능 분석

by 절차탁마 2025. 5. 18.

제목: [django] `bulk_update` 파헤치기: 실제 구현체와 SQL 변환, 성능 분석

 

최근 가장 많이 고민했던 내용은 다음과 같다.

"어떻게 대량의 데이터를 주기적으로 저장할 때 안정적이고 빠르게 저장할 수 있을까?"

 

Django ORM을 사용할 때 가장 순진한 방식으로 이를 처리하는 방법은 바로 객체를 하나씩 업데이트하는 것이다. 몇 건 되지 않는 레코드를 삽입하거나 수정할때는 큰 문제가 되진 않지만, 매우 많은 레코드를 처리해야하는 경우 심각한 성능 저하를 겪을 수 있다. 실제 내가 InnoDB, M1 Pro에서 테스트를 돌렸을 때 1000건에 대한 bulk_update 테스트에서 3.68초와 0.1741초라는 엄청 큰 차이를 확인할 수 있었던 것처럼 매우 큰 병목지점이 될 수 있음을 알 수 있다.

 

이번 글에서는 Django에서 언제 해당 기능이 proposal되었고 어떤 논의들이 이루어졌으며, 코드는 어떻게 작성되었는지를 중심으로 해당 기능을 익혀보고, 성능 테스트 결과를 직접 진행해본 결과를 공유하려고 한다. 이 글을 통해 Django ORM을 더욱 효과적으로 활용하고, 대용량 데이터 처리 성능을 최적화하는 데 필요한 지식을 얻을 수 있으면 한다.

bulk_update 의 내부 구현과 SQL 파헤치기

 bulk_update는 여러 모델 인스턴스의 특정 필드들을 데이터베이스에서 한 번에 업데이트하기 위해 설계된 메서드다.

먼저 bulk_update 메서드의 시그니처를 살펴보면:

# django/db/models/query.py
def bulk_update(self, objs, fields, batch_size=None):
    """
    Update the given fields in each of the given objects in the database.
    """
    # ... (이하 전체 코드)

이 메서드는 세 가지 주요 인자를 받는다:

  • objs: 업데이트 할 모델 인스턴스들의 iterable (리스트, 튜플 등).
  • fields: 업데이트할 필드 이름들의 리스트.
  • batch_size (선택 사항): 한 번의 데이터베이스 쿼리로 처리할 객체의 최대 개수이다. 이 값을 지정하지 않으면, Django는 연결된 데이터베이스 드라이버가 제안하는 최적의 크기를 사용하려고 시도한다.

 

bulk_update 코드 상세 분석

 

Django 소스 코드(django/db/models/query.py#L865)를 통해 bulk_update의 핵심 로직을 단계별로 살펴보자. 아래는 bulk_update 함수의 전체 코드이다. 이 코드가 어떻게 작동하는지 부분별로 나누어 살펴보자.

# django/django/db/models/query.py

def bulk_update(self, objs, fields, batch_size=None):
    """
    Update the given fields in each of the given objects in the database.
    """
    if batch_size is not None and batch_size <= 0:
        raise ValueError("Batch size must be a positive integer.")]
        
    if not fields:
        raise ValueError("Field names must be given to bulk_update().")
        
    objs = tuple(objs)
    
    # PK(기본키) 설정 여부 확인
    # DB에 이미 저장된 인스턴스여야만 bulk_update 가능
    if not all(obj._is_pk_set() for obj in objs):
        raise ValueError("All bulk_update() objects must have a primary key set.")

		# 메타데이터
    opts = self.model._meta
    # 필드 이름으로부터 실제 Field 인스턴스 목록 구성
    fields = [opts.get_field(name) for name in fields]
    
    # 가상 필드(concrete=False)나 M2M 필드는 불가
    if any(not f.concrete or f.many_to_many for f in fields):
        raise ValueError("bulk_update() can only be used with concrete fields.")
        
    # [START 상속 관계 포함하여 모든 PK 필드 수집.]
    all_pk_fields = set(opts.pk_fields)
    for parent in opts.all_parents:
        all_pk_fields.update(parent._meta.pk_fields)
    # [END 상속 관계 포함하여 모든 PK 필드 수집.]

		# PK 필드 업데이트 금지        
    if any(f in all_pk_fields for f in fields):
        raise ValueError("bulk_update() cannot be used with primary key fields.")
    
    # 업데이트 대상이 없으면 바로 종료 
    if not objs:
        return 0  # 0건 업데이트 되었다고 리턴.
        
    # 관련 필드 저장 준비
    # ForeignKey 등 관계 필드의 전처리 수행
    for obj in objs:
        obj._prepare_related_fields_for_save(
            operation_name="bulk_update", fields=fields
        )
        
    # PK is used twice in the resulting update query, once in the filter
    # and once in the WHEN. Each field will also have one CAST.
    self._for_write = True  # 쿼리셋이 쓰기 작업임을 표시.
    
    # [START DB별 최적의 배치 사이즈 계산]
    connection = connections[self.db]
    max_batch_size = connection.ops.bulk_batch_size(
        [opts.pk, opts.pk, *fields], objs
    )
    batch_size = min(batch_size, max_batch_size) if batch_size else max_batch_size
    # [END DB별 최적의 배치 사이즈 계산]
        
    requires_casting = connection.features.requires_casted_case_in_updates
    
    # 배치 단위로 잘라내는 제너레이터
    batches = (objs[i : i + batch_size] for i in range(0, len(objs), batch_size))
    updates = []
    
    # [[START 각 배치열 `CASE WHEN` 생성]]
    for batch_objs in batches: # obj 리스트
        update_kwargs = {}
        for field in fields: # 업데이트할 필드 순회
            when_statements = []
            for obj in batch_objs: # obj 리스트 순회 -> 개별 obj
                attr = getattr(obj, field.attname) # obj에 해당 필드 있나요?
                if not hasattr(attr, "resolve_expression"):
                    attr = Value(attr, output_field=field)
                when_statements.append(When(pk=obj.pk, then=attr))
            # CASE로 묶어서 하나의 SQL 표현식 생성
            case_statement = Case(*when_statements, output_field=field)
            if requires_casting:
                case_statement = Cast(case_statement, output_field=field)
            update_kwargs[field.attname] = case_statement
            
        # updates는 (pk 리스트, update_kwargs) 쌍.
        # len(updates) = 배치 갯수
        updates.append(([obj.pk for obj in batch_objs], update_kwargs))
    # [[END 각 배치열 `CASE WHEN` 생성]]
        
    rows_updated = 0
    queryset = self.using(self.db)
    # 트랜잭션 시작 (savepoint 없이 전체 묶음으로)
    # 왜 세이브 포인트가 없는가?
    with transaction.atomic(using=self.db, savepoint=False):
        for pks, update_kwargs in updates: # 배치 당 돌면서, 작업.
		        # 영향 받은 행의 개수 증가
            rows_updated += queryset.filter(pk__in=pks).update(**update_kwargs)
    return rows_updated # 전체 업데이트된 행의 개수 반환.

bulk_update.alters_data = True

 

실제 생성되는 SQL 예시 - 다음과 같은 모델과 데이터가 있다고 가정해 보자.

class Product(models.Model):
    name = models.CharField(max_length=100)
    stock = models.IntegerField()

# 업데이트할 객체
p1 = Product(id=1, name="A", stock=5) # 원래 stock은 다른 값이었다고 가정
p2 = Product(id=2, name="B", stock=3)
p3 = Product(id=3, name="C", stock=0)

# bulk_update 호출 (stock 필드만 업데이트)
Product.objects.bulk_update([p1, p2, p3], fields=['stock'])

이때 Django가 생성하는 SQL(MySQL 기준, batch_size가 충분히 크거나 지정되지 않은 경우)은 다음과 유사한 형태가 된다

START TRANSACTION;

UPDATE "app_product"
SET "stock" = CASE
    WHEN "id" = 1 THEN 5
    WHEN "id" = 2 THEN 3
    WHEN "id" = 3 THEN 0
END
WHERE "id" IN (1, 2, 3);

COMMIT;

만약 batch_size=2로 지정했다면, 두 개의 UPDATE 문이 같은 트랜잭션 내에서 실행된다.

 

첫 번째 배치 (pks=[1,2]):

UPDATE "app_product"
SET "stock" = CASE
    WHEN "id" = 1 THEN 5
    WHEN "id" = 2 THEN 3
END
WHERE "id" IN (1, 2);

두 번째 배치 (pks=[3]):

UPDATE "app_product"
SET "stock" = CASE
    WHEN "id" = 3 THEN 0
END
WHERE "id" IN (3);

 

이처럼 bulk_update는 코드 레벨에서 여러 검증과 준비 단계를 거친 후, CASE WHEN ... END 구문을 사용하여 각 레코드 ID별로 다른 값을 설정하고, WHERE IN (...) 절로 업데이트 대상을 한정하여 단일 SQL 쿼리(또는 배치별 쿼리)로 여러 행을 효율적으로 업데이트한다.

CASE WHEN ... WHERE IN (PK) 구조는 왜 채택되었을까?

그렇다면 Django 개발팀은 왜 여러 레코드를 각기 다른 값으로 업데이트하기 위해 CASE WHEN ... WHERE IN (PK) 라는 다소 복잡해 보이는 SQL 구조를 선택했을까? 이 의사결정을 알아보기 위해 장고 프로젝트의 티켓들을 살펴보았다.

관련 논의는 아래 티켓과 PR에서 찾아 볼 수 있었다.

 

#29037 (Add a bulk_update method to models) – Django

Currently it's not easily or neatly possible to update multiple rows with differing values using the ORM. Most people who need to update a number of models with a value that's distinct to each model need to issue N queries. This is akin to the situation th

code.djangoproject.com

 

Fixed #23646 -- Added QuerySet.bulk_update() to update many models efficiently. by orf · Pull Request #9606 · django/django

Ticket: https://code.djangoproject.com/ticket/23646 ToDo: Add tests for things like JSONField, ArrayField, GIS fields etc

github.com

 

내가 생각한 의구심에 대한 답변을 찾아볼 수 있었는데, 가장 중요한 고려 사항 중 하나는 다양한 데이터베이스 호환성이었다.

Django는 PostgreSQL, MySQL, SQLite, Oracle 등 여러 종류의 데이터베이스 백엔드를 지원했고, CASE WHEN ... 구문은 대부분의 표준 SQL을 지원하는 데이터베이스에서 공통적으로 사용 가능한, 비교적 표준적인 방법이다. 특정 데이터베이스 벤더에 특화된 고성능 대량 업데이트 구문도 존재하지만(예: PostgreSQL의 UPDATE ... FROM (VALUES ...) 또는 MySQL의 INSERT ... ON DUPLICATE KEY UPDATE), ORM의 코어 기능으로서는 범용성이 중요했을 것이다.

 

또한, 이미 커뮤니티와 서드파티 라이브러리를 통해 유사한 접근 방식의 효용성이 입증되었다는 점도 영향을 미쳤는데, 티켓에서는 Stack Overflow 등에서 CASE WHEN을 사용한 대량 업데이트 방식이 논의되고 있었으며, django-bulk-update와 같은 라이브러리가 이미 이와 유사한 방식으로 동작하며 그 효과를 보여주고 있었다.

bulk_update 직접 성능 테스트

bulk_update가 실제로 얼마나 효율적인지, 그리고 batch_size 설정이 성능에 어떤 영향을 미치는지 알아보기 위해 직접 성능 테스트를 진행했다. 테스트 환경은 MySQL을 사용했으며, 다양한 크기의 데이터셋과 배치 크기를 설정하여 개별 업데이트 방식과 bulk_update 방식의 성능을 비교했다. 아래는 테스트 시 실행된 SQL문의 예시이다.

 

개별 업데이트 SQL 샘플:

UPDATE `bulk_test_bulktestmodel` SET `value` = 1000, `description` = 'Updated Description 1000', `up...
UPDATE `bulk_test_bulktestmodel` SET `value` = 1001, `description` = 'Updated Description 1001', `up...
-- ... (데이터 개수만큼 반복)

bulk_update SQL 샘플 (배치 미적용 혹은 단일 배치):

BEGIN;
UPDATE `bulk_test_bulktestmodel` SET `value` = CASE WHEN (`bulk_test_bulktestmodel`.`id` = 11) THEN ... END, `description` = CASE WHEN ... END ...
WHERE `bulk_test_bulktestmodel`.`id` IN (...);
COMMIT;

테스트 결과 요약

데이터 크기 개별 업데이트 (초) bulk_update (초) 개별/bulk_update 메모리 차이(MB, bulk-개별) bulk_update 쿼리 수
10 0.0337 0.0081 4.17x +0.09 3 (BEGIN, UPDATE, COMMIT)
100 0.3434 0.0240 14.30x +0.63 3
1000 3.6891 0.1741 21.19x +6.47 3
5000 19.0566 1.4977 12.72x +18.19 3

테스트 결과는 bulk_update의 효율성을 명확히 보여준다. 모든 데이터 크기에서 bulk_update는 개별 업데이트 방식보다 월등히 빠른 속도를 기록했다. 데이터가 10개일 때는 약 4배, 1000개일 때는 무려 21배 이상 빨라지는 것을 확인할 수 있었다. 이는 주로 네트워크 왕복 횟수와 각 쿼리 실행에 따르는 오버헤드가 대폭 줄어들기 때문이다. bulk_update는 기본적으로 단일 트랜잭션 내에서 하나의 (또는 배치 크기에 따라 몇 개의) UPDATE 쿼리로 모든 작업을 처리하는 반면, 개별 업데이트는 데이터 수만큼 UPDATE 쿼리를 발생시킨다. (심지어 로컬에서 테스트한 것이기 때문에 DB와의 RTT가 훨씬 적은 영향을 미친다는 점을 고려하면 더 높은 결과가 나올 가능성도 있다.)

 

메모리 사용량 측면에서는 bulk_update가 CASE WHEN 절을 동적으로 생성하고 업데이트할 객체들을 메모리에 유지해야 하므로 개별 업데이트보다 다소 많은 메모리를 사용하는 경향을 보인다. 특히 데이터 크기가 커질수록 이 차이는 더 커진다. 하지만 실행 시간의 극적인 단축 효과를 고려하면, 대부분의 상황에서 이는 충분히 감수할 수 있는 트레이드오프이다. CPU 사용량 역시 bulk_update가 짧은 시간 동안 더 집중적으로 자원을 사용하는 패턴을 보였는데, 이는 한 번의 쿼리로 많은 작업을 동시에 처리하기 때문으로 보인다.

batch_size에 따른 성능 변화 (데이터 1,000개 / 5,000개)

batch_size를 명시적으로 설정했을 때 bulk_update의 성능이 어떻게 변하는지도 살펴보았다.

데이터 1,000개일 때:

배치 크기 실행 시간(초) 쿼리 수 메모리 사용 (MB)
100 0.1729 12 0.39
500 0.1803 4 0.12
1000 0.1747 3 1.59

데이터 5,000개일 때:

배치 크기 실행 시간(초) 쿼리 수 메모리 사용 (MB)
100 0.8761 52 0.72
500 0.8813 12 0.94
1000 0.9125 7 3.16

batch_size를 설정하면 bulk_update는 전체 객체 목록을 여러 배치로 나누어 각 배치마다 별도의 UPDATE 쿼리를 실행한다. 이로 인해 BEGIN과 COMMIT은 여전히 한 번씩만 발생하지만, 총 UPDATE 쿼리 수는 배치 수에 따라 증가하게 된다.

 

테스트 결과, 1,000개 데이터에서는 batch_size를 100으로 설정했을 때 가장 빠른 속도를 보였으나 그 차이가 다른 배치 크기와 비교해 매우 크지는 않았다. 5,000개 데이터의 경우에도 batch_size=100일 때 가장 빨랐다. batch_size를 작게 설정하면 한 번에 처리하는 데이터의 양이 줄어들어 SQL 쿼리문의 길이가 짧아지고, 이는 데이터베이스에 가해지는 부하를 분산시키는 효과를 가져올 수 있다. 특히 매우 큰 데이터셋을 다루거나, SQL 쿼리 길이 제한이 엄격한 환경에서는 유용할 수 있다.

 

하지만 batch_size가 너무 작으면 오히려 쿼리 실행 횟수 자체가 늘어나 성능이 저하될 수도 있다. Django가 내부적으로 계산하는 최적의 배치 사이즈(max_batch_size)는 이러한 요소들을 고려하여 결정된다. 메모리 사용량은 batch_size에 따라 다소 변동적인 모습을 보였는데, 이는 Python의 객체 관리 및 가비지 컬렉션 타이밍 등 복합적인 요인의 영향을 받을 수 있다.

 

따라서 batch_size를 명시적으로 설정하는 것은 특정 상황(예: 메모리 제약이 심하거나 매우 긴 SQL 쿼리를 피해야 하는 경우)에서는 유용할 수 있다. 그러나 대부분의 경우, Django가 자동으로 계산하는 배치 크기를 사용하거나 batch_size를 지정하지 않는 것(이 경우 max_batch_size가 사용됨)이 충분히 좋은 성능을 제공한다. 만약 특정 batch_size 값으로 최적화를 시도한다면, 애플리케이션의 특성과 데이터베이스 환경에 맞춰 충분한 테스트를 거치는 것이 중요하다.

결론 및 주요 시사점

Django의 bulk_update는 여러 객체를 각기 다른 값으로 업데이트해야 할 때 획기적인 성능 개선을 제공하는 강력한 기능이다.

내부적으로 여러 유효성 검사와 준비 단계를 거쳐, CASE WHEN ... WHERE pk IN (...) SQL 구문을 사용하여 효율성을 극대화하고, 단일 트랜잭션으로 데이터 정합성을 보장한다. 이러한 설계는 다양한 데이터베이스 호환성과 이미 검증된 성능을 고려한 결과이다.

 

성능 테스트를 통해 확인했듯이, bulk_update는 개별 업데이트 방식에 비해 수십 배 빠른 속도를 보여주었으며, 특히 데이터 양이 많을수록 그 효과는 더욱 커진다. batch_size 옵션을 통해 대량의 데이터를 더 작은 단위로 나누어 처리할 수 있지만, 대부분의 경우 Django의 기본 동작(DB 어댑터가 제안하는 최적 크기 사용)이 우수한 성능을 제공한다. 특정 최적화가 필요하다면, 애플리케이션 환경에 맞는 테스트를 통해 적절한 batch_size를 찾는 것이 좋다.

 

대량의 데이터를 업데이트하는 로직을 작성할 때는 항상 bulk_update 사용을 우선적으로 고려하여, 애플리케이션의 응답성과 데이터베이스 부하를 크게 개선해볼 수 있다.