김영한 님의 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의를 보고 작성한 내용입니다.
@Entity
로 정의하는 객체이며, 데이터가 변해도 식별자로 지속해서 추척이 가능합니다. 예를 들어, 회원 엔티티의 이름을 변경해도 식별자로 인식이 가능합니다.
int, String 처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 의미합니다. 식별자가 없고 값만 있기 때문에 변경했을 때 추척이 불가능합니다.
값 타입은 또 다시 기본값 타입, 임베디드 타입, 컬렉션 값 타입으로 분류할 수 있으며, 모든 값 타입은 값 타입을 소유한 엔티티의 생명주기에 의존합니다.
String 이나 자바 기본 타입인 int 나 double, 래퍼 클래스인 Integer 나 Long 등이 기본값 타입에 해당합니다.
기본값 타입은 생명주기를 엔티티에 의존합니다. 예를 들어 회원을 삭제하면 내부의 이름과 나이 필드도 함께 삭제됩니다.
또 값타입은 공유를 하면 안되는데 회원 이름을 변경한다고 해서 다른 회원의 이름이 변경되면 안됩니다.
int, double 과 같은 기본 타입은 항상 값을 복사하기 때문에 a = b 이후에 a 를 변경해도 b 는 변경되지 않습니다.
Integer 나 String 과 같은 클래스는 레퍼런스를 사용하기 때문에 공유는 가능하지만 변경은 불가능합니다.
새로운 값 타입을 직접 정의해서 사용할 수 있는데, JPA 에서는 이를 임베디드 타입이라고 합니다. 임베디드 타입은 int, String 과 같은 엔티티 내부의 값 타입입니다.
위의 그림은 값 타입을 사용하기 전과 후를 나타낸 그림입니다. startData, endDate 를 Period 라는 임베디드 타입을 만들고, 이를 Member 내부에서 사용하는 방식입니다.
JPA 에서 값 타입을 정의하는 곳에 표시하는 @Embeddable
과 값 타입을 사용하는 곳에 표시하는 @Embedded
를 사용하는데 임베디드 타입 내부에 기본 생성자는 필수입니다.
임베디드 타입은 재사용이 가능하고 응집도가 높아 Period.isWork()
처럼 해당 값 타입만 사용하는 의미있는 메서드를 만들 수 있습니다.
임베디드 타입을 사용할 때 매핑만 해주면 임베디드 타입을 사용하지 않을 때와 테이블의 구조는 동일합니다.
한 엔티티에서 같은 값 타입을 사용하면 컬럼명이 중복되는데 @AttributeOverrids
와 @AttributeOverride
를 사용해서 컬러명 속성을 재정의 할 수 있습니다.
public class Member {
@Embedded
private Address homeAddress;
@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 Address workAddress;
}
임베디드 타입은 값 타입을 포함하거나, 엔티티를 참조할 수 있습니다.
@Embeddable
public class Address {
...
@Embedded
private Zipcode zipcode; // 임베디드 타입 포함
}
@Embeddable
public class Zipcode {
private String area;
...
}
// ------------------------------------------
@Embeddable
public class PhoneNumber {
...
@ManyToOne
private PhoneEntity phoneEntity; // 엔티티 참조
}
@Entity
public class PhoneEntity {
@Id @GeneratedValue
private Long id;
...
}
임베디드 타입에 null
을 넣게되면 임베디드 타입에 해당하는 모든 컬럼들은 null
로 저장되게 됩니다.
public class Member {
...
@Embedded
private Address homeAddress = null;
}
임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 side effect 가 발생할 수 있어 위험합니다.
Address address = new Address("city", "street", "10000");
Member member1 = new Member();
member1.setName("member1");
member1.setAddress(address);
em.persist(member1);
Member member2 = new Member();
member2.setName("member2");
member2.setAddress(address);
em.persist(member2);
member1.getAddress().setCity("newCity");
member1 과 member2 가 같은 address 를 공유하는 상황에서 member1 을 통해 address 를 변경하면 insert 이후에 member1 과 memebr2 모두에게 update 쿼리가 날라가게 됩니다.
그래서 결국 member1 의 주소를 변경했지만 member2 의 주소도 newCity 로 변경됩니다.
Address address = new Address("city", "street", "10000");
Member member1 = new Member();
member1.setName("member1");
member1.setAddress(address);
em.persist(member1);
Address copyAddress = new Address(address.getCity(), address.getStreet(), address.getZipcode());
Member member2 = new Member();
member2.setName("member2");
member2.setAddress(copyAddress);
em.persist(member2);
member1.getAddress().setCity("newCity");
값 타입 자체를 공유하는 것이 아닌 값을 복사해서 위처럼 사용하는 것이 올바른 방법입니다.
자바 기본 타입은 값을 대입하면 값을 복사합니다. 하지만 임베디드 타입처럼 직접 정의한 값 타입은 자바 기본 타입이 아닌 객체 타입이고, 객체 타입은 참조 값을 복사해서 넣게 됩니다.
객체 타입을 수정할 수 없게 만들면 함께 수정되는 부작용을 원천 차단시킬 수 있습니다. 그래서 값 타입은 불변 객체로 설계해야 합니다.
불변 객체란 생성 시점 이후 절대 값을 변경할 수 없는 객체입니다. 생성자로만 값을 생성하고 setter 를 만들지 않으면 됩니다. ( Integer, String 이 대표적인 불변객체입니다. )
만약 값을 수정하고 싶다면 new
키워드를 통해 새로운 객체를 만들어 사용해야 합니다.
예를 들어, int 형 a 와 b 가 동일한 값을 가졌다면 ==
비교를 했을 때 동일하다고 판단됩니다. 하지만 Address 의 경우 동일한 값을 가져도 ==
비교를 했을 때 false 가 나오게 됩니다.
==
비교는 인스턴스의 참조 값을 비교하는 동일성 비교이기 때문에 인스턴스의 값을 비교하는 equals()
를 이용한 동등성 비교를 해야합니다.
즉, 값 타입은 a.equals(b)
를 사용해서 동등성 비교를 해야하며, equals()
는 기본이 ==
비교이기 때문에 값 타입의 equals()
메소드를 모든 필드를 사용하도록 재정의하는 것이 필요합니다.
@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.city)
&& Objects.equals(street, address.street)
&& Objects.equals(zipcode, address.zipcode);
}
@Override
public int hashCode() {
return Objects.hash(city, street, zipcode);
}
equals()
와 hashCode()
눈 기본적으로 생성해주는 것을 사용하는 것이 좋습니다.
값 타입 컬렉션이란 값 타입을 컬렉션에 담아서 사용하는 것을 말하는데 값 타입을 하나 이상 저장할 때 사용하며, @ElementCollection
, @CollectionTable
을 사용합니다.
값 타입이 하나만 있을 때는 엔티티의 필드로 넣으면 구현할 수 있었는데, 컬렉션을 사용하면 일대다 개념이기 때문에 RDB 에서 테이블 안에 컬렉션을 담을 수 없습니다.
그래서 값 타입 컬렉션에 대해서 별도의 테이블을 사용해야 합니다. 값 타입 테이블에 id 같은 개념을 넣어서 PK 로 쓰면 엔티티가 되어 버리기 때문에 값들을 묶어서 PK 로 사용해야 합니다.
public class Member {
...
@Embedded
private Address homeAddress;
@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();
}
@CollectionTable
은 생성될 테이블명을 지정합니다. 또 외래키를 지정할 수 있는데 위에서는 MEMBER_ID 를 외래키로 지정하였습니다.
Member member = new Member();
member.setName("member1");
member.setHomeAddress(new Address("city", "street", "10000"));
member.getAddressHistory().add(new Address("old1", "street", "10000"));
member.getAddressHistory().add(new Address("old2", "street", "10000"));
em.persist(member);
4-1 에서 지정한 것처럼 ADDRESS 라는 이름으로 테이블이 하나 생성되었고, 값 타입 컬렉션에 담긴 정보들은 ADDRESS 테이블에 저장된 것을 확인할 수 있습니다.
또 member 만 persist()
했는데 값 타입 컬렉션은 자동으로 저장되었습니다. 왜냐하면 값 타입 컬렉션도 스스로 라이프 사이클을 가지지 않기 때문에 라이프 사이클이 member 에 소속되어 다른 테이블임에도 함께 저장된 것입니다.
Member findMember = em.find(Member.class, member.getId());
System.out.println("----------------------------findMember 반환");
for (Address ad : findMember.getAddressHistory()) {
System.out.println("history = " + ad.getCity() + ", "
+ ad.getStreet() + ", "
+ ad.getZipcode());
}
select
m1_0.MEMBER_ID,
m1_0.city,
m1_0.street,
m1_0.zipcode,
m1_0.name
from
Member m1_0
where
m1_0.MEMBER_ID=?
----------------------------findMember 반환
select
ah1_0.MEMBER_ID,
ah1_0.city,
ah1_0.street,
ah1_0.zipcode
from
ADDRESS ah1_0
where
ah1_0.MEMBER_ID=?
member 에 소속된 homeAddress 에 해당하는 값 타입은 함께 조회되었지만, 값 타입 컬렉션은 조회되지 않았습니다. 이 말은 값 타입 컬렉션은 지연로딩이라는 뜻입니다.
그래서 findMember 이후에 값을 사용할 때 쿼리가 나가는 것을 확인할 수 있습니다.
Member findMember = em.find(Member.class, member.getId());
List<Address> addressHistory = findMember.getAddressHistory();
addressHistory.remove(new Address("old1", "street", "10000"));
addressHistory.add(new Address("newCity1", "street", "10000"));
remove()
를 통해 제거할 때 equals()
를 사용하기 때문에 이전에 했던 것처럼 equals()
와 hashCode()
를 제대로 구현해야 합니다.
또 값 타입은 불변 객체여야 하기 때문에 수정할 때 set 을 사용할 수 없으므로 새로운 값 타입 객체를 생성해서 사용합니다.
컬렉션의 값만 변경해도 JPA 가 DB 에 쿼리를 날려줍니다. 그래서 값 타입 컬렉션은 영속성 전이와 고아 객체 제거 기능을 가진다고 볼 수 있습니다.
값 타입 컬렉션에 변경 사항이 발생하면 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장합니다.
Hibernate:
delete from
ADDRESS
where
MEMBER_ID=?
Hibernate:
insert into
ADDRESS (MEMBER_ID, city, street, zipcode)
values (?, ?, ?, ?)
Hibernate:
insert into
ADDRESS (MEMBER_ID, city, street, zipcode)
values (?, ?, ?, ?)
위의 수정 코드의 SQL 로그인데 delete 를 보면 해당 member 에 해당하는 모든 값을 지우는 것을 확인할 수 있고, 하나의 address 만 추가했는데 두 개의 insert 문이 실행되었습니다.
기존 데이터 2개 중 하나만 지웠기 때문에 하나가 남아있게 되고, 신규 insert 와 함께 기존 insert 1번이 실행된 것입니다.
저장할 때도 주의해야 할 점이 있는데 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야 합니다. null 도 안되고, 중복 저장도 안됩니다.
@Entity
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address;
}
// -----------------------------
@Entity
public class Member {
...
// 일대다 단방향 매핑
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
}
그래서 실무에서는 상황에 따라 값 타입 컬렉션 대신 일대다 관계를 고려해서 엔티티를 만들고, 여기서 값 타입을 사용하는 것이 좋습니다. 영속성 전이와 고아 객체 제거 기능을 사용해서 값 타입 컬렉션처럼 사용하면 됩니다.