JPA 값 타입들

떡ol·2023년 4월 24일
0

JPA에 사용되는 값들의 종류에 대해 알아봅니다. 여지껏 사용했던 @Entity 타입의 객체는 key값으로 관리되므로 개별적인 생명주기를 갖고있는데 반해서, 값 타입들은 단순한 값 이상의 의미가 없습니다.

기본값 타입종류

자바 기본 타입은... 아래 설명에서 제외했습니다.

  1. 자바 기본 타입 (int, double)
    • 래퍼 클래스 (Integer, Long)
    • String
  2. 임베디드 타입 (embedded type, 복합 값 타입)
    • JPA에서 정의해서 사용
    • e.g. 좌표의 경우, Position Class
  3. 컬렉션 값 타입 (collection value type)
    • 마찬가지로 JPA에서 정의해서 사용
    • 컬렉션에 기본값 또는 임베디드 타입을 넣은 형태

임베디드 타입

임배디드 타입은 우리가 자바에서 객체에서 객체를 넘길때 사용하는 방식이랑 같습니다.
부모가 될 객체에는 @Embadded 자식이 될 객체에는 @Embeddable을 선언해주면 됩니다.

//main.java
public class main {
...
Member member = new Member();
member.setAddress(new Address("NY","Colinse","435631"));
...
}
//Memeber.java
@Entity
public class Member {
 ...
 @Embedded // 이건 선언을 해줘도되고 안해도됩니다. 그래도 알아보라고 작성해두는게 좋겠죠?
 private Address homeAddress;
 @Embedded
 private Address workAddress;
 ...
}
//Adress.java
@Embeddable // 자식이 될 객체에는 다음과 같이 선언합니다.
public class Address {
  private String city;
  private String street;
  private String zipcode;
  ...
}

사실 위에 코드를 돌려보면 DB구조에는 별차이가 없습니다. 그냥 Member 클래스에 city, street, zipcode 전부 작성한거랑 다를바없어요. 그래도 , 무엇보다 자바가 원하는 객체지향적인 설계이니 이것만으로도 왜사용하는지는 알겁니다.

임베디드 타입의 장점

1. 재사용 & 높은 응집도
new Address("NY","Colinse","435631") 식으로 선언해서 사용할 수 있으니 여러개의 맴버에 대입하기가 편하겠죠? 값들을 묶어 사용할 수 있으니 관리하기도 편하고 알아보기도 쉽고 응집도도 높아집니다.

2. 해당 값 타입만 사용하는 의미 있는 메소드를 만들 수 있음
아래 처럼 사용하는게 가능합니다.

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

3. 임베디드 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명주기를 의존함
사실 임베디드 타입은 DB구조에는 별차이가 없습니다. 그냥 줄줄이 풀어쓴거랑 같아요. 하지만 1번에서 설명했듯 객체지향 구조라는게 의미가 있는겁니다.
하지만 임베디드는 Entity가 아닙니다. 따라서 부모가되는 Entity(Memeber)가 사라지면, 임베디드 객체(Address)는 컨트롤을 못합니다. 당연히 em.find(Address) 안되죠.

임베디드 타입 심화

다중 선언 사용법
만약 같은 타입의 객체를 두번 선언하면 다음과 같이 작성하시면 됩니다.

@Entity
public class Member {
  ...
  @Embedded
  private Address homeAddress;
  @Embedded
  @AttributeOverrides({ // 새로운 컬럼에 저장합니다.
    @AttributeOverride(name="city", column=@Column(name = "WORK_CITY"),
    @AttributeOverride(name="street", column=@Column(name = "WORK_STREET"),
    @AttributeOverride(name="zipcode", column=@Column(name = "WORK_ZIPCODE")})
  private Address workAddress;
  ...
}

불변 객체로 관리해라
임베디드 타입은 객체값을 넘기게 됩니다. 두개의 부모 엔티티가 하나의 임베디드 객체를 갖게 되면 수정 시 오류가 발생할 수 있습니다.

//main.java
public class main {
...
Address addr = new Address("NY","Colinse","435631");

Member member1 = new Member();
member1.setAddress(addr);

Member member2 = new Member();
member2.setAddress(addr);

member2.getAddress.setZipcode("111111");// member1도 update 됩니다.
...
}

그래서, 불변 객체로 관리하라는 겁니다. 되도록이면 setter는 선언해주지말고, constructor로만 관리되는 객체로 만들어주시면 됩니다.

//main.java
public class main {
...

Member member1 = new Member();
member1.setAddress(new Address("NY","Colinse","435631")); // new 입니다.

Member member2 = new Member();
member2.setAddress(new Address("NY","Colinse","435631")); // 이것도요.

member2.getAddress.setZipcode("111111");// 이젠 동일성이 다를테니 따로 관리 됩니다.
...
}

마찬가지로 update를 위해서 setter할때도 해당객체에 접근해서 한개만 바꾸는게 아닙니다.

	Member findMember = em.find(Member.class, member.getId()); // 불러오기
    
    // findMember.getAddress().setCity("newCity"); // 틀린 방법
	findMember.setAddress(new Address("newCity", "newStreet", "11111"));
        

이제 불변성이 되어버린 객체는 ==을 사용할 수 없습니다.equals() 매서드를 override하여 값이 같은지 비교문은 사용하시면 됩니다.

//Address.java
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Address adress = (Address) o;
        return Objects.equals(getCity(), adress.getCity()) && Objects.equals(getStreet(), adress.getStreet()) && Objects.equals(getZipcode(), adress.getZipcode());
    }
// equals()를 바꿨으니 hashCode()도 해줘야겠죠?
    @Override
    public int hashCode() { 
        return Objects.hash(getCity(), getStreet(), getZipcode());
    }

컬렉션 타입

값 타입을 컬렉션에 담아쓰는 것을 말합니다.
@ElementCollection을 사용하여 컬렉션 타입이라는걸 지정해주고, @CollectionTable을 통해서 어떤 테이블에, 무엇을 join할건지를 넣어주면 됩니다.

@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<>();
    ...
}
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", "street1", "10001"));
member.getAddressHistory().add(new Address("old2", "street2", "10002"));

em.persist(member);

위와 같이 선언하여 실행해보면 member만 실행했는데 FAVORITE_FOOD, ADDRESS까지 DB가 입력이 되는걸 알 수 있습니다.
1:N 연관 관계에서 Cascade=ALL로 설정하고, orphanRemoval=true 로 설정한 것과 유사한 능력을 갖추게 되는 겁니다. find()를 조회해서 값을 확인해보면 쿼리문이 각각 나간다는것도 확인가능합니다. 즉, 지연로딩 전략을 사용하고 있다는 겁니다. @ElementCollection(fetch = LAZY) default입니다.

그럼 이게 Entity랑 다를게 뭐냐? 라고 생각할 수도 있어요... 중요한건 컬렉션에 핵심은 여기서 부터입니다. 값 타입은 PK가 없습니다. 그래서 DB를 제어하기 까다롭습니다. 아래 예제를 보시죠

		//위 코드 아래에 작성했습니다.
        em.flush();
        em.clear(); // 영속성을 비워주고...
        System.out.println("============ START ============");
        Member findMember = em.find(Member.class, member.getId()); // 다시불러옵니다.
        // Map도아니고 Entity도 아니라 key값을 잡아줄 방법이 없습니다... 그냥 remove하고 add해야 합니다.
        findMember.getFavoriteFoods().remove("치킨");
        findMember.getFavoriteFoods().add("한식");
		//마찬가지로 List타입도 동일합니다. 
        //equals(), hashCode() 에 대한 정의가 위에서 정의한 형식으로 바꿔주셔야합니다.
        findMember.getAddressHistory().remove(new Address("old1", "street1", "10001"));
        findMember.getAddressHistory().add(new Address("newCity1", "street1", "10001"));

위에 코드를 사용하여 실행해보면 결과는 잘나옵니다. 하지만 로그에는 다음과같이 나옵니다.

실행은 food를 먼저했는데, adress가 먼저나왔네요. 이건 영속성이 알아서 먼저처리한거라 이상하게 여길 필요는 없습니다.
getFavoriteFoods를 통해 나온 결과를 알려드리면 String 하나로만 비교를해서 진행을 해서 그런지 deleteinsert가 하나씩 발생했습니다.
getAddressHistorywhere 조건을 보시면 delete all을 해버리는 셈입니다. 그리고 변경사항을 반영한 모든 객체를 다시 담아서 insert합니다.

이것은 비교할 수 있는 대상이 Primary Key가 없어 객체로는 update하기 힘드니(중복 예방) 전체 삭제시키고, remove()가 반영된 List<Address>를 통째로 insert하게 되는겁니다.
대안으로는 @OrderColumn(name = "address_history_order")를 사용하여 UPDATE Query가 날라갈 수 있도록 할 수 있습니다만... 의도한데로 동작안되나 봅니다.

결론을 말씀드리면 컬렉션은 데이터를 변경할 일이 많다면, 개별적으로 생명주기를 갖추고있는 @Entity에 @OneToMany (일대다,1:N)를 사용하시는게 맞습니다.

단, 앞에 코드에서 getFavoriteFoods 처럼 컬렉션타입이 아니거나(일반 값 타입), html에 Select box(주소, 카테고리 종류 등)처럼 update가 잘 안발생하는 곳에서는 사용을 고려해볼만 합니다.

profile
하이

0개의 댓글