02. OR-Mapping과 상속
- -
이번 포스트에서는 OR-Mapping에 사용되는 애너테이션들에 대해 좀 더 자세히 알아보자.
주요 annotation들
@Entity
@Entity는 Entity로 관리할 클래스에 추가해야 하는 필수 annotation으로 클래스를 DB 테이블과 매핑하게 한다.
@Target(TYPE)
public @interface Entity {
String name() default "";
}
@Entity의 target이 type이지만 final class, enum, interface 등에는 사용할 수 없다.
name 속성은 entity의 이름으로 일반적으로 생략하면 클래스 이름이 적용된다. 패키지는 다르지만 이름이 같은 entity가 있다면 구별을 위해서 설정할 수 있다. entity의 이름은 나중에 JPQL을 작성할 때 사용된다.
@Entity // DB 테이블과 연동되어야 하는 클래스이다.
public class Member { ...}
@Table
entity와 매핑될 DB 테이블을 지정하는 annotation이다.
@Target(TYPE)
public @interface Table {
String name() default "";
String catalog() default "";
String schema() default "";
UniqueConstraint[] uniqueConstraints() default { };
}
일반적으로는 생략하는데 생략 시 Entity이름에 snake case(OurMember -> our_member)를 적용해서 테이블 이름으로 사용한다. 만약 테이블 이름이 충돌하는 경우는 name 속성으로 테이블 이름을 지정할 수 있다.
@Entity // DB 테이블과 연동되어야 하는 클래스이다.
@Table(name = "my_member") // 연결되는 테이블의 이름은 my_member로 한다.
public class Member { ...}
@Id
테이블에서 P.K로 사용될 컬럼에 대해 선언하는 애너테이션으로 필수 항목이다. 만약 @Id를 지정하지 않으면 org.hibernate.AnnotationException이 발생한다. 또한 Entity가 저장 되기 전에 반드시 값이 할당되어야 하는데 미 할당 시는 org.hibernate.id.IdentifierGenerationException이 발생한다.
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface Id {}
@Id를 지정할 수 있는 타입은 primitive type, wrapper type, String, Date, BigDecimal, BigInteger이다.
@Entity // 이 클래스는 테이블과 Mapping 된다.
public class Member {
@Id // 기본 키와 연결되는 컬럼이다.
private String id;
}
@Column
@Column은 Entity 클래스의 field와 테이블의 컬럼을 매핑하기 위한 애너테이션이다. 단순히 이름 지정 이외에 테이블에서 컬럼 선언 시 추가 할 수 있는 다양한 제약을 설정 할 수 있다.
@Target({METHOD, FIELD})
public @interface Column {
String name() default ""; // 필드와 매핑 할 테이블 column 이름, 생략 시 필드의 이름
boolean unique() default false; // unique 제약 사항
boolean nullable() default true; // null 가능 여부
boolean insertable() default true; // 자동 생성 insert SQL에 포함될 수 있는지 여부
boolean updatable() default true; // 자동 생성 update SQL에 포함될 수 있는지 여부
String columnDefinition() default ""; // 컬럼의 타입 등을 직접 설정할 경우 사용
int length() default 255; // 문자열 컬럼의 기본 크기
int precision() default 0; // 숫자형에서 소수점을 포함한 전체 자리 수
int scale() default 0; // 숫자형에서 소수의 자리 수
}
예를 들어 아래와 같이 Member의 field에 @Column을 사용할 수 있다. 기존의 Member를 수정하고 생성되는 DDL을 확인해보자.
@Entity
@Table(name = "member")
public class Member {
@Id
private Long mno;
@Column(name="id", nullable=false, unique=true, length = 100)
private String id;
@Column(nullable = false, length = 100)
private String name;
@Column(columnDefinition = "varchar(100) not null check(length(pass)>5)")
private String pass;
}
@Enumerated
성별이나 계절 등 값이 몇 개로 한정된 경우 enum을 사용하는데 enum 타입의 필드를 컬럼에 매핑하기 위해 @Enumerated을 사용할 수 있다.
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface Enumerated {
EnumType value() default ORDINAL;
}
@Enumerated은 EnumType(ORDINAL or STRING)의 value를 속성으로 갖는다. ORDINAL은 enum의 순서를 기반으로 사용하겠다는 뜻인데 만약 enum에 상수가 추가되거나 순서가 변경되면 대 혼란이 발생할 수 있다. 따라서 가급적 STRING의 사용을 권장한다. ORDINAL로 설정된 경우는 tinyint 등으로 컬럼이 생성되고 STRING으로 설정된 경우는 enum 타입으로 컬럼이 생성된다.
public enum Gender {
M, F
}
@Enumerated(EnumType.STRING) // 성별은 M, F로 고정
private Gender gender;
@Lob
@Lob은 BLOB 또는 CLOB 타입을 매핑할 때 사용된다. 컬럼 타입의 결정은 Entity의 field 타입에 따라 달라진다.
- BLOB(Binary Large Object) : byte []이나 java.sql.BLOB인 경우는 BLOB으로 처리된다.
- CLOB(Character Large Object): String, char [], java.sql.CLOB인 경우는 CLOB으로 처리된다.
상황에 따라 회원의 섬네일 이미지를 저장하는 등의 작업에 사용될 수 있다.
@MappedSuperclass를 이용한 공통 컬럼 관리
@MappedSuperclass
DB차원에서 데이터 이력을 관리하기 위해 생성시각과 수정시각에 해당하는 컬럼을 구성하는 경우가 많다. 이를 위해서 Entity에도 관련 필드 들이 필요하다. 이때 Entity 마다 필드를 추가하기 보다는 조상 클래스를 만들고 Entity들이 상속받게 하면 편리하다. 이때 조상클래스를 구성하기 위해 @MappedSuperclass를 사용한다. 일반적으로 @MappedSuperclass는 상속용 클래스에 사용되므로 클래스에 abstract를 추가해주고 스스로가 @Entity일 필요는 없다.
@MappedSuperclass
public abstract class BaseEntity {
// 공통적으로 필요한 컬럼들 정의
}
@CreationTimestamp, @UpdateTimestamp
@MappedSuperclass에 의해 가장 많이 사용되는 속성이 생성시각, 수정시각이다. 그런데 이 컬럼의 값들을 매번 수동으로 설정하기가 쉽지 않다. 이를 자동으로 하기위해 @CreationTimestamp, @UpdateTimestamp을 사용할 수 있다. 이들은 각각 insert, update 시점에 자동으로 대상 컬럼의 값을 설정해준다.
@MappedSuperclass
@Getter
@ToString
public abstract class BaseEntity {
@CreationTimestamp
@Column(updatable = false) // update될 때는 할당하지 않는다.
private LocalDateTime created ;
@UpdateTimestamp
@Column(insertable = false) // insert 될 때는 할당하지 않는다.
private LocalDateTime modified;
}
이와 유사하게 @CreatedDate, @LastModifiedDate도 있다.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
private LocalDateTime created ;
@LastModifiedDate
private LocalDateTime modified;
}
@SpringBootApplication
@EnableJpaAuditing // @Configuration에서 감사 활성화 필요
public class BoardappApplication { }
이 둘은 아래와 같은 미묘한 차이점을 가지고 있긴 하다.
@CreationTimestamp, @UpdateTimestamp | @CreatedDate, @LastModifiedDate | |
소속 | org.hibernate.annotations | org.springframework.data.annotation |
값 할당 시점 | 실제 쿼리 실행 후 | entity가 persist되는 시점(아직 쿼리는 실행 전) |
주의사항 | insert 후 설정되므로 update가 추가로 발생 가능 @Column(updatable=false) 필요 |
Entity 클래스에 @EntityListeners, @Configuration에 @EnableJpaAuditing 추가 필요 |
@AttributeOverride, @AttributeOverrides
상속의 관계를 구성할 때 간혹 부모 Entity의 필드가 자식 Entity의 필드와 이름이 겹치는 경우가 발생할 수 있는데 그대로 두면 자바에서는 내 맴버(this)와 조상의 맴버(super)로 구별할 수 있지만 데이터베이스에서는 단순히 한 테이블의 컬럼이므로 구별할 수 없다.
이런 경우에는 자식 Entity에서 @AttributeOverride나 @AttributeOverrides를 통해서 이름을 재정의 해주면 된다.
@Entity
@AttributeOverride(name = "created", column = @Column(name = "created_date"))
public class Member extends BaseEntity {
. . .
}
Entity 간의 상속
@Inheritance와 InheritanceType
자바의 객체에는 상속의 관계가 존재한다. 즉 PaidMember는 Member를 상속받아 결재일(LocalDate payDate)을 추가로 설정할 수 있다. 하지만 데이터베이스에는 상속의 구조가 존재하지 않는다. JPA에서는 이 둘 간의 괴리를 어떻게 처리할 수 있을까?
JPA에서는 @Inheritance 애너테이션을 이용해서 상속을 3가지 방식으로 처리할 수 있다.
@Target({TYPE})
@Retention(RUNTIME)
public @interface Inheritance {
/** The strategy to be used for the entity inheritance hierarchy. */
InheritanceType strategy() default InheritanceType.SINGLE_TABLE;
}
Inheritance는 클래스 레벨에 선언하는 애너테이션으로 strategy 속성을 이용해서 상속의 구현 방식을 지정하는데 기본은 SINGLE_TABLE이다.
public enum InheritanceType {
SINGLE_TABLE, /** 클래스 계층 구조의 모든 정보를 포함하는 하나의 테이블 구성*/
TABLE_PER_CLASS, /** entity 클래스 마다 하나의 테이블 구성. */
JOINED /** join을 이용한 관계 처리 */
}
InheritanceType.SINGLE_TABLE
이 속성은 말 그대로 하나의 테이블에 부모타입, 자식 타입의 데이터를 모두 저장한다. 따라서 좀 더 많은 속성을 갖는 자식 엔티티에 맞는 테이블을 만들게 된다.
SINGLE_TABLE은 하나의 테이블에 모든 데이터를 저장하므로 부모 타입인지, 자식 타입인지를 알 수 없다. 이를 구별하기 위해 어떤 타입인지 파악하기 위한 특별한 장치가 필요한데 이때 사용되는 애너테이션이 @DiscriminatorColumn과 @DiscriminatorValue이다.
annotation | 설명 |
@DiscriminatorColumn | SINGLE_TABLE 타입에서 부모 클래스에 선언되며 자식 테이블 구분 칼럼 name 속성으로 칼럼 이름을 등록하며 생략 시 기본은 DTYPE(discriminator type) |
@DiscriminatorValue | 각각의 클래스에 선언하며 @DiscriminatorColumn에 저장되는 값으로 생략 시 클래스 이름 사용 |
결국 기본 값을 사용한다면 생략해도 무방한 애너테이션들이다.
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public class Member extends BaseEntity { ...}
@Entity
public class PaidMember extends Member{
private LocalDate payDate;// 결재일
private String paymentMethod; // 결재 수단
}
SINGLE_TABLE 구성 결과 생성된 DDL은 아래와 같다.
create table member (
create_time timestamp(6),
mno bigint not null,
update_time timestamp(6),
id varchar(100) not null unique,
name varchar(100) not null,
pass varchar(100) not null check(length(pass)>5),
gender enum ('F','M'),
primary key (mno),
dtype varchar(31) not null, # 부모/자식을 구별하기 위한 컬럼
paid_date date, # paidMember의 내용
payment_method varchar(255) # paidMember의 내용
)
이 방법은 부모 타입의 데이터가 많을 경우에는 자식 타입을 위해 추가된 컬럼은 null이 할당되고 결과적으로 테이블의 낭비가 있을 수 있다.
InheritanceType.TABLE_PER_CLASS
TABLE_PER_CLASS는 클래스별로 별도의 테이블을 구성하는 방식이다. 이 방식에서 주의 사항은 @Id를 자동 생성할 때 @GeneratedValue의 stragy에 IDENTITY를 사용할 수 없다. 왜냐하면 IDENTITY는 테이블 마다 자동 증가하는 키를 만들어 내기 때문에 값이 충돌해서 데이터 무결성이 훼손될 수 있기 때문이다. (PaidMember도 Member이다.) 이런 경우는 각 테이블 외부에서 키를 관리하는 SEQUENCE나 TABLE등을 써야 한다.
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public class Member extends BaseEntity {
@Id // 기본 키와 연결되는 컬럼이다.
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long mno;
...
}
TABLE_PER_CLASS 구성 결과 생성된 DDL은 아래와 같다.
create table member (
create_time timestamp(6),
mno bigint not null,
update_time timestamp(6),
id varchar(100) not null unique,
name varchar(100) not null,
pass varchar(100) not null check(length(pass)>5),
gender enum ('F','M'),
primary key (mno)
)
create table paid_member (
paid_date date,
create_time timestamp(6),
mno bigint not null,
update_time timestamp(6),
id varchar(100) not null unique,
name varchar(100) not null,
pass varchar(100) not null check(length(pass)>5),
payment_method varchar(255),
gender enum ('F','M'),
primary key (mno)
)
부모 테이블의 컬럼이 그대로 자식 테이블의 컬럼으로 사용되고 있는 것을 알 수 있다.
InheritanceType.JOINED
마지막 방식은 JOINED이다. 이 방식은 조상 Entity를 저장하기 위한 테이블과 자식 Entity에 특화된 내용만으로 구성된 테이블을 따로 구성하고 둘을 join해서 사용한다.
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public class Member extends BaseEntity {
@Id // 기본 키와 연결되는 컬럼이다.
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long mno;
}
JOINED 구성 결과 생성된 DDL은 아래와 같다.
create table member (
create_time timestamp(6),
mno bigint not null,
update_time timestamp(6),
id varchar(100) not null unique,
name varchar(100) not null,
pass varchar(100) not null check(length(pass)>5),
gender enum ('F','M'),
primary key (mno)
)
create table paid_member (
pay_date date,
mno bigint not null,
payment_method varchar(255),
primary key (mno)
)
alter table if exists paid_member
add constraint FK
foreign key (mno)
references member
세가지 방식의 비교
읽으면서 각 방식의 장 단점을 파악할 수 있었으리라 생각되지만 한번 더 정리하면 아래와 같다.
전략 | 장점 | 단점 |
JOINED |
•테이블이 정규화됨
•저장 공간을 효율적으로 사용함
|
•많은 join으로 join 쿼리가 복잡해지고 성능 저하 가능
•자식 Entity 등록 시 insert가 두 개의 테이블에서 발생
|
SINGLE_TABLE |
•Join이 필요 없고 조회 속도 면에서 가장 빠름
•조회 쿼리가 단순함
|
•조상 Entity 등록 시 자식 Entity의 컬럼은 모두 null로 공간 낭비
•테이블이 지나치게 커질 경우 오히려 속도 저하 발생
|
TABLE_PER_CLASS |
•거의 사용하지 않음
|
역시 상황에 따라 다르겠지만 일반적으로는 JOINED가 권장된다.
InheritanceType.JOINED 동작 확인
InheritanceType.JOINED 방식의 동작을 테스트 해보자.
Entity간에 상속의 관계가 생기면서 Lombok 사용 시 수정해야 할 내용이 발생한다.
먼저 상속의 관계에서 객체 생성 시 builder 패턴을 사용하기 위해서는 @Builder 대신 @SuperBuilder를 사용해야 한다. @SuperBuilder는 @Builder와 달리 클래스 레벨에만 적용할 수 있는 애너테이션이며 상속 관계에 있는 모든 클래스들(BaseEntity, Member, PaidMember)에 선언되어야 한다.
@ToString, @EqualsAnsHashCode에도 필요에 따라 callSuper=true 속성을 추가해야할 수 있다. 이 속성이 없으면 현재 클래스의 field만을 이용해서 처리하기 때문에 객체를 출력/비교 할때 전체 내용을 확인할 수 없어진다.
@Test
@DisplayName("상속 관계에서 자식 엔티티 저장 확인")
public void extendsTest() {
// Given: 새로운 PaidMember 엔티티 생성
PaidMember member = PaidMember.builder().mno(100L).id("uid").name("name")
.pass("123456").paymentMethod("신용카드")
.build();
// When: 엔티티를 저장
PaidMember saved = mrepo.saveAndFlush(member);
// Then: 생성 시간이 null이 아님을 확인
Assertions.assertNotNull(saved.getCreateTime(), "생성 시간이 null이 아닙니다.");
}
위의 테스트는 통과할 것이고 이에 따른 SQL은 다음과 같이 2건이 실행된다.
Hibernate:
insert
into
member
(create_time, gender, id, name, pass, mno)
values
(?, ?, ?, ?, ?, ?)
Hibernate:
insert
into
paid_member
(paid_date, payment_method, mno)
values
(?, ?, ?)
'Spring Model > 02. 객체 매핑과 P.C' 카테고리의 다른 글
06. 엔티티의 상태 관리 (0) | 2022.04.12 |
---|---|
05. EntityManager와 Persistence Context (0) | 2022.03.19 |
04.자동 키 매핑 전략 (0) | 2020.06.07 |
03. Value Type (0) | 2020.06.07 |
01. Hello Spring Data JPA (0) | 2020.05.30 |
소중한 공감 감사합니다