API에서 PK 노출로 인한 보안 강화하기 (feat. UUID v7 & BINARY(16))
✅ 문제 인식: PK 노출로 인한 보안 취약점
예를 들어, 다음과 같은 요청이 있다고 가정해봅시다.
GET /users/123
이 요청에서 123
은 데이터베이스의 기본키입니다. 이 구조는 직관적이지만, 공격자가 다음과 같은 시도를 하게 만들 수 있습니다:
GET /users/124
DELETE /users/125
PUT /users/126
이러한 접근은 일종의 ID 추측 공격으로 이어지며, 인증·권한 검사가 미흡한 경우 IDOR (Insecure Direct Object Reference) 취약점이 발생합니다. 이로 인해 사용자의 민감한 데이터에 접근하거나 데이터를 변경할 수 있게 됩니다.
🤔 고민 1: UUID를 PK로 사용하면?
처음에 생각한 해결책은 UUID를 PK로 사용하는 것이었습니다.
장점
무작위 값으로 인해 예측이 불가능합니다.
리소스를 외부에 안전하게 노출할 수 있습니다.
단점
UUID는 보통 128비트(16바이트)로 1크기가 큽니다.
특히 UUID v4는 완전히 무작위라 인덱스 정렬 성능이 크게 떨어집니다.
B Tree 인덱스의 성능 저하 및 데이터 파편화가 발생할 수 있습니다.
🤔 고민 2: Snowflake를 PK로 사용하면?
Twitter의 Snowflake 방식은 시간 기반 64비트 ID를 생성해 고성능을 기대할 수 있는 전략입니다.
장점
숫자만 사용하므로 UUID보다 짧아 저장 공간을 절약할 수 있습니다.
시간 기반이므로 인덱싱 성능이 뛰어납니다.
높은 생성 속도로 트래픽이 많은 환경에서도 유리합니다.
단점
워커 ID, 데이터센터 ID 등 구성 요소를 관리해야 합니다.
시퀀스 충돌이나 시간 동기화 문제를 해결해야 합니다.
Snowflake ID를 생성하는 전용 서비스 또는 라이브러리가 필요합니다.
🤔 고민 3: 기존 PK 유지 + UUID 식별자 사용
기존 Auto Increment PK는 유지하고, 외부 식별용 보조 식별자를를 추가하는 것입니다.
장점
DB 내부 연산은 정수형 PK로 빠르게 수행 가능
외부에는 UUID를 노출해 보안성을 확보
단점
API 설계 시 ID와 UUID를 병행 관리해야 하므로 복잡도가 증가할 수 있습니다.
UUID는 128비트(16바이트)로 크기 때문에 저장 공간과 인덱스 크기 증가
🔍 고민 3-1: 어떤 UUID 버전을 쓸까?
버전 | 설명 | 특징 |
v4 | 무작위 | 예측 불가하지만 정렬 비효율 |
v7 | 시간 기반 + 랜덤 | 시간순 정렬 가능, 인덱스 삽입 성능 우수 |
🎯 내가 선택한 방법
기존 Auto Increment PK 유지 + UUID v7 식별자 추가
선택 이유
UUID v7은 시간 기반 정렬이 가능해 인덱스 정렬 시 성능 저하가 적습니다.
UUID v7은 크기가 크지만 BINARY로 변환해서 저장하면 어느정도 보완할 수 있습니다. (변환 과정이 추가되는 번거로움이 있음)
외부 노출용으로는 충분히 안전하며 예측 불가
내부적으로는 여전히 정수 PK로 빠르게 데이터 접근 가능
🔨 구현 방식 (Spring + JPA + MySQL 기준)
라이브러리 추가
// UUIDv7
implementation 'com.github.f4b6a3:uuid-creator:5.3.2'
UUID 변환 컨버터
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.nio.ByteBuffer;
import java.util.UUID;
@Converter(autoApply = false) // 자동 적용되지 않도록 설정 (명시적으로 @Convert 사용 필요)
public class UUIDBinaryConverter implements AttributeConverter<UUID, byte[]> {
/**
* UUID → byte[16] 변환
* DB에 저장할 때 호출됨
* UUID는 128비트이므로 Long 2개(64비트 + 64비트)로 분리해 ByteBuffer에 저장
*/
@Override
public byte[] convertToDatabaseColumn(UUID uuid) {
if (uuid == null) return null;
// 16바이트 배열 할당
ByteBuffer buffer = ByteBuffer.allocate(16);
// UUID의 Most Significant Bits와 Least Significant Bits를 순서대로 삽입
buffer.putLong(uuid.getMostSignificantBits());
buffer.putLong(uuid.getLeastSignificantBits());
// Byte 배열 반환
return buffer.array();
}
/**
* byte[16] → UUID 변환
* DB에서 조회할 때 호출됨
* 저장된 16바이트 값을 다시 Long 2개로 읽어와 UUID로 복원
*/
@Override
public UUID convertToEntityAttribute(byte[] bytes) {
// null이거나 길이가 16바이트가 아니면 null 반환
if (bytes == null || bytes.length != 16) return null;
// 바이트 배열을 ByteBuffer로 래핑하여 Long 값 2개 추출
ByteBuffer buffer = ByteBuffer.wrap(bytes);
long high = buffer.getLong(); // 앞쪽 8바이트
long low = buffer.getLong(); // 뒤쪽 8바이트
// UUID 생성
return new UUID(high, low);
}
}
엔티티 설정
@Slf4j
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Where(clause = "deleted_date IS NULL")
@SQLDelete(sql = "UPDATE Item SET deleted_date = CURRENT_TIMESTAMP WHERE item_id = ?")
@Table(
indexes = {
@Index(name = "idx_deleted_date_item_name", columnList = "deleted_date, item_name"),
@Index(name = "idx_deleted_date_brand_id", columnList = "deleted_date, brand_id")
}
)
public class Item extends BaseEntity {
// Item 엔티티의 기본 키
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "item_id")
private Long id;
@Column(name = "item_uuid", nullable = false, unique = true, columnDefinition = "BINARY(16)")
@Convert(converter = UUIDBinaryConverter.class)
private UUID uuid;
// 상품의 이름
@Column(name = "item_name")
private String itemName;
// 상품의 가격
private BigDecimal price;
// 상품의 재고 수량
private Long stock;
// Brand 엔티티와의 다대일 관계
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "brand_id")
private Brand brand;
// 상품 사진의 URL
@Builder.Default
@OneToMany(mappedBy = "item")
private List<ImageFile> imageFileList = new ArrayList<>();
// OrderItem 엔티티들과의 일대다 관계
@Builder.Default
@OneToMany(mappedBy = "item")
private List<OrderItem> orderItemList = new ArrayList<>();
@Builder.Default
@OneToMany(mappedBy = "item")
private List<RecipeItem> recipeItemList = new ArrayList<>();
// 기타 메서드 생략
}
UUID를 외부 식별자로 사용하여 PK 노출 방지
BINARY(16)
으로 저장하여 VARCHAR 대비 저장 공간 절약UUIDBinaryConverter
를 통해 UUID <-> byte[] 자동 변환unique = true
설정으로 자동 유니크 인덱스 생성
💬 마무리하며
PK 노출을 막기 위해 UUID를 도입하는 것은 보안적인 측면에서 매우 효과적인 선택이었습니다. 하지만 실서비스 환경에서의 성능, 인덱스 정렬 효율성, 구현 난이도 등을 고려했을 때 단순히 UUID v4나 Snowflake만으로는 만족스럽지 않았습니다.
저는 보안과 성능, 그리고 프로젝트의 규모와 복잡도를 종합적으로 고려해 기존 Auto Increment PK + UUID v7 보조 키 전략을 선택했습니다.
UUID v7은 시간 기반으로 정렬 가능하고 예측은 어렵기 때문에, 보안성과 성능의 균형을 맞추기에 적절한 선택지라고 판단했습니다.
이 과정을 통해 개발 편의성과 보안 간의 트레이드오프, 그리고 서비스 특성에 따라 어떤 설계가 더 현실적인지를 깊이 고민해볼 수 있었습니다.