[MongoDB] ObjectId는 유일성을 보장할까?

Wonjae Lee
9 min readFeb 25, 2024

--

> 이 글은 pymongo(4.6.2), mongodb(7.0)을 기준으로 작성되었습니다.

Intro

Mongodb에서 document를 생성할 때 우리는 _id 필드에 대해 고민하는 순간이 온다. _id 필드를 입력하지 않으면 자동으로 ObjectId 으로 채워주는데.. ObjectId는 뭐지? 그리고 유일성을 보장해주는건가?

ObjectId가 뭔지, 유일성을 보장하는지, 믿고 써도 되는지 알아보자.

ObjectId와 BSON

Object id는 사실 BSON의 데이터 타입 스펙중 하나이다. BSON은 mongodb 내부에서 데이터를 저장하는 형식이다. BSON은 Binary JSON의 줄임말이며, 데이터를 바이너리화 하여 처리하기 때문에 빠르고 JSON보다 더 많은 데이터 타입을 표현할 수 있으며 탐색에 유리하다. 이정도만 알고 간단히 넘어가자. (bson-json 참고)

ObjectId

Object id는 document id의 기본 데이터형이다. 전통적인 mysql db라면 보통 id를 순차적으로 증가하는 기본값으로 정하겠지만, 여러 shard를 가지고 분산처리를 하는 Mongodb에서는 순차값을 동기화하는 작업은 어렵고 시간이 걸린다.

ObjectId는 여러 장비에 걸쳐 전역적으로 고유하게 생성하기 쉽게, 동기화가 필요 없도록 설계되었다.

ObjectId는 12바이트 스토리지를 사용한다. 여러개의 ObjectId를 연속으로 생성하면 마지막 숫자 몇개만 바뀌고, 몇초 간격을 두면 중간숫자 몇개가 바뀐다. 왜그럴까? ObjectId의 생성 방식을 살펴보자.

ObjectId의 생성방식

[4byte]: Unix timestamp

  • 초단위로 저장되는 timestamp 이다.
  • 문서가 대략 입력 순서대로 정렬된다. 이로 인해 ObjectId가 효율적으로 인덱싱된다.
  • 이 특성으로 인해서 n개의 문서가 동시에 입력되면 id가 겹치지 않을까 라는 걱정을 할 수 있지만, 다른 요소들이 있기 때문에 그렇지 않다.

[5byte]: Random value

  • 랜덤값을 추가하여 충돌 가능성을 낮춘다.
  • pymongo 기준으로 프로세스별 os.urandom을 사용하여 랜덤값을 생성한다.

[3byte]: Counter

  • 서로 다른 시스템에서 충돌하는 ObjectId 들을 생성하지 않도록 랜덤값으로 시작하는 Counter이다.
  • 당연히 3byte 수가 꽉 찰 수 있다. 그러면 0부터 카운터가 올라가겠지만, 앞에 random 값이 달라지고 timestamp 도 달라지기 때문에 겹칠 확률이 매우 적다.

ObjectId source code (in pymongo)

# In ObjectId class in pymongo driver
_pid = os.getpid()

_inc = SystemRandom().randint(0, _MAX_COUNTER_VALUE)
_inc_lock = threading.Lock()

@classmethod
def _random(cls) -> bytes:
"""Generate a 5-byte random number once per process."""
pid = os.getpid()
if pid != cls._pid:
cls._pid = pid
cls.__random = _random_bytes()
return cls.__random

def __generate(self) -> None:
"""Generate a new value for this ObjectId."""
# 4 bytes current time
oid = struct.pack(">I", int(time.time()))

# 5 bytes random
oid += ObjectId._random()

# 3 bytes inc
with ObjectId._inc_lock:
oid += struct.pack(">I", ObjectId._inc)[1:4]
ObjectId._inc = (ObjectId._inc + 1) % (_MAX_COUNTER_VALUE + 1)

self.__id = oid

위 코드에서 __generate 메서드를 살펴보면 위에 설명된 것 처럼 4, 5, 3 byte를 생성하는 것을 볼 수 있다. timestamp는 int로 변환되어 초단위로 생성된다.

def insert_one(
self,
document: Union[_DocumentType, RawBSONDocument],
bypass_document_validation: bool = False,
session: Optional[ClientSession] = None,
comment: Optional[Any] = None,
) -> InsertOneResult:
"""Insert a single document.
:param document: The document to insert. Must be a mutable mapping
type. If the document does not have an _id field one will be
added automatically.
"""
common.validate_is_document_type("document", document)
if not (isinstance(document, RawBSONDocument) or "_id" in document):
document["_id"] = ObjectId() # 이쪽에서 _id 필드를 채워주고 있다.

write_concern = self._write_concern_for(session)
return InsertOneResult(
self._insert_one(
document,
ordered=True,
write_concern=write_concern,
op_id=None,
bypass_doc_val=bypass_document_validation,
session=session,
comment=comment,
),
write_concern.acknowledged,
)

위 코드는 insert_one 메서드의 내부 코드이다. 우리가 Document에 _id 필드를 넣어주지 않으면 **driver에서** ObjectId를 생성해 주는것을 볼 수 있다. 이렇게 각 클라이언트에서 사용하는 Driver에서 ObjectId를 생성하고, 이게 유일성을 보장해야 하기 때문에 id 생성 방식이 복잡한 것이다.

ObjectId의 충돌 확률 😵‍💫

위처럼 timestamp + random 값 + counter를 조합한다고 해도 충분히 겹칠 가능성이 있지 않을까? 동시에, 같은 process에서 counter를 가지는 문서를 생성한다면?? 생각이 많아지는데, 초당 문서 생성수를 기준으로 간단히 계산해보자.

초당 1개의 문서가 삽입되는 경우

  • 처음 4바이트가 변경되므로 중복될 수 없다.

동일한 순간 N개의 문서가 삽입되는 경우

  • 처음 4바이트가 동일하다 해도 이후 8바이트가 다르다.
  • 8byte 는 5byte 랜덤값 x 3byte 랜덤으로 시작하는 counter로 이루어져 있고, 이 수의 모든 경우의 수는 2⁶⁴개 이다.
  • 즉 “초당 문서 생성수 / 2⁶⁴” 가 충돌 확률이 된다. 초당 문서 생성수가 1이면 18경 분의 1 확률로 충돌이 발생한단 얘긴데.. 18경 rps인 서비스가 있을까? 보통 많아도 1만 rps라고 가정해도 충돌 확률이 매우매우 낮다.

UUID(v4)의 충돌 확률 😵‍💫

Mongodb를 사용할때 uuid도 _id의 값으로 사용한다. 이 값의 충돌 확률은 어떻게 될까?

  • 32개의 16진수 숫자로 표시되는 128비트로 구성된다
  • uuid의 다른 버전들과는 다르게 v4는 난수에만 의존한다.
  • 모든 uuid의 개수가 2¹²⁸개 이므로 ObjectId보다 더 충돌 확률이 낮은것을 알 수 있다.
  • 다만 byte로 저장하면 16 byte이지만 문자열로 저장하면 36 byte가 되기 문에 효율상 byte 형으로 저장하는게 낫다.

ObjectId vs UUID(v4)

둘다 충분히 좋은 id 체계이고, 분산환경에서 유일성을 보장한다. 둘 중 어떤 방식을 쓰더라도 이슈는 없을것으로 보이나 서비스에 따라 다를 것 같다.

  • ObjectId: 순서대로 생성되어 저장되기 때문에 인덱싱에 유리하다. mongodb에서 기본 제공하는 _id 타입이다.
  • UUID: ObjectId 보다 더 충돌 확률이 적다. UUID 타입은 없어서 문자열로 저장하거나, 효율적으로 저장하기 위해 Bson binData 타입으로 저장하는게 좋고, 생성 순서와 id는 무관하다.

결론

우리는 ObjectId에 대해서 조사하고 id 충돌 확률에 대해서 계산해봤다. 절대 충돌할 수 없는 id 체계는 없고, 다만 확률이 얼마나되는지를 체크하고 충돌 확률을 계산하고, 그 확률 내에서 충돌이 발생하지 않는다고 생각하고 사용할 뿐이다.

그러한 관점에서 ObjectId는 분산환경인 Mongodb에서 유일성을 잘 보장한다!

주요 내용 요약

  • ObjectId는 여러 장비에 걸쳐 전역적으로 고유하게 생성하기 쉽게, 동기화가 필요 없도록 설계되었다.
  • timestamp(4byte), random(5byte), counter(3byte)로 구성되어있다
  • 초당 N개의 문서가 생성된다면 18경 분의 N확률로 충돌될 수 있다.
  • ObjectId도 uuid도 분산환경에서 유일성을 잘 보장한다.

Reference

--

--