자바 ORM 표준 JPA 프로그래밍 - 기본편 수업을 듣고 정리한 내용입니다.
✏️ 이번 Chapter 중요한 것
- 임베디드 타입(복합 값 타입), 값 타입 컬렉션이 중요하다!
✔️ 엔티티 타입
@Entity
로 정의하는 객체이다.
✔️ 값 타입
int
, Integer
, String
처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체이다.
(1) 기본 값 타입
- 자바 기본 타입(
int
,double
)- 래퍼 클래스(
Integer
,Long
)String
ex)
String name, int age
💡 참고 - 자바의 기본 타입은 절대 공유할 수 없다.
int
,double
같은 기본 타입 (primitive type
)은 절대 공유할 수 없다.- 기본 타입은 항상 값을 복사한다. (참조값 공유)
Integer
같은 래퍼 클래스나String
같은 특수한 클래스는 공유 가능한 객체이지만 변경 불가능하다. (불변객체)
(2) 임베디드 타입(embedded type, 복합 값 타입)
- 정의해서 사용하는 타입
(3) 컬렉션 값 타입(collection value type)
- 자바 컬렉션에 기본값 타입이나 임베디드 타입을 넣은 것
새로운 값 타입을 직접 정의해서 사용할 수 있는데, JPA에서는 이것을 임베디드 타입(embedded type)이라 한다.
중요한 것은 직접 정의한 임베디드 타입도 int
, String
처럼 값 타입이라는 것이다.
Member - 회원 엔티티
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
// 근무 기간
@Temporal(TemporalType.DATE) java.util.Date startDate;
@Temporal(TemporalType.DATE) java.util.Date endDate;
// 집 주소 표현
private String city;
private String street;
private String zipcode;
// ...
}
이 엔티티는 어떻게 될까?
➡️ 회원 엔티티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 가진다.
이런 설명은 단순한 정보를 풀어둔 것일 뿐, 근무 시작일과 우편번호는 서로 아무 관련이 없다!
이와 같이 설명해야 명확하다.
➡️ 회원 엔티티는 이름, 근무 기간, 집 주소를 가진다.
회원이 상세한 데이터를 그대로 가지고 있는 것은 객체지향적이지 않으며 응집력만 떨어뜨린다. 대신에 근무 기간, 주소 같은 타입이 있다면 코드가 더 명확해질 것이다.
(근무기간, 집 주소)를 가지도록 임베디드 타입을 사용해보자.
Member - 값 타입 적용 회원 엔티티
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Embedded Period workPeriod; // 근무 기간
@Embedded Address homeAddress; // 집 주소
// ...
}
Peroid - 기간 임베디드 타입
@Embeddable
public class Period {
@Temporal(TemporalType.DATE) java.util.Date startDate;
@Temporal(TemporalType.DATE) java.util.Date endDate;
// ..
public boolean isWork(Date date) {
// .. 값 타입을 위한 메소드를 정의할 수 있다.
}
}
Address - 주소 임베디드 타입
@Embeddable
public class Address {
@Column(name = "city") // 매핑할 컬럼 정의 가능
private String city;
private String street;
private String zipcode;
// ..
}
startDate
, endDate
를 합해서 Period
(기간) 클래스를 만들었다.city
, street
, zipcode
를 합해서 Address
(주소) 클래스를 만들었다.새로 정의한 값 타입들은 재사용할 수 있고 응집도도 아주 높다. 또한 위의 기간 임베디드 타입의 Period.isWork()
처럼 해당 값 타입만 사용하는 의미 있는 메소드도 만들 수 있다.
임베디드 타입을 사용하려면 다음 2가지 어노테이션이 필요하다. 참고로 둘 중 하나는 생략해도 된다.
@Embeddable
: 값 타입을 정의하는 곳에 표시@Embedded
: 값 타입을 사용하는 곳에 표시그리고 임베디드 타입은 기본 생성자가 필수다.
임베디드 타입을 포함한 모든 값 타입은 엔티티의 생명주기에 의존하므로 엔티티와 임베디드 타입의 관계를 UML로 표현하면 컴포지션(composition) 관계
가 된다. (컴포지션 관계 위에 있는 그림이다.)
💡 참고
하이버네이트는 임베디드 타입을 컴포넌트(components)라 한다.
임베디드 타입을 데이터베이스 테이블에 어떻게 매핑하는지 아래 그림을 통해 알아보자!
Period
import java.time.LocalDateTime;
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
...
Address
@Embeddable
public class Address {
private String city;
private String street;
@Column(name = "ZIPCODE")
private String zipcode;
public Address() {
}
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
...
Member
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
@Embedded
private Period workPeriod;
@Embedded
private Address homeAddress;
...
JpaMain
Member member = new Member();
member.setUsername("hello");
member.setHomeAddress(new Address("city","street","123"));
member.setWorkPeriod(new Period());
em.persist(member);
tx.commit();
결과
임베디드 타입은 엔티티의 값일 뿐이다. 따라서 값이 속한 엔티티의 테이블에 매핑한다. 예제에서 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다.
임베디드 타입 덕분에 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능하다. 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.
ORM을 사용하지 않고 개발하면 테이블 컬럼과 객체 필드를 대부분 1:1로 매핑한다. 주소나 근무 기간 같은 값 타입 클래스를 만들어서 더 객체지향적으로 개발하고 싶어도 SQL을 직접 다루면 테이블 하나에 클래스 하나를 매핑하는 것도 고단한 작업인데 테이블 하나에 여러 클래스를 매핑하는 것은 상상하기도 싫을 것이다. 이런 지루한 반복 작업은 JPA에 맡기고 더 세밀한 객체지향 모델을 설계하는데 집중하자!
🔔 임베디드 타입과 UML
UML에서 임베디드 값 타입은 아래 그림처럼 기본타입처럼 단순하게 표현하는 것이 편리하다.![]()
임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있다. JPA 표준 명세가 제공하는 아래 예제 코드와 그림으로 임베디드 타입의 연관관계를 알아보자!
엔티티는 공유될 수 있으므로 참조한다고 표현하고, 값 타입은 특정 주인에 소속되고 논리적인 개념상 공유되지 않으므로 포함한다고 표현했다.
@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
를 사용하면 된다. 예를 들어 회원에게 주소가 하나 더 필요하면 어떻게 해야 할까?
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Embedded Address homeAddress;
@Embedded Address companyAddress;
}
Member
엔티티를 보면 집 주소에 회사 주소를 하나 더 추가했다. 문제는 테이블에 매핑되는 컬럼명이 중복되는 것이다.
이때는 아래 예제와 같이 @AttributeOverrides
를 사용해서 매핑정보를 재정의해야 한다.
@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")},
@AttributeOverrdie(name="zipcode", column=@Column(name
= "COMPANY_ZIPCODE")}
})
Address companyAddress;
}
아래에 생성된 테이블을 보면 재정의한대로 변경되어 있다.
CREATE TABLE MEMBER (
COMPANY_CITY varchar(255),
COMPANY_STREET varchar(255),
COMPANY_ZIPCODE varchar(255),
city varchar(255),
street varchar(255),
zipcode varchar(255),
...
)
@AttributeOverride
를 사용하면 어노테이션을 너무 많이 사용해서 엔티티 코드가 지저분해진다.
다행히도 한 엔티티에 같은 임베디드 타입을 중복해서 사용하는 일은 많지 않다.
💡 참고
@AttributeOverrdies
는 엔티티에 설정해야 한다. 임베디드 타입이 임베디드 타입을 가지고 있어도 엔티티에 설정해야 한다.
임베디드 타입이 null
이면 매핑한 컬럼 값은 모두 null
이 된다.
member.setAddress(null); // null 입력
em.persist(member);
회원 테이블의 주소와 관련된 CITY
, STREET
, ZIPCODE
컬럼 값은 모두 null
이 된다.
값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다. 따라서 값 타입은 단순하고 안전하게 다룰 수 있어야 한다.
✔️ 값 타입 공유 참조
✔️ 값 타입 복사
✔️ 객체 타입의 한계
기본 타입 (primitive type
)
int a = 10;
intb = a; // 기본 타입은 값을 복사
b = 4;
객체 타입
Address a = new Address(“Old”);
Address b = a; // 객체 타입은 참조를 전달
b.setCity(“New”)
✔️ 불변 객체
immutable object
)로 설계해야한다. Setter
)를 만들지 않으면 된다.
💡 참고
Integer
,String
은 자바가 제공하는 대표적인 불변 객체- 불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막을 수 있다.
➡️ 왠만하면 불변을 사용하자!
값 타입 : 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야한다!
int a = 10;
int b = 10;
Address a = new Address(“서울시”)
Address b = new Address(“서울시”)
identity
) 비교 : 인스턴스의 참조 값을 비교, ==
사용equivalence
) 비교 : 인스턴스의 값을 비교, equals()
사용a.equals(b)
를 사용해서 동등성 비교를 해야 한다.equals()
메소드를 적절하게 재정의(주로 모든 필드 사용)
값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection
, @CollectionTable
어노테이션을 사용하면 된다.
Member - 값 타입 컬렉션
@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") // 값이 하나고, 테이블을 만들 때 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>();
// ...
}
Address
@Embeddable
public class Address {
@Column
private String city;
private String street;
private String zipcode;
// ...
}
✔️ 테이블 생성 실행시
@Embedded
private Address homeAddress;
로 인해 city, street, ZIPCODE 가 필드 변수에 추가된다.
@ElementCollection
@CollectionTable(name = "FAVORITE_FOODS",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_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>();
✔️ 값 타입 컬렉션 UML과 값 타입 컬렉션 ERD
값 타입 컬렉션의 Member
엔티티를 보면 값 타입 컬렉션을 사용하는 favoriteFoods addressHistory
에 @ElementCollection
을 지정했다.
값 타입 컬렉션 UML은 객체의 UML을 표시했다.
favoriteFoods
는 기본값 타입인 String
을 컬렉션으로 가진다. 이것을 데이터베이스 테이블로 매핑해야 하는데 관계형 데이터베이스의 테이블은 컬럼안에 컬렉션을 포함할 수 없다. 따라서 값 타입 컬렉션 ERD처럼 별도의 테이블을 추가하고 @CollectionTable
를 사용해서 추가한 테이블을 매핑해야 한다. 그리고 favoriteFoods
처럼 값으로 사용되는 컬럼이 하나면 @Column
을 사용해서 컬럼명을 지정할 수 있다.
addressHistory
는 임베디드 타입인 Address
를 컬렉션으로 가진다. 이것도 마찬가지로 별도의 테이블을 사용해야 한다. 그리고 테이블 매핑정보는 @AttributeOverride
를 사용해서 재정의할 수 있다.
💡 참고
@CollectionTable
를 생략하면 기본값을 사용해서 매핑한다.
- 기본 값 :
{엔티티이름}_{컬렉션 속성 이름}
- ex)
Member
엔티티의addressHistory
는Member_addressHistory
테이블과 매핑한다.
값 타입 컬렉션 사용 예제
Member member = new Member();
// 임베디드 값 타입
member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));
// 기본값 타입 컬렉션
member.getFavoriteFoods().add("짬뽕");
member.getFavoriteFoods().add("짜장");
member.getFavoriteFoods().add("탕수육");
// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("서울", "강남", "123-123"));
member.getAddressHistory().add(new Address("서울", "강북", "000-000"));
em.persist(member);
실행 결과
Hibernate:
call next value for hibernate_sequence
Hibernate:
/* insert hellojpa.Member
*/ insert
into
Member
(city, street, ZIPCODE, USERNAME, MEMBER_ID)
values
(?, ?, ?, ?, ?)
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
(?, ?, ?, ?)
Hibernate:
/* insert collection
row hellojpa.Member.favoriteFoods */ insert
into
FAVORITE_FOODS
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
/* insert collection
row hellojpa.Member.favoriteFoods */ insert
into
FAVORITE_FOODS
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
/* insert collection
row hellojpa.Member.favoriteFoods */ insert
into
FAVORITE_FOODS
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
/* insert hellojpa.Member
*/ insert
into
Member
(city, street, ZIPCODE, USERNAME, MEMBER_ID)
values
(?, ?, ?, ?, ?)
바로 위는 Member 테이블 생성
등록하는 코드를 보면 마지막에 member
엔티티만 영속화했다.
JPA
는 이 때, member
엔티티의 값 타입도 함께 저장한다. 실제 데이터베이스에 실행되는 INSERT SQL
은 다음과 같다.
member
: INSERT SQL
1번member.homeAddress
: 컬렉션이 아닌 임베디드 값 타입이므로 회원 테이블을 저장하는 SQL에 포함된다.member.favoriteFoods
: INSERT SQL
3번member.addressHistory
: INSERT SQL
2번따라서 em.persist(member)
한 번 호출로 총 6번의 INSERT
SQL을 실행한다. (물론 영속성 컨텍스트를 플러시할 때 SQL을 전달한다.)
INSERT INTO MEMBER ID, CITY, STREET, ZIPCODE) VALUES (1, '통영', '몽돌해수욕장', '660-123')
INSERT INTO FAVORITE_FOODS (MEMBER_ID, FOOD_NAME) VALUES (1, "짬뽕")
INSERT INTO FAVORITE_FOODS (MEMBER_ID, FOOD_NAME) VALUES (1, "짜장")
INSERT INTO FAVORITE_FOODS (MEMBER_ID, FOOD_NAME) VALUES (1, "탕수육")
INSERT INTO ADDRESS (MEMBER_ID, CITY, STREET, ZIPCODE) VALUES
(1, '서울', '강남', '123-123')
INSERT INTO ADDRESS (MEMBER_ID, CITY, STREET, ZIPCODE) VALUES
(1, '서울', '강북', '000-000')
💡 참고
값 타입 컬렉션은 영속성 전이(Cascade
) + 고아 객체 제거(ORPHAN REMOVE
) 기능을 필수로 가진다고 볼 수 있다.
✔️ 값 타입 컬렉션 지연로딩
Member member = new Member();
// 임베디드 값 타입
member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));
// 기본값 타입 컬렉션
member.getFavoriteFoods().add("짬뽕");
member.getFavoriteFoods().add("짜장");
member.getFavoriteFoods().add("탕수육");
// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("서울", "강남", "123-123"));
member.getAddressHistory().add(new Address("서울", "강북", "000-000"));
em.persist(member);
em.flush();
em.clear();
//
System.out.println("========START============");
Member findMember = em.find(Member.class, member.getId());
System.out.println("========end============");
tx.commit();
✔️ 지연로딩 테스트
Member member = new Member();
// 임베디드 값 타입
member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));
// 기본값 타입 컬렉션
member.getFavoriteFoods().add("짬뽕");
member.getFavoriteFoods().add("짜장");
member.getFavoriteFoods().add("탕수육");
// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("서울", "강남", "123-123"));
member.getAddressHistory().add(new Address("부산", "강북", "000-000"));
em.persist(member);
em.flush();
em.clear();
//
System.out.println("========START============");
Member findMember = em.find(Member.class, member.getId());
List<Address> addressHistory = findMember.getAddressHistory(); // LAZY
for (Address address : addressHistory) {
System.out.println("address.getCity() = " + address.getCity());
}
Set<String> favoriteFoods = findMember.getFavoriteFoods(); // LAZY
for (String favoriteFood : favoriteFoods) {
System.out.println("favoriteFood = " + favoriteFood);
}
System.out.println("========end============");
tx.commit();
실행 결과
➡️ 이는 지연로딩이 발생한 것이다. (호출될 때 테이블이 생성됨)
@ElementCollection(fetch = FetchType.LAZY)
@ElementCollection
default은 LAZY
이다.LAZY
가 기본이다.
지연 로딩으로 모두 설정되었을 때 아래 예제를 실행하면 어떻게 될까?
// SQL : SELECT ID, CITY, STREET, ZIPCODE FROM MEMBER WHERE ID = 1
Member member = em.find(Member.class, 1L) // 1. member
// 2. member.homeAddress
Address homeAddress = member.getHomeAddress();
// 3. member.favoriteFoods
Set<String> favoriteFoods = member.getFavoriteFoods(); // LAZY
// SQL : SELECT MEMBER_ID, FOOD_NAME FROM FAVORITE_FOODS
// WHERE MEMBER_ID = 1
for (String favoriteFood : favoriteFoods) {
System.out.println("favoriteFood = " + favoriteFood);
}
// 4. member.addressHistory
List<Address> addressHistory = member.getAddressHistory(); // LAZY
// SQL : SELECT MEMBER_ID, CITY, STREET, ZIPCODE FROM ADDRESS
// WHERE MEMBER_ID = 1
addressHistory.get(0);
위 소스를 실행할 때 데이터베이스에 호출하는 SELECT
SQL은 다음과 같다.
member
: 회원만 조회한다. 이때 임베디드 값 타입인 homeAddress
도 함께 조회한다. SELECT
SQL을 1번 호출한다.member.homeAddress
: 1번에서 회원을 조회할 때 같이 조회해 둔다.member.favoriteFoods
: LAZY
로 설정해서 실제 컬렉션을 사용할 때 SELECT
SQL을 1번 호출한다.member.addressHistory
: LAZY
로 설정해서 실제 컬렉션을 사용할 때 SELECT
SQL을 1번 호출한다.
✔️ 값 타입 컬렉션 수정
값 타입 컬렉션을 수정하면 어떻게 되는지 알아보자!
기존 소스
Member member = new Member();
// 임베디드 값 타입
member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));
// 기본값 타입 컬렉션
member.getFavoriteFoods().add("짬뽕");
member.getFavoriteFoods().add("짜장");
member.getFavoriteFoods().add("탕수육");
// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("서울", "강남", "123-123"));
member.getAddressHistory().add(new Address("부산", "강북", "000-000"));
em.persist(member);
em.flush();
em.clear();
//
System.out.println("========START============");
Member findMember = em.find(Member.class, member.getId());
(1) 임베디드 값 타입 수정
// 1. 임베디드 값 타입 수정
Address a = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", a.getStreet(),a.getZipcode()));
실행 결과
실행 결과를 보면 update문이 실행된 것을 확인할 수 있다.
(2) 기본 값 타입 컬렉션 수정
// 2. 기본 값 타입 컬렉션 수정
// 짬뽕 -> 한식
findMember.getFavoriteFoods().remove("짬뽕");
findMember.getFavoriteFoods().add("한식");
실행 결과
(3) 임베디드 값 타입 컬렉션 수정
// 3. 임베디드 값 타입 컬렉션 수정
// 업데이트
findMember.getAddressHistory().remove(new Address("서울", "강남", "123-123"));
findMember.getAddressHistory().add(new Address("newCity1", "street", "1000"));
Address 테이블
에 있던 (서울, 강남, 123-123)을 삭제하고Address 테이블
(newCity1, street, 1000)을 추가한다.실행 결과
➡️ 그런데, 이러한 값 타입 컬렉션 방법은 쓰면 안된다. (왜? 테이블 출력 내용들을 보고 추적하는게 상당히 어렵다.)
전체적인 실행 결과
변경 전
변경 후
MEMBER
테이블 조회 결과ADDRESS
테이블 조회 결과FAVORITE_FOODS
테이블 조회 결과➡️ 수정한 내용들이 정상적으로 저장 및 화면에 출력된 것을 확인할 수 있다.
✔️ 값 타입 컬렉션 수정 추가 정리 내용
Member member = em.find(Member.class, 1L);
// 1. 임베디드 값 타입 수정
member.setHomeAddress(new Address("새로운도시", "신도시1", "123456"));
// 2. 기본값 타입 컬렉션 수정
Set<String> favoriteFoods = member.getFavoriteFoods();
favoriteFoods.remove("탕수육");
favoriteFoods.add("치킨");
// 3. 임베디드 값 타입 컬렉션 수정
List<Address> addressHistory = member.getAddressHistory();
addressHistory.remove(new Address("서울", "기존 주소", "123-123"));
addressHistory.add(new Address("새로운 도시", "새로운 주소", "123-456"));
homeAddress
임베디드 값 타입은 MEMBER
테이블과 매핑했으므로 MEMBER
테이블만 UPDATE
한다. 사실 Member
엔티티를 수정하는 것과 같다.String
타입은 수정할 수 없다.equals
, hashCode
를 꼭 구현해야 한다.
✏️ 엔티티 vs 값 타입
- 엔티티 : 식별자가 있으므로 엔티티의 값을 변경해도 식별자로 데이터베이스에 저장된 원본 데이터를 쉽게 찾아서 변경할 수 있다.
- 값 타입 : 식별자라는 개념이 없고 단순한 값들의 모음이므로 값을 변경해버리면 데이터베이스에 저장된 원본 데이터를 찾기는 어렵다.
특정 엔티티 하나에 소속된 값 타입은 값이 변경되어도 자신이 소속된 엔티티를 데이터베이스에서 찾고 값을 변경하면 된다. 문제는 값 타입 컬렉션이다. 값 타입 컬렉션에 보관된 값 타입들은 별도의 테이블에 보관된다. 따라서 여기에 보관된 값 타입의 값이 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵다는 문제가 있다.
이런 문제로 인해 JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면, 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스에 다시 저장한다.
값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. 따라서 데이터베이스 기본 키 제약 조건으로 인해 컬럼에 null
을 입력할 수 없고, 같은 값을 중복해서 저장할 수 없는 제약도 있다.
ex)
식별자가 100번인 회원이 관리하는 주소 값 타입 컬렉션을 변경하면 다음 SQL 같이 테이블에서 회원 100번과 관련된 모든 주소 데이터를 삭제하고 현재 값 타입 컬렉션에 있는 값을 다시 저장한다. 여기서는 현재 값 타입 컬렉션에 주소가 2건 있어서 2번 INSERT
되었다.
DELETE FROM ADDRESS WHERE MEMBER_ID=100
INSERT INTO ADDRESS (MEMBER_ID, CITY, STREET, ZIPCODE)
VALUES (100, ...)
INSERT INTO ADDRESS (MEMBER_ID, CITY, STREET, ZIPCODE)
VALUES (100, ...)
✔️ 값 타입 컬렉션 대안
Cascade
) + 고아 객체 제거(ORPHAN REMOVE
) 기능을 적용하면 값 타입 컬렉션처럼 사용할 수 있다.AddressEntity
- 새로운 엔티티
@Entity
@Table(name="ADDRESS")
public class AddressEntity {
@Id
@GeneratedValue
private Long id;
private Address address;
public AddressEntity(String city, String street, String money) {
this.address = new Address(city, street, money);
}
// getter, setter
...
}
Member
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<AddressEntity>();
JpaMain
Member member = new Member();
// 임베디드 값 타입
member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));
// 기본값 타입 컬렉션
member.getFavoriteFoods().add("짬뽕");
member.getFavoriteFoods().add("짜장");
member.getFavoriteFoods().add("탕수육");
// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new AddressEntity("서울", "강남", "123-123"));
member.getAddressHistory().add(new AddressEntity("부산", "강북", "000-000"));
em.persist(member);
em.flush();
em.clear();
System.out.println("========START============");
System.out.println("========end============");
tx.commit();
Member
: 일 대 Address
: 다
실행 결과
ADDRESS 테이블
을 보면 이전에 없던 ID
가 추가된 것을 확인할 수 있다. ➡️ 이는 엔티티를 의미한다.
이럴 경우, 엔티티 ID
가 존재하므로 CITY, STREET, ZIPCODE를 마음껏 변경해도 된다.
💡 참고
- 값 타입 컬렉션을 변경했을 때 JPA 구현체들은 테이블의 기본 키를 식별해서 변경된 내용만 반영하려고 노력한다.
- 하지만 사용하는 컬렉션이나 여러 조건에 따라 기본 키를 식별할 수도 있고 식별하지 못할 수도 있다.
- 따라서 값 타입 컬렉션을 사용할 때는 모두 삭제하고 다시 저장하는 최악의 시나리오를 고려하면서 사용해야 한다.
- 값 타입 컬렉션의 최적화에 관한 내용은 각 구현체의 설명서를 참고하자!
📌 정리
(1) 엔티티 타입의 특징
- 식별자(
@Id
)가 있다.
- 엔티티 타입은 식별자가 있고 식별자로 구별할 수 있다.
- 생명 주기가 있다.
- 생성하고, 영속화하고, 소멸하는 생명 주기가 있다.
em.persist(entity)
로 영속화한다.em.remove(entity)
로 제거한다.- 공유할 수 있다.
- 참조 값을 공유할 수 있다. 이것을 공유 참조라 한다.
- 예를 들어 회원 엔티티가 있다면 다른 엔티티에서 얼마든지 회원 엔티티를 참조할 수 있다.
(2) 값 타입의 특징
- 식별자가 없다.
- 생명 주기를 엔티티에 의존한다.
- 스스로 생명주기를 가지지 않고 엔티티에 의존한다. 의존하는 엔티티를 제거하면 같이 제거된다.
- 공유하지 않는 것이 안전하다.
- 엔티티 타입과는 다르게 공유하지 않는 것이 안전하다. 대신에 값을 복사해서 사용해야 한다.
- 오직 하나의 주인만이 관리해야 한다.
- 불변(
Immutable
) 객체로 만드는 것이 안전하다.
값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다. 특히 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안 된다. 식별자가 필요하고 지속해서 값을 추적하고 구분하고 변경해야 한다면 그것은 값 타입이 아닌 엔티티다.
Member
, Delivery
에는 주소 정보가 나열되어 있다.
✔️ 값 타입 적용 전
public class Member {
...
private String city;
private String street;
private String zipcode;
...
}
public class Delivery {
...
private String city;
private String street;
private String zipcode;
...
}
Address
라는 값 타입을 만들어서 나열된 주소 대신에 사용하도록 변경해보자!
✔️ 값 타입을 사용한 결과 UML
Address - 값 타입 주소
package jpabook.jpashop.domain;
import javax.persistence.Column;
import javax.persistence.Embeddable;
import java.util.Objects;
@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();
}
public String getCity() {
return city;
}
public String getStreet() {
return street;
}
public String getZipcode() {
return zipcode;
}
private void setCity(String city) {
this.city = city;
}
private void setStreet(String street) {
this.street = street;
}
private void setZipcode(String zipcode) {
this.zipcode = 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(getCity(), address.getCity()) && Objects.equals(getStreet(), address.getStreet()) && Objects.equals(getZipcode(), address.getZipcode());
}
@Override
public int hashCode() {
return Objects.hash(getCity(), getStreet(), getZipcode());
}
}
Member - 회원에 값 타입 적용
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String name;
// private String city; // 삭제
// private String street; // 삭제
// private String zipcode; // 삭제
@Embedded // 추가
private Address address; // 추가
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<Order>();
...
}
Delivery - 배송에 값 타입 적용
import javax.persistence.*;
@Entity
public class Delivery {
@Id @GeneratedValue
@Column(name = "DELIVERY_ID")
private Long id;
@OneToOne(mappedBy = "delivery")
private Order order;
// private String city; // 삭제
// private String street; // 삭제
// private String zipcode; // 삭제
@Embedded // 추가
private Address address; // 추가
@Enumerated(EnumType.STRING)
private DeliveryStatus status; //ENUM [READY(준비), COMP(배송)]
...
}
값 타입 주소(Address
)의 Address
값 타입을 만들고 이것을 회원(Member
)에 값 타입 적용와 배송(Delivery
)에 값 타입 적용에 적용했다. 이제 주소 정보에 필드나 로직이 추가되면 Address
값 타입만 변경하면 된다.
참고