Django bulk_create에서 ID가 건너뛰는 현상 해결하기

2025. 5. 19. 15:44개발/Django

728x90
반응형

Django와 PostgreSQL을 사용하다 보면 bulk_create를 할 때 ID가 갑자기 크게 건너뛰는 현상을 경험할 수 있습니다. 예를 들어 ID가 12845에서 갑자기 1892456으로 점프하는 경우 말이죠. 이 글에서는 이 현상의 원인과 해결 방법을 알아보겠습니다.

🤔 문제 상황

다음과 같은 Django 코드를 실행했을 때:

# 데이터 준비
products = []
for data in data_list:
    products.append(
        Product(
            sku=data["sku"],
            name=data["name"],
            category_id=data["category_id"],
            price=data["price"],
            description=data["description"],
            # ... 기타 필드들
        )
    )

# bulk_create 실행
Product.objects.bulk_create(
    products,
    update_conflicts=True,
    unique_fields=["sku"],
    update_fields=[
        "name",
        "category_id",
        "price",
        "description",
        # ... 업데이트할 필드들
    ],
)

ID가 12845에서 갑자기 1892456으로 건너뛰는 현상이 발생했습니다.

🔍 원인 분석

이 현상은 오류가 아닌 PostgreSQL의 정상적인 동작입니다. 두 가지 주요 원인이 있습니다:

1. PostgreSQL 시퀀스 캐싱

PostgreSQL은 성능 향상을 위해 시퀀스 값을 미리 캐시합니다. 연결이 종료되거나 트랜잭션이 롤백되면 캐시된 값들이 버려지면서 ID가 점프할 수 있습니다.

2. ON CONFLICT의 시퀀스 소모 (주요 원인)

update_conflicts=True를 사용하면 Django는 PostgreSQL의 INSERT ... ON CONFLICT DO UPDATE 구문을 생성합니다. 이때:

  1. PostgreSQL이 먼저 nextval()을 호출해서 새로운 ID를 가져옴
  2. 충돌(conflict)이 발생하면 INSERT 대신 UPDATE를 실행
  3. 이미 소모된 시퀀스 값은 되돌려지지 않음

만약 1000개의 레코드 중 950개가 이미 존재한다면, 950개의 시퀀스 값이 불필요하게 소모됩니다.

🛠️ 해결 방법

방법 1: 기존 레코드 분리 후 처리 (권장)

가장 효율적인 방법은 새로운 레코드와 업데이트할 레코드를 미리 분리하는 것입니다:

from django.db import transaction

@transaction.atomic
def efficient_bulk_upsert(data_list):
    # 1. 기존에 존재하는 SKU 조회
    existing_skus = set(
        Product.objects.filter(
            sku__in=[item["sku"] for item in data_list]
        ).values_list('sku', flat=True)
    )
    
    # 2. 새로운 레코드와 업데이트할 레코드 분리
    new_products = []
    update_products = []
    
    for data in data_list:
        product_obj = Product(
            sku=data["sku"],
            name=data["name"],
            category_id=data["category_id"],
            price=data["price"],
            description=data["description"],
            stock_quantity=data["stock_quantity"],
            manufacturer=data["manufacturer"],
            # ... 나머지 필드들
        )
        
        if data["sku"] in existing_skus:
            update_products.append(product_obj)
        else:
            new_products.append(product_obj)
    
    # 3. 새로운 레코드만 bulk_create (시퀀스 정상 사용)
    if new_products:
        Product.objects.bulk_create(new_products)
    
    # 4. 기존 레코드는 bulk_update
    if update_products:
        Product.objects.bulk_update(
            update_products,
            fields=[
                "name", "category_id", "price", "description",
                "stock_quantity", "manufacturer", "is_active",
                # ... 업데이트할 필드들
            ]
        )

    print(f"생성: {len(new_products)}개, 업데이트: {len(update_products)}개")

장점:

  • 시퀀스가 불필요하게 소모되지 않음
  • 성능이 더 좋음 (한 번의 쿼리로 기존 레코드 확인)
  • 더 예측 가능한 동작

방법 2: 다른 모델 예제 - 사용자 정보

@transaction.atomic
def efficient_user_bulk_upsert(user_data_list):
    # 1. 기존에 존재하는 이메일 조회
    existing_emails = set(
        UserProfile.objects.filter(
            email__in=[item["email"] for item in user_data_list]
        ).values_list('email', flat=True)
    )
    
    # 2. 새로운 사용자와 업데이트할 사용자 분리
    new_users = []
    update_users = []
    
    for data in user_data_list:
        user_obj = UserProfile(
            email=data["email"],
            username=data["username"],
            first_name=data["first_name"],
            last_name=data["last_name"],
            phone=data["phone"],
            birth_date=data["birth_date"],
            # ... 나머지 필드들
        )
        
        if data["email"] in existing_emails:
            update_users.append(user_obj)
        else:
            new_users.append(user_obj)
    
    # 3. 새로운 사용자만 bulk_create
    if new_users:
        UserProfile.objects.bulk_create(new_users)
    
    # 4. 기존 사용자는 bulk_update
    if update_users:
        UserProfile.objects.bulk_update(
            update_users,
            fields=[
                "username", "first_name", "last_name", "phone",
                "birth_date", "last_login", "is_verified",
                # ... 업데이트할 필드들
            ]
        )

    print(f"생성: {len(new_users)}개, 업데이트: {len(update_users)}개")

방법 3: ignore_conflicts 사용

업데이트가 필요 없고 단순히 중복을 무시하고 싶다면:

Product.objects.bulk_create(
    products,
    ignore_conflicts=True  # update_conflicts 대신
)

방법 4: 시퀀스 리셋

이미 건너뛴 ID를 다시 연속적으로 만들고 싶다면:

from django.db import connection

def reset_sequence(table_name):
    with connection.cursor() as cursor:
        cursor.execute(f"""
            SELECT setval(
                pg_get_serial_sequence('{table_name}', 'id'), 
                COALESCE(MAX(id), 1)
            ) 
            FROM {table_name};
        """)

# 사용 예시
reset_sequence('shop_product')  # 또는 UserProfile 테이블의 경우
reset_sequence('accounts_userprofile')

또는 Django 관리 명령 사용:

python manage.py sqlsequencereset shop accounts

🎯 성능 최적화 팁

1. 배치 크기 조정

대량의 데이터를 처리할 때는 배치 크기를 조정하세요:

def bulk_upsert_in_batches(data_list, batch_size=1000):
    for i in range(0, len(data_list), batch_size):
        batch = data_list[i:i + batch_size]
        efficient_bulk_upsert(batch)
        print(f"처리 완료: {i + len(batch)}/{len(data_list)}")

# 사용자 데이터의 경우
def bulk_user_upsert_in_batches(user_data_list, batch_size=500):
    for i in range(0, len(user_data_list), batch_size):
        batch = user_data_list[i:i + batch_size]
        efficient_user_bulk_upsert(batch)
        print(f"사용자 처리 완료: {i + len(batch)}/{len(user_data_list)}")

💡 마무리

Django의 bulk_create에서 ID가 건너뛰는 현상은 오류가 아닌 PostgreSQL의 정상적인 동작입니다. 특히 update_conflicts=True를 사용할 때 ON CONFLICT 구문의 특성상 시퀀스가 소모되는 것은 피할 수 없습니다.

가장 좋은 해결책은 기존 레코드를 미리 분리해서 새로운 레코드만 bulk_create하고, 기존 레코드는 bulk_update를 사용하는 것입니다. 이렇게 하면 시퀀스도 절약하고 성능도 향상시킬 수 있습니다.

ID의 연속성이 중요한 비즈니스 로직이 있다면 시퀀스를 리셋할 수 있지만, 일반적으로는 ID의 갭은 문제가 되지 않으므로 크게 신경 쓸 필요는 없습니다.