[JPA] 임베디드 타입 ( @Embedded, @Embeddable )
@Id 어노테이션이 붙은 필드는 식별자를 의미한다.
JPA는 식별자로 엔티티를 구분한다. @ManyToOne 등은 연관관계를 의미한다. JPA는 어노테이션으로 다른 엔티티와 연관관계를 정의한다. 이렇듯 개발자는 JPA가 엔티티를 목적에 맞게 사용하도록 여러 어노테이션으로 '표시'를 남긴다. ( 엔티티 매핑, 연관관계 매핑, 상속관계 매핑, 영속성 전이 등등 ) 이와같은 엔티티 관련 필드는 제외하고, 단순히 컬럼에 값을 저장하기 위한 필드를 '값타입'이라 부른다.
Student 엔티티
@Entity
@Data
public class Student {
@Id
@GeneratedValue
@Column(name = "STUDENT_ID")
private Long id;
private String name; //값타입
}
값타입에는 컬럼에 들어갈 데이터가 저장된다. 그러므로 데이터는 정합성에 맞아야 한다. 데이터는 일관되고 품질이 유지되어야 한다. 그러나 객체지향App의 특성상 데이터의 정합성이 지켜지기 힘들다.
원시타입변수와 참조타입변수
JAVA는 원시타입변수와 참조타입변수를 제공한다.
원시타입변수는 문자(char), 정수(int), 실수(double) 등이 있다. 원시타입변수의 특징은 '값복사'이다.
int a = 20;
int b = a;
간단한 코드이다. 여기서 b에는 a의 '주소'가 복사되지 않는다. a가 지닌 '값'이 복사된다. 그래서 b에는 20이 들어간다. a를 30으로 바꾸어도 b는 그대로 20이다. '값'을 복사했기 때문이다. a와 b는 서로 독립적이다. 이렇듯 '값'을 복사하면 서로에게 영향을 주지않는다.
반면 참조타입변수는 '주소'를 복사한다.
Address addressA = new Address();
Address addressB = addressA;
addressB에는 addressA의 주소가 들어간다. 만약 addressA의 값이 변하면 addressB의 값도 변한다. addressA와 addressB는 서로에게 영향을 준다. 만약 엔티티의 필드가 참조타입변수라면 어떻게 될까?
엔티티의 필드는 테이블의 컬럼이 된다. 데이터는 오로지 엔티티의 제어에 의해서만 변경되어야 한다. 그러나 참조타입변수는 예기치 못한 다른 제어에 의해 데이터가 변경될 여지가 있다. 즉 데이터의 정합성이 깨진다. JPA는 데이터정합성을 지키기 위해, 엔티티에 쓰이는 참조타입변수도 마치 원시타입변수처럼 사용되도록 강제장치를 마련했다. 이런 원리로 제어되는 필드를 '값타입'이라 부른다.
값타입에는 3가지 종류가 있다.
1) 기본값 타입 ( 원시타입변수)
2) 임베디드 타입 ( 참조타입변수 )
3) 값타입 컬렉션 ( 컬렉션타입변수 )
3가지는 원시타입변수처럼 다른 제어에 의해 영향받지 않도록 '값복사'에 '초점'이 맞추어져 있다. 기본값 타입은 원시타입변수로 이미 JAVA에서 '값복사'를 구현해놓았기에 별다른 특이점은 없다.
임베디드 타입
엔티티의 id, name, startDate,endDate 필드는 테이블의 동일한 이름의 컬럼으로 매핑된다. 그러나 필드가 너무 많으면 관리하기 힘들다. 그래서 엔티티에 객체 하나를 내장(임베디드) 시킬 수 있다.
엔티티는 필드를 포함하는 객체를 하나 내장시켜 엔티티를 단순화했다. 테이블은 기존과 다르지 않도록 JPA 제어한다. 객체를 내장하면 좋은점은 단순화되고 객체의 메소드를 사용할 수 있다.
직원의 근무일수를 계산한다고 가정하자.
임베디드 타입 객체를 사용하면 [ endDate-startDate ] 로직을 엔티티 클래스가 갖고 있지 않아도 된다. Period가 메소드로 구현하고 엔티티는 호출해서 쓰면된다. 이처럼 객체를 내장하면 관심사를 분리하고 응집도를 높혀 엔티티를 단순화할 수 있다.
그러나 문제가 있다.
앞서 말했듯, 엔티티는 Period 내장객체에 참조변수로 접근한다. Period 객체는 엔티티 외 다른 객체에 의해 변경될 여지가 있다. 그러므로 Period를 불변객체로 만들어야 한다. 불변객체란 생성된 시점을 제외하고 그 어떤 객체도 수정할 수 없는 객체이다. 만드는 법은 간단하다. setter 메소드를 안 만들면된다. setter 메소드를 만들지 않으면 그 누구도 수정할 수 없다. 대표적인 불변객체로 Integer, String이 있다.
@Embedded, @Embeddable
MEMBER 엔티티 클래스 ( @Embedded )
@Entity
@Data
public class Member {
@Id @GeneratedValue
@Column (name = "MEMBER_ID")
private Long id;
private String name;
@Embedded
private Period period; //내장객체
//근무일수 반환하는 메소드
public Long getWorkDays(){
return period.getDays(); // 내장객체 메소드 호출
}
}
엔티티 클래스는 @Embedded 어노테이션으로 임베디드 객체임을 표시한다. getWorkDays()는 근무일수를 반환하는 메소드로 임베디드 객체를 호출하여 단순한 코드로 구현되었다.
PERIOD 클래스 ( @Embeddable )
@Embeddable
@Getter
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
// 기본생성자 반드시 필요!
public Period() {
}
// 생성시점 이후 데이터 변경 불가
public Period(LocalDateTime startDate, LocalDateTime endDate) {
this.startDate = startDate;
this.endDate = endDate;
}
public long getDays(){
return ChronoUnit.DAYS.between(startDate, endDate);
}
}
임베디드 객체는 @Embeddable 어노테이션으로 표시된다. 필드를 startDate, endDate를 가지고 있다. 두 필드는 엔티티의 컬럼이 된다. 그리고 @Getter만 있고 @Setter는 없다. 임베디드 객체는 생성시점 이후 데이터 변경이 불가능한 불변객체여야 한다.
한 가지 특이한 점이 있다. 바로, 기본생성자이다.
JAVA는 명시적으로 생성자를 선언하지 않으면 기본생성자를 자동으로 제공한다. Period 클래스는 매개변수 두개를 가진 생성자를 선언하였다. 그러므로 기본생성자가 자동으로 생성되지 않는다. 그러나 임베디드 객체는 기본생성자가 필요하다.
DB에서 엔티티를 조회하면 엔티티 객체가 동적으로 생성되어야 하는데, JPA는 Reflection을 활용한다. 그런데 Reflection은 기본생성자로만 객체를 생성할 수 있다. 그래서 엔티티 클래스도 반드시 기본생성자를 가져야 한다. 엔티티 클래스의 임베디드타입 객체도 같이 동적으로 생성되기에 똑같이 기본생성자가 필요하다. 자세한 내용은 아래 링크를 참조하기를 바란다.
그럼 이제 프로그램을 실행해보자.
Main 클래스
public class Main {
public static void main(String[] args) {
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("h2");
EntityManager entityManager = entityManagerFactory.createEntityManager();
EntityTransaction tx = entityManager.getTransaction();
tx.begin();
try {
//Period 객체 생성
LocalDateTime startDateTime = LocalDateTime.of(2023,1,1,0,0);
LocalDateTime endDateTime = LocalDateTime.of(2023,12,31,23,59);
Period period = new Period(startDateTime,endDateTime);
//Member 객체 생성
Member member = new Member();
member.setName("이지훈");
member.setPeriod(period);
//영속화
entityManager.persist(member);
tx.commit();
}catch (Exception e) {
e.printStackTrace();
tx.rollback();
}finally {
entityManager.close();
}
}
}
Member 테이블
Period 임베디드 타입 객체에 있던 필드가 컬럼으로 매핑된 모습을 확인할 수 있다. 이렇듯, 임베디드 타입은 테이블 결과는 그대로 유지하면서 엔티티를 단순화 시키는 장점이 있다.
동등성(equals) 비교
임베디드타입 객체는 객체이지만 '값'의 개념으로 사용된다. 그래서 참조주소를 비교하는 동일성(==)비교가 아닌 값을 비교하는 동등성(equals)비교를 해야한다. 그러므로 임베디드타입 객체는 필드데이터를 담아 equals 메소드를 재정의해야한다. 재정의는 자동생성을 활용하는 것이 좋다.
@Embeddable
@Getter
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
// 기본생성자 반드시 필요!
public Period() {
}
public Period(LocalDateTime startDate, LocalDateTime endDate) {
this.startDate = startDate;
this.endDate = endDate;
}
public long getDays(){
return ChronoUnit.DAYS.between(startDate, endDate);
}
// 동등성비교를 위한 equals 메소드 ( 재정의 )
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Period period = (Period) o;
return Objects.equals(startDate, period.startDate) && Objects.equals(endDate, period.endDate);
}
@Override
public int hashCode() {
return Objects.hash(startDate, endDate);
}
}
이와 같이, equals를 활용하여 값이 동일한지 확인할 수 있다. 이번 포스팅에서는 임베디드 타입 객체에 대해서 알아보았다. 다음 포스팅에서는 값타입 컬렉션을 다루어 보겠다.
참고자료