이 포스트는 김영한님의 "자바 ORM 표준 JPA 프로그래밍"을 참조하였음을 알립니다.
📕 목차
1. 기본 값 타입 (Basic value type)
2. 임베디드 타입 (Embedded type, 복합 값 타입)
3. 값 타입과 불변 객체
4. 값 타입 비교
5. 값 타입 컬렉션 (Collection value type)
✒️ 값 타입의 종류
Entity 타입은 식별자를 통해 지속해서 추적할 수 있지만, 값 타입은 추적할 수 없는 단순 수치 정보다.
모든 값 타입은 Entity의 Life cycle에 의존하므로 컴포지션(composition) 관계가 된다.
• 기본값 타입
∘ 자바 기본 타입
∘ 래퍼 클래스
∘ String
• 임베디드 타입
• 컬렉션 값 타입
1. 기본 값 타입 (Basic value type)
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private int age;
...
}
- Member Entity는 id라는 식별자 값도 가지고 Life cycle도 있다.
- 값 타입인 name, age 속성은 식별자 값도 없고 Life cycle이 Member Entity에 의존한다.
- Member Entity instance를 제거하면 함께 사라진다.
- 값 타입은 서로 공유해선 안 된다.
2. 임베디드 타입 (Embedded type, 복합 값 타입)
📌 What is Embedded type?
Embedded type이란 새로운 값 타입을 직접 정의해서 사용하는 것이다.
기존의 경우에 근무 기간과 집 주소 표현을 위해 Member Entity에 다음과 같은 필드를 명시했을 것이다.
// 근무 기간
@Temporal(TemporalType.DATE) LocalDateTime startDate;
@Temporal(TemporalType.DATE) LocalDateTime startDate;
// 집 주소
private String city;
private String street;
private String zipCode;
- 단순히 정보를 풀어둔 것뿐 서로 아무런 관련이 없다.
- "{근무 시작일, 근무종료일} ⇒ 근무 기간, {주소 도시, 주소 번지, 주소 우편} ⇒ 집 주소"라는 타입으로 묶는다면 코드가 명확해진다.
@Entity
public class Member {
...
@Embedded Period workPeriod;
@Embedded Address homeAddress;
...
}
@Embeddable
public class Period {
@Temporal(TemporalType.DATE)
LocalDateTime startDate;
@Temporal(TemporalType.DATE)
LocalDateTime endDate;
public boolean isWork(LocalDateTime date) {
... // 값 타입을 위한 메서드 정의
}
}
@Embeddable
public class Address {
@Column (name = "city")
private String city;
private String street;
private String zipcode;
}
- Embedded type은 2가지 Annotation이 필요하다. (둘 중 하나는 생략 가능)
- @Embeddeable : 값 타입을 정의하는 곳에 표시
- @Embedded : 값 타입을 사용하는 곳에 표시
- 기본 생성자가 필수다.
- 응집도가 높아지고, 해당 값 타입만의 의미있는 메서드를 만들 수 있다.
- 하이버네이트는 Embedded type을 컴포넌트(component)라 한다.
📌 Embedded 타입과 Table 매핑
💡 잘 설계한 ORM Application은 매핑한 Table 수보다 Class 수가 더 많다.
- Embedded type을 사용하기 전과 후의 매핑하는 Table은 같다.
- Object와 Table을 아주 세밀하게(fine-grained) 매핑하는 것이 가능하다.
📌 Embedded 타입과 연관 관계
@Entity
public class Member {
...
@Embedded Address address;
@Embedded PhoneNumber phoneNumber;
...
}
@Embeddable
public class Address {
@Column (name = "city")
private String city;
private String street;
private String state;
@Embedded private Zipcode zipcode;
}
@Embeddable
public class Zipcode {
String zip;
String plusFour;
}
@Embeddable
public class PhoneNumber {
String areaCode;
String localNumber;
@ManyToOne PhoneServiceProvider provider; // Entity reference
}
@Entity
public class PhoneServiceProvider {
@Id String name;
...
}
- Embedded type은 값 타입을 포함하거나 Entity를 참조할 수 있다.
📌 @AttributeOverride: 속성 재정의
@Entity
public class Member {
...
@Embedded private Address address;
@Embedded private Address address;
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "company_city")),
@AttributeOverride(name = "street", column = @Column(name = "company_street")),
@AttributeOverride(name = "state", column = @Column(name = "company_state")),
@AttributeOverride(name = "zipcode", column = @Column(name = "company_zipcode"))
})
private Address companyAddress;
...
}
- 같은 Embedded type을 중복 사용하고 싶은 경우 @AttributeOverrides, @AttributeOverride를 통해 매핑 정보를 재정의한다.
📌 Embedded Type과 null
member.setAddress(null);
em.persist(member);
- Embedded type이 null이면 매핑한 column 값은 모두 null이 된다.
3. 값 타입과 불변 객체
📌 값 타입 공유 참조
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();
address.setCity("NewCity"); // 회원1의 address 값을 공유해서 사용
member2.setHomeAddress(address);
- Embedded type을 공유하면 원치 않은 동작을 야기할 수 있다.
- 위의 로직은 영속성 컨텍스트가 회원1, 2 모두 바뀐 것으로 판단하여 각각의 UPDATE SQL을 실행한다.
무언가 수정했는데 전혀 예상치 못한 곳에서 문제가 발생하는 것을 부작용(side effect)이라 한다.
📌 값 타입 복사
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();
// 회원1의 address 값을 복사해서 새로운 newAddress 값을 생성
Address newAddress = address.clone();
newAddress.setCity("NewCity");
member2.setHomeAddress(newAddress);
- 기본 타입(primitive type)은 항상 값을 복사해서 전달하지만, 객체 타입은 항상 참조값을 전달한다.
- 객체 타입은 복사하지 않고 원본의 참조 값을 직접 넘기는 것을 막을 방법이 없다.
- 근본적인 해결책은 객체의 값을 수정하는 setter 메서드를 모두 제거하라.
- 이렇게 하면 공유 참조를 해도 값을 변경하지 못하므로 부작용의 발생을 막을 수 있다.
Domain Class의 경우에 필요한 정보일까 싶긴 한데, clone()은 기본적으로 얕은 복사만 수행하니 조심하자.
📌 불변 객체 (Immutable Object)
@Embeddable
public class Address {
private String city;
protected Address () {} // JPA에서 기본 생성자 필수
public Address(String city) {this.city = city;}
public String getCity() { return city }
}
- 불변 객체는 side-effect를 원천 차단할 수 있다.
- 참조값을 공유하더라도 수정할 방법이 없으므로 안전하다.
4. 값 타입 비교
Address a = new Address("서울시", "종로구", "1번지");
Address b = new Address("서울시", "종로구", "1번지");
System.out.println(a == b); // false
System.out.println(a.equals(b)); // true
- 동일성(Identity) 비교 : 인스턴스 참조 값을 비교, == 사용
- 동등성(Equivalence) 비교 : 인스턴스 값을 비교, equals() 사용
(비록 객체지만) 값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 한다.
Address의 equals() 메서드를 재정의하여 동등성 비교를 수행해야 하라.
5. 값 타입 컬렉션 (Collection value type)
@Entity
public class Member {
...
@Embedded private Address homeAddress;
@ElementCollection
@CollectionTable(name = "favorite_food", joinColumns = @JoinColumn(name = "member_id"))
@Column(name = "food_name")
private List<String> favoriteFoods = new ArrayList<>();
@ElementCollection
@CollectionTable(name = "address", joinColumns = @JoinColumn(name = "member_id"))
private List<Address> addressHistory = new ArrayList<>();
...
}
@Embeddable
public class Address {
@Column (name = "city")
private String city;
private String street;
private String zipcode;
}
- 값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollction, @ColletionTable 어노테이션을 사용한다.
- Database는 Collection을 저장할 수 없으므로 별도의 Table을 생성하여 저장한다.
- @ColletionTable을 생략하면 기본 값을 사용해서 매핑한다.
- {Entity 이름}_{Collection 속성 이름} ⇒ Member_addressHistory
📌 사용법
@Test
@DisplayName("값 타입 컬렉션 테스트")
void testValueCollection() {
/* init */
Member member = Member.builder().username("foo").age(10).homeAddress(
new Address("city", "street", "zipcode")
).build();
// 기본값 타입 컬렉션
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
// 임베디드 타입 컬렉션
member.getAddressHistory().add(new Address("서울", "1", "111-111"));
member.getAddressHistory().add(new Address("부산", "2", "222-222"));
testEntityManager.persist(member);
...
}
- member: INSERT SQL 1번
- member.homeAddress : Embedded value type이므로 Member 저장 SQL에 포함
- member.favoriteFoods : INSERT SQL 2번
- member.addressHistory : INSERT SQL 2번
즉, em.persiste(member) 한 번의 호출로 총 5번의 INSERT SQL문이 실행된다.
💡 값 타입 컬렉션은 영속성 전이(Cascade) + 고아 객체 제거 기능을 필수로 가져야 한다.
@ElementCollection(fetch = FetchType.LAZY)
- 값 타입 컬렉션 또한 fetch 전략을 선택할 수 있다. (default = LAZY)
📌 제약사항
- Entity에는 식별자가 있지만, 값 타입은 단순한 값들의 모음이므로 값이 수정되면 Database에 저장된 원본 데이터를 찾기 힘들다.
- 값 타입은 그나마 자신이 소속된 Entity를 찾아 값을 변경하면 된다.
- Collection value type은 별도의 Table에 보관되므로 원본 데이터를 찾기가 힘들다.
- 때문에 JPA는 값 타입 컬렉션을 다르게 취급한다.
- 변경 사항이 발생한다.
- Collection value type이 매핑된 테이블의 (Entity와) 연관된 모든 데이터를 삭제한다.
- 현재 값 타입 컬렉션 객체에 있는 모든 값을 database에 다시 저장한다.
- 따라서 실무에서는 Collection value type이 매핑된 Table에 data가 많다면 일대다 관계를 고려해야 한다.
- Collection value type을 매핑하는 Table은 모든 column을 묶어 primary key를 구성해야 한다.
- 기본 키 제약 조건으로 인해 column에 null을 입력할 수 없다.
- 같은 값을 중복해서 저장할 수 없다.
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
- 영속성 전이(Cascade) + 고아 객체 제거(Orphan Remove) 기능을 적용해 일대다 관계로 설정하면 위의 제약을 풀 수 있다.