1. 기본 값 타입
JPA의 데이터 타입 분류
엔티티 타입
@Entity
로 정의하는 객체- 데이터가 변해도 식별자로 지속해서 추적 가능. 예) 회원 entity의 키 값을 변경해도 식별자로 인식 가능
값 타입
- int, Integer, String 처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체
- 식별자 X → 변경시 추적 불가
값 타입 분류
- 기본 값 타입 : 자바 기본 타입, 래퍼 클래스(Integer, Long), String
- 임베디드 타입 : 복합 값 타입
- 컬렉션 값 타입
기본 값 타입
- 생명주기를 엔티티에 의존 : 회원 삭제 -> 그 안의 필드도 같이 삭제
- 값 타입은 공유 ❌
📌Note : 자바의 기본 타입은 절대 공유 불가능
int, double 같은 primitive type은 공유가 불가능하다. 기본 타입은 항상 값을 복사해서 사용한다. Integer 같은 래퍼 클래스나 String 같은 특수한 클래스는 공유 가능한 객체이지만 변경은 불가능하다.
2. 임베디드 타입
임베디드 타입
- 새로운 값 타입을 직접 정의할 수 있다.
- 주로 기본 값 타입을 모아서 만들어진 복합 값 타입이라고도 함 ex) int, String 같은 값 타입
- 일하는 기간을 데이터로 넣는다고 생각을 해보자. 그렇게 되면 시작 시간, 종료 시간 두 값을 저장해야한다.
이 두개를 합쳐서 우리는 "기간"이라고 부를 수 있다. 이렇게 여러개의 값을 모아서 만든 값 타입을 임베디드 타입이라고 한다. - 값이 null이면 매핑한 컬럼 값은 모두 null
사용법
@Embeddable
: 값 타입을 정의하는 곳에 표시Embedded
: 값 타입을 사용하는 곳에 표시- 기본 생성자 필수
//Member.class
@Embedded // 내장타입 포함
private Address address;
//Address.class
@Getter
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
protected Address() {
}
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
장점
- 재사용 가능
- 응집도 높음(클래스 내)
- Period.isWork()처럼 값 타입만 사용하는 의미 있는 메소드를 만들 수 있음
임베디드 타입과 테이블 매핑
- 임베디드 타입 : 엔티티의 값 → 사용 전 후에 매핑하는 테이블은 같음
- 객체와 테이블을 아주 세밀하게 매핑 가능
- 용어, 코드가 공통화가 된다
- 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많음
❓ 만약, 한 엔티티에서 같은 임베디드 타입을 사용하게되면 어떻게될까?
: 두번 정의해서 실행시키면 오류가 난다(컬럼명 중복). @AttributeOverride
를 사용해보자!
@AttributeOverride
: 속성 재정의
@AttributeOverrides
, @AttributeOverride
을 사용해서 컬럼명 속성의 재정의할 수 있다.
3. 값 타입과 불변 객체
"값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다. 따라서 값 타입은 단순하고 안전하게 다룰 수 있어야 한다."
값 타입 공유 참조
임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다. 공유되어 있는 상태에서 값 타입이 변경되면 공유되고 있는 객체들의 값이 전부 변경되기 때문이다.
값 타입의 실제 인스턴스인 값을 공유하는 것은 위험하므로, 대신 값(인스턴스)를 복사해서 사용하자.
객체 타입의 한계
- 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다.
- But, 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이라는 것이 문제이다.
- 자바 기본 타입은 기본 타입에 값을 대입하면 값을 복사한다.
- 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없으므로 객체의 공유 참조는 피할 수 없다.
불변 객체
- 객체 타입을 수정할 수 없게 만들면 부작용을 원천 차단할 수 있다!
→ 값 타입은 불변 객체로 설계해야한다.- 불변 객체 : 생성 시점 이후 절대 값을 변경할 수 없는 객체
- 생성자로만 값을 설정하고 Setter를 만들지 않으면 된다.
- ex) Integer, String
결론 : 불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막을 수 있다.
4. 값 타입의 비교
값 타입 : 인스턴스가 달라도 그 안에 값이 같으면 같은 것이다.
값 타입의 비교
- 동일성(identity) 비교 : 인스턴스의 참조 값을 비교.
==
사용 - 동등성(equivalence) 비교 : 인스턴스의 값을 비교,
equals()
사용 - 값 타입은
a.equals(b)
를 사용해서 동등성 비교- 값 타입의
equals()
메소드를 적절하게 재정의(주로 모든 필드 사용)
- 값 타입의
@Override2개 나
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Address)) {
return false;
}
Address address = (Address) o;
return Objects.equals(city, address.city)
&& Objects.equals(street, address.street)
&& Objects.equals(zipcode, address.zipcode);
}
5. 값 타입 컬렉션
값 타입을 컬렉션으로 사용하는 것을 값 타입 컬렉션이라고 한다. 예제를 보자.
관계형 데이터베이스는 테이블 안에 컬렉션을 저장할 수 없는 구조를 가지고 있다. 컬렉션을 저장하기 위한 별도의 테이블이 필요하다. 즉, 다음과 같이 Member를 기준으로 별도의 테이블을 생성해야한다.
참고로, 값타입은 식별자를 따로 두게되면 이는 엔티티로 봐야하므로 전체를 묶어서 PK로 사용한다. 어노테이션은 값 타입 컬렉션이라는 것을 알려주는 @ElementCollection과 테이블 명을 지정하는 @CollectionTable을 사용한다.
정리
- 값 타입을 하나 이상 저장할 때 사용
- @ElementCollection, @CollectionTable 사용
- 데이터베이스는 컬렉션을 같은 테이블에 저장 불가능 → 별도의 테이블 생성
값 타입 예제
package hellojpa;
@Entity
public class Member {
...
@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<>();
}
JoinColumn
은 외래키로 값을 적용 시켜준다.favoriteFoods
는 필드가 한개이다. 필드가 한 개인 경우에만 한해서 컬럼명을 변경(디폴트는 컬렉션명)할 수 있다. 즉, Address 타입은 필드가 여러개이므로 변경할 수 없다.
public class JpaMain {
public static void main(String[] args) {
...
try {
Member member = new Member();
member.setName("member1");
member.setHomeAddress(new Address("homeCity", "street", "20000"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");
member.getAddressHistory().add(new Address("old1", "street", "10000"));
member.getAddressHistory().add(new Address("old2", "street", "10000"));
em.persist(member);
...
}
}
}
- Member 만 persist해도 연결된 값타입 컬렉션도 변경이 된다.
- 컬렉션들은 기본적으로 지연로딩 전략을 사용한다.
컬렉션 값 변경
Address a = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", a.getStreet(), a.getZipcode()));
// 치킨 -> 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
- 객체 타입의 컬렉션은 새로운 인스턴스를, String 타입의 컬렉션은 삭제 후 삽입을 통해 값을 수정한다. 마치 cascade + 고아객체 제거를 설정한 것과 비슷하다.
컬렉션 값 삭제
// 삭제를 위해선 동등성(equals 재정의)필요
findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressHistory().add(new Address("newCity1", "street", "10000"));
// 출력 로그
Hibernate:
/* delete collection hellojpa.Member.addressHistory */
delete
from
ADDRESS
where
MEMBER_ID=?
Hibernate:
/* insert collection row hellojpa.Member.addressHistory */
insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection row hellojpa.Member.addressHistory */
insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
- Address와 관련된 데이터를 모두 삭제 후 남아 있는 데이터의 개수 만큼 INSERT 쿼리문을 날린다.
→ 현재 Address에 남아 있는 데이터는 2개이므로 INSERT 쿼리문이 2번 실행 되었다.
⚠️ 주의사항
값 타입은 엔티티와 다르게 식별자 개념이 없어, 변경하면 추적이 어렵다.
값 타입 컬렉션에 변경 사항이 발생하면 주인 엔티티와 관련된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. 즉, null 과 중복이 있으면 안된다.
값 타입 컬렉션 대안
- 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려한다.
- 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용한다. cascade + 고아 객체 제거를 사용해서 값 타입을 컬렉션처럼 사용한다.
일대다 예시
@Entity
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address;
public AddressEntity(String city, String street, String zipcod) {
this.address = new Address(city, street, zipcod);
}
public AddressEntity() {}
}
@Entity
public class Member {
...
// @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<>();
}
- addressHistory를 일대다관계로 설계하고, cascade와 고아 객체 제거를 적용한다.
❓그럼 언제 쓸 까??
selectbox가 있어서 사용자가 옵션을 선택하는 등 정말 단순한 경우에만 사용한다.
정말 단순한 경우에만 값타입을 사용하고 이 외에는 일대다로 풀어내자.
값 타입은 정말 값 타입이라 판단될 때만 사용한다. 식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아닌 엔티티다.
6. 실전예제 - 값 타입 매핑
package jpabook.jpashop.domain;
@Getter @Setter
@Embeddable
public class Address {
@Column(length = 10)
private String city;
@Column(length = 20)
private String street;
@Column(length = 5)
private String zipcode;
public String fullAddress() {
return getCity() + " " + getStreet() + " " + getZipcode();
}
@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(getCity(), address.getCity()) && Objects.equals(getStreet(), address.getStreet()) && Objects.equals(getZipcode(), address.getZipcode());
}
@Override
public int hashCode() {
return Objects.hash(getCity(), getStreet(), getZipcode());
}
}
@Embeddable
을 정의해서 임베디드 값 타입 클래스를 생성하고, 값을 수정할 때 동등성 비교를 위한equals
와hashCode
를 재정의한다.equals
를 만들 때 필드값이 아닌getter
로 비교하면 추후 프록시 상태의 객체에도 접근할 수 있다.
Delivery
와Member
에@Embedded private Address address
를 적용해준다.
이 글은 김영한 님의 "자바 ORM 표준 JPA 프로그래밍 - 기본 편" 강의를 듣고 정리한 내용입니다.