jpa와 데이터타입

jpa를 기준으로 본 데이터 타입

  • jpa를 기준으로 데이터 타입은 엔티티와 엔티티 아닌 것으로 구분한다.

entity 타입

  • @Entity 로 정의하는 객체
  • 데이터가 변하더라도 식별자를 통해 추적 가능.

값 타입

  • int, Integer, String 등 자바의 기본 타입이나 객체
  • 식별자가 없으며 추적 불가능.

값 타입의 분류

  • 기본값 타입(기본타입, wrapper type, String)
  • 임베디드 타입 embedded type
  • 컬렉션 값 타입 collection value type

데이터의 공유로 인한 사이드이펙트

  • 기본값 타입은 공유로 인한 문제가 발생하지 않는다. wrapper 클래스는 참조 변수를 공유할 수 있지만 데이터를 수정할 수 없다.
Integer a = 10;
Integer b = a;
a = 20;

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

임베디드 타입

@Entity
@Setter
@Getter
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;

//    private LocalDateTime startDate;
//    private LocalDateTime endDate;

    @Embedded
    private Period wordPeriod;

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

    @Embedded
    private Address homeAddress;

    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name = "city", column = @Column(name = "WORK_CITY")),
            @AttributeOverride(name = "street", column = @Column(name = "WORK_STREEET")),
            @AttributeOverride(name = "zipcode", column = @Column(name = "WORK_ZIPCODE"))
    })
    private Address wordAddress;

}

@Getter
@Setter
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
public class Address {
    private String city;
    private String street;
    private String zipcode;
}

@Getter
@Setter
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
public class Period {
    private LocalDateTime startDate;
    private LocalDateTime endDate;
}
Member member = new Member();
member.setName("user");
member.setHomeAddress(new Address("seoul", "some-gil","1234"));
member.setWordAddress(new Address("busan", "any-gil","56547"));
member.setWordPeriod(new Period());

em.persist(member);
  • 특정 필드값을 묶어서 하나의 클래스로 만든다. 이를 jpa에서는 embedded 타입이라 하며 해당 객체에는 @Embedded로 어너테이션을 붙이고, 해당 클래스는 @Embeddable을 붙인다.
  • 기본생성자를 꼭 필요로 한다. NoArgsConstructor을 사용했다.
  • 동일한 클래스를 중복사용할 경우 AttributeOverrides 어너테이션을 사용한다.

임베디드 타입의 특징과 장점

  • 임베디드 타입의 사용과 관계없이 테이블의 상태는 동일하다.
  • 엔티티 객체를 효과적으로 모델링할 수 있다. 세밀하게 맵핑 가능하다.
  • 잘 설계된 ORM 어플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.

임베디드 타입과 상속 타입 중 무엇을 사용하는가?

  • 아래의 김영한 선생님의 답변이 있다. (https://www.inflearn.com/questions/18578)
CreatedDate, UpdatedDate 둘을 합쳐서 하나의 임베디드 타입으로 정의하는 것과 @MappedSuperclass로 정의하는 것의 차이가 궁금하신 거지요?

결국 상속을 사용하는 것과 위임을 사용하는 것의 차이 입니다.

객체지향의 일반적인 법칙을 따르면 상속보다는 위임이 더 좋기 때문에 위임을 사용하겠지만, 이 경우는 상속을 사용하는게 더욱 편리합니다.

임베디드 타입으로 만들면 예를 들어서 다음과 같이 만들게 됩니다.

class TraceDate {

  TYPE createdDate;

  TYPE updatedDate;

}

이런 경우 JPQL 쿼리를 하려면 다음과 같이 항상 traceDate라는 식으로 임베디드 타입을 적어주어야 합니다.

select m from Member m where m.traceDate.createdDate > ?

상속을 사용하면 다음과 같이 간단하고 쉽게 풀립니다.

select m from Member m where m.createdDate > ?

결국 둘중 선택이기는 합니다만, 편리함과 직관성 때문에, 저는 이 경우 상속을 사용합니다^^

감사합니다.

값 타입 공유 참조의 문제

  • 임베디드 타입을 여러 엔티티에서 공유하면 위험하다.
  • 사이드 이펙트가 발생한다.

final Address seoul = new Address("seoul", "some-gil", "1234");

Member member = new Member();
member.setName("user");
member.setHomeAddress(seoul);
em.persist(member);

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

member.getHomeAddress().setCity("newCity");
Hibernate: 
    /* insert jpa7_datatype.b_share.Member
        */ insert 
        into
            Member
            (city, street, zipcode, name, endDate, startDate, MEMBER_ID) 
        values
            (?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    /* insert jpa7_datatype.b_share.Member
        */ insert 
        into
            Member
            (city, street, zipcode, name, endDate, startDate, MEMBER_ID) 
        values
            (?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    /* update
        jpa7_datatype.b_share.Member */ update
            Member 
        set
            city=?,
            street=?,
            zipcode=?,
            name=?,
            endDate=?,
            startDate=? 
        where
            MEMBER_ID=?
Hibernate: 
    /* update
        jpa7_datatype.b_share.Member */ update
            Member 
        set
            city=?,
            street=?,
            zipcode=?,
            name=?,
            endDate=?,
            startDate=? 
        where
            MEMBER_ID=?
  • member에 대한 address를 변경하려고 했다. 그러나 member2에 대한 address도 같이 변경되었다.
  • 매우 치명적인 사이드이펙트이며 수정하기가 매우 어렵다.
final Address seoul2 = new Address(seoul.getCity(), seoul.getStreet(), seoul.getZipcode());

Member member2 = new Member();
member2.setName("user");
member2.setHomeAddress(seoul2);
em.persist(member2);
  • 그러므로 위와 같이 객체의 복사를 통해 코드를 짜야 한다.
  • 하지만 자바에서 직접 정의한 값 타입은 자바에서는 기본타입이 아니라 객체 타입이다.
  • 객체 타입의 참조를 막고 복사를 강제할 방법은 존재하지 않는다.
  • 객체의 공유는 피할 수 없다.

불변 객체

  • 객체 타입을 수정할 수 없도록 부작용을 원천 차단.
  • 값 타입은 불변 객체로 설계해야 한다.
  • 생성자로만 값을 설정하고 Setter를 만들어서는 안된다.
  • Integer, String은 자바가 제공하는 대표적인 불변객체이다.
@Getter
//@Setter
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
public class Address {
    private String city;
    private String street;
    private String zipcode;
}
final Address seoul = new Address("seoul", "some-gil", "1234");

Member member = new Member();
member.setName("user");
member.setHomeAddress(seoul);
em.persist(member);

// member.getHomeAddress().setCity("newCity"); // 세터 사용 불가능

final Address busan = new Address("busan", seoul.getStreet(), seoul.getZipcode());
member.setHomeAddress(busan); // 임베디드 타입 자체를 교체한다.

tx.commit();
  • 세터를 없애고 생성자를 통해서만 값을 넣는다. (그 이외에 불변객체를 만드는 다양한 방법 중 하나를 선택한다. )
  • 모든 임베디드 타입은 반드시 세터를 막아야 한다.

값 타입 컬렉션

  • 하나의 엔티티로 사용하기에는 내용이 좁지만, 컬렉션의 형태로 다양한 값을 가져야 하는 경우가 있다. 회원이 있으면, 회원의 선호하는 음식이 있을 수 있으며, 이를 객체에서는 List<String> favoriteFood; 로 필드에 넣을 수 있다.
  • 하지만 관계형 데이타베이스에서는 칼럼에 컬렉션을 넣을 수 없다. 최근에는 JSON을 통해 컬렉션을 넣기도 하지만, 기본적으로는 불가능하다.
  • 이를 값 타입 컬렉션으로 해소한다.

@Entity
@Setter
@Getter
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;

    @ElementCollection
    @CollectionTable(
            name = "FAVORITE_FOOD"
            , joinColumns = @JoinColumn(name = "MEMBER_ID")
    )
    @Column(name = "FOOD_NAME")
    private Set<String> favoriteFoods = new HashSet<>();

    @Embedded
    private Address homeAddress;

    @ElementCollection
    @CollectionTable(
            name = "ADDRESS"
            , joinColumns = @JoinColumn(name = "MEMBER_ID")
    )
    private List<Address> addressHistory = new ArrayList<>();
}

@Getter
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
public class Address {
    private String city;
    private String street;
    private String 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(city, address.city) && Objects.equals(street, address.street) && Objects.equals(zipcode, address.zipcode);
    }

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

  • 위의 코드는 선호하는 음식과 주소 내역을 값타입으로 가진다.
  • Embedded 타입도 가능하다.
  • CollectionTable 을 통해 새로운 테이블을 생성하고 해당 테이블의 FK와 PK를 객체의 Id로 하고 한다.
  • 값 타입의 출력은 지연로딩을 기본으로 한다.
  • 값 타입 컬렉션은 엔티티의 값 타입과 같이 생명주기를 엔티티에 의존한다. cascade.all 과 고아객체 제거 기능을 이미 가지고 있는 것과 같다.

  • 실제로 위의 코드를 구현하면 아래와 같다.
Member member = new Member();
member.setName("kim");
member.setHomeAddress(new Address("seoul", "some-gil", "12345"));
member.getAddressHistory().add(new Address("Cairo", "some-gil", "12345"));
member.getAddressHistory().add(new Address("San Tiago", "some-gil", "12345"));

member.getFavoriteFoods().add("김치");
member.getFavoriteFoods().add("치킨");

em.persist(member);

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

System.out.println("=========A==========");
final Member findMember = em.find(Member.class, member.getId());

System.out.println("==========B=========");
final List<Address> addressHistory = findMember.getAddressHistory();

for (Address address : addressHistory) {
    System.out.println("address = " + address.getCity());
}
Hibernate: 
    /* insert jpa7_datatype.c_collection.Member
        */ insert 
        into
            Member
            (city, street, zipcode, name, MEMBER_ID) 
        values
            (?, ?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row jpa7_datatype.c_collection.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row jpa7_datatype.c_collection.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row jpa7_datatype.c_collection.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
Hibernate: 
    /* insert collection
        row jpa7_datatype.c_collection.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
=========A==========
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_2_0_,
        member0_.city as city2_2_0_,
        member0_.street as street3_2_0_,
        member0_.zipcode as zipcode4_2_0_,
        member0_.name as name5_2_0_ 
    from
        Member member0_ 
    where
        member0_.MEMBER_ID=?
==========B=========
Hibernate: 
    select
        addresshis0_.MEMBER_ID as member_i1_0_0_,
        addresshis0_.city as city2_0_0_,
        addresshis0_.street as street3_0_0_,
        addresshis0_.zipcode as zipcode4_0_0_ 
    from
        ADDRESS addresshis0_ 
    where
        addresshis0_.MEMBER_ID=?

address = Cairo
address = San Tiago
  • 각 각의 테이블을 생성하여 insert함을 볼 수 있다.
  • 지연로딩으로서 강제로 초기화 할 때 프록시가 엔티티를 호출함을 확인할 수 있다.
System.out.println("==========D=========");
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("라면");

findMember.getAddressHistory().remove(new Address("seoul", "some-gil", "12345")); // 이 경우 반드시 동등성 비교를 하도록 equals를 override 해야 한다.
findMember.getAddressHistory().add(new Address("new Seoul", "some-gil", "12345"));
==========D=========
Hibernate: 
    select
        favoritefo0_.MEMBER_ID as member_i1_1_0_,
        favoritefo0_.FOOD_NAME as food_nam2_1_0_ 
    from
        FAVORITE_FOOD favoritefo0_ 
    where
        favoritefo0_.MEMBER_ID=?
Hibernate: 
    /* delete collection jpa7_datatype.c_collection.Member.addressHistory */ delete 
        from
            ADDRESS 
        where
            MEMBER_ID=?
Hibernate: 
    /* insert collection
        row jpa7_datatype.c_collection.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row jpa7_datatype.c_collection.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row jpa7_datatype.c_collection.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* delete collection row jpa7_datatype.c_collection.Member.favoriteFoods */ delete 
        from
            FAVORITE_FOOD 
        where
            MEMBER_ID=? 
            and FOOD_NAME=?
Hibernate: 
    /* insert collection
        row jpa7_datatype.c_collection.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
  • 값을 변경하는 경우 임베디드 타입과 동일하게 불변 객체로 다뤄야 한다.
  • 값 타입의 경우 컬렉션을 수정하는 것과 동일하기 때문에, add 및 remove 등 컬렉션 매서드를 사용한다. 객체 형태의 데이터를 삭제할 때는, 그것의 비교를 동등성을 기반으로 해야하므로, 반드시 equals를 override 해야 한다. 그렇지 않으면 계속 insert만 된다.
  • 값 타입 컬렉션을 변경할 때, delete를 수행한 후 insert를 수행함을 확인할 수 있다. 이로 인하여 값의 변경에 대한 추적이 어렵다. 언제나 삭제되고 새로 생성된다.
  • 값 타입은 엔티티와 다르게 식별자가 없다. 이로 인하여 update가 불가능하여 관리가 어렵다.

값 타입의 사용 제한

  • 정말로 단순하고 데이터가 사라져도 전혀 문제가 없는 것에 대해서만 사용한다. 선호하는 음식 등 매우 단순하 것을 사용한다.
  • 실무에서는 엔티티를 만드는 것으로 해결한다.
@Entity
@Setter
@Getter
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;

    @Embedded
    private Address homeAddress;

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

@Getter
@Entity
@NoArgsConstructor
@Table(name = "ADDRESS")
public class AddressEntity {

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

    @Embedded
    private Address address;

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

    public void setId(Long id) {
        this.id = id;
    }
}
Member member = new Member();
member.setName("kim");
member.setHomeAddress(new Address("seoul", "some-gil", "12345"));

member.getAddressHistory().add(new AddressEntity("Cairo", "some-gil", "12345"));
member.getAddressHistory().add(new AddressEntity("San Tiago", "some-gil", "12345"));

em.persist(member);
Hibernate: 
    /* insert jpa7_datatype.d_collection2entity.Member
        */ insert 
        into
            Member
            (city, street, zipcode, name, MEMBER_ID) 
        values
            (?, ?, ?, ?, ?)
Hibernate: 
    /* insert jpa7_datatype.d_collection2entity.AddressEntity
        */ insert 
        into
            ADDRESS
            (city, street, zipcode, ADDRESS_ID) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert jpa7_datatype.d_collection2entity.AddressEntity
        */ insert 
        into
            ADDRESS
            (city, street, zipcode, ADDRESS_ID) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* create one-to-many row jpa7_datatype.d_collection2entity.Member.addressHistory */ update
        ADDRESS 
    set
        MEMBER_ID=? 
    where
        ADDRESS_ID=?
Hibernate: 
    /* create one-to-many row jpa7_datatype.d_collection2entity.Member.addressHistory */ update
        ADDRESS 
    set
        MEMBER_ID=? 
    where
        ADDRESS_ID=?
  • 위와 같은 방식으로 진행한다.
  • 다만 OneToMany 단방향 맵핑이 되며 update 쿼리가 발생한다.