JPA(Java Persistence API)

[JPA] Part 14. 컬렉션 값 타입

cwchoiit 2023. 10. 25. 11:59
728x90
반응형
SMALL

엔티티에 컬렉션으로 관리하는 데이터는 흔히 있을 수 있는 일이지만 DB는 기본적으로는 컬렉션 데이터를 지원하지 않는다. 물론 요즘은 여러 방법으로 컬렉션을 테이블에서 관리할 수 있지만(JSON으로 데이터를 저장한다든지 등) 그러나 정석적인 방법은 컬렉션 데이터를 테이블 화해서 참조로 관리하는 것이다. 

 

728x90
반응형
SMALL

아래 그림을 보자. 

멤버라는 엔티티가 관리하는 데이터 favoriteFoods와 addressHistory는 컬렉션 값 타입이다. 이런 엔티티를 테이블화 하기 위해서는 각 컬렉션 값 타입을 테이블로 분리해서 1:N 관계로 만드는 것이다. 이게 정석적인 방법이다. 

위 그림을 보면 Address도 FavoriteFood도 모든 필드가 하나의 PK인데 이 이유는 컬렉션 '값 타입'이기 때문이다. 값 타입은 하나의 값 자체가 고유값이 되어야 하는 것이다. 만약, 여기서 어떤 구분할 수 있는 PK가 따로 있으면 그건 값 타입이 아니라 엔티티라고 불려야한다.

 

구현하는 방법도 간단하다. 코드를 보자

 

Address Class

package org.example.entity.collection;

import javax.persistence.Embeddable;

@Embeddable
public class Address {

    private String city;
    private String street;
    private String zipcode;

    public Address() {}

    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
}

Member Class

package org.example.entity.collection;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Entity
public class Member {

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

    private String name;

    @Embedded
    private Address address;

    @ElementCollection
    @CollectionTable(
            name = "FAVORITE_FOOD",
            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<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Set<String> getFavoriteFoods() {
        return favoriteFoods;
    }

    public void setFavoriteFoods(Set<String> favoriteFoods) {
        this.favoriteFoods = favoriteFoods;
    }

    public List<Address> getAddressHistory() {
        return addressHistory;
    }

    public void setAddressHistory(List<Address> addressHistory) {
        this.addressHistory = addressHistory;
    }
}

멤버 엔티티가 관리하는 컬렉션 두 개가 있다. favoriteFoods, addressHistory.

이런 컬렉션 형태의 필드를 테이블화하기 위해 두 가지의 어노테이션이 필요하다. @ElementCollection, @CollectionTable. @CollectionTable 어노테이션에 name property는 테이블명을 의미한다.

joinColumns는 이 테이블이 어떤 테이블과 조인될지를 선정한다. 당연히 멤버와 일대다 매핑이 되어야하므로 멤버의 기본키로 조인했다.

 

favoriteFoods같은 경우엔 String으로 된 문자열 단일 값이기 때문에 컬럼명을 지정해주기 위해 @Column 어노테이션도 사용했다.

이렇게 두 개의 어노테이션만 있으면 자동으로 컬렉션 값 타입은 테이블로써 만들어지게 된다.

 

실행해보자.

실행 코드

package org.example;

import org.example.entity.collection.Address;
import org.example.entity.collection.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Member member = new Member();
            member.setName("member");
            member.getFavoriteFoods().add("Pizza");
            member.getFavoriteFoods().add("Chicken");
            member.getFavoriteFoods().add("Beef");

            member.getAddressHistory().add(new Address("city", "street", "zipcode"));

            em.persist(member);

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

코드를 보면 멤버 객체를 하나 생성하고 멤버 객체의 favoriteFoods와 addressHistory를 가져와 추가했다. 저 두 개의 엔티티는 멤버라는 부모 엔티티에 의해 생명주기가 관리된다. 즉, 멤버 객체가 생성되고 소멸되는 주기가 곧 저 두 엔티티의 생명주기이고 멤버에 의해 관리 되기 때문에 영속 컨텍스트에는 멤버만을 추가해도 알아서 새로이 추가된 favoriteFoods와 addressHistory가 INSERT문으로 나간다.

 

결과를 보자.

Hibernate: 
    insert 
    into
        Member
        (city, street, zipcode, name, MEMBER_ID) 
    values
        (?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        ADDRESS_HISTORY
        (MEMBER_ID, city, street, zipcode) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        FAVORITE_FOOD
        (MEMBER_ID, FOOD_NAME) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        FAVORITE_FOOD
        (MEMBER_ID, FOOD_NAME) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        FAVORITE_FOOD
        (MEMBER_ID, FOOD_NAME) 
    values
        (?, ?)

Member, AddressHistory, FavoriteFood 모두 INSERT문이 실행됐음을 확인할 수 있다.

컬렉션 타입은 이렇게 테이블화해서 관계 매핑으로 다루어야 한다.

 

근데 만약 데이터를 수정해야할 때는 어떻게 동작할까?

아래 수정 코드를 보자.

 

값 타입 컬렉션 수정하기

package org.example;

import org.example.entity.collection.Address;
import org.example.entity.collection.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Member member = new Member();
            member.setName("member");
            member.getFavoriteFoods().add("Pizza");
            member.getFavoriteFoods().add("Chicken");
            member.getFavoriteFoods().add("Beef");

            member.getAddressHistory().add(new Address("city", "street", "zipcode"));
            member.getAddressHistory().add(new Address("city2", "street2", "zipcode2"));

            em.persist(member);
            em.flush();
            em.clear();

            Member findMember = em.find(Member.class, member.getId());
            findMember.getAddressHistory().removeIf(address -> address.getCity().equals("city"));
            findMember.getAddressHistory().add(new Address("newCity", "newStreet", "newZipcode"));

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

새 멤버를 만들 때 addressHistory, favoriteFoods를 추가한 후 멤버만을 persist()해도 flush()를 호출하면 다음과 같이 데이터베이스에 Member, Address_History, Favorite_Food가 모두 추가된다. 그 이유는 값 타입은 어떤것이든 엔티티의 생명주기에 종속적이기 때문이다. 그러고 난 후 멤버를 데이터베이스에서 찾은 후 addressHistory를 하나 삭제하고 새로운 것 하나를 추가한다. 

 

그러고 트랜잭션을 커밋을 하면 다음과 같은 SQL문이 나간다.

Hibernate: 
    delete 
    from
        ADDRESS_HISTORY 
    where
        MEMBER_ID=?
Hibernate: 
    insert 
    into
        ADDRESS_HISTORY
        (MEMBER_ID, city, street, zipcode) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        ADDRESS_HISTORY
        (MEMBER_ID, city, street, zipcode) 
    values
        (?, ?, ?, ?)

전체를 삭제해버린 후 기존에 남은 하나와 새로 추가한 하나를 나란히 INSERT한다. 이것이 값 타입 컬렉션을 테이블로 관리할 때 가장 큰 문제가 된다. 값 타입 컬렉션 테이블은 식별자가 따로 없기 때문에 JPA가 삭제할 때 어떤 데이터를 가져와 삭제해야 하는지 알지 못한다. 그렇기 때문에 전체를 삭제한 후 종속된 엔티티가 가지고 있는 모든 데이터를 다시 하나씩 추가한다. 

 

결론은, 이 값 타입 컬렉션을 쓰면 안된다.

 

값 타입 컬렉션을 엔티티로 변경하라

값 타입 컬렉션의 가장 큰 문제는 식별자가 없기에 수정 쿼리를 수행할 때 JPA가 찾아내지 못한다는 것이다. 그리고 반대로 말하면 식별자가 있는 테이블은 엔티티라고 표현해야 한다. 

 

그럼 위 코드의 문제점을 어떻게 수정하면 될까?

 

AddressEntity로 값 타입 컬렉션을 엔티티로 승격시켜 식별자를 가지게 하는것이다.

AddressEntity Class

package org.example.entity.collection;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class AddressEntity {
    
    @Id @GeneratedValue
    @Column(name = "ADDRESS_ID")
    private Long id; 
    
    private 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;
    }
}

그리고, 멤버는 이 엔티티를 일대다 단방향으로 (또는 다대일 양방향도 좋은 방식) 변경한다. 

package org.example.entity.collection;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Entity
public class Member {

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

    private String name;

    @Embedded
    private Address address;

    @ElementCollection
    @CollectionTable(
            name = "FAVORITE_FOOD",
            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<>();*/
	
    // 변경후 코드
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "MEMBER_ID")
    private List<AddressEntity> addressHistory = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Set<String> getFavoriteFoods() {
        return favoriteFoods;
    }

    public void setFavoriteFoods(Set<String> favoriteFoods) {
        this.favoriteFoods = favoriteFoods;
    }

    public List<AddressEntity> getAddressHistory() {
        return addressHistory;
    }

    public void setAddressHistory(List<AddressEntity> addressHistory) {
        this.addressHistory = addressHistory;
    }
}

다대일 단방향, 양방향이 가장 좋은 방식이지만 이 경우 특별한 케이스이므로 일대다 단방향으로 설정해봤다. 이렇게 작성한 후 실행코드를 보자. 문제가 발생하는 부분이 생긴다.

 

아래 코드는 더 이상 사용할 수 없다. 왜냐하면 Member의 AddressHistory는 타입이 AddressEntity이기 때문에 Address 객체는 받을 수 없다.

member.getAddressHistory().add(new Address("city", "street", "zipcode"));

 따라서, 아래 코드로 변경해야 한다. 

member.getAddressHistory().add(new AddressEntity("city", "street", "zipcode"));

물론 AddressEntity에서 생성자를 만들어줘야 한다. 다음 코드가 그 부분이 추가된 상태이다.

package org.example.entity.collection;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class AddressEntity {

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

    private Address address;

    public AddressEntity() {

    }

    public AddressEntity(String city, String street, String zipcode) {
        this.address = new Address(city, street, zipcode);
    }

    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;
    }
}

 

이제 진짜 실행해볼 수 있다. 실행 코드는 다음과 같다.

package org.example;

import org.example.entity.collection.Address;
import org.example.entity.collection.AddressEntity;
import org.example.entity.collection.Member;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            Member member = new Member();
            member.setName("member");
            member.getFavoriteFoods().add("Pizza");
            member.getFavoriteFoods().add("Chicken");
            member.getFavoriteFoods().add("Beef");

            member.getAddressHistory().add(new AddressEntity("city", "street", "zipcode"));
            member.getAddressHistory().add(new AddressEntity("city2", "street2", "zipcode2"));

            em.persist(member);
            em.flush();
            em.clear();

            Member findMember = em.find(Member.class, member.getId());
            findMember.getAddressHistory().removeIf(address -> address.getAddress().getCity().equals("city"));
            findMember.getAddressHistory().add(new AddressEntity("newCity", "newStreet", "newZipcode"));
            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

이제 실행 코드를 보면 다음과 같이 SQL문이 출력된다.

Hibernate: 
    insert 
    into
        AddressEntity
        (city, street, zipcode, ADDRESS_ID) 
    values
        (?, ?, ?, ?)
Hibernate: 
    update
        AddressEntity 
    set
        MEMBER_ID=null 
    where
        MEMBER_ID=? 
        and ADDRESS_ID=?
Hibernate: 
    update
        AddressEntity 
    set
        MEMBER_ID=? 
    where
        ADDRESS_ID=?
Hibernate: 
    delete 
    from
        AddressEntity 
    where
        ADDRESS_ID=?

더 이상 전체 Address 정보를 삭제하지 않고, 삭제하고자 하는 하나의 레코드만을 삭제하는 모습을 확인할 수 있다. 멤버로 수정 작업을 했지만 AddressEntity에 대한 UPDATE문이 실행되는 건 멤버가 OneToMany로 일대다 단방향 매핑을 했기 때문이다. 

 

결론 

이런식으로 데이터의 식별자가 필요하고 데이터의 추적이 필요한 경우에는 값 타입 컬렉션을 사용하지 말고 엔티티로 승격시켜 사용해야 한다.

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] Part 16. Fetch JOIN (JPQL)  (0) 2023.10.30
[JPA] Part 15. JPQL  (0) 2023.10.28
[JPA] Part 13. 임베디드 타입  (0) 2023.10.23
[JPA] Part 12. CASCADE  (2) 2023.10.23
[JPA] Part 11. 지연로딩과 즉시로딩  (2) 2023.10.23