제육's 휘발성 코딩
반응형

목차

  • 기본 값 타입
  • 임베디드 타입 (복합 값 타입)
  • 값 타입 불변 객체
  • 값 타입 비교
  • 값 타입 컬렉션
  • 예제

기본 값 타입

JPA 데이터 타입

  • 엔티티 타입
    • @Entity로 정의하는 객체
    • 데이터가 변해도 식별자로 지속해서 추적 가능
    • 회원 엔티티의 키나 나이 값을 변경해도 식별자로 인식 가능
  • 값 타입
    • int, Integer, String 처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체
    • 식별자가 없고 값만 있으므로 변경 시 추적 불가

값 타입 분류

  • 기본 값 타입
    • 자바 기본 타입 (int, double)
    • 래퍼 클래스 (Integer, Long)
    • String
  • 임베디드 타입 (복합 값 타입)
  • 컬렉션 값 타입

기본 값 타입

  • 생명주기가 엔티티에 의존한다.
    • 회원을 삭제하면 나이, 필드도 함께 삭제와 같다.
  • 기본 값 타입은 복사하지, 공유되지 않는다
    • 회원 이름 변경 시 다른 회원 이름도 함께 변경되면 안된다.
    • 자바에서 String 이나 Integer 같은 래퍼클래스도 공유 가능한 객체이지만 변경할 수 없다.

임베디드 타입

  • 새로운 값 타입을 직접 정의할 수 있다.
  • 임베디드 타입도 기본 값처럼 변경할 수 없도록 사용한다.

image

  • 새로운 타입을 만들어서 비슷한 요소들을 묶어 가독성 좋게 만들 수 있다.

임베디드 타입 사용법

  • @Embeddable : 값 타입을 정의하는 곳에 표시
  • @Embedded : 값 타입을 사용하는 곳에 표시
  • 기본 생성자 필수

임베디드 타입의 장점

  • 재사용 가능하고, 응집도가 높다.
  • 값 타입만 사용하는 의미 있는 메서드를 만들 수 있고, 임베디드를 포함한 모든 값 타입은 소유한 엔티티에 생명주기를 의존한다.

임베디드 타입과 테이블 매핑

image

  • 테이블 매핑은 다음과 같이 임베디드 안에 있는 값 타입을 그대로 넣는다.

임베디드 사용 전 멤버 엔티티

package hellojpa;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
public class Member {

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

    // 기간 Period
    private LocalDateTime startDate;
    private LocalDateTime endDate;

    // 주소 Address
    private String city;
    private String street;
    private String zipcode;

}

임베디드 사용 후 엔티티

멤버 엔티티

package hellojpa;

import javax.persistence.*;

@Entity
public class Member {

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

    // 기간 Period
    @Embedded
    private Period workPeriod;

    // 주소 Address
    @Embedded
    private Address homeAddress;
}
  • 임베디드를 적용할 클래스를 만들어서 @Embedded 를 적용 시킨다.

주소 엔티티

package hellojpa;

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;
    }
}
  • @Embeddable 적용과 기본 생성자는 반드시 지켜야한다.

JpaMain

package hellojpa;

import javax.persistence.*;
import java.util.List;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        try {
            Member member = new Member();
            member.setName("hello");
            member.setHomeAddress(new Address("city", "street", "10000"));
            member.setWorkPeriod(new Period());
            em.persist(member);
            tx.commit();
        } catch (Exception e) {
            tx.rollback();
            e.printStackTrace();
        } finally {
            em.close();
        }
        emf.close();
    }
}
  • 다음과 같이 실행해보면 정상적으로 임베디드 타입이 만들어진 것을 볼 수 있다.

임베디드 타입 연관관계

image

  • 엔티티 타입 -> 임베디드 타입 -> 임베디드 타입의 연관 관계 가능
  • 엔티티 타입 -> 임베디드 타입 -> 엔티티 타입의 연관 관계 가능

@AttributeOverride

@Embedded
private Address homeAddress;

@Embedded
@AttributeOverrides({
  @AttributeOverride(name = "city", column = @Column(name = "WORK_CITY")),
  @AttributeOverride(name = "street", column = @Column(name = "WORK_STREET")),
  @AttributeOverride(name = "zipcode", column = @Column(name = "WORK_ZIPCODE"))
})
private Address workAddress;
  • 한 엔티티에서 같은 값 타입을 사용하려면 @AttributeOverrides를 사용해야 한다.

값 타입과 불변 객체

값 타입은 객체 세상을 단순화하려고 만든 개념이다. 따라서 단순하고 안전하게 다룰 수 있어야 한다.

image

  • 임베디트 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험할 수 있다.
package hellojpa;

import javax.persistence.*;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        try {
            Address address = new Address("city", "street", "10000");

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

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

            member.getHomeAddress().setCity("newCity");
            tx.commit();
        } catch (Exception e) {
            tx.rollback();
            e.printStackTrace();
        } finally {
            em.close();
        }
        emf.close();
    }
}
  • 다음과 같은 예시를 보자. 같은 주소를 가진 회원 중 한 회원이 주소를 바꾸면 값이 모두 바뀔 수 있다. 즉. 부작용(side effect)이 발생한다. 같이 공유하고 싶은 경우 Address 를 임베디드 타입이 아닌, 엔티티로 설계해야한다.
package hellojpa;

import javax.persistence.*;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        try {
            Address address = new Address("city", "street", "10000");

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

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

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

            member.getHomeAddress().setCity("newCity");
            tx.commit();
        } catch (Exception e) {
            tx.rollback();
            e.printStackTrace();
        } finally {
            em.close();
        }
        emf.close();
    }
}
  • 다음과 같이 새로운 인스턴스를 생성해서 넣어줘야 독립적으로 관리를 할 수 있다.

직접 정의한 값 타입은 컴파일 상에서 오류를 잡을 수 없어, 항상 값을 복사해서 사용해야한다는 한계가 있다.

값 타입을 애초에 불변 객체(Setter 생성X)로 설계하여 수정할 수 없게 만들어 부작용을 사전에 차단하자.

값을 변경하고 싶은 경우 새로운 인스턴스를 생성해서 사용하자.

값 타입 비교

int a = 10;
int b = 10;
System.out.println("(a==b) = " + (a==b));

Address address1 = new Address("city", "street", "10000");
Address address2 = new Address("city", "street", "10000");

System.out.println("address = " + (address1==address2));
System.out.println("address = " + (address1.equals(address2)));
  • 프리미티브형은 값이 같지만, 논프리미티브타입은 참조값을 이용하기 때문에 다르다. 마지막 .equals()는 어떨까? 당연히 false가 된다. 왜냐하면 Address 객체에서 별도로 equals()를 재정의 하지 않았기 때문이다. 재정의하여 사용하면 true를 반환한다.

동일성(Identity) 비교 : 인스턴스의 참조 값을 비교, == 사용

동등성(equivalence) 비교 : 인스턴스의 값을 비교, equals() 사용

값 타입 컬렉션

image

  • 데이터베이스는 테이블안에 컬렉션을 저장할 수 없다. 즉, 컬렉션을 저장하기 위한 별도의 테이블이 필요하다. 즉, 다음과 같이 Member를 기준으로 새로운 컬렉션 테이블을 만들어야 한다.
  • 값 타임 컬렉션이라는 것을 알려주는@ElementCollection와 테이블 명을 지정하는 @CollectionTable 사용한다.

값 타입 컬렉션 예제

Member 엔티티

package hellojpa;

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;

    @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> addressHistory = new ArrayList<>();
}
  • JoinColumn 은 외래키로 값을 적용 시켜준다. favoriteFoods는 필드가 한개이다. 필드가 한 개인 경우에만 한해서 컬럼명을 변경(디폴트는 컬렉션명)할 수 있다. 즉, Address 타입은 필드가 여러개이므로 변경할 수 없다.

JpaMain

package hellojpa;

import javax.persistence.*;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        try {
            Member member = new Member();
            member.setName("member1");
            member.setHomeAddress(new Address("homeCity", "street", "20000"));

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

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
            e.printStackTrace();
        } finally {
            em.close();
        }
        emf.close();
    }
}
  • Member 만 persist해도 연결된 값타입 컬렉션도 변경이 된다.

image

  • 다음과 같이 결과가 정상적으로 DB에 저장된 것을 확인할 수 있다.
Member member = new Member();
member.setName("member1");
member.setHomeAddress(new Address("homeCity", "street", "20000"));

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

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();
  • 컬렉션들은 기본적으로 지연로딩 전략을 사용한다.

컬렉션 값 변경

Address a = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", a.getStreet(), a.getZipcode()));

// 치킨 -> 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
  • 객체 타입의 컬렉션은 새로운 인스턴스를, String 타입의 컬렉션은 삭제 후 삽입을 통해 값을 수정한다. 마치 cascade + 고아객체 제거를 설정한 것과 비슷하다.
// 삭제를 위해선 동등성(equals 재정의)필요
findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressHistory().add(new Address("newCity1", "street", "10000"));

/* 출력 로그 
Hibernate: 
    /* delete collection hellojpa.Member.addressHistory */ delete 
        from
            ADDRESS 
        where
            MEMBER_ID=?
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
*/
  • Address와 관련되 데이터를 모두 삭제 후 남아 있는 데이터의 개수 만큼 INSERT 쿼리문을 날린다. 즉, 현재 Address에 남아 있는 데이터는 2개이므로 INSERT 쿼리문을 2번 실행을 한 것이다.

값 타입은 엔티티와 다르게 식별자 개념이 없어, 변경하면 추적이 어렵다.

값 타입 컬렉션에 변경 사항이 발생하면 주인 엔티티와 관련된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.

값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. 즉, null 과 중복이 있으면 안된다.

값 타입 컬렉션 대안

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

일대다 관계 예시

Address 엔티티
package hellojpa;

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

@Entity
public class AddressEntity {

    @Id @GeneratedValue
    private Long id;

    private Address address;

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

    public AddressEntity() {
    }
}
Member
package hellojpa;

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;

    @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> addressHistory = new ArrayList<>();

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "MEMBER_ID")
    private List<AddressEntity> addressHistory = new ArrayList<>();
}
  • addressHistory를 일대다관계로 설계하고, cascade와 고아 객체 제거를 적용한다.
JpaMain
package hellojpa;

import javax.persistence.*;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        try {
            Member member = new Member();
            member.setName("member1");
            member.setHomeAddress(new Address("homeCity", "street", "20000"));

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

            em.flush();
            em.clear();

            System.out.println("===== START =====");
            Member findMember = em.find(Member.class, member.getId());
            tx.commit();
        } catch (Exception e) {
            tx.rollback();
            e.printStackTrace();
        } finally {
            em.close();
        }
        emf.close();
    }
}
  • 다음과같이 컬렉션 값 타입 대신 일대다로 풀어낼 수 있다.

정말 단순한 경우에만 값타입을 사용하고 이 외에는 일대다로 풀어내자.

값 타입은 정말 값 타입이라 판단될 때만 사용한다. 식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아닌 엔티티다.

예제

image

  • 값 타입을 사용할만한 예제를 만들어보자. Member의 Address와 Delivery의 Address를 값타입으로 만들어보자.

Address 값 타입 생성

package jpabook.jpashop.domain;

import javax.persistence.Column;
import javax.persistence.Embeddable;
import java.util.Objects;

@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();
    }

    public String getCity() {
        return city;
    }

    private void setCity(String city) {
        this.city = city;
    }

    public String getStreet() {
        return street;
    }

    private void setStreet(String street) {
        this.street = street;
    }

    public String getZipcode() {
        return zipcode;
    }

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

    @Override
    public int hashCode() {
        return Objects.hash(getCity(), getStreet(), getZipcode());
    }
}
  • @Embeddable을 정의해서 임베디드 값 타입 클래스를 생성하고, 값을 수정할 때 동등성 비교를 위한 equalshashCode를 재정의한다. 참고로 equals를 만들 때 필드값이 아닌 getter로 비교하는 경우 추후 프록시 상태의 객체에도 접근할 수 있다.
  • DeliveryMember@Embedded private Address address를 적용해준다.

본 포스팅은 인프런-김영한님의 '자바 ORM 표준 JPA 프로그래밍 - 기본편'을 참고하여 정리했습니다.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^