Java - SpringJPA

Spring JPA (9) - JPA 값 타입 : 기본값, 임베디드, 값 타입 컬렉션

TerianP 2022. 10. 11.
728x90

1. JPA 데이터 타입 분류

기본값 타입 설명
엔티티 타입 @Entity 로 정의하는 객체
데이터가 변해도 식별자로 지속해서 추적 가능
ex) 회원 엔티티의 키나 나이를 변경해도 식별자로 인식 가능
기본값 타입 int, Integer, String 처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체
식별자가 없고, 값만 있으므로 변경시 추적 불가
---------
1. 기본값 타입
- int , double
- 래퍼 클래스 Integer, Long
- String

2. 임베디드 타입 
- embedded type, 복합 값 타입

3. 컬렉션 값 타입
- Collection value type

 

2. 기본값 타입

- 생명 주기를 엔티티에 의존

- 값 타입은 공유하면 XXXXXX => A 라는 회원 이름 변경 시 다른 회원의 이름도 함께 변경되면 안됨!

Ex) String name, int age

 

참고) 자바의 기본 타입 - primitive type - 은 절대 공유 X
- 기본 타입은 항상 값을 복사함
- Integer 같은 래퍼 클래스나 String 같은 특수한 클래스는 공유 가능한 객체지면 변경은 X

 

3. 임베디드 타입 : Embedded Type

1) 임베디드 타입이란?

이렇게 하나의 member 클래스를

- 새로운 값 타입을 직접 정의할 수 있음 => JPA 는 임베디드 타입(Embedded type) 이라 함

- 주로 기본 값 타입을 모아서 만들어서 복합 값 타입이라도고 함

- int, String 과 같은 값 타입

- 위 Member 엔티티에서 사용되는 값들에서 공통적으로 묶을 수 있는 변수(값)들을 하나로 묶은 후 해당 값만을 갖고 있는 class 를 생성한다

  • startDate - endDate는 날짜라는 개념을 기반으로 하나의 클래스로 묶을 수 있다
  • City, street, zipcode 는 주소 라는 개념을 기반으로 하나의 클래스로 묶을 수 있다
  • 결과적으로 Date 는 workDate, 주소는 Address 라는 클래스로 만든다.

공통된 부분을 가져와서 2개로..!!

 

2) 임베디드 타입 만들기 : @Embedded @Embeddable

- Member

@Entity(name = "period_member")
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Member extends BaseEntity {

    @Id
    @GeneratedValue
    private Long id;

    private String userName;


    @Embedded // 값 타입을 사용하는 곳에서 쓰는 어노테이션
    private WorkDate workDate;

    @Embedded // 값 타입을 사용하는 곳에서 쓰는 어노테이션
    private Address homeAddress;

}

 

- WorkDate

// 반드시 기본 생성자가 필요함!!!!
@Embeddable
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class WorkDate {
    private LocalDate startDate;
    private LocalDate endDate;
}

 

- Address

// 반드시 기본 생성자가 필요함!!!!
@Embeddable
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Address {
    private String city;

    private String street;

    private String zipcode;
}

 

 

3) 임베디드 타입의 장점

• 재사용성 향상 => 따로 클래스로 나눴기 때문에 여기저기서 사용 가능
• 높은 응집도 
• Period.isWork()처럼 해당 값 타입만 사용하는 의미 있는 메소드를 만들어 여러가지로 사용 가능
• 임베디드 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티 티에 생명주기를 의존함

 

4) 임베디드 타입과 매핑

• 임베디드 타입은 엔티티의 값일 뿐이다 => 따라서 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다.
• 객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능
• 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래 스의 수가 더 많다
  => 여기에 @mappedsuperclass 라도 더 사용하면...정말 많아 질 듯

Member 엔티티와 회원 테이블 매핑

 

5) 임베디드 타입과 연관관계

- Address 와 Zipcode 처럼 임베디드 타입도 다시 임베디드 타입을 가질 수 있다
- PhoneNumber 와 PhoneEntity 처럼 임베디드 타입이 Entity 를 가질 수도 있다 => FK 를 갖고있어야 한다

 

6) @AttributeOverride : 하나의 엔티티 안에서 같은 값 타입을 사용하는 경우!

하나의 엔티티 안에서 동일한 임베티드 타입으로 2개 이상의 속성(변수)에 매핑하는 경우
일반적으론 에러 발생
이때 사용하는 어노테이션이 @AttributeOverride
// 한 엔티티 안에서 두 개의 변수(속성)에 동일한 인베디드 타입을 매핑하는 경우 => 에러 발생!!!
@Embedded
private Address homeAddress;

@Embedded
private Address workAddress;

Repeated column 에러 발생


@AttributeOverride 구현하기

한 엔티티 안에서 두 개의 변수(속성)에 동일한 인베디드 타입을 매핑하는 경우
@AttributeOverride 사용 : name 에는 인베디드 타입에서의 변수명, column 에서는 컬럼명을 적어준다

// 한 엔티티 안에서 두 개의 변수(속성)에 동일한 인베디드 타입을 매핑하는 경우
// => @AttributeOverride 사용 : name 에는 인베디드 타입에서의 변수명, column 에서는 컬럼명을 적어준다
@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 homeAddress;

@Embedded
private Address workAddress;

zipcode 와 work_zipcode 컬럼이 따로 생성된다

 

4. 객체 타입과 불변 객체

값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념으로 값 타입은 단순하고 안전하게 다룰 수 있어야 한다.

1) 객체 타입의 공유 참조

임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험함 => 부작용 발생


2) 객체 타입의 공유 참조에 따른 부작용

분명히 member 의 city 만 가져와서 변경했지만, 결국 member2 까지 같이 변경되어 버리는 문제가 발생한다

// Address 객체를 만든 후
Address address = new Address("city", "street", "zipcode");

// member 와 member2 에 각각 저장
Member member = new Member();
member.setUserName("user1");
member.setHomeAddress(address);
member.setWorkDate(new WorkDate(LocalDate.of(2020,7,21), LocalDate.now()));
em.persist(member);

Member member2 = new Member();
member2.setUserName("user2");
member2.setHomeAddress(address);
member2.setWorkDate(new WorkDate(LocalDate.of(2020,7,22), LocalDate.now()));
em.persist(member2);

// 그리고 다시 member 의 address 에서 city 만 가져와서 변경하면...?
member.getHomeAddress().setCity("seoul");

update 문이 무려 2번!!

 


2) 객체 타입의 공유 참조에 따른 부작용 해결 방안

기존의 address 에 담긴 값들을 그대로 복사하여 새로운 객체를 생성한 후 해당 객체를 member2 에 set 한다

// Address 객체를 만든 후
Address address = new Address("city", "street", "zipcode");

// member 와 member2 에 각각 저장
Member member = new Member();
member.setUserName("user1");
member.setHomeAddress(address);
member.setWorkDate(new WorkDate(LocalDate.of(2020,7,21), LocalDate.now()));
em.persist(member);

// 기존의 address 에 담긴 값을 그대로 복사한 copyAddress 객체를 새로 생성하고 이를 member2 에 넣는다
Address copyAddress = new Address(address.getCity(), address.getStreet(), address.getZipcode());
Member member2 = new Member();
member2.setUserName("user2");
member2.setHomeAddress(copyAddress);
member2.setWorkDate(new WorkDate(LocalDate.of(2020,7,22), LocalDate.now()));
em.persist(member2);

// 그리고 다시 member 의 address 에서 city 만 가져와서 변경하면...?
// 결국 member 와 member2 의 address 에는 서로 다른 객체가 들어가고
// 따라서 member 의 city 를 바꾸더라도 member2 에는 영향이 없다
member.getHomeAddress().setCity("seoul");

이제는 update 한번만 나간다


3) 객체 타입의 한계

- 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다
- 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아닌 객체 타입이다
- 자바 기본 타입에 값을 대입하면 값을 복사한다
- 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없다 => 객체의 공유 참조는 피할 수 없다...!!

정리하자면,
기본 타입은 값을 복사하기 때문에 아무런 상관이 없으나
객체 타입은 메모리 주소를 복사하게 되고, 따라서 동일한 인스턴스가 생성된다.

 

4) 불변 객체

개발자는 결국 객체의 참조 값을 직접 대입하는 것을 막을 방법이 없다.
그렇다면 어떻게 객체 참조 문제를 해결 할 수 있을까? 정답은 바로 불변 객체에 있다
• 객체 타입을 수정할 수 없게 만들면 부작용을 원천 차단
• 값 타입은 불변 객체(immutable object)로 설계해야함
• 불변 객체: 생성 시점 이후 절대 값을 변경할 수 없는 객체
• 참고: Integer, String은 자바가 제공하는 대표적인 불변 객체

불변 객체 생성 방법 1: 생성자로만 값을 설정하고 수정자(Setter)를 만들지 않기
불변 객체 생성 방법 2:  수정자 setter 을 private 로 설정하기

 

방법1과 방법2 중 내가 원하는 방법으로 만들자

@Embeddable
// 객체 참조에 따른 방법 1 : @Setter : setter 를 없애버린다
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Address {
    private String city;

    private String street;

    private String zipcode;

    // 객체 참조에 따른 방법 2 : private 으로 setter 생성


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

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

    private void setZipcode(String zipcode) {
        this.zipcode = zipcode;
    }
}

 

5.  값 타입 비교

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

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

레퍼런스 타입은  참조값을 비교하는 == 를 사용하면 안된다!!

값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야 함
=> 값 타입의 equals() 메소드를 적절하게 재정의(주로 모든 필드 사용)
주로 인베디드 타입을 사용하는 경우 추후 인베디드 타입을 비교하기 위해 사용
// 객체의 값 타입 비교는 equals 로 사용해서 비교해야함
// == 를 사용하면 참조값을 비교하기 때문에 서로 다른 인스턴스에서는 무조건 false 가 나온다
@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);
}

 

6. 컬렉션 값 타입

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

아래와 같은 Member 엔티티에서 Set 과 List 라는 컬렉션을 사용하는 경우 특별한 어노테이션과 조작이 필요하다
=> 최종적으로 Member 엔티티는 Member, Favorite_food, Address 3가지의 테이블을 갖는다
즉, 컬렉션 타입마다 테이블이 하나씩 생기는 것!

 

1) 컬렉션 값 타입 매핑하기 : @ElementCollection, @CollectionTable (기본 지연로딩)

Member

/* 엔티티의 컬렉션 값 타입에 붙이는 어노테이션은 총 2가지 */
@ElementCollection // 엔티티의 해당 값 타입이 Collection 타입임을 의미하는 어노테이션
@CollectionTable(name = "favorite_food", // Collection 타입과 매핑되는 테이블을 적어두는 어노테이션
        // joinColumns 는 어떤 컬럼들과 매핑하는지 작성한다
        // @JoinColumn 는 name 에 적힌 테이블에서 어떤 컬럼과 조인할 것인지 작성
        // 즉 FK 에 해당하는 컬럼을 적어주면 된다
        joinColumns = @JoinColumn(name = "member_id"))
// Address 와는 다르게 하나의 타입으로 되어있는 경우 - 여기서는 Stirng -
// 아주 예외적으로 Column 이름을 작성할 수 있다
@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 테이블
favorite_food 테이블 => food_name 이란 컬럼으로 생성되었다
Address 테이블


3) 컬렉션 값 타입 저장

Member member = new Member();
member.setUserName("user1");

// List 타입 저장
member.getAddressHistory().add(new Address(address.getCity(), address.getStreet(), address.getZipcode()));
member.getAddressHistory().add(new Address(address.getCity()+"1", address.getStreet(), address.getZipcode()));
member.getAddressHistory().add(new Address(address.getCity()+"12", address.getStreet(), address.getZipcode()));

// Set 타입 저장
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("순대");

em.persist(member);

List 컬렉션에 맞춰서 address 테이블에 3번 insert 된다
테이블 확인

 

4) 컬렉션 값 타입 조회 : 기본 LAZY 전략

System.out.println("=============== 조회 start ============");
Member findMember = em.find(Member.class, member.getId());
System.out.println("====== 실제 컬렉션 값 타입을 가져올 때 Query 발생 ======");
findMember.getFavoriteFoods().forEach(food -> {
    System.out.println("food : " + food);
});

컬렉션 값 타입의 기본 전략은 LAZY !!

 

5) 컬렉션 값 타입 수정

컬렉션 값 타입의 수정은 다른 것들과는 사뭇 다르다. 
단순히 변경이 아닌 컬렉션 안에 있는 기존의 값을 찾아서 삭제한 후 새로운 값을 넣는 방식을 사용해야한다
특히 컬렉션에서 특정한 값을 찾을 때는 보통 equals 를 사용하기 때문에 이런 equals 와 hashcode 메서드를 오버라이딩 하는 것이 중요하다
        System.out.println("=============== 조회 start ============");
        Member findMember = em.find(Member.class, member.getId());

            /* 앞서 객체 타입 공유 참조에서 이야기했듯이
            *  단순히 setCity 하게 되면 동일한 객체 타입을 참조하고 있는 member 가 있다면
            *  해당 member 의 city 가 전부 바뀌어 버리는 큰 문제가 발생할 수 있다
            *  따라서 setCity 가 아닌 findMember.set 해서 새로운 인스턴스를 생성해서 넣어야 한다
            * */
        // 아래처럼 사용하면 안된다
//        findMember.getHomeAddress().setCity("절대 이렇게 하면 안된다!!!!");
        // 아래처럼 완전히 새로운 인스턴스로 교체하자
        findMember.setHomeAddress(new Address("city222", "street2222", "zipcode2222"));

        // 컬렉션 타입 수정 : Set 의 경우
        findMember.getFavoriteFoods().remove("치킨");
        findMember.getFavoriteFoods().add("한식");

        // 컬렉션 타입 수정 : List 의 경우
//        findMember.getAddressHistory().remove("city12"); 단순히 이렇게 사용하면 DB 에서 안지워진다

       /*
           컬렉션들은 대부분 대상을 찾을 때 기본적으로 equals 를 사용한다
            따라서 equals 를 꼭 오버라이딩해서 사용해야 하는 이유가 바로 이런 컬렉션 값 타입을 사용하면서
            컬렉션 안에 있는 값을 삭제, 변경 하기 위해서 이다.
        */

        // 따라서 List 에서 값을 찾아서 변경할 때는 내가 동일한 값을 갖는 인스턴스(객체)를 가져다 넣는다
        // 이렇게해야지 delete 문이 나가게 된다
        findMember.getAddressHistory().remove(new Address(address.getCity() + "12", address.getStreet(), address.getZipcode()));
        findMember.getAddressHistory().add(new Address("newCity", "newStreet", "newZipCode"));

 

6) 값 타입 컬렉션의 제약 사항 여기서 꼭 기억해야하는 중요한 점

아래 사진을 보면 "삭제 후 추가" 즉 수정이 잘 된 것을 알 수 있다. 그런데 뭔가 이상하다.
보면 delete from address where member_id 라는 쿼리문이 실행되서 member_id 에 해당하는 address 테이블 전체가 삭제되고, 이후 다시 member_id 에 해당하는 전체 내용이 추가되는 것을 알 수 있다
컬렉션 값 타입의 컬렉션은 제약사항이 있다

delete 삭제 + insert 추가

값 타입 컬렉션의 제약 사항

- 값 타입은 엔티티와 다르게 식별자 개념 - PK - 이 없다
- 값을 변경하면 추적이 어렵다

- 값 타입 컬렉션에 변경 사항이 발생하면 해당 테이블에서 주인 엔티티와 관련된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다
=> 만약 결제 내역과 결제 내역에 따른 상품들 이라는 테이블이 존재할 때 상품 내역 중 1개만 삭제한다고 해도,
A 라는 결제 내역에 속한 상품들 전체가 다시 삭제되고, 새로 변경되는 값을 합쳐서 다시 전체가 insert 된다
=> 딱봐도 쓰면 큰일나게 만들어졌따

- 컬렉션 타입을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성하는게 좋다 => null 입력 X, 중복 저장 X

 

 

 

2) 컬렉션 값 타입 정리 컬렉션 값 타입의 대안

컬렉션 값 타입 정리

- 값 타입을 하나 이상 저장할 때 사용 => 컬렉션이니까...!! 

- @ElementCollection, @CollectionTable 사용
=> @ElementCollection : 엔티티의 해당 값 타입이 Collection 타입임을 의미하는 어노테이션
=> @CollectionTable : Collection 타입과 매핑되는 테이블을 적어두는 어노테이션
     =>  joinColumns 파라미터는 어떤 컬럼들과 매핑하는지 작성한다
      => @JoinColumn 는 name 에 적힌 테이블에서 어떤 컬럼과 조인할 것인지 작성 : 즉 FK 에 해당하는 컬럼을 적어주면 된다

- 기본적으로 DB 는 컬렉션을 테이블에 저장할 수 없음, 따라서 컬렉션을 저장하기 위한 별도의 테이블 필요
- 값 타입 컬렉션은 지연 로딩 전략이 기본!! => 추가로 영속성 전이 Cascade 와 고아 객체 제거 기능도 거의 필수로 가져간다

 

컬렉션 값 타입의 대안

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

 

Member

@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "member_id")
private List<AddressEntity> addressHistory = new ArrayList<>();

 

AddressEntity

@Entity
@Table(name = "address")
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class AddressEntity {
    @Id
    @GeneratedValue
    @Column(name = "address_id")
    private Long id;

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

    private Address address;

}

댓글