UUID Primary Key vs Integer Primary Key

moodTRBL·2026년 2월 22일

컴퓨터 생활

목록 보기
4/4
post-thumbnail

Auto Increment 보안 문제

백엔드 애플리케이션을 개발할 때 나는 다음과 같은 패턴의 URL을 많이 만들었고, 많은 블로그에서 URL을 이런 식으로 구성하는 예제를 많이 보았었다. /mypage/user/1 이런식으로 사용하여 접속할 것이다.

/mypage/user/{userId}

이 URL에 대해 설명하자면, 특정 유저에 대한 상세 정보를 보기 위해 userId(테이블 primary key) 을 이용하여 접속하는 방법이다. 데이터를 unique하게 식별하기 위해서는 테이블에서 유일한 값인 primary key를 사용하는 것이 합리적이기 때문이다.

이 방법은 보안 문제가 존재한다. 단순히 primary key를 사용했다가 이유일까?

primary key를 사용해서 문제가 된 것이 맞다. 하지만 더 정확하게 말하자면 auto increment를 사용하는 정수형 primary key를 사용한 것이 문제이다.

테이블이 생성될 때마다 primary key는 1, 2, 3, ... 으로 순차적으로 값이 증가하게 된다. 따라서 떠한 userId만 알면 악의적인 사용자가 값을 하나씩 늘리거나 줄이면서 다른 정보에 쉽게 접근할 수 있을 것이다.
또한 현재 테이블의 누적 행 개수등의 민감한 정보들도 추론을 통해 충분히 알아낼 수 있을 것이다.

UUIDv4

보안 문제 발생의 원인이 다른 식별자가 추론가능하다는 문제였으므로 식별자를 추론하지 못하게 난수로 바꾸면 될 것이다. 이를 위해 자주 사용되는 것이 UUIDv4이다.

그러나 생각을 더 해볼 필요가 있다. 왜 데이터베이스는 굳이 auto increment라는 것을 만들어서 식별자 값이 하나씩 증가되도록 만들었을까? 바로 쓰기 성능때문이다.

데이터베이스는 b-tree를 사용하고 이는 디스크에서 구성되며 정렬된 상태를 유지하는 자료구조이다. 만약 b-tree
에 계속 더 큰 값이 들어와서 우측 끝의 리프 노드(페이지)가 가득 차게 되면 데이터베이스 엔진은 디스크에서 완전히 새로운 빈 페이지를 하나 할당받는다. 이런 경우는 단순히 페이지 하나를 추가하면 끝나는 동작이다.

업로드중..

그러나 정렬되지 않은 값이 들어오게 되면 달라진다. b-tree의 중간 어딘가로 데이터가 저장되어야 하면 페이지 중간에 빈 공간을 확보해야 하므로 새 페이지를 만드는 것에 더해, 기존 데이터의 대규모 물리적 복사 및 이동이 강제로 발생하게 된다.

또한 순차적으로 디스크 헤드를 옮겨가며 접근하는 것이 랜덤한 위치로 디스크 헤드를 이동시키는 것보다 훨씬 빠를 것이다.

UUIDv7

그렇다면 위에서 발견한 모든 문제를 해결하기 위해서는 난수지만 값이 순차적으로 증가하여 쓰기 성능을 떨어뜨리지 않는 값을 찾아야 한다. UUIDv7은 이 문제를 해결한다.

전체가 무작위 난수로 이루어진 기존 UUIDv4와 달리, UUIDv7은 밀리초 단위의 생성 시간을 앞부분에, 무작위 난수를 뒷부분에 배치한다. 따라서 이 식별자들을 데이터베이스에서 정렬하면 자연스럽게 시간의 흐름에 따른 정렬이 이루어지는 것을 확인할 수 있다.

따라서 데이터베이스는 언제나 b-tree의 가장 우측 끝 리프 노드에만 데이터를 추가하게 되며, 무거운 b-tree 연산인 페이지 분할이 원천 차단된다. 그러면서 난수의 특징은 유지하니까 이전에 말했던 정수 키의 보안문제는 해결하고 UUID의 성능문제도 해결하는 것이다.

코틀린 JPA 기준으로 다음과 같이 사용할 수 있다.

@Entity
class Member {
    @Id
    @UuidGenerator(style = UuidGenerator.Style.TIME)
    @Column(name = "id", updatable = false, nullable = false, columnDefinition = "BINARY(16)")
    var id: UUID? = null
        protected set

    ...
}

남아있는 문제들

UUIDv7로 바꿈으로써 보안과 쓰기 성능 문제를 해결했다. 그러나 아직 해결하지 못한 문제가 존재한다. 바로 읽기 성능이다.

기존 정수형 기본키인 BIGINT는 8바이트의 공간을 차지한다. 반면 UUID는 문자열 형태인 VARCHAR(36)으로 저장할 경우 4배 이상의 공간이 필요하며, BINARY(16)으로 저장하더라도 여전히 정수형보다 2배 더 큰 공간을 소모한다.

데이터베이스 시스템에서 디스크 접근 연산은 비용이 매우 높기 때문에, 메모리등을 최대한 캐시로 활용하여 디스크 I/O를 줄이는 것이 중요하다. 하지만 데이터의 크기가 커지면 캐시에 저장할 수 있는 데이터 건수가 감소하므로 캐시 효율이 떨어진다.

또한 이 크기 문제는 테이블에 생성되는 모든 세컨더리 인덱스에서도 단점으로 작용한다. 세컨더리 인덱스는 리프 노드에 실제 데이터의 물리적 주소가 아닌 기본키 값을 저장한다. 즉, 세컨더리 인덱스에서 조건에 맞는 기본키를 먼저 찾고, 그 값을 이용해 클러스터형 인덱스를 한 번 더 탐색하여 데이터를 가져오는 구조이다.

따라서 기본키의 물리적 크기가 커지면 테이블에 속한 모든 세컨더리 인덱스의 용량까지 커지게 된다.

그래서 써도되나?

역시 모든 기술이 그러하듯 UUIDv7도 식별자 추론을 방지하는 보안성과 b-tree의 쓰기 성능을 확보하는 대신 데이터베이스의 저장 공간과 캐시 효율성을 일부 양보하는 트레이드오프가 존재한다.

조사하면서 찾은 내용은 정수형 primary key대신 UUIDv7을 꽤나 사용하고 있다는 정보였다. 최근 IETF 표준으로 지정되기도 하였고, 특히 분산 서버 환경에서 강점이 있다고 한다.

분산 환경에서 노드 간의 식별자 충돌을 방지하기 위해 별도의 인프라(ZooKeeper, Redis 등)를 구성하거나 클락 동기화 문제를 관리하기 보다 그냥 UUIDv7을 사용하여 외부 인프라 의존성 없이 라이브러리 추가만으로 즉시 안전하게 사용할 수 있다는 장점이 있기 때문이다.

또한 데이터 크기 문제 또한 하드웨어의 발전으로 어느정도 커버가 된다고는 하지만 요즘 램값이 많이 비싸기 때문에 이는 아직도 문제가 된다고 생각한다.

profile
항상 감사합니다

0개의 댓글