공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
값 타입의 비교
값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 한다.
//값 타입
int a = 10;
int b = 10;
System.out.println("a == b " + (a == b)); //true
//임베디드 타입
Address address1 = new Address("city", "street", "10000");
Address address2 = new Address("city", "street", "10000");
System.out.println("address1 == address2 " + (address1 == address2)); //false
- int는 기본 값 타입으로 실제 값 자체를 저장한다.
- a와 b는 모두 값 10을 가지므로 a == b는 true를 반환한다.
- 객체 타입 비교 Address는 사용자 정의 클래스이며, 참조 타입이다.
- address1과 address2는 서로 다른 두 객체를 참조한다. 비록 두 객체가 동일한 값을 가지고 있더라도 == 연산자는 객체의 메모리 주소(참조)를 비교한다.
- address1과 address2는 다른 메모리 주소를 가지고 있으므로 address1 == address2는 false를 반환한다.
- 객체의 값을 비교하고 싶다면 equals를 쓰면 된다.
- 동일성(identity) 비교 : 인스턴스의 참조 값을 비교, == 사용
- 동등성(equivalence) 비교 : 인스턴스의 값을 비교, equals() 사용
- 값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야 한다.
- 값 타입의 equals() 메소드를 적절하게 재정의(주로 모든 필드 사용)
Address를 다시 equals를 써서 비교해 보자.
Address address1 = new Address("city", "street", "10000");
Address address2 = new Address("city", "street", "10000");
System.out.println("address1 == address2 " + (address1 == address2));
System.out.println("address1 equals address2 " + (address1.equals(address2)));
- 왜 false가 나왔을까?
- equals의 기본 비교는 == 비교이기 때문이다. 따라서 오버라이드를 해서 사용해야 한다.
@Embeddable
public class Address {
...
@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) &&
Objects.equals(zipcode, address.zipcode);
}
@Override
public int hashCode() {
return Objects.hash(city, street, zipcode);
}
}
- true가 나오는 것을 확인할 수 있다.
값 타입 컬렉션
- 값 타입을 하나 이상 저장할 때 사용한다.
- @ElementCollection, @CollectionTable을 사용한다.
- 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다.
- 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
@Entity
public class Member {
...
@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 엔티티에서 MEMBER_ID를 외래키로 잡아서 테이블을 생성한다.
- 기본 자료 구조 컬렉션 하위에 있는 인터페이스들은 다 쓸 수 있다. (Set, List 등)
- 값 타입을 컬렉션으로 저장한다. (여러 개 저장하기 위해)
- @ElementCollection을 쓰면 값 타입 컬렉션을 매핑할 수 있다.
- favoriteFoods Set <String> 필드는 기본 타입 String의 컬렉션이다. 따라서 각 요소는 컬렉션 테이블의 단일 열에 저장된다.
- 그래서 String은 따로 @Column으로 열 이름을 지정해 준다.
- Address는 @Embeddable로 지정되어 있기 때문에 Address 타입의 각 필드는 ADDRESS 테이블의 별도 열로 저장된다.
- 따라서 @Column 어노테이션을 사용할 필요가 없다.
- 만약 열 이름을 지정하고 싶으면 이전 시간에 썼던 @AttributeOverride로 지정해 주면 된다. 아래 코드와 같이 쓰면 된다.
//임베디드 타입 열 이름 지정 예시
@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "NEWNAME_CITY")),
@AttributeOverride(name = "street", column = @Column(name = "NEWNAME_STREET")),
@AttributeOverride(name = "zipcode", column = @Column(name = "NEWNAME_ZIPCODE"))
})
private List<Address> addressHistory = new ArrayList<>();
테이블을 만들었으니 실제 사용 예제 코드를 보자.
값 타입 컬렉션 사용 예제
저장
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "10000"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");
member.getAddressHistory().add(new Address("old1", "street", "10000"));
member.getAddressHistory().add(new Address("old2", "street", "10000"));
em.persist(member);
- Member, address, favoriteFoods 테이블에 각각 1번, 2번, 3번씩 INSERT 된 것을 확인할 수 있다.
- DB 테이블에도 기대했던 대로 값이 잘 들어간 것을 볼 수 있다.
- 값 타입 컬렉션을 따로 영속화하지 않고 member만 영속화했는데 address와 favoriteFoods도 같이 INSERT가 됐다.
- 즉, 값 타입은 Member 엔티티의 생명주기에 소속된다.
조회
em.flush(), em.clear()로 DB에 데이터를 넣어두고 애플리케이션을 다시 실행하는 것처럼 조회한다.
em.persist(member);
em.flush();
em.clear();
System.out.println("=====================START===================");
Member findMember = em.find(Member.class, member.getId());
tx.commit();
- Member를 조회했을 때 member의 정보만 가져온다는 것은 컬렉션들은 지연로딩이라는 뜻이다.
- address의 정보(city, street, zipcode)는 Member에 소속된 값 타입이기 때문에 같이 조회가 된다.
em.flush();
em.clear();
System.out.println("=====================START===================");
Member findMember = em.find(Member.class, member.getId());
List<Address> addressHistory = findMember.getAddressHistory();
for (Address address : addressHistory) {
System.out.println("address = " + address.getCity());
}
Set<String> favoriteFoods = findMember.getFavoriteFoods();
for (String favoriteFood : favoriteFoods) {
System.out.println("favoriteFood = " + favoriteFood);
}
tx.commit();
- 컬렉션 값들은 필요할 때 조회를 하면 결과와 같이 조회가 된다.
- @ElementCollection의 기본 FetchType은 LAZY이다. (지연로딩)
수정
city를 변경하고자 할 때
findMember.getHomeAddress().setCity("newCity");
'이런 식으로 바꾸면 되지'라고 생각할 수 있지만
Address의 setter는 값을 변경할 수 없도록 막아놨다. '그럼 public으로 변경하고 수정하면 되지' 라고 생각할 수 있지만
이전 시간에 공부했듯이 값타입은 잘못하면 사이드 이펙트가 생긴다. 따라서 값 타입은 불변해야 한다.
따라서 아래와 같이 새로운 객체를 만들어서 써야 한다. (city와 food 변경)
em.flush();
em.clear();
System.out.println("=====================START===================");
Member findMember = em.find(Member.class, member.getId());
// homeCity -> newCity
// findMember.getHomeAddress().setCity("newCity"); // X
Address a = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", a.getStreet(), a.getZipCode()));
//치킨 -> 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
tx.commit();
- city는 새로운 Address로 만들어서 update 하고
- 음식은 String 값타입이기 때문에 통째로 갈아 끼워야 한다(update 하면 안 된다.)
- 따라서 삭제 후 새로운 값을 add 한다.
- 컬렉션의 값만 변경해도 JPA가 DB에 쿼리가 보내서 변경해 준다.
이번에는 주소를 변경해 보자.
findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressHistory().add(new Address("newCity1", "street", "10000"));
- ADDRESS 테이블에서 "old1" "street" "10000"에 해당하는 행을 지운 후 새로운 데이터를 INSERT 하는 것을 예상했지만,
- 실제로는 삭제하는 데이터와 연관된 데이터는 모두 지운 뒤에 새로운 데이터를 INSERT 한다. 그래서 INSERT가 두 번 보내진 것이다.
- 따라서 이런 식의 방법은 권장하지 않는다.
참고: 값 타입 컬렉션은 영속성 전이(Cascade) + 고아 객체 제거 기능을 필수로 가진다.
값 타입 컬렉션의 제약사항
- 식별자 없음: 값 타입은 엔티티와 달리 식별자 개념이 없어서 변경 시 추적이 어렵다.
- 변경 사항 처리: 값 타입 컬렉션에 변경 사항이 발생하면 주인 엔티티와 연관된 모든 데이터를 삭제하고, 현재 값을 모두 다시 저장한다.
- 기본 키 구성: 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 하며, null 입력 및 중복 저장이 불가능하다.
값 타입 컬렉션 대안
실무에서는 상황에 따라 값 타입 컬렉션 대신 일대다 관계를 고려한다.
@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address;
public AddressEntity(String city, String street, String zipcode) {
this.address = new Address(city, street, zipcode);
}
public AddressEntity(Long id, Address address) {
this.id = id;
this.address = address;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Address getAddress() {
return address;
}
public void setAddress(Address address) {
this.address = address;
}
}
- 일대다 관계를 위한 AddressEntity 엔티티를 만든다.
- 그리고 여기서 @Embeddable로 정의된 Address 값 타입을 사용한다.
@Entity
public class Member {
...
// @ElementCollection
// @CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
// private List<Address> addressHistory = new ArrayList<>();
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
}
- @ElementCollection으로 값 타입 컬렉션으로 매핑되어 있던 것을 @OneToMany 관계로 변경하여 AddressEntity 엔티티를 사용한다. 이로 인해 값 타입 컬렉션의 제약을 피할 수 있다.
- 값 타입 컬렉션의 변경 사항이 있을 때마다 모든 데이터를 삭제하고 다시 삽입하는 대신, 엔티티를 사용함으로써 변경 사항을 더 효율적으로 관리할 수 있다.
- CascadeType.ALL : 부모 엔티티에 대한 모든 작업(저장, 수정, 삭제 등)을 자식 엔티티에도 전파한다.
- orphanRemoval = true(고아 객체 제거)를 사용하여 부모 엔티티에서 참조가 제거된 자식 엔티티를 자동 삭제할 수 있도록 한다.
- 여기서 자식 엔티티는 addressHistory이다.
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "10000"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");
member.getAddressHistory().add(new AddressEntity("old1", "street", "10000"));
member.getAddressHistory().add(new AddressEntity("old2", "street", "10000"));
em.persist(member);
tx.commit();
- update가 두 번 발생한 이유는 @OneToMany 1:N 단방향 관계에서 다른 테이블(ADDRESS)에 외래키가 있기 때문이다.
- Member 객체에 새로운 AddressEntity 객체를 추가할 때 ADDRESS테이블의 MEMBER_ID 값을 업데이트해야 하기 때문이다.
- 이는 ADDRESS 테이블의 기존 행을 새로운 Member와 연결하기 위해 두 번의 update 쿼리가 발생하는 것이다.
- ADDRESS 테이블에 ID값이 생기고 MEMBER_ID를 FK로 저장되었다. 자체적으로 ID가 있다는 것은 값 타입이 아니라 엔티티 타입이라는 뜻이다.
정리
엔티티 타입의 특징
- 식별자가 있다.
- 생명 주기 관리가 된다.
- 공유할 수 있다.
값 타입 특징
- 식별자가 없다.
- 생명 주기를 엔티티에 의존한다.
- 공유하지 않는 것이 안전하다(복사해서 사용)
- 불변 객체로 만드는 것이 안전하다.
값 타입은 진정한 값 타입일 때만 사용해야 하며, 엔티티와 혼동해서는 안 된다.
식별자가 필요하고 지속적으로 값을 추적하고 변경해야 한다면, 이는 값 타입이 아니라 엔티티로 설계해야 한다.
실전 예제 6 - 값 타입 매핑
Address라는 값 타입을 만들어서 Delivery와 Member에 적용해 보자.
@Entity
public class Member extends BaseEntity{
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String name;
private String city;
private String street;
private String zipcode;
...
}
Member의 city, street, zipcode를 값 타입으로 만들어 보자.
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
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(city, address.city) && Objects.equals(street, address.street) && Objects.equals(zipcode, address.zipcode);
}
@Override
public int hashCode() {
return Objects.hash(city, street, zipcode);
}
}
Address 값 타입을 만든다.
@Entity
public class Member extends BaseEntity{
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String name;
@Embedded
private Address address;
...
}
city, street, zipcode -> 값 타입으로 변경했다.
@Entity
public class Delivery extends BaseEntity{
@Id @GeneratedValue
private Long id;
// private String city;
// private String street;
// private String zipcode;
@Embedded
private Address address;
private DeliveryStatus status;
...
}
Delivery도 값 타입으로 변경해 준다.
의도한 대로 테이블이 생성되는 것을 볼 수 있다. (city, street, zipcode 생성)
값 타입의 장점 중 하나는 의미 있는 비즈니스 메서드를 만들거나 공통 관리되는 룰(컬럼 길이 등)을 만들 수도 있다.
@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();
}
...
}
'BackEnd > JPA' 카테고리의 다른 글
[JPA] 객체지향 쿼리 언어1 - 기본 문법(2) (0) | 2024.07.20 |
---|---|
[JPA] 객체지향 쿼리 언어1 - 기본 문법(1) (0) | 2024.07.19 |
[JPA] 값 타입(1) (0) | 2024.07.17 |
[JPA] 영속성 전이와 고아 객체 (0) | 2024.07.16 |
[JPA] 프록시와 연관관계 관리 (0) | 2024.07.16 |