백엔드 Back-end/장고 Django

Django ORM 사용 시 PostgreSQL 데이터베이스 외래 키(Foreign Key) 적용/미적용에 따른 성능 차이 실험

Tap to restart 2025. 5. 11. 16:00
반응형

배경

At GitHub we do not use foreign keys, ever, anywhere.

Personally, it took me quite a few years to make up my mind about whether foreign keys are good or evil, and for the past 3 years I'm in the unchanging strong opinion that foreign keys should not be used. Main reasons are:

FKs are in your way to shard your database. Your app is accustomed to rely on FK to maintain integrity, instead of doing it on its own. It may even rely on FK to cascade deletes (shudder). When eventually you want to shard or extract data out, you need to change & test the app to an unknown extent. FKs are a performance impact. The fact they require indexes is likely fine, since those indexes are needed anyhow. But the lookup made for each insert/delete is an overhead.
(출처: https://github.com/github/gh-ost/issues/331#issuecomment-266027731)

 

Github에서 Principal Software Engineer로 일했던 Shlomi Noach은 깃허브에서는 외래 키를 어디에서도 전혀 사용하지 않는다고 밝혔다. 그 이유로 샤딩의 어려움, 각 삽입이나 삭제 시마다 발생하는 추가 오버헤드를 들었다. 깃허브 사례처럼 현업에서 데이터베이스 레벨에서는 외래 키를 미적용하는 경우가 많다. 과연 얼마나 성능 차이가 있을까.

 

실험 환경

- 하드웨어: Mac mini(CPU: 3.2 GHz 6-Core Intel Core i7, RAM: 64GB)

- 언어 및 프레임워크: Python, Django 4.2.21
- 데이터베이스: PostgreSQL 15 도커로 구성

실험 가설

Django ORM 환경에서 외래 키 제약조건을 적용하지 않은 경우, 적용한 경우보다 전반적인 성능이 더 우수할 것으로 예상된다.

 

실험 시나리오

- 쓰기 테스트: Django bulk_create 통해서 10만건 쓰기 실행
- 쓰기 테스트: 개별 1만건 쓰기 실행
- 읽기 테스트: 1만건 무작위 읽기 실행
- 각 테스트는 동일 조건에서 30회씩 반복 수행

- 측정 항목: 반복 수행 평균 수행 시간

 

실험 코드

drf_crud 기본 코드를 활용해서 테스트했다.

 

모델 클래스

단순하게 Category와 Beverage 2개로 구성해서 테스트했다.

from django.db import models


class Category(models.Model):
    name = models.CharField(max_length=200)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)


class Beverage(models.Model):
    name = models.CharField(max_length=200)
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, db_constraint=True, null=True)
    price = models.IntegerField()
    is_available = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

 

Beverage 클래스의 category가 외래 키가 된다. 

category = models.ForeignKey(Category, on_delete=models.SET_NULL, db_constraint=True, null=True)

 

이때 db_constraint가 True이면 데이터베이스에도 FK 제약조건이 추가되며, False이면 데이터베이스 제약조건에 추가되지 않는다.

 

test1 데이터베이스는 db_constraint가 True로 마이그레이션 실행해서 FK 제약조건이 존재하고,

 

test2 데이터베이스는 db_constraint가 True로 마이그레이션 실행해서 FK 제약조건이 없다.

 

category를 1만개를 우선 만들어 놓았다.

 

category 1만개 생성 코드

input_categories.py
0.00MB

import os

import django
from faker import Faker

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
django.setup()

from app.models import Category

fake = Faker()


if __name__ == "__main__":
    batch_size = 1000
    total = 10000
    categories = []

    for i in range(total):
        name = fake.word()
        categories.append(Category(name=name))

    Category.objects.bulk_create(categories, batch_size=batch_size)

 

쓰기: Django bulk_create 통해서 10만건 쓰기 실행  코드

input_beverages.py
0.00MB

import os
import django
import random
import time
from faker import Faker

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
django.setup()

from app.models import Beverage, Category


def measure_insert_time(total=100000, batch_size=1000):
    fake = Faker()
    categories = list(Category.objects.all())
    beverages = [
        Beverage(
            name=fake.word(),
            category=random.choice(categories),
            price=random.randint(1000, 10000),
            is_available=random.choice([True, False]),
        )
        for _ in range(total)
    ]

    start = time.time()
    Beverage.objects.bulk_create(beverages, batch_size=batch_size)
    end = time.time()
    return end - start


def run_trials(trials=30):
    times = []

    for i in range(trials):
        print(f"실행 {i+1}/{trials} ...")
        elapsed = measure_insert_time()
        print(f"실행 {i+1} 소요 시간: {elapsed:.2f}초")
        times.append(elapsed)

    avg = sum(times) / len(times)
    print()
    print(f"beverages 추가 {trials}회 평균 소요 시간: {avg:.2f}초")


if __name__ == "__main__":
    run_trials(trials=30)

 

 

쓰기: 개별 1만건 쓰기 실행 코드

input_beverages_separately.py
0.00MB

 

import os
import django
import random
import time
from faker import Faker

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
django.setup()

from app.models import Beverage, Category


def measure_insert_time(total=10000, batch_size=1000):
    fake = Faker()
    categories = list(Category.objects.all())

    start = time.time()
    for _ in range(total):
        Beverage.objects.create(
            name=fake.word(),
            category=random.choice(categories),
            price=random.randint(1000, 10000),
            is_available=random.choice([True, False]),
        )
    end = time.time()
    return end - start


def run_trials(trials=30):
    times = []

    for i in range(trials):
        print(f"실행 {i+1}/{trials} ...")
        elapsed = measure_insert_time(total=10000)
        print(f"실행 {i+1} 소요 시간: {elapsed:.2f}초")
        times.append(elapsed)

    avg = sum(times) / len(times)
    print()
    print(f"{trials}회 평균 소요 시간: {avg:.2f}초")


if __name__ == "__main__":
    run_trials(trials=30)

 

읽기: 1만건 무작위 읽기 실행 코드

read_beverages.py
0.00MB

 

import os
import django
import random
import time
from faker import Faker

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
django.setup()

from app.models import Beverage, Category


def measure_read_time(n_reads=10000):
    ids = list(Beverage.objects.values_list("id", flat=True))

    start = time.time()
    for _ in range(n_reads):
        random_id = random.choice(ids)
        beverage = Beverage.objects.select_related("category").get(id=random_id)
        _ = beverage.category.name
    end = time.time()

    return end - start


def run_trials(trials=30):
    times = []

    for i in range(trials):
        print(f"실행 {i+1}/{trials} ...")
        elapsed = measure_read_time(n_reads=10000)
        print(f"실행 {i+1} 소요 시간: {elapsed:.2f}초")
        times.append(elapsed)

    avg = sum(times) / len(times)
    print()
    print(f"{trials}회 평균 소요 시간: {avg:.2f}초")


if __name__ == "__main__":
    run_trials(trials=30)

 

실험 결과

쓰기: Django bulk_create 통해서 10만건 쓰기 실행

외래 키 있을 때 5.75초

외래 키 없을 때 5.17초

 

쓰기: 개별 1만건 쓰기 실행

외래 키 있을 때 11.56초


외래 키 없을 때 11.35초

읽기: 1만건 무작위 읽기 실행

외래 키 있을 때 10.53초

외래 키 없을 때 10.48초

 

실험 결과
외래 키 제약 조건 유무
향상률
쓰기: Django bulk_create 통해서 10만건 쓰기 실행 5.75 초 5.17  10.0%
쓰기: 개별 1만건 쓰기 실행 11.56  11.35  1.8%
읽기: 1만건 무작위 읽기 실행 10.53  10.48  0.5%

 

외래 키 제약조건을 적용하지 않은 경우 쓰기와 읽기에서 더 빠른 결과를 얻었다. 하지만 대량 쓰기가 아닌 경우라면 큰 성능 차이는 아니었다. 

 

외래 키 제약조건을 적용하지 않으면 데이터베이스에서 각 테이블 사이의 관계를 파악하기 어려워진다. 외래 키 제약조건을 사용하지 않는 것이 과연 더 좋을지는 고민이 된다.

 

외래 키 제약조건 적용된 경우

 

외래 키 제약조건 적용되지 않은 경우

 

반응형