JPA

값 타입, 값 타입 컬렉션

salmon16 2023. 9. 1. 01:12

값 타입 공유 참조 

  • 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다
  • 부작용이 발생한다.

회원1, 회원2가 같은 주소의 타입을 공유하고 있을 때 그 값으로 OldCity가지고 있었는데 city값을 NewCity로 변경하면 회원1, 회원2가 가지고 있는 주소의 값이 변경될 수 있다.

 

코드를 살펴보자

Address address = new Address("city" , "street", "10000");

Member member1 = new Member();
member1.setName("member1");
member1.setHomeAddress(address);
em.persist(member1);

Member member2 = new Member();
member2.setName("member2");
member2.setHomeAddress(address);
em.persist(member2);

member1.getHomeAddress().setCity("NewCity");

위 코드에서 member1, member2는  address 인스턴스를 공유하고 있다.

이때 개발자는 member1의 address만 바꾸려고 getAddress를 해서 주소를 변경했는데 member2의 address까지 변경이 되는 오류가 발생한다.

 

만약 의도가 member1과 member2의 address를 같이 관리하는 것이라면 address도 Entity로 관리해야 한다.

 

  • 위 상황처럼 값 타입의 실제 인스턴스인 값을 공유하는 것은 위험하다.
  • 대신 값(인스턴스)을 복사해서 사용해야 한다.

코드를 아래와 같이 변경하면 member1의 city값을 바꾸어도 member2의 city값은 바뀌지 않는다.

Address address = new Address("city" , "street", "10000");

Member member1 = new Member();
member1.setName("member1");
member1.setHomeAddress(address);
em.persist(member1);

Address copyAddress = new Address(address.getCity(), address.getStreet(), address.getZipcode());

Member member2 = new Member();
member2.setName("member2");
member2.setHomeAddress(copyAddress);
em.persist(member2);

member1.getHomeAddress().setCity("NewCity");

객체 타입의 한계

  • 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다.
  • 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다
    • 자바의 기본 타입이면 항상 복사가 된다.
    • 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없다.
  • 그러므로 객체의 공유 참조는 피할 수 없다(개발자가 실수할 수 있다.)
int a = 1;
int b = a;
b = 4;

 

 

위 코드는 자바의 기본 타입이기 때문에 b를 4로 바꾸어 주어도 a는 1로 유지된다.

Address a = new Address("old");
Address b = a;
b.setAddress("new");

하지만 객체 타입은 참조를 전달하므로 b의 address를 바꾸면 a의 address도 바뀌게 된다.

객체 타입 공유 문제 해결방법 

  • 객체 타입을 수정할 수 없게 만들면 부작용을 원천 차단할 수 있다.
  • 값 타입을 불변 객체로 설계해야 한다.
    • 불변 객체 : 생성 시점 이후 절대 값을 변경할 수 없는 객체
  • 생성자로만 값을 설정하고 수정자(Setter)를 만들지 않는다.

※Integer, String은 자바가 제공하는 대표적인 불변 객체이다.

불변이라는 작은 제약으로 큰 부작용을 막을 수 있다.

 

만약 값을 바꾸고 싶다면 new Address로 새로운 인스턴스를 만들어 member.setAddress() 해주면 된다.

 

값 타입의 비교

값 타입 : 인스턴스가 달라도 값이 같으면 같은 것으로 봐야 한다.

int a = 10;
int b = 10;

a == b는 true

Address a = new Address("부산");
Address b = new Address("부산");

a == b 는 false

 

  • 동일성(identity) 비교 : 인스턴스의 참조 값을 비교 == 사용
  • 동등성(equivalence) 비교 : 인스턴스 값을 비교 equals() 사용
  • 값 타입은  a.equals(b)를 사용해서 동등성을 비교해야 한다.
  • 값 타입의 equals() 메서드를 적절하게 재정의 해야 한다. (주로 모든 필드 비교)

값 타입 컬렉션

값 타입 컬렉션이란 값 타입을 컬렉션에 담아서 사용하는 것을 말한다.

값 타입 Address, String을 컬렉션에 담아 사용함

테이블을 보자

관계형 데이터베이스는 컬렉션을 테이블 안에 담을 수 있는 구조가 없는 데이터베이스가 많다.

그래서 MEMBER : FAVORITE_FOOD [1 : N] 일대다로 테이블을 뽑아야 한다.

FAVORITE_FOOD, ADDRESS는 엔티티가 아니기 때문에 별도의 식별자를 가지지 못하고 각 테이블 칼럼들을 조합해서 PK를 만들어야 한다.

 

코드를 작성해 보자

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String name;

    @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> addressesHistory = new ArrayList<>();
@Embeddable
public class Address {
   private String city;
   private String street;
   private String zipcode;

 

 

  1. @ElementCollection 어노테이션을 사용하면 된다.
  2. 그리고 테이블의 이름을 설정해 주어야 한다. @CollectionTable 어노테이션의 name속성을 이용한다.  
  3. @JoinColumn 어노테이션을 사용해서 외래키의 칼럼 이름을 설정해 준다.

 

 

테이블들이 잘 생성된 것을 확인할 수 있다.

Member테이블에는 FACORITE_FOOD, ADDRESS에 대한 정보는 없는데 테이블을 Join시에는 FAVORITE_FOOD, ADDRESS의 FK와 MEMBER 테이블의 PK로 Join을 한다. 

 

값 타입 컬렉션

  • 값 타입을 하나 이상 저장할 때 사용
  • @ElementCollection, @CollectionTable 사용
  • 데이터베이스는 컬렉션을 저장할 수 없다.
  • 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
Member member = new Member();
member.setName("member1");
member.setHomeAddress(new Address("city1", "street", "10000"));

member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("햄버거");

member.getAddressesHistory().add(new Address("old1", "street", "10000"));
member.getAddressesHistory().add(new Address("old2", "street", "10000"));
member.getAddressesHistory().add(new Address("old3", "street", "10000"));

em.persist(member);

위 코드를 수행 시 collection에 대한 insert문이 각 3개씩 나가는 걸 확인할 수 있다

 

위 메인 코드에서 em.persist(member); 만 해주어도 FavoriteFoods, AddressHistory가 잘 들어가는 것을 확인할 수 있는데 이는 값타입이기 때문에 member와 생명주기가 같기 때문이다.

 

즉 member의 다른 값 타입 (name)도 persist를 따로 해주지 않는 거처럼 컬렉션도 따로 해주지 않아도 된다.

 

값 타입의 컬렉션은 영속성 전에 (Cascade) + 고아 객체 기능을 필수로 가진다고 볼 수 있다.

 

find

Member findMember = em.find(Member.class, member.getId());

em.flush(), em.clear()후 위 코드를 수행하면 어떻게 될까?

Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_6_0_,
        member0_.city as city2_6_0_,
        member0_.street as street3_6_0_,
        member0_.zipcode as zipcode4_6_0_,
        member0_.USERNAME as username5_6_0_ 
    from
        Member member0_ 
    where
        member0_.MEMBER_ID=?

select 쿼리를 보면 Member에 대한 것만 가져오는 것을 볼 수 있다.

즉 컬렉션은 지연 로딩이다.

List<Address> addressesHistory = findMember.getAddressesHistory();
for (Address address : addressesHistory) {
    System.out.println("address.getCity() = " + address.getCity());
}

for문을 돌며 컬렉션 값을 가져올 때 select문이 나가는 것을 볼 수 있다.

 

컬렉션 값 타입 수정

치킨을 한식으로 바꾸고 싶을 때 String도 값 타입이기 때문에 제거 후 새로운 인스턴스를 add해주는 것이 좋다.

 

//치킨 -> 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");

delete sql이 나가고 insert sql이 나가는 것을 볼 수 있다.

 

컬렉션의 값만 변경해도 데이터베이스에 쿼리가 전송된다.. 

Address를 비교하기 위해서는 Address에 equals 함수를 Override해야 한다.

@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.getCity()) &&
            Objects.equals(street, address.getStreet()) &&
            Objects.equals(zipcode, address.getZipcode());
}

@Override
public int hashCode() {return Objects.hash(city, street, zipcode);}

 

이후 FavoriteFoods를 바꿀 때와 같게 해 주면 된다.

findMember.getAddressesHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressesHistory().add((new Address("newCity1", "street", "10000")));
Hibernate: 
    /* delete collection hellojpa.Member.addressesHistory */ delete 
        from
            ADDRESS 
        where
            MEMBER_ID=?
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressesHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressesHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)

 

 

그러면 insert sql이 2번 나가게 되는데 delete sql을 보면 연관된 ADDRESS를 모두 지우고 컬렉션에 있는 것들을 insert하는 것을 볼 수 있다.

  • 값 타입은 식별자와 다르게 식별자 개념이 없다.
  • 값은 변경하면 추적을 하기 어렵다.
  • 값 타입에 변경사항이 발생하면, 주인 엔티티와 연관된 모든 ADDRESS를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
  • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. :null x 중복 저장 x

하나를 바꾸기 위해 모든 데이터를 지우고 다시 저장해야 하므로 성능상 매우 안 좋다

그러므로 실무에서 사용하지 않는 것이 좋다.

값 타입 컬렉션의 대안

  • 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다.
  • 일대다 관계를 위해 엔티티를 만들고,  여기에 값 타입을 사용한다.
  • 영속성 전이(Cascade) + 고아 객체 제거를 사용해서 값 타입 컬렉션처럼 사용한다.

 AddressEntity를 만들어서 사용해 보자

 

@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
    @Id
    @GeneratedValue
    private Long id;

    private Address address;
@Entity
public class Member {

	@Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String name;
    
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressesHistory = new ArrayList<>();

이렇게 사용하면 된다.

 

정리

  • 엔티티 타입의 특징
    • 식별자가 있다.
    • 생명 주기 관리가 된다.
    • 공유할 수 있다.
  • 값 타입의 특징
    • 식별자가 없다.
    • 생명 주기를 엔티티에 의존한다.
    • 공유하지 않는 것이 안전하다.(복사해서 사용)
    • 불변 객체로 만드는 것이 안전하다.

값 타입은 정말 값 타입아라고 생각될 때만 사용해야 하고 

엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들어서 사용하면 안 된다.

식별자가 필요하고 지속해서 값 추적, 변경해야 한다면 엔티티를 만들어서 사용해야 한다.

 

 

'JPA' 카테고리의 다른 글

JPA MultipleBagFetchException에러 해결하기  (0) 2024.01.20
JPQL(Java Persistence Query Language)  (0) 2024.01.03
임베디드 타입  (0) 2023.08.31
기본값 타입  (0) 2023.08.31
영속성 전이 : CASCADE, 고아객체  (0) 2023.08.31