<< JPA에서 제공하느 값 타입의 종류, 임베디드 타입에 대해 인지하고, 불변객체를 왜 사용하는지, 언제 어떻게 사용하는지 알아보자>>
1) 엔티티 타입
- @Entity 로 정의하는 클래스 객체
- 데이터가 변해도 식별자로 지속 추적 가능 > PK 값으로 구분 가능
2) 값 타입
- 변경시 추적이 어려운, 단순한 값으로 사용되는 자바 기본 타입 / 객체
값타입
기본값 / 임베디드 타입 / 컬렉션 타입 으로 나뉨 >> 값타입은 항상 불변객체로 한다라고 그냥 생각해도 됨 (아래 나옴)
기본값 - 생명 주기가 엔티티에 의존된다.
기본값, 기본 타입 은(int, double) 은 공유가 안된다.
int a= b
int b= a
a = 20;
출력해보면 a =20, b = 10 이 된다.
하지만 클래스 (래퍼 클래스처럼)
Integer a= new Integer(10);
Integer b = a;
a.setValue(20); // 이런거 없는데 한다고 치자
출력해보면 a=b=20 이라고 한다.
레퍼런스가 넘어가서 같은 인스턴스를 a,b 가 공유하고, 그 주소를 받아서 사용하기 때문이다.
<< 임베디드 타입 개요 >>
Member - id, name, startDate, endDate, zipcode, street, city 의 값들을 가지고 있다!
>> 뭔가 이상함. 이렇게 말하기 보단
멤버는 이름 / 근무 기간 / 집 주소에 대한 값을 가지고 있다! 라고 표현하는게 맞음.
Member - id, name, Period workPeriod, Address homeAddress 이렇게 표현하는게 객체지향상 맞음!
JPA 에서는 이런 경우를 , Embedded Annotation 을 통해서 한다.
>>>> 장점 : 당연히 객체지향 특징처럼 재사용이 가능하다. 응집도가 높다. 해당 값타입에서만 할 수 있는 논리 메소드를 만들 수 있다. ex) Period.isWorking() 등.
<4min 40 초 대 설명>
회원테이블은 바뀔게 없음 >> 하지만 JPA Entity 클래스상에서 바뀌는 것임. 매핑을 해주는 방식에 차이가 있음. (이런게 객체와 테이블의 차이임!)
@Entity
@Data
public class Employees {
@Id
@GeneratedValue
@Column(name = "employee_id")
private Long id;
private String username;
// 기간
private LocalDateTime startDate;
private LocalDateTime endDate;
// 주소
private String city;
private String street;
private String zipcode;
}
다음과 같이 정의되었다고 해보자. DB를 실행해보면 그냥 생각하는대로 들어가 있는 것을 볼 수 있다.
기간을 Period period로, 주소를 Address address 로 변경후 생성을 해보자.
@Data
@Embeddable
@NoArgsConstructor
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
}
@Data
@Embeddable
@NoArgsConstructor
public class Address {
private String city;
private String street;
private String zipcode;
}
기본생성자는 필수이며, Embeddable annotation 을 사용할 수 있다. 그리고 Employee 클래스를 다음과 같이 바꿀 수 있다.
@Entity
@Data
public class Employees {
@Id
@GeneratedValue
@Column(name = "employee_id")
private Long id;
private String username;
@Embedded
private Period workPeriod;
// private LocalDateTime startDate;
// private LocalDateTime endDate;
@Embedded
private Address homeAddress;
// private String city;
// private String street;
// private String zipcode;
}
DB를 실행해보면, 임베디드 클래스를 만들기 전과 테이블 상 아무 차이가 없음을 알 수 있다.
이게 제일 중요함 ==> 테이블은 동일함.
> 하지만 테이블에 좀 더 객체지향스럽게 매핑이 가능해진 것!
> 매핑 테이블 수보다 내부 객체지향 클래스 수가 더 많을 수록 활용도가 높은 것!
만약에 두개를 써야 한다면?
public class Employees {
...
@Embedded
private Period workPeriod;
@Embedded
private Address homeAddress;
private Address workAddress;
}
이거 그대로 돌려보면 에러가 발생함. 칼럼명들이 다 똑같은 상태에서 동일하게 넣으려 하기 때문.
@AttributeOverride 를 사용하면 됨.
public class Employees {
...
@Embedded
private Period workPeriod;
@Embedded
private Address homeAddress;
@Embedded
@AttributeOverrides({ // 동일 칼럼 하나하나에 대해 다 지정해줘야 함
@AttributeOverride(name = "city", column = @Column(name = "work_city")),
@AttributeOverride(name = "street", column = @Column(name = "work_street"))
})
private Address workAddress;
}
동일한 칼럼 하나하나에 대해서 새로운 칼럼명들을 위와 같이 지정해주면 됨.
<< 불변 객체 >>
임베디드 타입 같은 값을 여러 엔티티 상에서 공유하면 위험하다. 다음과 같은 예제를 보자.
@Test
@DisplayName("Embedded Type Dangerous")
void test2() {
tx.begin();
try {
Address address = new Address("city", "street");
Employees emp1 = new Employees();
emp1.setUsername("EMP1");
emp1.setHomeAddress(address);
Employees emp2 = new Employees();
emp2.setUsername("EMP2");
emp2.setHomeAddress(address);
em.persist(emp1);
em.persist(emp2);
// 아 emp2 의 street 은 달랐지 하고 바꾸려 함
emp2.getHomeAddress().setStreet("Another Street");
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
}
emf.close();
}
저걸 의도해서 같이 쓰고 싶은 거면, 값타입을 쓰면 안됨. 엔티티를 사용해야 하는 거임.
위와 같이 공유를 하면 안되고, 복사해서 만들어서 사용해야 함.
try {
Address address = new Address("city", "street");
Employees emp1 = new Employees();
emp1.setUsername("EMP1");
emp1.setHomeAddress(address);
Address copiedAddress = new Address(address.getCity(), address.getStreet());
Employees emp2 = new Employees();
emp2.setUsername("EMP2");
emp2.setHomeAddress(copiedAddress);
em.persist(emp1);
em.persist(emp2);
// 아 emp2 의 street 은 달랐지 하고 바꾸려 함
emp2.getHomeAddress().setStreet("Another Street");
tx.commit();
}
다음과 같이 새로운 객체를 생성하고 복사값을 넣어준 뒤로 세팅을 해주게 되면, 나중에 바꿨을 때도 아무 문제가 없다.
자 한번만 다시 복습하자면, 기본 값 타입은 공유시 값을 복사해서 넣어주고, 객체 값 타입은 공유시 참조값 (주소) 넣어준다. 이래서 객체 값타입은 같이 변경이 될 수 있는 거다.
> 이건 막을 수가 없음, 위에 복사 객체를 만드는것도 조금 이상해보이기도 하고, 깜빡할 확률이 굉장히 높음.
따라서, 아예 못하게 해버려야 한다 . 그런 설계를 불변 객체라고 한다.
값 타임은 불변 객체로 설계를 해야 함. 1) 생성자로만 값을 수정하고, Setter를 만들지 않는다. 2) Setter를 Private 으로 선언한다 (내부 활용시에만 바꿀 수 있도록) >> 그래야 위와 같이 해줬을 때 개발자가 "뭔가 잘못되었다", "이렇게 하면 안되는구나" 라는걸 인지할 수 있음. >> 참고로, String 과 Integer 는 대표적인 Java 가 제공하는 불변객체이다.
이렇게 하면 (위에선 복사객체?를 만들었기 때문에 괜찮지만) 위에 emp2.getHomeAddress().setStreet("~~"); 은 에러가 발생한다. Address 는 불변객체이기 떄문이다.
그렇다면 바꾸고 싶은디 어떻게 바꿔야 함?? Address 하나만 써도 못바꿈!!
다음과 같이 바꾸면 됨.
try {
Address address = new Address("city", "street");
Employees emp1 = new Employees();
emp1.setUsername("EMP1");
emp1.setHomeAddress(address);
// 다시 만들어야함 ㅋㅋㅋㅋㅋ
Address newAddress = new Address("NEW CITY", address.getStreet());
emp1.setHomeAddress(newAddress); // 이건 당연히 됨. Address가 불변객체
em.persist(emp1);
tx.commit();
}
다시 쓰면 됨 ㅋㅋㅋ. 이렇게 하는게 맞음.
<< 값 타입의 비교>>
int a = 10, int b = 10 >> a == b 시 TRUE (기본 값 타입은 그 내부 값이 같으면 같다고 취급)
Address a = new Address("ㅈㅈ"), b = new Address("ㅈㅈ"), >> a ==b 시 당연히 False. 다른 객체이기 때문 ㅇㅇ (당연한거)
객체랑 기본 값은 저장되는 위치가 다르기 때문에 비교되는 방식도 다름.
-- 동일성 비교와 동등성 비교를 구분해야 함.
동일성 (== 비교) , 동등성 (.eqauls() 비교)
자 방금전에 한 a, b 비교, equals 로 하면 어떨까?
a.equals(b) : 당연히 False 나옴. equals 비교는 기본이 == 비교이기 때문임.
따라서 동등성 비교를 따로 구분 짓기 위해서, 동등성 비교를 구분하려는 객체 클래스 내에서 .equals 를 Overriding 해야 함.
Address 내부의 equals 를 overriding 해보자. (Overriding 항목 보면 [Generate equals() and hashCode()] 라는 항목이 있음. IntelliJ에서 오버라이딩 하는거면 95% 동등성 확인하는 거임으로 자동으로 해줌. Address 에 대해서 equlas 를 만들어라라고 한다면, 다음과 같이 만들 것이고, 이는 내부 값들이 같으면 true를 반환하는 형식으로 짜여져 있음을 알 수 있다.
...
@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);
}
@Override
public int hashCode() { // 이건 모른다면 나중에 알아보면 되는디, 그냥 hashMap 등 자료구조에서 효율적인 사용을 위한 것임. 알아보면 좋을 듯
return Objects.hash(city, street);
}
}
이 상태에서 위에 a.equals(b) 를 해본다면 이제서야 True 가 나온다. 오버라이딩 된 함수가 동등성 비교 함수로 바뀌었기 때문이다. String 이 == 가 아니라 equlas 를 쓰는 것을 권장하는 이유이다.
임베디드 타입은 대부분 직접 다 해야하는데, 그런 경우 동일성 비교가 아니라 동등성 비교로 바꿔주는게 좋다. 근데 생각보다 .equals() 쓸 일은 많진 않음 ㅋㅋ. 그냥 하다가, 뭔가 임베디드 타입 같음을 비교하고 있는 것 같을 떄 아! 그거가 있었지! 하면서 생각나서 동등성 비교할 수 있으면 성공임 ㅇㅇ
<< 값 타입 컬렉션>>
그냥 말그대로 컬렉션인데 값 타입이 들어가는 컬렉션임. (List<Address> , List<Period> 등)
값 타입을 하나 이상 저장할 때 사용
List<Address>, Set<String> 등등
컬렉션은 기본적으로 1:N 관계이기 때문에 편법(All String 변환 따위)을 쓰지 않는 한 같은 테이블에 저장시킬 수 없기 때문에, @ElementCollection annotation 을 사용하여 이것은 [값 타입 컬렉션] 이다라는걸 말해주고,
@CollectionTable 을 통해 그 값타입을 따로 컬렉션으로 만들어 줄건데, 어떻게 만들어줄건지 지정해주면 된다. 가령, 위 Employee Class 를 다음과 같이 수정해볼 수 있다.
@Entity
@Data
public class Employees {
@Id
@GeneratedValue
@Column(name = "employee_id")
private Long id;
private String username;
@Embedded
private Address homeAddress;
/* 컬렉션 예제 */
@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<>();
}
이 상태에서 실행하면, favorite_food 라는 테이블과 address 라는 테이블이 생겨있고, 각각 String, Address(임베디드 저장되듯이) 에 해당하는 칼럼들이 형성된 모습을 확인할 수 있다. 그리고 Join 할 키들을 넣어줄 수 있다. (member_id)
사용 예제를 살펴보자.
{
..
try {
Employees employees = new Employees();
employees.setUsername("emp1");
employees.setHomeAddress(new Address("city", "street"));
employees.getFavoriteFoods().add("치킨");
employees.getFavoriteFoods().add("피자");
employees.getFavoriteFoods().add("족발");
employees.getAddressHistory().add(new Address("old1", "old1"));
employees.getAddressHistory().add(new Address("old2", "old2"));
em.persist(employees);
tx.commit();
} catch (Exception e) {
..
}
그냥 생각하듯이 해당 클래스의 get을 통해서 adding 을 해주면 다음과 같이 DB에 잘 들어가 있는 모습을 확인할 수 있따.
<<DB 모습>>
>> collection 만 하니까 다른 테이블인데도 불구하고 favorite_food 와 address 가 같이 persist 됨. 라이프 사이클이 같이 돌아감. <값 타입 컬렉션> 이기 때문. 값타컬도 생명주기가 Employee 에 소속됨. (String 도 값타입, Long 도 값 타입. 모두 Employee 에 의존하기 때문에, 컬렉션 값타입도 마찬가지임) Cascade All 처럼 알아서 들어가 있음. (고아 객체 제거 기능까지 다 들어가 있음)
이번엔 저장 말고 조회에 대해 살펴보자. 다음과 같이 Test를 수정한다.
{ ...
try {
// ... 동일
em.flush();
em.clear();
System.out.println("==================START ==================");
Employees employees1 = em.find(Employees.class, employees.getId());
tx.commit();
} catch (Exception e) {
...
}
Query 날라가는 것을 확인해보면, Employee 에 대한 값들만 가져오는 것을 알 수 있음. 컬렉션들은 다 ~ 지연 로딩이란 뜻.
그 이후로 가지고 오고 출력해보자.
System.out.println("==================START ==================");
Employees foundEmployee = em.find(Employees.class, employees.getId());
for (Address address :
foundEmployee.getAddressHistory()) {
System.out.println("address.getCity() = " + address.getCity());
}
그러고 쿼리 날라가는걸 보면, 그 이후로 Address에 대한 select 문이 또 나가고, where member_id = 조회 유저. 이 쿼리가 날라가는 것을 확인할 수 잇다. 그렇게 값타입 컬렉션도 지연로딩 전략을 사용하여 프록시를 사용하는 것을 알 수 있음. (들어가보면 @ElementCollection = Fetchtype.LAZY 로 default 설정되어 있음)
이제 수정을 보자. 이 부분이 그나마 이해가 필요.
위에서 바로 말했지만, 값 타입은 ALL IMMUTABLE 이다. 불변 객체들임.
==== 잠깐 ====
값 타입은 기본타입 / 임베디드 타입 / Collection타입 으로 나뉜다고 했다 .
값타입이 ALL IMMUTABLE 이란 무슨 소리냐
그냥 다 함부로 set 하면 안된다는 소리다.
즉 제일 처음에 말했던 것으로 돌아간다 -> Entity 에 대해서는 함부로 Setter를 열어두면 안된다. String 같은거야 값 자체가 그 value 니까 set을 써도 된다 하지만, (지금 말하는건 관례적인 set을 말하는거임 (this.~ = ~ ; ), 이렇게 수정하면 그 값을 또다른 어디서 참조하고 있으면 같이 변경되는 에러가 발생할 수도 있다. (사실 어떤 에런지 본 적이 없으니 정확히 모르겠음)
>> 기본 값 타입에 대해선 그 값을 변경해 줌으로써 변경이 충분하긴 하니까 set을 하는거랑 큰 차이 없는 행위지만, 임베디드 타입 같은 경우 반드시 새로 만들어서 넣어줘야 한다) (뭐 이렇게 내 생각을 적고 보니까 setter가 크게 문제있는 건 아닌듯. 다만 어떤과정을 통해서 set을 해줘야하는지 정확,. 아주 정확하게 알고 있어야 할 뿐)
===============
따라서, 수정할 떄 다음과 같이 해볼 수 있다.
@Test
@DisplayName("Embedded Type Collection Usage : Save and Update")
void test4() {
...
try{
Employees foundEmployee = em.find(Employees.class, employees.getId());
// 복습!! City 바꾸고 싶음
// foundEmployee.getHomeAddress().setCity("new City"); 이렇게 바꾸면 안됩니다.
Address preAddress = foundEmployee.getHomeAddress();
foundEmployee.setHomeAddress(new Address("new City", preAddress.getStreet())); // 통째로 바꿔야 함
// 이제 컬렉션 UPDATE
// Set<String> 을 치킨 > 한식으로 바꾸고 싶음
foundEmployee.getFavoriteFoods().remove("치킨");
foundEmployee.getFavoriteFoods().add("한식");
// String 도 결국 Immutable 성질을 적용시켜야 해서, 사실 UPDATE 따윈 없음
// 삭제하고 추가, 결국 갈아끼우는 것임
tx.commit();
}catch(){
...
}
기본 값 타입 컬렉션은 위처럼 해줄 수 있다. 그렇다면 임베디드 값 컬렉션 수정은 어떻게 할까? 갈아끼워야 한다고 했다.
{ ...
// 이젠 임베디드 컬렉션 Update : EQUALS 가 매우 중요한 타이밍
// 기존 컬렉션 내의 특정 객체를 그대로 넣어줌으로써 삭제하라고 하는 것
// 이제 .Equals 오버라이딩이 제대로 안되어 있으면 개판 나는것
foundEmployee.getAddressHistory().remove(new Address("old2", "old2"));
// 새거 넣어준다.
foundEmployee.getAddressHistory().remove(new Address("new City", "old2"));
tx.commit();
...
}
아직 끝이 아니다. 이 원리를 파악하려면, 제대로 SQL 까지 봐야 한다. SQL 문이 다음과 같이 나가는 것을 볼 수 있다.
...
Hibernate:
/* delete collection jpa.demo.type.Employees.addressHistory */ delete
from
address
where
member_id=?
...
Hibernate:
/* insert collection
row jpa.demo.type.Employees.addressHistory */ insert
into
address
(member_id, city, street)
values
(?, ?, ?)
Hibernate:
/* insert collection
row jpa.demo.type.Employees.addressHistory */ insert
into
address
(member_id, city, street)
values
(?, ?, ?)
...
기존 Address 테이블에서 현재 묶여있는 employee_id 값을 가진 애들을 모~~~~~~~~두 삭제하고, 그 다음에 현재 그 Employee 가 들고 있는 객체들을 다시 다 넣어준다.
느낌이 와야겠지만. 쿼리가 문제가 있는 거다. 결론적으론 맞게 나왔지만, 이거 이런식으로 동작하면 진짜 overwhelming 이 큰거다. (이 느낌 온다면 잘 공부하고 있는 거인듯)
왜 이런 일이 일어나는 걸까??
값은 변경하면 추적이 어려움. 따라서 값 컬렉션에 변경 사항이 생기면 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다 .
>>>>>>>> 결론 ㅋㅋㅋㅋㅋㅋ 쓰지 말아라 ^_^
연관 컬렉션은 식별자가 없기 때문에, DB에서 어떤 방식으로든 추적하기가 굉장히 어려움. (임베디드 말하는게 아니라 임베디드 컬렉션 말하는거임!!!) > 임베디드는 원래 테이블이 없기 때문에 id 가 없음. @ElementCollection 이거가 문제라는거임.
해결방안이 있긴 한데, 그것도 문제가 있음. >> 걍 쓰지마. 그냥 쓰지말라면 쓰지마. 이렇게 복잡하게 갈거면 설계를 잘못한거야. 걍 쓰지마 ㅇㅇ
이정도로 갈꺼면 Entity 를 새로 만들어서 PK를 쥐어주는게 맞음. 그리고 1:N관계로 풀어내는게 맞음. 시발아. 당연한거 아니냐.
즉, 이렇게 대안을 마련할 수 있다.
@Entity
public class AddressEntity {
@Id
@GeneratedValue
private Long id;
@Embedded
private Address address;
}
@Entity
public class Employees{
...
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "member_id")
private List<AddressEntity> addressHistory = new ArrayList<>();
...
}
이렇게 들어오면 맘대로 수정해도 됨.
실무에서는 값타입 컬렉션 대신에 일대다 관계를 우선적으로 고려를 해야한다. 진짜 조오옹ㄴ나 단순한거면 써도 되는데, 그럴바엔 String 같은 경우 Enum 이용하는게 나을듯..
정리
- 값타입은 정말 값타입을 써야겠다 할 때만 사용해라
- 엔티티와 값타입을 혼동하면 안됨. >>> 엔티티를 값타입으로 만들면 안됨!!! (오히려 배워서 더 헷갈린거 같긴함 ㅋㅋ)
- 식별자가 필요하고, 추적해야할 필요성을 느낀다면 진짜 무조건 엔티티임.
'Spring > JPA' 카테고리의 다른 글
[JPA] JPQL 쿼리 2 (FETCH JOIN의 등장) (0) | 2023.01.03 |
---|---|
[JPA] JPQL 쿼리 (0) | 2022.12.29 |
[JPA] 프록시와 연관관계 - 2 (0) | 2022.12.28 |
[JPA] 프록시와 연관관계 정리 (0) | 2022.12.26 |
[JPA] 연관관계 매핑 -2 (0) | 2022.12.26 |