UUID는 중복을 방지하고 예측할 수 없는 값이기 때문에 Primary Key의 값으로 좋은 선택지입니다.
하지만 UUID로 인해 성능 이슈가 발생할 수 있는 여지가 있고 이를 개발자 수준에서 고려하고 적절히 대처해야 합니다.
이번 포스팅에서는 PostgreSQL 기준으로 UUID 사용 시 발생할 수 있는 성능 이슈에 대해 정리하려 합니다.
UUID 저장 시 UUID 전용 데이터 타입에 저장하기
흔히 UUID를 저장하기 위한 데이터 타입으로 Text 타입 혹은 String 타입을 사용합니다.
uuid는 128 비트 길이의 값을 생성하기 때문에 메모리 상에서 16바이트의 공간을 차지합니다.
( 1byte = 8bit, 128bit / 8bit = 16byte )
이런 uuid 값의 길이는 항상 고정적이기 때문에 PostgreSQL에서 제공하는 UUID 데이터 타입은 uuid 값 1개를 저장하기 위해 온전히 16byte만 사용합니다.
하지만 String이나 Text 데이터 타입의 경우 저장하는 값의 길이나 부가 정보들을 저장하기 위해 메타데이터를 저장하게 되고 이로 인해 추가 오버헤드가 1byte에서 4byte 사이로 발생합니다.
이는 데이터가 적으면 이슈가 발생하지 않겠지만 데이터가 많아질수록 부담이 되는 메모리 크기입니다.
실제 메모리 크기 차이를 체감하기 위해 Python을 활용해 간단한 예시를 살펴보겠습니다.
먼저 2개의 테이블을 만들고 각각 Pk의 데이터 타입으로 String과 UUID 타입을 사용하였습니다.
class TextPkTable(Base):
__tablename__ = "text_pk_table"
pk = Column(String, primary_key=True, index=True)
class UUIDPkTable(Base):
__tablename__ = "uuid_pk_table"
pk = Column(UUID, primary_key=True, index=True)
이후 스크립트를 돌려 10,000,000개의 데이터를 insert 하였습니다.
import uuid
from app.database import Base, engine, SessionLocal
from app.models import TextPkTable, UUIDPkTable
def generate_text_pk_data(num_records):
return [
TextPkTable(pk=str(uuid.uuid4()))
for _ in range(num_records)
]
def generate_uuid_pk_data(num_records):
return [
UUIDPkTable(pk=uuid.uuid4())
for _ in range(num_records)
]
def bulk_insert(session, model, data, batch_size=10000):
for i in range(0, len(data), batch_size):
session.bulk_save_objects(data[i:i + batch_size])
session.commit()
print(f"Inserted records {i} to {i + batch_size}")
# Create the tables
Base.metadata.create_all(bind=engine)
# Insert TextPkTable data
with SessionLocal() as session:
text_data = generate_text_pk_data(10_000_000)
bulk_insert(session, TextPkTable, text_data)
# Insert UUIDPkTable data
with SessionLocal() as session:
uuid_data = generate_uuid_pk_data(10_000_000)
bulk_insert(session, UUIDPkTable, uuid_data)
이후 각 테이블이 차지하는 쿼리를 날려보면 아래와 같은 결과를 볼 수 있습니다.
select
relname as "table",
indexrelname as "index",
pg_size_pretty(pg_relation_size(relid)) "table size",
pg_size_pretty(pg_relation_size(indexrelid)) "index size"
from
pg_stat_all_indexes
where
relname not like 'pg%';
- String 타입을 사용한 테이블 : 651 MB / 730 MB (index)
- UUID 타입을 사용한 테이블 : 422 MB / 384 MB (index)
결과를 확인해보니 오버헤드로 인해 String 필드가 훨씬 더 많은 공간을 차지하는 것을 확인할 수 있었습니다.
따라서 UUID를 사용할 때는 UUID 전용 데이터 타입을 사용하면 메모리를 절약할 수 있습니다.
UUID는 인덱스와 적합하지 않음
정확히는 uuid v4와 PostgreSQL에서 기본 인덱스 알고리즘으로 사용하는 B- tree 알고리즘이 적합하지 않다는 의미인데요.
B- tree는 데이터 검색에서는 우수한 성능을 제공하지만 데이터 추가, 수정, 삭제에 대해서는 무겁다는 것을 아실 겁니다.
그 이유는 B- tree는 항상 정렬을 유지하는 트리이기 때문에 데이터의 변형에 의해 재정렬이 필요해지면 추가 작업이 필요합니다.
uuid v4는 완전한 무작위 값이기 때문에 새로운 row가 추가되는 등의 작업에서 일반적으로 인덱스를 재정렬하는 작업이 필요하게 됩니다.
때문에 row 수가 매우 많아질 경우 재정렬해야 하는 값들이 매우 많아져 성능 이슈가 발생할 수 있습니다.
이를 해결하기 위한 방법으로 uuid v4 대신 uuid v7을 사용할 수 있습니다.
uuid v7은 시간을 고려하여 생성되는 uuid 값으로 생성된 시점을 기준으로 큰 uuid 값을 가집니다.
uuid v7을 사용하면 uuid가 정렬이라는 특징을 가지게 되므로 삽입에 대해서 재정렬할 필요가 없어져 성능이 향상됩니다.
실제 예시를 살펴보기 위해 아래 2개의 테이블을 생성해 보겠습니다.
class UUIDV4Table(Base):
__tablename__ = "uuid_v4_table"
pk = Column(UUID, primary_key=True, index=True)
class UUIDV7Table(Base):
__tablename__ = "uuid_v7_table"
pk = Column(UUID, primary_key=True, index=True)
이후 10,000개의 데이터를 10번 반복하면서 각 테이블에 데이터를 insert 하는 스크립트를 작성하였습니다.
import time
import uuid
import uuid6
from app.database import Base, engine, SessionLocal
from app.models import UUIDV4Table, UUIDV7Table
def generate_uuid_v4_pk_data():
start_time = time.time() # 시작 시간 측정
for i in range(0, 10000):
new_row = UUIDV4Table(pk=uuid.uuid4())
session.add(new_row)
session.commit()
end_time = time.time() # 종료 시간 측정
print(f"UUID v4 data generation time: {end_time - start_time} seconds")
def generate_uuid_v7_pk_data():
start_time = time.time() # 시작 시간 측정
for _ in range(0, 10000):
new_row = UUIDV7Table(pk=uuid6.uuid7())
session.add(new_row)
session.commit()
end_time = time.time() # 종료 시간 측정
print(f"UUID v7 data generation time: {end_time - start_time} seconds")
# Create the tables
Base.metadata.create_all(bind=engine)
# Insert TextPkTable data
with SessionLocal() as session:
for _ in range(10):
generate_uuid_v4_pk_data()
generate_uuid_v7_pk_data()
실행 결과는 아래와 같습니다.
로그를 통해 uuid v4가 uuid v7보다
요약해 보면 PostgreSQL에서 UUID를 사용할 때는 아래 2가지를 고려해야 합니다.
- uuid를 String 또는 Text 타입 대신 PostgreSQL에서 제공하는 UUID 전용 필드를 사용할 것
- 인덱스 알고리즘으로 B- tree를 사용할 경우 uuid v4 대신 uuid v7을 사용할 것
두 이슈 모두 적은 테이블에서는 고려하지 않아도 되는 사항입니다.
하지만 PostgreSQL에서 uuid는 pk 값으로 자주 사용되기 때문에 성능 개선을 위해 알아두어야 할 특징일 것 같습니다.
위에서 사용된 모든 코드는 해당 github에서 확인하실 수 있습니다.
'개발공부' 카테고리의 다른 글
Go 언어 알아보기 (1) | 2024.07.18 |
---|---|
PostgreSQL의 Lock (0) | 2024.06.28 |
동시성 이슈가 발생하는 이유 (0) | 2024.06.26 |
Python List 내부 뜯어보기 (0) | 2024.02.18 |
Python에서 N+1을 해결하는 방법 (SQLAlchemy) (0) | 2024.01.28 |