[김영한 - JPA 기본] #9 값 타입
JPA의 데이터 타입 분류
엔티티타입
@Entity
로 정의하는 객체- 식별자(
Id
)가 있기 때문에 데이터가 변해도 추적 가능- 예) 회원 entitiy의 키나 나이가 변해도 id로 식별 가능
값 타입
- int, Integer, String,와 같은 자바 기본 타입이나 객체
- id가 별도로 존재하지 않고 값만 있으므로 변경시 추적 불가
- 예) int a = 100 에서 200으로 변경한다는 건 완전 다른 값으로 변경
- 엔티티의 생명주기에 의존한다
값 타입 분류
기본값 타입
- 자바 기본 타입 (int, double)
- 래퍼 클래스 (Integer, Long)
- String
임베디드 타입 (embedded type, 복합 값 타입)
- JPA에서 별도로 정의해야 사용 가능
컬렉션 값 타입 (collection value type)
- JPA에서 별도로 정의해야 사용 가능
기본 값 타입
엔티티에 의존하는 생명 주기
- 예) 회원 entitiy를 삭제하면 이름, 나이 필드도 함꼐 삭제
값 타입은 공유하면 안된다
- 예) 회원 이름 변경시 다른 회원 이름도 같이 변경되면 안된다
당연한 얘기지만 기본타입은 항상 값을 복사한다
그러나 래퍼, String 클래스는 공유가능(=
연산 가능, 레퍼런스 타입) 변경(set
)이 불가하다
이러한 이유 덕에 우린 기본 값 타입을 안전하게 사용이 가능했던 것이다 !
임베디드 타입 Embedded Type
JPA의 기능으로 새로운 값을 정의할 수 있다
주로 기본 값 타입을 모아서 만들어서 복합 값 타입 이라고도 한다
하지만 결국 int, String 같은 값 타입이다 ^^ ㅎ
임베디드 타입: 내 코드 (눈갱 주의!!)
실행부
LocalDateTime startDateTime = LocalDateTime.of(2020, 4, 1, 9, 0); // 내 입사일
LocalDateTime endDateTime = LocalDateTime.of(2022, 2, 7, 18, 0); // 내 퇴사일 by 우테코 입교
Member memberA = Member
.builder()
.name("user A")
.period(new Period(startDateTime, endDateTime))
.build();
em.persist(memberA);
boolean isWorkNow = memberA.getPeriod().isWork();
out.println("isWorkNow = " + isWorkNow);
isWorkNow 는 근속여부인데 true
로 현재 근속중인 것으로 나온다 !
@Entity
public class Member extends BaseEntity {
...
@Embedded
private Period period;
@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 = "WORK_CITY")
),
@AttributeOverride(name = "street",
column = @Column(name = "WORK_STREET")
),
@AttributeOverride(name = "zipcode",
column = @Column(name = "WORK_ZIPCODE")
)
})
private Address workAddress;
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
}
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Period {
@Column(name = "WORK_START_DATE")
private LocalDateTime startDate;
@Column(name = "WORK_END_DATE")
private LocalDateTime endDate;
public Period(LocalDateTime startDate, LocalDateTime endDate) {
this.startDate = startDate;
this.endDate = endDate;
}
public boolean isWork() {
LocalDateTime now = LocalDateTime.now();
return (startDate.equals(now) || now.isAfter(startDate)) && (endDate.equals(now) || now.isBefore(endDate));
}
}
임베디드 타입의 장점
재사용성이 좋으며 높은 응집도를 가진다
Periord.isWork()
처럼 해당 타입만 사용하는 의미 있는 메서드를 사용할 수 있다- 예) 현재 이 사람이 근속중인가?
SRP
원칙을 지킬 수 있다
이 역시 값 타입이므로 생명주기는 현재 타입을 소유한 엔티티에 의존한다 !
임베디드 타입과 테이블 매핑
- 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능
- 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다 !
임베디드 타입과 연관관계
- value 타입안에 value타입, Entity 를 넣을 수 있다고 한다
- 실제로 해보았을때는 둘다 잘 되지 않았다 …
아직 쓰일 일도 없구 공감이 안되어서 굳이 해보지는 않았다 ㅎㅎ..
- 실제로 해보았을때는 둘다 잘 되지 않았다 …
임베디드 타입: @AttributeOverride: 속성 재정의
한 인티티에서 같은 값 타입 사용시 컬럼명 중복
- 예) 집 주소, 회사 주소
@AttributeOverrides
,@AttributeOverride
사용 가능
임베디드 타입과 null
- 임베디드 타입 값이 null일 경우 매핑한 컬럼 값은 모두 null 이다
값 타입과 불변 객체
값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다.
문제의 코드
Address address = new Address("city A", "street", "zipcode");
Member memberA = Member
.builder()
.name("user A")
.homeAddress(address)
.build();
Member memberB = Member
.builder()
.name("user B")
.homeAddress(address)
.build();
memberA.getHomeAddress().setCity("new city"); // 문제 발생 !
em.persist(memberA);
em.persist(memberB);
위와 같은 상황에서 Address 객체라는 참조타입을 공유하므로 두 회원의 정보가 동시에 바뀌어버리는 문제가 발생한다 !
값 타입 공유 참조
- 임베디드 값 타입과 같은 값을 여러 엔티티에서 공유하는 것은 위험하다
- 참조형이기 때문에 수정이 일어났을 시 여러 엔티티에서 동시 변경
- 그떄그때 마다 복사해서 사용하자 ! (아래 코드 참고)
- 임베디드 타입과 같은 값 타입은 참조형이며
=
등의 연산을 피해갈 수 없다 - 운영에서 버그 찾기 정말 어렵다고 한다
값을 공유참조로부터 안전하게 변경하기 by 깊은 복사
Address newCity = new Address("new city", address.getStreet(), address.getZipcode());
memberA.setHomeAddress(newCity);
위와 같이 값을 복사해서 사용하면 된다 !
불변 객체 (Immutable Object)
불변이라는 작은 제약을 걸어 큰 재앙을 막을 수 있다
생성 시점 이후 절대 값을 변경할 수 없는 객체
진정한 의미의 값 객체
생성자로만 값을 할당하고 수정자(
Setter
)를 만들지 않는다예) Integer, String은 자바가 제공하는 대표적인 불변 객체이다
값 타입의 비교
우선 코드로 먼저 보도록 하자 !
Address address1 = new Address("city A", "street", "zipcode");
Address address2 = new Address("city A", "street", "zipcode");
System.out.println("(address1 == address2) = " + (address1 == address2));
System.out.println("address1.equals(address2) = " + address1.equals(address2));
address1 과 address2 가 같은지를 비교하는 코드이다
아마 자바를 열심히 공부를 한 사람들이라면 여기서 어떤 내용을 다룰 것인지 모두 느낌을 잡았을 것이다
결론적으로 euqlas와 hashCode를 오버라이드하면 된다
hash code도 오버라이딩해야 HashMap등의 사용하는 API들이 효율적으로 동작 가능하다
- 동일성(
identity
) 비교: 인스턴스의 참조 값을 비교,==
사용 - 동등성(
equivalence
) 비교: 인스턴스의 값을 비교,equals()
사용- 레퍼런스 타입인 값 타입은
a.equals(b)
를 사용해서 동등성 비교를 해야 함 equals()
메소드를 적절하게 재정의해야 한다 (주로 모든 필드 사용)
- 레퍼런스 타입인 값 타입은
값 타입 컬렉션
- 말 그대로 컬렉션 ! 값 타입을 하나 이상 저장할 때 사용
@ElementCollection
,@CollectionTable
사용- DB는 컬렉션이라는 값을 테이블에 저장할 수 없다 (예를들어 엑셀 한칸에 list형 자료를 넣을 수 없음)
- 컬렉션을 저장하기 위한 별도의 테이블 필요
- id등의 고유 식별자를 도입하는 순간 개념적으로 값 타입이 아닌 엔티티가 된다
- 때문에 값타입은 id가 존재하지 않는다
- 값을 변경하면 추적이 어렵다
- (그외) jpa 에서 플러시할 때 순서의 보장은 해주지 않는다 !
예제 코드
@Entity
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue
private Long id;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "FAVORITE_FOOD" ,
joinColumns = @JoinColumn(name = "MEMBER_ID")
)
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();
@OrderColumn(name = "address_history_order")
@ElementCollection
@CollectionTable(
name = "ADDRESS" ,
joinColumns = @JoinColumn(name = "MEMBER_ID")
)
private List<Address> addressHistory = new ArrayList<>();
...
}
Set<String> favoriteFoods = new HashSet<>();
favoriteFoods.add("간장 치킨");
favoriteFoods.add("불닭 볶음면");
favoriteFoods.add("돈까스");
List<Address> addressHistory = new ArrayList<>();
addressHistory.add(new Address("old1 city", "street", "zipcode"));
addressHistory.add(new Address("old2 city", "street", "zipcode"));
저장
Member memberA = Member
.builder()
.name("user A")
.favoriteFoods(favoriteFoods)
.addressHistory(addressHistory)
.homeAddress(new Address("homne city", "home street", "zipcode"))
.build();
em.persist(memberA);
조회
out.println("#1 memberA.getId() = " + memberA.getId());
수정 : 잘못된 예
findMember.getHomeAddress().setCity("updated city");
값 타입은 값
이기 떄문에 이렇게 하지 말것 !!!
통쨰로 갈아 끼워줘야 한다
수정 : 올바른 예
Address homeAddress = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("updated city", homeAddress.getStreet(), homeAddress.getZipcode()));
값 컬렉션은 지우고 add를 한다 !
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
주의 : 리스트는 delete & insert 할 떄
주인 엔티티와 연관된 모든 데이터를 삭제하고 변경할 데이터를 포함하여 모두 저장
findMember.getAddressHistory().remove(new Address("old1 city", "street", "zipcode"));
findMember.getAddressHistory().add(new Address("new1 city", "street", "zipcode"));
위 사항을 해결하려면 @OrderColumn
어노테이션을 추가해 주어야 한다 이럴 경우 위치에 해당하는 컬럼값이 생기는 데
이걸 기준으로 식별하여 값을 수정처러한다
수정된 코드 (List 값 컬렉션)
@OrderColumn(name = "address_history_order")
@ElementCollection
@CollectionTable(
name = "ADDRESS" ,
joinColumns = @JoinColumn(name = "MEMBER_ID")
)
private List<Address> addressHistory = new ArrayList<>();
그러나 위 옵션을 위험하다.. 원하는데로 동작하지 않을 떄도 있고..
0, 1, 2, 3 값등으로 셋팅되는데 그중 2가 없으면 null 이 들어오는 등
그러나.. 이렇게 번잡하게 쓸바엔 리스트 값타입을 엔티티로 승격
시키는 것이 낫다 !!
승격시킨 Address Entity 코드
@Entity
@Table(name = "ADDRESS")
@Getter @Setter
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address; // 이곳에서 한번 감싼다 !
public AddressEntity() {
}
public AddressEntity(Address address) {
this.address = address;
}
public AddressEntity(String city, String street, String zipcode) {
this.address = new Address(city, street, zipcode);
}
}
@Entity
class Member {
...
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
}
List<AddressEntity> addressHistory = new ArrayList<>();
addressHistory.add(new AddressEntity("old1 city", "street", "zipcode"));
addressHistory.add(new AddressEntity("old2 city", "street", "zipcode"));
Member memberA = Member
.builder()
...
.addressHistory(addressHistory)
.build();
그럼 값 타입 컬렉션은 언제 쓰는거야?
예를 들어 좋아하는 메뉴를 멀티 셀렉트 박스 (치킨, 피자) 로 구성된 항목 체크할 때 등 단순한거 할 때 사용한다
반대로 주소 이력같은 경우는 엔티티이다, (값을 변경하지 않더라도 !) 그 히스토리 테이블 대상으로 조회할 수도 있기 떄문
그외
equals
와 hash code
를 구현할 때 가급적이면 getter를 이용하는 게 좋다
그래야 프록시로 만들어졌을 때 진짜 객체에 접근할 수 있다 (영한님 말씀)
그리고 상태와 행위를 한 곳에서 관리할 수 있다 .. ㅎㅎ