본문 바로가기
Framework and Tool/JPA

JPA - 값 타입 - 값 타입 컬렉션

by ocwokocw 2021. 7. 25.

- 참조: 자바 ORM 표준 JPA 프로그래밍

- 값 타입 컬렉션

회원에서 좋아하는 음식들과 주소 내역들을 참조하고 있다고 하면 이런 값 타입을 2 개 이상 저장하기 위해서는 컬렉션에 보관해야 한다. JPA 에서는 값 타입 컬렉션을 맵핑할 수 있는 @ElementCollection 과 @CollectionTable 어노테이션을 제공한다.

 

@Entity
public class Member {

	@Id
	@Column(name = "USER_ID")
	private String id;

	@ElementCollection
	@CollectionTable(name = "FAVORITE_FOODS",
			joinColumns = @JoinColumn(name = "USER_ID"))
	@Column(name = "FOOD_NAME")
	private Set<String> favoriteFoods = new HashSet<>();
	
	@ElementCollection
	@CollectionTable(name = "ADDRESS",
			joinColumns = @JoinColumn(name = "USER_ID"))
	@AttributeOverrides({
		@AttributeOverride(name = "city", column = @Column(name = "USER_CITY")),
		@AttributeOverride(name = "street", column = @Column(name = "USER_STREET")),
		@AttributeOverride(name = "zipcode", column = @Column(name = "USER_ZIPCODE"))})
	private List<Address> addressHistories = new ArrayList<>();

 

favoriteFoods 부터 살펴보자. String 컬렉션 형인 favoriteFoods 의 경우 여러 속성을 가지는 값 타입은 아니다. 이렇게 1 개의 단일 형의 컬렉션인 경우 @Column 을 통해서 필드를 지정할 수 있다. @CollectionTable 은 @JoinTable 처럼 name 은 테이블의 이름을 joinColumns 은 외래키를 지정한다.

 

반면 addressHistory 의 경우 임베디드 타입인 Address 컬렉션 형이다. 만약 테이블의 맵핑정보를 변경하려면 @AttributeOverrides 어노테이션을 사용해주면 된다.

 

@ElementCollection
@CollectionTable(name = "ADDRESS",
		joinColumns = @JoinColumn(name = "USER_ID"))
@AttributeOverrides({
	@AttributeOverride(name = "city", column = @Column(name = "USER_CITY")),
	@AttributeOverride(name = "street", column = @Column(name = "USER_STREET")),
	@AttributeOverride(name = "zipcode", column = @Column(name = "USER_ZIPCODE"))})
private List<Address> addressHistories;

- 값 타입 컬렉션 사용

 

public static void save(EntityManager em) throws CloneNotSupportedException {
	
	EntityTransaction tx = em.getTransaction();
	tx.begin();
	
	Member member = new Member();
	
	member.setId("User#1");
	member.getFavoriteFoods().add("짜장");
	member.getFavoriteFoods().add("짬뽕");
	member.getFavoriteFoods().add("탕수육");
	
	member.getAddressHistory().add(new Address("City#1", "Street#1", "Zipcode#1"));
	member.getAddressHistory().add(new Address("City#2", "Street#2", "Zipcode#2"));
	
	em.persist(member);
	
	tx.commit();
	em.close();
}

 

위의 코드는 좋아하는 음식들과 주소내역을 영속하는 간단한 예제이다. Join 테이블을 사용하는 경우 Cascade 를 지정하지 않는다면 해당 엔티티들을 먼저 영속 시킨 후 member 에 추가 해줘야 하지만 값 타입 컬렉션의 경우 Member 엔티티만 영속시켜도 값 타입 들을 영속시킨다.

 

Hibernate: 
    /* insert com.example.demo.test1.Member
        */ insert 
        into
            member
            (user_id) 
        values
            (?)
Hibernate: 
    /* insert collection
        row com.example.demo.test1.Member.addressHistories */ insert 
        into
            address
            (user_id, user_city, user_street, user_zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row com.example.demo.test1.Member.addressHistories */ insert 
        into
            address
            (user_id, user_city, user_street, user_zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row com.example.demo.test1.Member.favoriteFoods */ insert 
        into
            favorite_foods
            (user_id, food_name) 
        values
            (?, ?)
Hibernate: 
    /* insert collection
        row com.example.demo.test1.Member.favoriteFoods */ insert 
        into
            favorite_foods
            (user_id, food_name) 
        values
            (?, ?)
Hibernate: 
    /* insert collection
        row com.example.demo.test1.Member.favoriteFoods */ insert 
        into
            favorite_foods
            (user_id, food_name) 
        values
            (?, ?)

 

해당 코드를 수행해보면 favorite_foods 와 address 테이블에 Insert SQL 이 잘 수행된것을 확인할 수 있다. 또한 DB 에도 값이 잘 저장된다.

앞에서 1 : N 을 사용할 때 성능에 대해 언급한적이 있다. @OneToMany, @ManyToMany 의 기본 fetch 전략은 Lazy 라고 하였는데, 값 타입 컬렉션들도 여러 개의 값을 가지므로 기본 fetch 전략은 Lazy 이다. fetch 전략은 @ElementCollection 에서 설정할 수 있다.

 

public static void find(EntityManager em) {
	
	EntityTransaction tx = em.getTransaction();
	tx.begin();

	Member member = em.find(Member.class, "User#1");
	
	List<Address> memberAddress = member.getAddressHistory();
	System.out.println("is Lazy Fetch Address");
	
	Set<String> memberFavoriteFoods = member.getFavoriteFoods();
	System.out.println("is Lazy Fetch Food");
	
	memberAddress.forEach(address -> System.out.println(address.getCity()));
	memberFavoriteFoods.forEach(System.out::println);
	
	tx.commit();
	em.close();
}

 

위의 코드를 작성하고 실행하면 결과가 아래와 같이 나온다.

 

Hibernate: 
    select
        member0_.user_id as user_id1_5_0_ 
    from
        member member0_ 
    where
        member0_.user_id=?
is Lazy Fetch Address
is Lazy Fetch Food
Hibernate: 
    select
        addresshis0_.user_id as user_id1_0_0_,
        addresshis0_.user_city as user_cit2_0_0_,
        addresshis0_.user_street as user_str3_0_0_,
        addresshis0_.user_zipcode as user_zip4_0_0_ 
    from
        address addresshis0_ 
    where
        addresshis0_.user_id=?
City#1
City#2
Hibernate: 
    select
        favoritefo0_.user_id as user_id1_4_0_,
        favoritefo0_.food_name as food_nam2_4_0_ 
    from
        favorite_foods favoritefo0_ 
    where
        favoritefo0_.user_id=?
짜장
짬뽕
탕수육

 

순서를 유의해서 살펴보면 값 타입 컬렉션들을 단순 참조하는 시점이 아닌 직접 사용할 때 조회 SQL 이 수행되는것을 확인할 수 있다.


- 값 타입 컬렉션의 항목 수정

값 타입 컬렉션에서 특정 항목을 제거하고 다른 항목을 삽입하고 싶을 때에는 주의해서 사용해야 한다. 

 

member.getAddressHistory().add(new Address("City#1", "Street#1", "Zipcode#1"));
member.getAddressHistory().add(new Address("City#2", "Street#2", "Zipcode#2"));

member.getAddressHistory().remove(new Address("City#1", "Street#1", "Zipcode#1"));
member.getAddressHistory().add(new Address("City#3", "Street#3", "Zipcode#3"));

 

위와 같이 City#1 의 주소를 삭제하고 City#3 의 신규 주소를 추가하여 실행하면 City#2, City#3 의 주소만 남기를 기대하겠지만 저장된 데이터를 조회하면 예상과는 다르게 조회된다.

만약 Java 에 대한 기본기가 탄탄하고, Value Object 와 Entity 에 대한 구분을 명확하게 할 줄 안다면 결과를 조회해보기도 전에 문제점을 알 수 있어야 한다.

 

equals 및 hashCode 를 오버라이드 하지 않았기 때문이다. 엔티티에서 객체를 식별할 때에는 식별자값이 있다. 하지만 주소에는 Id 를 별도로 지정하지 않았고, 이럴 경우 객체가 고유하다고 식별할 수 있는 기준은 메모리 주소이다. 하지만 Value Object 는 엔티티가 아니기 때문에 이렇게 고유성을 판별하면 안된다.

 

@Override
public int hashCode() {
	final int prime = 31;
	int result = 1;
	result = prime * result + ((city == null) ? 0 : city.hashCode());
	result = prime * result + ((street == null) ? 0 : street.hashCode());
	result = prime * result + ((zipcode == null) ? 0 : zipcode.hashCode());
	return result;
}

@Override
public boolean equals(Object obj) {
	if (this == obj)
		return true;
	if (obj == null)
		return false;
	if (getClass() != obj.getClass())
		return false;
	Address other = (Address) obj;
	if (city == null) {
		if (other.city != null)
			return false;
	} else if (!city.equals(other.city))
		return false;
	if (street == null) {
		if (other.street != null)
			return false;
	} else if (!street.equals(other.street))
		return false;
	if (zipcode == null) {
		if (other.zipcode != null)
			return false;
	} else if (!zipcode.equals(other.zipcode))
		return false;
	return true;
}

 

위처럼 equals와 hashCode 를 생성해주자. 이미 IDE 는 이런 중요성을 알고 있기 때문에 자동생성을 제공하고 있다. 위처럼 오버라이드 한 후 H2 에서 조회하면 결과가 기대한대로 나온다.

 


- 값 타입 컬렉션의 제약사항

만약 이미 저장된 값 타입을 영속성 컨텍스트로 불러온 후, 값 타입 컬렉션 요소 하나를 remove 하고 새로운 요소 하나를 추가하면 어떤식으로 동작할까? 해당 요소 하나만 지우고 다른 요소 하나를 추가할까?

 

Member member = em.find(Member.class, "User#1");

member.getAddressHistory().remove(new Address("City#1", "Street#1", "Zipcode#1"));
member.getAddressHistory().add(new Address("City#3", "Street#3", "Zipcode#3"));

 

위와 같이 코드를 작성하고 Log 를 보면 아래와 같이 결과가 나온다.

 

Hibernate: 
    /* delete collection com.example.demo.test1.Member.addressHistory */ delete 
        from
            address 
        where
            user_id=?
Hibernate: 
    /* insert collection
        row com.example.demo.test1.Member.addressHistory */ insert 
        into
            address
            (user_id, user_city, user_street, user_zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row com.example.demo.test1.Member.addressHistory */ insert 
        into
            address
            (user_id, user_city, user_street, user_zipcode) 
        values
            (?, ?, ?, ?)

 

address 를 삭제할 때 user_id 와 더불어서 해당 요소만 삭제하려면 city, street, zipcode 조건이 where 절에 있어야 하는데 없다. 연관된 데이터를 Clear 하고 DB에 저장해야 할 address 들에 대해 insert SQL 을 수행한다.

 

만약 값 타입 컬렉션의 목록이 많고 빈번하게 수정이 일어난다면 1 : N 관계로 맵핑을 고려해야 한다. 값 타입을 만약 1 : N 관계처럼 사용하고 싶다면 식별자가 존재하지 않으므로 모든 컬럼들을 묶어서 복합 키로 구성해야하며, 이렇게 되면 null 값도 할당이 불가능하다.

 

따라서 위와 같은 상황을 맞이하게 되면 1 : N 관계로 전환하고, Cascade 및 고아 객체 제거 기능 적용을 검토해야 한다.


- 마치면서

이번 절에서는 JPA 에서 값 타입 컬렉션을 어떻게 맵핑하는지 살펴보았다. 사용법도 잘 숙지해야겠지만 오히려 가장 중요한것은 Value Object 와 Entity 를 구분할줄 아는것이다. JPA 에 아무리 익숙하고 맵핑을 잘한다고 해도 Entity 와 ValueObject 를 혼동해서 사용하면 아무런 소용이 없다.

댓글