2024.10.25 업데이트
이제 JPA에서 역시 제일 중요한 부분 중 하나인 객체와 테이블 매핑에 대해서 알아보자.
Member
package org.example.entity;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(uniqueConstraints = {
@UniqueConstraint(name = "UniqueName", columnNames = { "name" }),
@UniqueConstraint(name = "UniqueEmail", columnNames = { "email" })
})
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
// ! strategy는 크게 4가지가 있다.
// ! IDENTITY = DB에게 기본키 생성을 위임
// ! SEQUENCE = DB에 Sequence 오브젝트를 만들어내서 그 오브젝트에서 다음 값, 또 다음 값을 꺼내서 할당
// ! TABLE = 키 생성 전용 테이블을 하나 만들어서 데이터베이스 시퀀스를 흉내내는 전략
// ! AUTO = 위 3개중에 아무거나 하나로 자동 지정
private Long id;
//! 컬럼 매핑
@Column(nullable = false)
private String email;
//! 컬럼 매핑
@Column(nullable = false)
private String name;
//! 날짜 타입 매핑
private LocalDateTime createdAt;
//! 날짜 타입 매핑
private LocalDateTime lastModifiedAt;
//! Enum Type (일반적으로 DB엔 ENUM이 없음)
//! 기본이 EnumType이 ORDINAL인데 이건 데이터베이스에 ENUM의 순서를 저장하기 때문에 좋지 않음 STRING으로 쓸 것
@Enumerated(EnumType.STRING)
private RoleType roleType;
//! VARCHAR 보다 더 큰 값이 들어가야 할 때
@Lob
private String description;
//! 특정 필드를 컬럼으로 생성하는게 아니라 그냥 객체 안에서 메모리에서만 관리하고자 하는 경우
@Transient
private String temp;
}
- @Entity 애노테이션이 붙은 클래스는 JPA가 관리하게 된다. 그 JPA가 관리하는 클래스를 엔티티라고 보통 칭한다.
- JPA를 사용해서 테이블과 매핑할 클래스에는 이렇게 반드시 @Entity 애노테이션이 붙어야 한다. (JPA가 애플리케이션을 띄울 때, 이 애노테이션이 달린 클래스들을 관리하고 테이블로 만들고 등등의 리플렉션 작업이 일어나기 때문에)
- 기본 생성자가 필수이다. 접근 제어자는 public 또는 protected 생성자로 가능하다. (이 역시도 JPA가 애플리케이션을 띄울 때, 동적으로 객체를 만들어야 하고 그때 리플렉션을 활용해서 기본 생성자로부터 객체를 만들기 때문에)
- final 클래스, enum, interface, inner 클래스로는 엔티티를 만드는 것이 불가능하다.
- 데이터베이스에 저장할 필드에 final 사용은 할 수 없다.
어 그럼 이 테이블을 어떻게 만드나요?
직접 데이터베이스에서 만들면 그게 가장 좋다. 근데, 개발이나 테스트 환경에서는 굳이 그럴 필요 없이 빨리 진행하는 게 더 효율적일 수가 있는데, 그때 사용하는 옵션이 바로 `hibernate.hbm2ddl.auto`이다.
먼저 이 옵션의 속성값들은 뭐가 있는지부터 살펴보자.
옵션 | 설명 |
create | 기존 테이블 삭제 후 다시 생성 (DROP + CREATE) |
create-drop | create이랑 비슷한데, 종료 시점에 테이블을 DROP |
update | 변경분만 반영 |
validate | 엔티티와 테이블이 정상 매핑되었는지만 확인, 정상적이지 않다면 에러를 발생시키고 애플리케이션 종료 |
none | 사용하지 않음 |
이렇게 5가지 옵션을 사용할 수 있고, 개발상에서는 그냥 CREATE, UPDATE, CREATE-DROP을 사용해서 빨리 빨리 테이블을 만들면 더 효율적으로 사용할 수 있다.
위 사진과 같이 옵션을 주면 실행할 때, 우선 테이블을 싹 다 삭제한 후, 엔티티 클래스를 테이블로 새로 만들어 준다.
그럼 이 기능을 사용해서 개발할땐 빨리 빨리 진행이 가능해 지겠지. 테이블 만드는 스크립트까지 굳이 작성 안해도 되니까.
그런데, 주의할점은 운영에서는 절대로 그 어떤 옵션도 사용하지말자.
굳이 굳이 사용할려면 가능한게 validate 정도이고, 나머지 옵션은 생각도 하면 안된다. 데이터가 어떻게 보면 회사에서 가장 중요한 정보이자 자산인데, 시스템이 자동화한 작업에 데이터를 믿고 맡길 수 있겠는가? 어떤 변수가 일어날지 알고 말이다.
@Column
가장 간단한 @Column 어노테이션부터 살펴보면, 이 어노테이션이 붙으면 필드를 컬럼과 매핑하겠다는 의미이다.
컬럼안에 옵션으로 nullable, unique, length 등 여러 옵션을 줄 수 있고, 이 옵션은 자바와는 상관없이 JPA에만 영향을 주는 것들이다.
이 애노테이션을 확인하는 부분이 JPA에서 리플렉션을 활용할 때이다. 그래서 어떤 제약조건이 있는지 체크해서 데이터베이스에 테이블을 만들거나 검증할 때 사용하는 거라서 자바 객체와는 연관은 없다!
만약, 속성을 추가할 필요가 없으면 이 @Column은 굳이 사용하지 않아도 알아서 데이터베이스에 컬럼으로 잘 추가된다.
@Column 속성들
속성 | 설명 | 기본값 |
name | 필드와 매핑할 테이블의 컬럼 이름 | 객체의 필드 이름 |
insertable | 등록 가능 여부 | true |
updatable | 변경 가능 여부 | true |
nullable (DDL) | null 값의 허용 여부를 설정한다. false로 설정하면 DDL 생성시에 not null 제약 조건이 붙는다. | true |
unique (DDL) | @Table의 uniqueConstraints와 같지만, 한 컬럼에 간단히 유니크 제약조건을 걸 때 사용한다. | false |
columnDefinition (DDL) | 데이터베이스 컬럼 정보를 직접 줄 수 있다. 예) varchar(100) default 'EMPTY' |
|
length (DDL) | 문자 길이 제약 조건, String 타입에만 허용 | 255 |
precision, scale (DDL) | BigDecimal 타입에서 사용한다(BigInteger도 가능). precision은 소수점을 포함한 전체 자릿수를, scale은 소수의 자릿수다. 참고로 double, float 타입에는 적용되지 않는다. 아주 큰 숫자나 정밀한 소수를 다루어야 할때만 사용한다. | precision = 19, scale=2 (이렇게 설정하면 전체 자리수가 19자리의 숫자이며 소수점은 2자리까지 표시하겠다는 의미가 된다) |
Timestamp
데이터베이스에 시간 관련 필드를 만들고자 하면 자바에서는 그냥 LocalDateTime 타입을 사용하면 된다.
@Enumerated
자바에서 ENUM 클래스를 사용해서 필드를 만들고 싶을 때가 더러 있다. 그러나 DB는 일반적으로 ENUM 타입은 존재하지 않고 그럴 때 이렇게 @Enumerated 라는 어노테이션을 붙이면 ENUM 클래스를 필드로 매핑할 수 있다.
여기서 중요한 건 EnumType.STRING 옵션이다. 왜 이게 중요하냐면 기본적으로 EnumType은 ORDINAL이 기본값인데 이것을 사용하면 안된다. 이 값은 데이터베이스에 ENUM의 순서를 값으로 사용한다는 뜻인데 이건 큰 문제가 하나 있다.
다음 코드를 보자.
package org.example.entity;
public enum RoleType {
USER, ADMIN
}
나는 두개의 ENUM Data가 있다. 이것을 데이터베이스에 필드로 선언하고 유저를 생성하는 코드를 짜보자.
package org.example;
import org.example.entity.Member;
import org.example.entity.RoleType;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
// ! EntityManagerFactory 이 녀석은 서비스가 띄워지면 딱 한개만 생성되어야 한다.
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
// ! EntityManager 이 녀석은 어떤 디비에 작업을 할 때마다 하나씩 만들어지고 작업이 끝나면 버려져야 한다.
EntityManager em = emf.createEntityManager();
// ! 모든 데이터베이스에 대한 변경 작업은 트랜잭션 안에서 일어나야 한다. (조회는 꼭 트랜잭션 안에서가 아니더라도 상관없다)
// ! 하나의 트랜잭션에서 원하는 작업을 끝내고 그 트랜잭션안에서 커밋을 해줘야 변경이 적용된다.
EntityTransaction tx = em.getTransaction();
// ! Transaction 시작
tx.begin();
try {
/* CREATE */
Member member = new Member();
member.setName("helloC");
member.setRoleType(RoleType.USER);
em.persist(member);
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
RoleType.USER로 생성할 멤버의 롤 타입을 지정해서 유저를 생성했다. 데이터베이스에는 어떻게 들어갈까?
보다시피 0이라는 값으로 들어간다. 이번엔 ADMIN으로 생성해보자.
ADMIN은 1로 생성이 된다. 이 0과 1은 ENUM의 순서를 의미한다. 근데 만약, 비즈니스 요구사항이 변경된다면 ?
"GUEST라는 롤도 추가해주세요"
이런 요구사항이 들어와서 이렇게 코드를 수정했다. 근데 하필 GUEST를 맨 앞에 추가했다!
package org.example.entity;
public enum RoleType {
GUEST, USER, ADMIN
}
이제 GUEST를 추가하면 어떤 일이 일어날까?
그렇다. 0으로 추가된다. 왜냐하면 GUEST를 맨 앞에 두었으니까. 이게 ORDINAL의 가장 큰 문제다. 이를 방지하기 위해서 STRING 타입으로 꼭 변경해야 한다.
@Lob
VARCHAR(255)보다 큰 매우 큰 문자열이 필요한 경우 @Lob을 사용한다. 대부분은 이 경우에서 다 끝나는데 이 Lob은 두가지 타입이 있다. BLOB, CLOB.
매핑하는 필드 타입이 문자면 CLOB 매핑, 나머지는 BLOB 매핑이 된다.
- BLOB → String, char[], java.sql.CLOB
- CLOB → byte[], java.sql.BLOB
@Transient
데이터베이스에 컬럼으로 추가하고 싶진 않지만 객체 메모리 상에서 관리할 데이터가 있을 때 이 어노테이션을 사용하면 데이터베이스에는 컬럼으로 추가되지 않는다.
@UniqueConstraint
이건 뭐냐면 일반적으로 필드에 unique 속성을 추가하고 싶다면 그냥 @Column(unique = true)라고 추가하면 되는데 이게 주는 문제는 제약의 이름이 마구잡이로 생성된다. 한번 예시를 보자.
@Column(nullable = false, unique = true)
private String name;
이렇게 하고 데이터베이스 테이블을 만들어보면 다음과 같은 SQL문이 출력된다.
보다시피 알 수 없는 이름으로 제약조건이 생성된다. 난 이게 싫다. 로그를 보고 알아 볼 수 있어야 문제가 생겼을 때도 수월하게 해결이 가능하니까. 이를 해결하기 위해서 @UniqueConstraint 어노테이션으로 제약 조건의 이름을 직접 명시한다. 바로 아래처럼.
@Table(uniqueConstraints = {
@UniqueConstraint(name = "UniqueName", columnNames = { "name" }),
@UniqueConstraint(name = "UniqueEmail", columnNames = { "email" })
})
저기서 name이 바로 제약조건의 이름이 된다. columnNames는 컬럼명을 의미한다.
이렇게 하고 다시 테이블을 만들게 해보면 다음과 같은 로그가 찍힌다.
@Id, @GeneratedValue
가장 중요한 부분인 기본키 생성과 전략이다. 우선 모든 테이블은 PK가 필요한데 그 PK를 생성하기 위해 @Id 라는 어노테이션을 붙인다. 그럼 JPA는 아 이 필드가 기본키가 될 녀석이구나라고 인식한다. 그럼 @GeneratedValue는 무엇일까? 기본키를 어떻게 생성할지에 대한 정보이다.
@GeneratedValue 어노테이션은 기본키 생성 전략을 가지는데 그 전략은 크게 4가지가 있다.
- IDENTITY: 데이터베이스에게 기본키 생성을 위임 (MySQL의 auto_Increment 같은)
- SEQUENCE: 데이터베이스 시퀀스를 생성하고 그 시퀀스의 값을 기본키로 지정
- TABLE: 기본키를 위한 테이블을 하나 만들고 데이터베이스 시퀀스를 흉내내는 전략
- AUTO: DB 방언(Dialect)에 따라 자동 지정 (기본값)
여기서 중요한건 IDENTITY와 SEQUENCE다.
IDENTITY는 데이터베이스에게 완전 위임을 하는 것이기 때문에 데이터베이스에 데이터가 추가가 되기 전까지 INSERT 대상의 객체 기본키를 알 수 없다. 그럼 여기서 의문이 생긴다.
분명히 엔티티 매니저가 persist()를 호출하면 영속 컨텍스트에 영속시키고 그 영속시킬 때 1차 캐시에 기본키를 저장하는데 INSERT가 되기 전까지 기본키를 알 수 없는 IDENTITY 전략은 어떻게 하지?
그래서! 이 IDENTITY 전략은 persist()를 할 때 영속시키기 전 데이터베이스에 추가한다. 로그로 바로 확인해볼 수 있다.
package org.example;
import org.example.entity.Member;
import org.example.entity.RoleType;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
// ! EntityManagerFactory 이 녀석은 서비스가 띄워지면 딱 한개만 생성되어야 한다.
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
// ! EntityManager 이 녀석은 어떤 디비에 작업을 할 때마다 하나씩 만들어지고 작업이 끝나면 버려져야 한다.
EntityManager em = emf.createEntityManager();
// ! 모든 데이터베이스에 대한 변경 작업은 트랜잭션 안에서 일어나야 한다. (조회는 꼭 트랜잭션 안에서가 아니더라도 상관없다)
// ! 하나의 트랜잭션에서 원하는 작업을 끝내고 그 트랜잭션안에서 커밋을 해줘야 변경이 적용된다.
EntityTransaction tx = em.getTransaction();
// ! Transaction 시작
tx.begin();
try {
/* CREATE */
Member member = new Member();
member.setName("helloCCC");
member.setId(1L);
member.setRoleType(RoleType.GUEST);
member.setEmail("test33");
System.out.println("BEFORE");
em.persist(member);
System.out.println("AFTER");
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
위 코드를 실행해보자. 지금 멤버에 엔티티에 @GeneratedValue 애노테이션을 아예 사용하지 않은 상태다. 즉, 내가 직접 지정한다는 의미이다. 이 상태에서는 절대 절대 트랜잭션 커밋이 일어나기 전까지 SQL문이 보이지 않을 것이다.
로그는 BEFORE, AFTER가 찍히고 난 후 INSERT문이 실행됐다. 당연하다. persist()는 영속 컨텍스트에 영속시킬 뿐 데이터베이스에 뭔가를 작업하는 단계가 아니니까.
근데 여기서 전략을 IDENTITY로 해보면 어떨까? persist()를 호출할 때 INSERT문이 보일것이다.
보다시피 `BEFORE`와 `AFTER` 사이에 INSERT문이 실행됐다. 즉, 영속 컨텍스트에 객체를 담기 위해선 기본키가 필요하고 IDENTITY 전략은 데이터베이스에 추가되기 전까지 기본키 값을 알 수 없기 때문에 내부 메커니즘이 IDENTITY일 경우 persist()를 호출하면 데이터베이스에 항상 SQL문을 날리는 것이다. 이 말은 INSERT 문에 한하여 쓰기 지연 SQL문은 IDENTITY에서 없다는 뜻이다.
반면, SEQUENCE는 데이터베이스의 시퀀스를 만들고 그 시퀀스를 가지고 기본키를 적용하는 전략이다. 그럼 생각해보자. 역시나 영속 컨텍스트에 객체를 보관하기 위해 기본키가 필요하고 전략이 SEQUENCE라면 persist()가 호출될 때 데이터베이스의 시퀀스를 조회하지 않을까? 맞다. (참고로 TABLE도 SEQUENCE랑 동일하게 작동한다 이 부분에서)
아래 사진은 전략을 SEQUENCE로 변경 후 새로운 멤버를 추가했을 때 로그 출력 결과다.
persist()가 호출될 때 시퀀스로부터 다음값을 먼저 불러온다. 왜냐하면 그래야 persist(member)가 호출될 때 이 멤버의 기본키를 알 수 있기 때문이다.
그럼 이 SEQUENCE 전략에선 이런 생각을 할 수 있다.
그럼 시퀀스를 가져오는 것 한 번, 데이터베이스에 INSERT 쿼리를 날리는 것 한 번해서 쓸데없이 두번 왔다 갔다 해야 하잖아? 성능에 좋지 않겠다.
어느 정도 합리적이다. 그러나 이에 대한 성능 최적화를 위해 allocationSize라는 속성이 있다.
@Entity
@SequenceGenerator(
name = "MEMBER_SEQ_GENERATOR",
sequenceName = "MEMBER_SEQ",
initialValue = 1,
allocationSize = 100
)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBER_SEQ_GENERATOR")
private Long id;
}
이렇게 시퀀스를 생성할 때 이름도 지정하고 기본값들을 지정할 수 있는 @SequenceGenerator 어노테이션이 있는데 여기서 allocationSize를 100으로 지정하면 메모리가 미리 시퀀스 100개를 가지게 되는 것과 동일하다.
그러면 영속 컨텍스트에 객체를 보관하기 위해 시퀀스 값을 가져오는 횟수는 객체를 저장한 개수가 100개가 될 때까지 딱 2번이다.
왜 두번이냐면, 우선 시퀀스는, 내가 allocationSize를 100으로 설정하면 시퀀스값을 호출할 때 데이터베이스 시퀀스가 100개씩 증가한다. 호출을 아예 한번도 하지 않으면 1인 상태이다. 그러면 애플리케이션에서 최초 호출을 했을 때 1을 받는다. 근데 allocationSize를 100으로 할당했는데 1이 나왔으니, "아 지금 최초 호출도 하지 않은 상태이구나!?"로 애플리케이션이 판단하고 한번 더 호출한다. 그럼 그때 데이터베이스 시퀀스가 100으로 증가하면서 메모리에 1부터 100까지를 미리 올려놓는다. 이후부터는 같은 객체에 대해 시퀀스값을 가져오는건 데이터베이스를 거치지 않고 메모리에 올라와있는 값을 사용하면 되는것이다. 언제까지? 100을 사용할때까지.
그래서 멤버가 100명이 될 때까지 시퀀스를 땡겨오지 않는다. 이것도 역시 로그로 직접 확인해보자.
package org.example;
import org.example.entity.Member;
import org.example.entity.RoleType;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
// ! EntityManagerFactory 이 녀석은 서비스가 띄워지면 딱 한개만 생성되어야 한다.
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
// ! EntityManager 이 녀석은 어떤 디비에 작업을 할 때마다 하나씩 만들어지고 작업이 끝나면 버려져야 한다.
EntityManager em = emf.createEntityManager();
// ! 모든 데이터베이스에 대한 변경 작업은 트랜잭션 안에서 일어나야 한다. (조회는 꼭 트랜잭션 안에서가 아니더라도 상관없다)
// ! 하나의 트랜잭션에서 원하는 작업을 끝내고 그 트랜잭션안에서 커밋을 해줘야 변경이 적용된다.
EntityTransaction tx = em.getTransaction();
// ! Transaction 시작
tx.begin();
try {
/* CREATE */
System.out.println("BEFORE");
Member member = new Member();
member.setName("helloCCC");
member.setRoleType(RoleType.GUEST);
member.setEmail("test33");
Member member2 = new Member();
member2.setName("helloBBB");
member2.setRoleType(RoleType.GUEST);
member2.setEmail("test22");
Member member1 = new Member();
member1.setName("helloAAA");
member1.setRoleType(RoleType.GUEST);
member1.setEmail("test11");
em.persist(member);
em.persist(member2);
em.persist(member1);
System.out.println("AFTER");
tx.commit();
} catch (Exception e) {
System.out.println(e.getMessage());
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
자, 위 사진에서 볼 수 있듯 시퀀스의 값은 두 번만 호출했다. 이제 100개의 시퀀스가 채워질 때 까지 더 이상 시퀀스를 데이터베이스로부터 가져오지 않는다. 이렇게 성능 최적화도 가능하다.
'JPA(Java Persistence API)' 카테고리의 다른 글
[JPA] Part 7. 다대일, 일대다, 일대일, 다대다 (0) | 2023.10.19 |
---|---|
[JPA] Part 6. 객체 지향형 모델링, 단방향 양방향 연관관계 주인 (0) | 2023.10.18 |
[JPA] Part 4. 영속성 컨텍스트 (0) | 2023.10.17 |
[JPA] Part 3. JPA를 뿌리부터 시작해보기 (0) | 2023.10.17 |
[JPA]: Part 2. Hibernate 정의와 JPA와 Hibernate의 차이점 (2) | 2023.10.11 |