엔티티(Entity)와 값 타입(Value Type)
JPA의 데이터 타입을 가장 크게 분류하면 엔티티(Entity) 타입과 값(Value) 타입으로 나눌 수 있다.
엔티티 타입은 @Entity로 정의하는 객체이고, 값 타입은 int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말한다.
DDD에서는 엔티티 + 값으로 애그리 거트를 구축한다.
값 타입은 다음 3가지로 나눌 수 있다.
- 기본값 타입(baisc value type)
String, int, Integer처럼 자바가 제공하는 기본 데이터 및 래퍼 클래스 타입 - 임베디드 타입(embedded type) - 복합 값 타입
JPA에서 사용하가 직접 정의한 값 타입 - 컬렉션 값 타입(collection value type)
하나 이상의 값 타입을 저장할 때 사용
기본값 타입(baisc value type)
@Entity
public class Member {
@Id
// ... 생략
private String zipCode;
private String defaultAddress;
private String detailAddress;
}
값 타입은 엔티티에 의존하므로 다른 곳에 공유하면 안 된다.
예를 들어 다른 엔티티에서 Member엔티티의 defaultAddress값을 바꿀 수 있게 되면 추적이 힘들고 객체지향이라는 장점이 없어진다. 무조건 private로 선언하자
임베디드 타입(embedded type) - 복합 값 타입
JPA에서는 새로운 값 타입을 직접 정의해서 사용할 수 있는데 이것을 임베디드 타입이라 한다.
중요한 것은 직접 정의한 임베디드 타입도 int, String처럼 값 타입이라는 것이다.
@Entity
public class Member {
@Id
// ... 생략
@Embedded
private Address address;
}
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
public class Address {
private String zipCode;
private String defaultAddress;
private String detailAddress;
}
- @Embeddable : 값 타입을 정의하는 곳에 표시
- @Embedded : 값 타입을 사용하는 곳에 표시
만약 모든 값들을 기본값 타입으로 선언하게 되면 알아보기 힘들고 객체에 대한 응집도가 떨어진다.
관련된 기본값 타입을 클래스로 묵어 임베디드 타입으로 선언하게 되면 코드가 좀 더 명확해질 것이다.
임베디드 타입과 연관관계
임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있다.
@Entity
public class Member {
@Id
// ... 생략
@Embedded
private Address address;
}
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
public class Address {
private String zipCode;
private String defaultAddress;
private String detailAddress;
// 임베디드 타입에 엔티티 참조
@ManyToOne City city;
}
@AttributeOverride를 사용한 속성 재정의
임베디드 타입에 정의한 매핑 정보를 재정의하려면 @AttributeOverride를 사용하면 된다.
@Entity
public class Member {
@Id
// ... 생략
@Embedded
private Address address;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "zipCode", column = @Column(name = "company_zipcode")),
@AttributeOverride(name = "defaultAddress", column = @Column(name = "company_default_address")),
@AttributeOverride(name = "detailAddress", column = @Column(name = "company_detail_address"))
})
private Address companyAddress;
}
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
public class Address {
private String zipCode;
private String defaultAddress;
private String detailAddress;
// 임베디드 타입에 엔티티 참조
@ManyToOne City city;
}
같은 Address 클래스를 사용하지만 매핑 칼럼을 바꿀 수 있다.
임베디드 타입 안에 또 다른 임베디드가 있을 경우에도 엔티티에 모두 설정해야 한다.
임베디드 타입과 null
임베디드 타입이 null이면 매핑한 칼럼 값은 모두 null이 된다.
위에 adress 객체가 null이면 zipCode, defaultAddress, detailAddress가 null이 된다는 소리다.
값 타입 공유 참조 금지!
임베디드 타입 같은 값 타입을 여러 엔티티에 공유하는 것은 절대로 금지다.
만약 회원 1과 같은 주소를 회원 2에 저장을 한다고 가정해보자
member1.setAddress(new Address("OldCity"));
Address address = member1.getAddress();
address.setCity("NewCity");
member2.setAddress(address); // 회원1의 address 값을 공유해서 사용
이 코드를 실행하면 회원 2의 주소면 NewCity로 변경되길 기대하지만 회원 1과 회원 2는 같은 address 인스턴트를 참조하기 때문에 회원 1에 주소도 NewCity로 변경되는 상황이 발생한다.
이런 부작용을 막으려면 값을 복사해서 사용하면 된다.
값 타입 복사
위에 예제에서 보듯 실제 인스턴스인 값을 공유하는 것은 매우 위험하다.
대신에 값(인스턴스)을 복사해서 사용해야 한다.
member1.setAddress(new Address("OldCity"));
Address address = member1.getAddress();
Address newAddress = address.clone(); // 복사해서 새로운 객체 생성
newAddress.setCity("NewCity");
member2.setAddress(newAddress);
이처럼 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다.
임베디드 타입은 clone()을 사용해서 복제하지만 자바의 기본 타입은 아래 코드처럼 그냥 값을 대입하면 복사해서 전달된다.
int a = 10;
int b = a;
b = 4;
// a = 10, b = 4
나는 엔티티를 구축할 때부터 엔티티에 Setter를 절대로 넣지 않고 엔티티를 매개변수로 받지도 않는다.
아예 엔티티 밖에서 엔티티의 값을 변경하지 못하도록 하는 것이다.
엔티티는 오직 엔티티 안에 선언된 함수에서만 사용할 수 있도록 설계하는 것이 좋다.
불변 객체
위와 같은 문제를 피하려면 엔티티에 Setter 함수를 만들지 않고 구축해 한번 만들면 변경이 불가능한 불변 객체로 만든다.
@Embeddable
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Address {
private String city;
}
member1.setAddress(new Address("OldCity"));
Address address = member1.getAddress();
member2.setAddress(new Address(address.getCity()));
만약 address를 공유한다 하더라도 값을 수정할 수 없기 때문에 부작용을 예방할 수 있다.
값 타입 컬렉션
값 타입 하나 이상을 저장하려면 컬렉션에 보관하고 @ElementCollection, @CollectionTable을 사용하면 된다.
@Entity
public class Member {
@Id @GeneratedValue
private long id;
@Embedded
private Address address;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "FAVORITE_FOODS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();
}
@Embeddable
public class Address {
@Column
private String city;
private String street;
private String zipcode;
}
@ElementCollection에서도 Fetch 전략을 설정할 수 있다.
Fetch 전략이 무엇인지 잘 모르겠다면 아래 블로그 글을 참고 바란다.
값 타입 컬렉션의 제약사항
엔티티는 식별자가 있으므로 엔티티의 값을 변경해도 식별자로 데이터베이스에 저장된 데이터를 쉽게 찾아서 변경할 수 있다.
반면에 값 타입은 식별자가 없어서 데이터를 찾지 못하기 때문에 값 타입 컬렉션의 변경사항이 일어나면 해당 데이터를 모두 지우고 다시 등록된다.
예를 들어
회원의 favoriteFoods를 변경하려고 한다면 해당 회원의 favoriteFoods 데이터를 모두 삭제하고 다시 등록한다.
DELETE FROM FAVORITE_FOODS WHERE MEMBER_ID = 1;
INSERT INTO FAVORITE_FOODS (MEMBER_ID, FOOD_NAME) VALUES (1, "짜장면")
INSERT INTO FAVORITE_FOODS (MEMBER_ID, FOOD_NAME) VALUES (1, "짬뽕")
.
.
.
INSERT INTO FAVORITE_FOODS (MEMBER_ID, FOOD_NAME) VALUES (1, "탕수육")
favoriteFoods 데이터가 100개라면 INSERT문이 100번 실행되는 것이다.
따라서 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 OneToMany 관계를 고려해야 한다. OneToMany 관계와 영속성 전이(Cascade) + 고아 객체 제거(Orphan Remove)를 사용하면 값 타입 컬렉션처럼 사용할 수 있다.
추가로 값 타입 컬렉션을 매핑하는 테이블은 모든 칼럼을 묶어서 기본 키를 구성해야 한다.
따라서 데이터베이스 기본 키 제약 조건으로 인해 컬럼에 null을 입력할 수 없고, 같은 값을 중복해서 저장할 수 없는 제약도 있다.
값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다.
특히 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안 된다.
식별자가 필요하고 지속해서 값을 추적하고 구분하고 변경해야 한다면 그것은 값 타입이 아닌 엔티티이다.