UUID vs Auto Increment 중 PK 선택하기
우리의 경험에 따르면 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를 사용하는 것이다.
Youtube 역시 아래와 같이 의미적으로는 좋은 URL이 될 수는 없지만 UUID보단 짧게 사용할 수 있어서 그나마 가독성이 뛰어난 편이다.
TSID (Time-Sorted Unique Identifiers)
유튜브에서 사용하는 UID처럼 트위터에서 만든 Snowflake를 참조한 TSID라는 것이다.
장점을 나열하자면 아래와 같다.
- 8바이트(UUID는 16바이트)로 상대적으로 공간효율성이 높다.
- 시간에 따라 점점 커지는 값으로 정렬이 가능하다.
- 숫자지만 base-62로 encode하면 외부적으로는 시간에 대한 유추도 불가능하다.(근데 이게 Youtube UID 같기도하고..?)
하지만 이 또한 길이가 꽤 돼서 PK로 쓸 수 있는 조건이 되는가는 또 다른 문제다.
위 글을 보면 "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만을 쓰는 것을 고려하는 것이 좋다.
참고