기본 값 타입은 말 그대로, 기본으로 제공되는 값 타입을 의미한다. 예시로는 다음과 같은 타입들이 있다.
기본 값 타입의 특징으로는 생명 주기를 엔티티에 의존한다는 것이다. 예를 들면, 회원 엔티티를 삭제하면 이름, 나이와 같은 필드들도 같이 삭제가 된다. 또한 값 타입이 변경될 시에는 공유되지 않아야 한다라는 특징도 가진다. 즉, A회원의 이름을 변경했을 때 B회원의 이름은 바뀌지 않아야 한다는 것이다.
참고로, 자바 기본 타입은 주소를 복사하는 것이 아니라 항상 값을 복사를 한다. 하지만 래퍼 클래스(Integer, Long ..) 은 공유가 가능하지만, 변경이 불가능하다. 새로운 인스턴스를 만들어야 한다.
이 부분이 조금 중요하다. 물론 성능의 개선을 이뤄내는 것은 아니지만, 가독성이나 타입의 재사용에 많은 도움이 될 수 있다.
임베디드 타입은 개발자가 제공되는 값 타입과는 다른 새로운 값 타입을 만드는 것이다. JPA에서는 이 타입을 임베디드 타입
이라고 한다. 주로 기본값 타입을 모아서 만들어서 복합 값 타입
이라고도 한다.
예를 들면, 회원 엔티티에서 주소 정보를 가진다고 가정해보자. 주소에는 우편번호, 상세주소와 같이 여러 값들이 필요하다. 하지만, "주소"를 표현하기 위해 여러 타입들이 정의되는 것이 코드로 유지보수할 때에는 불편할 수 있기에 임베디드 타입으로 새롭게 정의해서 개발자들이 유지보수하기 쉽게, 개발하기 편하게 나온 타입이다.
우선 임베디드 타입
을 사용할 때는 기본 생성자가 필수이다.
왜 기본생성자가 필수일까?
기본 생성자가 없으면 JPA가프록시 객체
를 만들 수 없어 문제가 발생할 수 있고, 또한 JPA는리플렉션
을 사용하여 객체를 생성하기 때문에 기본 생성자가 필요하다.
값 타입을 정의하는 클래스에서는 @Embeddable
어노테이션을 붙여줘야 하고, 값 타입을 사용하는 필드에서는 @Embedded
어노테이션을 붙여줘야 한다. 아래 예시를 보면, 바로 이해가 될 것이다.
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
public Address() {
}
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
}
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
// 주소 Address
@Embedded
private Address homeAddress;
}
위처럼 설계하면, 실제 데이터베이스에서 Member 테이블에는 id
, username
, city
, street
, zipcode
필드를 가지게 된다.
즉, 엔티티 내에 직접 필드를 정의하는 것과 결과적으로는 차이가 없지만, 임베디드 타입을 사용함으로써 가독성이 좋아지고, 재사용성이 좋아진다는 것을 확인할 수 있다.
임베디드 타입에 정의한 매핑정보를 재정의하려면, 엔티티에 @AttributeOverride
를 사용하면 된다. 예를 들어 위의 회원에게 주소가 하나 더 필요하다면 어떻게 해야할까?
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
// 주소 Address
@Embedded
private Address homeAddress;
@Embedded
private Address companyAddress;
}
바로 위 예제처럼 작성하게되면, 필드 이름이 중복된다. (city
, street
, zipcode
필드가 중복됨)
이럴 때 @AttributeOverride 어노테이션을 이용해서 아래처럼 해결하면 된다.
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@Embedded
private 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"))
})
private Address companyAddress;
}
이렇게 되면 실제로 DB에서 Member 테이블에는 city
, street
, zipcode
, COMPANY_CITY
, COMPANY_STREET
, COMPANY_ZIPCODE
필드가 저장이 됨으로써 필드 중복을 피할 수 있다.
임베디드 타입이 null 이면, 임베디드 타입에 포함되는 컬럼들(city, street, zipcode)은 모두 null이 된다.
임베디드 타입의 장점을 정리하면 다음과 같다.
앞에서도 얘기했지만 DB에서는 임베디드 타입을 쓰는 것과 안쓰는것에 차이가 없다.
잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.
임베디드 타입과 같이 객체 타입은 불변 객체로 사용해야 한다. 불변객체는 생성 시점 이후 값(필드)을 절대 변경할 수 없는 객체를 의미한다.
왜 불변객체로 만들어야할까? 예제를 봐보자.
// 잘못된 코드 (불변객체 X)
Address address = new Address("city", "street", "1000");
Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("member2");
member2.setHomeAddress(address);
em.persist(member2);
member2.getHomeAddress().setCity("newCity");
tx.commit();
목적은 member2 객체의 city 필드만 변경시키고 싶었지만, 결과적으로는 member1의 city 필드도 변경되었다. update 쿼리도 2번 나가는 것을 확인할 수 있다.
이유는 임베디드 타입이 객체타입이라서 Address
인스턴스 하나에 Member
인스턴스 2개가 공유 참조를 하고 있었던 것이다.
이 문제를 해결하려면, Address 객체를 불변 객체로 만들어야 한다.
// Address.java
// Setter 삭제
@Embeddable
@Access(AccessType.FIELD)
public class Address {
private String city;
private String street;
private String zipcode;
public Address() {
}
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
public String getCity() {
return city;
}
public String getStreet() {
return street;
}
public String getZipcode() {
return zipcode;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Address address = (Address) o;
return Objects.equals(city, address.getCity())
&& Objects.equals(street, address.getStreet())
&& Objects.equals(zipcode, address.getZipcode());
}
@Override
public int hashCode() {
return Objects.hash(getCity(), getStreet(), getZipcode());
}
}
@Access(AccessType.FIELD)
를 넣은 이유JPA는
필드 접근
,프로퍼티 접근
2가지 방법을 이용해 엔티티의 데이터에 접근한다. 하지만,@Id
어노테이션이 붙은 필드가 없는 클래스는 데이터에 접근할 때 프로퍼티로 접근해야할지, 필드로 접근해야할지 명확히 판단되지 않아서 인텔리제이에서 setter를 지우게 되면, getter에 에러 메시지(For property-based access both setter and getter should be present) 가 띄게 된다.
그렇기에@Access
어노테이션을 활용해 필드 접근을 하도록 구현해준다. 참고로,@Id
어노테이션이 있는 클래스에서는 모든 데이터에 접근할 때 필드 접근을 하도록 자동으로 세팅된다.
// 올바른 코드 (불변객체O)
Address address = new Address("city", "street", "1000");
Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("member2");
member2.setHomeAddress(address);
em.persist(member2);
// member2.getHomeAddress().setCity("newCity");
member2.setHomeAddress(new Address("newCity", address.getStreet(), address.getZipcode()));
tx.commit();
Address 를 불변 객체로 만듦으로써 부작용(side-effect) 를 막을 수 있게 되었다. setter를 삭제해도 되고, setter를 private으로 만들어도 된다.
DB에서는 컬렉션을 저장할 수가 없다. 따라서 컬렉션을 저장하기 위한 별도의 테이블이 필요하다. 위와 그림처럼, Member 클래스에서 값 타입을 컬렉션으로 가지게 되면, DB에서는 별도의 테이블을 만들어 사용한다.
@ElementCollection
어노테이션과 @CollectionTable
어노테이션을 활용해서 만들면 된다. @ElementCollection
은 해당 타입이 컬렉션 타입이라는 것을 명시하는 것이고, @CollectionTable
은 DB에서 만들 새로운 테이블에 대한 내용을 정의하는 것이다.
참고로, 값 타입 컬렉션은 영속성 전이(Cascade) + 고아객체 제거 기능을 필수로 가진다.
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@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<>();
}
Address address = new Address("homeCity", "street", "10000");
Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);
member1.getFavoriteFoods().add("치킨");
member1.getFavoriteFoods().add("족발");
member1.getFavoriteFoods().add("피자");
member1.getAddressHistory().add(new Address("old1", "street", "10000"));
member1.getAddressHistory().add(new Address("old2", "street", "10000"));
em.persist(member1);
em.flush();
em.clear();
tx.commit();
// 실행결과
Hibernate:
/* insert org.example.domain.Member
*/ insert
into
Member
(city, street, zipcode, username, endDate, startDate, MEMBER_ID)
values
(?, ?, ?, ?, ?, ?, ?)
Hibernate:
/* insert collection
row org.example.domain.Member.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row org.example.domain.Member.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row org.example.domain.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
/* insert collection
row org.example.domain.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
/* insert collection
row org.example.domain.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
값 타입 컬렉션들은 모두 지연 로딩 방식으로 조회한다.
Address address = new Address("homeCity", "street", "10000");
Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);
member1.getFavoriteFoods().add("치킨");
member1.getFavoriteFoods().add("족발");
member1.getFavoriteFoods().add("피자");
member1.getAddressHistory().add(new Address("old1", "street", "10000"));
member1.getAddressHistory().add(new Address("old2", "street", "10000"));
em.persist(member1);
em.flush();
em.clear();
System.out.println("============ START ============");
Member findMember = em.find(Member.class, member1.getId());
tx.commit();
...
============ START ============
Hibernate:
select
member0_.MEMBER_ID as member_i1_6_0_,
member0_.city as city2_6_0_,
member0_.street as street3_6_0_,
member0_.zipcode as zipcode4_6_0_,
member0_.username as username5_6_0_,
member0_.endDate as enddate6_6_0_,
member0_.startDate as startdat7_6_0_
from
Member member0_
where
member0_.MEMBER_ID=?
지연 로딩
으로 조회되므로, AddressHistory 컬렉션이나 FavoriteFoods 컬렉션은 조회되지 않는 것을 확인할 수 있다.
...
System.out.println("============ START ============");
Member findMember = em.find(Member.class, member1.getId());
List<Address> addressHistory = findMember.getAddressHistory();
for (Address address1 : addressHistory) {
System.out.println("address = " + address1.getCity());
}
Set<String> favoriteFoods = findMember.getFavoriteFoods();
for (String favoriteFood : favoriteFoods) {
System.out.println("favoriteFood = " + favoriteFood);
}
tx.commit();
============ START ============
Hibernate:
select
member0_.MEMBER_ID as member_i1_6_0_,
member0_.city as city2_6_0_,
member0_.street as street3_6_0_,
member0_.zipcode as zipcode4_6_0_,
member0_.username as username5_6_0_,
member0_.endDate as enddate6_6_0_,
member0_.startDate as startdat7_6_0_
from
Member member0_
where
member0_.MEMBER_ID=?
Hibernate:
select
addresshis0_.MEMBER_ID as member_i1_0_0_,
addresshis0_.city as city2_0_0_,
addresshis0_.street as street3_0_0_,
addresshis0_.zipcode as zipcode4_0_0_
from
ADDRESS addresshis0_
where
addresshis0_.MEMBER_ID=?
address = old1
address = old2
Hibernate:
select
favoritefo0_.MEMBER_ID as member_i1_4_0_,
favoritefo0_.FOOD_NAME as food_nam2_4_0_
from
FAVORITE_FOOD favoritefo0_
where
favoritefo0_.MEMBER_ID=?
favoriteFood = 족발
favoriteFood = 치킨
favoriteFood = 피자
값 타입 컬렉션에서 삭제하면, delete 쿼리가 나가고, 새로운 것을 추가하면 insert 쿼리가 나간다. 마치 영속성 전이 + 고아 객체 제거 기능처럼!
System.out.println("============ START ============");
Member findMember = em.find(Member.class, member1.getId());
// 치킨 -> 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
// old1 -> new1
// equals() 메서드 이용해서 객체 찾아서 지운다.
findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressHistory().add(new Address("new1", "street", "10000"));
tx.commit();
============ START ============
Hibernate:
select
member0_.MEMBER_ID as member_i1_6_0_,
member0_.city as city2_6_0_,
member0_.street as street3_6_0_,
member0_.zipcode as zipcode4_6_0_,
member0_.username as username5_6_0_,
member0_.endDate as enddate6_6_0_,
member0_.startDate as startdat7_6_0_
from
Member member0_
where
member0_.MEMBER_ID=?
Hibernate:
select
favoritefo0_.MEMBER_ID as member_i1_4_0_,
favoritefo0_.FOOD_NAME as food_nam2_4_0_
from
FAVORITE_FOOD favoritefo0_
where
favoritefo0_.MEMBER_ID=?
Hibernate:
select
addresshis0_.MEMBER_ID as member_i1_0_0_,
addresshis0_.city as city2_0_0_,
addresshis0_.street as street3_0_0_,
addresshis0_.zipcode as zipcode4_0_0_
from
ADDRESS addresshis0_
where
addresshis0_.MEMBER_ID=?
Hibernate:
/* delete collection org.example.domain.Member.addressHistory */ delete
from
ADDRESS
where
MEMBER_ID=?
Hibernate:
/* insert collection
row org.example.domain.Member.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row org.example.domain.Member.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
Hibernate:
/* delete collection row org.example.domain.Member.favoriteFoods */ delete
from
FAVORITE_FOOD
where
MEMBER_ID=?
and FOOD_NAME=?
Hibernate:
/* insert collection
row org.example.domain.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
조금 생각해봐야할 부분은, Address 타입 컬렉션(AddressHistory
)에서는 하나를 지우고 하나를 저장하는 쿼리가 아니라, 관련 memberId 를 가지는 모든 컬럼을 삭제하고 삭제되지 않은 기존 컬럼과 새롭게 저장된 컬럼을 다시 저장하는 insert 쿼리를 날리게 된다.
그러므로, Address 타입 컬렉션(AddressHistory
) 관련 쿼리는 delete 쿼리 1번(memberId 기준으로 삭제), insert 쿼리 2번('new1', 'old2')가 발생한 것이다.
즉, 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
결론적으로, 이건 쓰면 안된다! 개발자가 예상하지 못한 성능 저하를 야기할 수 있다.
그럼 어떻게? 일대다 관계를 고려해서 새로운 테이블을 관리하도록 하자.
@Entity
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address;
}
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@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<>();
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
}
값 타입은 정말 간단한 값들을 컬렉션으로 저장할 때만 사용하도록 한다. 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다.
식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 엔티티 타입으로 만들어야 한다.
자바 ORM 표준 JPA 프로그래밍
https://1-7171771.tistory.com/123
https://ttl-blog.tistory.com/120#%ED%--%--%EB%--%-C%--%EC%A-%--%EA%B-%BC%--%EB%B-%A-%EC%-B%-D%EA%B-%BC%--%ED%--%--%EB%A-%-C%ED%-D%BC%ED%-B%B-%--%EC%A-%--%EA%B-%BC%--%EB%B-%A-%EC%-B%-D%--%ED%--%A-%EA%BB%--%--%EC%--%AC%EC%-A%A-%ED%--%--%EA%B-%B-