📈 Database

UUID vs Auto Increment 중 PK 선택하기

loose 2022. 11. 15. 05:11
반응형

 

우리의 경험에 따르면 95%의 경우
PK 선택은 항상 Auto Increment Integer 여야 합니다.

가독성은 단순함을 이끕니다. 
숫자는 쓰기 쉽고, 기억하기 쉽고, 의사소통하기 쉽습니다. 

 

사건의 발단

 

프로젝트 진행 과정에서 Auto Increment로 PK 사용으로 발생될 수 있는 문제점이 있었다

Increment PK는 정수형으로 보통 1, 2, 3, 4 순서로 순차적 채번이 된다는 특징이 있다.

만약 A라는 사용자가 게시물(PK는 1)을 올린다고 가정하고 B라는 악의적인 사용자가 본인의 글을 수정을 할 때 API에 요청 데이터 중 PK ID만 1로 바꾸면 1번 글이 수정된다는 취약점이 존재한다.

그래서 비즈니스 로직에서 해당 게시물을 쓴 소유자와 현재 수정하려는 사용자가 일치한 지를 파악하는 로직을 작성해야 했다.

 

간단한 슈도 코드를 작성하면 아래와 같다.

if(게시물의 소유자 == 현재 사용자){
	수정 로직 작성
}

여기까지만 보면 복잡한 로직은 없어보이지만 깊이가 깊어질수록 문제점이 생긴다.

 

예를들어 만약에 내 게시물의 댓글을 내가 지울 수 있는 권한이 있는 경우라고 가정했을 때 이 경우에도 타 사용자가 내 게시물의 댓글을 악의적으로 지울 수 있는 가능성을 배제해야한다.

 

다만, 댓글은 내가 쓴 게시물과는 달리 '소유자'가 내가 아니고 다른 사람일 수 있기 때문에 첫번째로 해당 게시물의 댓글이 내 게시물에 속해있는 것인지 우선적으로 확인해야한다.

그러므로 서버에 삭제할 댓글에 대한 정보해당 게시물에 대한 정보 2개를 요청 값으로 보낸다고 상상해보자.

 

그럼 서버에서 슈도 코드는 이렇게 작성할 수 있다.

A = 삭제할 댓글 정보(요청값)
B = 해당 게시물 정보(요청값)

if(A를 DB에서 조회해서 가져온 게시물 FK == B의 PK){
	삭제 로직 작성
}

댓글이 속해 있는 게시물을 DB에서 가져와 현재 게시물과 비교해서 동등할 경우에 삭제하는 코드를 만들었다.

 

하지만 문제는 여기서 끝나지 않는다.

요청자는 악의적으로 해당 게시물에 대한 정보조차 수정했을 수 있으니 첫번째 슈도코드처럼 그 게시물의 소유주가 현재 사용자와 일치한 지를 확인해야 비로소 댓글을 지울 수 있는 로직을 작성할 수 있었다.

 

그래서 완성된 슈도 코드를 보면 이렇다.

A = 삭제할 댓글 정보
B = 게시물 정보

if(A를 DB에서 조회해서 가져온 게시물 FK == B의 PK){
	if(게시물의 소유자 == 현재 사용자){
		삭제 로직 작성
	}
}

 

겨우 삭제 한 번 하겠답시고 이러한 복잡한 검증 절차가 필요한걸까라는 의구심이 들지 않는가?

만약에 PK가 참조하기 어려운 랜덤값이라면 API 요청의 취약점은 어느정도 해결 되지 않을까?

이러한 문제들은 말 그대로 정수형 PK ID는 추측이 가능하다라는 취약점이 존재하기 때문이다.

 

이러한 취약점이 실제로 스팀에 터진적이 있는데 검증 절차의 취약점 때문에 벌어진 사건이 바로 watch paint dry 사건이다.
"16세 소년은 어느 날 정식 승인 절차를 무시하고 스팀 상점에 게임을 업로드 할 수 있는 스팀의 보안 취약점을 발견했다. 이 소년은 몇 달동안 이 사실을 밸브 코퍼레이션 측에 알렸으나 번번히 무시당했고, 문제의 심각성을 널리 알리기 위해 'watch paint dry'라는 게임을 만들어 본인이 알아낸 보안 취약점을 통해 스팀의 승인 절차 없이 스팀의 새 게임 목록에 등록했다."

 

그래서 결국에는 PK ID를 Increment 방식이 아닌 랜덤키를 반환해주는 UUID를 사용하면 해결 될 문제라고 보였다.

하지만 과연 그럴까?

UUID를 PK로 사용하기

 

UUID는 위와 같은 특징을 갖고 만들어지며 

무려 340,282,366,920,938,463,463,374,607,431,768,211,456개의 경우의 수가 존재한다.

 

그래서 Increment PK에 비해 악의적인 사용자가 ID를 추측해서 요청하는 것이 불가능에 가깝다.

또 UUID를 사용함으로써 얻을 수 있는 장점이 하나 더 있는데, Increment PK의 단점 중 DB 병합과정에서 서로 다른 DB가 같은 키를 사용한다면 충돌이 발생할 수 있다는 것이다. 하지만 UUID는 충돌이 발생하지 않는 장점이 존재한다.

 

UUID는 위와 같은 장점들이 존재하지만 PK로 사용했을 때 단점이 너무 크다.

대표적인 단점은 아래와 같다.

 

의미적으로 알아보기 힘들다.

 

/posts/123
/posts/df6fdea1-10c3-474c-ae62-e63def80de0b

뭐가 더 보기 쉬운가? 말할 것도 없다.

위의 값은 123번째 글이라는 명확한 표현이 가능하지만

아래 값은 기억하기도 힘들뿐더러 관리도 어렵다.

정렬이 안된다.

 

테이블을 정렬하려고 할 때 UUID를 기준으로 정렬하는 것은 어렵다.

당연히 String 값은 정렬이 힘들기 때문에 시간 별로 정렬하는 방법을 먼저 찾게 될 것이다.

용량이 너무 크다.

 

정수형 PK에 비해 길이가 길어서 용량이 너무 많이 할당되고 그 뿐만 아니라 해당 테이블과 연관된 테이블에서 FK를 쓴다면 또 그만큼의 용량이 추가가 되는 것이다. 또한 인덱스는 PK를 기준으로 만들어지는데, 인덱스 용량도 그만큼 커진다고 볼 수 있다.

 

해결방법

 

근본적으로 프로그램 설계에 있어 Increment 방식이든 UUID든 어떠한 방식으로든 PK는 외부에 절대 노출되어서는 안되는 것이 베스트라고 한다.

그래서 나와 같은 문제를 안고있을 때 가장 현명한 솔루션은 PK와 UUID를 모두 사용하는 것이다.

데이터베이스가 정수형 Increment를 PK로 데이터 관계를 관리하도록 만들고 외부에 노출시킬 API용 ID가 필요하다면 보조키로 UUID로 채워진 컬럼을 추가한다.(삽입 시 트리거로 사용)

다만, 이 방법은 문제를 회피하는 것이고 문제 해결에 대한 완벽한 방어를 말하진 않는다.
왜냐면 각각의 방식이 Trade-Off가 존재하기 때문이다. 해당 설명은 밑에서 다시 하겠다.

다시 주제로 넘어가서 UUID를 외부로 노출시킬 때는 URL을 좀 더 친화적으로 Slug를 만들 필요가 있다.

Slug(슬러그) - 슬러그
(Slug)란 원래 신문이나 잡지 등에서 제목을 쓸 때, 중요한 의미를 포함하는 단어만을 이용해 제목을 작성하는 방법을 말합니다

아래와 같이 UUID에 Semantic한 값을 담아서 표현할 수 있다.

 

보조키라 해도 String 형태의 UUID를 그대로 사용하는 것은 잘못된 것이라고 한다.

데이터베이스 메커니즘에서 Increment PK도 Long형의 8바이트 정수로 저장되는 데, 이 점을 활용해서 16바이트의 UUID 보다는 8바이트 정수를 사용하는 것도 고려해보라고 한다.

8바이트라고 해도 경우의 수가 아래처럼 많긴하다.

8 Byte Long의 경우의 수 –9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807

다만 UUID의 비해선 한참이나 경우의 수가 부족하기 때문에 중복에 대한 취약점이 더 쉽게 드러난다.

UUID 경우의 수 - 340,282,366,920,938,463,463,374,607,431,768,211,456

이외에도 좋은 대안을 찾았는데 Youtube의 UID를 사용하는 것이다.

 

GitHub - gpslab/base64uid: Generate UID like YouTube

Generate UID like YouTube. Contribute to gpslab/base64uid development by creating an account on GitHub.

github.com

Youtube 역시 아래와 같이 의미적으로는 좋은 URL이 될 수는 없지만 UUID보단 짧게 사용할 수 있어서 그나마 가독성이 뛰어난 편이다.

 

TSID (Time-Sorted Unique Identifiers)

 

GitHub - f4b6a3/tsid-creator: A Java library for generating Time-Sorted Unique Identifiers (TSID).

A Java library for generating Time-Sorted Unique Identifiers (TSID). - f4b6a3/tsid-creator

github.com

유튜브에서 사용하는 UID처럼 트위터에서 만든 Snowflake를 참조한 TSID라는 것이다.

장점을 나열하자면 아래와 같다.

  • 8바이트(UUID는 16바이트)로 상대적으로 공간효율성이 높다.
  • 시간에 따라 점점 커지는 값으로 정렬이 가능하다.

  • 숫자지만 base-62로 encode하면 외부적으로는 시간에 대한 유추도 불가능하다.(근데 이게 Youtube UID 같기도하고..?)

하지만 이 또한 길이가 꽤 돼서 PK로 쓸 수 있는 조건이 되는가는 또 다른 문제다.

 

The best UUID type for a database Primary Key - Vlad Mihalcea

Learn what UUID type works best for a database Primary Key column and why a time-sorted TSID is more effective than the standard UUID.

vladmihalcea.com

위 글을 보면 "it fits better as a B+Tree index key."라는 표현을 쓰는데 TSID가 B+Tree에 적합하기 때문에 기본키로 사용하는 것도 꽤 나쁘지 않다라고 한다.

여기서 꽤 궁금해진다 정수형(PK) + TSID와 그냥 TSID(PK)를 비교해봤을 때 성능 테스트를 한번 해보고 싶기도 하다.(해볼분?)

어떤 것을 선택해야 하나?

 

우선 결론적으로 UUID를 선택할 지 Increment PK를 선택할 지는 프로그램마다 성격이 다르기 때문에 개발자가 선택해야 될 문제라는 것이다.

왜냐면 각각의 PK 방식은 Trade-Off가 존재하기 때문이다.

 

Increment PK가 노출이 취약하다는 특징으로만 봤을 때 만약 그 시스템이 고객 시스템이 아닌 사내 프로그램, 관리자 프로그램 같은 경우에는 악의적인 사용자가 있을리 없다는 가정을 할 수 있기 때문에 굳이 UUID를 선택할 이유가 없을 수도 있다.

 

또 예를들어 익명 자유 게시판과 같이 누구나 쓸 수 있고 지울 수 있는 게시판이라면 UUID가 아니라 각각의 게시글, 댓글마다 비밀번호로 관리하게 해야한다. 이런 경우 UUID는 고유의 ID로서 활용되지 않기 때문이다. UUID 값이 모든 사용자에게 노출이 되므로 UUID의 장점을 이용할 수 없기 때문이다.

 

글의 앞단에서도 설명했듯 95%의 상황에서는 Increment PK를 사용해야하고 보조키로 UUID를 사용하는게 대안이라고 한다. 그럼 5%의 상황은 뭘까? 5%의 상황은 UUID를 기본키로 사용하는 것인데 그렇게 하는 이유는 데이터베이스 병합 시에 키 충돌 문제가 발생할 것 같을때만 쓰라고 한다.(극히 드물다)

결론적으로는 가장 이상적인 솔루션은 PK를 UUID로 사용하고 절대 외부로 노출 시키지 않는 것이라고 한다. 하지만 말 그대로 이상적이기 때문에 그렇게 하면 제대로 개발을 할 수 없다. PK 자체를 외부로 꺼내지 않으면 API 요청을 할 수 없기 때문이다.

그렇기에 실무에서는 Increment와 UUID를 같이 쓰거나 Increment만을 쓰는 것을 고려하는 것이 좋다.

참고

 

UUID or GUID as Primary Keys? Be Careful!

You can use of UUIDs as the primary key to avoid database scale problems. But should you? I propose an alternative.

tomharrisonjr.com

 

 

SQL Primary Key - UUID or Auto Increment Integer / Serial?

Tianzhou Aug 24, 2021 · 4 min read

www.bytebase.com

728x90