쌩로그

Jpa기본 09. 값 타입(인프런 + 자바 ORM 표준 JPA 프로그래밍) 본문

Spring/JPA

Jpa기본 09. 값 타입(인프런 + 자바 ORM 표준 JPA 프로그래밍)

.쌩수. 2023. 12. 3. 11:15
반응형

포스팅 개요

  1. 포스팅 개요
  2. 본론
        2-1. 기본값 타입
        2-2. 임베디드 타입(복합 값 타입)
        2-3. 값 타입과 불변 객체
        2-4. 갑 타입의 비교
        2-5. 값 타입 컬렉션
  3. 요약

1. 포스팅 개요

해당 포스팅은 인프런에서 영한님의 JPA기본 강의에서 값 타입 파트와 해당 파트에 맞는 책의 챕터를 보고 학습한 내용을 요약 및 정리하는 포스팅입니다.

2. 본론

JPA의 데이터 타입을 가장 크게 분류하면 엔티티 타입과 값 타입으로 나눌 수 있습니다.
엔티티 타입은 @Entity로 정의하는 객체고, 값 타입은 int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말합니다.

엔티티 타입은 식별자를 통해 지속해서 추적할 수 있지만, 값 타입은 식별자가 없고 숫자나 문자같은 속성만 있으므로 추적할 수 없습니다.\

값 타입은 다음 3가지로 나눌 수 있습니다.

  • 기본 값 타입(basic value type)
    • 자바 기본 타입(예 : int, double)
    • 래퍼 클래스(예 : Integer)
    • String
  • 임베디드 타입(embedded type : 복합 값 타입)
  • 컬렉션 값 타입(collection value type)

기본 값 타입은 String, int처럼 자바가 제공하는 기본 데이터 타입이고, 임베디드 타입은 JPA에서 사용자가 직접 정의한 값 타입입니다.

컬렉션 갑 타입은 하나 이상의 값 타입을 저장할 때 사용합니다.

2-1. 기본값 타입

다음은 기본 값 타입 예시입니다.

@Entity
public class Member {

  @Id @GenerateValue
  private Long id;

  private String name;
  private int age;
}

위의 코드 Member에서 String, int가 값 타입입니다.
값 타입인 name, age 속성은 식별자 값도 없고 생명주기도 회원 엔티티에 의존합니다.
따라서 회원 엔티티 인스턴스를 제거하면 name, age 값도 제거됩니다.
그리고 값 타입은 공유하면 안 됩니다.

참고

자바에서 int, double 같은 기본 타입(primitive type)은 절대 공유되지 않습니다.
예를 들어 a=b 코드는 b의 값을 복사해서 a에 입력합니다. 물론 Integer처럼 패러 클래스나 String 같은 특수한 클래스도 있습니다. 이것들은 객체지만 자바 언어에서 기본 타입처럼 사용할 수 있게 지원하므로 기본 값 타입으로 정의했습니다.

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

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

다은은 임베디드 타입 예시입니다.

@Entity
public class Member {

  @Id @GenerateValue
  private Long id;
  private String name;

  // 근무기간
  private LocalDateTime startDate; // 책에서는 @Temporal 애너테이션 사용. 요즘은 LocalDateTime를 알아서 JPA가 읽음
  private LocalDateTime endDate;

  // 집 주소 표현
  private String city;
  private String street;
  private String zipCode;
}

위의 코드는 평범한 Member 엔티티입니다.
누군가에게 이 코드를 설명하려면 어떻게 이야기 할까요..?
아마 다음과 같이 할 것입니다.

"회원 엔티티는 이름, 근무시작일, 근무종료일, 주소 도시, 주소 번지, 주소 우편 번호를 가집니다."

하지만, 이런 설명은 단순히 정보를 풀어둔 것뿐입니다.
근무 시작일과 우편번호는 서로 아무 관련이 없습니다. 이보단 다음처럼 설명하는 것이 더 명확합니다.

"회원 엔티티는 이름, 근무 기간, 집 주소를 가집니다."

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

근무기간, 집 주소를 가지도록 임베디드 타입을 사용해보겠습니다.

@Entity
public class Member {

  @Id @GenerateValue
  private Long id;

  private String name;

  @Embedded Period workPeriod; // 근무 기간
  @Embedded Address homeAddress; // 집 주소
}

// 근무 기간
@Embeddable
public class Period {

  private LocalDateTiem startDate;
  private LocalDateTiem endDate;

  public boolean isWork(LocalDateTime date) {
    // .. 값 타입을 위한 메서드
  }
}

// 주소
@Embeddable
public class Address {

  @Column(name ="city") // 매핑할 컬럼 정의 가능
  private String city;
  private String street;
  private String zipcode;

}

Member 코드를 보면 회원 엔티티가 더욱 의미 있고, 응집력 있게 변한 것을 알 수 있습니다.
Period 코드를 보면 startDate, endDate를 합해서 Period 클래스를 만들었습니다.
Address 코드를 보면 city, street, zipcode를 합해서 Address(주소)클래스를 만들었습니다.

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

임베디드 타입을 사용하려면 다음 2가지 어노테이션이 필요합니다.
참고로 둘 중 하나는 생략가능합니다.
(참고로 명시적인 것을 좋아하시는 영한님은 둘 다 사용하신다고 하셨습니다.)

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

그리고 임베디드 타입은 기본 생성자가 필수입니다.
임베디드 타입을 포함한 모든 값 타입은 엔티티의 생명주기에 의존하므로 엔티티와 임베디드 타입의 관계를 UML로 표현하면 컴포지션(composition) 관계가 됩니다.

참고

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

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

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

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

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

참고 - 임베디드 타입과 UML

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

임베디드 타입과 연관관게

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

참고

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

@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를 참조합니다.

@AttributeOverride : 속성 재정의

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

@Entity
public class Member {

  @Id @GenerateValue
  private Long id;
  private String name;

  @Embedded Address homeAddress;
  @Embedded Address companyAddress;
}

위의 코드를 보면 집 주소에 회사 주소를 하나 더 추가했습니다.
문제는 테이블에 매핑하는 컬럼명이 중복됩니다.
이 때 다음과 같이 @AttributeOverrides를 사용해서 매핑정보를 재정의해야 합니다.
(참고로 @AttributeOverride는 7장 고급 매핑 쪽에서도 나왔습니다.)

@Entity
public class Member {

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

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

    // 주소
    @Embedded
    private Address homeAddress;

    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name="city", column=@Column(name = "COMPANY_CITY")),
            @AttributeOverride(name="street", column=@Column(name = "COMPANY_STREET")),
            @AttributeOverride(name="zipcode", column=@Column(name = "COMPANY_ZIPCODE"))
    })
    private Address companyAddress;

}

위의 코드에서 생성된 테이블을 보면 재정의한대로 변경되어 있습니다.

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

참고

@AttributeOverrides는 엔티티에 설정해야합니다.
임베디드 타입이 임베디드 타입을 가지고 있어도 엔티티에 설정해야 합니다.

임베디드 타입과 null

참고로 임베디드 타입이 null이면 매핑한 컬럼 값은 모두 null이 됩니다.

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

위의 코드대로라면 주소와 관련된 CITY, STREET, ZIPCODE 컬럼은 모두 null이 됩니다.

2-3. 값 타입과 불변 객체

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

값 타입 공유 참조

임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험합니다.
공유하면 어떤 문제가 발생할까요?

위의 그림에 대한 상황을 코드로 나타내면 다음과 같습니다.

// 책 예제
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();

address.setCity("NewCity"); // 회원1의 address 값을 공유해서 사용
member2.setHomeAddress(address);

// 현재 시점 코드
Member member1 = new Member();
Member member2 = new Member();

Address address = new Address("oldCity", "street","10000");

member1.setHomeAddress(address);
member2.setHomeAddress(address);

member2.getHomeAddress().setCity("newCity"); // member2에만 변경

em.persist(member1);
em.persist(member2);

회원2에 새로운 주소를 할당하려고 회원1의 주소를 그대로 참조해서 사용했습니다.
다음 코드를 실행하면 어떻게 될까요? 회원2의 주소만 NewCity로 변경되길 기대했지만, 회원1의 주소도 "NewCity"로 변경되어 버립니다.

그 결과입니다.

그리고 위의 그림에서 보면 회원1과 회원2가 같은 address 인스턴스를 참조하기 떄문인데,
영속성 컨텍스트는 회원1과 회원2 둘 다 city 속성이 변경된 것으로 판단해서 회원1, 회원2 각각 UPDATE SQL을 실행합니다.

이러한 공유 참조로 인해 발생하는 버그는 정말 찾아내기 힘든데요.
이렇듯 뭔가를 수정했는데 전혀 예상치 못한 곳에서 문제가 발생하는 것을 부작용(side effect)이라고 합니다.
이런 부작용을 막으려면 값을 복사해서 사용하면 됩니다.

값 타입 복사

값 타입의 실제 인스턴스인 값을 공유하는 것은 위험합니다. 대신에 값(인스턴스)를 복사해서 사용해야 합니다.

위의 그림을 코드로 나타내면 다음과 같습니다.

  // 책
member1.setHomeAddress(new Address("oldCity"));
Address address = member1.getHomeAddress();

// 회원1의 address 값을 복사해서 새로운 newAddress값을 생성
Address newAddress = address.clone();

newAddress.setCity("NewCity");
member2.setHomeAddress(newAddress);

// 현재 시점
Member member1 = new Member();
Member member2 = new Member();

Address address = new Address("oldCity", "street","10000");

member1.setHomeAddress(address);

em.persist(member1);
em.persist(member2);

System.out.println("==================아래 UPDATE 쿼리나감 ===================");
member2.setHomeAddress(new Address("newCIty", address.getStreet(), address.getZipcode()));

회원 2에 새로운 주소를 할당하기 위해 기존의 address값을 복사해서 새로운 Address 인스턴스를 생성해서 할당하도록 했습니다.
(책에서는 clone으로 나와있는데, 인스턴스를 복사해서 생성된 인스턴스를 반환하는 기능이라고 합니다.)

위의 코드를 실행하면 의도한 대로 회원2의 주소만 판단해서 회원2에 대해서 UPDATE SQL을 실행합니다.

이와 같이 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있습니다.
문제는 임베디드 타입처럼 직접 정의한 값 타입은 **자바의 기본 타입(primitive type)이 아니라 객체 타입이라는 것입니다.

자바는 기본 타입에 값을 대입하면 값을 복사해서 전달합니다.

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

위의 코드의 최종 결과는
a = 10, b = 4입니다. int b = a에서 a의 값 10을 복사해서 b에 넘겨주었습니다. 따라서 a,b는 완전히 독립된 값을 가지며, 부작용이 없습니다.
문제는 Address같은 객체입니다. 자바는 객체에 값을 대입하면 항상 참조값을 전달합니다.

Address a = new Address("Old");
Address b = a; // 객체 타입은 항상 참조 값을 전달합니다.
b.setCity("New");

Address b = a에서 a가 참조하는 인스턴스의 참조 값을 b에 넘겨줍니다. 따라서 a와 b는 같은 인스턴스를 공유 참조합니다.
마지막 줄의 b.setCity("New")의 의도는 b.city 값만 변경하려 했지만 공유 참조로 인해 부작용이 발생해서 a.city 값도 변경됩니다.

물론 객체를 대입할 때마다 인스턴스를 복사해서 대입하면 공유 참조를 피할 수 있습니다.
문제는 복사하지 않고 원본의 참조 값을 직접 넘기는 것을 막을 방법이 없다는 것입니다.
자바는 대입하려는 것이 값 타입인지 아닌지는 신경 쓰지 않습니다. 단지 자바 기본 타입이면 값을 복사해서 넘기고, 객체면 참조를 넘길 뿐입니다.

Address a = new Address("Old");
Address b = a.clone(); // 항상 복사해서 넘겨야 한다.
// Address b = a; // 이렇게 참조만 넘기면 부작요이 발생할 수 있다.
b.setCity("New");

객체의 공유 참조는 피할 수 없습니다.
따라서 근본적인 해결책이 필요한데 가장 단순한 방법은 객체의 값을 수정하지 못하게 막으면 됩니다.
예를 들어 Address 객체의 setCity() 같은 수정자 메소드를 모두 제거해하면 공유 참조를 해도 값을 변경하지 못하므로 부작용의 발생을 막을 수 있습니다.

불변 객체

값 타입은 부작용 걱정 없이 사용할 수 있어야 합니다.
부작용이 일어나면 값 타입이라고 할 수 없습니다. 객체를 불변하게 만들면 값을 수정할 수 없으므로 부작용을 원천 차단할 수 있습니다. 따라서 값 타입은 될 수 있으면 불변 객체(immutable Object)로 설계해야 합니다.

한 번 만들면 절대 변경할 수 없는 객체를 불변 객체라고 합니다.
불변 객체의 값은 조회할 수 있지만 수정할 수 없습니다.
불변 객체도 결국은 객체입니다.
따라서 인스턴스의 참조 값 공유를 피할 수 없습니다.
하지만 참조 값을 공유해도 인스턴스의 값을 수정할 수 없으므로 부작용이 발생하지 않습니다.

불변 객체를 구현하는 다양한 방법이 있지만 가장 간단한 방법은 생성자로만 값을 설정하고 수정자를 만들지 않으면 됩니다.

다음은 불변 객체로 정의된 Address 클래스입니다.

@Embeddable
public class Address {

  private String city;

  protected Address() {} // JPA에서 기본 생성자는 필수입니다.

  // 생성자로 초기값을 설정합니다.
  public Address(String city) {
    this.city = city;
  }

  // 접근자(Getter)는 노출한다.
  public String getCity() {
    return city;
  }

  // 수정자(Setter)는 만들지 않는다.

}


// 불변 Address 사용
Address adress = member1.getHomeAddress();
// 회원1의 주소 값을 조회해서 새로운 주소값을 생성
Address newAddress = new Address(adress.getCity());
member2.setHomeAddress(newAddress);

위의 Address 클래스는 이제 불변 객체입니다.
값을 수정할 수 없으므로 공유해도 부작용이 발생하지 않습니다.
만약 값을 수정해야한다면 사용하는 예시 코드처럼 새로운 객체를 생성해서 사용하면 됩니다.
참고로 Integer,String 은 자바자 제공하는 대표적인 불변 객체입니다.

정리하자면 불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막을 수 있습니다.

2-4. 갑 타입의 비교

자바가 제공하는 객체 비교는 2가지입니다.

  • 동일성(identity) 비교 : 인스턴스의 참조 값을 비교, == 사용
  • 동등성(Equivalence) 비교 : 인스턴스의 값을 비교, equals() 사용
Address a = new Address("서울시")
, "종로구", "1번지");
, "종로구", "1번지");

Address 값 타입을 a == b 로 동일성 비교하면 둘은 서로 다른 인스턴스이므로 결과는 거짓입니다.
하지만 이것은 기대하는 결과가 아닙니다. 값 타입은 비록 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 합니다. 따라서 값 타입을 비교할 때는 a.equalse(b)를 사용해서 동등성 비교를 해야 합니다.
물론 Address의 equals() 메소드를 재정의해야 합니다.

값 타입의 equals() 메서드를 재정의할 때는 보통 모든 필드의 값을 비교하도록 구현합니다.

참고

자바에서 equalse()를 재정의하면 hashCode()도 재정의하는 것이 안전합니다. 그렇지 않으면 해시를 사용하는 컬렉션(HashSet, HashMap)이 정상 작동하지 않습니다. 자바 IDE에는 대부분 equals, hashCode 메소드를 자동으로 생성해주는 기능이 있습니다.

2-5. 값 타입 컬렉션

값 타입을 하나이상 저장하려면 컬렉션에 보관하고 @ElementCollection, @CollectionTable 어노테이션을 사용하면 됩니다. 다음 예시를 보겠습니다.

@Entity
public class Member {

  @Id @GenerateValue
  private Long id;

  @Embedded
  private Address homeAddress;

  @ElementCollection
  @CollectionTable(name = "FAVORITE_FOODS",
    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<>();
  //...
}

@Embeddable
public class Address {

  @Column
  private String city;
  private String street;
  private String zipcode;

}

예시 코드의 Member 엔티티를 보면 값 타입 컬렉션을 사용하는 favotieFoods, addressHistory에 @ElemnetCollection을 지정했습니다.
위의 그림 중 위의 그림은 객체의 UML을 표시한 것입니다.

favoriteFoods는 기본값 타입인 String을 컬렉션으로 가집니다.
이것을 데이터베이스 테이블로 매핑해야 하는데 관계형 데이터베이스의 테이블은 컬럼 안에 컬렉션을 포함할 수 없습니다.
따라서 그림 중 아래 ERD 그림처럼 별도의 테이블을 추가하고 @CollectionTable를 사용해서 추가한 테이블을 매핑해야 합니다.
favoriteFoods처럼 값으로 사용되는 컬럼이 하나면 @Column을 사용해서 컬럼명을 지정할 수 있습니다.
addressHistory은 임베디드 타입인 Address를 컬렉션으로 가집니다. 마찬가지로 별도의 테이블을 사용해야 합니다.그리고 테이블 매핑정보는 @AttributeOverride를 사용해서 재정의할 수 있습니다.

참고

@CollectionTable를 생략하면 기본값을 사용해서 매핑합니다.
기본갑 : 엔티티이름_컬렉션 속성 이름
예를 들어, Member 엔티티의 addressHistory는 Member_addressHistory 테이블과 매핑합니다.

값 타입 컬렉션 사용

값 타입 컬렉션 사용 예시입니다.

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("서울","용산","123-123"));

em.persist(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 결과입니다.

참고

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


값 타입 컬렉션도 조회할 때 패치 전략을 선택할 수 있는데 LAZY가 기본입니다.

@ElementCollection(fetch = FetchType.LAZY)

지연 로딩으로 모두 설정했을 때 다음 예제를 실행해보겠습니다.

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("서울","용산","123-123"));

em.persist(member);

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

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

Address homeAddress = findMember.getHomeAddress();
System.out.println("========== 2. member.homeAddress ============");

Set<String> faoriteFoods = findMember.getFavoriteFoods(); // LAZY
System.out.println("========== 3. member.favoriteFoods ============");

System.out.println("faoriteFoods 실객체 생성 직전");
for(String favoriteFood : faoriteFoods) {
System.out.println("favoiteFood = " + favoriteFood);
}

List<Address> addressHistory = findMember.getAddressHistory(); // LAZY
System.out.println("========== 4. member.addressHistory ============");

System.out.println("addressHistory 실객체 생성 직전");
addressHistory.get(0);

다음은 결과입니다.
(insert는 아까 봤으므로 생략했습니다.)

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 = em.find(Member.class, 1L);

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

// 2. 기본값 타입 컬렉션 수정
Set<String> favotieFoods = 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를 꼭 구현해야 합니다.

값 타입 컬렉션의 제약사항

값 타입은 식별자라는 개념이 없고 단순한 값들의 모음아므로 값을 변경해버리면 데이터베이스에 저장된 원본 데이터를 찾기는 어렵습니다.
특정 엔티티 하나에 소속된 값 타입은 값이 변경되어도 자신이 소속된 엔티티를 데이터베이스에서 찾고 값을 변경하면 됩니다. 문제는 값 타입 컬렉션입니다. 값 타입 컬렉션에 보관된 값 타입들은 별도의 테이블에 보관됩니다. 따라서 여기에 보관된 값 타입의 값이 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵다는 문제가 있습니다.
이런 문제로 인해 JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면, 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스에 다시 저장합니다.

예를 들어 식별자가 100번인 회원이 관리하는 주소 값 타입 컬렉션을 변경하면 다음 SQL 같이 테이블에서 회원 100번과 관련된 모든 주소 데이터를 삭제하고 현재 값 타입 컬렉션에 있는 값을 다시 저장합니다.

다음 얘시입니다.

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"));
member.getAddressHistory().add(new Address("old3", "street", "10000"));

em.persist(member);

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

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

// old -> new
findMember.getAddressHistory().remove(new Address("old1", "street", "10000")); // equlas, hashCode로 등등한거 찾음
findMember.getAddressHistory().add(new Address("newCity1", "street", "10000"));

이처럼 값 타입 컬렉션에서 하나만 수정하려고 했지만, delete 쿼리 한 번으로 관련된 데이터를 모두 삭제하고 insert 쿼리를 세 번 날렸습니다.

실무에서는 값 타입 컬렉션이 매핑된 테이블에서 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려해야 합니다.

추가로 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 합니다.
데이터베이스 기본 키 제약 조건으로 인해 컬럼에 null을 입력할 수 없고, 같은 값을 중복해서 저장할 수 없는 제약도 있습니다.
지금까지 설명한 문제를 해결하려면 값 타입 컬렉션을 사용하는 대신 다늠 코드 예시처럼 새로운 엔티티를 만들어서 일대다 관계로 설정하면 됩니다. 여기에 추가로 영속성 전이(Cascade) + 고아 객체 제거(PRPHAN REMOVE) 기능을 적용하면 값 타입 컬렉션처럼 사용할 수 있습니다.

@Entity
public class AddressEntity {

  @Id
  @GenerateValue
  private Long id;

  @Embedded Address address;
  ...

}

설정 코드는 다음과 같습니다.

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

참고

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

3. 요약

엔티티 타입(Entity Type)과 값 타입(value Type)의 특징은 다음과 같습니다.

엔티티 타입의 특징

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

값 타입의 특징

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

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

참고 1

값 타입은 Value Object입니다.

참고 2

값 타입과 엔티티에 대한 더 상세한 내용은 조영호님의 글(aeternum.egloos.com)이나 (현재 서비스가 종료되었습니다.) 책 도메인 주도 설계를 읽어보길 권한다고 합니다.

다음 포스팅은 JPQL인데, 강의상 챕터는 2번에 나눠져있지만, 책에서는 하나의 챕터로 구성되어있습니다.
따라서 강의 두 챕터 + 책 한 챕터의 내용이 다음 포스팅 내용이 될 거 같습니다.

감사합니다.

728x90
Comments