2022. 7. 22. 15:19ㆍLanguage`/JPA
JPA의 데이터 타입을 큰 카테고리로 나누면 총 2개의 카테고리로 나눌 수 있다
- 엔티티 타입
- 값 타입
엔티티 타입은 말그대로 @Entity로 정의되는 객체를 의미하고, 값 타입은 단순히 값으로 사용되는 자바의 기본 타입이나 객체를 의미한다
여기서 이 두 타입의 가장 큰 차이점은 "추적의 가능성"이다
→ 엔티티 타입은 "식별자"를 통해서 엔티티를 추적할 수 있지만, 값 타입은 별도의 식별자 개념이 없기 때문에 값이 바뀌어버리면 추적 자체가 불가능하다
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "member")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String name;
private Integer age;
}
현재 Member라는 엔티티가 존재하고 DB에는 다음 데이터가 있다고 하자
그리고 식별자(1L)에 대한 Member를 찾은 다음에 정보를 update해보자
Member findMember = em.find(Member.class, 1L);
findMember.updateInfo("memberA-Update", 99);
그러면 여기서 위의 [식별자 = 1 {20, memberA}]란 Member와 [식별자 1 {99, memberA-Update}]라는 Member는 서로 다른 Member일까??
>> 엔티티 타입은 "식별자"로 추적이 가능하고 둘다 동일한 식별자를 가지고 있기 때문에 같은 Member이다
그러면 이제 자바의 기본 값 타입을 살펴보자
int value = 100;
System.out.println(Integer.hashCode(value));
value = 200;
System.out.println(Integer.hashCode(value));
과연 두 출력문에서는 value라는 값 타입에 대한 동등성을 찾을 수 있을까?
완전히 다른 hashCode를 가지고 있다. 왜냐하면 값 타입은 단순한 수치 정보이고 값이 바뀌게 되면 완전히 다른 것으로 보기 때문이다
String line = "hello";
System.out.println(line.hashCode());
line = "Spring";
System.out.println(line.hashCode());
String이라는 자바 기본 타입으로 hashCode를 비교해봐도 서로 완전히 다른 hashCode를 가짐을 확인할 수 있다
>> hashCode가 다르다는 의미는 타입의 주소값이 다르다는 의미이므로 결론적으로 값 타입은 추적이 불가능하다는 것을 알 수 있다
값 타입
값 타입은 크게 3가지로 나눌 수 있다
- 기본값 타입
- 자바 기본 타입 (int, double, short, ...)
- 래퍼 클래스 (Integer, Double, Long, ...)
- String
- 임베디드 타입 (복합 값)
- 컬렉션 값 타입
그리고 여기서 하나 알아야 하는 것은 "기본 값 타입"은 [equals() & hashCode()]를 따로 구현하지 않아도 비교가 되지만 "임베디드 타입과 같은 복합 값 타입"은 "반드시 equals() & hashCode()를 재정의"해야 한다
▶ 1. 기본값 타입
기본값 타입은 말그대로 [자바 기본 타입 / 래퍼 클래스 / String]을 의미한다
int value1 = 100;
long value2 = 200L;
double value3 = 5.0;
Integer value4 = 100;
Long value5 = 200L;
Double value6 = 5.0;
String line = "Hello Spring World!!";
이러한 기본값 타입들은 값이 변경되면 이전 값에 대한 추적이 완전히 불가능한 상태이다
따라서 식별자 불가능하고 그에 따른 생명주기도 없는 타입이다
값 타입은 "절대 공유하면 안되는 타입"이다. 그리고 애초에 기본값 타입은 공유 자체가 불가능한 타입이다
왜냐하면 기본값 타입은 값에 대한 참조를 그대로 전달해주는게 아니라 값을 "복사"해서 전달해주기 때문에 공유가 애초에 되지 않는다
▶ 2. 임베디드 타입 (복합 값 타입)
임베디드 타입은 새로운 값 타입을 직접 정의해서 사용하는 것을 의미한다
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "member")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String name;
private Integer age;
// 근무 기간
private LocalDate startDate;
private LocalDate endDate;
// 집 주소
private String city;
private String street;
private String zipcode;
}
이러한 Member Entity가 존재하고 내부 필드는 다음과 같이 부를 수 있다
Member Entity는 (이름, 나이. [근무 시작일, 근무 종료일], [주소 도시, 주소 번지, 주소 우편번호])를 가진다
그런데 현실적으로 생각해보면 어느 누가 Member에 대한 설명을 위와 같이 할까라는 생각이 든다
더 보편적인 설명은 다음과 같을 것이다
Member Entity는 (이름, 나이, 근무 기간, 집 주소)를 가진다
이렇게 설명하는게 더 깔끔하고 현실적일 것이다
따라서 "근무 기간 & 집 주소"를 임베디드 타입으로 생성해서 적용시켜보자
근무 기간
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Embeddable
public class Period {
private LocalDate startDate;
private LocalDate endDate;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Period period = (Period) o;
return Objects.equals(getStartDate(), address.getStartDate()) && Objects.equals(getEndDate(), address.getEndDate());
}
@Override
public int hashCode() {
return Objects.hash(getStartDate(), getEndDate());
}
}
집주소
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
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 Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "member")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String name;
private Integer age;
// 근무 기간
@Embedded
private Period workPeriod;
// 집 주소
@Embedded
private Address address;
}
이와 같이 더 깔끔한 Entity를 정의할 수 있다
그리고 임베디드 값 타입을 사용할 때는 다음 과정을 거쳐야 한다
1. 임베디드 값 타입에는 반드시 @Embeddable을 붙여야 한다
2. 임베디드 값 타입을 "사용하는" Entity에서는 사용할 때 @Embedded를 붙여야 한다
3. 임베디드 타입은 "기본 생성자가 필수"이다
여기서 가장 중요한 사실은 "임베디드 타입도 값 타입"이라는 점이다
>> 따라서 임베디드 타입은 절대 공유되면 안되는 타입이다
그리고 임베디드 타입을 "재정의"해서 사용할 때는 @AttributeOverride를 사용하면 된다
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "member")
public class Member {
...
...
// 집 주소
// 회사 주소
}
현재 Address라는 임베디드 값 타입을 사용하려고 하는데 이를 (집 주소 & 회사 주소)로 나눠서 사용하려고 한다
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "member")
public class Member {
...
...
// 집 주소
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "HOME_CITY")),
@AttributeOverride(name = "street", column = @Column(name = "HOME_STREET")),
@AttributeOverride(name = "zipcode", column = @Column(name = "HOME_ZIPCODE"))
})
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;
}
이와 같이 하나의 속성에 대해서 @AttributeOverride로 재정의하고 재정의할 속성이 여러개면 @AttributeOverrides내부에 재정의해주면 된다
Hibernate:
create table member (
member_id bigint not null auto_increment,
age integer,
COMPANY_CITY varchar(255),
COMPANY_STREET varchar(255),
COMPANY_ZIPCODE varchar(255),
HOME_CITY varchar(255),
HOME_STREET varchar(255),
HOME_ZIPCODE varchar(255),
name varchar(255),
endDate date,
startDate date,
primary key (member_id)
) engine=MyISAM
값 타입의 공유
임베디드 타입 같은 값 타입은 여러 엔티티에서 공유하면 위험하고 절대 공유되서는 안되는 타입이다
다음과 같은 시나리오가 존재한다고 하자
현재 DB상에 MemberA, memberB가 존재하고 둘다 Address라는 임베디드 값 타입을 가지고 있다고 하자
Address address = new Address("city", "street", "12345");
Member memberA = new Member("memberA", 30, address);
Member memberB = new Member("memberB", 50, address);
여기서 memberA가 이사를 해서 Address가 [city-Update, street-Update, 54321]로 바뀌었다고 하자
Address address = new Address("city", "street", "12345");
Member memberA = new Member("memberA", 30, address);
Member memberB = new Member("memberB", 50, address);
memberA.getAddress().setCity("city-Update");
memberA.getAddress().setStreet("street-Update");
memberA.getAddress().setZipcode("city-54321");
em.persist(memberA);
em.persist(memberB);
이러면 과연 DB에는 각 member에 대한 어떤 값들이 저장될까?
>> 변경을 하지 않은 memberB의 주소도 동일하게 변경됨을 확인할 수 있다
사실 당연한 변화이다. 왜냐하면 두 memberA, memberB 모두 동일한 "Address"라는 임베디드 값 타입을 참조하고 있고, 이 값에 대한 변화가 이루어지면 당연히 둘다 변화된 참조를 가지고 DB에 저장이 되기 때문이다
따라서 임베디드 값 타입은 "반드시 불변 객체"로 만들어야 한다
불변 객체로 만드는 방법은 굉장히 다양하지만 지금은 "생성자를 통한 생성"을 다룰 것이다
반드시 임베디드 값 타입은 생성자로만 값이 할당되어야 하고 setter와 같은 변경을 가하는 메소드는 만들지 않아야 한다
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
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());
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "member")
public class Member {
...
...
@Embedded
private Address address;
public void updateAddress(Address address){
this.address = address;
}
그리고 엔티티레벨에 아예 따로 "의미있는 변경 메소드"를 만들어서 구현해주는 것이 더 바람직한 설계이다
Member memberA = new Member("memberA", 30, new Address("city", "street", "12345"));
Member memberB = new Member("memberB", 50, new Address("city", "street", "12345"));
memberA.updateAddress(new Address("city-Update", "street-Update", "54321"));
em.persist(memberA);
em.persist(memberB);
이제 원하는대로 memberA에 대한 변경만 이루어진 것을 확인할 수 있다
▶ 3. 값 타입 컬렉션
값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 추가적인 애노테이션을 붙여주면 된다
- @ElementCollection
- @CollectionTable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "member")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String name;
private Integer age;
@Embedded
private Address address;
@ElementCollection
private Set<String> favoriteFoods = new HashSet<>();
@ElementCollection
private List<Address> addressHistory = new ArrayList<>();
}
1. favoriteFood라는 String 값 타입을 여러개 저장하고 Set 컬렉션에 보관하기
2. Address라는 임베디드 값 타입을 여러개 저장하고 List 컬렉션에 보관하기
관계형 DB에서는 컬럼내부에 컬렉션을 포함할 수 없기 때문에 컬렉션인 값들에 대해서는 "별도의 테이블"이 구축되어서 FK로 조인하는 형태이다
<Member Table>
member_id라는 PK와 더불어서 {name, age, 임베디드 값 Address : [city, street, zipcode]} 컬럼을 가지고 있다
<Member_addressHistory Table>
member 테이블의 PK인 member_id를 식별관계로 맺어서 본인의 PK이자 FK로 사용한다
그리고 임베디드값 타입의 필드인 {city, street, zipcode}를 컬럼으로 가지고 있다
→ 모든 컬럼을 하나의 PK로 활용한다
<Member_favoriteFoods Table>
member 테이블의 PK인 member_id를 식별관계로 맺어서 본인의 PK이자 FK로 사용한다
그리고 String 필드인 {favoriteFoods}를 컬럼으로 가지고 있다
→ 모든 컬럼을 하나의 PK로 활용한다
여기서 @CollectionTable 애노테이션을 활용하면 생성되는 컬렉션 테이블에 대한 추가 정보도 설정해줄 수 있다
거의 @JoinTable과 비슷한 속성들을 가지고 있고, 거의 동일하게 매핑해주면 된다
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "member")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String name;
private Integer age;
@Embedded
private Address address;
@ElementCollection
@CollectionTable(
name = "favorite_foods",
joinColumns = @JoinColumn(name = "member_id")
)
@Column(name = "food_name")
private Set<String> favoriteFoods = new HashSet<>();
@ElementCollection
@CollectionTable(
name = "address_history",
joinColumns = @JoinColumn(name = "member_id")
)
private List<Address> addressHistory = new ArrayList<>();
}
@CollectionTable에 설정한대로 [테이블 이름, FK/PK로 가져오는 이름]이 제대로 적용됨을 확인할 수 있다
- 임베디드 값 타입인 Address는 따로 @Column을 매핑하지 않아서 임베디드 값 타입 내부 필드이름이 그대로 들어갔다. 만약에 변경하고 싶으면 @AttributeOverride로 재정의 해주면 된다
- favoriteFoods의 경우 @Column으로 컬럼이름 설정해주지 않으면 그대로 컬렉션 필드이름인 favoriteFoods가 들어가게 되고 위에서는 @Column(name = "food_name")으로 설정해주었기 때문에 food_name으로 컬럼이 생성됨을 확인할 수 있다
값 타입 컬렉션 사용 (저장/수정/삭제)
public void registerAddress(Address address){
this.address = address;
this.getAddressHistory().add(address);
}
일단 address를 저장할 때 컬렉션에도 저장할 수 있도록 간편한 편의 메소드를 하나 생성해두었다
Member member = new Member("member", 30);
member.registerAddress(new Address("city1", "street1", "zipcode1"));
member.getFavoriteFoods().add("짜장면");
member.getFavoriteFoods().add("탕수육");
member.getFavoriteFoods().add("짬뽕");
member.registerAddress(new Address("city2", "street2", "zipcode2")); // 주소 변경
em.persist(member);
이렇게 최종적으로 member만 persist하면 어떠한 query가 db로 날라가게 되는걸까?
>> 결론적으로 말하자면 총 6개의 insert query가 날라가고 하나씩 살펴보자
(1) Member에 대한 insert query
Hibernate:
/* insert Type.domain.Member
*/ insert
into
member
(city, street, zipcode, age, name)
values
(?, ?, ?, ?, ?)
이 insert query는 em.persist(member)에 대한 insert query이다
(2) List<Address> 컬렉션에 존재하는 2개의 Address에 대한 insert query
Hibernate:
/* insert collection
row Type.domain.Member.addressHistory */ insert
into
address_history
(member_id, city, street, zipcode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row Type.domain.Member.addressHistory */ insert
into
address_history
(member_id, city, street, zipcode)
values
(?, ?, ?, ?)
(3) Set<String> 컬렉션에 존재하는 3개의 food에 대한 insert query
Hibernate:
/* insert collection
row Type.domain.Member.favoriteFoods */ insert
into
favorite_foods
(member_id, food_name)
values
(?, ?)
Hibernate:
/* insert collection
row Type.domain.Member.favoriteFoods */ insert
into
favorite_foods
(member_id, food_name)
values
(?, ?)
Hibernate:
/* insert collection
row Type.domain.Member.favoriteFoods */ insert
into
favorite_foods
(member_id, food_name)
values
(?, ?)
근데 나머지 컬렉션의 5개 instance에 대한 insert query는 어떻게 발생된걸까??
사실 값 타입 컬렉션은 "Cascade + orphanRemoval" 기능을 필수적으로 가지고 있다.
이러한 기능덕분에 값 타입 컬렉션의 내부 instance들은 포함관계의 Entity가 persist될 때 추가적으로 insert되는 것이다
그리고 추가적으로 @ElementCollection으로 지정된 값 타입 컬렉션의 기본 로딩 전략은 "지연 로딩"이다
Member findMember = em.find(Member.class, 1L);
Set<String> favoriteFoods = findMember.getFavoriteFoods();
List<Address> addressHistory = findMember.getAddressHistory();
// favoriteFoods 내부 리스트들 조회
System.out.println("===== favoriteFoods 내부 instance 조회 =====");
for(String s : favoriteFoods){
System.out.println(s);
}
// addressHistory 내부 리스트들 조회
System.out.println("===== addressHistory 내부 instance 조회 =====");
for(Address a : addressHistory){
System.out.println(a);
}
Hibernate:
select
member0_.member_id as member_i1_2_0_,
member0_.city as city2_2_0_,
member0_.street as street3_2_0_,
member0_.zipcode as zipcode4_2_0_,
member0_.age as age5_2_0_,
member0_.name as name6_2_0_
from
member member0_
where
member0_.member_id=?
===== favoriteFoods 내부 instance 조회 =====
Hibernate:
select
favoritefo0_.member_id as member_i1_1_0_,
favoritefo0_.food_name as food_nam2_1_0_
from
favorite_foods favoritefo0_
where
favoritefo0_.member_id=?
짬뽕
짜장면
탕수육
===== addressHistory 내부 instance 조회 =====
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_history addresshis0_
where
addresshis0_.member_id=?
Address{city='city1', street='street1', zipcode='zipcode1'}
Address{city='city2', street='street2', zipcode='zipcode2'}
"지연 로딩"전략을 활용하기 때문에 컬렉션 내부 값에 실제로 접근할 때 select query가 나가게 되는 것을 확인할 수 있다
그러면 이제 값 타입 컬렉션에 대한 "수정"을 해보자
Member findMember = em.find(Member.class, 1L); // member(1L) 조회
Set<String> favoriteFoods = findMember.getFavoriteFoods(); // favoriteFoods에 대한 수정 (삭제 -> 추가)
favoriteFoods.remove("탕수육");
favoriteFoods.add("마라탕");
List<Address> addressHistory = findMember.getAddressHistory(); // addressHistory에 대한 수정 (삭제 -> 추가)
addressHistory.remove(new Address("city1", "street1", "zipcode1"));
addressHistory.add(new Address("city3", "street3", "zipcode3"));
(1) Member, favorite_food, address_history에 대한 select query
Hibernate:
select
member0_.member_id as member_i1_2_0_,
member0_.city as city2_2_0_,
member0_.street as street3_2_0_,
member0_.zipcode as zipcode4_2_0_,
member0_.age as age5_2_0_,
member0_.name as name6_2_0_
from
member member0_
where
member0_.member_id=?
Hibernate:
select
favoritefo0_.member_id as member_i1_1_0_,
favoritefo0_.food_name as food_nam2_1_0_
from
favorite_foods 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_history addresshis0_
where
addresshis0_.member_id=?
(2) 기본 값 타입 (favorite_food) 컬렉션 수정
Hibernate:
/* delete collection row Type.domain.Member.favoriteFoods */ delete
from
favorite_foods
where
member_id=?
and food_name=?
Hibernate:
/* insert collection
row Type.domain.Member.favoriteFoods */ insert
into
favorite_foods
(member_id, food_name)
values
(?, ?)
일단 delete query를 통해서 "탕수육" instance를 제거해주고, insert query를 통해서 "마라탕" instance를 넣어준 것을 알 수 있다
(3) 임베디드 값 타입 (address_history) 컬렉션 수정
Hibernate:
/* delete collection Type.domain.Member.addressHistory */ delete
from
address_history
where
member_id=?
Hibernate:
/* insert collection
row Type.domain.Member.addressHistory */ insert
into
address_history
(member_id, city, street, zipcode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row Type.domain.Member.addressHistory */ insert
into
address_history
(member_id, city, street, zipcode)
values
(?, ?, ?, ?)
여기서는 delete query를 보면 "member_id = 1"인 모든 instance를 삭제하는 것을 볼 수 있다
이 과정을 자세히 살펴보면 다음과 같다
1. member_id = 1인 "모든 instance"를 삭제
2. 현재 값 타입 컬렉션에 있는 값들을 "다시 전부 저장(insert)"
따라서 (1) 과정에서 일단 member_id = 1인 모든 instance를 삭제한다. 그리고나서 List<Address> 값 타입 컬렉션 내부에는 [주소2, 주소3]이 존재하기 때문에 이 2개의 instance를 다시 전부 DB에 insert query로 넣어주는 것이다
이렇게 전부 삭제하고 다시 다 넣는 이유는 다음과 같다
엔티티 타입은 식별자가 존재하기 때문에 값을 변경해도 식별자를 통해서 DB에서 쉽게 찾아올 수 있다
하지만 값 타입은 식별자라는 개념이 없고 단순한 값들이기 때문에 이 값이 변경되면 원본 데이터를 추적할 수 없다
따라서 원래 보관된 값들에 대해서 어느것이 삭제되었는지 판별하기 어렵기 때문에 일단 전부 삭제하고 나서 값 타입 컬렉션에 남아있는 instance들을 다시 전부 insert해주는 것이다
따라서 이렇게 값 타입 컬렉션에는 성능적 이슈가 발생할 수 있기 때문에 차라리 값 타입 컬렉션을 사용하기 보다는 새로운 엔티티를 만들어서 일대다 관계로 설정하는 것이 더 바람직하다고 볼 수 있다
엔티티 타입 & 값 타입의 특징
엔티티 타입
- 식별자(@Id)가 존재하고 이 식별자를 통해서 엔티티를 구별할 수 있다
- 생명주기가 존재한다 [생성 / 영속 / 소멸]
- em.persist를 통해서 영속 / em.remove를 통해서 소멸
- 공유가 가능하다
값 타입
- 식별자가 없다
- 생명주기를 엔티티에 의존한다
- 기본적으로 cascade + orphanRemoval 기능이 존재하기 때문에 의존하는 엔티티가 제거되면 같이 제거된다
- 공유가 불가능하고 공유하면 데이터 정합성에 문제가 발생한다
- 값 타입은 VO(Value Object)라고도 한다