
이번 강의는 엔티티 매핑 챕터를 수강했다! 이번 챕터에서는 나도 알고 있는 내용이 많기 때문에 내가 모르는 부분과 신기한(?) 내용들을 위주로 정리해보자! 😀
컬럼과 매핑을 수행할 때 사용하는 어노테이션이다. 나의 경우에는 필드와 매핑할 테이블의 컬럼 이름을 지정하기 위해 name 속성만을 사용했었는데, 그 외에도 다양한 옵션들이 존재하는 것을 확인할 수 있었다.
이 옵션은 컬럼의 데이터를 수정했을 때 DB에 INSERT 혹은 UPDATE 할 것인지를 지정하는 속성이다. 예를 들어 하단의 코드를 본다면 데이터 INSERT는 허용하지만 변경 사항에 대해서는 DB에 UPDATE를 하지 않는다는 설정이다. (insertable은 기본적으로 true이기에 updatable만 신경쓰면 된다!)
@Column(name="name", insertable = true, updatable = false)
public String name;
이 두 개는 DDL 생성할 때 제약 조건을 걸어두기 위한 속성이다. 단, unique 속성은 DDL을 생성할 때 키 이름이 랜덤하게 지정되어 식별하기 어렵기 때문에 @Table 어노테이션의 uniqueConstraints 속성으로 지정하는 것을 권장한다!

말 그대로 컬럼에 대한 정의를 직접 지정해주는 속성이다.
// @Column(name="description", columnDefinition= "varchar(100) default 'EMPTY'")
@Column(name="description", columnDefinition= "text")
public String description;
이 어노테이션은 Enum 타입을 매핑할 때 사용한다. 단, 기본값인 ORDINAL을 사용하면 안된다. 예를 들어, Enum 클래스가 존재하고 기본값 ORDINAL을 사용하는 상태에서 데이터를 삽입한다고 가정해보자.
public enum RoleType {
USER, ADMIN
}
// 데이터 삽입 코드
Member user = new Member("kevin", RoleType.USER);
Member admin = new Member("david", RoleType.ADMIN);
em.persist(user);
em.persist(admin);
그럼 데이터베이스에서는 Enum 순서를 저장하게 된다.
| name | roleType |
|---|---|
| kevin | 1 |
| david | 2 |
여기서 Enum 클래스에 GUEST를 새로 추가하면 어떻게 될까?
public enum RoleType {
GUEST, USER, ADMIN
}
기존에 저장한 데이터에서 엄청난 혼란이 오게된다. kevin은 실제론 USER지만 roleType이 1이기에 GUEST로 처리가 될 것이고, david는 실제론 ADMIN이지만 roleType이 2이기에 USER로 판별되는 심각한 오류가 발생하게 된다. 따라서 @Enumerated 어노테이션을 사용할 때는 항상 EnumType.STRING을 사용해야한다.
날짜 타입을 매핑할 때 사용하는 어노테이션이다. 하지만 최신 하이버네이트에서는 LocalDate, LocalDateTime을 사용할 때 생략이 가능하다고 한다. (나도 이 어노테이션은 한번도 사용해본적 없다. 😜 ㅎㅎ)
@Temporal(TemporalType.TIMESTAMP)
public Date createdAt;
DB의 BLOB, CLOB 타입과 매핑할 때 사용하는 어노테이션이다. 참고로 이 어노테이션에서는 지정할 수 있는 속성이 없다. 그저 매핑하는 필드의 타입이 문자일 경우 CLOB으로 매핑하고 나머지는 모두 BLOB로 매핑해준다.
필드 매핑 혹은 데이터베이스 저장과 조회와 관계없이 메모리상에서만 임시로 특정 값을 보관하고 싶을 때 사용하는 어노테이션이다.
@Transient
private Integer temp;
이번에는 기본키 전략을 살펴보자.
IDENTITY는 DB에 위임하는 기본키 자동 생성 전략이다. MySQL과 같이 SEQUENCE를 제공하지 않고, AUTO_INCREMENT 기능을 제공해 기본 키 값을 자동으로 생성하는 DBMS에서 사용한다. 이 전략은 주로 MySQL, PostgreSQL, SQL Server, DB2에서 사용한다.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
하지만 이 전략에는 숨겨진 비밀이 하나있다. (이 부분이 이번 강의에서 내가 재미있었던 포인트다!)
생각해보자. AUTO_INCREMENT의 경우 DB에 값이 들어가야만 PK를 알 수 있다. 하지만 JPA는 트랜잭션이 커밋되는 시점에 DB에 데이터를 반영한다고 했다. 그럼 1차 캐시에 필요한 PK는 어떻게 될까??
정답은 IDENTITY 전략일 때는 트랜잭션을 커밋이 아닌, 영속화를 하는 시점에 DB에 반영된다는 것이다. 또한 JDBC 드라이버에는 내부적으로 DB에 데이터를 넣는 순간에 PK값을 반환해주기 때문에 추가적인 SELECT 쿼리가 필요없다.
즉, 여기서 가장 중요한 포인트는 - IDENTITY 전략에서는 예외적으로 persist() 메서드를 호출할 때 INSERT 쿼리가 DB에 날라가기 때문에 모아서 처리(쓰기 지연)하는 것이 불가능하다는 것이다.
try {
Member member = new Member("kevin", 20, RoleType.USER, "description");
System.out.println("=== BEFORE ===");
entityManager.persist(member);
System.out.println("=== AFTER ===");
entityTransaction.commit();
} catch (Exception e) {
entityTransaction.rollback();
} finally {
entityManager.close();
}
entityManagerFactory.close();

참고로 영한님의 말씀에 따르면 버퍼링해서 쓰기 작업을 처리하는 것은 엄청난 이점을 가지지는 않는 것 같다고 하셨다. 트랜잭션을 작게 잘라내는 것은 성능에 문제가 있을 수는 있지만, 한 트랜잭션 내에서 일어나는 통신은 비약적으로 크게 영향을 미치지 않기 때문이라고 하셨다!
SEQUENCE는 DB SEQUENCE를 사용해 기본키를 할당하는 전략이다. 이 전략은 Sequence를 지원하는 Oracle, PostgreSQL, DB2, H2 DB에서 사용한다. (내가 현재 사용하고 있는 MySQL에서는 Sequence 전략을 제공하지 않기 때문에 실제 동작 과정을 살펴볼 수 없었다... 🥲)
@Entity
@SequenceGenerator(
name = "MEMBER_SEQ_GENERATOR",
sequenceName = "MEMBER_SEQ",
initialValue = 1, allocationSize = 1)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "MEMBER_SEQ_GENERATOR")
private Long id;
TABLE은 기본키 생성 테이블을 사용하는 전략이다. 키 생성 전용 테이블을 생성하기 때문에 모든 DB에서 사용이 가능하지만 호출할 때마다 테이블을 조회하기에 성능적으로 떨어지는 단점이 있다. 자주 사용하는 전략은 아니다!
@Entity
@TableGenerator(
name = "MEMBER_SEQ_GENERATOR",
table = "MY_SEQUENCES",
pkColumnValue = "MEMBER_SEQ", allocationSize = 1)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.TABLE,
generator = "MEMBER_SEQ_GENERATOR")
private Long id;
CREATE TABLE MY_SEQUENCES (
sequence_name VARCHAR(255) NOT NULL,
next_val BIGINT,
PRIMARY KEY ( sequence_name )
)

AUTO 전략은 선택한 DB 방언에 따라 자동으로 IDENTITY, SEQUENCE, TABLE 전략을 자동으로 선택한다. DB의 종류도 많고 기본 키 생성 방식도 다양하기에 이 전략은 DB를 변경해도 코드 수정을 하지 않아도 되는 장점을 가지고 있어, 키 생성 전략이 정해지지 않은 개발 초기 단계나 프로토타입 개발 시에 편리하게 사용할 수 있다.
SEQUENCE 혹은 TABLE 전략의 경우 키 값에 대한 정보를 알려면 DB에 접근해야한다. 따라서 매번 INSERT할 때마다 조회하는 것은 성능적인 문제가 있다. 그래서 SEQUENCE 및 TABLE 전략에서는 allocationSize를 이용하여 성능 최적화를 한다.
강의에서는 SEQUENCE 전략으로 최적화를 진행했지만, MySQL을 이용하는 나는 TABLE 전략을 이용하여 성능 최적화를 진행해보았다.
먼저 Member 클래스의 @TableGenerator에 allocationSize = 1로 구성했을 때를 살펴보자.
// Member.java
@Entity
@Table(name = "MEMBER")
@TableGenerator(
name = "MEMBER_SEQ_GENERATOR",
table = "MY_SEQUENCES",
pkColumnValue = "MEMBER_SEQ", allocationSize = 1)
public class Member {
@Id
@GeneratedValue(
strategy = GenerationType.TABLE,
generator = "MEMBER_SEQ_GENERATOR")
private Long id;
메인 클래스는 두 개의 Member 객체를 삽입하는 코드다.
// JpaMain.java
try {
Member member1 = new Member("kevin1", 21, RoleType.USER, "description1");
Member member2 = new Member("kevin2", 22, RoleType.ADMIN, "description2");
System.out.println("=== BEFORE ===");
entityManager.persist(member1);
entityManager.persist(member2);
System.out.println("=== AFTER ===");
entityTransaction.commit();
} catch (Exception e) {
entityTransaction.rollback();
} finally {
entityManager.close();
}
entityManagerFactory.close();

결과를 보면 BEFORE와 AFTER 사이에 키 생성 테이블의 값을 SELECT하고 UPDATE하는 쿼리가 각각 두 번씩 날라가는 것을 확인할 수 있다.
반대로 기본값인 allocationSize = 50로 설정해보자.
// Member.java
@Entity
@Table(name = "MEMBER")
@TableGenerator(
name = "MEMBER_SEQ_GENERATOR",
table = "MY_SEQUENCES",
pkColumnValue = "MEMBER_SEQ", allocationSize = 50)
public class Member {
@Id
@GeneratedValue(
strategy = GenerationType.TABLE,
generator = "MEMBER_SEQ_GENERATOR")
private Long id;

신기하게도 이번에는 BEFORE와 AFTER 사이에 키 생성 테이블의 값을 SELECT하고 UPDATE하는 쿼리가 한 번씩 날라가는 것을 확인할 수 있다.
그럼 allocationSize = 50인 상태에서 데이터를 50개 이상 집어넣으면 어떻게 될까?
// JpaMain.java
try {
Member member1 = new Member("kevin1", 21, RoleType.USER, "description1");
Member member2 = new Member("kevin2", 22, RoleType.ADMIN, "description2");
System.out.println("=== BEFORE ===");
entityManager.persist(member1);
entityManager.persist(member2);
for(int i = 0; i < 50; i++) {
entityManager.persist(new Member("test", 23, RoleType.USER, "test"));
}
System.out.println("=== AFTER ===");
entityTransaction.commit();
} catch (Exception e) {
entityTransaction.rollback();
} finally {
entityManager.close();
}
entityManagerFactory.close();

결과는 allocationSize = 1을 했을 때 처럼 INSERT와 UPDATE 쿼리가 두 번씩 날라가는 것을 확인할 수 있다.
위의 실습을 통해 유추할 수 있듯, allocationSize는 Sequence 한 번 호출에 증가하는 수를 설정하는 것이다. 쉽게 생각한다면 미리 지정한 수만큼 값을 DB에 올려놓고 메모리에 가져오는 방식이다. 실습에서의 allocationSize = 50 설정은 DB에서 PK값을 50개 미리 받아온 후 메모리에 올려두고 애플리케이션단에서 메모리에 있는 값을 영속화할 때 PK로 할당해준다는 의미다. (영상에 따르면 사이즈는 100 정도로 설정하는 것이 적당하다고 했다.)
참고로 아무것도 없는 상태에서 가장 첫 번째 데이터를 INSERT 할 때는 Sequence가 두 번씩 호출된다. (내가 실습을 했을 때도 첫 데이터를 넣을 때는 항상 두 번씩 호출되었다... 🫠)
이유는 단순하다. 처음에는 DB의 Sequence가 1이 조회된다. 하지만 성능을 최적화하려고 50개씩 메모리에 올려두자 선언했는데 50개가 넘어오지 않으니까 한번 더 호출한 것이다. 즉, 50개를 메모리에 가져와야하는데 DB에서는 50개가 할당된게 없으니까 이상하다 싶어서 한번 더 호출한 셈이다. ㅎㅎ

23.01.21
추가적으로 나는 궁금한점이 있어서 커뮤니티에 allocationSize와 관련된 질문을 올렸다. 그리고 공식 서포터즈에 의해 답변을 얻을 수 있었다.

1. 데이터가 순서대로 삽입되지 않는다.
나는 바보같이 순서만을 따졌던 것 같다. PK는 각 행을 고유하게 식별하는 역할이 더 중요하다는점을 잊고있었다..
2. 서버가 다운될 경우 Sequence 공백이 생긴다.
Sequence가 중간에 공백이 생길 수는 있지만 크게 문제가 되지 않으며, 트래픽이 엄청 많은 서비스가 아닌 이상 Sequence를 받아오는 속도가 매우 빠르기 때문에 allocationSize = 1로 설정해도 큰 영향이 없다고 한다. 결국 그렇게 크게 신경쓸 필요는 없다는 것 같다!
23.01.21
Hibernate 5부터 MySQL에서의 GenerationType.AUTO는 IDENTITY가 아닌 TABLE을 기본 시퀀스 전략으로 가져간다고한다!