9장. 값 타입

배유연·2023년 11월 5일

JPA 의 데이터 타입을 가장 크게 분류하면 엔티티 타입과 값 타입으로 나눌 수 있다.
엔티티 타입은 식별자를 통해 지속해서 추적할 수 있지만, 값 타입은 식별자가 없고 숫자나 문자같은 속성만 있으므로 추적할 수 없다.

값타입은 세가지로 나눌 수 있다.

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

1. 기본값 타입

public class Member {

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

    @Column(name = "name", nullable = false, length = 10)
    private String username;
    private Integer age; 
    ...
}

Member에서 String 과 Integer가 값 타입이다. name과 age 속성은 식별자 값도 없고 생명주기도 회원 엔티티에 의존한다. 값타입은 공유하면 안된다.


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

새로운 값 타입을 직접 정의해서 사용하는 것을 임베디드 타입이라고 한다.
다음 코드를 보자.

@Entity
public class Member { 
	@Id @GeneratedValue
    private Long id;
    private String name;
    
    @Embedded Period workPeriod; //근무 기간
    @Embedded Address homeAddress; //집 주소
    ...
}
@Embeddable
public class Period { 
	@Temporal(TemporalType.DATE) Date startDate;
    @Temporal(TemporalType.DATE) Date endDate;
    ...
}

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

멤버 엔티티에서 여러 정보를 나열하는것 보단, @Embedded를 사용해서 응집력있게 표현하는 것이 좋다.
이렇게 하면 Period, Addess를 다른 엔티티에서 재사용할 수 있다.

🔷임베디드 타입

  • 아래 어노테이션은 둘 중에 하나를 생략해도 된다.
    - @Embeddable : 값 타입을 정의하는 곳에 표시
    - @Embedded : 값 타입을 사용하는 곳에 표시
  • 임베디드 타입은 기본 생성자가 필수다.
  • 임베디드 타입을 포함한 모든 값 타입은 엔티티의 생명주기에 의존하므로 엔티티와 임베디드 타입의 관계는 컴포지션 관계가 된다.

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


임베디드 타입은 엔티티의 값일 뿐이다. 즉 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다.
잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.

🔷임베디드 타입과 연관관계


임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있다.

엔티티는 공유될 수 있으므로 참조한다고 표현하고, 값 타입은 특정 주인에 소속되고 논리적인 개념상 공유되지 않으므로 포함한다고 표현했다.

코드를 보자.

@Entity
public class Member9_6 {


    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    Address9_6 address;
    @Embedded
    PhoneNumber phoneNumber;
    ...
}
@Embeddable
public class Address9_6 {

    String street;
    String city;
    String state;
    @Embedded
    Zipcode zipcode; //임베디드 타입 포함

}

@Embeddable
class Zipcode {

    String zip;
    String plusFour;
}

@Embeddable
public class PhoneNumber {

    String areaCode;
    String localNumber;
    @ManyToOne
    PhoneServiceProvider provider; // 엔티티 참조
}

@Entity
class PhoneServiceProvider {

    @Id
    String name;
}

Address가 값 타입인 Zipcode를 포함하고, 값 타입인 PhoneNumber가 엔티티 타입인 PhoneServiceProvider를 참조한다.

생성된 DB ERD 다이터그램이다.

🔷@AttributeOverride : 속성 재정의

임베디드 타입에 정의한 매핑정보를 재정의하려면 엔티티에 @AttributeOverride를 사용하면 된다.
Member 엔티티에 집주소랑 회사주소를 등록하려고 한다. 이렇게 한다면 컬럼명이 중복될 텐데, 이 때 @AttributeOverride를 사용하면 된다.

@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    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"))
    })
    Address companyAddress;
    ...
}

🔷임베디드 타입과 null

임베디드 타입이 null이면, 매핑한 컬럼 값은 모두 null이 된다.

member.setAddress(null); //null입력
em.persist(member);

이렇게 하면 주소와 관련된 city, street, zipcode 컬럼 값은 모두 null이 된다.


3. 값 타입과 불변 객체

값 타입은 단순하고 안전하게 다룰 수 있어야한다.

🔶값 타입 공유 참조

임베디드 타입을 여러 엔티티에서 공유하면 어떤 문제가 있을까?

member1.setHomeAddress(new Address("OldCity"));
Adderss address = member1.getHomeAddress();

address.setCity("NewCity"); 
member2.setHomeAddress(address);//회원1의 address값을 공유해서 사용

이렇게 회원1의 주소를 그대로 회원2가 사용하면 회원2만 바뀌길 원했지만, 영속성 컨텍스트는 UPDATE 문을 회원1도 같이 NewCity로 실행한다.
이렇게 뭔가를 수정했는데 전혀 예상치 못한 곳에서 문제가 발생하는 것을 부작용이라 한다.

🔶값 타입 복사

이번엔 복사해서 사용해보자.

member1.setHomeAddress(new Address("OldCity"));
Adderss address = member1.getHomeAddress();

//회원1의 address 값을 복사해서 새로운 newAddress 값을 생성
Address newAddress = address.clone();

address.setCity("NewCity"); 
member2.setHomeAddress(newAddress);

이렇게 하면 영속성 컨텍스트는 회원2의 주소만 변경된 것으로 판단하고 회원2에 대해서만 update 쿼리를 실행한다.

이렇게 복사해서 사용하면 문제를 방지할 수 있지만, 원본의 참조 값을 직접 넘기는 것을 아예 방지해야하는데.... 그게 문제다!
즉, 객체의 공유 참조는 피할 수 없다.
이런걸 근본적으로 해결하기 위해서는 수정자 메소드를 모두 제거하는 수밖에!

🔶불변객체

값 타입은 부작용 걱정 없이 사용할 수 있어야한다. 그래서 객체를 불변하게 만들면 값을 수정할 수 없으므로 부작용을 원천 차단할 수 있다.
따라서 값 타입은 될 수 있으면 불변객체로 설계하자!

불변객체를 구현하는 방법은 수정자를 만들지 않고 생성자로만 값을 설정하자.

@Embeddable
public class Address{
	private String city;
    protected Address(){}//JPA에서 기본 생성자는 필수다.
    
    //생성자로 초기값을 설정한다.
    public Address(String city){this.city = city;}
    
    //getter만
    public String getcity(){
    	return city;
    }
}

이렇게 하면 Address를 불변객체로 관리할 수 있다.


4. 값 타입 비교

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

값 타입을 비교할 때는 a.equals(b)를 사용해서 동등성 비교를 해야한다.
물론 Address의 equals() 메소드를 재정의해야한다.

자바에서는 equals()를 재정의하면 hashCode()도 재정의하는 것이 안전하다.
그렇지 않으면 해시를 사용하는 컬랙션(HashMap, HashSet)이 정상동작하지 않는다.


5. 값 타입 컬렉션

값 타입을 하나 이상 저장하려면 컬렉션에 보관하고, @ElementCollection, @CollectionTable어노테이션을 사용해야한다.

@Entity
public class Member{
	@Id @GeneratedValue
    private Long id;
    
    @Embedded
    private Address homeAddress;
    
    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOODS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
    @Column(name="FOOD_NAME")
    private Set<String> favoriteFoods = new HashSet<String>();
    
    @ElementCollection
    @CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
    private List<Address> favoriteFoods = new ArrayList<Address>();
    //...
}


Member 엔티티를 보면 값 타입 컬렉션을 사용하는 favoriteFoods, addressHistory에 @ElementCollection을 지정했다.

그리고 이 컬렉션은 관계형 db에서 포함할 수 없으므로, 별도의 테이블을 추가하고 @CollectionTable을 사용해서 추가한 테이블을 매핑해야한다.

🔷값 타입 컬렉션 사용

등록

private static void save(EntityManager em) {
        Member9_12 member = new Member9_12();
        //임베디드 값 타입
        member.setHomeAddress(new Address("통양", "몽돌해수욕장", "6666-4444"));

        // 기본값 타입 컬렉션
        member.getFavoriteFoods().add("짬뽕");
        member.getFavoriteFoods().add("피자");
        member.getFavoriteFoods().add("삼겹살");

        //임베디드 값 타입 컬렉션
        member.getAddressHistory().add(new Address("서울", "강남", "02-1111-2222"));
        member.getAddressHistory().add(new Address("동두천", "생연로", "031-444-5555"));

        em.persist(member);
    }

등록하는 코드를 보면 em.persist(member); 로 멤버만 영속화한다.
이 호출로 총 6개의 insert 문이 발생한다.

값 타입 컬렉션은 영속성전이+고아객체 제거 기능을 필수로 가진다고 생각하면 된다!!

조회

값 타입 컬렉션의 기본 패치 전략은 LAZY이다.
@ElemenetCollection(fetch = FetchType.LAZY)

	private static void getMemberData(EntityManager em) {
        //select 
        Member9_12 member = em.find(Member9_12.class, 1L);

        Address homeAddress = member.getHomeAddress();

        System.out.println("homeAddress = " + homeAddress);

        Set<String> favoriteFoods = member.getFavoriteFoods();
        System.out.println("-----------------------------------");
        for (String food : favoriteFoods) {
            System.out.println("food = " + food);
        }

        List<Address> addressHistory = member.getAddressHistory();

        System.out.println("-----------------------------------");
        System.out.println("addressHistory = " + addressHistory.get(0));

    }

위 코드 실행시

Hibernate: 
    select
        member9_12x0_.id as id1_27_0_,
        member9_12x0_.city as city2_27_0_,
        member9_12x0_.street as street3_27_0_,
        member9_12x0_.zipcode as zipcode4_27_0_ 
    from
        Member9_12 member9_12x0_ 
    where
        member9_12x0_.id=?
homeAddress = Address{city='통양', street='몽돌해수욕장', zipcode='6666-4444'}
-----------------------------------
Hibernate: 
    select
        favoritefo0_.MEMBER_ID as MEMBER_I1_13_0_,
        favoritefo0_.FOOD_NAME as FOOD_NAM2_13_0_ 
    from
        FAVORITE_FOODS favoritefo0_ 
    where
        favoritefo0_.MEMBER_ID=?
food = 짬뽕
food = 피자
food = 삼겹살
-----------------------------------
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=?
addressHistory = Address{city='서울', street='강남', zipcode='02-1111-2222'}

가 실행된다.(LAZY)

수정

	private static void update(EntityManager em) {
        Member9_12 member = em.find(Member9_12.class, 1L);
        //임베디드 값 타입 수정
        member.setHomeAddress(new Address("통영22", "신도시", "123123"));

        //기본 값 타입 컬렉션 수정
        Set<String> favoriteFoods = member.getFavoriteFoods();
        favoriteFoods.remove("짬뽕");
        favoriteFoods.add("엄마집밥");

        //임베디드 값 타입 컬렉션 수정
        List<Address> addressHistory = member.getAddressHistory();
        addressHistory.remove(new Address("서울", "강남", "02-1111-2222"));
        addressHistory.add(new Address("new_서울", "신도림", "02-1111-2222"));

    }

위처럼 임베디드 값 타입은 그냥 setter로 수정하여 member테이블에 update가 실행된다.
기본값 타입 컬렉션 수정과 임베디드 값 타입 컬렉션 수정은 불변해야해서 기존 것을 삭제하고 새로운 것을 추가한다.

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

값 타입은 식별자라는 개념이 없고 단순한 값들의 모음이므로 값을 변경해버리면 데이터베이스에 저장된 원본 데이터를 찾기가 어렵다.
따라서 JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스에 다시 저장한다.

참고로,
기본값 타입 컬렉션은 수정한 데이터만 수정하고, 임베디드 값 타입 컬렉션은 모든 데이터를 삭제하고 모든 값을 다시 저장한다.

그렇기 때문에, 값타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려하자!

또한, 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야한다.
이것을 하이버네이트가 자동으로 만들어주지 않으니, 수동으로 개발자가 기본키로 구성해야한다.
(참고링크)

JPA가 만들어준 DDL을 완전히 믿으면 안된다!!😱

profile
기초를 중요시하는 백엔드개발자입니당

0개의 댓글