JPA 식별자 전략 선정과 Equals HashCode

JPA 식별자 전략 선정과 Equals HashCode

JPA의 기본 사항을 정리한다.

식별자

흔히 @Id로 정하는 식별자는 DB 별 생성 전략 설정, 자바에서의 equals, hashCode 메서드 오버라이딩을 고려해야 한다.
그리고 비즈니스 상황에 따라 설정하는 자연키도 존재할 수 있다.


DB 별 생성 전략 설정

DB별로 추천되는 식별자 생성 전략이 다르다.
MySql의 경우 기본키는 Clustered Index 로 사용된다.
Sequence가 지원되는 DB의 경우 Sequence 전략을 사용하는 것이 좋다.

ClusterdIndex는 data를 저장하는 물리적인 순서를 인덱스 순서로 사용하는 것을 말한다.

[BAD] AUTO,TABLE : 키 생성 전용 테이블을 사용한다. (모든 DB)

Auto 전략시에 JPA는 거의 Table 전략을 사용한다. Table 전략은 성능상 제일 별로라고 생각하면 된다.
확장성도 없고 DB Connection에서 IDENTITY, SEQUENCE보다 느리다.
또한 신규 INSERT시에 다음과 같이 쿼리가 3개가 필요해진다.

  • SELECT 자동 생성 Sequence next.val
  • UPDATE 시퀀스 값 + 1
  • INSERT 엔티티 대신 특정 DB 벤더에 대해 의존적이지는 않다는 장점은 하나 있다.

[GOOD] IDENTITY : 기본키 생성을 DB에 위임한다. (MySQL)

  • 특정 DB에 맞게 다르게 적용된다.
  • 원래는 트랜잭션 COMMIT시에 flush가 이뤄지지만, DB에서 id 값을 받아와야 하므로 save() 시점에 INSERT 문이 나간다.(flush)
    • 이후 1차 캐시에 id가 할당된 객체가 등록된다.

별다른 시퀀스에 대한 조정이 없는 경우 MySQL의 경우 BATCH INSERT가 되지 않더라도 IDENTITY 전략을 사용하는 것이 좋다.
매 save 마다 flush를 진행하기 때문에 BatchInsert 사용이 불가능하다.

IDENTITY 전략을 사용할 때 Batch Insert가 안 되는 이유

IDENTITY 전략은 데이터베이스가 각 레코드의 기본 키(ID)를 자동으로 생성하도록 하는 방식이다.
이때 기본 키는 일반적으로 AUTO_INCREMENT 속성을 통해 생성된다.
이를 위해, 각 객체가 저장될 때마다 데이터베이스는 새로운 ID 값을 할당하고 반환해야 한다.
이 과정에서 다음과 같은 이유로 Batch Insert가 불가능하다

  1. 개별 INSERT 쿼리: IDENTITY 전략을 사용하면, 엔티티가 저장될 때마다 데이터베이스로부터 ID 값 받아와야 한다. 따라서, 각 엔티티마다 개별 INSERT 쿼리를 실행해야 한다. Batch Insert는 여러 개의 INSERT 쿼리를 하나의 배치로 묶어 한 번에 실행하는 방식인데, IDENTITY 전략은 이를 불가능하게 만든다.
  2. ID 할당 시점: Batch Insert는 여러 레코드를 한꺼번에 삽입하고, 그 후에 한 번에 커밋하는 방식이다. 그러나 IDENTITY 전략에서는 INSERT 쿼리 실행 시점에 각 레코드의 ID가 데이터베이스에서 생성되고, 그 값을 애플리케이션으로 반환해야 한다. 이를 위해 각 INSERT 쿼리는 개별적으로 실행되어야 하며, 이는 Batch Insert와 상충된다.
  3. 1차 캐시 동기화: Hibernate와 같은 ORM 프레임워크는 엔티티가 저장될 때 1차 캐시에 해당 엔티티를 등록하고, ID 값을 할당한다. IDENTITY 전략에서는 ID 값이 데이터베이스에서 생성되기 때문에, INSERT 쿼리 이후에 해당 ID 값을 1차 캐시에 반영해야 한다. 이를 위해 각 엔티티가 개별적으로 저장되고 ID 값을 받아와야 하므로 Batch Insert를 사용할 수 없다.

[GOOD] SEQUENCE : DB 시퀀스를 사용해서 기본키를 생성한다. (PostgreSQL,Oracle)

Hibernate에서 SEQUENCE 방식은 배치 처리 지원, 별도 Table 없이 DB Sequence 사전 할당, 증분 Step을 지원해 좋은 선택지이다. 특히, 대규모 트랜잭션에서 효율적으로 작동하며, IDENTITY 전략과 달리 Batch Insert도 가능하다.

SEQUENCE 전략의 장점

  • 배치 처리 지원: SEQUENCE 전략은 여러 엔티티를 한 번에 삽입하는 Batch Insert를 지원한다.
  • DB 시퀀스 활용: 별도의 테이블 없이 데이터베이스 시퀀스를 사용해 기본 키를 생성할 수 있다.
  • 증분 Step 지원: 시퀀스의 증분 값을 설정하여 ID 값을 원하는 만큼 증가시킬 수 있다.
  • 사전 할당: Hibernate는 시퀀스 값을 미리 할당받아 성능을 최적화할 수 있다.

SEQUENCE호출을 알고리즘으로 최적화

Hibernate는 SEQUENCE 전략을 사용할 때 여러 가지 알고리즘을 지원한다.
각 알고리즘은 특정 상황에 따라 적합하게 선택할 수 있다.

아무것도 적용하지 않았을 경우

//file: `기본 Sequence 전략`
@Entity
public class Author_Sequence {

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

    private String name;
}
[Hibernate] 
    create sequence author_sequence_seq start with 1 increment by 50

위와 같이 기본으로 사용하는 경우, 기본 incrementSize는 50, 초기값은 1로 시작한다.
DEFAULT OptimizerFactory.java의 Optimizer를 할당해주는 메서드다.

  • 기본 정책
    • incrementSize <= 1 인 경우 NONE
    • 특정 Optimizer가 할당된 경우 해당 Optimizer사용
    • 그 외 기본적으로 POOLED 알고리즘 사용

기본 incremnetSize가 50이기 때문에 POOLED 내장 옵티마이저를 사용한다.
기본적인 알고리즘부터 하나씩 알아보자.

hilo 알고리즘

공식은 [increment_size * (hi - 1) + 1, increment_size * hi]

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE,generator = "hiloGenerator")
    @GenericGenerator(name = "hiloGenerator"
            ,type = SequenceStyleGenerator.class
            ,parameters = {
                    @Parameter(name = "sequence_name",value = "hilo_sequence"),
                    @Parameter(name = "initial_value",value = "1"),
                    @Parameter(name = "increment_size",value = "100"),
                    @Parameter(name = "optimizer",value = "hilo")
            }
    )
    private Long id;
[Hibernate] 
    create sequence hilo_sequence start with 1 increment by 1
  • hi = 1, [1,100]
    • A라는 사람이 트랜잭션을 실행하고 데이터베이스에서 hi 1을 배정받는다.
    • 메모리 내에서 1~100까지의 범위를 사용한다.
  • hi = 2, [101,200]
    • B라는 사람이 트랜잭션을 실행하고 데이터베이스에서 hi 2를 배정받는다.
    • 메모리 내에서 101~200까지의 범위를 사용한다.
    • 200 범위를 넘으면 hi 3을 배정받는다.
    • 메모리 내에서 201~300까지의 범위를 사용한다.
  • hi = 3, [201,300]
    • C라는 사람이 트랜잭션을 실행하고 데이터베이스에서 hi 4를 배정받는다.
    • 메모리 내에서 301~400까지의 범위를 사용한다.

위 방식으로 increment_size만큼 증가하며, 최대값에 도달하면 다음 hi로 넘어간다.
1000건을 save시에 10번의 next_val 쿼리만 발생한다.

hi-lo에서 nextval 직접 호출시 문제 간혹 nativequery로 nextVal을 직접 호출하는 경우가 있는데, 이는 hilo 알고리즘을 사용할 때 문제가 될 수 있다.

@Modifying
@Query(value = "INSERT INTO author (id, name) VALUES (NEXTVAL('hilo_sequence'), ?1)",
        nativeQuery = true)
void saveNative(String name);

위 코드와 같이 nextVal을 호출하는 외부 로직이 존재하는 경우, 1000건을 save한 상황에서 다음 nextVal은 11이다.
외부에서는 hilo인지 모르기 때문에 Author를 저장할 때 id는 11이 되고, pk 무결성 오류를 발생시키게 된다.
이는 밑에 나오는 Pooled-Lo 알고리즘을 사용하면 해결할 수 있다.

Pooled 알고리즘

JPA에서 시퀀스로 기본선정하는 알고리즘이다.

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE,generator = "pooledGenerator")
    @GenericGenerator(name = "pooledGenerator"
            ,type = SequenceStyleGenerator.class
            ,parameters = {
                    @org.hibernate.annotations.Parameter(name = "sequence_name",value = "pooled_sequence"),
                    @org.hibernate.annotations.Parameter(name = "initial_value",value = "1"),
                    @org.hibernate.annotations.Parameter(name = "increment_size",value = "100"),
                    @org.hibernate.annotations.Parameter(name = "optimizer",value = "pooled")
            }
    )
    private Long id;
[Hibernate] 
    create sequence pooled_sequence start with 1 increment by 100

sequence의 increment가 100으로 설정되어있는 것에 주목하자. (hilo는 1)
제일 고점(top)으로 시퀀스 숫자를 가져온 뒤, 메모리 내에 생성되어있는 하위 숫자(bottom)부터 사용하기 시작한다.

  • hi = 1
    • A라는 사람이 트랜잭션을 실행하고 데이터베이스에서 hi 1을 배정받는다.
    • 1은 initial-value 이다.
  • hi = 101 [1 - 101]
    • A라는 사람이 1개 이상을 save 하게 되면, 새 hi 101을 바로 배정받는다.
    • 1 ~ 101까지 메모리에 생성된 식별자를 사용한다.
  • hi = 201 [101 - 201]
    • B라는 사람이 트랜잭션을 실행하고 데이터베이스에서 hi 201을 배정받는다.
    • 102 ~ 201까지 메모리에 생성된 식별자를 사용한다.

이처럼 nextVal을 호출할 때 마다 Top(고점)을 가져오고, 메모리 내에 있는 Bottom(저점)을 사용한다.
Pooled에서 nextval 직접 호출 앞서 hilo는 시퀀스 자체의 값이 특수한 계산용 값을 배출하기 때문에 외부 시스템에서 nextVal을 호출하는 경우, 중복 PK 오류가 나는 것을 확인했다. 반면 Pooled 알고리즘은 외부 시스템 처리 시에는 Top 값을 가져오게 만들기 때문에 중복될 일이 없다.
조금 id가 띄엄띄엄 생길 수는 있을듯.

Pooled-Lo 알고리즘

Pooled와 유사하지만, hi의 값이 Bottom값을 가져온다.

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE,generator = "pooledloGenerator")
    @GenericGenerator(name = "pooledloGenerator"
            ,type = SequenceStyleGenerator.class
            ,parameters = {
                    @org.hibernate.annotations.Parameter(name = "sequence_name",value = "pooledlo_sequence"),
                    @org.hibernate.annotations.Parameter(name = "initial_value",value = "1"),
                    @org.hibernate.annotations.Parameter(name = "increment_size",value = "100"),
                    @org.hibernate.annotations.Parameter(name = "optimizer",value = "pooled-lo")
            }
    )
    private Long id;
[Hibernate] 
    create sequence pooledlo_sequence start with 1 increment by 100
  • hi = 1 [1 ~ 100]
    • A라는 사람이 트랜잭션을 실행하고 데이터베이스에서 hi 1을 배정받는다.
    • 1 ~ 100까지 메모리에 생성된 식별자를 사용한다.
  • hi = 101 [101 ~ 200]
    • B라는 사람이 트랜잭션을 실행하고 데이터베이스에서 hi 101을 배정받는다.
    • 101 ~ 200까지 메모리에 생성된 식별자를 사용한다.
  • hi = 201 [201 ~ 300]
    • C라는 사람이 트랜잭션을 실행하고 데이터베이스에서 hi 201을 배정받는다.
    • 201 ~ 300까지 메모리에 생성된 식별자를 사용한다.


이 또한 외부 시스템과의 연계 (nextVal 호출) 시에는 중복 PK 오류가 발생하지 않는다.

결론

명시적으로 Id 생성 전략을 선택해주는 것이 좋다.(MySQL - IDENTITY, PostgreSQL - SEQUENCE 등)
Table 생성자를 피해야 하는게 핵심이다.
그리고 IDENTITY는 매 번 flush를 진행한다는 점과 Sequence는 여러 알고리즘을 효율적으로 선택해서 사용하는 것을 고려해서 사용해야 한다.


DB에 따라 선정된 최적의 생성 전략에 따라 달라지는 Equals And HashCode

엔티티의 모든 상태 전환 (Transient, Persistent, Detached, Removed)에서 동일성을 유지하기 위해 equals와 hashCode를 오버라이딩 해야 한다.

hashCode와 equals의 역할

equals 메서드: 두 객체가 동일한지 비교하는 메서드다. 두 객체가 동일하다고 판단되면 true, 그렇지 않으면 false를 반환한다.
hashCode 메서드: 객체의 해시 코드를 반환하는 메서드다. 동일한 객체는 동일한 해시 코드를 반환해야 하며, 해시 코드는 컬렉션(예: HashSet, HashMap)에서 객체를 빠르게 찾는 데 사용된다.
양방향 연관관계에서 사용되고, Set에 저장 혹은 재연결되면 동일성을 유지하기 위해 사용된다.

[BAD] Equals와 HashCode를 Override하지 않는 것

비명시 적으로 equals와 hashCode를 사용하면, 객체의 동일성을 비교할 때 오류가 발생할 수 있다.
오버라이드 되지 않으면 기본 구현을 사용하는데 이는 두 객체의 값이 동일한지 확인하는 목적으로 제공되지 않는다.
동일한 메모리 주소를 갖는 경우에만 동등한 것으로 간주한다.

[BAD] @EqualsAndHashCode : Lombok 자동 생성

Lombok의 @EqualsAndHashCode 어노테이션을 사용하면, 엔티티 클래스에 대해 자동으로 equals와 hashCode 메서드를 생성해준다.
보통 자동 생성은 적절하지 않을 수 있다. 특히, IDENTITY 또는 SEQUENCE 전략을 사용할 때는 Lombok의 자동 생성 기능이 올바른 동작을 보장하지 못할 수 있다.
이 경우, 직접 equals와 hashCode 메서드를 오버라이딩 해야 한다.

@EqualsAndHashCode는 모든 필드를 사용하여 equals와 hashCode를 생성한다.
이는 같은 id를 가져도 title이 다른 책을 다른 객체로 인식하는 문제가 발생할 수 있다.

//file: `Lombok으로 생성되는 코드`

@EqualsAndHashCode
class Book {
    private Long id;
    private String title;
    private String isbn;
}

public boolean equals(final Object o) {
    if (o == this) {
        return true;
    }
    if (!(o instanceof LombokDefaultBook)) {
        return false;
    }
    LombokDefaultBook other = (LombokDefaultBook) o;
    if (!other.canEqual(this)) {
        return false;
    }
    Object this$id = getId();
    Object other$id = other.getId();
    if (this$id == null ? other$id != null : !this$id.equals(other$id)) {
        return false;
    }
    Object this$title = getTitle();
    Object other$title = other.getTitle();
    if (this$title == null ? other$title != null : !this$title.equals(other$title)) {
        return false;
    }
    Object this$isbn = getIsbn();
    Object other$isbn = other.getIsbn();
    return this$isbn == null ? other$isbn == null : this$isbn.equals(other$isbn);
}

protected boolean canEqual(final Object other) {
    return other instanceof LombokDefaultBook;
}

public int hashCode() {
    int PRIME = 59;
    int result = 1;
    Object $id = this.getId();
    result = result * PRIME + ($id == null ? 43 : $id.hashCode());
    Object $title = this.getTitle();
    result = result * PRIME + ($title == null ? 43 : $title.hashCode());
    Object $isbn = this.getIsbn();
    result = result * PRIME + ($isbn == null ? 43 : $isbn.hashCode());

    return result;
}

맘 편하게 사용하려면 Lombok에서는 @RequiredArgsConstructor, @Getter 정도만 사용하고 나머지는 직접 구현하는 방법이 좋아보인다.

[GOOD] IDENTITY, SEQUENCE의 경우

엔티티는 초기 상태인 transient 상태에서 ID가 null일 수 있고, 데이터베이스에 저장된 후 managed 상태가 되면 유효한 ID를 받게 된다.

주요 고려사항

  1. ID가 null인 경우: ID가 null이면 해당 객체는 아직 데이터베이스에 저장되지 않은 transient 상태다. 이 경우, 다른 객체와 동일하지 않다고 판단한다.
  • 두 개의 transient 상태 객체는 서로 다르다.
  • 하나의 transient 상태 객체와 하나의 managed 상태 객체는 서로 다르다.
  1. ID가 유효한 경우: ID가 유효하면, 데이터베이스에 저장된 managed 상태다. 이 경우, 동일한 ID를 가진 객체는 동일하다고 판단한다.
  2. hashCode: 동일한 객체가 null ID 또는 유효한 ID를 가질 수 있으므로, hashCode에서 ID를 사용하지 않는다. 대신 클래스의 해시코드를 반환하여 일관성을 유지한다.

@Override
public boolean equals(Object o) {
    if (obj == null) return false;
    if (this == obj) return true;
    if (getClass() != obj.getClass()) return false;

    final Book other = (Book) obj;
    return id != null && id.equals(other.getId());
}

@Override
public int hashCode() {
    return getClass().hashCode();
}

HashCode 상수를 반환하는 이유

모든 객체는 동일한 해시 코드를 가젝 되어서, 해시 기반 컬렉션에 모든 객체가 동일한 버킷에 들어가게 한다.
이후 equals로 비교하여 동일한 객체인지 확인한다.
id가 null인 transient 상태의 경우, 같지 않은 객체로 판단한다.

따라서 상수를 반환하면, transient와 managed 상태에서 동작을 일관되게 유지하는데 도움이 된다.


JPA와 함께 @NaturalId로 설정하는 자연키를 잘 다루는법

  • 작은 기본키는 작은 인덱스를 생성하고, 큰 기본키(UUID, 복합)은 큰 인덱스를 생성한다.
    • 요구되는 공간 및 인덱스 사용 적인 이유로 숫자 기본키가 최선의 선택이다.
  • 기본키는 JOIN에도 사용되니 더욱 Long이 최선이다.

따라서 엔티티에 Long 타입의 id를 가지게 하는게 많은 이점이 있는 것 같은데? 맞다.
그럼 이 자연키를 언제 쓰면 좋을까?

자연키를 사용하는 이유

  1. 비즈니스적으로 요구사항이 존재할 때 (이메일, 민증, 제품 시리얼 넘버 등)
  2. 여러 시스템 간의 데이터 통합을 위해 공통 식별자가 필요한 경우

1의 경우 해당 요구사항에 대한 명세의 의미로도 쓰여 유용할 것 같다. 지정된 자연키로 호출도 빈번한 상황이다.
2의 대표적인 경우, 자연 식별자 는 일종의 불변한 칭호와 같다.
test 서버와 운영서버간의 ID는 Auto Increment로 생성되어 다른 숫자를 가져도, 자연키는 불변이라 같은 데이터를 가리킨다.
외부/내부, 내부의 어느곳에서든 동일한 레코드의 호출이 가능한 장점이 있다.
(ex: 의자의 경우 ‘MD3’ 같은 시리얼 넘버나 ‘M3흔들의자’를 30% 할인한다 등)

JPA 에서 자연키를 지원하는 방법

지정된 방법이 아니면 의도치 않은 SQL이 나가거나 하는 불안감이 있으니 아래와 같은 방법을 고정적으로 사용하려고 한다.

Column 선언

//가변 자연 키
@Natural(mutable = true)
@Column(nullable = false, updatable = true, unique = true)
private String isbn;

//불변 자연 키
@NaturalId(mutable = false)
@Column(nullable = false, updatable = false, unique = true)
private String serialNumber;

Repository 선언

//file: `NaturalRepository.java`
@NoRepositoryBean
public interface NaturalRepository<T, ID extends Serializable> extends JpaRepository<T, ID> {
    //@NaturalId 단일 필드로 선언된 경우
    Optional<T> findBySimpleNaturalId(ID naturalId);

    //@NaturalId 여러개, 복합 필드로 선언된 경우
    Optional<T> findByNaturalId(Map<String, Object> naturalIds);
}
//file: `NaturalRepositoryImpl.java`
@Transactional(readOnly = true)
public class NaturalRepositoryImpl<T, ID extends Serializable>
        extends SimpleJpaRepository<T, ID> implements NaturalRepository<T, ID> {
    private final EntityManager entityManager;

    public NaturalRepositoryImpl(JpaEntityInformation entityInformation, EntityManager entityManager) {
        super(entityInformation, entityManager);

        this.entityManager = entityManager;
    }

    @Override
    public Optional<T> findBySimpleNaturalId(ID naturalId) {
        Optional<T> entity = entityManager.unwrap(Session.class)
                .bySimpleNaturalId(this.getDomainClass())
                .loadOptional(naturalId);

        return entity;
    }

    @Override
    public Optional<T> findByNaturalId(Map<String, Object> naturalIds) {
        NaturalIdLoadAccess<T> loadAccess = entityManager.unwrap(Session.class)
                .byNaturalId(this.getDomainClass());
        naturalIds.forEach(loadAccess::using);

        return loadAccess.loadOptional();
    }
}
//file: `실제 Repository`
@Repository
public interface BookRepository<T, ID> extends NaturalRepository<Book, Long> {
}
//file: `실제 사용 Service`

public Book fetchFirstBookByNaturalId() {
    // single Natural Id 사용인 경우
    Optional<Book> foundArBook = bookRepository.findBySimpleNaturalId("001-AR");


    // multiple Natural Id 사용인 경우
    Map<String, Object> ids = new HashMap<>();
    ids.put("sku", 1L);
    ids.put("isbn", "001-AR");
    Optional<Book> foundArBook = bookRepository.findByNaturalId(ids);


    return foundArBook.orElseThrow();
}

Custom Repository를 만들었으니, Jpa Repository 빈을 활성화 시켜준다.

//file: `Main 혹은 Config`
@SpringBootApplication
@EnableJpaRepositories(repositoryBaseClass = NaturalRepositoryImpl.class)
public class MainApplication {
}

성능 개선 포인트

Hibernate 5.5 미만에서는, Optional<Book> foundArBook = bookRepository.findBySimpleNaturalId("001-AR"); 를 작동하면 2개의 쿼리가 발생된다.

  1. SELECT isbn
  2. SELECT 1로 가져온 id로 entity

5.5 이상은 아래와 같은 1개의 쿼리로 최적화 되어 있다.

  1. isbn으로 entity Select

그래서 5.5 미만은 `@NaturalIdCache , @Cache 로 최적화가 필요하다. (쿼리를 1개만 나오게 하고 싶으면)

그리고 이 NaturalId로 연관관계를 맺을 수도 있는데, 비즈니스 상으로 자연키는 언제든 수정이 가능하다고 봐야 하기 때문에 관계 맺는데 사용하지 않을 것 같다.


요구사항에 따라 Long이 아닌, 식별 번호를 부여하게 되는 경우 (ex:A-00001)

기존 Id에 적용할 수 있는 SequenceStyleGenerator를 Custom하여 @Id 등록 시에 교체해주면 된다.

CustomSequenceId 생성기 작성

//file: `CustomSequenceIdGenerator.java`
public class CustomSequenceIdGenerator extends SequenceStyleGenerator {
    public static final String PREFIX_PARAM = "prefix";
    public static final String PREFIX_DEFAULT_PARAM = "";
    private String prefix;

    public static final String NUMBER_FORMAT_PARAM = "numberFormat";
    public static final String NUMBER_FORMAT_DEFAULT_PARAM = "%d";
    private String numberFormat;

    @Override
    public Serializable generate(SharedSessionContractImplementor session, Object object) throws HibernateException {
        return prefix + String.format(numberFormat, super.generate(session, object));
    }

    @Override
    public void configure(Type type, Properties params, ServiceRegistry serviceRegistry) throws MappingException {
        super.configure(LongType.INSTANCE, params, serviceRegistry);

        prefix = ConfigurationHelper.getString(PREFIX_PARAM, params, PREFIX_DEFAULT_PARAM);
        numberFormat = ConfigurationHelper.getString(NUMBER_FORMAT_PARAM, params, NUMBER_FORMAT_DEFAULT_PARAM);
    }
}
//file: `적용 엔티티`
@Entity
public class Author implements Serializable {
    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "hilopooledlo")
    @GenericGenerator(name = "hilopooledlo",
            strategy = "com.bookstore.generator.id.CustomSequenceIdGenerator",
            parameters = {
                    @Parameter(name = CustomSequenceIdGenerator.SEQUENCE_PARAM, value = "hilo_sequence"),
                    @Parameter(name = CustomSequenceIdGenerator.INITIAL_PARAM, value = "1"),
                    @Parameter(name = CustomSequenceIdGenerator.OPT_PARAM, value = "pooled-lo"),
                    @Parameter(name = CustomSequenceIdGenerator.INCREMENT_PARAM, value = "100"),
                    @Parameter(name = CustomSequenceIdGenerator.PREFIX_PARAM, value = "A-"),
                    @Parameter(name = CustomSequenceIdGenerator.NUMBER_FORMAT_PARAM, value = "%010d")
            }
    )
    private String id;
}

sequence 알고리즘은 pooled-lo, 직접 만든 CustomSequenceIdGenerator를 사용하고, prefix와 numberFormat을 설정하여 사용한다.
나의 경우 쇼핑몰에서 정산서나 유저 등에 고유 식별형태를 부여해달라고 요청이 들어오면 사용한다.

ManyToMany의 연결 Table 격상하여 사용하는 방법

보통 ManyToMany로 관계를 맺으면 JavaCode상에는 없지만 자동으로 연결 Table을 만들어서 관계를 맺는다.
이 경우 성능상 문제도 많고 해당 연결 Table에 추가적인 컬럼이나 의미를 담을 수 없어 격상시켜 다중 ManyToOne으로 변경하는 것은 이미 유명한 사실.
그렇다면 잘맺는 방법을 알아보자.

Author, Book 엔티티 선언

//file: `Author엔티티`
@Entity
public class Author implements Serializable {
    private static final long serialVersionUID = 1L;

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

    private String name;
    private String genre;
    private int age;

    @OneToMany(mappedBy = "author",
            cascade = CascadeType.ALL, orphanRemoval = true)
    private List<AuthorBook> books = new ArrayList<>();

    //getter 등
    
    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (this == obj) {
            return true;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }

        return id != null && id.equals(((Author) obj).id);
    }

    @Override
    public int hashCode() {
        return 2021;
    }
    
    @Override
    public String toString() {
        return "Author{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", genre='" + genre + '\'' +
                ", age=" + age +
                ", books=" + books +
                '}';
    }
}
//file: `Book엔티티`
@Entity
public class Book implements Serializable {
    private static final long serialVersionUID = 1L;

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

    private String title;
    private String isbn;

    @OneToMany(mappedBy = "book",
            cascade = CascadeType.ALL, orphanRemoval = true)
    private List<AuthorBook> authors = new ArrayList<>();

    //getter 등

    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (this == obj) {
            return true;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }

        return id != null && id.equals(((Book) obj).id);
    }

    @Override
    public int hashCode() {
        return 2021;
    }

    @Override
    public String toString() {
        return "Book{" +
                "id=" + id +
                ", title='" + title + '\'' +
                ", isbn='" + isbn + '\'' +
                ", authors=" + authors +
                '}';
    }
}

기본적으로 Author(N) - Book(M)의 Many To Many관계를 Author(1) - AuthorBook(N) - Book(1)으로 변경한다.

AuthorBook 복합키 + 격상된 연결 Table

격상된 AuthorBook 엔티티는 복합키를 가지고 @MapsId로 불필요한 컬럼을 제거한다.

//file : `AuthorBook엔티티`
@Entity
public class AuthorBook implements Serializable {
    private static final long serialVersionUID = 1L;

    @EmbeddedId
    private AuthorBookId id;

    @MapsId("authorId")
    @ManyToOne(fetch = FetchType.LAZY)
    private Author author;

    @MapsId("bookId")
    @ManyToOne(fetch = FetchType.LAZY)
    private Book book;

    private Date publishedOn = new Date();

    public AuthorBook() {
    }

    public AuthorBook(Author author, Book book) {
        this.author = author;
        this.book = book;
        this.id = new AuthorBookId(author.getId(), book.getId());
    }

    public AuthorBookId getId() {
        return id;
    }
    
    public Author getAuthor() {
        return author;
    }

    public void setAuthor(Author author) {
        this.author = author;
    }

    public Book getBook() {
        return book;
    }

    public void setBook(Book book) {
        this.book = book;
    }

    public Date getPublishedOn() {
        return publishedOn;
    }

    public void setPublishedOn(Date publishedOn) {
        this.publishedOn = publishedOn;
    }

    @Override
    public int hashCode() {
        int hash = 7;
        hash = 29 * hash + Objects.hashCode(this.author);
        hash = 29 * hash + Objects.hashCode(this.book);
        return hash;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (this == obj) {
            return true;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }

        final AuthorBook other = (AuthorBook) obj;
        if (!Objects.equals(this.author, other.author)) {
            return false;
        }

        if (!Objects.equals(this.book, other.book)) {
            return false;
        }

        return true;
    }
}
//file: `AuthorBookId.java`
@Embeddable
public class AuthorBookId implements Serializable {
    private static final long serialVersionUID = 1L;

    @Column(name = "author_id")
    private Long authorId;

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

    public AuthorBookId() {
    }

    public AuthorBookId(Long authorId, Long bookId) {
        this.authorId = authorId;
        this.bookId = bookId;
    }

    public Long getAuthorId() {
        return authorId;
    }

    public void setAuthorId(Long authorId) {
        this.authorId = authorId;
    }

    public Long getBookId() {
        return bookId;
    }

    public void setBookId(Long bookId) {
        this.bookId = bookId;
    }

    @Override
    public int hashCode() {
        int hash = 7;
        hash = 31 * hash + Objects.hashCode(this.authorId);
        hash = 31 * hash + Objects.hashCode(this.bookId);
        return hash;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (this == obj) {
            return true;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }

        final AuthorBookId other = (AuthorBookId) obj;
        if (!Objects.equals(this.authorId, other.authorId)) {
            return false;
        }

        if (!Objects.equals(this.bookId, other.bookId)) {
            return false;
        }

        return true;
    }
}
//file: `생성되는 관련 테이블`
    create table author_book (
        author_id bigint not null,
        book_id bigint not null,
        published_on timestamp(6),
        primary key (author_id, book_id)
    )

복합키 주의사항

  • 복합키 클래스는 public이어야 한다.
  • Serializable을 구현해야 한다.
  • equals(), hashCode()를 구현해야 한다.

Service에서 실사용하기

@Service
public class BookstoreService {
    private final AuthorRepository authorRepository;
    private final BookRepository bookRepository;
    private final AuthorBookRepository authorBookRepository;

    public BookstoreService(AuthorRepository authorRepository, BookRepository bookRepository, AuthorBookRepository authorBookRepository) {
        this.authorRepository = authorRepository;
        this.bookRepository = bookRepository;
        this.authorBookRepository = authorBookRepository;
    }

    @Transactional
    public void addAuthorAndBook() {
        Author author = new Author();
        author.setName("Alicia Tom");
        author.setAge(38);
        author.setGenre("Anthology");

        authorRepository.save(author);

        Book book = new Book();
        book.setIsbn("001-AT");
        book.setTitle("The book of swords");

        bookRepository.save(book);

        AuthorBook authorBook = new AuthorBook(author, book);

        authorBookRepository.saveAndFlush(authorBook);

        System.out.println("Author: " + author);
        System.out.println("Book: " + book);
    }
}