예전에 신규 프로젝트를 맡아 설계부터 진행한 적이 있다. DB 테이블 설계를 할 때 평소같았으면 PK 값을 기본 Integer 를 사용했을텐데, 새로운 방식을 사용해보고 싶어서 UUID 로 적용해보고자 했다. 그 땐 몰랐지만 운영을 해오며 겪었던 문제점과 힘들었던 점에 대해 정리하고자 한다.
PK 를 UUID 로 설정한 이유
당시 생성해야 했던 테이블이 7개 정도 되었고, 각 테이블의 PK 를 UUID 로 설정했다. 보안상의 이유가 가장 큰 부분을 차지했는데, 이 서비스가 외부로 노출이 되어야 했기 때문인데, PK 가 Auto-increment 하게 설정되어 있으면 외부로 데이터가 쉽게 노출될 리스크가 크다. 트렌비 홈 화면에 노출될 데이터였기 때문에 좀 더 보안에 신경을 쓰려고 했다. 괜히 ID 값이 노출되어서 다른 회사에서 지표로 활용해버리면 안되기 때문이다.
또한 이 방법을 한번도 사용해본적이 없어서 한번 사용해보고 싶다는 도전 정신이 있기도 했다. (지금은 꽤나 후회를 하고 있지만..)
데이터
잘 운영을 해오다가.. 생성했던 7개의 테이블 중 하나에서 운영상 이슈가 문제가 발생했다. 먼저 문제가 되는 테이블에 대한 소개를 하자면 이력이 쌓이는 형식의 데이터이다. 하나의 Group에 대해 상태값이 변경되면서 새로운 Row 가 생성될 수 있다. 예를 들어, Group 100번에 대해 2023-01-01 에 UUID_1 으로 row 가 생성되었다. Group 100 의 상태가 CREATED -> COMPLETED 로 변경되면서 새로운 row (id: UUID_2) 가 추가되었다.
ID | GROUP ID | Status | CreatedAt | ... |
UUID_1 | 100 | CREATED | 2023-01-01 | ... |
UUID_2 | 100 | COMPLETED | 2023-01-02 | ... |
UUID_3 | 100 | DELETED | 2023-01-04 | ... |
UUID_4 | 200 | CREATED | 2023-01-04 | ... |
... | ... | ... | ... | ... |
Group 별 최신 데이터 가져오기
순서가 없는 랜덤한 UUID 를 PK 로 적용해두었기 때문에 최신값을 가져오는 쿼리의 경우에는 createdAt 을 기반으로 설정해두었다. 각 Group 별 최신 Row 를 가져오기 위해 기존에는 아래 쿼리를 활용했다. 서브 쿼리를 활용하여 각 group 의 최신 created_at 을 가져오고, createdAt 으로 맵핑을 시켜주었다. 이 쿼리를 작성할 때의 전제 조건은 중복 created_at 이 없을 것이란 전제가 있었다. (created_at 을 timestamp 형식으로 만들어두었기 때문에 설마 millisecond 까지 똑같은 데이터가 생길까 라는 생각으로...)
SELECT *
FROM table AS a
WHERE
a.created_at IN (
SELECT MAX(b.created_at)
FROM table AS b
GROUP BY group_id
)
데이터가 적을 때는 문제가 되지 않아서 테스트 케이스도 통과하고 몇 개월 운영이 잘 되었다. 하지만 갑자기 이 쿼리를 활용하는 배치 시스템이 중단되었고, 그 원인을 찾아가보았다.
역시 전제를 깔아두면 안된다
위 쿼리의 전제를 깨는 데이터가 생성되었다. created_at 이 완전, 완전 똑같은 row 가 발생한 것이다. 서브 쿼리에서 각 group 별 최신 created_at 의 데이터를 가져오긴 했지만 본 쿼리에서 created_at 을 기준으로만 쿼리를 하기 때문에 원하는 group id 에 해당하는 row 를 가져오지 못했다.
서브 쿼리의 결과로 아래와 같은 groupBy 데이터가 추출되었고 원하지 않게 SUB_QUERY_UUID_1 의 Row 가 선택되어 비즈니스 로직을 제대로 처리하지 못했다.
ID | GROUP ID | Status | CreatedAt |
SUB_QUERY_UUID_1 | 100 | DELETED | 2023-01-04 |
SUB_QUERY_UUID_2 | 200 | CREATED | 2023-01-04 |
고쳐보자
결국 문제는 created_at 을 기준으로 쿼리를 했기 때문이고 incremental 한 PK ID 였다면 쿼리를 작성하기 훨씬 편했을 것이고 이런 문제도 발생하지 않았을 것이다 (물론 쿼리를 더 자세하게, 세세하게 작성했다면 이런 문제가 발생하지 않았을 수도 있다).
쿼리를 아래처럼 수정하여 이 문제를 해결했다. (결국 ID 비교가 필요하다.)
SELECT *
FROM table AS a
WHERE created_at = (
SELECT MAX(created_at)
FROM table AS b
WHERE a.id = b.id
)
원래는 이렇게 고치려고 했다
최신 데이터를 가져온다는 의미를 좀 더 직관적으로 전달할 수 있도록 orderBy 와 limit 의 조합으로 문제를 해결하고자 했다. 하지만 사용하고 있는 QueryDSL 에 한계가 있었다. 서브쿼리 내 LIMIT 을 걸게되면 제대로 작동이 안된다는 것이었다.
SELECT *
FROM table AS a
WHERE a.id = (
SELECT b.id
FROM table AS b
WHERE a.id = b.id
ORDER BY created_at DESC
LIMIT 1
)
위와 같이 QueryDSL 에 작성을 하고 동작을 해보면 아래와 같은 에러가 발생한다.
Caused by: org.h2.jdbc.JdbcSQLDataException: Scalar subquery contains more than one row
다른 곳에서 찾은 UUID 의 장점과 단점
UUID 를 사용하는 이유로는 대부분 보안상의 이유가 가장 크다고 말하고 있고 그 외에도 장점과 단점에 대해 정리하는 내용을 가져왔다.
- (장점) 데이터베이스가 여러 개인 경우, 하나의 ID 가 여러 데이터베이스에서도 고유한 값이라고 볼 수 있다.
- (장점) UUID 는 stateless 하다 (데이터를 DB 에 insert 하기 전에 ID 값을 알 수 있다).
- (단점) 저장 공간이 많이 필요하다.
회고를 하며
- 이런 리서치를 프로젝트 전에 좀 더 했더라면 좋았을텐데 라는 아쉬움이 남는다.
'에러 핸들링' 카테고리의 다른 글
Port 8080 was already in use (feat. 8080 포트 죽이기) (0) | 2023.04.03 |
---|---|
Config data resource ... via location ... does not exist (0) | 2023.03.08 |
ArithmeticException 해결하기 (0) | 2023.02.12 |
UriComponentsBuilder 한글 적용이 잘 안될 때 (0) | 2023.02.09 |
Could not open JPA EntityManager for transaction (0) | 2023.02.03 |
댓글