fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • claude-code 페이지로 이동
    • harness 페이지로 이동
    • llm 페이지로 이동
    • ops 페이지로 이동
    • practice 페이지로 이동
  • algorithm 페이지로 이동
    • Alias Method: 가중치 랜덤 선택을 전처리 O(n), 추출 O(1)로 바꾸기
    • Welford's Online Algorithm: 값을 저장하지 않고 평균과 분산 계산하기
  • architecture 페이지로 이동
    • distributed-systems 페이지로 이동
    • domain 페이지로 이동
    • evolution 페이지로 이동
    • patterns 페이지로 이동
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • postgresql 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 한국어 형태소 분석기 Nori vs Lindera — OpenSearch에서 Milvus로 갈 때 어휘 검색은 유지되나
    • 정규화와 역정규화: 무결성과 조회 비용 사이의 선택
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 5종을 실제로 벤치마크했다 — 같은 recall에서 QPS는 얼마나 갈리나
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • Envoy Proxy
    • Graceful Shutdown
    • 종료 신호는 어디서 멈추는가
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS와 TLS: 핸드셰이크, 인증서, 종료 지점
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • Alpine 이미지의 Java 에서 링크 바꿔치기(TOCTOU)를 막지 못하는 이유
    • Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • 로그에 traceId 남기기 — MDC 부터 OpenTelemetry 까지
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • `setImmediate()`
  • kafka 페이지로 이동
    • Kafka 클러스터 아키텍처: Broker, KRaft Controller와 복제
    • Kafka 기본 개념: Topic, Partition, Offset과 Segment
    • Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • Kafka 파티션·리밸런스·컨슈머 지연 운영
    • Kafka를 로그로 이해하기: 복제, 재처리, 상태와 데이터 통합
    • Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • SSH forced command 로 배포 키가 실행할 수 있는 명령 제한하기
  • mlops 페이지로 이동
    • llm-serving 페이지로 이동
    • model-router 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • 한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPS
  • network 페이지로 이동
    • Connection reset by peer는 누가 보낸 걸까 — 리버스 프록시 홉마다 TCP 연결은 따로 논다
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • observability 페이지로 이동
    • Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • K8s 위 Spring Boot 앱의 메트릭 수집
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ThreadLocal 에서 contextvars 로 — Python 의 요청 컨텍스트 전파
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS가 줄지 않는 이유: CPython, gc.collect(), malloc_trim
  • rabbitmq 페이지로 이동
    • RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • resume 페이지로 이동
    • 경험 및 경력기술서
    • 김병태 경력기술서
    • 김병태 포트폴리오
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • thinking 페이지로 이동
    • 자기주도 학습: 질문에서 시작해 글로 검증하기
    • 좋은 일을 넘어 중요한 일을 하는 법
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • Glossary↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/java/JPA 식별자 전략: Long IDENTIT…
javadb

JPA 식별자 전략: Long IDENTITY와 외부용 UUID v7

JPA 엔티티의 기본키를 정할 때는 INSERT 성능, 인덱스 크기와 외부 API의 식별자를 함께 봐야 한다. 이 글에서 다루는 선택은 일반 엔티티의 기본키를 Long IDENTITY로 통일하고, 외부에 노출할 식별자가 필요한 엔티티에만 UUID v7 컬럼을 추가하는 방식이다. 분석 결과를 저장하는 서비스에서 검토한 판단을 일반화했으며, 특정 업무의 구현...

2026.10.09·10 min read·0 views

JPA 엔티티의 기본키를 정할 때는 INSERT 성능, 인덱스 크기와 외부 API의 식별자를 함께 봐야 한다. 이 글에서 다루는 선택은 일반 엔티티의 기본키를 Long IDENTITY로 통일하고, 외부에 노출할 식별자가 필요한 엔티티에만 UUID v7 컬럼을 추가하는 방식이다. 분석 결과를 저장하는 서비스에서 검토한 판단을 일반화했으며, 특정 업무의 구현 과정과 운영 수치는 다루지 않는다.

기술 설명은 Jakarta Persistence 3.1, Hibernate ORM 6.6, MySQL 8.4와 Java 21을 기준으로 한다. 아래 결정은 이 조건에서의 선택이며, 분산 환경에서 INSERT 전에 기본키가 필요하거나 대량 삽입이 핵심이라면 다른 전략이 더 적합할 수 있다.

생성 전략과 식별자가 생기는 시점

@Id는 엔티티 식별자를 지정하고, @GeneratedValue는 그 값을 생성하는 전략을 지정한다. 기본키가 있다는 사실과 자동으로 생성된다는 사실은 별개다. 애플리케이션에서 UUID를 직접 할당하거나 업무 키를 쓰면 @GeneratedValue 없이 @Id를 사용할 수 있다.

전략INSERT 전에 ID 확보Hibernate JDBC INSERT batch장점비용과 이식성
IDENTITY불가. DB INSERT 후 확보해당 엔티티는 비활성화짧은 숫자 키, DB가 생성 책임 보유DB identity 지원 필요, 배치 제약
SEQUENCE가능. 시퀀스에서 먼저 할당가능ID 선할당, 할당 묶음으로 호출 감소DB 시퀀스 지원과 할당 크기 설정 필요
TABLE가능. 생성용 테이블에서 먼저 할당가능시퀀스 없는 DB에서도 구현 가능추가 조회·갱신과 잠금 경합
애플리케이션 UUID가능. 객체 생성 때 할당 가능가능DB와 독립적으로 생성, 분산 생성에 유리키 크기, 정렬 특성과 저장 형식 검토 필요
자연키·복합키가능. 업무 값이 확정되어야 함가능업무상 유일성을 식별자에 표현키 변경과 넓은 외래키, 매핑 복잡도

표의 ‘가능’은 생성 전략이 배치를 막지 않는다는 뜻이다. hibernate.jdbc.batch_size 설정, SQL 형태와 flush 경계 등 실제 배치 조건도 맞아야 한다. saveAll()을 호출한다고 곧바로 JDBC batch가 되는 것은 아니다. IDENTITY는 Hibernate가 ID를 받기 위해 INSERT를 실행해야 하므로 해당 INSERT의 JDBC 배치를 비활성화한다. 이 제약을 DB의 다중 행 INSERT 자체가 불가능하다는 뜻으로 읽으면 안 된다. Hibernate JDBC batching

‘INSERT 전’과 ‘persist() 호출 전’도 다르다. SEQUENCE와 TABLE의 값은 보통 영속화 과정에서 생성기에 요청해 받는다. 애플리케이션 UUID는 영속화 요청 전에 직접 생성할 수 있다. IDENTITY의 실제 INSERT 시점은 트랜잭션과 구현체 동작에 영향을 받으므로 모든 save()가 같은 시점에 SQL을 보낸다고 가정하지 않는다.

MySQL의 AUTO_INCREMENT는 IDENTITY에 대응한다. MySQL 8.4에는 PostgreSQL처럼 독립적인 사용자 시퀀스 객체가 없으므로 SEQUENCE를 그대로 쓰는 선택은 맞지 않는다. Hibernate가 시퀀스 생성기를 테이블로 대체할 수도 있지만, 실제 SQL과 잠금 비용을 확인해야 한다. Hibernate 시퀀스 생성기

자연키는 코드처럼 업무 의미를 가진 값을 기본키로 사용하는 방식이다. 복합키는 여러 값을 하나의 식별자로 묶으며, @EmbeddedId나 @IdClass로 매핑한다. 일별 통계의 ‘날짜와 분류 코드’처럼 조합 자체가 행의 의미인 경우에는 자연스럽다. 반면 변경 가능한 이메일 등을 기본키로 쓰면 관련 외래키까지 변경해야 할 수 있다. 복합키 클래스에는 규격에 맞는 equals()와 hashCode() 등이 필요하다. Jakarta Persistence 식별자 규격

Jakarta Persistence 3.1의 GenerationType.UUID도 선택지다. 다만 이 선언만으로 UUID v7 생성이 보장되지는 않는다. 버전이 계약의 일부라면 실제 생성기를 확인한다. AUTO 역시 특정 DB에서 항상 IDENTITY를 선택한다는 선언이 아니다.

InnoDB 기본키의 비용

클러스터드 인덱스와 삽입 위치

InnoDB는 기본키를 클러스터드 인덱스로 사용하고, 그 리프 페이지에 행 데이터를 저장한다. 행은 B-tree 안에서 기본키 순서로 조직된다. 이것은 디스크 파일 전체가 기본키 순서대로 연속 배치된다는 뜻도, ORDER BY 없는 조회 결과가 정렬된다는 뜻도 아니다. MySQL 클러스터드 인덱스

증가하는 숫자 키는 대체로 인덱스의 끝부분에 삽입된다. 랜덤 UUID v4는 여러 기존 페이지에 삽입을 분산시킨다. 페이지에 여유가 없으면 분할이 필요하고, 여러 페이지를 읽고 변경하면서 버퍼 풀의 지역성이 떨어질 수 있다. 순차 키도 페이지 분할을 없애지는 않으며, 높은 동시성에서는 끝부분의 경합이 생길 수 있다. 따라서 UUID v7을 쓰면 모든 삽입이 빨라진다고 단정하지 않고, 실제 인덱스 구성과 부하로 비교한다. MySQL InnoDB 인덱스의 물리 구조

보조 인덱스에도 붙는 기본키

InnoDB의 보조 인덱스 레코드에는 기본키가 포함된다. 보조 인덱스에서 찾은 기본키로 클러스터드 인덱스의 행을 조회하기 때문이다. 기본키가 커지면 기본키 인덱스뿐 아니라 여러 보조 인덱스의 공간과 캐시 사용량에도 영향을 준다. MySQL 보조 인덱스

저장 형식값의 크기 기준주의점
BIGINT8바이트Java Long과 대응하는 숫자 키
CHAR(36)utf8mb4 선언상 최대 144바이트36문자와 문자당 최대 4바이트의 곱
BINARY(16)16바이트UUID 128비트를 문자열 없이 저장

144바이트는 문자 집합에 따른 최대 길이 계산이다. 일반 UUID 문자열의 영문 숫자와 하이픈은 ASCII 문자이므로, utf8mb4라는 이유만으로 모든 값이 실제로 144바이트를 차지한다고 계산하면 틀린다. 실제 레코드 크기에는 행 형식과 인덱스 메타데이터도 관여한다. 이 표는 인덱스 전체 크기나 성능 배수를 제시하는 표가 아니다. MySQL 자료형 저장 공간

UUID를 저장할 때는 BINARY(16)을 우선 검토한다. 문자열의 편의가 필요하면 ASCII 문자 집합과 비교 규칙도 검토할 수 있다. Java 필드가 UUID라는 것만으로 모든 DB에서 같은 SQL 타입이 선택되지는 않으므로 ORM 매핑과 DDL을 함께 확인한다.

UUID v4와 v7의 차이

두 버전 모두 128비트다. v4는 버전·variant 비트를 제외한 122비트를 랜덤 값으로 사용한다. v7은 앞부분에 시간을 넣고, 나머지 부분에 랜덤 값 등을 넣는다. RFC 9562 UUID v4

UUID v7 필드비트 수내용
unix_ts_ms48Unix epoch 기준 밀리초
ver4버전 7
rand_a12랜덤 값 또는 규격이 허용하는 순서 보강 값
var2UUID variant
rand_b62랜덤 값 또는 규격이 허용하는 순서 보강 값

일반적인 v7 구현은 시간 외 74비트를 랜덤 값으로 채운다. 규격은 같은 밀리초 내 순서를 보강하기 위한 카운터와 더 세밀한 시간의 사용도 허용한다. 따라서 v7이라는 사실만으로 같은 밀리초 안에서 생성 순서가 보장되지는 않는다. 순수 랜덤 구현에서는 그 구간의 순서가 랜덤이다. 시계가 뒤로 이동하거나 여러 노드의 시간이 어긋나면 전체 생성 순서도 보장되지 않는다. RFC 9562 UUID v7

v7의 정렬성은 표준 바이트 순서에서 시간 필드가 앞에 있다는 특성이다. DB에 바이트 순서를 바꿔 저장하면 이 이점을 잃을 수 있다. MySQL의 UUID_TO_BIN(uuid, 1)은 v1의 시간 부분 재배열을 위한 옵션이므로 v7에 그대로 적용하지 않는다. v7은 기본 바이트 순서를 유지하고 읽기와 쓰기에 같은 변환을 사용한다. MySQL UUID_TO_BIN

외부에 v7을 노출하면 앞의 48비트에서 생성 시각을 읽을 수 있다. 랜덤 부분 때문에 다음 식별자를 순차적으로 알아내기는 어렵지만, 시각을 숨기는 식별자는 아니다. 또한 UUID의 유일성은 확률과 생성기 품질에 의존하므로 DB의 UNIQUE 제약도 유지한다. RFC 9562 보안 고려사항

Java에서 생성하는 방법

Java 21의 UUID.randomUUID()는 v4를 만든다. UUID 생성자로 v7 비트를 담을 수는 있지만, randomUUID()를 v7 생성기로 사용할 수는 없다. Java 21 UUID API

직접 구현할 때 필요한 비트 배치는 다음과 같다. 아래 코드는 RFC 구조를 설명하기 위한 일반화한 예제이며, 같은 밀리초 내 단조 증가와 시계 역행 처리는 제공하지 않는다.

java
import java.security.SecureRandom;
import java.util.UUID;
 
public final class UuidV7Example {
    private static final SecureRandom RANDOM = new SecureRandom();
 
    public static UUID create() {
        long timestamp = System.currentTimeMillis();
        long most = ((timestamp & 0xFFFFFFFFFFFFL) << 16)
                | 0x7000L
                | (RANDOM.nextLong() & 0x0FFFL);
        long least = (RANDOM.nextLong() & 0x3FFFFFFFFFFFFFFFL)
                | 0x8000000000000000L;
        return new UUID(most, least);
    }
}

직접 만든 생성기에는 버전 7, variant 2, 시간 필드 복원과 바이트 변환 왕복 검사가 필요하다. 정렬 순서가 계약이라면 동일 시각, 시계 역행과 동시 호출 테스트를 별도로 추가해야 한다. 충돌을 못 봤다는 테스트만으로 유일성을 증명할 수는 없다.

운영에서는 검증된 라이브러리를 먼저 검토할 수 있다. uuid-creator는 UuidCreator.getTimeOrderedEpoch()로 v7을 생성한다. uuid-creator 공식 예제 다른 선택지인 Java UUID Generator도 v7을 제공한다. 생성기의 단조 증가 범위, 시계 역행 정책, 스레드 안전성, 라이선스와 지원 Java 버전을 채택 버전에서 확인한다. Java UUID Generator

내부 숫자 키와 외부 UUID의 분리

내부 id는 조인과 외래키에 사용하고, 외부 uuid는 API 경로와 응답에서 사용한다. 예를 들어 GET /reports/{uuid} 요청은 UUID로 행을 찾지만, 다른 테이블은 그 행의 숫자 ID를 참조한다. 아래 DDL은 관계를 설명하는 예제다.

sql
CREATE TABLE report (
    id BIGINT NOT NULL AUTO_INCREMENT,
    uuid BINARY(16) NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uk_report_uuid (uuid)
);

숫자 ID를 외부에 그대로 내보내면 값을 하나씩 바꾸는 열거가 쉬워지고, 데이터 규모나 생성 순서를 추정하는 단서도 생긴다. UUID를 외부 식별자로 고정하면 DB 이관이나 데이터 통합 때 내부 숫자 키를 다시 배정할 여지도 생긴다. 이때 기존 UUID와 새 내부 ID의 대응은 보존해야 한다.

UUID는 접근 권한을 대신하지 않는다. 사용자는 UUID를 알더라도 해당 행을 조회할 권한이 있어야 하며, 서버는 소유자나 권한 범위를 함께 검사해야 한다. v7의 생성 시각 노출을 허용할 수 없는 데이터라면 외부 식별자에 v4 등 다른 방식을 검토한다.

비용도 있다. UUID 컬럼과 UNIQUE 인덱스가 추가되고, 애플리케이션에는 두 식별자가 공존한다. UUID 보조 인덱스에는 숫자 기본키도 포함되며, UUID로 조회한 뒤 행을 읽는 경로도 고려해야 한다. API DTO, 로그와 서비스 메서드가 어느 식별자를 받는지 명확히 정해야 한다.

선택한 규칙과 예외

우리의 선택은 일반 엔티티 기본키를 Long과 GenerationType.IDENTITY로 통일하는 것이었다. 외부 식별자가 필요한 엔티티에만 UUID v7을 추가하고, 내부 외래키는 숫자로 유지한다. 이는 특정 테이블만 UUID 기본키로 바꾸는 것보다 서비스 전체의 관계 매핑과 식별자 취급을 일관되게 유지하려는 판단이다.

대안 B는 외부 식별자가 필요한 엔티티의 기본키 자체를 v7으로 바꾸는 방식이었다. UUID 컬럼을 중복으로 두지 않고 INSERT 전에 기본키를 확보할 수 있다. 반면 UUID가 기본키인 엔티티와 숫자가 기본키인 엔티티가 섞이고, 외래키 타입과 공통 코드의 전제가 달라진다. v7이어도 기본키는 16바이트이므로 숫자 키와 같은 인덱스 비용은 아니다. 이 조건에서는 서비스 전체의 일관성을 우선해 대안 B를 선택하지 않았다.

IDENTITY의 JDBC batch 제약은 남는다. 대량 저장이 핵심인 경로라면 별도 JDBC 삽입, 다른 저장 모델이나 생성 전략을 검토하고 실제 부하로 판단해야 한다. UUID 컬럼을 추가한다고 IDENTITY의 배치 제약이 해결되지는 않는다.

예외는 값 자체가 업무 키인 사전·통계 테이블과 고정된 한 행을 표현하는 테이블로 한정한다. 사전은 불변 코드, 통계는 날짜와 분류의 조합처럼 의미가 분명한 키를 사용할 수 있다. 고정 한 행 테이블은 정해진 ID를 직접 할당할 수 있지만, 그것만으로 두 번째 행의 생성을 막지는 못한다. 필요하면 DB 제약도 함께 둔다. 각 예외에는 키 형태, 자동 생성하지 않는 이유와 변경 조건을 기록한다.

ArchUnit으로 기본 규칙 검사

문서만으로는 새 엔티티의 다른 타입이나 기본 전략 AUTO를 발견하기 어렵다. ArchUnit으로 @Entity를 수집하고 사용자 정의 ArchCondition으로 식별자 규칙을 검사하면 CI에서 위반을 찾을 수 있다. ArchUnit 사용자 정의 조건

다음 코드는 필드 접근을 쓰는 프로젝트를 위한 일반화한 JUnit 5 예제다. 예제 검증 환경은 Java 21, ArchUnit 1.4.1, Jakarta Persistence API 3.1.0과 JUnit 5.11.4다. 상속한 ID 필드도 확인하고, 예외가 아닌 엔티티의 프로퍼티 ID와 @EmbeddedId는 실패시킨다. 프로퍼티 접근을 허용하는 프로젝트라면 getter의 반환 타입과 애너테이션을 검사하는 조건을 별도로 작성한다. XML 매핑을 사용하는 프로젝트에는 그 매핑까지 검사하는 추가 검증이 필요하다.

java
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import org.junit.jupiter.api.Test;
 
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Map;
 
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
 
class EntityIdRuleTest {
    // 일반화한 예외 이름이다. 실제 코드에서는 완전한 클래스명으로 관리한다.
    private static final Map<String, String> EXCEPTIONS = Map.of(
            "example.domain.DictionaryEntry", "불변 업무 코드",
            "example.domain.DailyStatistic", "날짜와 분류의 복합키",
            "example.domain.GlobalSettings", "고정 한 행의 명시적 ID"
    );
 
    @Test
    void entitiesUseLongIdentity() {
        var imported = new ClassFileImporter().importPackages("example.domain");
        classes().that().areAnnotatedWith(Entity.class)
                .should(new ArchCondition<JavaClass>("use one Long IDENTITY field") {
                    @Override
                    public void check(JavaClass entity, ConditionEvents events) {
                        if (EXCEPTIONS.containsKey(entity.getName())) {
                            return;
                        }
                        var ids = new ArrayList<Field>();
                        boolean unsupported = false;
                        for (Class<?> type = entity.reflect();
                             type != null && type != Object.class;
                             type = type.getSuperclass()) {
                            for (Field field : type.getDeclaredFields()) {
                                if (field.isAnnotationPresent(Id.class)) {
                                    ids.add(field);
                                }
                                unsupported |= field.isAnnotationPresent(EmbeddedId.class);
                            }
                            unsupported |= Arrays.stream(type.getDeclaredMethods())
                                    .anyMatch(method -> method.isAnnotationPresent(Id.class)
                                            || method.isAnnotationPresent(EmbeddedId.class));
                        }
                        boolean valid = !unsupported && ids.size() == 1;
                        if (valid) {
                            Field id = ids.get(0);
                            GeneratedValue generated = id.getAnnotation(GeneratedValue.class);
                            valid = id.getType() == Long.class && generated != null
                                    && generated.strategy() == GenerationType.IDENTITY;
                        }
                        events.add(new SimpleConditionEvent(entity, valid,
                                entity.getName() + " must declare one Long IDENTITY field"));
                    }
                }).check(imported);
    }
}

ArchUnit의 reflect()를 사용하므로 테스트 실행 시 엔티티와 관련 타입이 클래스패스에 있어야 한다. 패키지 범위도 실제 모든 엔티티를 포함하도록 지정한다. 빈 범위가 검사 성공으로 처리되지 않도록 기본 빈 규칙 실패 동작을 유지한다.

예외 목록은 전체 검사의 무조건 면제 목록으로 끝내지 않는다. 각 예외가 여전히 존재하는지 확인하고, 자연키·복합키·고정 ID의 기대 형태를 별도 테스트로 검사한다. 사용하지 않는 예외는 삭제하며, 새 예외를 추가할 때 이유도 함께 리뷰한다.

기본 규칙의 검증에는 정상 Long IDENTITY, 잘못된 UUID·primitive long, 빠진 @GeneratedValue, AUTO, 상속 ID, 복합키와 프로퍼티 ID를 각각 넣는다. 외부 UUID의 v7 생성과 UNIQUE 제약, 실제 DB의 BINARY(16) 매핑은 이 구조 검사만으로 증명되지 않으므로 생성기 테스트와 DB 통합 테스트에서 확인한다.

관련 문서

  • JPA 벌크 변경과 트랜잭션 정합성
  • Spring Data JPA 트랜잭션 흔한 실수들
  • MySQL / InnoDB 인덱스 허브
on this page
  • 01생성 전략과 식별자가 생기는 시점
  • 02InnoDB 기본키의 비용
  • 클러스터드 인덱스와 삽입 위치
  • 보조 인덱스에도 붙는 기본키
  • 03UUID v4와 v7의 차이
  • Java에서 생성하는 방법
  • 04내부 숫자 키와 외부 UUID의 분리
  • 05선택한 규칙과 예외
  • 06ArchUnit으로 기본 규칙 검사
  • 07관련 문서
tags
#study#jpa#mysql#uuid

이런 글도

  • java

    [학습중] JPA 벌크 변경과 트랜잭션 정합성

    2026.07.21
  • java

    Alpine 이미지의 Java 에서 링크 바꿔치기(TOCTOU)를 막지 못하는 이유

    2026.10.09
  • java

    쿠버네티스에 올렸더니 X-Forwarded-For 가 사라졌다 — forward-headers-strategy 와 RemoteIpValve

    2026.08.04
  • java

    서블릿 async 재디스패치에서 preHandle이 두 번 돌고 응답 객체가 바뀌는 이유

    2026.08.02

댓글 (0)