
JPA에서 가장 중요한 일은 엔티티와 테이블을 정확하게 매핑하는 것이다. 그에 따라 매핑 애노테이션을 숙지할 필요가 있다. 일단 @Entity 애노테이션에 대해 알아보자.
@Entity가 부착된 클래스는 엔티티(Entity)라고 하고 비로소 JPA의 관리 대상이 된다. JPA 스펙에 따라 이 클래스는 동적 프록시 생성을 위해 파라미터가 없는 기본 생성자를 필수로 가져야 하며, 클래스나 영속화될 필드에 final 키워드를 사용할 수 없다. enum, interface, 내부 클래스 역시 엔티티로 지정할 수 없다.
@Entity의 속성에는 name이 있다. JPA가 내부적으로 구분하는 이름으로, 기본값으로는 클래스 이름이 사용된다. 만약 다른 패키지에 이름이 같은 엔티티 클래스가 있다면 이름을 지정해서 충돌되지 않도록 처리해야 한다.
@Table 애노테이션은 엔티티와 매핑할 테이블을 지정한다. 아래 그림과 같이 name 속성을 따로 지정해주면, 그 이름으로 쿼리가 날라간다. 생략하면 엔티티 이름을 테이블 이름으로 그대로 사용한다.

추가로, catalog와 schema 속성이 있다. 카탈로그 기능이 있는 DB에서 catalog를 매핑할 때 catalog를 설정하면 되고, 스키마 기능이 있는 DB에서 schema를 매핑할 때 schema를 사용하면 된다. 자세한 부분은 뒤에서 알아보자.
JPA는 클래스의 매핑 정보를 보면 어떤 테이블에 어떤 컬럼을 사용하는지 알 수 있다. 그래서 아예 애플리케이션 로딩 시점에 DB 테이블을 생성하는 기능도 지원한다. 물론, 이런 것은 운영에서 사용되면 안 된다.
그리고 데이터베이스 방언을 활용해서 DB에 맞는 적절한 DDL을 생성해준다. 예를 들어, 가변 문자 같은 경우에 Oracle은 varchar2, MySQL은 varchar같은 걸 사용한다. 이렇게 생성된 DDL은 꼭 개발에서만 사용해야 한다. 일단 확인해보기 위해 먼저 persistence.xml에 아래와 같은 코드를 추가하자.
<property name="hibernate.hbm2ddl.auto" value="create" />

한번 실행해보면, 제일 먼저 drop table ... 쿼리가 보인다. 기존에 있던 테이블을 지우고, 다시 생성하는 것이다. 예를 들어, 어떤 필드가 추가되거나 수정되었다 하더라도 DB를 건들 필요 없이 엔티티 클래스에서 코드만 수정해준다면 기존 테이블을 깔끔하게 날려주고 새롭게 테이블을 만들어 준다.
그리고 다른 속성으로는 create-drop이 있다. 이건 create와 똑같은데 애플리케이션이 종료하는 시점에 테이블을 날려버린다는 차이가 있다. 그래서 애플리케이션 종료 후, DB를 확인해보면 테이블을 조회할 수 없는 것을 확인할 수 있다.


그리고 update 속성이 있다. 이 속성은 변경된 부분만 반영해주는 것이다. 한번 실행 결과로 확인해보자. 초기에는 Member 엔티티 클래스에 id와 name밖에 없었는데 age가 추가되는 상황이다.

보다시피, 초기에 id와 name이 잘 생성됐다. 근데 이제 age 필드를 추가하고 싶은데 drop table ...로 테이블을 삭제하고 싶지 않은 것이다. 추가하고 확인해보면…

테이블이 삭제되지 않고 alter table ...로 age 필드만 추가된 것을 볼 수 있다. 여기서 주의해야 할 점은 기존의 필드를 제거하는 것은 안 된다. 당연하게도 필드를 지우게 되면 큰 혼란이 올 수 있기 때문이다.
또 다른 속성인 validate는 엔티티와 테이블이 정상 매핑되었는지를 체크해준다. 예를 들어, 새로운 필드를 하나 추가한다고 해보자. 추가한 그 필드가 DB에 없다고 오류를 던진다.

이렇게 스키마 자동 생성 기능에 대한 속성 종류에 대해 알아봤는데, 주의할 점이 있다. 운영 서버에서 create와 create-drop, update처럼 DDL을 수정하는 옵션을 절~~대로 사용하면 안 된다. 오직 개발 초기 단계에서만 사용할 것을 권장한다. update 정도는 사용해도 되지 않을까 생각이 들지만, 그렇지 않다. 물론 개발 서버에서 사용하면 편하긴 하겠지만, 데이터가 몇 천만 건 있는 상태에서 ALTER 쿼리가 나갔다가 시스템 중단 상태에 빠질 수도 있다. 그래서 가급적이면 ALTER 쿼리를 내가 직접 만든 것을 DB에 한번 테스트하는 것이 좋다.
예를 들어, 회원 이름은 필수로 입력되어야 하고, 10자를 초과하면 안 된다는 제약 사항이 있다고 가정하자. 이때 @Column 애노테이션을 사용하면 된다.
package hellojpa;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "mbr")
public class Member {
@Id
private Long id;
@Column(name = "username", nullable = false, length = 10) // 추가된 부분
private String name;
public Member() {};
}
위 코드처럼 @Column 매핑 정보에 nullable 속성 값을 false로 지정하면 자동 생성되는 DDL에 not null 제약 조건을 추가할 수 있다. 그리고 length 속성 값을 통해서는 문자의 크기를 지정할 수도 있다.
먼저 회원 클래스를 수정해보자. 회원은 일반 회원과 관리자로 구분되어야 하고, 회원 가입일과 수정일을 가지고 있어야 한다. 추가로 회원을 설명할 수 있는 길이 제한이 없는 필드가 존재해야 한다.
package hellojpa;
import jakarta.persistence.*;
import java.util.Date;
@Entity
public class Member {
@Id
private Long id;
@Column(name = "name")
private String username;
private Integer age;
@Enumerated(EnumType.STRING)
private RoleType roleType;
@Temporal(TemporalType.TIMESTAMP)
private Date createdDate;
@Temporal(TemporalType.TIMESTAMP)
private Date lastModifiedDate;
@Lob
private String description;
public Member() {};
}
근데 여기서 @Enumerated라는 애노테이션이 보인다. 자바에서는 객체에서 열거형 타입을 쓰고 싶으면 사용할 수 있지만, DB에서는 열거형 타입을 사용할 수 없다. 물론 enum 타입 비슷한게 있는 DB도 있긴 하다… 근데 기본적으로 없다고 보면 된다. 그때 이 @Enumerated 애노테이션을 사용하면 된다. 그리고 EnumType을 쓸 때 주의할 점이 있는데, EnumType 기본값은 ORDINAL과 STRING이 있다. ORDINAL은 enum의 순서를 DB에 저장하는 것이고, STRING은 enum의 이름을 그대로 DB에 저장하는 것이다.
여기서 중요한 것은 ORDINAL은 사용하면 안 된다는 점이다. 예를 들어, 기존 ENUM에 정의해둔 항목들이 있고, DB에도 번호가 지정되어 있을 것이다. 하지만, 여기서 ENUM에 어떤 항목을 추가해버린다면, 순서가 꼬여 버릴 수 있다. “무조건… EnumType.STRING을 사용하도록 하자.”
그 다음 @Temporal이라는 애노테이션도 보이는데, 얘는 날짜 타입이다. 지금 필드를 보면 생성 일자와 수정 일자가 있는데 이 필드에 @Temporal 애노테이션을 붙여줬다. 여기서 TemporalType을 들여다보면 총 3가지가 있다.
package jakarta.persistence;
public enum TemporalType {
DATE,
TIME,
TIMESTAMP;
private TemporalType() {}
}
자바는 사실 DATE 타입 안에 그냥 날짜, 시각, 날짜와 시간 모두 다 들어 있다. 하지만 DB에는 위 3가지로 구분해서 사용한다. 그렇기 때문에 매핑 정보를 줘야 한다. 하지만, 최신 Hibernate에서는 LocalDate와 LocalDateTime을 사용할 때는 생략이 가능하다.
그리고 @Lob 애노테이션이 있는데, DB에 뭔가 varchar를 넘어서는 큰 컨텐츠를 사용하고 싶을 때 붙여주면 된다. 마지막으로는 @Transient가 있는데 이건 DB와 매핑을 하고 싶지 않을 때 사용한다. 이걸 붙여주면 DB에 저장도 되지 않고, 조회도 되지 않는다. 주로 메모리상에서만 임시로 어떤 값을 보관하고 싶을 때 사용한다.
그냥 여기서는 @Column 애노테이션을 확실히 알고 넘어가는 것이 중요하다. 표로 다시 정리하고 넘어가도록 하자.
| 속성 | 설명 | 기본값 |
|---|---|---|
| name | 필드와 매핑할 테이블의 컬럼 이름 | 객체의 필드 이름 |
| insertable, updatable | 등록, 변경 가능 여부 | TRUE |
| nullable(DDL) | null 값의 허용 여부를 설정, false로 설정하면 DDL 생성 시에 not null 제약 조건이 붙음 | |
| unique(DDL) | @Table의 uniqueConstraints와 같지만, 한 컬럼에 간단히 유니크 제약 조건을 걸 때 사용 | |
| columnDefinition(DDL) | DB 컬럼 정보를 직접 줄 수 있음 | 필드의 자바 타입과 방언 정보를 사용 |
| length(DDL) | 문자 길이 제약 조건, String 타입에 만 사용 | 255 |
| precision, scale(DDL) | BigDecimal(+BigInteger) 타입에서 사용, precision은 소수점을 포함한 전체 자릿수를, scale은 소수의 자릿수 | precision = 19, scale = 2 |
지금까지는 @Id 애노테이션을 이용해서 회원의 기본키를 직접 할당했다. 하지만, DB에게 알아서 할당해달라고 할 수도 있다. 오라클의 Sequence Object라든가 MySQL의 AUTO_INCREMENT 같은 기능으로 값을 생성할 수 있다. 근데 이 생성된 값을 기본키로 어떻게 사용할 수 있을까? 이렇게 DB마다 기본키를 생성하는 방법이 다른 문제를 JPA는 어떻게 해결하는지 알아보자.
첫 번째는 그냥 지금까지 했던 것처럼 @Id 애노테이션을 사용해서 직접 할당하는 방식이 있다.
두 번째로, 자동 생성 전략을 사용하고 싶다면 @GeneratedValue 애노테이션을 원하는 키 생성 전략을 선택해서 생성하도록 하면 된다. 그렇다면 @GeneratedValue 애노테이션의 키 생성 전략에는 무엇이 있는지 알아보자.
기본키 생성을 DB에 위임하는 방식이다. 예를 들어, MySQL에서 AUTO_INCREMENT 기능은 DB가 기본키를 자동으로 생성해준다.

이제 DB에 값을 추가할 때, ID 컬럼을 비워두면 DB가 알아서 순서대로 값을 채워준다.

이렇게 @GeneratedValue 애노테이션의 IDENTITY 전략을 사용하면 JPA는 기본키 값을 얻어오기 위해 DB를 추가로 조회한다. em.persist()를 호출해서 엔티티를 저장한 직후에 할당된 식별자 값을 얻을 수 있다. 이처럼 “IDENTITY 전략은 데이터를 DB에 INSERT 한 후에 기본키 값을 조회할 수 있다.” 따라서 엔티티 식별자 값을 할당하려면 JPA는 추가로 DB를 조회해야 한다.
엔티티가 영속 상태가 되기 위해서는 식별자가 반드시 필요하다. 하지만, 위에서 봤다시피 IDENTITY 식별자 생성 전략에서는 데이터베이스 테이블에 데이터가 실제로 INSERT 되어야만 비로소 생성된 식별자 값을 획득할 수 있다.
영속성 컨텍스트 1차 캐시의 키 값은 엔티티의 식별자다. 따라서 em.persist()가 호출되는 즉시 식별자를 확보하기 위해 JPA는 쓰기 지연을 포기하고 INSERT 쿼리를 데이터베이스로 즉각 전송하게 된다. 이로 인해 대규모 데이터 삽입 시 hibernate.jdbc.batch_size를 통한 다중 JDBC Batch Insert 최적화가 원천적으로 불가능해지는 치명적인 성능 제약이 발생한다. 따라서 대용량 배치 처리에서는 이 전략을 피해야 한다.
데이터베이스 시퀀스는 유일한 값을 순서대로 생성하는 특별한 DB 오브젝트다. SEQUENCE 전략은 이 시퀀스를 사용해서 기본키를 생성하는 것이다. 우선 사용할 데이터베이스 시퀀스를 매핑해야 한다.
아래 스크린샷을 보면, member_seq_generator라는 시퀀스 생성기를 등록했다. 그리고 sequenceName으로는 member_seq를 지정했는데 이제 JPA는 이 시퀀스 생성기를 실제 DB의 member_seq 시퀀스와 매핑한다. 그리고 @GeneratedValue 애노테이션의 생성 전략을 SEQUENCE로 설정하고, 방금 등록한 시퀀스 생성기를 선택해준다. 이제부터 id 식별자 값은 member_seq_generator 시퀀스 생성기가 할당해준다.

SEQUENCE 전략은 IDENTITY 전략과 비슷하지만, 내부 동작 방식은 차이가 있다. SEQUENCE 전략은 em.persist()를 호출할 때 먼저 데이터베이스 시퀀스를 사용해서 식별자를 조회한다. 그리고 조회한 식별자를 엔티티에 할당한 후에 엔티티를 영속성 컨텍스트에 저장한다. 그 이후에 트랜잭션을 커밋해서 플러시가 일어나면 엔티티를 DB에 저장한다.
키 생성 전용 테이블을 하나 만들어서 데이터베이스 시퀀스를 흉내내는 전략이다. 어떤 DB에는 AUTO_INCREMENT, 어떤 DB에는 SEQUENCE가 있는데, 테이블 전략은 이런 생각할 필요 없이 모든 DB에 적용할 수 있다. 테이블 하나를 만들어서 거기서 키를 계속 생성하는 것이다. 잘 사용되지는 않는다. 간단하게 살펴보고 넘어가자.


먼저 @TableGenerator를 사용해서 테이블 키 생성기를 등록한다. 그리고 member_seq_generator라는 이름의 테이블 키 생성기를 등록하고 방금 생성한 my_sequences 테이블을 키 생성용 테이블로 매핑했다. 그리고 아래 @GeneratedValue의 생성 전략으로 GenerationType.TABLE을 선택해줬다. 그리고 @GeneratedValue.generator에 방금 만든 테이블 키 생성기를 지정했다. 그럼 이제부터 id 식별자 값은 member_seq_generator 테이블 키 생성기를 할당한다.
TABLE 전략은 SEQUENCE 전략과 매우 유사하다. 그냥 시퀀스 대신에 테이블을 사용한다는 점만 다르다.
지금까지의 내용을 정리하자면, 영속성 컨텍스트는 엔티티를 식별자 값으로 구분하기 때문에 엔티티를 영속 상태로 만들기 위해서는 식별자 값이 반드시 있어야 한다고 했다.
em.persist()를 호출한 직후에 발생하는 일을 식별자 할당 전략별로 요약하면 아래와 같다.
직접 할당: em.persist()를 호출하기 전에 애플리케이션에서 직접 식별자 값을 할당해야 한다. 만약 식별자 값이 없으면 예외가 발생한다.
SEQUENCE: 데이터베이스 시퀀스에서 식별자 값을 획득한 후 영속성 컨텍스트에 저장한다.
TABLE: 데이터베이스 시퀀스 생성용 테이블에서 식별자 값을 획득한 후 영속성 컨텍스트에 저장한다.
IDENTITY: 데이터베이스에 엔티티를 저장해서 식별자 값을 획득한 후 영속성 컨텍스트에 저장한다. 이때 주의해야 할 점은 테이블에 데이터를 저장해야 식별자 값을 획득할 수 있다는 것이다.
기본키는 NULL값을 허용하지 않고, 유일해야 하며, 변하면 안 된다는 조건을 모두 만족해야 한다. 처음 2개 조건은 쉽지만, “변하면 안 된다” 는 점이 어렵다. 미래까지 이 조건을 만족하는 자연키는 찾기 어렵기 때문이다. 그래서 “대리키를 사용하는 것을 권장” 한다.
예를 들어, 자연키인 전화번호를 기본키로 선택하면 그 번호가 유일할 수 있지만, 전화번호가 없을 수도 있고 변경될 수도 있다. 따라서 기본키로 적당하지 않다. 하지만 진짜 문제가 될 수 있는 것은 주민등록번호처럼 그럴 듯하게 보이는 값이다. 주민등록번호는 NULL값이 아니고 유일하며 변하지 않는다는 조건들을 모두 만족하는 것처럼 보인다. 하지만 현실과 비즈니스 규칙은 생각보다 쉽게 변한다. 주민등록번호도 예외가 아닐 수 있다.