9장_값 타입
JPA의 데이터 타입을 크게 두 가지로 나누면 엔티티 타입과 값 타입이 있다. 엔티티 타입은 @Entity
로 정의하는 객체고, 값 타입의 경우 자바 기본 자료형부터 객체까지 가능하다. 엔티티 타입의 경우는 식별자로 구분되기 때문에 식별자 값만 변하지 않는다면 같은 엔티티로 인식된다. 하지만 값 타입은 식별자가 없기 때문에 값이 달라지면 완전히 다른 값으로 대체된다.
값 타입은 세 가지로 나눌 수 있다.
- 자바 기본형
- 임베디드 타입
- 컬렉션 타입
임베디드 타입은 JPA에서 사용자가 지정한 값 타입이고 컬렉션 타입은 하나 이상의 값 타입을 저장할 때 사용한다.
기본 값 타입
기본 값 타입은 엔티티에 의존한다. 엔티티가 사라지면 값 타입 역시 소멸된다.
또한, 값 타입은 절대 공유되지 않는다.
임베디드 타입
임베디드 타입은 사용자가 정의하는 복합 값 타입이다. @Embeddable
을 정의하는 값 타입에 @Embedded
를 엔티티에서 사용할 필드 값에 적용해준다.
public class Member{
private String name;
@Temporal(TemporalType.DATE)
private Date startDate;
private Date endDate;
private String city;
private String street;
private String zipcode;
// getter & setter
}
위와 같이 회원 객체가 모든 값을 가지는 것은 데이터의 나열일 뿐 객체지향스럽지 않고 응집력이 없다.
이를 근무기간과 집 주소로 나누는 것이 더 좋을 것이다. 이 때 임베디드 타입을 사용할 수 있다.
public class Member{
private String name;
@Embedded
private Period workPeriod;
@Embedded
private Address homeAddress;
}
@Embeddable
public class Period{
@Temporal(TemporalType.DATE)
private Date startDate;
@Temporal(TemporalType.DATE)
private Date endDate;
}
@Embeddable
public class Address{
private String city;
private String street;
private String zipcode;
}
위와 같이 사용하는 것이 더 바람직하다. 임베디드 타입을 포함한 모든 값 타입은 엔티티에 의존하며 생명주기 역시 엔티티가 관리한다. 둘의 관계는 컴포지션 관계이다.
임베디드 타입에 기본 생성자는 필수이며, 두 어노테이션 중 하나는 생략이 가능하다.
임베디드 타입과 테이블 매핑
임베디드 타입은 엔티티의 값에 불과하기 때문에 클래스가 늘어난다 하더라도 테이블의 개수는 그대로다. 임베디드 타입 덕분에 객체와 테이블 아주 세밀하게 매핑하는 것이 가능하다. 잘 설계한 ORM 애플리케이션은 클래스의 개수가 테이블의 개수보다 많다.
ORM을 사용하지 않고 개발하면 테이블 컬럼과 객체 필드를 대부분 1:1로 매핑한다. 값 타입 클래스를 하나 더 만들어서 객체지향적으로 개발하고 싶어도 SQL을 직접 다루면 여러 클래스를 매핑하는 작업은 상당히 고된 일이다. 이런 작업은 JPA에 게 맡기고 좀 더 세밀한 객체지향 모델을 설계할 수 있다.
엔티티의 경우 공유가 가능하므로 참조라는 표현을, 값 타입은 공유가 불가능하므로 포함이라고 표현했다.
임베디드 타입과 연관관계
임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있다.
public class Member{
@Embedded
private Address homeAddress;
@Embedded
private PhoneNubmer phoneNumber;
}
@Embeddable
public class Address{
String city;
String street;
String state;
@Embedded
Zipcode zipcode;
}
@Embeddable
public class Zipcode{
String zip;
String plusFour;
}
@Embeddable
public class PhoneNumber{
String areaCode;
String localNumber;
@ManyToOne
PhoneServiceProvider provider;
}
@Entity
public class PhoneServiceProvider{
@Id
String name;
}
위와 같이 하나의 임베디드 값 타입이 다른 임베디드 값 타입을 포함(Zipcode)할 수도 다른 엔티티를 참조(PhoneServiceProvider)할 수도 있다.
@OverrideAttributes : 속성 재정의
위 상태에서 회사 주소가 하나 추가된다면 @Embedded
와 함께 Address companyAddress
와 같이 새로운 필드를 추가하면 된다. 다만 이렇게만 할 경우 테이블 매핑 시 컬럼명이 중복되어 오류가 발생한다. 이 때 @OverridesAttributes
로 해당 컬럼들의 이름을 바꿀 수 있다.
public class Member{
@Embedded
@OverrideAttributes(
@OverrideAttribute(name = "city", @Column(name = "COMPANY_CITY")),
@OverrideAttribute(name = "street", @Column(name = "COMPANY_STREET")),
@OverrideAttribute(name = "state", @Column(name = "COMPANY_STATE"))
)
private Address companyAddress;
@Embedded
private Address homeAddress;
@Embedded
private PhoneNubmer phoneNumber;
}
위와 같이 컬럼명을 재정의할 수 있다.
임베디드 타입과 null
member1.setAddress(null);
em.persist(); // city, street, state, zipcode 모두 null
임베디드 타입에 null을 넣은 경우 임베디드 타입의 모든 컬럼은 null이 된다.
값 타입과 불변 객체
값 타입은 여러 값을 좀 더 단순화해서 객체로 만든 것이다. 따라서, 이를 사용할 때는 단순하고 안전하게 사용해야 한다.
이전에 값 타입의 경우 공유해서는 안된다고 했다. 위와 같이 회원1과 회원2가 주문이라는 값 타입을 포함하고 있을 때 회원1이 주문을 물건 1에서 물건 2로 바꾸면 회원 2 역시 주문이 바뀌게 된다.
이처럼 값 객체를 여러 엔티티에서 공유할 경우 공유한 모든 엔티티의 값 타입이 변할 수 있다. 이렇듯 뭔가를 수정했는데 전혀 예상치 못한 곳에서 문제가 발생하는 것을 부작용 side effect 라고한다.
이러한 부작용을 막으려면 값을 복사해서 사용하면 된다.
값 타입의 복사
값 타입의 실제 인스턴스를 공유하는 것은 위험하므로 값 타입을 복사해서 사용해야 한다. 자바 기본 타입에서는 값을 대입할 경우 해당 값을 복사해서 사용한다. 이와 달리 객체 타입에서 객체를 대입할 경우 참조값을 전달한다.
int a = 100;
int b = a;
b = 4; // a = 100, b = 4
Order order1 = new Order("프링글스");
Order order2 = order1; // 참조 전달
order2.setMenu("포카칩"); // order1 = 포카칩, order2 = 포카칩
위와 같이 order2의 메뉴만 바꾸려고 했는데 order1의 메뉴도 바뀌게 된다.
Order order1 = new Order("프링글스");
Order order2 = order1.clone();
order2.setMenu("포카칩"); // order1 = 프링글스, order2 = 포카칩
임베디드 타입처럼 직접 정의한 값 타입은 인스턴스를 복사해 대입하면 공유 참조를 피할 수 있다.
하지만 근본적으로 복사하지 않고 원본의 참조값을 직접 전달하는 방법을 막을 방법이 없다. 객체의 공유 참조 문제는 피할 수 없다. 이를 해결하기 위한 가장 단순한 방법은 객체의 값을 바꾸지 못하게 막으면 된다.
불변 객체
값 타입을 사용할 때는 부작용이 없어야 한다. 한 번 만들면 다시는 값을 변경할 수 없는 객체를 불변 객체라고 한다. 객체를 불변하게 만들면 부작용을 원천 차단할 수 있다. 따라서, 값 타입을 만들 때는 가능하면 불변 객체로 만드는 것이 좋다. 불변 객체도 객체이므로 공유 참조 문제는 피할 수 없다. 다만, 참조를 공유해도 값을 변경할 수 없기 때문에 부작용이 없다.
값을 변경하고 싶다면 새로운 객체를 만들어서 사용해야 한다. 불변 객체를 만드는 가장 간단한 방법은 생성자를 통해서만 값을 지정하고 수정자를 없애 수정을 막는 것이다.
class Order{
String menu;
public Order(String menu){
this.menu = menu;
}
public String getMenu(){
return menu;
}
}
Order order1 = new Order("프링글스");
Order order2 = new Order(order2.getMenu());
member2.setOrder(order2);
이제 Order는 불변객체이다. 값을 수정할 수 없으므로 부작용이 발생하지 않는다. 값을 수정하기를 원한다면 새로운 객체를 생성해서 사용하는 방법밖에 없다.
값 타입의 비교
Member a = new Member("지훈", 15, "서울");
Member b = new Member("지훈", 15, "서울");
두 객체는 실제 메모리 주소값은 다르지만 객체가 포함하고 있는 값은 모두 같기 때문에 같다고 볼 수 있다. 이 때 사용한 것은 ==
동일성 비교가 아닌 equals
동등성 비교이다. 객체인 값 타입은 equals
를 재정의할 필요가 있다. 일반적으로 객체에서 equals
를 재정의할 때는 모든 필드를 사용한다. 또한, hashCode
메소드도 추가적으로 재정의해야 Hash를 사용하는 Map이나 Set에서도 문제없이 사용할 수 있다.
값 타입 컬렉션
값 타입을 하나 이상 저장하고 싶다면 컬렉션을 사용하고 @ElementCollection
, @CollectionTable
어노테이션을 사용하면 된다.
@Entity
public class Member{
@Id @GeneratedValue
private Long id;
@Embedded
private Address address;
@ElementCollection
@CollectionTable(
@name = "FAVORITE_FODDS",
@JoinColums(@JoinColumn(name = "MEMBER_ID"))
)
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();
@ElementCollection
@CollectionTable(
@name = "ADDRESS",
@JoinColumns(@JoinColumn(name = "MEMBER_ID")
)
private List<Address> addressHistory = new ArrayList<>();
}
@Embeddable
public class Address{
private String city;
private String street;
private String state;
}
Member 클래스는 두 컬렉션을 값 타입으로 포함한다. 그러나 데이터베이스에서는 컬렉션을 저장할 수 없으므로 별도의 테이블로 만들어야 하는데 이를 @CollectionTable
로 테이블로 매핑해 저장할 수 있다. 또한, 컬럼이 하나인 경우 @Column
어노테이션으로 이름을 지정할 수 있다. 여러 개인 경우 @OverrideAttributes
로 재정의해야 한다.
@CollectionTable
을 생략하면 엔티티명_속성명을 컬럼명으로 가지는 테이블이 매핑된다.
Member member = new Member();
// 컬렉션이 아닌 엔티티의 필드이기 때문에 member에 포함
member.setHomeAddress(new Address("대구","동성로","대한민국"));
member.getFavoriteFoods.add("탕수육");
member.getFavoriteFoods.add("치킨");
member.getFavoriteFoods.add("육개장");
member.getAddressHistory.add(new Address("인천", "남동구", "대한민국"));
member.getAddressHistory.add(new Address("서울", "마포구", "대한민국"));
em.persist(member);
값 타입은 엔티티에 종속되기 떄문에 엔티티를 영속화하는 것만으로 값 타입도 함께 저장된다. 실제 INSERT SQL
은 총 6번 실행된다.
값 타입 컬렉션도 Fetch
전략을 선택할 수 있는데 LAZY
가 기본이다.
값 타입의 컬렉션을 수정하면 어떻게 될까?
// 1. 임베디드값 타입 수정
member.setHomeAddress(new Address("새로운 도시", "신도시1", "대한민국");
// 2. 기본값 타입 컬렉션 수정
member.getFavoriteFoods.remove("탕수육");
member.getFavoriteFoods.add("회");
// 3. 임베디드값 타입 컬렉션 수정
member.getAddressHistory.remove("서울", "마포구", "대한민국"));
member.getAddressHistory.add("고양", "일산", "대한민국"));
1) 임베디드값 타입 수정의 경우 MEMBER 엔티티를 업데이트하는 것과 같다.
2) 기본값 타입 컬렉션의 경우 String을 수정할 수 없으므로 제거하고 새로 추가한다.
3) 임베디드값 타입 컬렉션의 경우 값 타입은 불변해야 하므로 수정이 아닌 제거 후 새로 추가해야 한다. 이 때, 반드시 equals
와 hashCode
를 구현해야 한다.
값 타입 컬렉션의 제약사항
식별자가 있는 엔티티와 다르게 값 타입의 경우 변경되었을 때 데이터베이스의 원본을 추적하기 어렵다. 따라서, JPA 구현체들은 값 타입의 변경이 일어나게 되면 해당 값 타입과 연관된 테이블의 컬럼들을 모두 삭제하고, 새로 저장한다. 따라서, 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 대신 일대다 매핑을 고려해야 한다. 추가로 값 타입 컬렉션을 매핑한 테이블은 모든 컬럼을 묶어서 기본 키로 등록해야 한다. 따라서, 데이터베이스 기본 키 제약 조건에 따라 컬럼에 NULL을 넣을 수도 중복된 값을 넣을 수도 없다. 이 문제들을 해결하려면 기본 값 컬렉션을 사용하는 대신 일대다 관게을 사용하면 된다. 여기에 추가적으로 영속성 전이와 고아 객체 제거 기능까지 적용하면 값 타입 컬렉션처럼 사용할 수 있다.
정리
엔티티 타입
- 식별자가 있다.
- 공유가 가능하다.
- 생명주기가 있다.
값 타입
- 식별자가 없다.
- 되도록 공유하지 않는 것이 안전하다.
- 생명주기가 엔티티 타입에 의존한다.
정말 필요할 때가 아니면 값 타입을 사용하지 않는 것을 추천한다. 또한, 값 타입과 엔티티를 혼동해서 잘못 사용하게 되면 안된다.
'JPA' 카테고리의 다른 글
12장_스프링 데이터 JPA (0) | 2024.04.02 |
---|---|
11장_웹 애플리케이션 제작 (0) | 2024.04.01 |
Java ORM 표준 - JPA 프로그래밍 10장 (0) | 2024.03.24 |
Java ORM 표준 - JPA 프로그래밍 8장 (0) | 2023.11.30 |