Post

JPA PK 전략 완전 정복 — UUID · 복합키 · @EmbeddedId

JPA PK 전략 완전 정복 — UUID · 복합키 · @EmbeddedId

1. UUID PK — Hibernate 6.x 공식 지원


Spring Boot 3.x (Hibernate 6.x)부터 UUID를 PK로 사용하는 것이 공식 지원된다.

1
2
3
4
5
6
7
8
@Entity
public class Book extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(name = "book_id", updatable = false, nullable = false)
    private UUID id;
}

UUID 전략은 DB가 아닌 애플리케이션 레벨에서 PK를 생성한다. 덕분에 em.persist() 직전에 UUID를 미리 알 수 있어 IDENTITY 전략과 달리 쓰기 지연(Batch INSERT)이 가능하다.

📌 UUID vs IDENTITY 비교

항목IDENTITYUUID
PK 생성 주체DB (auto_increment)애플리케이션 (UUID 생성기)
쓰기 지연 (Batch)불가가능
저장 공간8 bytes (BIGINT)16 bytes (BINARY(16))
인덱스 단편화없음 (순차)있음 (랜덤) → UUID v7로 완화 가능
분산 환경PK 충돌 위험충돌 없음
가독성좋음낮음

대량 INSERT가 빈번한 경우나 MSA 분산 환경에서는 UUID가 유리하다. 단일 서버 + 순차 조회 중심이라면 IDENTITY가 여전히 좋은 선택이다.

📌 UUID를 String으로 저장하는 경우

1
2
3
4
5
6
7
8
9
10
// Hibernate 6.x — UUID 타입 그대로 사용 (권장)
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

// String으로 받고 싶은 경우
@Id
@UuidGenerator
@Column(columnDefinition = "VARCHAR(36)")
private String id;

DB에 저장되는 형태는 Hibernate의 ImplicitNamingStrategyColumnDefinition에 따라 BINARY(16) 또는 VARCHAR(36)이 된다. 조회 성능을 위해 BINARY(16) 저장이 권장된다.

📌 Persistable과 isNew() — UUID PK의 함정

UUID처럼 PK를 직접 할당하는 경우 SimpleJpaRepository.save()isNew() 판단이 항상 false가 되어 persist 대신 merge가 호출된다. merge는 내부에서 불필요한 SELECT를 먼저 실행하므로 성능 낭비가 생긴다.

Persistable 인터페이스를 구현해서 해결한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Book implements Persistable<UUID> {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @Override
    public UUID getId() { return id; }

    // createdAt이 null이면 신규 엔티티로 판단
    @Override
    public boolean isNew() { return createdAt == null; }
}

2. 복합키 — @IdClass


복합키(Composite Key)는 두 개 이상의 컬럼이 합쳐져 PK를 이루는 경우다. JPA는 @IdClass@EmbeddedId 두 가지 방식을 지원한다.

@IdClass는 복합키 클래스를 엔티티 외부에 별도로 정의하고, 엔티티에는 각 PK 필드를 @Id로 선언하는 방식이다.

📌 복합키 클래스 제약 조건

  • Serializable 구현 필수
  • 기본 생성자 필수
  • equals(), hashCode() 구현 필수
  • PK 클래스가 public이어야 함

📌 @IdClass 코드 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 1. 복합키 클래스 (PK 식별자 역할)
public class LoanRecordId implements Serializable {

    private Long memberId;
    private Long bookId;

    public LoanRecordId() {}

    public LoanRecordId(Long memberId, Long bookId) {
        this.memberId = memberId;
        this.bookId = bookId;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof LoanRecordId)) return false;
        LoanRecordId that = (LoanRecordId) o;
        return Objects.equals(memberId, that.memberId)
            && Objects.equals(bookId, that.bookId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(memberId, bookId);
    }
}

// 2. 엔티티 — @IdClass 선언 + 각 PK 필드에 @Id
@Entity
@IdClass(LoanRecordId.class)
@Table(name = "loan_record")
public class LoanRecord extends BaseEntity {

    @Id
    @Column(name = "member_id")
    private Long memberId;

    @Id
    @Column(name = "book_id")
    private Long bookId;

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    private LoanStatus status;

    private LocalDate loanDate;
    private LocalDate dueDate;
}

📌 @IdClass 조회 방법

1
2
3
4
5
6
7
8
// 복합키로 조회
LoanRecordId id = new LoanRecordId(1L, 2L);
LoanRecord record = em.find(LoanRecord.class, id);

// Spring Data JPA Repository
public interface LoanRecordRepository extends JpaRepository<LoanRecord, LoanRecordId> {
    List<LoanRecord> findByMemberId(Long memberId);
}

3. 복합키 — @EmbeddedId


@EmbeddedId는 복합키를 @Embeddable 클래스로 캡슐화하고, 엔티티에 하나의 필드로 포함시키는 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 1. 복합키 클래스 — @Embeddable
@Embeddable
@Getter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class LoanRecordId implements Serializable {

    @Column(name = "member_id")
    private Long memberId;

    @Column(name = "book_id")
    private Long bookId;
}

// 2. 엔티티 — @EmbeddedId 사용
@Entity
@Table(name = "loan_record")
public class LoanRecord extends BaseEntity {

    @EmbeddedId
    private LoanRecordId id;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("memberId")   // EmbeddedId의 memberId 필드와 FK를 연결
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("bookId")     // EmbeddedId의 bookId 필드와 FK를 연결
    @JoinColumn(name = "book_id")
    private Book book;

    @Enumerated(EnumType.STRING)
    private LoanStatus status;

    private LocalDate loanDate;
}

@MapsId@EmbeddedId 내부의 특정 필드를 연관관계 FK와 연결해주는 어노테이션이다. 이 설정이 없으면 member_id 컬럼이 중복으로 생성되는 문제가 발생한다.

📌 @EmbeddedId 조회 방법

1
2
3
4
5
6
7
8
9
10
// 복합키로 조회
LoanRecordId id = new LoanRecordId(1L, 2L);
LoanRecord record = em.find(LoanRecord.class, id);

// JPQL에서 사용 — 내부 필드로 접근
List<LoanRecord> records = em.createQuery(
    "SELECT lr FROM LoanRecord lr WHERE lr.id.memberId = :memberId",
    LoanRecord.class)
    .setParameter("memberId", 1L)
    .getResultList();

4. @IdClass vs @EmbeddedId 비교


항목@IdClass@EmbeddedId
PK 클래스 위치엔티티 외부 별도 클래스@Embeddable 클래스
엔티티 필드 선언각 PK 필드에 @Id 반복@EmbeddedId 하나
JPQL 접근lr.memberIdlr.id.memberId
@MapsId 필요 여부불필요연관관계와 함께 쓸 때 필요
코드 직관성각 필드를 직접 선언하므로 명시적복합키가 하나로 캡슐화되어 응집도 높음
실무 선호도단순한 경우 선호비즈니스 의미가 있는 복합키에 선호

💡 실무에서는 복합키보다 Surrogate Key(대리키) — IDENTITY 또는 UUID 단일 PK를 사용하는 것이 훨씬 일반적이다. 복합키는 레거시 DB를 그대로 매핑해야 하거나, 관계 테이블(N:M 중간 테이블)에서 두 FK를 PK로 쓰는 경우에 주로 사용된다.


5. 정리


주제핵심 요약
UUID PKHibernate 6.x 공식 지원. GenerationType.UUID. 쓰기 지연 가능. Persistable로 isNew() 보완 필요
@IdClass복합키 클래스 외부 정의. 엔티티에 각 @Id 필드 반복 선언. 단순한 복합키에 적합
@EmbeddedId복합키를 @Embeddable로 캡슐화. @MapsId로 연관관계 FK와 연결. 비즈니스 의미 있는 복합키에 적합
실무 권장복합키보다 단일 Surrogate Key(IDENTITY/UUID) 사용이 훨씬 일반적

참고 자료

This post is licensed under CC BY 4.0 by the author.