✏️ JPA 값 타입

박상민·2023년 10월 9일
0

JPA

목록 보기
14/24
post-thumbnail

⭐️ 엔티티 타입, 값 타입?

엔티티 타입

  • @Entity로 정의하는 객체, @Entity를 붙여서 관리하던 클래스들
  • pk값으로 관리가 되기때문에 데이터가 변해도 식별자로 지속적으로 추적이 가능, 관리도 편리
  • ex.회원 엔티티의 키나 나이 값을 변경해도 식별자로 인식 가능

값 타입

  • int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체
  • 식별자가 없고 값만 있으므로 변경시 추적이 불가
  • ex. 숫자 100을 200으로 변경하면 완전히 다른 값으로 대체

⭐️값 타입

값 타입 분류

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

📌 기본값 타입

  • 예): String name, int age
  • 생명주기를 엔티티의 의존
    • 예) 회원을 삭제하면 이름, 나이 필드도 함께 삭제
  • 값 타입은 공유하면X
    • 예) 회원 이름 변경시 다른 회원의 이름도 함께 변경되면 안됨

기본 값 타입들은 생명주기가 엔티티에 의존되어 있다. 예를 들어 Student라는 엔티티에 있는 ine age, String name은 student1 객체가 삭제 되면 같이 삭제 된다.
참고로 자바의 기본 타입은 절대 공유되지 않는다. int, double과 같은 기본 타입(primitive type)은 항상 값을 복사하기 때문에 절대 공유해서는 안되고, Integer 같은 래퍼 클래스나 String 같은 특수한 클래스는 공유는 가능한 객체이지만 변경할 수 없다.

📌 임베디드 타입(Embedded type, 복합 값 타입)

  • 새로운 값 타입을 직접 정의할 수 있음
  • JPA는 임베디드 타입(embedded type)이라 함
  • 주로 기본 값 타입을 모아서 만들어서 복합 값 타입이라고도 함
  • int, String과 같은 값 타입

임베디드 타입(embedded type)은 주로 기본 값 타입을 모아서 만들기 때문에 복합 값 타입이라고도 한다. 즉, 임베디드 타입 역시 int, String과 같은 값 타입이다. 임베디드 타입의 값이 null이면 매핑한 컬럼 값 역시 모두 null 이다.
복합 값 타입으로 새로운 값 타입을 직접 정의할 수 있다.
예를들어 회원 정보에서 비슷한 정보끼리 묶어 관리하고 싶다면 그런 묶음을 임베디드 타입으로 만들어 주고 사용하면 된다.


위와 같이 회원 정보 중 startDate와 endDate를 묶어 Periodcity, street, zipcode를 묶어 Address로 관리하려면, Address와 Period 클래스에(값 타입을 정의하는 곳에) @Embeddable을 붙여주고, 값 타입을 사용하는 곳인 Member에는 @Embedded를 붙여주면 된다.
임베디드 타입 사용법

  • @Embeddable: 값 타입을 정의하는 곳에 표시
  • @Embedded: 값 타입을 사용하는 곳에 표시
  • 기본 생성자 필수
//Address 클래스
@Embeddable
public class Address {

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

    public Address() { // 기본생성자 필수 
    }
}

//Period 클래스
@Embeddable
public class Period {

    private LocalDateTime startDate;
    private LocalDateTime endDate;

    public Period() { // 기본생성자 필수 
    }
}

//Member 클래스
public class Member {

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

    @Column(name = "USERNAME")
    private String username;
        
    //period
    @Embedded
    private Period period;

    //address
    @Embedded
    private Address homeAddress;    
 }

임베디드 타입의 장점

  • 재사용
  • 높은 응집도
  • Period.isWork()처럼 해당 값 타입만 사용하는 의미 있는 메소드를 만들 수 있음
  • 임베디드 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명주기를 의존함

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

임베디드 타입으로 빼서 관리를 해도 결국 Member 테이블에 변화는 없다. 임베디드 타입으로 만들어준 Period와 Address가 모두 Member테이블에 들어가 있다. 임베디드 타입은 엔티티의 값일 뿐이고, 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다. 객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능하다.

  • 임베디드 타입은 엔티티의 값일 뿐이다.

  • 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다.

  • 객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능

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

📌 컬렉션 값 타입(Collection value type)

  • 값 타입을 하나 이상 저장할 때 사용
  • @ElementCollection, @CollectionTable 사용
  • 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다.
  • 컬렉션을 저장하기 위한 별도의 테이블이 필요함

예시

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


예를 들어 위 사진처럼 Member마다 여러 Address를 가지는 경우, Member마다 여러개의 선호 음식을 가지는 경우라고 해보자.

Member.class

@Entity
public class Member {
    @Id //pk가 무엇인지 알려줌, pk 매핑
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    @Column(name = "USERNAME")
    private String username;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    @Embedded
    private Address homeAddress;

	/**
    값 타입 컬렉션
    */
    @ElementCollection //default: fetch = LAZY
    @CollectionTable(name = "FAVORITE_FOOD", joinColumns =
        @JoinColumn(name = "MEMBER_ID")  //FK
    )
    @Column(name = "FOOD_NAME")
    private Set<String> favoriteFoods = new HashSet<>();

    @ElementCollection
    @CollectionTable(name = "ADDRESS", joinColumns =
        @JoinColumn(name = "MEMBER_ID")  //FK
    )
    private List<Address> addressesHistory = new ArrayList<>();
    
    //Getter, Setter ...
}

위는 Member마다 여러 Address를 가지는 경우, Member마다 여러개의 선호 음식을 가지는 경우의
Member 클래스 코드이다.

joinColumns에는 FK로 쓸 값을 넣어주고 식별자는 모든 컬럼의 PK의 조합이 된다. 컬렉션을 위한 별도의 테이블이 만들어진다.

지연 로딩을 사용해서 Member를 가져올 때 FAVORITE_FOOD는 프록시 객체로 들어오고 실제로 쓰일 때 쿼리가 날아간다. 영속성 전이(Cascade)와 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.

테스트 코드를 작성해서 확인해보자.

테스트 코드

Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "1000"));

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

member.getAddressesHistory().add(new Address("old1", "street", "1000"));
member.getAddressesHistory().add(new Address("old2", "street", "1000"));

em.persist(member);

tx.commit();

---------------------------
## 결과 쿼리##
Hibernate: 
    call next value for hibernate_sequence
Hibernate: 
    /* insert hellojpa.Member
        */ insert 
        into
            Member
            (city, street, zipcode, TEAM_ID, USERNAME, MEMBER_ID) 
        values
            (?, ?, ?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressesHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressesHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, zipcode) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)

값 타입 컬렉션을 따로 persist 하지 않고 멤버만 persist 하니까 값 타입 컬렉션들은 자동으로 persiste 된다.
컬렉션은 다른 테이블인데도 불구하고 생명 주기가 같이 돌아간다.

값 타입 컬렉션의 생명 주기가 멤버에 의존하는 것이다.

값 타입들은 별도로 persist 하거나 업데이트 할 게 없다. 그냥 멤버에서 해당 값을 수정하거나 하면 자동으로 업데이트 된다.
값 타입 컬렉션은 이전에 소개한 영속성 전이의 고아 객체 제거 기능을 필수로 가진다고 보면 된다.

쉬운 예시로 Member에 소속된 username도 값 타입이다.

Member에 소속된 @Embedded private Address homeAddress;은 당연히 같이 persist 된다.

✔︎ 값 타입 수정 예제

값 타입 컬렉션은 값을 수정할 시 추적이 어렵다. 값 타입 컬렉션에 변경 사항이 발생하면 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
예를 들어 기존에 3개가 있었고 1개를 지우고 1개를 넣는다면 delete 전체삭제 쿼리 + insert 3개가 나가는 것이다.

  • 값 타입은 엔티티와 다르게 식별자 개념이 없다.
  • 값은 변경하면 추적이 어렵다.
  • 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 관련된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
  • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야한다.
    • null 입력X, 중복 저장X

이런 상황이 생기는 이유는 값 타입이 엔티티와 다르게 식별자 개념이 없기 때문이다. 식별자가 PK가 조합으로 이뤄져있어서 조회가 번거롭다.

Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "1000"));

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

member.getAddressesHistory().add(new Address("old1", "street", "1000"));
member.getAddressesHistory().add(new Address("old2", "street", "1000"));

em.persist(member);

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

//값 타입 수정
Member findMember = em.find(Member.class, member.getId());
findMember.getHomeAddress().setCity("newCity");

값 타입을 변경할 때 위처럼 set을 이용해서 변경해주는 것도 가능은 하다. 그러나 이 방법은 사이드 이펙트라는 큰 부작용이 존재해 절대 해서는 안되는 방법이다.

Member findMember = em.find(Member.class, member.getId());

Address oldAddress = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCicy", oldAddress.getStreet(), oldAddress.getZipcode()));

값 타입을 수정하고 싶으면 setHomeAddress를 해서 Address를 통째로 갈아끼워야한다.
번거롭지만 이런 식으로 완전히 새로운 인스턴스로 갈아끼우는 것이 맞다.

//치킨 -> 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
findMember.getAddressesHistory().remove(new Address("old1", "street", "1000"));
findMember.getAddressesHistory().add(new Address("newCity1", "street", "1000"));

식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아닌 엔티티로 구현해야한다.

📌 값 타입 컬렉션 대안

값 타입 컬렉션은 많은 제약사항 때문에 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려해야한다.
일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용해서 영속성 전이(Cascade) + 고아 객체 제거로 값 타입 컬렉션'처럼' 사용하자.

1:N 관계를 위한 AddressEntity

@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
	//...
}
@Entity
public class Member extends BaseEntity {
	//...
    
	//@ElementCollection
	//@CollectionTable(name = "ADDRESS", joinColumns = 
	//	@JoinColumn(name = "MEMBER_ID")
	//)
	//private List<Address> addressHistory = new ArrayList<>();
    
	@OneToMany(cascade = CascadeType.ALL, orphanRomoval = true)
	@JoinColumn(name = "MEMBER_ID")
	private List<AddressEntity> addressHistory = new ArrayList<>();
    
	//...
}

📌 값 타입과 불변 객체

값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다. 따라서 최대한 단순하고 안전하게 다룰 수 있어야 한다.
우선 값 타입들은 서로 공유를 하면 안된다. 다른 회원의 나이가 변경되었다고 다른 회원의 이름도 변경되면 안되는 것처럼 기본값 타입은 애초에 값을 복사하기 때문에 공유를 할 수 없고, 래퍼 클래스나 String은 참조값을 복사하기 때문에 공유가 가능하지만 수정이 불가능하기 때문에 괜찮다.
하지만 임베디드 타입은 직접 정의한 객체 타입이기 때문에 기본값 타입과는 다르게 공유가 가능하고 수정 또한 가능해서 유의해야한다.

  • 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위함
  • 부작용(side effect) 발생


예를들어 위 그림과 같이 회원1과 회원2의 주소가 같다면, 하나의 Address 객체를 만든 뒤 같이 넣어주면 값을 공유하게 된다.

위 그림에 대한 코드

Address address = new Address("city","street","zipcode");

Member member1 = new Member();
member1.setUsername("A");
member1.setAddress(address);

Member member2 = new Member();
member2.setUsername("B");
member2.setAddress(member1.getAddress());

이렇게되면 회원1이나 2가 이후 주소를 변경해도 함께 변경되기 때문에 문제가 생길 수 있다. 그래서 값 타입의 실제 인스턴스인 값을 공유하는 방식 대신, 값(인스턴스)를 복사해서 사용한다.

**값을 복사해서 사용

Address address = new Address("city","street","zipcode");

Member member1 = new Member();
member1.setUsername("A");
member1.setAddress(address);

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

Member member2 = new Member();
member2.setUsername("B");
member2.setAddress(copyAddress));

객체 타입의 한계

  • 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용은 피할 수 있다.

  • 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다.

  • 자바 기본 타입에 값을 대입하면 값을 복사한다.

  • 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없다.

객체 타입은 참조를 복사해서 넘긴다. 참조를 복사하면 인스턴스 하나를 같이 사용하는 것이기 때문에 수정시 같이 변경될 위험이 있다.

그럼 어떻게 해결해야할까?
참조를 전달하는 걸 막을 수 있는 방법은 없다. 그래서 객체 타입을 수정할 수 없게 만들면 된다.
아예 변경 자체를 불가능하게 만들면 문제가 해결된다.

✔︎ 불변 개체

  • 생성 시점 이후 절대 값을 변경할 수 없는 객체

  • 객체 타입을 수정할 수 없게 만들면 부작용을 원천 차단

  • 값 타입은 불변 객체(immutable object)로 설계해야함

  • 생성자로만 값을 설정하고 수정자(Setter)를 만들지 않으면 됨

  • 참고: Integer, String은 자바가 제공하는 대표적인 불변 객체

불변객체로 만드는 방법은 setter를 정의하지 않거나 private로 정의하면 된다.

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

📌 값 타입 비교

  • 동일성 비교: 인스턴스의 참조 값을 비교한다. == 을 이용해서 비교

자바의 기본타입은 값이 같으면 같은 공간을 쓰기 때문에 아래와 같은 경우 최종적으로 b와 c는 같은 값, 같은 주소를 갖게 된다.

int a = 10;
int b = a; // 기본타입으로, 공유 x
int c = 10;
a = 20;

System.out.println("a = " + a);  // a = 20
System.out.println("b = " + b);  // b = 10
System.out.println("c = " + c);  // c = 10
  
System.out.println("b == c : " + (b == c));  // b == c : true
System.out.println("b = " + System.identityHashCode(b));  // b = 157627094
System.out.println("c = " + System.identityHashCode(c));  // c = 157627094
  • 동등성 비교: 인스턴스의 값을 비교한다. equals() 사용
    자바의 기본타입을 제외하고는 동등성 비교를 해줘야 값만 비교가 가능하다.
    따라서 값 타입은 자바의 기본타입을 제외하고는 모두 equals 연산을 사용해야하며, 오브젝트 타입 같이 따로 정의해준 타입은 equals를 따로 정의해줘야한다.
    Address같은 경우 equals를 override해서 각각의 요소마다 비교를 할 수 있도록 해야한다.

Equals, HashCode Override

@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);
}
  • 값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야 한다.
  • 값 타입의 Equals() 메소드를 적절하게 재정의(주로 모든 필드 사용)

[정리]

엔티티 타입의 특징

  • 식별자O
  • 생명 주기 관리
  • 공유

값 타입의 특징

  • 식별자X
  • 생명 주기를 엔티티에 의존
  • 공유하지 않는 것이 안전(복사해서 사용)
  • 불변 객체로 만드는 것이 안전

출처
자바 ORM 표준 JPA 프로그래밍 강의
게시글 속 자료는 모두 위 강의 속 자료를 사용했습니다.

profile
스프링 백엔드를 공부중인 대학생입니다!

0개의 댓글