API에서 PK 노출로 인한 보안 강화하기 (feat. UUID v7 & BINARY(16))

트러블슈팅UUIDv4UUIDv7SnowFlakePKMySQLJPASpring Boot오늘의 식탁
avatar
2025.05.03
·
10 min read

문제 인식: 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은 시간 기반으로 정렬 가능하고 예측은 어렵기 때문에, 보안성과 성능의 균형을 맞추기에 적절한 선택지라고 판단했습니다.

이 과정을 통해 개발 편의성과 보안 간의 트레이드오프, 그리고 서비스 특성에 따라 어떤 설계가 더 현실적인지를 깊이 고민해볼 수 있었습니다.


📚 Reference







- 컬렉션 아티클