본문 바로가기
Dev/JPA

[JPA] 값 타입

by dev_jsk 2021. 9. 1.
728x90
반응형

JPA의 데이터 타입 분류

엔티티 타입

  • @Entity로 정의하는 객체
  • 데이터가 변해도 식별자로 지속해서 추적 가능
  • ex. 회원 엔티티의 키나 나이 값을 변경해도 식별자로 인식 가능

값 타입

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

기본값 타입

종류

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

특징

  • 생명주기를 엔티티의 의존 (ex. 회원을 삭제하면 이름, 나이 필드도 함께 삭제)
  • 값 타입은 공유하면 안된다. (ex. 회원 이름 변경 시 다른 회원의 이름도 함께 변경되면 안된다. 부작용(=side effect))
    ※ 자바의 기본 타입 int, double 같은 기본 타입(primitive type)은 항상 값을 복사하며 절대 공유되지 않고, Integer같은 래퍼 클래스나 String같은 특수한 클래스도 공유 가능한 객체(참조값을 공유)이지만 변경할 수 없다.

임베디드 타입

특징

  • 새로운 값 타입을 직접 정의할 수 있다.
  • JPA는 임베디드 타입(embedded type)이라 한다.
  • 주로 기본값 타입을 모아 만들어서 복합값 타입이라고 한다.
  • int, String과 같은 값 타입

장점

  • 재사용이 용이하다.
  • 높은 응집도를 가진다.
  • 해당 값 타입만 사용하는 의미 있는 메소드를 만들 수 있다.
  • 임베디드 타입을 포함한 모든 값 타입은 값 타입을 소유한 엔티티에 생명주기를 의존한다.

테이블 매핑

  • 임베디드 타입은 엔티티의 값일 뿐이다. (값이 NULL이면 매핑한 컬럼 값 모두 NULL)
  • 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다.
  • 객체와 테이블을 아주 세밀하게(find - grained) 매핑하는 것이 가능하다.
  • 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.

연관관계

임베디드 타입 클래스 내에 엔티티가 속할 수 있다.

사용 방법

  • @Embeddable : 값 타입을 정의하는 곳에 해당 어노테이션 사용
  • @Embedded : 값 타입을 사용하는 곳에 해당 어노테이션 사용
  • 임베디드 타입을 선언한 클래스에는 기본 생성자를 필수로 가져야 한다.

예제

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

 

구조

회원 엔티티와 근무기간, 집주소 값 타입의 관계

테이블 매핑

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

소스

- Member.java

@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;

    // getter, setter
}

- Period.java

@Embeddable  // 임베디드 타입 : 값 타입 정의 어노테이션
public class Period {
  
    private LocalDateTime startDate;
    private LocalDateTime endDate;

    public Period() {}

    public Period(LocalDateTime startDate, LocalDateTime endDate) {
      this.startDate = startDate;
      this.endDate = endDate;
    }

    // getter, setter
}

- Address.java

@Embeddable // 임베디드 타입 : 값 타입 정의 어노테이션
public class Address {
  
    private String city;
    private String street;
    private String zipcode;

    public Address() {}

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

    // getter, setter
}

- JpaMain.java

// 임베디드 타입 예시
Member member = new Member();
member.setUsername("userA");
member.setHomeAddress(new Address("city", "street", "100-1"));
member.setWorkPeriod(new Period());

em.persist(member);

동작 결과

(1) Table DDL 결과

임베디드 타입 값이 컬럼으로 생성된다.

(2) Insert SQL 및 결과

@AttributeOverride (속성 재정의)

  • 한 엔티티에서 같은 값 타입을 사용하기 위한 어노테이션
  • 그냥 같은 값 타입을 사용하게 되면 컬럼명 중복 문제 발생
  • @AttributeOverrides, @AttributeOverride를 사용하여 컬럼명 재정의

속성 재정의 예제

소스

- Member.java

@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;
    
    // 주소
    @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;

    // getter, setter
}

동작 결과

기존 Address와 재정의한 컬럼명이 생성된다.

값 타입과 불변 객체

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

값 타입 공유 참조

임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다. (side effect 발생)

 

예시

// JpaMain.java
Address address = new Address("city", "street", "100-1");

Member member = new Member();
member.setUsername("userA");
member.setHomeAddress(address);
em.persist(member);

Member member2 = new Member();
member2.setUsername("userB");
member2.setHomeAddress(address);
em.persist(member2);

// 참조 예시. member, member2 전부 new City로 변경된다.
member.getHomeAddress().setCity("new City");

회원 1, 2의 주소가 전부 변경된다.

값 타입 복사

값 타입의 실제 인스턴스인 값을 공유하는 것은 위험하기 때문에 값(인스턴스)를 복사하여 사용한다.

 

예시

// JpaMain.java
Address address = new Address("city", "street", "100-1");

Member member = new Member();
member.setUsername("userA");
member.setHomeAddress(address);
em.persist(member);

// 값 타입 복사 예시(Address 복사)
Address copyAddress = new Address("city", "street", "100-1");

Member member2 = new Member();
member2.setUsername("userB");
member2.setHomeAddress(copyAddress);  // 복사한 인스턴스로 변경
em.persist(member2);

// member의 Address 정보만 변경된다.
member.getHomeAddress().setCity("new City");

서로 다른 인스턴스이기 때문에 member의 Address 정보만 변경된다.

※ 기존 인스턴스 값 변경 시에도 복사하여 사용한다.

// 기존 Address
Address address = new Address("city", "street", "100-1");

// address 내 값을 바꾸고 싶을때도
// 해당 인스턴스를 복사하여
// 필요한 부분의 값만 바꿔 생성자를 통해 새 인스턴스를 만들어 바꿔야 한다.
Address newAddress = new Address("New City", address.getStreet(), address.getZipcode());

객체 타입의 한계

  • 임베디드 타입처럼 직접 정의한 값 타입은 자바 기본 타입이 아닌 객체 타입이다.
  • 자바 기본 타입에 값을 대입하면 복사를 하지만 객체 타입은 참조값을 전달하기 때문에 값을 공유하게 되어 추후 값 변경 시 영향을 받게 된다.
  • 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없어 객체의 공유 참조는 피할 수 없다.

불변 객체

  • 생성 시점 이후 절대 값을 변경할 수 없는 객체로 생성자로만 값을 설정하고 수정자(setter)를 만들지 않는 객체
  • 객체 타입을 수정할 수 없도록 만들어 부작용(side effect)을 원천 차단하기 때문에 값 타입은 불변 객체(immutable object)로 설계해야 한다.
  • ex. Integer, String

값 타입 비교

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

동일성 비교 vs 동등성 비교

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

예제

/** ValueMain.java */
// 자바 기본 타입 비교 (== 사용)
int a = 10;
int b = 10;

System.out.println("a == b : " + (a==b));  // true

// 값 타입 비교 (equals() 사용)
Address address1 = new Address("city", "street", "zipcode");
Address address2 = new Address("city", "street", "zipcode");

// equals() 기본 동작이 == 비교이기 때문에 false
// Address 클래스 내에서 equals() 상속받아 재정의하여 동작시 true
System.out.println("address1 == address2 : " + (address1.equals(address2)));

/** Address.java */
private String city;
private String street;
private String zipcode;

public Address() {}

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

// equals() 재정의
@Override
public boolean equals(Object obj) {
    if (this == obj) return true;
    if (obj == null || getClass() != obj.getClass()) return false;
    Address address = (Address) obj;
    return (Objects.equals(city, address.city)
        && Objects.equals(street, address.street)
        && Objects.equals(zipcode, address.zipcode));
}

// equals() 재정의 시 hashCode()도 재정의 필요
@Override
public int hashCode() {
    return Objects.hash(city, street, zipcode);
}

equals() 재정의 후 true

값 타입 컬렉션

  • 관계형 데이터베이스는 테이블 내에 컬렉션을 담을 수 있는 구조가 없기 때문에 별도의 테이블이 필요하다.
  • 값 타입을 하나 이상 저장할때 사용한다.
  • @ElementCollection, @CollectionTable어노테이션을 사용하여 구현한다.

값 타입 컬렉션 사용 예제

구조

엔티티(위) / 테이블(아래)

저장 예제

- Member.java

@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;

    @ElementCollection  // 컬렉션 지정
    @CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))  // 컬렉션 테이블
    @Column(name = "FOOD_NAME")  // 테이블 내 외래키 제외 1개의 컬럼일 경우 예외적으로 사용
    private Set<String> favoriteFoods = new HashSet<>();

    @ElementCollection  // 컬렉션 지정
    @CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))  // 컬렉션 테이블
    private List<Address> addressHistory = new ArrayList<>();

    // getter, setter
}

- JpaMain.java

Member member = new Member();
member.setUsername("userA");
member.setHomeAddress(new Address("homeCity", "street", "zipcode"));

member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("햄버거");
member.getFavoriteFoods().add("아이스크림");

member.getAddressHistory().add(new Address("oldCity1", "street", "zipcode"));
member.getAddressHistory().add(new Address("oldCity2", "street", "zipcode"));

em.persist(member);  // 값 타입은 생명주기가 없기 때문에 Member의 생명주기를 따라간다.

- Table DDL SQL

- Insert SQL 및 결과

값 타입은 생명주기가 없기 때문에 Member의 생명주기를 따른다.

 

조회 예제

- Member.java는 이전 예제와 동일

- JpaMain.java

Member member = new Member();
member.setUsername("userA");
member.setHomeAddress(new Address("homeCity", "street", "zipcode"));

member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("햄버거");
member.getFavoriteFoods().add("아이스크림");

member.getAddressHistory().add(new Address("oldCity1", "street", "zipcode"));
member.getAddressHistory().add(new Address("oldCity2", "street", "zipcode"));

em.persist(member);  // 값 타입은 생명주기가 없기 때문에 Member의 생명주기를 따라간다.

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

System.out.println("===== find Member Start =====");
Member findMember = em.find(Member.class, member.getId());  // Member 정보만 조회한다. (지연로딩)

System.out.println("===== find AddressHistory Start =====");
List<Address> addressHistory = findMember.getAddressHistory();  // 강제 호출, DB 조회
for(Address address : addressHistory) {
    System.out.println("address.city : " + address.getCity());
}

System.out.println("===== find FavoriteFoods Start =====");
Set<String> favoriteFoods = findMember.getFavoriteFoods(); // 강제 호출, DB 조회
for(String food : favoriteFoods) {
    System.out.println("favorite food : " + food);
}

- Select SQL 및 결과

값 타입은 지연로딩이기 때문에 Member만 조회한다.

※ 값 타입 컬렉션도 지연로딩 전략 사용

 

수정 예제

- Member.java는 이전 예제와 동일

- JpaMain.java

Member member = new Member();
member.setUsername("userA");
member.setHomeAddress(new Address("homeCity", "street", "zipcode"));

member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("햄버거");
member.getFavoriteFoods().add("아이스크림");

member.getAddressHistory().add(new Address("oldCity1", "street", "zipcode"));
member.getAddressHistory().add(new Address("oldCity2", "street", "zipcode"));

em.persist(member);  // 값 타입은 생명주기가 없기 때문에 Member의 생명주기를 따라간다.

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

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

// (1) homeCity -> newCity (homeAddress)
//     값 타입 수정은 단순 값 수정이 아닌 새로운 인스턴스로 변경해야 한다.
//     (단순 값 수정은 side effect 발생)
Address oldAddress = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", oldAddress.getStreet(), oldAddress.getZipcode()));

// (2) 치킨 -> 한식
//     값 타입 컬렉션 수정은 기존 값을 지우고 새로 추가해야 한다.
//     컬렉션의 값만 변경해도 JPA가 알아서 insert, update, delete 쿼리를 실행한다.
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");

// (3) oldCity1 -> newCity1 (addressHistory)
//     값 타입 컬렉션이 수정되면 주인 엔티티와 관련된 모든 데이터를 삭제하고
//     값 타입 컬렉션에 현재 남은 값을 다시 insert 한다.
findMember.getAddressHistory().remove(new Address("oldCity1", "street", "zipcode"));
findMember.getAddressHistory().add(new Address("oldCity1", "street", "zipcode"));

- Update SQL

addressHistory의 경우 컬렉션 내 값이 변경되면 주인 엔티티와 연관된 값을 모두 삭제 후 컬렉션에 남아있는 값을 다시 insert 한다.

※ 값 타입 컬렉션은 영속성 전이 + 고아객체 제거 기능을 필수로 가진다고 볼 수 있다.

값 타입 컬렉션의 제약사항

  • 값 타입은 엔티티와 다르게 식별자 개념이 없어 값 변경시 추적이 어렵다.
  • 값 타입 컬렉션에 변경 사항이 발생하면 주인 엔티티와 연관된 모든 데이터를 삭제하고 값 타입 컬렉션에 있는 현재값을 모두 저장한다.
  • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야 한다. (NULL 허용 X, 중복 저장 X)

값 타입 컬렉션 대안

  • 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려하는 것이 더 좋다.
  • 일대다 관계를 위한 엔티티를 만들고 여기에서 값 타입을 사용한다.
  • 영속성 전이 + 고아객체 제거를 사용해서 값 타입 컬렉션 처럼 사용한다.

예제

- Member.java

@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;

    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
    @Column(name = "FOOD_NAME")
    private Set<String> favoriteFoods = new HashSet<>();

    // 값 타입 컬렉션 대안
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "MEMBER_ID")
    private List<AddressEntity> addressHistory = new ArrayList<>();

    // getter, setter
}

- AddressEntity.java

@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
    
    @Id @GeneratedValue
    private Long id;

    private Address address;

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

    // getter, setter
}

- JpaMain.java

Member member = new Member();
member.setUsername("userA");
member.setHomeAddress(new Address("homeCity", "street", "zipcode"));

member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("햄버거");
member.getFavoriteFoods().add("아이스크림");

// 값 타입 컬렉션 대안
member.getAddressHistory().add(new AddressEntity("oldCity1", "street", "zipcode"));
member.getAddressHistory().add(new AddressEntity("oldCity2", "street", "zipcode"));

em.persist(member);  // 값 타입은 생명주기가 없기 때문에 Member의 생명주기를 따라간다.

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

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

// 값 타입 컬렉션 대안
findMember.getAddressHistory().remove(new AddressEntity("oldCity1", "street", "zipcode"));
findMember.getAddressHistory().add(new AddressEntity("oldCity1", "street", "zipcode"));

- 동작 결과

엔티티로 변경 시 update SQL 실행

정리

  • 엔티티 타입은 식별자가 존재하며 생명 주기를 관리할 수 있으며 공유를 할 수 있다.
  • 값 타입은 식별자가 없고 생명 주기를 엔티티에 의존하기 때문에 공유하지 않는 것이 안전하지만 공유될 경우가 있어 불변 객체로 만든다.

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

728x90
반응형

댓글