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)라고도 한다