본문 바로가기
BackEnd/JPA

[JPA] 값 타입(2)

by 개발 Blog 2024. 7. 18.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

값 타입의 비교

값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 한다.

//값 타입
int a = 10;
int b = 10;
System.out.println("a == b " + (a == b)); //true

//임베디드 타입
Address address1 = new Address("city", "street", "10000");
Address address2 = new Address("city", "street", "10000");
System.out.println("address1 == address2 " + (address1 == address2)); //false

- int는 기본 값 타입으로 실제 값 자체를 저장한다. 

  • a와 b는 모두 값 10을 가지므로 a == b는 true를 반환한다.

- 객체 타입 비교 Address는 사용자 정의 클래스이며, 참조 타입이다.

  • address1과 address2는 서로 다른 두 객체를 참조한다. 비록 두 객체가 동일한 값을 가지고 있더라도 == 연산자는 객체의 메모리 주소(참조)를 비교한다.
  • address1과 address2는 다른 메모리 주소를 가지고 있으므로 address1 == address2는 false를 반환한다.

- 객체의 값을 비교하고 싶다면 equals를 쓰면 된다.

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

- 동등성(equivalence) 비교 : 인스턴스의 값을 비교, equals() 사용

- 값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야 한다.

- 값 타입의 equals() 메소드를 적절하게 재정의(주로 모든 필드 사용)

 

Address를 다시 equals를 써서 비교해 보자.

Address address1 = new Address("city", "street", "10000");
Address address2 = new Address("city", "street", "10000");

System.out.println("address1 == address2 " + (address1 == address2));
System.out.println("address1 equals address2 " + (address1.equals(address2)));

- 왜 false가 나왔을까? 

- equals의 기본 비교는 == 비교이기 때문이다. 따라서 오버라이드를 해서 사용해야 한다.

@Embeddable
public class 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);
    }
}

- true가 나오는 것을 확인할 수 있다.

 

값 타입 컬렉션

- 값 타입을 하나 이상 저장할 때 사용한다.

- @ElementCollection, @CollectionTable을 사용한다.

- 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다.

- 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.

@Entity
public class Member {

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

- FAVORITE_FOOD와 ADDRESS 엔티티에서 MEMBER_ID를 외래키로 잡아서 테이블을 생성한다.

- 기본 자료 구조 컬렉션 하위에 있는 인터페이스들은 다 쓸 수 있다. (Set, List 등)

- 값 타입을 컬렉션으로 저장한다. (여러 개 저장하기 위해)

- @ElementCollection을 쓰면 값 타입 컬렉션을 매핑할 수 있다.

- favoriteFoods Set <String> 필드는 기본 타입 String의 컬렉션이다. 따라서 각 요소는 컬렉션 테이블의 단일 열에 저장된다.

- 그래서 String은 따로 @Column으로 열 이름을 지정해 준다.

- Address는 @Embeddable로 지정되어 있기 때문에 Address 타입의 각 필드는 ADDRESS 테이블의 별도 열로 저장된다.

- 따라서 @Column 어노테이션을 사용할 필요가 없다.

- 만약 열 이름을 지정하고 싶으면 이전 시간에 썼던 @AttributeOverride로 지정해 주면 된다. 아래 코드와 같이 쓰면 된다.

//임베디드 타입 열 이름 지정 예시

@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
@AttributeOverrides({
    @AttributeOverride(name = "city", column = @Column(name = "NEWNAME_CITY")),
    @AttributeOverride(name = "street", column = @Column(name = "NEWNAME_STREET")),
    @AttributeOverride(name = "zipcode", column = @Column(name = "NEWNAME_ZIPCODE"))
})
private List<Address> addressHistory = new ArrayList<>();

 

테이블을 만들었으니 실제 사용 예제 코드를 보자.

값 타입 컬렉션 사용 예제

저장

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

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

member.getAddressHistory().add(new Address("old1", "street", "10000"));
member.getAddressHistory().add(new Address("old2", "street", "10000"));

em.persist(member);

- Member, address, favoriteFoods  테이블에 각각 1번, 2번, 3번씩 INSERT 된 것을 확인할 수 있다.

- DB 테이블에도 기대했던 대로 값이 잘 들어간 것을 볼 수 있다.

- 값 타입 컬렉션을 따로 영속화하지 않고 member만 영속화했는데 address와 favoriteFoods도 같이 INSERT가 됐다.

- 즉, 값 타입은 Member 엔티티의 생명주기에 소속된다.

 

조회

em.flush(), em.clear()로 DB에 데이터를 넣어두고 애플리케이션을 다시 실행하는 것처럼 조회한다.

em.persist(member);

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

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

tx.commit();

- Member를 조회했을 때 member의 정보만 가져온다는 것은 컬렉션들은 지연로딩이라는 뜻이다.

- address의 정보(city, street, zipcode)는 Member에 소속된 값 타입이기 때문에 같이 조회가 된다.

 

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

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

List<Address> addressHistory = findMember.getAddressHistory();
for (Address address : addressHistory) {
    System.out.println("address = " + address.getCity());
}

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

tx.commit();

- 컬렉션 값들은 필요할 때 조회를 하면 결과와 같이 조회가 된다.

- @ElementCollection의 기본 FetchType은 LAZY이다. (지연로딩)

 

수정

city를 변경하고자 할 때

findMember.getHomeAddress().setCity("newCity");

'이런 식으로 바꾸면 되지'라고 생각할 수 있지만

Address의 setter는 값을 변경할 수 없도록 막아놨다. '그럼 public으로 변경하고 수정하면 되지' 라고 생각할 수 있지만 

이전 시간에 공부했듯이 값타입은 잘못하면 사이드 이펙트가 생긴다. 따라서 값 타입은 불변해야 한다.

 

따라서 아래와 같이 새로운 객체를 만들어서 써야 한다. (city와 food 변경)

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

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

// homeCity -> newCity
// findMember.getHomeAddress().setCity("newCity"); // X

Address a = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", a.getStreet(), a.getZipCode()));

//치킨 -> 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");

tx.commit();

- city는 새로운 Address로 만들어서 update 하고

- 음식은 String 값타입이기 때문에 통째로 갈아 끼워야 한다(update 하면 안 된다.)

- 따라서 삭제 후 새로운 값을 add 한다.

- 컬렉션의 값만 변경해도 JPA가 DB에 쿼리가 보내서 변경해 준다.

 

이번에는 주소를 변경해 보자.

findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressHistory().add(new Address("newCity1", "street", "10000"));

- ADDRESS 테이블에서 "old1" "street" "10000"에 해당하는 행을 지운 후 새로운 데이터를 INSERT 하는 것을 예상했지만

- 실제로는 삭제하는 데이터와 연관된 데이터는 모두 지운 뒤에 새로운 데이터를 INSERT 한다. 그래서 INSERT가 두 번 보내진 것이다.

- 따라서 이런 식의 방법은 권장하지 않는다.

 

참고: 값 타입 컬렉션은 영속성 전이(Cascade) + 고아 객체 제거 기능을 필수로 가진다.

 

값 타입 컬렉션의 제약사항

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

 

값 타입 컬렉션 대안

실무에서는 상황에 따라 값 타입 컬렉션 대신 일대다 관계를 고려한다.

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

    @Id @GeneratedValue
    private Long id;

    private Address address;

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

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

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }
}

- 일대다 관계를 위한 AddressEntity 엔티티를 만든다.

- 그리고 여기서 @Embeddable로 정의된 Address 값 타입을 사용한다.

 

@Entity
public class Member {

	 ...

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

- @ElementCollection으로 값 타입 컬렉션으로 매핑되어 있던 것을 @OneToMany 관계로 변경하여 AddressEntity 엔티티를 사용한다. 이로 인해 값 타입 컬렉션의 제약을 피할 수 있다.

- 값 타입 컬렉션의 변경 사항이 있을 때마다 모든 데이터를 삭제하고 다시 삽입하는 대신, 엔티티를 사용함으로써 변경 사항을 더 효율적으로 관리할 수 있다.

- CascadeType.ALL : 부모 엔티티에 대한 모든 작업(저장, 수정, 삭제 등)을 자식 엔티티에도 전파한다.

- orphanRemoval = true(고아 객체 제거)를 사용하여 부모 엔티티에서 참조가 제거된 자식 엔티티를 자동 삭제할 수 있도록 한다.

- 여기서 자식 엔티티는 addressHistory이다.

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

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

member.getAddressHistory().add(new AddressEntity("old1", "street", "10000"));
member.getAddressHistory().add(new AddressEntity("old2", "street", "10000"));

em.persist(member);

tx.commit();

- update가 두 번 발생한 이유는 @OneToMany 1:N 단방향 관계에서 다른 테이블(ADDRESS)에 외래키가 있기 때문이다.

  • Member 객체에 새로운 AddressEntity 객체를 추가할 때 ADDRESS테이블의 MEMBER_ID 값을 업데이트해야 하기 때문이다.
  • 이는 ADDRESS 테이블의 기존 행을 새로운 Member와 연결하기 위해 두 번의 update 쿼리가 발생하는 것이다.

- ADDRESS 테이블에 ID값이 생기고 MEMBER_ID를 FK로 저장되었다. 자체적으로 ID가 있다는 것은 값 타입이 아니라 엔티티 타입이라는 뜻이다.

 

정리

엔티티 타입의 특징

- 식별자가 있다.

- 생명 주기 관리가 된다.

- 공유할 수 있다.

 

값 타입 특징

- 식별자가 없다.

- 생명 주기를 엔티티에 의존한다.

- 공유하지 않는 것이 안전하다(복사해서 사용)

- 불변 객체로 만드는 것이 안전하다.

 

값 타입은 진정한 값 타입일 때만 사용해야 하며, 엔티티와 혼동해서는 안 된다. 

식별자가 필요하고 지속적으로 값을 추적하고 변경해야 한다면, 이는 값 타입이 아니라 엔티티로 설계해야 한다.

 

실전 예제 6 - 값 타입 매핑

Address라는 값 타입을 만들어서 Delivery와 Member에 적용해 보자.

 

@Entity
public class Member extends BaseEntity{

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;
    private String city;
    private String street;
    private String zipcode;
    
    ...
    
}

Member의 city, street, zipcode를 값 타입으로 만들어 보자.

@Embeddable
public class Address {

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

    public String getCity() {return city;}

    public String getStreet() {return street;}

    public String getZipcode() {return zipcode;}

    private void setCity(String city) {this.city = city;}

    private void setStreet(String street) {this.street = street;}

    private void setZipcode(String zipcode) {this.zipcode = 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);
    }
}

Address 값 타입을 만든다.

 

@Entity
public class Member extends BaseEntity{

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

    @Embedded
    private Address address;
    
    ...
}

city, street, zipcode -> 값 타입으로 변경했다.

 

@Entity
public class Delivery extends BaseEntity{

    @Id @GeneratedValue
    private Long id;

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

    @Embedded	
    private Address address;
    
    private DeliveryStatus status;
    
    ...
}

Delivery도 값 타입으로 변경해 준다.

 

의도한 대로 테이블이 생성되는 것을 볼 수 있다. (city, street, zipcode 생성)

 

값 타입의 장점 중 하나는 의미 있는 비즈니스 메서드를 만들거나 공통 관리되는 룰(컬럼 길이 등)을 만들 수도 있다.

@Embeddable
public class Address {

    @Column(length = 10)
    private String city;
    @Column(length = 20)
    private String street;
    @Column(length = 5)
    private String zipcode;

    public String fullAddress(){
        return getCity() + " " + getStreet() + " " + getZipcode();
    } 
    
    ...
}