Intro
get_or_create()는 특정 arguments를 포함하는 instance의 반환을 보장하는 유용한 메소드입니다.
This is meant to prevent duplicate objects from being created when requests are made in parallel, and as a shortcut to boilerplatish code. — Django docs.
반복적인 get_or_create() 요청에 대해서 한 번의 요청에만 새로운 instance를 생성하고 이후의 요청에는 이미 생성된 instance를 반환하기 때문이죠.
>>> Product.objects.get_or_create(name='i-phone')
(<Product: Product object (1)>, True)
>>> Product.objects.get_or_create(name='i-phone')
(<Product: Product object (1)>, False)
SQL
복사
그런데 get_or_create()가 항상 race condition을 방지하는 것은 아닙니다.
이번 글에서 race condition은 데이터베이스에 유일하게 존재해야 하는 row가 중복 생성되는 상황을 말합니다.
하나의 instance를 기대하고 objects.get()을 실행했을 때 다음 에러 메세지를 볼 수도 있습니다:
MultipleObjectsReturned: get() returned more than one <Model> -- it returned 2!
SQL
복사
get_or_create()를 사용했지만 race condition이 발생할 수 있는 상황과 예방법에 대해서 알아봅시다.
How get_or_create() works
# django/db/models/query.py
def get_or_create(self, defaults=None, **kwargs):
self._for_write = True
try:
return self.get(**kwargs), False # 1
except self.model.DoesNotExist: # 2
params = self._extract_model_params(defaults, **kwargs)
try: # 3
with transaction.atomic(using=self.db):
params = dict(resolve_callables(params))
return self.create(**params), True # 4
except IntegrityError: # 5
try:
return self.get(**kwargs), False # 6
except self.model.DoesNotExist: # 7
pass
raise
Python
복사
원활한 설명을 위해 기존 코드에 있던 주석은 모두 제거 했습니다.
소스 코드를 분석해보면 다음과 같습니다:
주어진 **kwargs로 get()을 시도하고 instance가 존재하면 반환합니다. (#1)
instance가 존재하지 않으면(#2)
**kwargs와 defaults로 새로운 instance 생성을 시도하고(#3)
새로운 instance가 생성되면 반환합니다. (#4)
중복으로 인해 IntegrityError 발생하면(#5)
instance를 재탐색하여 반환합니다.(#6)
instance를 찾지 못하면 에러로 처리합니다.(#7)
Plain Text
복사
get_or_create()에서는 instance의 중복 생성을 방지하기 위해 get()을 시도한 뒤 create()를 수행합니다.
만약 다수의 요청이 동시에 get()을 실행하여(#1) instance가 존재하지 않는 것을 확인한 뒤(#2), 각각 create() 요청을 하면(#4) 어떻게 될까요?
데이터베이스 테이블에 Unique Constraint가 걸려있다면 데이터베이스 차원에서 중복 생성을 방지합니다.
따라서 하나의 요청을 제외한 나머지 요청에서는 IntegrityError가 발생할 것(#6)이고, 재탐색을 통해 이미 생성된 하나의 instance가 get()에 의해 반환될 것입니다.(#7)
그런데 Unique Constraint이 걸려 있지 않다면 위의 상황에서 중복 instance가 생성됩니다.
이를 타임라인에 따라 나타내면 다음과 같습니다:
1.
요청1 — instance가 존재하지 않는 것을 확인
2.
요청2 — instance가 존재하지 않는 것을 확인
3.
요청1 — 새로운 instance 생성
4.
요청2 — 새로운 instance 생성
다음과 같은 concurrent requests 테스트를 해보면 instance가 일정하지 않은 개수로 중복 생성됩니다.
import concurrent.futures
import threading
import requests
thread_local = threading.local()
def get_session():
if not hasattr(thread_local, "session"):
thread_local.session = requests.Session()
return thread_local.session
def request_api(url):
credentials = ('username', 'password')
session = get_session()
with session.get(url, auth=credentials) as response:
print(response.content)
def request_concurrently(urls):
n_workers = len(urls)
with concurrent.futures.ThreadPoolExecutor(max_workers=n_workers) as executor:
executor.map(request_api, urls)
if __name__ == "__main__":
urls = ['http://127.0.0.1:8000/api/test/'] * 5
request_concurrently(urls)
Python
복사
Conclusion
Django 차원에서 concurrent requests에 대해 race condtion 발생을 완벽하게 막는 것은 불가능합니다.
데이터베이스 차원에서 row의 중복 생성을 방지할 수 있도록 Unique 제약을 걸어주는 것이 반드시 필요합니다.
다시 말해 Unique 제약이 걸려있지 않은 컬럼에 대해 get_or_create()를 함부로 사용하면 안 됩니다.
Outro
글 작성을 마치고 추가 자료를 검색하던 중에 Medium에서 다음 글을 발견 했습니다.
이번 포스트에서 다룬 내용과 같은 상황에 대해서 다루고 있으니 설명이 부족했다면 위의 글을 참고해주세요.