분산 시스템에서 활용할 수 있는 다양한 ID 전략에 대해 알아보자.

반응형

분산 시스템이 보편화되면서 효율적인 ID 생성 전략의 중요성이 더욱 커지고 있다.

이번 포스팅에서는 DB에서 사용되는 여러 ID 생성 전략을 비교하고, Spring Boot와 JPA 환경에서의 구현 방법을 살펴보자.

참고로, 각 전략의 장단점은 MySQL InnoDB 스토리지 엔진 기준으로 타 DBMS와는 차이가 있을 수 있다.

 

Auto-Increment ID 전략

Auto-increment는 가장 전통적인 ID 생성 방식이다.

DB 테이블에 새 레코드가 삽입될 때마다 자동으로 증가하는 정수 값을 ID로 할당한다.

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) /
    private Long id;
    
		... 생략
}

 

장점
  • 구현이 단순하고 직관적이다.
  • 일반적으로 4바이트(INT), 8바이트(BIGINT)로 PK 크기가 작다.
  • 관련 레코드가 같은 페이지나 인접 페이지에 있어 I/O 효율과 버퍼풀 캐싱 효율성이 증가한다. (DB는 페이지 단위로 데이터를 읽고 쓰고, 버퍼풀은 데이터 페이지 단위로 캐싱하기 때문)

 

단점
  • 수평 확장에 제한적이다.
  • 모든 ID 생성 요청이 단일 DBMS에 집중된다.
  • ID가 순차적이라 예측이 가능해 보안에 위험이 존재한다.
  • 벌크 인설트 안된다는 점도 있으면 좋을듯

 

수평 확장에 제한적이라는 점에 추가적으로 설명해보면, 샤딩을 적용해 2개의 샤드가 있다고 가정해보자.

중요한 점은 Auto_Increment 전략은 DB에 저장하기 전에 ID 값을 알 수 없다.

즉, 샤드 별 ID 증분 설정이 없이 저장하게 되면 샤드마다 동일한 ID 값을 가져, ID 기준으로 라우팅을 할수가 없다.

이 문제를 해결하기 위해선 다른 시작 값과 증분을 설정하는 방법이 있다.

 

홀수/짝수 방식으로 설정한다고 해보자.

각 DB에 샤드 개수 만큼 증분 시키면서 시작 값은 서로 다르게 설정한다.

쓰기 작업 방식은 라운드 로빈 방식으로 DB에 라우팅하고, 읽기 작업은 ID를 기준으로 라우팅한다.

이 방식을 사용하는 이유가 궁금할 수 있다.

애초에 

근데 샤드 별 ID 증분 설정이 없이 저장하게 되면 샤드마다 동일한 ID 값을 가져, ID 기준으로 라우팅을 할수가 없게 되기 때문이다.

 

etc-image-0

현재의 경우에는 잘 작동하겠지만 만약 추가된다면 ?

 

다음과 같이 새 패턴에 맞게 이미 적재된 데이터를 옮겨야하고 추가적인 설정이 필요하다.

즉, SOURCE가 추가/제거 될 때마다 복잡한 작업이 필요해진다.

etc-image-1

 

이러한 단점을 해소하기 위해 어떤 서버가 어떤 ID 범위를 사용하는지 관리하는 전용 서버를 추가할 수 있다.

(로드 밸런싱 된 경우 가정. 단일 서버 구성인 경우 무의미)

etc-image-2

문제는 ID 할당 전용 서버가 SPOF가 된다.

이 서버에 장애가 발생하면, 해당 서버를 이용하는 모든 시스템이 영향을 받는다.

이를 위해 D 할당 전용 서버의 가용성을 보장해주기 위해 여러 대로 구성하는 등의 추가적인 관리가 필요해진다.


UUID (Universally Unique Identifier) 전략

UUID는 128비트(16바이트) 크기의 값으로, 충돌 가능성이 매우 낮은 고유 식별자이다.

UUID는 일반적으로 32자리의 16진수와 4개의 하이픈으로 표현된다.

(예: 123e4567-e89b-12d3-a456-426614174000).

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private Long id;
    
		... 생략
}

위키 피디아에 의하면 UUID의 충돌 확률이 50%가 되려면 약 86년 동안 초당 10억 개의 UUID를 생성해야한다고 한다.

즉, 충돌 가능성이 매우 낮다고 할 수 있다.

etc-image-3
(참고 자료 : https://en.wikipedia.org/wiki/Universally_unique_identifier )

 

UUID는 총 8개의 버전이 있으며 가장 흔히 쓰이는 버전은 4로 무작위 생성 방식을 갖는다.

etc-image-4
(참고 자료 : https://namu.wiki/w/UUID )

 

장단점에 대해서는 UUID 버전 4 기준으로 설명한다.

장점
  • 중앙 조정 없이 독립적으로 고유한 키 생성이 가능하다.
  • 예측 불가능한 값으로 보안성 향상된다.
  • 서버 추가에 따른 조정 불필요해 수평 확장에 유리하다.

 

단점
  • PK 크기(16바이트)가 커지기 때문에 버퍼풀에 많은 인덱스를 로드하지 못해 검색 성능이 저하된다.
  • UUID들이 무작위로 정렬되어 ID만으로는 어떤 데이터가 최신인지 판별할 수 없다.
  • B-Tree 구조를 사용하는 인덱스에서 트리 전체에 걸쳐 무작위로 삽입되어 균형 조정이 빈번하게 발생한다.
  • 관련 레코드가 디스크 전체에 분산되어 각 레코드가 존재한 데이터 페이지를 찾기위해 별도의 I/O가 필요하고 버퍼풀 캐싱 효율 또한 떨어진다.

스노우 플레이크 (Snowflake) ID 전략

스노우 플레이크 ID는 Twitter에서 개발한 분산 시스템용 ID 생성 방식으로, 64비트 정수 ID를 생성한다.

ID는 여러 구성 요소로 이루어져 있다.

0 - 00000000000000000000000000000000000000000 - 0000000000 - 000000000000
│                       │                            │            │
부호비트                타임스탬프                   워커 ID      시퀀스 번호
  • 부호 비트: 1비트, (항상 0, 양수 보장), 음수를 구별할 상황이 있음에 대비
  • 타임스탬프: 41비트, 기원 시각 (epoch) 이후로 몇 밀리초 (milisecond) 가 경과했는지를 나타내는 값
  • 워커 ID: 10비트 (최대 1024개 노드 식별)
  • 시퀀스 번호: 12비트 (동일 밀리초 내 4096개 ID 생성 가능)

타임스탬프는 41비트로 최대 표현 가능한 년 단위로 환산하면 약 69.7년이다.

즉, 2025년부터 시작한다면 2094년도에 타임스탬프가 소진된다.

 

스노우 플레이크 ID 전략을 사용하기 위해선 JPA 자동 생성 전략을 사용하지 않고 직접 ID를 생성해 적용해주어야 한다.

적용 방법에 대해선 아래 포스팅 링크를 첨부한다.

https://ramka-devstory.tistory.com/2

 

장점
  • 분산 환경에서 ID의 유일성을 보장한다.
  • 타임스탬프를 포함하기 때문에 시간 기반으로 생성 순서를 알 수 있다.
  • ID에서 생성 시간, 서버 정보 추출 가능
  • 8바이트(Long)로 PK 크기가 작다.
  • 워커 ID로 서버 구분하여 수평 확장에 유리하다.

 

단점
  • 각 서버마다 고유 ID(워커 ID) 할당 및 관리가 필요하다.
  • 표준 라이브러리가 없어 직접 구현이 필요하다.
  • 서버 시간에 의존적이기 때문에 시간에 오류가 있을 시 ID 충돌 가능하다.

TSID (Time-Sorted ID) 전략

TSID는 스노우플레이크 ID에서 영감을 받아 개발된 ID 생성 방식으로, 시간 기반으로 정렬 가능한 64비트 ID를 제공한다.

이 방식은 다음과 같은 구조를 갖는다.

                                            adjustable
                                           <---------->
|------------------------------------------|----------|------------|
       time (msecs since 2020-01-01)           node       counter
                42 bits                       10 bits     12 bits

- time:    2^42 = ~69 years or ~139 years (with adjustable epoch)
- node:    2^10 = 1,024 (with adjustable bits)
- counter: 2^12 = 4,096 (initially random)
  • 타임스탬프: 42비트 (밀리초 단위)
  • 노드 / 카운터 : 총 22비트

노드는 분산 환경에서 각 서버나 워커를 구분하는 데 사용된다.

10비트로 최대 1,024개의 서로 다른 노드를 지원할 수 있으며 총 22비트 내에서 비트 수를 node 비트를 조정할 수 있다.

(node 비트를 12로 수정하면 counter는 10)

 

카운터는 같은 밀리초 내에 같은 노드에서 생성된 ID를 구분하기 위한 랜덤한 시퀀스 번호다.

기본 12비트로 밀리초당 최대 4,096개의 고유 ID를 생성할 수 있다.

 

적용하기

적용을 위해선 아래 라이브러리를 추가해야한다.

자세한 내용은 https://github.com/vladmihalcea/hypersistence-tsid 저장소에서 확인할 수 있다.

implementation 'io.hypersistence:hypersistence-tsid:2.1.4'

 

1. 라이브러리가 제공하는 TsidGenerator사용하기

@Entity
public class User {
    @Id
    @GeneratedValue(generator = "tsid")
    @GenericGenerator(
        name = "tsid", 
        strategy = "io.hypersistence.tsid.hibernate.TsidGenerator"
    )
    private Long id;
    
    ... 생략
}

 

2. application.properties에서 현재 노드 ID 설정

# 노드 ID 설정 (0-1023 범위)
hypersistence.tsid.node-id=1

만약 특별한 요구사항이 있거나 TSID 생성 로직을 제어하고 싶다면

직접 IdentifierGenerator를 인터페이스를 구현해 사용할 수 있다.

 

장점
  • 스노우플레이크보다 단순한 구조 그리고, 라이브러리 제공으로 구현 간편
  • 분산 환경에서 ID의 유일성을 보장한다.
  • 타임스탬프를 포함하기 때문에 시간 기반으로 생성 순서를 알 수 있다.
  • ID에서 생성 시간, 서버 정보 추출 가능
  • 8바이트(Long)로 PK 크기가 작다.
  • 워커 ID로 서버 구분하여 수평 확장에 유리하다.
  • 스노우플레이크 보다 타임스탬프의 비트가 1 크기 때문에 2배 더 긴 기간을 표현할 수 있다. (약 139.4년)
  • 내부적으로 toString() 사용 시 Base32로 인코딩하여 URL로 사용 가능하다. (예: 0ujtsYcgvSTL8PAhbFXK) (디코딩 기능도 내장)

 

단점
  • 서버 시간에 의존적이기 때문에 시간에 오류가 있을 시 ID 충돌 가능하다.
  • 각 서버마다 고유 ID(워커 ID) 할당 및 관리가 필요하다.
  • 라이브러리를 사용하기 때문에 내부 구현을 제어하고 싶다면 직접 구현이 필요하다.

참고자료
  • 가상 면접 사례로 배우는 대규모 시스템 설계 기초