엔티티 타입
값 타입
기본값 타입
임베디드 타입(embedded type, 복합 값 타입)
컬렉션 값 타입(collection value type)
@Getter
@Setter
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
// 기간 Period
private LocalDateTime startDate;
private LocalDateTime endDate;
// 주소
private String city;
private String street;
private String zipcode;
}
위와 같은 Member엔티티가 존재할 경우 아래의 그림처럼 DB의 정보는 그대로 두고, 자바에서 Member엔티티의 정보를 묶고싶은 경우가 있습니다.
이때, 사용할수 있는방법이 임베디드입니다.
@Getter
@Setter
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
// 기간 Period
@Embedded
private Period workPeriod;
// 주소
@Embedded
private Adress homeAdress;
}
@Embeddable
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
}
@Embeddable
public class Adress {
private String city;
private String street;
private String zipcode;
}
@Embeddable: 값 타입을 정의하는 곳에 표시
@Embedded: 값 타입을 사용하는 곳에 표시
Member member = new Member();
member.setUsername("A");
member.setWorkPeriod(new Period());
member.setHomeAdress(new Adress("city_1", "street_2", "zipcode_3"));
em.persist(member);
자바 소스코드에서는 객체지향처럼 개발을 하기위해 Period, Adress로 나누었지만, DB에서는 전혀 상관없이 깔끔하게 테이블 1개로 형성된것을 확인할수있습니다.
객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능해집니다.
잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많습니다.
만약, 아래와같이 home과 work adress가 2개가 필요한 경우 @AttributeOverride사용하면됩니다.
...
@Embedded
private Adress homeAdress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "WORK_CITY")),
@AttributeOverride(name = "street", column = @Column(name = "WORK_STREET")),
@AttributeOverride(name = "zipcode", column = @Column(name = "WORK_ZIPCODE")),
})
private Adress workAdress;
...
값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념입니다.
따라서 값 타입은 단순하고 안전하게 다룰 수 있어야 합니다.
Adress adress = new Adress("city_1", "street_2", "zipcode_3");
Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAdress(adress);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("member1");
member2.setHomeAdress(adress);
em.persist(member2);
정상적으로 member1, member2에 Adress가 입력이 되었습니다.
그리고 member1에 city값을 newCity로 변경하고싶을때 아래와같은 코드를 입력하게됩니다.
...
member1.getHomeAdress().setCity("newCity");
...
이렇게 되면 아래와같이 member1, member2의 city값이 변경됩니다.
원하는 member1에 city값만 변경된것이 아닌, member2에 city값도 변경된걸 확인 할 수 있습니다.
이렇게 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험합니다.
이를 해결하기위해 사용하고자하는 Adress는 따로 생성시켜 사용해야합니다.
...
Adress adress = new Adress("city_1", "street_2", "zipcode_3");
...
Adress copyAdress = new Adress("city_1", "street_2", "zipcode_3");
...
그러면 수정해도 문제없이 member1만 newCity로 변경된걸 확인하실수 있습니다.
항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 순 있습니다.
문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입 입니다.
객체 타입은 참조를 복사해서 직접 대입하는 것을 막을 방법이 없습니다.
객체의 공유 참조는 피할 수 없습니다.
이를 해결하기위해 객체타입은 불변객체로 생성해야합니다.
불변객체란? 생성 시점 이후 절대 값을 변경할 수 없는 객체
생성자로만 값을 설정하고 수정자(Setter)를 만들지 않으면 됩니다.
이를 통해 수정하고싶은경우 생성자를통해 새롭게 생성시켜 사용하시면 됩니다.
// 새로운 Adress로 변경하고싶은경우
Adress newCity = new Adress(adress.getCity(), adress.getStreet(), adress.getZipcode());
member1.setHomeAdress(newCity);
값 타입: 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 합니다.
동일성(identity) 비교: 인스턴스의 참조 값을 비교, == 사용
동등성(equivalence) 비교: 인스턴스의 값을 비교, equals() 사용
값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야 함
값 타입의 equals() 메소드를 적절하게 재정의(주로 모든 필드 사용)
...for...Adress.class
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Adress adress = (Adress) o;
return Objects.equals(city, adress.city) &&
Objects.equals(street, adress.street) &&
Objects.equals(zipcode, adress.zipcode);
}
@Override
public int hashCode() {
return Objects.hash(city, street, zipcode);
}
....
Adress adress1 = new Adress("city1","street1", "zipcode1");
Adress adress2 = new Adress("city1","street1", "zipcode1");
// true가 출력되는걸 확인할 수 있습니다.
System.out.println("adress1 equals adress2 : " + adress1.equals(adress2));
Member기반에 FAVORITE_FOOD와 ADDRESS테이블이 있는경우 예제 소스코드처럼 @ElementCollection을 사용하여 매핑이 가능합니다.
@Getter
@Setter
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
@Embedded
private Adress homeAdress;
@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME")
private Set<String> favoritFoods = new HashSet<>();
@ElementCollection
@CollectionTable(name = "ADRESS",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Adress> adress = new ArrayList<>();
}
테이블이 총 3개가 생성된걸 확인할수있습니다.
FAVORITE_FOOD테이블에 MEMBER_ID + FOOD_NAME으로 생성되었고,
ADRESS테이블에도 MEMBER_ID + Adress객체의 값들로 생성된걸 확인할수있습니다.
DB에서는 컬렉션같은 정보를 테이블 1개에 저장 할 수 없기때문에, 별도에 테이블로 생성 후에 저장하는 형식으로 만들게됩니다.
Member member = new Member();
member.setUsername("member1");
member.setHomeAdress(new Adress("city1", "street1", "zipcode1"));
member.getFavoritFoods().add("치킨");
member.getFavoritFoods().add("족발");
member.getFavoritFoods().add("피자");
member.getAdressHistory().add(new Adress("old1", "old1", "old1"));
member.getAdressHistory().add(new Adress("old2", "old2", "old2"));
em.persist(member);
em.persist한번으로 모든 데이터가 INSERT되었습니다.
System.out.println("==========================================");
Member findMember = em.find(Member.class, member.getId());
여기서, findMember를 실행하게된다면 아래와같이 쿼리가 실행됩니다.
한번에 데이터를 조회할때 즉, 값타입들은 지연로딩으로 데이터를 조회하게됩니다.
ElementCollection에 Defatul값이 LAZY인걸 확인할 수 있습니다.
// homeAdress 수정
Adress homeAdress = findMember.getHomeAdress();
// 이뮤터블 객체임으로 new Adress로 새롭게 생성하여 작업
findMember.setHomeAdress(new Adress("newCity", homeAdress.getStreet(), homeAdress.getZipcode()));
// 치킨을 찾아 삭제 후 한식으로 변경
findMember.getFavoritFoods().remove("치킨");
findMember.getFavoritFoods().add("한식");
// Adress에 equals가 오버라이딩이 되어있다는 가정하에 remove가 정상작동 됨
findMember.getAdressHistory().remove(new Adress("old1", "old1", "old1"));
findMember.getAdressHistory().add(new Adress("newCity", "old1", "old1"));
정상적으로 수정된걸 확인할 수 있습니다.
단, 특이한 상황이있습니다.
findMember.getAdressHistory().remove(new Adress("old1", "old1", "old1"));
findMember.getAdressHistory().add(new Adress("newCity", "old1", "old1"));
소스코드를 보고는 개인적으로는 old1이 삭제되고 난 후 newCity가 INSERT되겠다 라고 생각하였지만, 실행 쿼리는 전혀 다르게 동작하였습니다.
해당 MEMBER에 ADRESS를 모두 삭제 후, INSERT를 모두 시키는 방식이였습니다.
값 타입은 엔티티와 다르게 식별자 개념이 없습니다. 값은 변경하면 추적이 어렵습니다.
값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장합니다.
현재 상황과 똑같은 상황이죠.
값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 합니다.
지금 같은 상황이라면, 값타입을 사용하지않는걸 추천합니다.
실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려해야합니다.
@Entity
@Getter
@Setter
@Table(name = "ADRESS")
@NoArgsConstructor
public class AdressEntity {
@Id @GeneratedValue
@Column(name = "ADRESS_ID")
private Long id;
private Adress adress;
public AdressEntity(String c, String s, String z) {
this.adress = new Adress(c, s, z);
}
}
AdressEntity를 한개 생성합니다. 이후 Member엔티티에서는 연결을 위해 아래와같이 수정합니다.
...
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AdressEntity> adressHistory = new ArrayList<>();
...
그림과같이 ADRESS_ID가 추가된걸 확인 할 수 있습니다.
그러면, 값타입은 간단한 selectBox에 멀티로 사용할때이며 그 외는 엔티티로 승격시켜서 사용하는것이 편합니다.
이력을 남긴다면 엔티티! 이력이 필요없이 단순 데이터면 값타입으로 하시는걸 추천합니다.