자바 ORM 표준 JPA 프로그래밍 - 기본편 수업을 듣고 정리한 내용입니다.

 

✏️ 이번 Chapter 중요한 것

  • 임베디드 타입(복합 값 타입), 값 타입 컬렉션이 중요하다!

 

📚 1. 기본값 타입

📖 A. JPA의 데이터 타입 분류

✔️ 엔티티 타입

  • @Entity로 정의하는 객체이다.
  • 데이터가 변해도 식별자로 지속해서 추적 가능하다.
    → 엔티티 내부의 모든 값들을 바꿔도 식별자만 유지되면 추적이 가능하다는 뜻이다.
  • ex) 회원 엔티티의 키나 나이 값을 변경해도 식별자로 인식 가능하다.

 

✔️ 값 타입

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

 

📖 B. 값 타입 분류

(1) 기본 값 타입

  • 자바 기본 타입(int, double)
  • 래퍼 클래스(Integer, Long)
  • String

ex)

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

 

💡 참고 - 자바의 기본 타입은 절대 공유할 수 없다.

  • int, double 같은 기본 타입 (primitive type)은 절대 공유할 수 없다.
  • 기본 타입은 항상 값을 복사한다. (참조값 공유)
  • Integer 같은 래퍼 클래스나 String 같은 특수한 클래스는 공유 가능한 객체이지만 변경 불가능하다. (불변객체)

 

(2) 임베디드 타입(embedded type, 복합 값 타입)

  • 정의해서 사용하는 타입

 

(3) 컬렉션 값 타입(collection value type)

  • 자바 컬렉션에 기본값 타입이나 임베디드 타입을 넣은 것

 

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

새로운 값 타입을 직접 정의해서 사용할 수 있는데, JPA에서는 이것을 임베디드 타입(embedded type)이라 한다.
중요한 것은 직접 정의한 임베디드 타입도 int, String처럼 값 타입이라는 것이다.

 

Member - 회원 엔티티

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String name;
    
    // 근무 기간
    @Temporal(TemporalType.DATE) java.util.Date startDate;
    @Temporal(TemporalType.DATE) java.util.Date endDate;
    
    // 집 주소 표현
    private String city;
    private String street;
    private String zipcode;
    // ...
}
스크린샷 2022-03-13 오후 8 49 47

이 엔티티는 어떻게 될까?
➡️ 회원 엔티티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 가진다.

이런 설명은 단순한 정보를 풀어둔 것일 뿐, 근무 시작일과 우편번호는 서로 아무 관련이 없다!

이와 같이 설명해야 명확하다.
➡️ 회원 엔티티는 이름, 근무 기간, 집 주소를 가진다.

 

회원이 상세한 데이터를 그대로 가지고 있는 것은 객체지향적이지 않으며 응집력만 떨어뜨린다. 대신에 근무 기간, 주소 같은 타입이 있다면 코드가 더 명확해질 것이다.

(근무기간, 집 주소)를 가지도록 임베디드 타입을 사용해보자.
Member - 값 타입 적용 회원 엔티티

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @Embedded Period workPeriod;     // 근무 기간
    @Embedded Address homeAddress;   // 집 주소
    // ...
}

 

Peroid - 기간 임베디드 타입

@Embeddable
public class Period {

    @Temporal(TemporalType.DATE) java.util.Date startDate;
    @Temporal(TemporalType.DATE) java.util.Date endDate;
    // ..
    
    public boolean isWork(Date date) {
        // .. 값 타입을 위한 메소드를 정의할 수 있다.
    }
}

 

Address - 주소 임베디드 타입

@Embeddable 
public class Address {

    @Column(name = "city")    // 매핑할 컬럼 정의 가능
    private String city;
    private String street;
    private String zipcode;
    // ..
}
스크린샷 2022-03-13 오후 8 49 51
  • 값 타입 적용 회원 엔티티를 보면 회원 엔티티가 더욱 의미 있고 응집력 있게 변한 것을 알 수 있다.
  • 기간 임베디드 타입를 보면 startDate, endDate를 합해서 Period(기간) 클래스를 만들었다.
  • 주소 임베디드 타입를 보면 city, street, zipcode를 합해서 Address(주소) 클래스를 만들었다.

새로 정의한 값 타입들은 재사용할 수 있고 응집도도 아주 높다. 또한 위의 기간 임베디드 타입의 Period.isWork()처럼 해당 값 타입만 사용하는 의미 있는 메소드도 만들 수 있다.

임베디드 타입을 사용하려면 다음 2가지 어노테이션이 필요하다. 참고로 둘 중 하나는 생략해도 된다.

  • @Embeddable : 값 타입을 정의하는 곳에 표시
  • @Embedded : 값 타입을 사용하는 곳에 표시

그리고 임베디드 타입은 기본 생성자가 필수다.

임베디드 타입을 포함한 모든 값 타입은 엔티티의 생명주기에 의존하므로 엔티티와 임베디드 타입의 관계를 UML로 표현하면 컴포지션(composition) 관계가 된다. (컴포지션 관계 위에 있는 그림이다.)

 

💡 참고
하이버네이트는 임베디드 타입을 컴포넌트(components)라 한다.

 

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

임베디드 타입을 데이터베이스 테이블에 어떻게 매핑하는지 아래 그림을 통해 알아보자!

스크린샷 2022-03-13 오후 8 55 00

 

Period

import java.time.LocalDateTime;

public class Period {

    private LocalDateTime startDate;
    private LocalDateTime endDate;
	...

 

Address

@Embeddable
public class Address {
    private String city;
    private String street;

    @Column(name = "ZIPCODE")
    private String zipcode;

    public Address() {
        
    }

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

 

Member

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;


    @Embedded
    private Period workPeriod;

    @Embedded
    private Address homeAddress;

	...

 

JpaMain

            Member member = new Member();
            member.setUsername("hello");
            member.setHomeAddress(new Address("city","street","123"));
            member.setWorkPeriod(new Period());
            em.persist(member);

            tx.commit();

 

결과

스크린샷 2022-03-28 오후 5 53 24 스크린샷 2022-03-28 오후 5 59 24
  • 임베디드 타입을 사용시 용어, 필드명 등을 공통으로 사용할 수 있어 매우 유용하다.

 

임베디드 타입은 엔티티의 값일 뿐이다. 따라서 값이 속한 엔티티의 테이블에 매핑한다. 예제에서 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다.

임베디드 타입 덕분에 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능하다. 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.

ORM을 사용하지 않고 개발하면 테이블 컬럼과 객체 필드를 대부분 1:1로 매핑한다. 주소나 근무 기간 같은 값 타입 클래스를 만들어서 더 객체지향적으로 개발하고 싶어도 SQL을 직접 다루면 테이블 하나에 클래스 하나를 매핑하는 것도 고단한 작업인데 테이블 하나에 여러 클래스를 매핑하는 것은 상상하기도 싫을 것이다. 이런 지루한 반복 작업은 JPA에 맡기고 더 세밀한 객체지향 모델을 설계하는데 집중하자!

 

🔔 임베디드 타입과 UML
UML에서 임베디드 값 타입은 아래 그림처럼 기본타입처럼 단순하게 표현하는 것이 편리하다.

스크린샷 2022-03-13 오후 8 57 07

 

📖 B. 임베디드 타입과 연관관계

임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있다. JPA 표준 명세가 제공하는 아래 예제 코드와 그림으로 임베디드 타입의 연관관계를 알아보자!

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

스크린샷 2022-03-13 오후 8 56 36

 

@Entity
public class Member {

    @Embedded Address address;           // 임베디드 타입 포함
    @Embedded PhoneNumber phoneNumber;   // 임베디드 타입 포함
    // ...
}

@Embeddable
public class Address {
    String street;
    String city;
    String state;
    @Embedded Zipcode zipcode;           // 임베디드 타입 포함
}

@Embeddable
public class ZipCode {
    String zip;
    String plusFour;
}

@Embeddable
public class PhoneNumber {
    String areaCode;
    String localNumber;
    @ManyToOne PhoneServiceProvider provider;    // 엔티티 참조
    ...
}

@Entity
public class PhoneServiceProvider {
    @Id String name;
    ...
}

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

 

📖 C. @AttributeOverride : 속성 재정의

임베디드 타입에 정의한 매핑정보를 재정의하려면 엔티티에 @AttributeOverride를 사용하면 된다. 예를 들어 회원에게 주소가 하나 더 필요하면 어떻게 해야 할까?

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @Embedded Address homeAddress;
    @Embedded Address companyAddress;
}

Member 엔티티를 보면 집 주소에 회사 주소를 하나 더 추가했다. 문제는 테이블에 매핑되는 컬럼명이 중복되는 것이다.
이때는 아래 예제와 같이 @AttributeOverrides를 사용해서 매핑정보를 재정의해야 한다.

 

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @Embedded Address homeAddress;
    
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name="city", column=@Column(name
            = "COMPANY_CITY")},
        @AttributeOverride(name="street", column=@Column(name
            = "COMPANY_STREET")},
        @AttributeOverrdie(name="zipcode", column=@Column(name
            = "COMPANY_ZIPCODE")}
    })
    Address companyAddress;
}

아래에 생성된 테이블을 보면 재정의한대로 변경되어 있다.

CREATE TABLE MEMBER (
    COMPANY_CITY varchar(255),
    COMPANY_STREET varchar(255),
    COMPANY_ZIPCODE varchar(255),
    city varchar(255),
    street varchar(255),
    zipcode varchar(255),
    ...
)

@AttributeOverride를 사용하면 어노테이션을 너무 많이 사용해서 엔티티 코드가 지저분해진다.
다행히도 한 엔티티에 같은 임베디드 타입을 중복해서 사용하는 일은 많지 않다.

 

💡 참고
@AttributeOverrdies는 엔티티에 설정해야 한다. 임베디드 타입이 임베디드 타입을 가지고 있어도 엔티티에 설정해야 한다.

 

📖 D. 임베디드 타입과 null

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

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

회원 테이블의 주소와 관련된 CITY, STREET, ZIPCODE 컬럼 값은 모두 null이 된다.

 

📚 3. 값 타입과 불변 객체

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

 

✔️ 값 타입 공유 참조

  • 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다.
  • 부작용(side effect) 발생
스크린샷 2022-03-14 오후 3 16 13

 

✔️ 값 타입 복사

  • 값 타입의 실제 인스턴스인 값을 공유하는 것은 위험하다.
  • 대신 값(인스턴스)를 복사해서 사용한다.
스크린샷 2022-03-14 오후 3 17 19

 

✔️ 객체 타입의 한계

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

 

기본 타입 (primitive type)

int a = 10;
intb = a; // 기본 타입은 값을 복사 
b = 4;

 

객체 타입

Address a = new Address(Old);
Address b = a; // 객체 타입은 참조를 전달 
b.setCity(New)

 

✔️ 불변 객체

  • 객체 타입을 수정할 수 없게 만들면 부작용을 원천 차단
  • 값 타입은 불변 객체(immutable object)로 설계해야한다.
  • 불변 객체 : 생성 시점 이후 절대 값을 변경할 수 없는 객체
  • 생성자로만 값을 설정하고 수정자(Setter)를 만들지 않으면 된다.

 

💡 참고

  • Integer, String은 자바가 제공하는 대표적인 불변 객체
  • 불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막을 수 있다.

 

➡️ 왠만하면 불변을 사용하자!

 

📚 4. 값 타입의 비교

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

int a = 10;
int b = 10;

 

Address a = new Address(“서울시”) 
Address b = new Address(“서울시”)

 

  • 동일성(identity) 비교 : 인스턴스의 참조 값을 비교, == 사용
  • 동등성(equivalence) 비교 : 인스턴스의 값을 비교, equals() 사용
  • 값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야 한다.
  • 값 타입의 equals() 메소드를 적절하게 재정의(주로 모든 필드 사용)

 

📚 5. 값 타입 컬렉션

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

Member - 값 타입 컬렉션

@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") // 값이 하나고, 테이블을 만들 때 FOOD_NAME으로 만든다.
    private Set<String> favoriteFoods = new HashSet<String>(); 
    
    @ElementCollection
    @CollectionTable(name = "ADDRESS", 
        joinColumns = @JoinColumn(name = "MEMBER_ID"))
    private List<Address> addressHistory = new ArrayList<Address>();
    // ...
}

 

Address

@Embeddable
public class Address {

    @Column
    private String city;
    private String street;
    private String zipcode;
    // ...
}

 

✔️ 테이블 생성 실행시

스크린샷 2022-03-28 오후 6 29 40
   @Embedded
    private Address homeAddress;

로 인해 city, street, ZIPCODE 가 필드 변수에 추가된다.

 

스크린샷 2022-03-28 오후 6 31 41
    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOODS",
        joinColumns = @JoinColumn(name = "MEMBER_ID"))
    @Column(name = "FOOD_NAME") // 값이 하나고, 테이블을 만들 때 FOOD_NAME으로 만든다.
    private Set<String> favoriteFoods = new HashSet<String>(); 
  • 열에 FOOD_NAME이 추가된 것을 확인할 수 있다.

 

스크린샷 2022-03-28 오후 6 33 51
    @ElementCollection
    @CollectionTable(name = "ADDRESS",
            joinColumns = @JoinColumn(name = "MEMBER_ID"))
    private List<Address> addressHistory = new ArrayList<Address>();
  • 로 인해 city, street, ZIPCODE 가 필드 변수에 추가된다.
  • 그리고, MEMBER_ID가 있어야하는 이유는 어떤 테이블, 어떤 MEMBER id에 소속된 것인지 알아야 하기 때문이다. 즉, 외래키! (테이블 구별된 것을 확인하기 위해)

 

✔️ 값 타입 컬렉션 UML과 값 타입 컬렉션 ERD

스크린샷 2022-03-14 오후 4 27 37

값 타입 컬렉션의 Member 엔티티를 보면 값 타입 컬렉션을 사용하는 favoriteFoods addressHistory@ElementCollection을 지정했다.
값 타입 컬렉션 UML은 객체의 UML을 표시했다.

favoriteFoods는 기본값 타입인 String을 컬렉션으로 가진다. 이것을 데이터베이스 테이블로 매핑해야 하는데 관계형 데이터베이스의 테이블은 컬럼안에 컬렉션을 포함할 수 없다. 따라서 값 타입 컬렉션 ERD처럼 별도의 테이블을 추가하고 @CollectionTable를 사용해서 추가한 테이블을 매핑해야 한다. 그리고 favoriteFoods처럼 값으로 사용되는 컬럼이 하나면 @Column을 사용해서 컬럼명을 지정할 수 있다.

addressHistory는 임베디드 타입인 Address를 컬렉션으로 가진다. 이것도 마찬가지로 별도의 테이블을 사용해야 한다. 그리고 테이블 매핑정보는 @AttributeOverride를 사용해서 재정의할 수 있다.

 

💡 참고
@CollectionTable를 생략하면 기본값을 사용해서 매핑한다.

  • 기본 값 : {엔티티이름}_{컬렉션 속성 이름}
  • ex) Member 엔티티의 addressHistoryMember_addressHistory 테이블과 매핑한다.

 

📖 A. 값 타입 컬렉션 사용

값 타입 컬렉션 사용 예제

Member member = new Member();

// 임베디드 값 타입
member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));

// 기본값 타입 컬렉션
member.getFavoriteFoods().add("짬뽕");
member.getFavoriteFoods().add("짜장");
member.getFavoriteFoods().add("탕수육");

// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("서울", "강남", "123-123"));
member.getAddressHistory().add(new Address("서울", "강북", "000-000"));

em.persist(member);

실행 결과

Hibernate: 
    call next value for hibernate_sequence
Hibernate: 
    /* insert hellojpa.Member
        */ insert 
        into
            Member
            (city, street, ZIPCODE, USERNAME, MEMBER_ID) 
        values
            (?, ?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, ZIPCODE) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, ZIPCODE) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOODS
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOODS
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOODS
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
    /* insert hellojpa.Member
        */ insert 
        into
            Member
            (city, street, ZIPCODE, USERNAME, MEMBER_ID) 
        values
            (?, ?, ?, ?, ?)

바로 위는 Member 테이블 생성

  • addressHistory : getAddressHistory()
  • favoriteFoods : getFavoriteFoods()
스크린샷 2022-03-28 오후 6 42 31
  • 변수, 컬렉션들도 값 타입이다. 즉, Member 테이블에 포함된다.
  • 그래서, Member을 변경할 시 값 타입이 전부 변경된다.

 

등록하는 코드를 보면 마지막에 member 엔티티만 영속화했다.
JPA는 이 때, member 엔티티의 값 타입도 함께 저장한다. 실제 데이터베이스에 실행되는 INSERT SQL은 다음과 같다.

  • member : INSERT SQL 1번
  • member.homeAddress : 컬렉션이 아닌 임베디드 값 타입이므로 회원 테이블을 저장하는 SQL에 포함된다.
  • member.favoriteFoods : INSERT SQL 3번
  • member.addressHistory : INSERT SQL 2번

따라서 em.persist(member) 한 번 호출로 총 6번의 INSERT SQL을 실행한다. (물론 영속성 컨텍스트를 플러시할 때 SQL을 전달한다.)

INSERT INTO MEMBER ID, CITY, STREET, ZIPCODE) VALUES (1, '통영', '몽돌해수욕장', '660-123')
INSERT INTO FAVORITE_FOODS (MEMBER_ID, FOOD_NAME) VALUES (1, "짬뽕")
INSERT INTO FAVORITE_FOODS (MEMBER_ID, FOOD_NAME) VALUES (1, "짜장")
INSERT INTO FAVORITE_FOODS (MEMBER_ID, FOOD_NAME) VALUES (1, "탕수육")
INSERT INTO ADDRESS (MEMBER_ID, CITY, STREET, ZIPCODE) VALUES
    (1, '서울', '강남', '123-123')
INSERT INTO ADDRESS (MEMBER_ID, CITY, STREET, ZIPCODE) VALUES
    (1, '서울', '강북', '000-000')

 

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

 

✔️ 값 타입 컬렉션 지연로딩

	        Member member = new Member();

// 임베디드 값 타입
            member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));

// 기본값 타입 컬렉션
            member.getFavoriteFoods().add("짬뽕");
            member.getFavoriteFoods().add("짜장");
            member.getFavoriteFoods().add("탕수육");

// 임베디드 값 타입 컬렉션
            member.getAddressHistory().add(new Address("서울", "강남", "123-123"));
            member.getAddressHistory().add(new Address("서울", "강북", "000-000"));

            em.persist(member);

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

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

            System.out.println("========end============");

            tx.commit();
스크린샷 2022-03-28 오후 6 49 05
  • Member에 소속된 필드들은 같이 조회된다.
  • 다만, 컬렉션들은 지연로딩이다.

 

✔️ 지연로딩 테스트

            Member member = new Member();

						// 임베디드 값 타입
            member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));

						// 기본값 타입 컬렉션
            member.getFavoriteFoods().add("짬뽕");
            member.getFavoriteFoods().add("짜장");
            member.getFavoriteFoods().add("탕수육");

						// 임베디드 값 타입 컬렉션
            member.getAddressHistory().add(new Address("서울", "강남", "123-123"));
            member.getAddressHistory().add(new Address("부산", "강북", "000-000"));

            em.persist(member);

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

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

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


            Set<String> favoriteFoods = findMember.getFavoriteFoods();    // LAZY

            for (String favoriteFood : favoriteFoods) {
                System.out.println("favoriteFood = " + favoriteFood);
            }


            System.out.println("========end============");

            tx.commit();

실행 결과

스크린샷 2022-03-28 오후 6 55 57
  • 실행 결과를 보면, findMember.getAddressHistory();가 호출되고 나서 address 테이블이 생성되었고
  • findMember.getFavoriteFoods(); 가 호출되고 나서 favoriteFood 테이블이 생성되었다.

➡️ 이는 지연로딩이 발생한 것이다. (호출될 때 테이블이 생성됨)

 

@ElementCollection(fetch = FetchType.LAZY)
  • @ElementCollection default은 LAZY이다.
  • 값 타입 컬렉션도 조회할 때 페치 전략을 선택할 수 있는데 LAZY가 기본이다.

 

지연 로딩으로 모두 설정되었을 때 아래 예제를 실행하면 어떻게 될까?

// SQL : SELECT ID, CITY, STREET, ZIPCODE FROM MEMBER WHERE ID = 1
Member member = em.find(Member.class, 1L)     // 1. member

// 2. member.homeAddress
Address homeAddress = member.getHomeAddress();

// 3. member.favoriteFoods
Set<String> favoriteFoods = member.getFavoriteFoods();    // LAZY

// SQL : SELECT MEMBER_ID, FOOD_NAME FROM FAVORITE_FOODS
// WHERE MEMBER_ID = 1
for (String favoriteFood : favoriteFoods) {
    System.out.println("favoriteFood = " + favoriteFood);
}

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

// SQL : SELECT MEMBER_ID, CITY, STREET, ZIPCODE FROM ADDRESS
// WHERE MEMBER_ID = 1
addressHistory.get(0);

위 소스를 실행할 때 데이터베이스에 호출하는 SELECT SQL은 다음과 같다.

  1. member : 회원만 조회한다. 이때 임베디드 값 타입인 homeAddress도 함께 조회한다. SELECT SQL을 1번 호출한다.
  2. member.homeAddress : 1번에서 회원을 조회할 때 같이 조회해 둔다.
  3. member.favoriteFoods : LAZY로 설정해서 실제 컬렉션을 사용할 때 SELECT SQL을 1번 호출한다.
  4. member.addressHistory : LAZY로 설정해서 실제 컬렉션을 사용할 때 SELECT SQL을 1번 호출한다.

 

✔️ 값 타입 컬렉션 수정
값 타입 컬렉션을 수정하면 어떻게 되는지 알아보자!

기존 소스

            Member member = new Member();

						// 임베디드 값 타입
            member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));

						// 기본값 타입 컬렉션
            member.getFavoriteFoods().add("짬뽕");
            member.getFavoriteFoods().add("짜장");
            member.getFavoriteFoods().add("탕수육");

						// 임베디드 값 타입 컬렉션
            member.getAddressHistory().add(new Address("서울", "강남", "123-123"));
            member.getAddressHistory().add(new Address("부산", "강북", "000-000"));

            em.persist(member);

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

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

(1) 임베디드 값 타입 수정

            // 1. 임베디드 값 타입 수정
            Address a = findMember.getHomeAddress();
            findMember.setHomeAddress(new Address("newCity", a.getStreet(),a.getZipcode()));
  • 현재 Member의 홈 주소를 참고하여
  • 새로운 Address 클래스를 생성하여 update한다.

실행 결과
스크린샷 2022-03-29 오후 12 18 53

실행 결과를 보면 update문이 실행된 것을 확인할 수 있다.

 

(2) 기본 값 타입 컬렉션 수정

            // 2. 기본 값 타입 컬렉션 수정
            // 짬뽕 -> 한식
            findMember.getFavoriteFoods().remove("짬뽕");
            findMember.getFavoriteFoods().add("한식");
  • 짬뽕을 없애고, 한식으로 바꾼다.
  • 이때는 remove로 짬봉을 없애고, 한식을 새로 추가한다.

실행 결과

스크린샷 2022-03-29 오후 12 33 18
  • 실행 결과를 보면 삭제(delete문) 후, 삽입(insert문)이 실행된 것을 확인할 수 있다.

 

(3) 임베디드 값 타입 컬렉션 수정

            // 3. 임베디드 값 타입 컬렉션 수정
            // 업데이트
            findMember.getAddressHistory().remove(new Address("서울", "강남", "123-123"));
            findMember.getAddressHistory().add(new Address("newCity1", "street", "1000"));
  • 기존 Address 테이블에 있던 (서울, 강남, 123-123)을 삭제하고
  • 새로운 Address 테이블 (newCity1, street, 1000)을 추가한다.

실행 결과

스크린샷 2022-03-29 오후 12 25 56
  • delete문이 한 번 실행된 후, insert문이 2번 실행됬는데?
    • delete문 : ADDRESS 테이블에서 MEMBER_ID 관련 데이터들을 모두 지운다.
    • 1번 insert문, 2번 insert문을 실행한다.

➡️ 그런데, 이러한 값 타입 컬렉션 방법은 쓰면 안된다. (왜? 테이블 출력 내용들을 보고 추적하는게 상당히 어렵다.)

 

전체적인 실행 결과

변경 전
스크린샷 2022-03-29 오후 12 34 27

변경 후
스크린샷 2022-03-29 오후 12 34 04

  • MEMBER 테이블 조회 결과
  • ADDRESS 테이블 조회 결과
  • FAVORITE_FOODS 테이블 조회 결과

➡️ 수정한 내용들이 정상적으로 저장 및 화면에 출력된 것을 확인할 수 있다.

 

✔️ 값 타입 컬렉션 수정 추가 정리 내용

Member member = em.find(Member.class, 1L);

// 1. 임베디드 값 타입 수정
member.setHomeAddress(new Address("새로운도시", "신도시1", "123456"));

// 2. 기본값 타입 컬렉션 수정
Set<String> favoriteFoods = member.getFavoriteFoods();
favoriteFoods.remove("탕수육");
favoriteFoods.add("치킨");

// 3. 임베디드 값 타입 컬렉션 수정
List<Address> addressHistory = member.getAddressHistory();
addressHistory.remove(new Address("서울", "기존 주소", "123-123"));
addressHistory.add(new Address("새로운 도시", "새로운 주소", "123-456")); 
  1. 임베디드 값 타입 수정 : homeAddress 임베디드 값 타입은 MEMBER 테이블과 매핑했으므로 MEMBER 테이블만 UPDATE한다. 사실 Member 엔티티를 수정하는 것과 같다.
  2. 기본값 타입 컬렉션 수정 : 탕수육을 치킨으로 변경하려면 탕수육을 제거하고 치킨을 추가해야 한다. 자바의 String 타입은 수정할 수 없다.
  3. 임베디드 값 타입 컬렉션 수정 : 값 타입은 불변해야 한다. 따라서 컬렉션에서 기존 주소를 삭제하고 새로운 주소를 등록했다. 참고로 값 타입은 equals, hashCode를 꼭 구현해야 한다.

 

📖 B. 값 타입 컬렉션의 제약사항

✏️ 엔티티 vs 값 타입

  • 엔티티 : 식별자가 있으므로 엔티티의 값을 변경해도 식별자로 데이터베이스에 저장된 원본 데이터를 쉽게 찾아서 변경할 수 있다.
  • 값 타입 : 식별자라는 개념이 없고 단순한 값들의 모음이므로 값을 변경해버리면 데이터베이스에 저장된 원본 데이터를 찾기는 어렵다.

특정 엔티티 하나에 소속된 값 타입은 값이 변경되어도 자신이 소속된 엔티티를 데이터베이스에서 찾고 값을 변경하면 된다. 문제는 값 타입 컬렉션이다. 값 타입 컬렉션에 보관된 값 타입들은 별도의 테이블에 보관된다. 따라서 여기에 보관된 값 타입의 값이 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵다는 문제가 있다.

이런 문제로 인해 JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면, 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스에 다시 저장한다.

값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. 따라서 데이터베이스 기본 키 제약 조건으로 인해 컬럼에 null을 입력할 수 없고, 같은 값을 중복해서 저장할 수 없는 제약도 있다.

ex)
식별자가 100번인 회원이 관리하는 주소 값 타입 컬렉션을 변경하면 다음 SQL 같이 테이블에서 회원 100번과 관련된 모든 주소 데이터를 삭제하고 현재 값 타입 컬렉션에 있는 값을 다시 저장한다. 여기서는 현재 값 타입 컬렉션에 주소가 2건 있어서 2번 INSERT 되었다.

DELETE FROM ADDRESS WHERE MEMBER_ID=100
INSERT INTO ADDRESS (MEMBER_ID, CITY, STREET, ZIPCODE)
    VALUES (100, ...)
INSERT INTO ADDRESS (MEMBER_ID, CITY, STREET, ZIPCODE)
    VALUES (100, ...)

 

✔️ 값 타입 컬렉션 대안

  • 따라서 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다.
    • 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용한다.
  • 지금까지 설명한 문제를 해결하려면 값 타입 컬렉션을 사용하는 대신에 아래 예제처럼 새로운 엔티티를 만들어서 일대다 관계로 설정하면 된다.
  • 추가로 영속성 전이(Cascade) + 고아 객체 제거(ORPHAN REMOVE) 기능을 적용하면 값 타입 컬렉션처럼 사용할 수 있다.

AddressEntity - 새로운 엔티티

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

    @Id
    @GeneratedValue
    private Long id;
    private Address address;


    public AddressEntity(String city, String street, String money) {
        this.address = new Address(city, street, money);
    }
		// getter, setter
    ...
}
  • AddressEntity 생성자에서 Address를 생성한다.

 

Member

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

 

JpaMain

           Member member = new Member();

						// 임베디드 값 타입
            member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));

						// 기본값 타입 컬렉션
            member.getFavoriteFoods().add("짬뽕");
            member.getFavoriteFoods().add("짜장");
            member.getFavoriteFoods().add("탕수육");

						// 임베디드 값 타입 컬렉션
            member.getAddressHistory().add(new AddressEntity("서울", "강남", "123-123"));
            member.getAddressHistory().add(new AddressEntity("부산", "강북", "000-000"));

            em.persist(member);

            em.flush();
            em.clear();
            
            System.out.println("========START============");

            System.out.println("========end============");

            tx.commit();
  • Member : Address :
  • 현장에서는 이와 같이 많이 사용한다.

 

실행 결과

스크린샷 2022-03-29 오후 1 23 33

ADDRESS 테이블을 보면 이전에 없던 ID가 추가된 것을 확인할 수 있다. ➡️ 이는 엔티티를 의미한다.
이럴 경우, 엔티티 ID가 존재하므로 CITY, STREET, ZIPCODE를 마음껏 변경해도 된다.

 

💡 참고

  • 값 타입 컬렉션을 변경했을 때 JPA 구현체들은 테이블의 기본 키를 식별해서 변경된 내용만 반영하려고 노력한다.
  • 하지만 사용하는 컬렉션이나 여러 조건에 따라 기본 키를 식별할 수도 있고 식별하지 못할 수도 있다.
  • 따라서 값 타입 컬렉션을 사용할 때는 모두 삭제하고 다시 저장하는 최악의 시나리오를 고려하면서 사용해야 한다.
  • 값 타입 컬렉션의 최적화에 관한 내용은 각 구현체의 설명서를 참고하자!

 

📌 정리
(1) 엔티티 타입의 특징

  • 식별자(@Id)가 있다.
    • 엔티티 타입은 식별자가 있고 식별자로 구별할 수 있다.
  • 생명 주기가 있다.
    • 생성하고, 영속화하고, 소멸하는 생명 주기가 있다.
    • em.persist(entity)로 영속화한다.
    • em.remove(entity)로 제거한다.
  • 공유할 수 있다.
    • 참조 값을 공유할 수 있다. 이것을 공유 참조라 한다.
    • 예를 들어 회원 엔티티가 있다면 다른 엔티티에서 얼마든지 회원 엔티티를 참조할 수 있다.

 

(2) 값 타입의 특징

  • 식별자가 없다.
  • 생명 주기를 엔티티에 의존한다.
    • 스스로 생명주기를 가지지 않고 엔티티에 의존한다. 의존하는 엔티티를 제거하면 같이 제거된다.
  • 공유하지 않는 것이 안전하다.
    • 엔티티 타입과는 다르게 공유하지 않는 것이 안전하다. 대신에 값을 복사해서 사용해야 한다.
    • 오직 하나의 주인만이 관리해야 한다.
    • 불변(Immutable) 객체로 만드는 것이 안전하다.

값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다. 특히 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안 된다. 식별자가 필요하고 지속해서 값을 추적하고 구분하고 변경해야 한다면 그것은 값 타입이 아닌 엔티티다.

  • 진짜 간단할 때 치킨과 피자만 선택하는 것처럼, update가 필요없을 때 값 타입을 사용한다.
  • 주소 이력 조회와 같이 조금이라도 복잡하다면 엔티티를 사용해야 한다.

 

📚 6. 실전 예제 - 6. 값 타입 매핑

Member, Delivery에는 주소 정보가 나열되어 있다.

✔️ 값 타입 적용 전

public class Member {
    ...
    private String city;
    private String street;
    private String zipcode;
    ...
}

public class Delivery {
    ...
    private String city;
    private String street;
    private String zipcode;
    ...
}

Address라는 값 타입을 만들어서 나열된 주소 대신에 사용하도록 변경해보자!

 

✔️ 값 타입을 사용한 결과 UML

스크린샷 2022-03-14 오후 6 29 54

 

Address - 값 타입 주소

package jpabook.jpashop.domain;

import javax.persistence.Column;
import javax.persistence.Embeddable;
import java.util.Objects;

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

    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(getCity(), address.getCity()) && Objects.equals(getStreet(), address.getStreet()) && Objects.equals(getZipcode(), address.getZipcode());
    }

    @Override
    public int hashCode() {
        return Objects.hash(getCity(), getStreet(), getZipcode());
    }
}

 

Member - 회원에 값 타입 적용

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Member {

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

    private String name;
    
    // private String city;          // 삭제
    // private String street;        // 삭제
    // private String zipcode;       // 삭제

    @Embedded                        // 추가
    private Address address;         // 추가

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<Order>();
    ...
}

 

Delivery - 배송에 값 타입 적용

import javax.persistence.*;

@Entity
public class Delivery {

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

    @OneToOne(mappedBy = "delivery")
    private Order order;
    
    // private String city;          // 삭제
    // private String street;        // 삭제
    // private String zipcode;       // 삭제

    @Embedded                        // 추가
    private Address address;         // 추가

    @Enumerated(EnumType.STRING)
    private DeliveryStatus status; //ENUM [READY(준비), COMP(배송)]
    ...
}

값 타입 주소(Address)의 Address 값 타입을 만들고 이것을 회원(Member)에 값 타입 적용와 배송(Delivery)에 값 타입 적용에 적용했다. 이제 주소 정보에 필드나 로직이 추가되면 Address 값 타입만 변경하면 된다.

 


참고

profile
"야, (오류 만났어?) 너두 (해결) 할 수 있어"

0개의 댓글