WEB/JPA

김영한 (ORM 표준 JPA 프로그래밍 9) 값 타입

Tony Lim 2021. 3. 16. 16:22
728x90

 

JPA의 데이터 타입 분류

엔티티 타입

  • @Entity로 정의하는 객체
  • 데이터가 변해도 식별자로 지속해서 추적 가능
  • 예) 회원 엔티티의 키나 나이 값을 변경해도 식별자로 인식 가능 

값타입

  • int, Integer, String  처럼 단순히 값으로 사용하는 자바 기본타입이나 객체
  • 식별자가 없고 값만 있으므로 변경시 추적 불가
  • 예) 숫자 100을 200으로 변경하면 완전히 다른 값으로 데체
  • 생명주기를 엔티티에 의존

 

임베디드 타입

새로운 값 타입을 직접 정의 할 수 있음

주로 기본 값 타입들을 모아서 만들어서 복합 값 타입이라고 함, int,String 과 같은 값 타입

이런식으로 데이터들을 합쳐서 추상화 시켜서 좀 더 이해하기 쉽다. 

JPA에서는 이것들을 @Embeddable (= 값타입을 정의하는곳에 표시), @Embedded (= 값 타입을 사용하는곳에 표시) 을 사용해서 표시한다.

테이블 입장에서는 달라질 것이 없다. 매핑만 해줄 뿐이다.

이렇게 묶었을 때 해당 객체와 연관된 메소드를 가지게 할 수 있는 장점이 있다.

 

    //기간 Period
    private LocalDateTime startDate;
    private LocalDateTime endDate;
@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;
    }
}
    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;
    
    @Embedded
    @AttributeOverrides({
    	@AttributeOverride(name="city",
        	column=@Column(name= "WORK_CITY"))})
    private Address workAddress;

임베디트 타입은 엔티티의 값일 뿐임으로 매핑하는 테이블은 그대로이다. 

잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많음

@AttributeOveride 를 통해 중복해서 사용할 수 있다. 이 경우에는 별도의 column이 지정한 이름으로 추가적으로 생겨난다.

 

 

 

값 타입과 불변 객체

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

기본타입의 경우는 복사할떄 동시에 여러개가 변하는 경우가 있을 수 없다. 하지만 객체는 그것이 가능하다.

Address 같은 임베디드 타입이 여러 엔티티에서 공유하면 위험해진다.

update쿼리가 2번 나가면서 2 member 모두다 변경이 되어버렸다.

이걸 방지하기위해 새로운 new Address로 만들어서 주입하는 방법이 있다.

애초에 값 타입은 불변객체로 만들어야한다.

불변 객체 = 생성 시점 이후 절대 값을 변경할 수 없는 객체 , 생성자로만 값을 설정, setter를 안 만들면 된다.

 

 

값 타입 컬렉션

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

Set<String> 처럼 안에 들어간 객체가 속성이 여러개가 아니면 Column으로 이름 매핑이 가능하다

    create table FAVORITE_FOOD (
       MEMBER_ID bigint not null,
        favoriteFoods varchar(255)
    )
    create table ADDRESS (
       MEMBER_ID bigint not null,
        city varchar(255),
        street varchar(255),
        zipcode varchar(255)
    )

값 타입을 하나 이상을 저장할때 @ElementCollection , @CollectionTable을 통해 사용한다.

이런식으로 아예 새로운 테이블이 형성이 된다. 

            Member member = new Member();
            member.setUsername("member1");
            member.setHomeAddress(new Address("city1","street","10000"));

            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("피자");
            member.getFavoriteFoods().add("피자");

            member.getAddressHistory().add(new Address("old1","street","10"));
            member.getAddressHistory().add(new Address("old2","street2","102"));
            
            em.persist(member);

            tx.commit();

모두다 값타입들이기 때문에 member랑 lifeCycle을 같이 동반한다. 그래서 따로 persist 해주지 않아도 문제 없다.

서로 다 동일한 Member_id 로 들어간것을 확인할 수 있다.

값타입은 조회 될떄 지연로딩할 이유가 없으므로 그냥 같이 조회되지만 값타입 컬렉션은 지연로딩을 사용한다.

위에서도 언급했지만 값타입을 변경하고 싶을 때는 항상 그냥 새로운 인스턴스를 만들어서 그냥 갈아껴줘야한다.

            findMember.getFavoriteFoods().remove("치킨");
            findMember.getFavoriteFoods().add("한식");
Hibernate: 
    /* delete collection row hellojpa.Member.favoriteFoods */ delete 
        from
            FAVORITE_FOOD 
        where
            MEMBER_ID=? 
            and favoriteFoods=?
Hibernate: 
    /* insert collection
        row hellojpa.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, favoriteFoods) 
        values
            (?, ?)

알아서 delete 쿼리를 날린후에 insert 쿼리를 날린다.

하지만 단순 String이 아닌 Address 컬렉션의 경우는 다르다.

    @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);
    }
            findMember.getAddressHistory().remove(new Address("old2","street2","102"));
            findMember.getAddressHistory().add(new Address("newcity1","street2","102"));

지울려면 이런식으로 객체를 그대로 넣어줘야 제대로 지워진다. 이때 equals와 hashcode가 제대로 구현이 되어있지 않으면 사고가 난다. 하지만 쿼리가 이상하다.

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
            (?, ?, ?, ?)

delete 1 번 insert 2번 쿼리가 나간것을 확인 -> 아예 테이블에서 주인 엔티티와 관련된 row들을 다 지우고 새로 갈아끼기 떄문에 성능이 떨어질 수 있다.  

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

Address를 추적하는 방법으로는 @OrderColumn을 써서 값 타입 컬렉션 테이블에 order column을 추가해서 구분할 수 있도록해주는데 이것도 위험하다.
Collection element의 순서 column이 추가 된것이다. 0,1,2 이런식으로 쭉 증가한다.

중간에 하나빠지면 null도 들어가고 아무튼 쓰지 말라했다. 

값 타입 컬렉션의 대안은 = 일대다 관계를 고려해야한다. 현재는 Address table의 PK가 존재하지 않으므로 DB 운영이 어려운 상태이다. -> 4개의 column으로 pk를 내가 만들어줘야 한다.

@Entity
@Table(name = "ADDRESS")
public class AddressEntity
{
    @Id @GeneratedValue
    private Long id;

    private Address address;

    public AddressEntity()
    {
    }

    public AddressEntity(Address address)
    {
        this.address = address;
    }

    public AddressEntity(String city, String street, String zipcode)
    {
        address = new Address(city,street,zipcode);
    }
//    @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<>();
            findMember.getAddressHistory().remove(new AddressEntity("old2","street2","102"));
            findMember.getAddressHistory().add(new AddressEntity("newcity1","street2","102"));

기존의 값 컬렉션에서 아예 하나의 AddressEntity를 생성하여 일대다 단방향 매핑관계를 만들어주었다. 

AddressEntity 의 테이블이름을 Address로 해주었고 그 결과 PK가 생성이 된 테이블이 생성되었다.

그렇다면 값 타입 컬렉션은 언제 쓰는가? 진짜 단순한 경우에만 써야한다.

 

실전 예제 - 6. 값 타입 매핑

@Embeddable
public class Address
{

    private String city;
    private String street;
    private String zipcode;
    
    private String fullAddress()
    {
       return getCity()+getStreet()+getZipcode(); 
    }

    public String getCity()
    {
        return city;
    }


    public String getStreet()
    {
        return street;
    }


    public String getZipcode()
    {
        return 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());
    }
}

equals 메소드를 오버라이딩 할때 getter를 통해서 값을 참조하는것이 좋다. 그냥 직접필드를 참조하게 되면 프록시인 경우에는 비교가 불가능하기 때문이다. 그리고 이것이 안전한 방법이다.

또한 이렇게 값 타입을 사용하게되면 그 클래스안에 의미있는 메소드들을 만들어 줄 수 있다.

728x90