[JPA 프로그래밍] 8. 값 타입
JPA의 데이터 타입 분류
JPA의 데이터 타입을 크게 분류하면 엔티티 타입과 값 타입으로 나눌 수 있다
엔티티 타입
- 엔티티 타입은 @Entity로 정의하는 객체
- 데이터가 변해도 식별자로 지속해서 추적 가능
- ex) 회원 엔티티의 키나 나이 값을 변경해도 식별자로 인식 가능
값 타입
- int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체
- 식별자가 없고 값만 있으므로 변경시 추적 불가
- ex) 숫자 100을 200으로 변경하면 완전히 다른 값으로 대체
기본값 타입은 String, int처럼 자바가 제공하는 기본 데이터 타입이고, 임베디드 타입은 JPA에서 사용자가 직접 정의한 값이다. 마지막으로 컬렉션 값 타입은 하나 이상의 값 타입을 저장할 때 사용한다.
기본값
회원 엔티티에서 name과 age가 기본값 타입이다. 회원 엔티티는 id라는 식별자 값도 가지고 생명주기도 있지만, 값 타입인 name, age 속성은 식별자 값도 없고 생명 주기도 회원 엔티티에 의존한다. 따라서 회원 엔티티를 삭제하면 이름, 나이 필드도 함께 사라진다. 그리고 값 타입은 공유하면 안 된다.
@Entity
public class Member{
@Id @GeneratedValud
private Long id;
private String name;
private int age;
}
[참고]
int, double 같은 기본 타입은 절대 공유하면 안 된다. 기본 타입은 항상 값을 복사한다. Integer 같은 래퍼 클래스나 String 같은 특수한 클래스는 공유 가능한 객체이지만 변경 불가하다.
임베디드 타입 (복합 값 타입)
새로운 값 타입을 직접 정의해서 사용할 수 있는데, JPA에서는 이를 임베디드 타입이라고 한다. 중요한 것은 직접 정의한 임베디드 타입도 int, String처럼 값 타입이라는 점이다.
예시
회원 엔티티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 가진다. 야기서 공통되는 근무와 주소를 묶어서 임베디드 타입으로 만들 수 있다.
근무(시작일, 종료일)과 주소(도시, 번지, 우편번호)를 묶어서 workPeriod와 homeAddress 임베디드 타입으로 나타냈다.
임베디드 타입과 테이블 매핑
- @Embeddable : 값 타입을 정의하는 곳에 표시
- @Embedded : 값 타입을 사용하는 곳에 표시
- 기본 생성자 필수이다.
@Entity
public class Member {
@Embedded
private Period workPeriod;
@Embedded
private Address homeAddress;
}
@Embeddable
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
}
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
}
- 임베디드 타입은 엔티티의 값일 뿐이다
- 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다
- 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능하다
- 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다
임베디드 타입의 장점
- 재사용
- 높은 응집도
- 해당 값만 사용하는 의미 있는 메소드를 만들 수 있다
- 임베디드 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명 주기를 의존한다.
임베디드 타입과 연관관계
임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있다.
@AttributeOverride : 속성 재정의
임베디드 타입에 정의한 매핑 정보를 재정의하려면 엔티티에 @AttributeOverride를 사용하면 된다.
@Entity
public class Member{
...
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column("HOME_CITY")),
@AttributeOverride(name = "street", column = @Column("HOME_STREET")),
@AttributeOverride(name = "zipcode", column = @Column("HOME_ZIPCODE"))})
private Address homeAddress;
}
임베디드 타입과 null
임베디드 타입의 값이 null이면 매핑한 컬럼 값은 모두 null이다.
값 타입과 불변 객체
값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다. 따라서 값 타입은 단순하고 안전하게 다룰 수 있어야 한다.
값 타입 공유 참조
임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다.
회원 2에 새로운 주소를 할당하려고 회원1의 주소를 그대로 참조해서 사용했다. 이 코드를 실행하면, 회원2의 주소만 변경되길 기대했지만 회원1의 주소도 "NewCity"로 변경되어 버린다. 값 타입의 실제 인스턴스인 값을 공유하는 것은 이러한 부작용을 가져올 수 있기 때문에 위험하다.
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();
address.setCity("NewCity");
member2.setHomeAddress(address);
이러한 부작용을 막으려면 값(인스턴스)를 복사해서 사용하면 된다.
회원 2에 새로운 주소를 할당하기 위해 clone() 메소드를 만들었는데, 이 메소드는 자신을 복사해서 반환하도록 구현했다. 이 코드를 실행하면 의도한 대로 회원2의 주소만 "NewCity"로 변경된다.
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();
Address newAddress = address.clone();
address.setCity("NewCity");
member2.setHomeAddress(address);
한계
항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다. 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이라는 점이다. 자바 기본 타입에 값을 대입하면 값을 복사하지만, 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없다. 즉, 객체의 공유 참조는 피할 수 없다.
이를 해결할 수 있는 가장 단순한 방법은 객체의 값을 수정하지 못하게 막으면 된다
불변 객체
값 타입은 부작용 걱정 없이 사용할 수 있어야 한다. 부작용이 일어나면 값 타입이라 할 수 없다. 객체를 불변하게 만들면 값을 수정할 수 없으므로 부작용을 원천 차단할 수 있다. 따라서 값 타입은 될 수 있으면 불변 객체로 설계해야 한다. 생성자로만 값을 설정하고 수정자를 만들지 않으면 된다.
만약, 값을 수정하고 싶다면 새로운 인스턴스를 만들어 설정하면 된다.
Address address = new Address("city", "street", "10000");
Member m1 = new Member();
m1.setUsername("m1");
m1.setHomeAddress(address);
em.persist(m1);
Address newAddress = new Address("newCity", address.getStreet(), address.getZipcode());
m1.setHomeAddress(newAddress);
값 타입의 비교
- 동일성 비교(identity) : 인스턴스의 참조 값을 비교, == 사용
- 동등성 비교(equivalence) : 인스턴스의 값을 비교, equals() 사용
Address 값 타입을 a == b로 동일성 비교를 하면 둘은 다른 인스턴스이므로 거짓이 나온다. 이것은 우리가 원하는 값이 아니다. 값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야한다. 따라서 값 타입을 비교할 때는 a.equals(b)를 사용해서 동등성 비교를 해야 한다. 물론 Address의 equals 메소드를 적절하게 재정의 해야 한다.
값 타입 컬렉션
값 타입을 하나 이상 저장하려면 컬렉션에 보관하도 @ElementCollection, @CollectionTable 어노테이션을 사용하면 된다.
favoriteFoods는 기본값 타입인 String을 컬렉션으로 가진다. 이것을 데이터베이스 테이블로 매핑해야 하는데 관계형 데이터베이스의 테이블은 컬럼 안에 컬렉션을 포함할 수 없다. 따라서 ERD처럼 별도의 테이블을 추가해서 매핑해야 한다.
@Entity
public class Member {
...
@Embedded
private Address homeAddress;
@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD", joinColumns =
@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<>();
...
}
값 타입 컬렉션의 제약 사항
값 타입은 엔티티와 다르게 식별자 개념이 없다. 그렇기 때문에 값을 변경해버리면 추적이 어렵다.
값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다. 예를 들어 식별자가 100번인 회원이 관리하는 주소 값 타입 컬렉션을 변경하면 테이블에서 회원 100번과 관련된 모든 주소 테이블을 삭제하고 현재 값 타입 컬렉션에 있는 값을 다시 저장한다.
따라서 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다. 일대다 관계를 위한 엔티티를 만들고 여기서 값 타입을 사용하면 된다. 여기에다 영속성 전이와 고아 객체 제거 기능을 적용하면 값 타입 컬렉션처럼 사용할 수 있다.
정리
엔티티 타입의 특징
- 식별자가 있다.
- 엔티티 타입은 식별자가 있고 식별자로 구분할 수 있다 - 생명 주기가 있다
- 생성하고, 영속화하고, 소멸하는 생명 주기가 있다
- em.persist()로 영속화한다
- em.remove()로 제거한다 - 공유할 수 있다
- 참조 값을 공유할 수 있다.
- 예를 들어 회원 엔티티가 있다면 다른 엔티티에서 얼마든지 회원 엔티티를 참조 할 수 있다
값 타입의 특징
- 식별자가 없다
- 생명 주기를 엔티티에 의존한다
- 스스로 생명주기를 가지지 않고 엔티티에 의존한다. 의존하는 엔티티를 제거하면 같이 제거된다 - 공유하지 않는 것이 안전하다
- 엔티티 타입과는 다르게 공유하지 않는 것이 안전하다. 대신에 값을 복사해서 사용해야 한다
- 오직 하나의 주인만이 관리해야 한다 - 불변 객체로 만드는 것이 안전하다