본문 바로가기
Spring 단기심화 프로젝트

배달 플랫폼 데이터베이스 PK 전략: 실무 중심 설계 과정

by JuNo_12 2025. 9. 29.

배경

배달 주문 관리 플랫폼을 개발하면서 데이터베이스 설계의 첫 단계인 Primary Key 전략을 결정해야 했습니다. 요구사항 문서에는 "모든 주요 엔티티의 식별자는 UUID를 사용"이라고 명시되어 있었지만, 이것이 정말 최선의 선택일까요?

이 글에서는 UUID, BIGINT, 그리고 커스텀 ID 생성 전략을 비교 분석하고, 실제 기업들이 어떻게 문제를 해결하는지 살펴보며, 최종적으로 우리 프로젝트에 적합한 전략을 선택한 과정을 공유합니다.


문제 정의

초기 요구사항

프로젝트 요구사항은 명확했습니다:

  • 모든 주요 엔티티(주문, 결제 등)는 UUID를 PK로 사용
  • 유저 엔티티는 예외 (username 사용)
  • 보안과 확장성을 고려한 설계

의문점

하지만 개발을 진행하면서 몇 가지 의문이 생겼습니다:

"모든 테이블에 UUID를 써야 할까?"

  • 가게 목록, 메뉴, 카테고리 같은 공개 데이터까지?
  • URL이 GET /stores/550e8400-e29b-41d4-a716-446655440000 이렇게 길어지는 게 맞나?

"권한 체크만 잘하면 BIGINT도 안전하지 않나?"

  • 순차적 ID가 정말 보안 문제인가?
  • UUID는 단지 추측을 어렵게 만들 뿐 아닌가?

"실제 기업들은 어떻게 할까?"

  • 배달의민족 주문번호: BM202501291234567
  • 이건 UUID도 아니고 BIGINT도 아닌데?

UUID 분석

UUID란 무엇인가

UUID(Universally Unique Identifier)는 128비트 값으로, 전 세계 어디서든 중복 없이 고유한 식별자를 생성할 수 있는 표준입니다.

550e8400-e29b-41d4-a716-446655440000

핵심 특징:

  • 중복 확률: 사실상 0% (2^128 조합)
  • 중앙 조정 없이 독립적 생성 가능
  • 비순차적이라 다음 값 예측 불가능

UUID의 장점

1. 분산 환경에서의 강점

모놀리식 애플리케이션에서는 체감하기 어렵지만, MSA(Microservices Architecture)나 멀티 리전 환경에서 UUID의 진가가 드러납니다.

단일 DB (모놀리식):
└─ AUTO_INCREMENT → 1, 2, 3, 4... (충돌 없음)

분산 DB (MSA):
├─ 서울 리전 → AUTO_INCREMENT → 1, 2, 3...
└─ 도쿄 리전 → AUTO_INCREMENT → 1, 2, 3...
    → 충돌 발생!

UUID 사용:
├─ 서울 리전 → 550e8400-...
└─ 도쿄 리전 → 7c9e6679-...
    → 충돌 없음!

 

2. 보안상 이점

여기서 핵심 질문: "권한 체크만 잘하면 BIGINT도 안전하지 않나?"

답은 "맞다"입니다. 하지만 UUID는 다중 방어선(Defense in Depth)의 개념입니다.

시나리오: 개발자가 권한 체크를 실수로 누락

BIGINT 사용 시:
GET /orders/1
GET /orders/2
GET /orders/3
→ 몇 분 만에 수천 건 데이터 노출

UUID 사용 시:
GET /orders/550e8400-e29b-41d4-a716-446655440000
→ 다음 UUID를 추측할 수 없어 실질적으로 접근 불가

이것이 UUID의 진짜 가치입니다. 완벽한 보안이 아니라, 실수가 있어도 피해를 최소화하는 것입니다.

 

3. 정보 노출 방지

BIGINT:
- 첫 주문: ID = 1
- 오늘 주문: ID = 50000
→ "이 서비스는 총 5만 건의 주문이 있구나"
→ 경쟁사가 시장 규모를 추정할 수 있음

UUID:
- 어떤 패턴도 추측 불가능
→ 비즈니스 정보 보호

 

UUID의 단점

1. 가독성 문제

실제로 사용해보면 가장 먼저 느끼는 불편함입니다.

디버깅 시:
"주문 ID 550e8400-e29b-41d4-a716-446655440000을 확인해주세요"
→ 복사/붙여넣기 필수, 말로 전달 불가능

"주문 ID 123을 확인해주세요"
→ 간단명료

 

2. 성능 이슈

저장 공간:
- UUID: 16바이트
- BIGINT: 8바이트
→ 2배 차이

인덱스 크기:
- 수백만 레코드 시 수십~수백 MB 차이
- 랜덤 값이라 인덱스 단편화 발생

 

3. URL 복잡도

GET /api/v1/stores/550e8400-e29b-41d4-a716-446655440000/menus/7c9e6679-7425-40de-944b-e07fc1f90ae7

vs

GET /api/v1/stores/1/menus/42

BIGINT 재평가

BIGINT의 장점

1. 심플함

CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY
);

끝입니다. 추가 설정 없이 데이터베이스가 모든 것을 처리합니다.

2. 성능

  • 인덱스 크기 작음
  • 순차적 삽입으로 인덱스 단편화 없음
  • 정렬 및 범위 검색 빠름

3. 개발 편의성

  • 디버깅 용이
  • 로그 추적 간편
  • 팀 간 커뮤니케이션 원활

 

BIGINT는 정말 안전하지 않나?

핵심: 보안은 권한 체크에서 나온다

UUID를 쓰든 BIGINT를 쓰든, 권한 체크를 빠뜨리면 취약합니다. 반대로 권한 체크를 철저히 하면 BIGINT도 안전합니다.

권한 체크 O + BIGINT = 안전
권한 체크 O + UUID = 더 안전 (다중 방어선)
권한 체크 X + UUID = 여전히 취약 (다만 공격이 어려울 뿐)

 

그렇다면 UUID는 오버스펙인가?

아닙니다. 상황에 따라 다릅니다.

공개 데이터 (가게, 메뉴, 카테고리):
→ UUID는 오버스펙
→ BIGINT로 충분

민감 데이터 (주문, 결제, 개인정보):
→ UUID가 적절
→ 다중 방어선으로 리스크 관리

하이브리드 전략

도메인 특성에 따른 차별화

고민 끝에 "모든 테이블에 동일한 전략을 쓸 필요는 없다"는 결론에 도달했습니다.

최종 전략:

데이터 유형 PK 전략 테이블 이유
민감 데이터 UUID Order, Payment 추측 불가능해야 함
공개 데이터 BIGINT Store, Menu, Review 추측되어도 문제없음
자연키 VARCHAR User username이 이미 고유

 

실제 적용

민감 데이터:

CREATE TABLE p_order (
    id BINARY(16) PRIMARY KEY,
    user_username VARCHAR(100) NOT NULL,
    store_id BIGINT NOT NULL
);

CREATE TABLE p_payment (
    id BINARY(16) PRIMARY KEY,
    order_id BINARY(16) NOT NULL UNIQUE
);

 

공개 데이터:

CREATE TABLE p_store (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    store_name VARCHAR(255) NOT NULL
);

CREATE TABLE p_review (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    store_id BIGINT NOT NULL,
    order_id BINARY(16) NOT NULL UNIQUE
);

 

API 설계

민감 데이터:
GET /v1/orders/550e8400-e29b-41d4-a716-446655440000
GET /v1/payments/7c9e6679-7425-40de-944b-e07fc1f90ae7

공개 데이터:
GET /v1/stores/1
GET /v1/menus/42
GET /v1/reviews/123

장점

  1. 보안과 효율성의 균형: 필요한 곳에만 UUID 사용
  2. 개발 생산성: 공개 데이터는 BIGINT로 빠르게 개발
  3. 리소스 최적화: 불필요한 저장 공간 낭비 방지
  4. 실무 적합성: 많은 기업이 실제로 사용하는 방식

커스텀 ID 생성 전략

설계

실제 기업들의 사례를 바탕으로 우리 서비스에 맞는 커스텀 ID를 설계했습니다.

주문 ID:

ORD-20250129-A1B2C3

구성:
- ORD: 주문 식별자
- 20250129: 생성 날짜
- A1B2C3: 랜덤 6자리 (영숫자)

결제 ID:

PAY-20250129-X7Y8Z9

구성:
- PAY: 결제 식별자
- 20250129: 생성 날짜
- X7Y8Z9: 랜덤 6자리

 

장점 분석

1. 비즈니스 친화성

고객 응대:
"주문번호가 어떻게 되시나요?"
"ORD-20250129-A1B2C3입니다"
→ 명확하고 전달하기 쉬움

UUID라면:
"550e8400-e29b-41d4-a716-446655440000입니다"
→ 전화로 전달 불가능

 

2. 운영 효율성

로그 분석:
[2025-01-29] Order ORD-20250129-A1B2C3 created
→ 한눈에 날짜 파악, 해당일 주문임을 즉시 인식

[2025-01-29] Order 550e8400-e29b-41d4-a716-446655440000 created
→ 날짜 정보 없음, 추가 쿼리 필요

 

3. 트러블슈팅

"어제 결제 건 중에 문제 있는 거 찾아줘"
→ PAY-20250128-로 시작하는 것만 grep하면 됨

UUID라면:
→ created_at으로 필터링 후 하나하나 확인

4. 보안

6자리 영숫자 랜덤:

  • 가능한 조합: 36^6 = 약 21억
  • 날짜별로 분산되므로 하루 수백만 건도 충분히 처리
  • 추측 불가능

 

충돌 방지

전략 1: 날짜 기반 분산

날짜가 다르면 같은 랜덤 값이 나와도 전체 ID는 다릅니다.

ORD-20250129-ABC123
ORD-20250130-ABC123
→ 다른 ID

 

전략 2: 재시도 로직

극히 드문 충돌 상황을 대비한 재시도:

최대 3번 재시도
각 시도마다 새로운 랜덤 값 생성
3번 모두 실패 시 에러 발생 (로깅 후 알림)

실무에서 충돌 확률:

  • 하루 10만 건 생성 시
  • 충돌 확률: 약 0.002%
  • 거의 발생하지 않지만 대비책은 필요

 

구현

핵심 컴포넌트만 간단히:

IdGenerator:

  • generateOrderId(): "ORD-20250129-A1B2C3" 생성
  • generatePaymentId(): "PAY-20250129-X7Y8Z9" 생성

서비스 레이어:

  • 엔티티 생성 시 IdGenerator로 ID 할당
  • 충돌 체크 및 재시도 로직

데이터베이스:

  • PK 타입: VARCHAR(20)
  • 인덱스: 날짜 부분으로 파티셔닝 가능

UUID 대안 기술들

커스텀 ID 외에도 다양한 대안이 있습니다.

Snowflake ID

Twitter에서 개발한 64비트 분산 ID 생성 알고리즘입니다.

구조:

41비트(타임스탬프) + 10비트(머신ID) + 12비트(시퀀스)
= 64비트 Long 타입

장점:

  • UUID보다 절반 크기 (8바이트)
  • 시간순 정렬 가능
  • 초당 수백만 건 생성 가능
  • 숫자라서 다루기 편함

단점:

  • 구현 복잡도 높음
  • 머신 ID 관리 필요
  • 분산 환경 필수

사용 사례: Twitter, Discord, Instagram

 

ULID

비교적 최근에 나온 UUID 대안입니다.

형식:

01ARZ3NDEKTSV4RRFFQ69G5FAV
(26자)

장점:

  • UUID보다 짧음 (26자 vs 36자)
  • 시간순 정렬 가능
  • URL-safe (하이픈 없음)
  • 대소문자 구분 없음

단점:

  • UUID만큼 널리 쓰이지 않음
  • 일부 시스템에서 지원 부족

 

Hash ID

BIGINT를 해시로 인코딩하는 방식입니다.

예시:

BIGINT 1 → "jR"
BIGINT 123 → "Mj3"
BIGINT 9001 → "kQVg"

장점:

  • 내부적으로 BIGINT 유지
  • 외부 노출 시에만 인코딩
  • 짧은 길이
  • 양방향 변환 가능

단점:

  • 디코딩 가능 (완전한 보안 아님)
  • Salt 관리 필요

사용 사례: YouTube 동영상 ID

 

비교

방식 크기 정렬 보안 구현 비즈니스 친화성
UUID 16바이트 높음 쉬움 낮음
Snowflake 8바이트 중간 어려움 낮음
ULID 26자 높음 보통 낮음
Hash ID 가변 낮음 보통 낮음
커스텀 가변 높음 보통 높음

최종 결정

선택: 커스텀 ID 생성 전략

민감 데이터:

주문: ORD-20250129-A1B2C3
결제: PAY-20250129-X7Y8Z9

 

공개 데이터:

가게, 메뉴, 리뷰: BIGINT AUTO_INCREMENT

 

의사결정 근거

 

1. 실무 적합성

실제 배달의민족, 쿠팡, 토스 등 주요 서비스가 사용하는 방식과 동일합니다. 학습 프로젝트지만 실무와 최대한 유사하게 구현하는 것이 목표였습니다.

 

2. 비즈니스 가치

기술적 완벽함보다 비즈니스 가치를 우선했습니다:

  • CS 팀의 업무 효율성
  • 고객과의 원활한 소통
  • 운영팀의 트러블슈팅 편의성

3. 확장 가능성

현재: ORD-20250129-A1B2C3

미래 확장:
- 리전 정보 추가: ORD-KR-20250129-A1B2C3
- 서비스 타입: ORD-QUICK-20250129-A1B2C3
- 파트너 정보: ORD-P123-20250129-A1B2C3

 

4. 포트폴리오 차별화

단순히 UUID나 BIGINT를 쓰는 것이 아니라, 비즈니스 요구사항을 분석하고 커스텀 솔루션을 설계한 과정 자체가 포트폴리오의 강점이 됩니다.


트레이드오프와 한계

인정하는 한계

1. 구현 복잡도

BIGINT AUTO_INCREMENT보다 복잡합니다:

  • ID 생성 로직 구현 필요
  • 충돌 처리 로직 필요
  • 테스트 케이스 증가

2. 완벽한 순차성 부재

날짜 내에서는 랜덤이므로 완벽한 순차 정렬은 불가능합니다. 하지만 created_at 컬럼으로 해결 가능합니다.

 

3. 유지보수

자체 구현한 로직이므로 팀원 모두가 이해하고 유지보수할 수 있어야 합니다. 문서화와 코드 리뷰가 중요합니다.

선택한 이유

이런 한계에도 불구하고 커스텀 ID를 선택한 이유:

복잡도 증가 < 비즈니스 가치 + 운영 효율성 + 학습 효과

실무에서는 항상 완벽한 해답이 없습니다. 상황에 맞는 합리적인 타협점을 찾는 것이 중요합니다.


구현 시 주의사항

1. 권한 체크는 필수

가장 중요한 원칙: ID 전략은 보안의 2차 방어선일 뿐입니다.

어떤 ID를 쓰든 권한 체크를 빠뜨리면 취약합니다. 반대로 권한 체크를 철저히 하면 어떤 ID든 안전합니다.

모든 API에서 권한 체크 구현:

  • 본인 데이터인지 확인
  • 역할(Role) 기반 접근 제어
  • 통합 테스트로 검증

2. 일관성 유지

좋은 예:
- 주문: ORD-20250129-A1B2C3
- 결제: PAY-20250129-X7Y8Z9
- 환불: RFD-20250129-P9Q8R7
→ 일관된 패턴

나쁜 예:
- 주문: ORD-20250129-A1B2C3
- 결제: payment_20250129_xyz
- 환불: R-123456
→ 일관성 없음

3. 문서화

커스텀 로직은 반드시 문서화:

  • ID 생성 규칙
  • 각 부분의 의미
  • 충돌 처리 방법
  • 확장 계획

4. 모니터링

  • ID 생성 실패율 모니터링
  • 충돌 발생 시 알림
  • 생성 성능 추적

결론

핵심 교훈

1. 모든 상황에 맞는 완벽한 해답은 없다

UUID, BIGINT, 커스텀 ID 모두 장단점이 있습니다. 중요한 것은 우리 서비스의 요구사항입니다.

2. 기술은 비즈니스를 위해 존재한다

기술적 완벽함보다 비즈니스 가치가 우선입니다. 가장 세련된 기술이 아니라 가장 적합한 기술을 선택해야 합니다.

3. 실무에서 배우는 것이 가장 가치 있다