본문 바로가기
데이터베이스/JPA

[JPA] 값 타입 컬렉션 : @ElementCollection, @CollectionTable

by jeonghaemin 2021. 3. 24.
728x90

JPA에서 데이터 타입은 엔티티 타입과 값 타입으로 분류할 수 있다.

여기서 값 타입은 단순히 값으로 사용하는 자바의 프리미티브 타입이나 객체를 말한다.

값 타입의 분류

  • 기본값 타입 : 자바 프리미티브 타입, 래퍼 클래스(Integer, Long 등), String
  • 임베디드 타입(복합 값 타입)
  • 값 타입 컬렉션 : 값 타입을 여러 개 저장하고자 할 때 사용하며, 자바의 컬렉션을 사용한다.

값 타입 컬렉션

@ElementCollection, @CollectionTable 애노테이션을 붙여 값 타입 컬렉션을 사용할 수 있다.

//임베디드 타입
@Embeddable
public class Address {

    private String city;

    private String street;

    private String zipcode;

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

    //기본적으로 컬렉션들은 값의 비교를 eqauls 메서드를 사용하기 때문에 재정의 해준다.
    @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);
    }
}

@Entity
public class Person {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @ElementCollection
    @CollectionTable(
        name = "foods", 
        joinColumns = @JoinColumn(name = "person_id")
    )
    @Column(name = "food_name") //값이 하나고 내가 정의한 것이 아니기 때문에 예외적으로 컬럼명 변경 허용
    Set<String> favoriteFoods = new HashSet<>();

    @ElementCollection
    @CollectionTable(
        name = "address", 
        joinColumns = @JoinColumn(name = "person_id")
    )
    List<Address> addressList = new ArrayList<>();

    public Person(String name) {
        this.name = name;
    }

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

    public List<Address> getAddressList() {
        return addressList;
    }
}

문자열 String 타입과 임베디드 타입인 Address 타입을 컬렉션에 저장하는 예제이다.

위 코드를 보면 @CollectionTable 애노테이션을 사용하여 테이블의 이름과 외래 키를 지정해 주는 것을 볼 수 있다.

데이터베이스는 컬렉션을 저장할 수 없기 때문에 별도의 테이블을 만들어 일대다 관계로 풀어서 컬렉션을 저장해야 한다. 그렇기 때문에 별도로 만들어질 테이블의 이름과 외래 키를 지정해준다.

위 코드를 실행해보면 Hibernate도 다음과 같이 테이블을 만들어준다.

Hibernate: 

    create table address (
       person_id bigint not null,
        city varchar(255),
        street varchar(255),
        zipcode varchar(255)
    )
Hibernate: 

    create table foods (
       person_id bigint not null,
        food_name varchar(255)
    )
Hibernate: 

    create table Person (
       id bigint not null,
        name varchar(255),
        primary key (id)
    )

값타입 컬렉션도 결국은 값 타입이기 때문에 따로 생명주기를 가지지 않고, 엔티티와 같은 생명주기를 따라간다. 그렇기 때문에 값을 저장하거나 삭제하고자 할 때 일반적으로 컬렉션을 사용하는 것과 마찬가지로 단순히 값을 추가하거나 삭제하기만 하면 데이터베이스에 반영이 된다.

  • 일대다 관계에서 cascade = ALL, orphanRemoval = true를 설정한 것과 같다고 볼 수 있다.
Person person = new Person("kim");
entityManager.persist(person);

//=====저장 과정=====
person.getAddressList().add(new Address("city1", "street1", "1000"));
person.getAddressList().add(new Address("city2", "street2", "1001"));
person.getAddressList().add(new Address("city3", "street3", "1002"));

//======삭제 과정=====
entityManager.flush(); //데이터베이스에 반영
entityManager.clear(); //영속성 컨텍스트 초기화

Pesron findPerson = entityManager.find(Person.class, person.getId());

findPerson.getAddressList().remove(new Address("city1", "street1", "1000"));

테이블을 보면 저장,삭제 모두 정상적으로 실행된 것을 알 수 있다.

하지만 삭제 과정에서 실행된 쿼리를 보면 생각지 못한 쿼리가 나가는 것을 볼 수 있다.

Hibernate: 
    /* delete collection hello.Person.addressList */ 
        delete from address where person_id=?
Hibernate: 
    /* insert collection row hello.Person.addressList */ 
        insert into address(person_id, city, street, zipcode) values (?, ?, ?, ?)
Hibernate: 
    /* insert collection row hello.Person.addressList */ 
        insert into address (person_id, city, street, zipcode) values (?, ?, ?, ?)

단순히 삭제하고자 하는 컬럼만 삭제하는 것이 아닌 관련된 컬럼을 모두 삭제하고, 남아 있는 컬럼을 다시 저장하는 것을 볼 수 있다.

값 타입 컬렉션의 제약

  • 값 타입은 엔티티와 다르게 식별자 개념이 없기 때문에 값을 변경하면 추적이 어렵다.
  • 값 타입 컬렉션에 변경 사항(저장, 삭제)이 발생하면, 소유하는 엔티티와 연관된 모든 데이터를 삭제하고, 현재 남아있는 값을 모두 다시 저장한다.(예제에서는 삭제를 예로 들었지만, 저장도 마찬가지)
  • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야 함 → null 입력 X, 중복 저장 X

값 타입 컬렉션의 대안

  • 값 타입 컬랙션 대신에 일대다 관계를 고려하자.
  • 일대다 엔티티를 만들고, 엔티티에서 값 타입을 사용.
  • cascade와 고아 객체 제거를 설정해서 값 타입 컬렉션처럼 사용.
@Entity
@Table(name = "address")
public class AddressEntity {

    @Id @GeneratedValue
    private Long id;

    private Address address;

    protected AddressEntity() { }

    public AddressEntity(Address address) {
        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;
    }
}

public class Person {
        //....생략 

        @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "member_id")
    private List<AddressEntity> addressList = new ArrayList<>();

        //....생략
}

값 타입은 정말 값 타입이라 판단되고, 정말 단순할 때 사용하는 것이 좋다.

식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 값 타입이 아닌 엔티티로 사용해야 한다.

참고

댓글