beadss / jpa-study

jpa슽터디입니다
1 stars 2 forks source link

9장 정리 #26

Open joont92 opened 5 years ago

joont92 commented 5 years ago

JPA의 데이터 타입은 크게 엔티티 타입값 타입이 있다.
엔티티 타입은 @Entity로 정의하는 객체이고, 값 타입은 int, Integer, String 처럼 단순 값으로 사용하는 자바 기본 타입이나 객체를 말한다.
(영한님은 여기서 엔티티 타입은 살아있는 생물이고, 값 타입은 단순한 수치 정보라고 표현했다.)

JPA는 값 타입을 표현하는 방법을 몇가지 더 제공한다.
아래는 JPA에서 제공하는 값 타입의 목록이다.

값 타입은 기본적인 특징은 아래와 같다.

값 타입

자바의 primitive 타입, Wrapper 클래스, String 클래스를 말한다.

@Entity
class Member{
    @Id
    private Long id;

    private String name;
    private int age;
}

임베디드 타입(복합 값 타입)

괄호안에 써놨듯이, 여러개의 값 타입을 묶어서 하나의 값 타입으로 정의하는 방법이다.
우선 값 타입을 적용하기 전의 코드는 아래와 같다.

@Entity
class Member{
    @Id
    private Long id;

    // 근무기간  
    @Temporal(TemporalType.DATE)
    private Date startDate;
    @Temporal(TemporalType.DATE)
    private Date endDate;

    // 집주소  
    private String city;
    private String street;
    private String zipCode;
}

위 처럼 엔티티가 모든 속성을 flat 하게 가지는 것은 객체지향적이지 않다.
근무기간, 집주소로 묶을 수 있다면 더 좋을 것이다.
그런데 JPA가 그것을 지원해준다! ㅋㅋ

@Entity
class Member{
    @Id
    private Long id;

    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;
}

@Embeddable
class Period{
    @Temporal(TemporalType.DATE)
    private Date startDate;
    @Temporal(TemporalType.DATE)
    private Date endDate;

    public boolean isWork(Date date){
        // 값 타입을 위한 메서드 또한 작성 가능  
    }
}

@Embeddable
class Address{
    @Column(name = "city") // 매핑할 컬럼 지정 가능  
    private String city;
    private String street;
    private String zipcode;
}

작성한 값 타입은 다른 곳에서 재사용 될수도 있고, 값 타입만을 위한 메서드도 작성 가능하다.
엔티티가 더욱 의미있고 응집력있게 변했다(!!)

이러한 임베디드 타입을 정의하려면 아래의 2가지 어노테이션이 필요하다.

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

이렇게 작성한 임베디드 타입은 테이블에 아래와 같이 매핑된다.
![임베디드 타입과 테이블 매핑]()

임베디드 타입은 엔티티의 값일 뿐이다. 그러므로 보다시피 임베디드 타입을 사용하기 전과 후에 매핑되는 테이블은 같다.

ORM을 사용하지 않았더라면 객체와 테이블은 대부분 1:1로 매핑되었을 것을,
ORM을 사용함으로써 객체와 테이블을 더 세밀하게 매핑할 수 있다.
(잘 설계한 ORM 어플리케이션은 매핑힌 클래스의 수가 테이블의 수보다 더 많다)

임베디드 타입의 포함과 연관관계

임베디드 타입은 다른 임베디드 타입을 포함할 수 있고, 다른 엔티티를 참조할 수도 있다.

@Entity
class Member{
    // ...
    @Embedded
    private Address address;

    @Embedded
    private PhoneNumber phoneNumber;
}

class Address{
    private String city;
    private String street;
    @Embedded // 포함 가능
    private Zipcode zipcode;
}

@Embeddable
class Zipcode{
    String zip;
    Strign code;
}

class PhoneNumber{
    String areaCode;
    String localNumber;
    @ManyToOne // 연관관계 가능
    PhoneServiceProvider phoneServiceProvider;
}

@Entity
class PhoneServiceProvider{
    @Id
    private String name;
}

속성 재정의: @AttributeOverride

아래와 같이 정의하고 싶을 수 있다.

class Member{
    // ... 
    @Embedded
    private Address homeAddress;
    @Embedded
    private Address companyAddress;
}

ORM 에서만 객체로 묶을 뿐, 테이블 레벨에선 flat하게 펴지므로 위와 같이 정의하는 것은 불가능하다.
컬럼명이 중복되기 때문이다.
이럴땐 @AttributeOverride를 통해 컬럼명을 재정의해야 한다.

class Member{
    // ... 
    @Embedded
    private Address homeAddress;
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "city", column = @Column(name = "company_city")),
        @AttributeOverride(name = "street", column = @Column(name = "company_street")),
        @AttributeOverride(name = "zipcode", column = @Column(name = "company_zipcode"))
    })
    private Address companyAddress;
}

name에는 Address 내의 필드명을 써주고, column에는 @Column 어노테이션을 써서 재정의 해주면 된다.
어노테이션이 너무 많이 사용되서 지저분하긴 하지만, 다행히(?) 이렇게 한 엔티티에 중복해서 임베디드를 사용할 일이 많이 없다.

@AttributeOveride는 엔티티에 설정해야 한다. 임베디드 타입이 임베디들 타입을 가지고 있어도 엔티티에 설정해야 한다.

임베디드 타입과 null

임베디드 타입이 null 이면 매핑한 컬럼 값을 모두 null 이 된다(!!)

// city, street, zipcode가 모두 null이 됨  
member.setAddress(null);

임베디드 타입의 딜레마

임베디드 타입을 만들어서 값 타입으로 사용한것 까진 괜찮았다. 하지만 여기서 근본적인 문제점이 있다.
임베디드 타입이 값 타입이긴하지만, 그건 jpa의 입장이고, java에서는 그냥 일반적인 객체라는 것이다.
즉, 자바의 기본 값 타입처럼 바로 사용할 수 없다. 그러므로 그런 딜레마를 해결해줘야 한다.
발생하는 딜레마는 아래와 같다.

불변성

언급했듯이, 임베디드 타입은 그냥 일반 객체이다. 그러므로 아래와 같은 상황을 막을 수 없다.

Address address1 = new Address("city", "street", "zipcode1");
Member member1 = Member.builder()
        .name("joont1")
        .homeAddress(address1)
        .build();

Address address2 = address1;
address2.setZipcode("zipcode2");
Member member2 = Member.builder()
        .name("joont2")
        .homeAddress(address2)
        .build();

em.persist(member1);
em.persist(member2);

값 타입이라면 값이 공유되지 않을테니,
member1에 zipcode1, member2에 zipcode2가 저장되어야 한다.

알다시피 자바의 기본 타입은 기본적으로 값을 복사한다

int a = 10;
int b = a;
a = 20;

assertThat(a, is(20));
assertThat(b, is(10));

하지만 Address는 자바 기본 값 타입이 아니므로 값을 복사하지 않는다.
그러므로 기대한대로 동작하게 하려면 아래와 같이 사용해야 하는데,

Address address1 = new Address("city", "street", "zipCode1");
Member member1 = Member.builder()
        .name("joont1")
        .homeAddress(address1)
        .build();

Address address2 = address1.clone(); // 복사
address2.setZipcode("zipcode2");
Member member2 = Member.builder()
        .name("joont2")
        .homeAddress(address2)
        .build();

em.persist(member1);
em.persist(member2);

중요한건 항상 이렇게 사용되도록 강제할 수도 없고, 이를 무시하고 직접 참조를 전달하는 행위를 막을 방법도 없다는 거다.
그러므로 이럴떄는 그냥 객체 자체를 불변객체로 만들어서 부작용이 발생하지 않도록 만들어버리는게 낫다.

자바 값 타입은 불변객체로 만드는 것이 좋다.

자바에서 객체를 불변객체로 만드는 방법은 여러가지가 있지만, 가장 간단한 방법은 setter를 만들지 않는것이다.

@AllArgsConstructor // 전체 프로퍼티를 받는 생성자
@Embeddable
class Address{
    private String city;
    private String street;
    private String zipcode;
}

여전히 address 참조를 전달할 수 있지만, 전달해도 값을 수정할 수 없으니 부작용이 발생하지 않는다.

비교

값 타입이라면 동일성 비교(==)나 동등성 비교(equals)가 동작해야 한다.
하지만 현재 Address는 그것이 보장되지 않으므로, equals 메서드를 재정의 해줘야한다.

@AllArgsConstructor
@EqualsAndHashCode // 전체 필드에 대해 equals와 hashCode 재정의
@Embeddable
class Address{
    private String city;
    private String street;
    private String zipcode;
}

임베디드 타입의 equals 메서드를 재정의 할 때는 보통 모든 필드의 값을 비교하도록 구현한다.
그리고 equals 메서드를 재정의하면 hashCode까지 같이 재정의 해주는 것이 좋다.
그렇지 않으면 해시를 사용하는 컬렉션(HashSet, HashMap)에서 문제가 발생할 수 있기 때문이다.

값 타입 컬렉션

여러개의 값 타입을 저장할 떄 사용한다. 그러려면 컬렉션에 저장해야 하는데, RDB에서는 컬럼에 컬렉션을 저장할 수 없다.
그러므로 값 만을 저장하는 테이블을 따로 만들어서 사용해야 한다.
![값 타입 컬렉션 ERD]()

위 ERD를 엔티티에서 매핑하면 아래와 같다.

@Entity
class Member{
    // ...

    @Embedded
    private Address homeAddress;

    @ElementCollection
    @CollectionTable(
        name = "FAVORITE_FOOD", 
        joinColumns = @JoinColumn(name = "member_id")
    )
    @Column(name = "food_name")
    private List<String> favoriteFoodList = new ArrayList<>();

    @ElementCollection
    @CollcetionTable(
        name = "ADDRESS_HISOTRY",
        joinColumns = @JoinColumn(name = "member_id")
    )
    private List<Address> addressHistory = new ArrayList<>(); // Address는 위와 동일
}

@ElementCollection으로 값 타입 컬렉션 인것을 알려주고,
@CollectionTable로 해당 값들을 저장한 테이블을 알려주면 된다(외래키랑 같이).
favorite_food처럼 값으로 사용되는 컬럼이 하나일 경우 @Column을 사용해서 컬럼명을 지정할 수 있다.

값 타입 컬렉션 사용

저장

Member member = new Member();

member.setHomeAddress(new Address("city", "street", "zipCode4"));

member.getFavoriteFoodList().add("pork");
member.getFavoriteFoodList().add("beef");

member.getAddressHistory().add(new Address("city1", "street1", "zipcode1"));
member.getAddressHistory().add(new Address("city2", "street2", "zipcode2"));
member.getAddressHistory().add(new Address("city3", "street3", "zipcode3"));

em.persist(member);

member : insert 1번
homeAddress : 임베디드 값 타입이므로 member에 포함됨
favoriteFoodList : insert 2번
addressHistory : insert 3번

조회

값 타입 컬렉션도 조히할 때 패치 전략을 사용할 수 있다. Default는 LAZY이다.

@ElementCollection(fetch = FetchType.LAZY)

조회 방식은 일반적인 @OneToMany 조회 할때와 동일하다.
직접 사용할 때 조회된다.

수정

Member member = em.find(Member.class, 1);
List<String> favoriteFoodList = member.getFavoriteFoodList();
favoriteFoodList.set(0, "changed pork");
favoriteFoodList.set(1, "changed beef");

List<Address> addressHisotry = member.getAddressHistory();
addressHisotry.get(0).setStreet("changed street");

값 타입은 식별자가 없는 단순한 값들의 모음으로 테이블에 저장된다.
그래서 여기에 저장된 값이 변경되면 데이터베이스에 저장된 원본 데이터의 값을 찾기 어렵게 된다.
이러한 문제로 인해 JPA는 값 타입 컬렉션에 변경사항이 발생하면,
값 타입 컬렉션에 매핑된 테이블의 모든 데이터를 삭제하고, 현재 값 타입 컬렉션에 있는 모든 값을 다시 데이터베이스에 저장한다.

즉, 위와 같은 코드에서는 아래와 같이 쿼리가 발생한다.

/** address_history **/
delete 
    from
        ADDRESS_HISTORY 
    where
        member_id=1

insert 
    into
        ADDRESS_HISTORY
        (member_id, city, street, zipcode) 
    values
        (1, "city1", "street1", "zipcode1")
insert 
    into
        ADDRESS_HISTORY
        (member_id, city, street, zipcode) 
    values
        (1, "city2", "changed street", "zipcode2") -- insert modifired data
insert 
    into
        ADDRESS_HISTORY
        (member_id, city, street, zipcode) 
    values
        (1, "city3", "street3", "zipcode3")

/** favorite_food **/
delete 
    from
        FAVORITE_FOOD 
    where
        member_id=1

insert 
    into
        FAVORITE_FOOD
        (member_id, food_name) 
    values
        (1, "changed pork");  -- insert modifired data
insert 
    into
        FAVORITE_FOOD
        (member_id, food_name) 
    values
        (1, "changed beef");  -- insert modifired data

이러한 비효율적인 특징이 있으므로 만약 값 타입 컬렉션에 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신 일대다 관계를 고려해보는 것이 좋다.
게다가 값 타입 컬렉션은 모든 컬럼을 묶어서 기본키를 구성하므로, 컬럼에 null을 입력할 수 없고, 중복된 값을 입력할 수 없는 제약조건도 있다.