Search

Django get_or_create() 사용시 race condition 발생하는 경우

Intro

get_or_create()는 특정 arguments를 포함하는 instance의 반환을 보장하는 유용한 메소드입니다.
또 concurrent requests에 대해서 데이터베이스의 race condition을 방지하는 수단입니다.
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에서 다음 글을 발견 했습니다.
이번 포스트에서 다룬 내용과 같은 상황에 대해서 다루고 있으니 설명이 부족했다면 위의 글을 참고해주세요.

References