외래키 대신 UUID를 사용하여 연관관계의 복잡도 해소하기

728x90

상황

쇼핑몰 프로젝트를 진행하면서 회원과 상품과 같은 주요 테이블에 지나치게 많은 연관관계를 집중시킨 결과, 몇 가지 문제가 발생했습니다.

 

위 이미지는 설계 단계에서 작성한 초기 ERD의 회원 테이블 부분입니다.

개발 초기에는 큰 문제가 없었지만, 테이블 간 의존도가 높아지면서 아래와 같은 문제들이 발생했습니다.

요구사항 변경의 어려움

 

회원 테이블은 구매 내역, 장바구니, 리뷰 등 다양한 테이블과 연관되어 있었습니다.

이런 상황에서 상품에 대한 찜하기 기능을 도입하려고 하자, 기존에는 특정 상품에 대한 구매 내역과 리뷰만 로드했었지만 이제는 추가적으로 찜하기 테이블을 조인해야 했습니다.

 

이로 인해 새로운 기능과 기존 기능 간 의도치 않은 상호작용을 방지하기 위해 기존 코드를 반복적으로 테스트해야 했고, 데이터 일관성 유지에도 많은 번거로움이 있었습니다.

 

불필요한 조인으로 인한 성능 문제

 

DB 엔진이 최적화된 방법으로 조인을 수행하기를 기대했지만, 연관관계가 많아지면서 처리해야 할 레코드 수가 증가해 성능 저하가 발생하였습니다.

 

또한 유저 수가 많아질 경우, 여러 트랜잭션이 동시에 조인 쿼리를 실행하게 되면서 Race Condition이 발생할 가능성도 커졌습니다.

 

보안 문제

 

기존의 Auto Increment 방식의 ID 값은 순차적으로 증가하는 번호 형태여서 API 설계가 간단했습니다. 예를 들어, 첫 번째 회원의 memberId가 1이면 다음 회원은 2가 되는 방식이었습니다.

// 예시 코드
@DeleteMapping("/{memberId}")
public void deleteMember(@PathVariable("memberId") Long memberId) {
    memberService.deleteMember(memberId);
}

 

그러나 누군가 특정 memberId가 존재한다는 것을 알게 되면, 연속된 숫자를 통해 다른 사용자의 ID 값을 쉽게 추측할 수 있는 문제가 있습니다.

 

물론 실제로 위와같이 직접적인 회원 삭제 로직을 처리하지 않겠지만, 민감한 데이터에 대해서는 ID를 노출하지 않도록 하고, 추가적인 인증 절차를 마련할 필요가 있었습니다.

 

해결 과정

1. Auto Increment ID와 UUID의 Trade-off 분석 및 선택

먼저, 기본키로 Auto Increment ID와 UUID 중 어느 것을 사용할지 분석했습니다.

 

UUID의 장단점

  • 장점:
    • 경우의 수가 엄청나게 많아 공격자가 쉽게 추측하거나 중복된 값이 발생할 가능성이 없습니다.
    • 분산 서버를 도입해도 충돌 없이 고유한 ID를 생성할 수 있습니다.
  • 단점:
    • 용량이 큽니다. UUID는 일반적으로 128비트(16바이트) 크기를 차지하는 반면, Auto Increment ID는 4바이트(32비트) 또는 8바이트(64비트) 크기의 정수형 타입입니다. 이로 인해 FK로 사용될 경우에도 큰 용량을 차지하게 됩니다.
    • 인덱싱과 검색 성능이 떨어질 수 있습니다. 랜덤한 UUID는 B+Tree 같은 인덱스 구조에 적합하지 않습니다. UUID가 랜덤하게 삽입되면 인덱스 페이지가 채워지는 비율이 낮아지고, 이로 인해 캐싱 효율 저하, 디스크 사용량 증가, 성능 저하가 초래될 수 있습니다.

특히, 현재 사용 중인 MySQL의 경우 기본적으로 테이블에 Clustered Index를 적용하고 있습니다.

 

Clustered Index는 데이터를 순차적인 인덱스로 저장하는데 최적화된 방식입니다.

랜덤한 UUID값을 인덱스로 사용하게 되면 데이터를 추가할 때마다 구조를 재배치해야 하므로 성능에 큰 영향을 미치게 될 것입니다.

 

 

 Auto Increment의 장단점

  • 장점:
    • B+Tree 방식에 적합하여 정렬, 검색, 조인 연산이 수월합니다.
    • 저장 공간을 절약할 수 있습니다.
    • 가독성이 좋습니다. 개발 단계에서 로그를 확인하거나 디버깅하기가 쉽습니다.
  • 단점:
    • 보안에 취약합니다.
    • 분산 환경에서 데이터 중복 문제가 발생할 수 있습니다.

 

✅  그 외 참고 자료

 

RDB에서 UUID를 사용할 때 고민해볼점

UUID를 Primary Key로 사용하는 것은 이점이 많습니다. 구조상 중복 발생 확률이 매우 적으며(약 수백조 분의 일 확률), 길이는 일정하고 알파벳과 숫자로만 이루어져 있어 다루기도 쉽죠. 또한, 비즈

velog.io

 

 

위의 글에 따르면, 데이터가 수백만 건에 달하는 환경에서 UUID를 기본키로 쓰는 경우가 기존에 비해 약 10% 정도로 더 느려지는 현상이 있었다고 합니다.

 

UUID v1을 사용하는 경우 성능 저하를 약간 더 줄일 수 있긴하나, 시간을 변수로 하여 UUID를 생성하기 때문에 비슷한 시간대에 만들어진 값들이 유사해지는 단점이 있었습니다.

 

 

이번 프로젝트에서는 분산 서버를 도입하지 않으므로, 성능보다는 데이터 무결성과 보안을 더 중시하고자 했습니다. 또한, 검색 관련 기능을 도입할 예정이므로 인덱싱 효율도 고려했습니다.

 

이에 따라 기본키는 Auto Increment ID를 사용하고, 회원과 상품과 같은 중요한 데이터를 다루는 API에는 매개변수로 UUID(v4 버전)를 사용하는 방법을 채택했습니다.

 

 

2. 외래키 대신 UUID를 사용하여 연관관계 해소

JOIN을 활용하는 것이 무조건 나쁜 것은 아니므로, 다음과 같은 상황에서는 연관관계를 유지했습니다.

 

연관관계 매핑 기준

  • A를 조회할 때 연관된 B를 반드시 조회해야 하는 경우
    • JOIN을 사용하여 연관된 데이터를 한 번에 가져옵니다.
  • A를 삭제할 때 B도 함께 삭제되어도 괜찮은 경우
    • Cascade 정책을 사용하여 연관된 자식 테이블까지 한 번에 삭제합니다.
  • 성능보다 데이터 무결성을 더 중요시하는 경우
    • 조인을 통해 데이터의 일관성을 유지하고 잘못된 데이터 삽입을 방지합니다.
  • 매핑을 하더라도 ManyToOne으로 설정
    • 단방향 매핑을 통해 엔티티 간의 의존성을 줄일 수 있습니다.
    • MappedBy 없이 간단하게 매핑할 수 있습니다.

 

개선 전

 

기존에는 주문 테이블과 회원 테이블을 다대일 관계로 설정했습니다.

그러나 주문 데이터는 환불과 같은 고객지원 또는 비즈니스 데이터로 활용할 여지가 있기 때문에, 회원이 탈퇴하더라도 주문 데이터는 남겨두는 게 좋다고 판단했습니다.

그래서 두 테이블 간의 연관관계를 제거하고 주문 테이블에 회원의 UUID를 저장하는 방식으로 변경했습니다.

 

개선 후

 

이를 통해 테이블 간의 의존성을 줄이고, 불필요한 JOIN 연산을 줄이며 데이터 스키마를 단순화할 수 있었습니다.

 

다만, 주문 테이블에 저장된 회원의 UUID가 실제로 존재하지 않을 경우 데이터 무결성이 깨질 수 있으므로, 주문을 생성할 때 회원의 UUID가 유효한지 검증하는 절차가 필요할 것입니다.

 

728x90