[Spring Boot + JPA + PostgreSQL] 다양한 기본키(PK) 생성 방식과 주의사항

SungBum Park·2022년 6월 1일
0

해당 글의 예제 코드는 이 링크를 참조해주세요.

JPA에서 엔티티의 PK 생성 방법은 여러가지가 존재합니다. JPA에서 제공해주는 방식과 이를 PostgreSQL에서 사용했을 때, 어떻게 동작하는지 그리고 어떤 주의할 사항이 있는지 살펴보겠습니다.

1. @GeneratedValue 사용하지 않는 경우

@GeneratedValue 어노테이션을 사용하지 않는 경우, 반드시 엔티티를 생성할 때, 직접 id 값을 지정해주어야 한다. 그렇지 않은 경우, 아래와 같은 에러가 발생한다.

ids for this class must be manually assigned before calling save()

엔티티를 저장하는 save() 메서드 호출 전에 id값이 명시되어 있어야 한다는 오류이다.

id에 넣을 값은 DB의 PK 속성에 알맞는 값을 사용해야 한다.

DB의 PK(Primary Key) 제약조건
1. 테이블당 오직 하나의 필드에만 설정가능하다.
2. 중복되지 않는 고유한 값이어야 한다.
3. NULL 값이 아니다.

주민등록 번호나 휴대폰 번호와 같은 자연키로는 PK 제약조건을 만족하기 어렵다. (해당 시점에는 만족하더라도, 시간이 지나면서 만족하지 않는 경우가 많이 생긴다.)

따라서, 자연키 대신으로 UUID, 랜덤 값, 자동증가 값 등을 사용할 수 있다. 하지만, 대부분 직접하기 보다는 DB에 이러한 키 생성을 맡긴다. (@GeneratedValue 사용)

예제

UUID를 사용하는 예제를 잠깐 살펴보자.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
@Entity
public class MemberUUIDKey implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    private UUID id;
    private String name;
    private String email;

    public MemberUUIDKey(UUID id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }
}

아래 테스트를 동작시켜보자.

@Test
@Rollback(value = false) // commit 실행으로 insert 쿼리까지 보기 위함.
void save() {
    // id = null 삽입 시, ids for this class must be manually assigned before calling save() 에러 발생
    MemberUUIDKey member = new MemberUUIDKey(UUID.randomUUID(), "parker", "parker@gmail.com");

    MemberUUIDKey saved = memberUUIDKeyRepository.save(member);

    assertThat(saved).isNotNull();
    log.info("{}", saved);
}

테스트 실행 결과에서 SQL은 다음과 같은 순서로 동작한다.

# 테스트 실행마다, 테이블을 새로 생성한다.
create table memberuuidkey (
   id uuid not null,
    email varchar(255),
    name varchar(255),
    primary key (id)
)

# log.info에서 saved를 먼저 조회한다.(영속성 컨텍스트에서 조회?)
select
    memberuuid0_.id as id1_4_0_,
    memberuuid0_.email as email2_4_0_,
    memberuuid0_.name as name3_4_0_ 
from
    memberuuidkey memberuuid0_ 
where
    memberuuid0_.id=?

# Commit 시점에 insert 쿼리가 날라간다.
insert into memberuuidkey (email, name, id) values (?, ?, ?)

2. @GeneratedValue 사용하는 경우

2.1. @GeneratedValue(strategy = GenerationType.TABLE) (비권장)

  • 키 설정을 위한 테이블 생성
    • 기본 설정으로 hibernate_sequences 테이블 생성
    • @SequenceGenerator 로 키 설정 테이블 설정 가능
  • 단점
    • 키 설정을 위한 새로운 테이블이 생성되어야 함.
    • 최적화하기 힘듦. (키 설정을 위한 테이블을 생성하기 보다는 DB마다 최적화된 방법을 사용하는 것이 좋다.)

엔티티

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.TABLE)
    private Long id;
    private String name;
    private String email;

    public Member(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }
}

테스트 코드 (모든 전략에서 같은 테스트 코드이므로, 아래 전략부터는 생략함.)

@Test
@Rollback(value = false) // commit 실행으로 insert 쿼리까지 보기 위함.
void save() {
    Member member = new Member(null, "parker", "parker@gmail.com");

    Member saved = memberRepository.save(member);

    assertThat(saved).isNotNull();
    log.info("{}", saved);
}

JPA 동작 순서

# 키 설정용 테이블 생성
create table hibernate_sequences (
   sequence_name varchar(255) not null,
    next_val int8,
    primary key (sequence_name)
)

# 기본값으로 0 세팅
insert into hibernate_sequences(sequence_name, next_val) values ('default',0)

# Member 테이블 생성
create table member (
   id int8 not null,
    email varchar(255),
    name varchar(255),
    primary key (id)
)

# 키 설정 테이블에서 id로 사용할 값 조회
select
    tbl.next_val 
from
    hibernate_sequences tbl 
where
    tbl.sequence_name=? for update
        of tbl

# 다음 사용을 위해 id 업데이트
update
    hibernate_sequences 
set
    next_val=?  
where
    next_val=? 
    and sequence_name=?

# log.info 조회 결과

# Commit 시점에 insert 쿼리가 날라간다.
insert into memberuuidkey (email, name, id) values (?, ?, ?)

직접 DB툴에서 동작시켜보면, 위처럼 테이블이 생성된 것이 보인다.

2.2. @GeneratedValue(strategy = GenerationType.IDENTITY) (권장)

  • 키 생성을 DB에 위임한다.
  • 이 전략에서 실제 키 값을 알기위해서는 DB에 실제 매핑된 시점(Commit 이후)에만 알 수 있다.
    • 따라서, persist() 메서드 호출 시점에 바로 insert 쿼리가 발생한다. (원래는 commit 시점에 보낸다.)
    • JPA에서는 원래 commit 시점에 모아서 변경 쿼리를 보내는데, 이 이점을 사용할 수는 없다. (큰 단점은 아니다.)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
@Entity
public class Member {

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

    public Member(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }
}
create table member (
       id int8 generated by default as identity,
        email varchar(255),
        name varchar(255),
        primary key (id)
    )

# save() 시점에 바로 insert 쿼리 발생
insert into memberuuidkey (email, name) values (?, ?)

# log.info 조회에서 해당 member의 id 값 조회
select
        currval('member_id_seq')

member_id_seq 은 테이블은 아니고, PostgreSQL에서 자체적으로 만든 Sequence이다.

  • Sequence의 이름 생성 방식은 {테이블 이름}_{id 컬럼 이름}_seq 이다.

2.2.1 Sequence 길이 주의사항

PostgreSQL에서 테이블 또는 Sequence 이름 길이는 최대 영문 63글자(63bytes) 이내이다. (참고 링크)

위를 초과하면 최대길이를 넘어간 부분은 생략하여 생성하게 된다.

하지만, 여기서 더 주의할 점은 Spring Boot의 설정 hibernate.temp.use_jdbc_metadata_defaults 값은 기본적으로 true로 설정되어 있는데, 이 설정은 JDBC 환경구성을 하는 과정에서 기본 메타 데이터를 사용할지에 대한 설정이다. 이를 사용할 때, 데이터베이스 자체와는 다른 JDBC 자체 설정들이 포함하여 기존의 생각과 다르게 동작할 때가 많다.

여기서는 Sequence의 최대길이가 넘어가는 경우에 위 설정이 true인 경우에는 생략된 시퀀스도 인식을 하지만, false인 경우에는 인식을 하지 못한다.

아래 예제를 살펴보자.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
@Entity
public class ThisIsVeryLongNameTable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "this_is_very_long_long_long_long_id", updatable = false, nullable = false)
    private Long id;
    private String data;

    public ThisIsVeryLongNameTable(Long id, String data) {
        this.id = id;
        this.data = data;
    }
}

위 엔티티에서 Sequence의 이름은 this_is_very_long_name_table_this_is_very_long_long_long_long_id_seq 가 될 것이며, 총 길이는 68bytes이다.

따라서 63bytes가 넘어간 부분은 아래와 같이 생략해서 시퀀스가 생성된다.

hibernate.temp.use_jdbc_metadata_defaults=true 인 경우 아래 테스트를 동작시켜보자.

@Test
void save() {
    ThisIsVeryLongNameTable table = new ThisIsVeryLongNameTable(null, "TEMP");

    ThisIsVeryLongNameTable saved = thisIsVeryLongNameTableRepository.save(table);

    assertThat(saved).isNotNull();
    log.info("{}", saved);
}

정상적으로 통과하는 것을 볼 수 있다.

hibernate.temp.use_jdbc_metadata_defaults=false 로 변경해서 다시 한 번 테스트를 돌려보자.

ERROR: relation "this_is_very_long_name_table_this_is_very_long_long_long_long_i" does not exist

위 에러가 발생한다.

select
        currval('this_is_very_long_name_table_this_is_very_long_long_long_long_id_seq')

위 쿼리로 풀 네임의 Sequence를 조회하려고 하지만, 실제로는 생략된 시퀀스가 생성되어 있어 찾지 못해 에러가 발생한다.

(hibernate.temp.use_jdbc_metadata_defaults=true 인 경우, 위 select currval... 쿼리도 생략이 되는데… 쿼리 자체를 내부적으로 다르게 호출하는 듯하기도 하다.)

결론은 위 설정이 false가 필요하다면, 테이블과 id의 컬럼 길이에 대해서 신경을 써주어야 한다.

2.3. @GeneratedValue(strategy = GenerationType.SEQUENCE)

  • DB의 Sequence Object를 활용한다.
    • PostgreSQL은 hibernate_sequence 이름의 시퀀스가 생성된다.
    • TABLE 전략과 비슷한 이름으로 생성되지만, 위는 테이블이 아닌 시퀀스에 생성된다.
    • @SequenceGenerator 어노테이션으로 설정을 변경할 수 있다.
  • id가 필요한 경우, 해당 시퀀스에서 조회를 해야 해서 id 조회를 위한 쿼리가 따로 발생한다.
    • allocationSize 설정을 1보다 큰 값으로 설정하면, 쿼리가 매번 나가지 않도록 최적화할 수 있다. (동시성도 모두 DB 자체에서 처리되어 있다.)
    • allocationSize 는 50 ~ 100 사이를 권장한다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
@Entity
public class Member {

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

    public Member(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }
}
create sequence hibernate_sequence start 1 increment 1

create table member (
       id int8 not null,
        email varchar(255),
        name varchar(255),
        primary key (id)
    )

select nextval ('hibernate_sequence')

# log.info로 member 조회 완료

# Commit 시점에 insert 쿼리가 날라간다.
insert into memberuuidkey (email, name, id) values (?, ?, ?)

2.4. @GeneratedValue(strategy = GenerationType.AUTO)

  • JPA에서 DB에 따라 자동으로 키 생성 전략을 선택한다.
  • hibernate.id.new_generator_mappings 의 설정에 따라 선택 기준이 달라진다.
  • PostgreSQL에서는 SEQUENCE 전략을 사용함.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String name;
    private String email;

    public Member(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }
}
create sequence hibernate_sequence start 1 increment 1

create table member (
       id int8 not null,
        email varchar(255),
        name varchar(255),
        primary key (id)
    )

select
        nextval ('hibernate_sequence')

# log.info로 member 조회 완료

# Commit 시점에 insert 쿼리가 날라간다.
insert into memberuuidkey (email, name, id) values (?, ?, ?)

참고자료

profile
https://parker1609.github.io/ 블로그 이전

0개의 댓글