본문 바로가기

java

중복 거래 방지를 위한 uuid 생성(UUIDv7, time-based uuid)

계기
uuid란?
Custom UUIDv7 구현
결론

계기

동일 입출금에 대한 중복 거래 방지를 위해서는 어떻게 해야 하는가?

 

입출금 거래 시스템에 대한 애플리케이션 로직을 구현하며 '요청의 동일성을 어떻게 검증할 것인가?'에 대한 고민이 있었다. 여러 client에서 들어오는 입출금 요청은 단 한 번만 처리되어야 한다. 구현은 단일 서버를 가정하였기 때문에 입금과 출금 모듈도 하나의 트랜잭션으로 처리되지만, 네트워크 오류나 클라이언트-서버 간의 timeout, 재처리 로직 등에 의해서 동일 입출금 요청이 여러 번 서버에 요청이 올 수도 있다.

예를 들어 사용자가 100원을 A통장에서 B통장으로 옮기고자 한다면, A통장에서 100원을 출금해 B통장으로 100원을 입금해야 한다. 출금과 입금은 한 번씩만 이루어져야 한다. 위에서 가정하듯이 입출금 모듈이 통합되어 있고(동일 은행이라고 해보자), 트랜잭션도 하나라면 구현은 비교적 간단하다. 트랜잭션으로 인해 all or nothing이기 때문이다.

하지만 출금 계좌와 입금 계좌의 은행이 다르다면 시스템도 다르다. 각 시스템의 트랜잭션은 분리되어 있다. A은행의 100원 출금 요청은 성공하였으나, B은행으로의 100원 입금 요청 시 처리 시간이 길어졌고, A은행의 timeout 설정이 B 은행보다 짧아서 입금 요청의 성공 여부와는 상관 없이 A은행에서의 출금 요청이 실패로 응답되었다고 해보자. A은행은 사용자든, 스케쥴러든 B은행에 입금 요청이 성공했는지 재확인해야 할 것이다. 이 때 해당 거래의 유일성과 동일성을 보장할 수 있도록 uuid를 설정하였다.

uuid란?

Universally Unique Identifiers

128-bit 고유 식별자로 RFC 9562에서 정의하고 있는 표준이다. RFC 4122(2005년 7월)는 RFC 9562(2024년 5월)에 의해 대체되었다. UUIDv1 ~ UUIDv8 까지 정의되어 있고, 각 버전에 따라 그 쓰임이 다르다.

time-based uuid를 생성하기 위해서는 v1, v6, v7을 사용할 수 있는데, v1과 v6는 그레고리력(Gregorian Calendar)을 따르고 v7은 unix epoch time 기반이다. v1은 시간 정보가 suffix로, v6과 v7은 prefix로 되어 있어 시간 순서로 정렬되어 있고 항상 증가하는 값을 가진다.

그레고리력은 날짜 그 자체에 집중하기 때문에 윤년이나 월 길이 등을 고려하여 날짜 계산의 복잡성이 높다. unix epoch time은 표준으로 1970년 1월 1일을 기준으로 경과한 초를 나타내는 숫자다. 64비트에 나노초까지 저장하여도 충분히 긴 시간을 표현할 수 있다.

이 중에서도 unix epoch time 기준 time-based uuid v7의 구현에 대해서 살펴보고자 한다.

 

Custom UUIDv7 구현

https://github.com/f4b6a3/uuid-creator
https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-7

 

uuid 생성 라이브러리를 사용하면 간단하게 생성할 수 있지만, 단순하게 커스텀하여 구현해보고자 한다.

rfc 문서에 따르면 다음과 같은 규칙을 따른다. 총 16바이트(128비트)로, 4비트를 한 자리로 보았을 때 48비트(timestamp) + 4비트(version) + 12비트(pseudorandom) + 2비트(variant) + 62비트(pseudorandom)로 구성된다.

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           unix_ts_ms                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          unix_ts_ms           |  ver  |       rand_a          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var|                        rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                            rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

이를 좀 더 단순화해서 UuidGenerator 유틸 메서드를 만들어서 표현한다. 48비트(timestamp) + 4비트(version) + 16비트(random) + 64비트(random)

public final class UuidGenerator {
​
    private static final Random random = new Random();
​
    public static UUID generateTimeBased() {
        long timestamp = Instant.now().toEpochMilli();
        long mostSigBits = (timestamp & 0xFFFFFFFFFFFFL) << 16;  //timestamp(48bit)
        mostSigBits |= 0x0000000000007000L;  //version(4bit)
        mostSigBits |= (random.nextInt(1 << 16) & 0xFFFF);  //random(16bit)
        long leastSigBits = random.nextLong();  //random(64bit)
        return new UUID(mostSigBits, leastSigBits);  //total(128bit)
    }
}

 

jdk에서도 기본적으로 제공하는 UUID 객체를 사용하면 16바이트 uuid 표준을 사용할 수 있고, 데이터베이스와 orm(jpa) 등에서도 호환이 가능하다. DB에도 MySQL 8.0 기준으로 binary(16)으로 저장 가능하다.

 

결론

분산 시스템이나 내가 컨트롤할 수 없는 시스템과의 연계 spec에서의 uuid를 공유해야 할 때 사용할 수 있는 방법이지만, 사실 밀리세컨드 단위의 거래에서 중복이 발생할 수 있는 서비스가 아니라면 굳이 이렇게까지 해야할까란 생각이 들기는 한다. 일하면서도 특정 기관과의 거래에서 timestamp와 기관코드 정도, 필요 시 시퀀스를 포함하여 스펙 협의를 하여도 충분한 트래픽을 감당할 수 있다.

다만 time-based uuid를 사용하는 이유가 적은 바이트(16byte) 수로 일관되게 증가하는(prefix timestamp) 데이터를 가질 수 있고, 랜덤 bit수에 따라 중복 가능성을 현저히 줄일 수 있다는 장점이 있는 것으로 보인다. n개의 bit를 랜덤하게 가져가면 ms마다 2^n 만큼의 경우의 수가 발생하므로 중복이 발생할 가능성이 거의 없다고 봐도 무방하다.