BOOK 3 - 자바 ORM 표준 JPA 프로그래밍(9)
값 타입
- JPA의 데이터 타입을 크게 분류하면 엔티티 타입과 값 타입으로 나눌 수 있다.
- 엔티티 타입 :
@Entity로 정의하는 객체 - 값 타입 :
int,Integer,String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체
- 엔티티 타입 :
- 엔티티 타입은 식별자를 통해 지속해서 추적할 수 있지만, 값 타입은 식별자가 없고 속성만 있으므로 추적할 수 없다.
- 값 타입은 다음 3가지로 나눌 수 있다.
- 기본값 타입
- 자바 기본 타입(
int,double, …) - Wrapper class (
Integer, …) - String
- 자바 기본 타입(
- 임베디드 타입 (복합 값 타입)
- JPA에서 사용자가 직접 정의한 값 타입
- 컬렉션 값 타입
- 기본값 타입
기본 값 타입
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
private int age;
...
}
- 위 코드에서
String,int가 값 타입이다. Member엔티티는id라는 식별자 값도 가지고 생명주기도 있지만, 값 타입인name,age속성은 식별자 값도 없고 생명주기도 회원 엔티티에 의존한다.- 즉, 회원 엔티티 인스턴스를 제거하면
name,age값도 제거된다.
- 즉, 회원 엔티티 인스턴스를 제거하면
- 값 타입은 서로 공유되지 않는다.
- 예를 들어 다른 회원 엔티티의 이름을 변경한다고 해서 나의 이름까지 변경되지 않는다.
임베디드 타입 (복합 값 타입)
- JPA에서 새로운 값 타입을 직접 정의해서 사용할 수 있는데, 이것을 임베디드 타입이라고 한다.
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
private int age;
@Temporal(TemporalType.DATE)
Date startDate;
@Temporal(TemporalType.DATE)
Date endDate;
private String city;
private String street;
private String zipcode;
...
}
- 위 코드에서는 회원이 근무 시작일, 우편번호 등의 상세한 데이터를 모두 그대로 가지고 있는 것은 객체지향적이지 않으며 응집력을 떨어뜨린다.
- 근무 기간 / 주소를 타입 객체로 정의하면 코드가 좀 더 명확해질 것이다.
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@Embedded
Period workPeriod;
@Embedded
Address homeAddress;
}
@Embeddable
public class Period {
@Temporal(TemporalType.DATE)
Date startDate;
@Temporal(TemporalType.DATE)
Date endDate;
public boolean isWork(Date date) {
// 값 타입을 위한 메소드를 정의할 수 있다.
}
}
@Embeddable
public class Address {
@Column(name="city")
private String city;
private String street;
private String zipcode;
...
}
- 새로 정의한 값 타입들은 재사용할 수 있고, 응집도도 아주 높다.
- 또한 해당 값 타입만 사용하는 의미 있는 메소드도 만들 수 있다.
@Embeddable: 값 타입을 정의하는 클래스에 표시-
@Embedded: 값 타입을 사용하는 필드에 표시 - 임베디드 타입은 기본 생성자가 필수다.
임베디드 타입과 테이블 매핑

- 임베디드 타입 객체는 값이 속한 엔티티 테이블에 매핑한다.
- 즉, 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다.
임베디드 타입과 연관관계
- 임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있다.
@Entity
public class Member {
@Embedded
Address address;
@Embedded
PhoneNumber phoneNumber;
}
@Embeddable
public class Address {
String street;
String city;
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;
...
}
- 위 코드에서 임베디드 타입인
Address가 값 타입인Zipcode를 포함하고,PhoneNumber가 엔티티 타입인PhoneServiceProvider를 참조한다.
@AttributeOverride : 속성 재정의
- 임베디드 타입에 정의한 매핑정보를 재정의하려면 엔티티에
@AttributeOverride를 사용하면 된다. - 회원에게 집 주소와 회사 주소 정보가 같이 있다고 생각해보자.
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@Embedded
Address homeAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name="city", column=@Column(name="COMPANY_CITY")),
@AttributeOverride(name="street", column=@Column(name="COMPANY_STREET")),
@AttributeOverride(name="zipcode", column=@Column(name="COMPANY_ZIPCODE"))
})
Address companyAddress;
}
-
위 코드 처럼 회사 주소에 대한 매핑정보를 재정의할 수 있다.
-
해당
Member엔티티에 대한 테이블은 다음과 같이 생성된다.CREATE TABLE MEMBER ( ... COMPANY_CITY varchar(255), COMPANY_STREET varchar(255), COMPANY_ZIPCODE varchar(255), city varchar(255), street varchar(255), zipcode varchar(255), ... )
임베디드 타입과 null
- 임베디드 타입이
null이면 매핑한 컬럼 값은 모두null이 된다.
member.setAddress(null);
em.persist(member);
- 위 코드를 실행하면
MEMBER테이블의 해당 회원의CITY,STREET,ZIPCODE컬럼 값은 모두null이 된다.
값 타입과 불변 객체
값 타입 공유 참조
- 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다.
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();
address.setCity("NewCity");
member2.setHomeAddress(address);
- 위와 같이 회원1의
Address인스턴스를 그대로 참조해서 사용하여 회원2에 값을 지정했다.- 회원1과 회원2가 같은 인스턴스를 참조하기 때문에 회원2의 주소를 변경했을 때 회원1의 주소도 함께 변경되어 버린다.
- 영속성 컨텍스트는 회원1과 회원2 둘 다
city속성이 변경된 것으로 판단해서 회원1, 회원2 각각 UPDATE SQL을 실행한다.
값 타입 복사
- 값 타입의 실제 인스턴스를 같이 참조하는 것보다 인스턴스의 값을 복사해서 사용해야 한다.
memer1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();
Address newAddress = address.clone();
newAddress.setCity("NewCity");
member2.setHomeAddress(newAddress);
- 회원2에 새로운 주소를 할당하기 위해
clone()메소드를 만들어 자신의 값을 복사해서 새로운 인스턴스를 반환하도록 구현한다.- 회원1과 회원2는 서로 다른 인스턴스를 참조하기 때문에 회원2의 주소만 변경된다.
- 영속성 컨텍스트는 회원2의 주소만 변경된 것으로 판단해서 회원2에 대해서만 UPDATE SQL을 실행한다.
자바 기본 타입에 값을 대입하면 값을 복사해서 전달하기 때문에 항상 독립된 값을 가지고 부작용이 없다.
객체 타입은 복사하지 않고 참조 값을 직접 넘기는 것을 막을 방법이 없기 때문에 객체 공유 참조가 일어나지 않게 항상 주의하여야 한다.
불변 객체
- 객체를 불변하게 만들면 값을 수정할 수 없으므로 부작용을 차단할 수 있다.
- 값 타입은 될 수 있으면 불변 객체로 설계해야 한다.
- 불변 객체의 값은 조회할 수 있지만 수정할 수 없다.
- 인스턴스의 참조 값 공유가 일어나도 인스턴스의 값을 수정할 수 없기 때문에 부작용이 발생하지 않는다.
@Embeddable
public class Address {
private String city;
protected Address();
public Address(String city) {
this.city = city;
}
public String getCity() {
return city;
}
// Setter를 만들지 않는다.
}
Address address = member1.getHomeAddress();
Address newAddress = new Address(address.getCity());
member2.setHomeAddress(newAddress);
값 타입 컬렉션
- 값 타입을 하나 이상 저장하려면 컬렉션에 보관하고
@ElementCollection,@CollectionTable어노테이션을 사용한다.
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Embedded
private Address homeAddress;
@ElementCollection
@CollectionTable(name="FAVORITE_FOODS", joinColumns=@JoinColumn(name="MEMBER_ID"))
@Column(name="FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<String>();
@ElementCollection
@CollectionTable(name="ADDRESS", joinColumns=@JoinColumn(name="MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<Address>();
...
}
favoriteFoods는 기본값 타입인String을 컬렉션으로 가진다.- 이것을 데이터베이스 테이블로 매핑해야 하는데 관계형 데이터베이스 테이블은 컬럼 안에 컬렉션을 포함할 수 없다.
- 별도의
FAVORITE_FOODS라는 테이블을 추가하고@CollectionTable로 해당 테이블을 매핑한다. - 값으로 사용되는 컬럼이 하나인 경우
@Column을 사용해서 컬럼명을 지정할 수 있다.
addressHistory는 임베디드 타입인Address를 컬렉션으로 가진다.- 이것도 별도의
ADDRESS라는 테이블을 사용한다.
- 이것도 별도의
값 타입 컬렉션 사용
Member member = new Member();
member.setHomeAddress(new Address("city1"));
member.getFavoriteFoods().add("food1");
member.getFavoriteFoods().add("food2");
member.getFavoriteFoods().add("food3");
member.getAddressHistory().add(new Address("city2"));
member.getAddressHistory().add(new Address("city3"));
em.persist(member);
- 위 코드에서 마지막에
member엔티티만 영속화 했다.- 이 때 JPA는
member엔티티의 값 타입도 함께 저장한다. - 데이터베이스에서 실행되는 INSERT SQL은 다음과 같다.
member: INSERT SQL 1번member.homeAddress:MEMBER테이블에 포함되므로member엔티티 저장시 같이 저장된다.member.favoriteFoods: INSERT SQL 3번member.AddressHistory: INSERT SQL 2번
- 이 때 JPA는
- 값 타입 컬렉션은 영속성 전이와 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
페치 전략
- 값 타입 컬렉션도 조회할 때 페치 전략을 사용할 수 있다.
- 기본 값은
LAZY이다. (@ElementCollection(fetch=FetchType.LAZY))
- 기본 값은
- 데이터베이스에서 실행되는 SELECT SQL은 다음과 같다.
member: SELECT SQL 1번member.homeAddress:MEMBER테이블에 포함되므로member엔티티 조회 시 같이 조회된다.member.favoriteFoods:LAZY로 설정해서 실제 컬렉션을 사용할 때 SELECT SQL 1번 호출한다.member.addressHistory:LAZY로 설정해서 실제 컬렉션을 사용할 때 SELECT SQL 1번 호출한다.
값 타입 컬렉션의 제약사항
-
엔티티는 식별자가 있으므로 엔티티의 값을 변경해도 식별자로 데이터베이스에 저장된 원본 데이터를 쉽게 찾을 수 있다.
-
값 타입은 식별자라는 개념이 없고 단순한 값들의 모임이므로 값을 변경해버리면 데이터베이스에 저장된 원본 데이터를 찾기 어렵다.
-
이런 문제로 인해 JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 다시 데이터베이스에 저장한다.
- 따라서 데이터가 많은 경우, 값 타입 컬렉션 대신 일대다 관계를 고려하는 것이 좋다.
-
또한 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다.
- 기본 키 제약조건으로 인해 컬럼에
null을 입력할 수 없고, 같은 값을 중복해서 저장할 수도 없다.
- 기본 키 제약조건으로 인해 컬럼에
-
위와 같은 제약사항으로 인해 값 타입 컬렉션보다 일대다 관계로 매핑해서 사용하는 것이 좋다.
@Entity public class AddressEntity { @Id @GeneratedValue private Long id; @Embedded Address address; ... }@OneToMany(cascade=CascadeType.ALL, orphanRemoval=true) @JoinColumn(name="MEMBER_ID") private List<AddressEntity> addressHistory = new ArrayList<AddressEntity>();