[Spring] JPA 엔티티 매핑
스프링 복습 및 정리 12.1P
JPA를 이용해 DB의 테이블과 상호 작용(데이터 저장, 수정, 조회, 삭제 등) 하기 위해 제일 먼저 해야 되는 작업은
DB의 테이블과 엔티티 클래스 간의 매핑 작업이다.
엔티티 매핑 작업은 크게 객체와 테이블 간의 매핑, 기본 키 매핑, 필드와 열 간의 매핑, 엔티티 간의 연관 관계 매핑 등으로 나눌 수 있다.
엔티티와 테이블 간의 매핑
@Entity
@Table
public class Member {
@Id
private Long memberId;
}
@Entity 매핑 어노테이션을 이용해 엔티티 클래스와 테이블을 매핑했다.
클래스 레벨에 @Entity 어노테이션을 붙이면 JPA 관리 대상 엔티티가 된다.
@Entity 어노테이션의 애트리뷰트로 name을 설정할 수 있다.
name 애트리뷰트를 설정하지 않으면 기본값으로 클래스 이름을 엔티티 이름으로 사용한다.
@Entity(name = "USERS")
@Table(name = "USERS")
public class Member {
@Id
private Long memberId;
}
위와 같이 name 애트리뷰트를 사용해서 엔티티 이름과 테이블 이름을 변경할 수 있다.
@Table 어노테이션은 옵션이지만 @Entity, @Id 어노테이션은 필수이다.
기본 키 매핑
JPA에서는 기본적으로 @Id 어노테이션을 추가한 필드가 기본 키 열이 되는데,
JPA에서는 이러한 기본 키를 어떤 방식으로 생성해 줄지에 대한 다양한 전략을 지원한다.
JPA에서 지원하는 기본 키 생성 전략은 아래와 같다.
기본 키 직접 할당
애플리케이션 코드 상에서 기본키를 직접 할당해주는 방식이다.
기본 키 자동 생성
- IDENTITY
- 기본키 생성을 DB에 위임하는 전략이다.
- DB에서 기본 키를 생성해 주는 대표적인 방식은 MySQL의 AUTO_INCREMENT 기능을 통해 자동 증가 숫자를 기본키로 사용하는 방식이 있다.
- SEQUENCE
- DB에서 제공하는 시퀀스를 사용해서 기본키를 생성하는 전략이다.
- TABLE
- 별도의 키 생성 테이블을 사용하는 전략이다.
- 키 생성 전용 TABLE을 별도로 만들어야 되고, 키를 조회하고 업데이트하는 쿼리를 추가적으로 전송해야 하기 때문에 성능면에서 좋지 않다.
기본키 직접 할당 엔티티
@Entity
@Getter
@NoArgsConstructor
public class Member {
@Id
private Long memberId;
public Member(Long memberId) {
this.memberId = memberId;
}
}
@Id 어노테이션만 추가하면 기본적으로 기본 키 직접 할당 전략이 적용된다.
기본키 직접 할당 예
@Configuration
public class JpaIdDirectMappingConfig {
private EntityManager em;
private EntityTransaction tx;
@Bean
public CommandLineRunner testJpaSingleMappingRunner(EntityManagerFactory emFactory){
this.em = emFactory.createEntityManager();
this.tx = em.getTransaction();
return args -> {
tx.begin();
em.persist(new Member(1L)); // (1)
tx.commit();
Member member = em.find(Member.class, 1L);
System.out.println("# memberId: " + member.getMemberId());
};
}
}
(1)과 같이 기본 키를 직접 할당해서 엔티티를 저장한다.
만약 (1)에서 기본 키 없이 엔티티를 저장하면 아래와 같은 에러 메시지를 출력한다.
Caused by: javax.persistence.PersistenceException: org.hibernate.id.IdentifierGenerationException: ids for this class must be manually assigned before calling save(): … …
IDENTITY 전략
DB에서 기본 키를 대신 생성해 준다.
@NoArgsConstructor
@Getter
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long memberId;
public Member(Long memberId) {
this.memberId = memberId;
}
}
@Configuration
public class JpaIdIdentityMappingConfig {
private EntityManager em;
private EntityTransaction tx;
@Bean
public CommandLineRunner testJpaSingleMappingRunner(EntityManagerFactory emFactory){
this.em = emFactory.createEntityManager();
this.tx = em.getTransaction();
return args -> {
tx.begin();
em.persist(new Member());
tx.commit();
Member member = em.find(Member.class, 1L);
System.out.println("# memberId: " + member.getMemberId());
};
}
}
위 코드에서는 Member 엔티티에 IDENTITY 전략이 적용되었기 때문에
Member 엔티티에 별도의 기본 키 값을 할당하지 않았다.
Hibernate: drop table if exists member CASCADE
Hibernate: create table member (member_id bigint generated by default as identity, primary key (member_id))
// (1)
Hibernate: insert into member (member_id) values (default)
# memberId: 1
실행 결과를 보면 MEMBER 테이블에 데이터를 저장하고,
기본키 값이 자동으로 생성되어 조회가 정상적으로 된 것을 확인할 수 있다.
SEQUENCE 전략
DB 시퀀스를 이용한다.
@NoArgsConstructor
@Getter
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long memberId;
public Member(Long memberId) {
this.memberId = memberId;
}
}
@Configuration
public class JpaIdIdSequenceMappingConfig {
private EntityManager em;
private EntityTransaction tx;
@Bean
public CommandLineRunner testJpaSingleMappingRunner(EntityManagerFactory emFactory){
this.em = emFactory.createEntityManager();
this.tx = em.getTransaction();
return args -> {
tx.begin();
em.persist(new Member());
Member member = em.find(Member.class, 1L);
System.out.println("# memberId: " + member.getMemberId());
tx.commit();
};
}
}
위 코드도 Member 엔티티 객체를 생성하면서 별도의 기본 키 값을 전달하지 않았다.
하지만 SEQUENCE 전략을 사용하도록 지정했으므로 엔티티가 영속성 컨텍스트에 저장되기 전에
DB가 시퀀스에서 기본 키에 해당하는 값을 제공할 것이다.
Hibernate: drop table if exists member CASCADE
Hibernate: drop sequence if exists hibernate_sequence
// (1)
Hibernate: create sequence hibernate_sequence start with 1 increment by 1
Hibernate: create table member (member_id bigint not null, primary key (member_id))
// (2)
Hibernate: call next value for hibernate_sequence
# memberId: 1
Hibernate: insert into member (member_id) values (?)
실행 결과를 보면 (1)에서 데이터베이스에 시퀀스를 생성한다.
(2)에서 Member 엔티티 영속성 컨텍스트에 저장하기 전에 데이터베이스에서 시퀀스 값을 조회하는 것을 볼 수 있다.
이 시퀀스 값은 Member 엔티티의 memberId 필드에 할당된다.
AUTO 전략
마지막으로 @Id 필드에 @GeneratedValue(strategy = GenerationType.AUTO)
를 지정하면
JPA가 데이터베이스의 Dialect에 따라서 적절한 전략을 자동으로 선택한다.
Dialect는 표준 SQL 등이 아닌 특정 데이터베이스에 특화된 고유한 기능을 의미한다.
만일 JPA가 지원하는 표준 문법이 아닌 특정 데이터베이스에 특화된 기능을 사용할 경우 Dialect가 처리해 준다.
필드와 열 간의 매핑
Member 엔티티 클래스 필드와 열 간의 매핑
@NoArgsConstructor
@Getter
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long memberId;
// (1)
@Column(nullable = false, updatable = false, unique = true)
private String email;
...
...
public Member(String email) {
this.email = email;
}
}
@Column [더보기]
(1)에서 사용한 @Column 어노테이션은 필드와 열을 매핑해 주는 어노테이션이다.
그런데 만약 @Column 어노테이션이 없고, 필드만 정의되어 있다면 JPA는 기본적으로 이 필드가 테이블의 열과 매핑되는 필드라고 간주하게 된다.
또한, @Column 어노테이션에 사용되는 애트리뷰트의 값은 디폴트 값이 모두 적용된다.
int나 long 같은 원시 타입일 경우,
@Column 어노테이션이 생략되면 기본적으로 nullable=false이다.
그런데 예를 들어서 개발자가 int price not null 이라는 조건으로 열을 설정하길 원하는데
nullable에 대한 명시적인 설정 없이 단순히 @Column 어노테이션만 추가하면
nullable=true가 기본값이 되기 때문에 테이블에는 int price not null로
열이 설정되는 것이 아니라 int price와 같이 설정이 될 것이다.
그럼 email 필드가 설정한 대로 동작하는지 테스트를 아래에서 해보겠다.
@Configuration
public class JpaColumnMappingConfig {
private EntityManager em;
private EntityTransaction tx;
@Bean
public CommandLineRunner testJpaSingleMappingRunner(EntityManagerFactory emFactory){
this.em = emFactory.createEntityManager();
this.tx = em.getTransaction();
return args -> {
// testEmailNotNull(); // (1)
// testEmailUpdatable(); // (2)
// testEmailUnique(); // (3)
};
}
private void testEmailNotNull() {
tx.begin();
em.persist(new Member());
tx.commit();
}
private void testEmailUpdatable() {
tx.begin();
em.persist(new Member("abc@gmail.com"));
Member member = em.find(Member.class, 1L);
member.setEmail("abc@yahoo.co.kr");
tx.commit();
}
private void testEmailUnique() {
tx.begin();
em.persist(new Member("abc@gmail.com"));
em.persist(new Member("abc@gmail.com"));
tx.commit();
}
}
(1)은 email 필드에 아무 값도 입력하지 않고 데이터를 저장하고 있다.
위 코드에서 설정한 email 필드는 nullable=false이기 때문에 에러가 발생해야 한다.
(2)는 이미 등록한 email 주소를 다시 수정하고 있다.
updatable=false로 설정했기 때문에 이미 등록한 email 주소는 수정되지 않아야 한다.
(3)은 email 주소를 한번 더 등록하고 있다.
unique=true이기 때문에 에러가 발생해야 한다.
이제 (1), (2), (3) 차례대로 주석을 해제해서 실행해보겠다.
(1) 메서드 실행 결과
java.lang.IllegalStateException: Failed to execute CommandLineRunner
...
...
Caused by: javax.persistence.PersistenceException:
org.hibernate.PropertyValueException:
not-null property references a null or transient value :
com.demuu.entity_mapping.single_mapping.column.Member.email
...
...
null이 아닌 입력 값이 있어야 하는데 존재하지 않기 때문에 PropertyValueException을 래핑 한
PersistenceException이 발생했으므로 nullable=false 설정은 정상적으로 동작했다.
(2) 메서드 실행 결과
Hibernate: insert into member (member_id, email) values (default, ?)
INSERT 쿼리가 발생했지만 UPDATE 쿼리가 발생하지 않았다.
따라서 updatable=false 설정이 정상적으로 동작했다.
(3) 메서드 실행 결과
java.lang.IllegalStateException: Failed to execute CommandLineRunner
Caused by: javax.persistence.PersistenceException:
org.hibernate.exception.ConstraintViolationException: could not execute statement
Caused by: org.hibernate.exception.ConstraintViolationException:
could not execute statement
Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException:
Unique index or primary key violation:
"PUBLIC.UK_MBMCQELTY0FBRVXP1Q58DN57T_INDEX_8 ON PUBLIC.MEMBER(EMAIL NULLS FIRST) VALUES
( /* 1 */ 'abc@gmail.com' )"; SQL statement:
insert into member (member_id, email) values (default, ?) [23505-212]
email 주소는 고유한 값이어야 하는데, 동일한 email 주소가 INSERT 되면서
JdbcSQLIntegrityConstraintViolationException, ConstraintViolationException, PersistenceException이 래핑 되어 순자적으로 전파되었다.
마지막으로 아래는 Member 엔티티 클래스와 테이블 간의 매핑 어노테이션을 추가한 전체 소스 코드이다.
@NoArgsConstructor
@Getter
@Setter
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long memberId;
@Column(nullable = false, updatable = false, unique = true)
private String email;
@Column(length = 100, nullable = false)
private String name;
@Column(length = 13, nullable = false, unique = true)
private String phone;
@Column(nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@Column(nullable = false, name = "LAST_MODIFIED_AT")
private LocalDateTime modifiedAt = LocalDateTime.now();
@Transient
private String age;
public Member(String email) {
this.email = email;
}
public Member(String email, String name, String phone) {
this.email = email;
this.name = name;
this.phone = phone;
}
}
엔티티와 테이블 매핑 권장 사용 방법
클래스 이름 중복 등의 특별한 이유가 없다면 @Entity와 @Id 어노테이션만 추가한다.
만일 엔티티 클래스가 테이블 스키마 명세의 역할을 하길 바란다면 @Table 어노테이션에 테이블명을 지정해 줄 수 있다.
기본키 생성 전략은 데이터베이스에서 지원해 주는 AUTO_INCREMENT 또는 SEQUENCE를 이용할 수 있도록
IDENTITY 또는 SEQUENCE 전략을 사용하는 것이 좋다.
@Column 정보를 명시적으로 모두 지정하는 것은 번거롭긴 하지만 다른 누군가가 엔티티 클래스
코드를 확인하더라도 테이블 설계가 어떤 식으로 되어 있는지 한눈에 알 수 있다는 장점이 있다.
엔티티 클래스 필드 타입이 Java의 원시 타입일 경우, @Column 어노테이션을 생략하지 말고,
최소한 nullable=false 설정을 하는 게 좋다.
@Enumerated 어노테이션을 사용할 때 EnumType.ORDINAL을 사용할 경우,
enum의 순서가 뒤바뀔 가능성도 있으므로 처음부터 EnumType.ORDINAL 대신에 EnumType.STRING을 사용하는 것이 좋다.
@Enumerated [더보기]
Spring Boot 핵심 정리 모음
1P - POJO, IoC, DI, AOP, PSA >
3P - 컴포넌트 스캔과 의존성 자동 주입, @Autowired >
5P - Spring MVC / MVC의 동작 방식과 구성 요소 >
7P - HTTP Header, Rest Client >
9P - Service / Entity 클래스, Mapper / MapStruct >
10P - 예외 처리 (@ExceptionHandler, @RestControllerAdvice) >
11.1P - DDD, 도메인 엔티티 및 테이블 설계 >
11.2P - 데이터 액세스 계층 구현 (도메인 엔티티 클래스 정의) >
11.3P - 데이터 액세스 계층 구현 (서비스, 리포지토리 구현) >
12P - JPA(Java Persistence API) >
12.3P - Spring Data JPA 데이터 액세스 계층 구현 >
14P - Spring MVC Testing 단위 테스트 >
14.3P - 슬라이스 테스트 (API, 데이터 액세스 계층) >
14.5P - TDD (Test Driven Development) >
15P - API 문서화 (Documentation) >
댓글남기기