목표
MySQL에서 UUID를 최대한 효율적으로 사용해 보기 위한 노력 과정을 기술합니다.
개요
관계형 DB에서 데이터(튜플)을 식별하기 위해 PK(Primary Key, 기본키)를 사용합니다.
하지만, 클라이언트와 서버 사이에서 데이터를 확인하기 위해 PK를 주고받는 것은 보안적인 측면에서 위험합니다.
만약 다음과 같은 URL이 있다면 어떨까요?
http://www.domain.com/user/info?userid=1
파라미터로 들어가는 userid의 값만 바꿔도, 다른 사람의 정보를 확인할 수 있는 것을 예측할 수 있습니다.
이처럼 예측가능한 모델이 되어 SQL Injection의 위험성이 존재하기 때문에, PK값을 그대로 넘겨주는 것은 바람직하지 않습니다.
따라서, 고유값을 갖는 특정 값으로 데이터를 식별할 필요가 있습니다.
서버 내에서 특정한 키를 발급하거나, 세션ID 등을 활용해 고유값을 받는 값을 활용한다면 이를 해결할 수 있습니다.
하지만, 트래픽이 많아져 서버를 늘리게 된다면(Scale Out) 글로벌한 환경에서 고유값을 유지할 수 있도록 관리해야 합니다.
특정 데이터를 조합하는 등 다양한 방법이 있겠지만, 그 중에서도 UUID를 활용할 수 있습니다.
UUID란?
UUID(Universally Unique Identifier)는 공개 소프트웨어 재단(OSF)에서 만든 고유성이 보장되는 표준 규약입니다.
다음과 같이 128bits로 구성되어 총 32개의 문자가 5묶음으로 구분되어 있는 형태입니다. (8-4-4-4-12개의 형태)
e6107646-b269-11ed-afa1-0242ac120002
각 필드의 구성 내용은 다음과 같습니다.
Timestamp - Timestamp - Timestamp & Version - Variant & Clock sequence - Node id
이는 V1, V2의 경우이고 구체적인 필드 내용은 버전 별로 상이합니다. (버전이 높다고 좋은 게 아닙니다!)
버전 별 정보
버전 | 내용 |
V1 | - Timestamp와 MAC 주소를 기반으로 UUID를 생성. - MAC주소를 추적당할 수 있기 때문에,RFC 4122 규약은 마지막 48비트를 임의의 노드 ID로 대체할 수 있도록 허용. |
V2 | - Timestamp와 MAC 주소, DCE 보안 버전을 기반으로 UUID를 생성. - 첫 8비트를 Timestamp의 정보 대신 local domain 번호를 기반으로 발급. - 노드/도메인/식별자 당 7분에 1개 이상 필요시 부적합. |
V3 | - Namespace 기반으로 MD5 해싱 알고리즘을 활용해 생성. - URL, 도메인 이름, Object identifier, X500을 namespace 스펙으로 가짐. - Namespace가 같으면 동일한 UUID가 생성됨. - MD5는 역해시가 가능하므로, V5를 사용하도록 권장. |
V4 | - 무작위로 UUID를 생성. - 버전 정보, variant 정보를 제외하고 무작위로 생성. - 무수히 많은 가짓수 존재 (중복 가능성 약 0.00000000006%). |
V5 | - Namespace 기반으로 SHA-1 해싱 알고리즘을 활용해 생성. - 그 외 V3와 동일 |
즉, UUID는 MAC 주소 혹은 노드ID를 기준으로 생성해 네트워크 내에서 중복되지 않는 ID로, 분산 환경에서도 활용할 수 있습니다.
MySQL에서 UUID 도입시 고려해야 할 점
한 마디로 UUID는 고유한 값을 갖는 키값으로서 활용할 수 있습니다. 하지만, MySQL에서 UUID를 도입할 때 고려해야 할 점이 있습니다.
MySQL은 클러스터드 인덱스로 되어 있어 순차적인 인덱스에 최적화되어 있기 때문에, UUID는 성능적인 측면에서 비효율적입니다.
클러스터드 인덱스는 B- 트리 구조로 되어 있어 항상 정렬된 상태를 유지합니다.(MySQL8 기준)
시퀀스를 기반으로 순차적으로 값이 올라가는 경우, 데이터를 삽입할 때 구성이 크게 변하지 않습니다.
하지만, 무작위 값을 인덱스로 사용하게 되면 데이터를 추가할 때마다 구조를 재배치해야 하므로 성능에 영향을 미치게 됩니다.
또한, 인덱스는 하드디스크에 저장이 되는데, 32bytes라는 비교적 큰 값을 사용하게 되면 인덱스 페이지의 크기가 커지는 문제가 있습니다.
보안적인 측면에서 얻는 장점과 성능상 단점에서 오는 Trade-Off를 고려해서 UUID를 도입할 필요가 있습니다.
UUID 도입 결정과 성능 개선 시도
결론부터 말씀드리면, 진행하고 있는 'NEMODU' 프로젝트에 UUID를 도입하는 것을 결정했습니다.
회원과 운동기록 등의 기능은 토큰의 정보를 통해 본인 확인을 진행할 수 있어 UUID가 불필요합니다.
반면, 챌린지의 경우 불가능한 케이스가 존재해 UUID를 도입하기로 결정했습니다.
그래도 UUID의 단점을 모두 안고 갈 수는 없는 노릇입니다.
UUID를 최대한 순차적으로 만들고 중복된 값을 줄인다면 UUID를 최대한 개선할 수 있습니다.
UUID 버전 선택
먼저, 사용할 UUID의 버전을 선택해야 합니다.
Namespace에 의존적인 V2, V3, V5는 제외하고, V1, V4 중에 선택해야 합니다.
대부분 UUID를 사용하는 케이스는 고유한 값이 필요하기 때문에, 무작위 값인 V4를 많이 사용합니다.
하지만, 무작위 값이 생성되면 순차적으로 만드는 것이 불가능하므로, 일부 규칙에 의거하는 V1을 선택했습니다.
생성 시간과 생성지를 유추할 수 있지만, 사실상 공개된 정보들이라 문제가 되진 않다고 판단했습니다.
순차적인 UUID 만들기
UUID V1은 각 필드가 다음과 같은 정보를 갖고 생성됩니다.
Timestamp - Timestamp - Timestamp & Version - Variant & Clock sequence - Node id
시간을 기준으로 생성되는 값이기 때문에, 순서를 잘 정렬하면 최대한 순차적인 값을 생성할 수 있습니다.
1, 2, 3번째 필드에 들어가는 정보는 Timestamp의 하위 32bits, 중간 16bits, 상위 12btis로 구성이 되어 있습니다.
따라서, 이를 역순으로 구성하면 시간 순서의 UUID를 생성할 수 있습니다.
실제로 UUID를 생성해서 검증해보겠습니다.
Java.Util.UUID 클래스는 UUID V4를 지원하고, V1을 만들기 위해서 Timestamp 계산이 추가적으로 들어가야 합니다.
안정적으로 UUID V1을 생성하기 위해 다음과 같은 라이브러리를 추가했습니다.
implementation "com.fasterxml.uuid:java-uuid-generator:4.0.1"
이후, 10개의 UUID를 생성해 결과를 확인해 봤습니다.
Generators.timeBasedGenerator().generate(); //UUID V1 생성
/** 결과
* e8eb287d-b295-11ed-b062-6154355cf97a
* e8eb4f8e-b295-11ed-b062-19583c733b0b
* e8eb769f-b295-11ed-b062-3f042de161fb
* e8eb76a0-b295-11ed-b062-99371b4b2895
* e8eb76a1-b295-11ed-b062-bbe2a7590eb4
* e8eb76a2-b295-11ed-b062-478ed313cf4e
* e8eb76a3-b295-11ed-b062-71a927e2a90c
* e8eb76a4-b295-11ed-b062-1797917006c1
* e8eb76a5-b295-11ed-b062-2fa9ea5d72ea
* e8eb76a6-b295-11ed-b062-8dca1bd336b5
*/
먼저, Timestamp에 영향을 받는 1, 2,3 번째 필드를 보면 2, 3번째 필드는 동일하고 1번째 필드만 값이 증가하고 있음을 알 수 있습니다.
이를 역순으로 배치한다면, 의도한 대로 순차적인 UUID를 생성할 수 있습니다.
그 외에 눈에 띄는 건 4번째 필드입니다. Clock Sequence를 기반으로 생성되는 필드인데, 단순히 Sequence를 뜻하는 것이 아닙니다.
프로그램이 시작할 때 생성되는 난수로, 시간에 따라 증가한다고 해서 Clock Sequnce라는 이름이 붙은 것입니다.
서버를 재가동하거나, Scale Out을 하게 되면 해당 필드는 변하게 되므로 항상 같은 값이라고 판단할 수 없습니다.
5번째 필드는 서버의 Ethernet 주소를 기반으로 생성되는데, 따로 전달해주지 않아 난수가 생성되고 있습니다.
Timestamp를 기반으로 생성하는 V1 특성상, 노드ID와 같이 중복이 되는 값보단 난수를 생성해 중복될 확률을 낮추는 것이 효율적이라고 판단했습니다.
즉, 3 - 2 - 1 - 4 - 5 순서대로 구성하면 3번째 일부 값까지 순차적인 값을 가질 수 있습니다. (절대적인 것은 아닙니다.)
결과를 확인하면 다음과 같습니다.
UUID newUuid = Generators.timeBasedGenerator().generate();
String[] uuidArr = newUuid.toString().split("-");
String uuidStr = uuidArr[2]+"-"+uuidArr[1]+"-"+uuidArr[0]+"-"+uuidArr[3]+"-"+uuidArr[4];
/** 결과
* 11ed-b298-96b0902a-9cc2-01c350f3bc5e
* 11ed-b298-96b216cb-9cc2-b5c17fd0c029
* 11ed-b298-96b216cc-9cc2-4f73d388187c
* 11ed-b298-96b216cd-9cc2-15495a3ab283
* 11ed-b298-96b216ce-9cc2-fbf627d3d00d
* 11ed-b298-96b216cf-9cc2-69dc711a8565
* 11ed-b298-96b216d0-9cc2-ffc1071ac876
* 11ed-b298-96b216d1-9cc2-a55022087a69
* 11ed-b298-96b216d2-9cc2-5ff23ac1442d
* 11ed-b298-96b216d3-9cc2-357537df1aa0
*/
UUID 크기 최소화하기
우선, 각 필드를 구분하기 위한 dash('-')는 의미가 없으므로 제거합니다. 그러면 총 32개의 문자열이 생성됩니다.
이걸 DB에 저장하게 되면 CHAR(32)의 필드를 PK로 사용하게 됩니다. 일반적으로 사용하는 BIGINT(8바이트)보다 4배 큽니다.
MySQL의 경우 PK로 지정하면 자동으로 인덱스로 지정되므로, 4배나 큰 값을 계속해서 저장하는 것입니다.
이를 개선하기 위해, CAHR(32)가 아닌 Binary형태로 변환해 BINARY(16)으로 저장하면 크기를 절반으로 줄일 수 있습니다.
물론, DB에 Binary 타입으로 저장하게 되면, 앞으로 UUID를 조회할 땐 사람이 식별할 수 있는 값으로 변환하는 과정이 필요합니다.
이와 같은 변환 과정을 담을 Util 클래스를 생성해 UUID와 관련한 책임을 분리해 필요한 곳에서 사용할 수 있도록 작성했습니다.
private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
//UUID 생성 후 binary 변환
static public byte[] createUUID() {
UUID uuidV1 = Generators.timeBasedGenerator().generate();
String[] uuidV1Parts = uuidV1.toString().split("-");
String sequentialUUID = uuidV1Parts[2]+uuidV1Parts[1]+uuidV1Parts[0]+uuidV1Parts[3]+uuidV1Parts[4];
String sequentialUuidV1 = String.join("", sequentialUUID);
ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
bb.putLong(Long.parseUnsignedLong(sequentialUuidV1.substring(0, 16), 16));
bb.putLong(Long.parseUnsignedLong(sequentialUuidV1.substring(16), 16));
return bb.array();
}
//사람이 식별 가능한 문자열로 변환
public static String bytesToHex(byte[] bytes) {
char[] hexChars = new char[bytes.length * 2];
for (int i = 0; i < bytes.length; i++) {
int v = bytes[i] & 0xFF;
hexChars[i * 2] = HEX_ARRAY[v >>> 4];
hexChars[i * 2 + 1] = HEX_ARRAY[v & 0x0F];
}
return new String(hexChars).toLowerCase();
}
16bytes를 가진 배열에 각 8bytes씩 올바른 값을 넣기 위해 2번에 나눠서 변환을 진행합니다.
반대로 문자열로 바꿀 땐, 비트 연산을 통해 문자열로 변환합니다. 일반적으로 UUID는 소문자이므로 toLowerCase를 사용했습니다.
테스트 결과는 다음과 같습니다.
String:11edb29c6c37028d9c82b941a5c00fb9 | Bytes:[17, -19, -78, -100, 108, 55, 2, -115, -100, -126, -71, 65, -91, -64, 15, -71]
String:11edb29c6c38fe5e9c827d6ee4439732 | Bytes:[17, -19, -78, -100, 108, 56, -2, 94, -100, -126, 125, 110, -28, 67, -105, 50]
String:11edb29c6c39256f9c82df8576052ea9 | Bytes:[17, -19, -78, -100, 108, 57, 37, 111, -100, -126, -33, -123, 118, 5, 46, -87]
String:11edb29c6c3925709c82bd4c36c22f1f | Bytes:[17, -19, -78, -100, 108, 57, 37, 112, -100, -126, -67, 76, 54, -62, 47, 31]
String:11edb29c6c3925719c8269f81862b3d6 | Bytes:[17, -19, -78, -100, 108, 57, 37, 113, -100, -126, 105, -8, 24, 98, -77, -42]
String:11edb29c6c3925729c8217b59d47540e | Bytes:[17, -19, -78, -100, 108, 57, 37, 114, -100, -126, 23, -75, -99, 71, 84, 14]
String:11edb29c6c3925739c828dae3055d326 | Bytes:[17, -19, -78, -100, 108, 57, 37, 115, -100, -126, -115, -82, 48, 85, -45, 38]
String:11edb29c6c3925749c82d392bf96b641 | Bytes:[17, -19, -78, -100, 108, 57, 37, 116, -100, -126, -45, -110, -65, -106, -74, 65]
String:11edb29c6c3925759c825bba1cb2edbb | Bytes:[17, -19, -78, -100, 108, 57, 37, 117, -100, -126, 91, -70, 28, -78, -19, -69]
String:11edb29c6c3925769c82d32a7898ed47 | Bytes:[17, -19, -78, -100, 108, 57, 37, 118, -100, -126, -45, 42, 120, -104, -19, 71]
어느 정도 순차적이면서, 16bytes로 변환되어 저장되고 있는 모습을 확인할 수 있습니다.
실제 성능 비교
테스트를 통해 실제 성능을 비교해 보겠습니다. 케이스는 다음과 같습니다.
1. 일반적인 Sequence PK(Auto Increment)
2. UUID: CHAR(32)
3. Sequential UUID: BINARY(16)
테스트 환경은 다음과 같습니다.
Spring boot 2.7.8
Spring Data JPA
MySQL 8.0.30
맨 처음 연결할 때 잡다한 설정에 따라 시간 편차가 발생할 수도 있으니, 다른 테이블에 1000개의 데이터를 삽입한 후, 테스트를 진행했습니다.
데이터 삽입
데이터 조회에 특화되어 있는 MySQL의 특성에 따라, 랜덤 한 값이 삽입되면 비교적 낮은 성능을 보일 수밖에 없습니다.
데이터가 삽입될 때마다 B-트리에 재배치(인덱스 재정렬)가 일어나기 때문입니다. 이에 대한 성능 테스트를 진행했습니다.
100만 개의 데이터를 삽입했고, 10회 진행 결과에 대한 평균입니다.
종류 | 결과(sec) |
Sequence PK | 202.7sec |
UUID | 313.3sec |
Sequential UUID | 288.9sec |
테이블에서 보시는 것과 같이 Sequential한 PK가 가장 빠른 속도를 보여줬습니다.
Sequential UUID는 일반 UUID보다 빠르게 동작하는 것을 알 수 있습니다. (생각보다 차이가 적긴 합니다..)
데이터가 있는 상태에서 추가로 데이터를 삽입한다면 B-트리의 재구성이 필요해 차이가 더 벌어질 것으로 예상하고 추가 데이터를 삽입했습니다.
기존 데이터가 존재하는 상황에서 10만개의 데이터를 추가로 삽입하는 테스트 10회 평균입니다.
종류 | 결과(sec) |
Sequence PK | 21.2sec |
UUID | 34.8sec |
Sequential UUID | 26.7sec |
빈 테이블에 데이터를 삽입할 때와 비슷한 차이를 보이고 있습니다.
테스트 결과로 기존 UUID V1을 Sequential하게 바꾸는 것이 어느정도 효과가 있는 것을 알 수 있습니다.
데이터 조회
각 테이블 별 100만 건의 데이터가 있는 상태에서, 다음 2가지 상황에서 데이터를 조회할 때 성능을 테스트해 보겠습니다.
1. 맨 마지막에 삽입된 100만 번째 데이터 조회
2. offset 500000, limit 1000 조건을 추가해, 테이블 풀스캔 후 일부 데이터 조회
10회 진행 결과에 대한 평균입니다.
종류 | 1번 테스트 결과(ms) | 2번 테스트 결과(ms) |
Sequence PK | 0.8ms | 72.8ms |
UUID | 1.4ms | 90.2ms |
Sequential UUID | 1.1ms | 78.6ms |
정리
이상으로 UUID에 대해서 알아보고, 최대한 효율적으로 사용하는 방법에 대해서 알아보았습니다.
UUID 자체가 고유성을 갖는 값에 초점을 맞춘 것이기 때문에, Sequence에 유리한 MySQL에는 어울리지 않는 것이 사실입니다.
Sequential UUID도 어느 정도 순차적인 값을 갖지만 절대적인 것은 아니고, 일반적인 PK보다 성능이 낮은 것은 사실입니다.
하지만, 보안적인 측면에서는 이점이 분명히 존재합니다. 이러한 것을 고려하고, 우여곡절 끝에 성공적으로 프로젝트에 적용할 수 있었습니다.
Reference
'프로젝트 > NEMODU' 카테고리의 다른 글
[NEMODU] 메인 화면 조회 API 개선하기 (0) | 2023.04.10 |
---|