본문 바로가기

Web Sever 개발과 CS 기초/스프링

JPA 값 타입 이해하기

관련 내용

<JPA 영속성 컨텍스트부터 다양한 매핑까지 완전히 이해하기>

  • JPA 기본 동작과 영속성 원리 이해하기

JPA 기본 동작과 영속성 원리 이해하기

  • JPA 엔티티 매핑 - 테이블, 컬럼, 기본키

JPA 엔티티 매핑 - 테이블, 컬럼, 기본키

  • JPA 연관 관계 매핑과 고급 매핑

JPA 연관 관계 매핑과 고급 매핑

  • JPA지연 로딩, 즉시 로딩 이해와 사용법 영속성전이, 고아 객체 알아보기

JPA지연 로딩, 즉시 로딩 이해와 사용법 영속성전이, 고아 객체 알아보기

  • (현재 글)JPA 값 타입 이해하기

JPA 값 타입 이해하기

  • JPQL, fetch join 중심으로 이해하기

JPA JPQL, fetch join 중심으로 이해하기

개요 목적

JPA 엔티티 안에는 다양한 값 필드들이 존재한다. (기본 값, 컬랙션 등)

반면에 DB의 테이블은 모든 데이터를 하나의 컬럼에 저장한다. 자바처럼 다양한 값 타입이 없는 것이다.

이런 둘의 차이 엔티티의 다양한 값 타입을 → DB 테이블의 컬럼에 잘 맞춰 변경해주는 것이 JPA 값 타입의 역할이다.

이번 시간에는 JPA 값 타입의 종류와 사용법에 대해서 알아본다.

마지막에는 테이블 생성 전략에 대해서도 간단하게 알아본다.

값 타입의 종류

  • 기본값 타입
    • 자바 기본 타입 (int, double)
    • 래퍼 클래스 (Integer, Long)
    • String
  • 임베디드 타입 (복합 값 타입)
    • JPA에서 정의해서 사용해야 한다.
    • ex) 집의 주소를 담은 객체(Address.class)
  • 컬렉션 값 타입
    • 마찬가지로 JPA에서 정의해서 사용해야 한다.
    • 컬렉션에 기본값 또는 임베디드 타입을 넣은 형태이다.

1. 기본 값 타입

기본 값 타입을 JPA에서 사용할 때 가장 큰 특징은, 공유되지 않아서 안전하다는 것이다.

데이터가 공유 되지 않아야 하는 이유(불변 객체여야 하는 이유) 사이드 이펙트 문제

B라는 엔티티의 이름이라는 필드에 “asdf”라는 값이 있었다. 그런데 A라는 엔티티가 이름을 설정할 때, B 엔티티 이름 필드를 참조해서 값을 작성했다. 데이터가 공유된 것이다. 그런데 여기서, B엔티티의 이름 필드를 “zzz”로 변경해서 DB에 넘겼더니, 그 값을 공유한 A DB 테이블의 이름 컬럼도 “zzz”로 변경되는 문제가 발생했다. 그래서 JPA에서 데이터는 공유되면 안 된다.

자바의 기본(primitive) 타입은 절대 공유되지 않는다.

int a = 20;
// 20이라는 값을 복사 a 데이터가 복사된 것이 아니다.
int b = a; 
b = 10;
//a 값은 그대로 20이다.

Integer 같은 래퍼 클래스나 String과 같은 특수한 클래스는 공유 가능한 객체이지만 변경은 불가능하다. setter가 없다.

Integer a = new Integer(10);
// a의 참조를 복사할 수는 있지만
Integer b = a;  
//이런 식으로 set할 수는 없다.
a.setValue(20); 
//Java에서는 변경 자체를 불가능(Immutable)하게 만들어서 Side Effect를 막았다.

2. 값에 임베디드 타입 사용 (복합 값 타입)

기본 값을 모아서 객체 자체를 값으로 사용한다. 아래 같은 예시로 기본 값 타입으로만 구성하는 것이 아니라, 기본 값 타입들을 묶어서 만든 객체를 값으로 등록 할 수 있다.

방법

@Embeddable (값 타입을 정의 하는 곳에 표시한다.) 

@Embedded (값 타입을 사용하는 곳에 표시한다.)

임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다. JPA 코드만 달라질 뿐이다.

@Entity
public class Member {
  ...
  @Embedded
  private Address homeAddress;
  @Embedded
  private Address workAddress;
  ...
}
@Embeddable
@NoArgsConstructor
public class Address {
  private String city;
  private String street;
  private String zipcode;
  ...
}
@Embeddable
@NoArgsConstructor
public class Period {
  private LocalDateTime startDate;
  private LocalDateTime endDate;
  ...
}

임베디드 타입의 특징

  • 임베디드 타입은 임베디드 타입을 가질 수 있다.
    • e.g. Address «Value» 임베디드 타입은 Zipcode «Value» 라는 임베디드 타입을 가진다.
  • 임베디드 타입은 엔티티 타입을 가질 수 있다.
    • e.g. PhoneNumber «Value» 임베디드 타입이 PhoneEntity «Entity» 를 가질 수 있다. FK만 가지면 되기 때문이다.
  • 한 Entity 안에서 같은 값 타입을 2개 이상 가지면 @AttributeOverrides, @AttributeOverride를 통해 속성을 재정의하면 된다.
@Entity
public class Member {
  ...
  @Embedded
  private Address homeAddress;
  @Embedded
  //같은 Address라서 컬럼 값이 같아지는 문제
  //@AttributeOverrides 각 임베디드 타입 컬럼명을 지정할 수 있다.
  @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;
  ...
}

임베디드 타입(객체)은 공유 참조 사이드 이펙트 문제를 조심해야 한다.

기본 값 타입과 달리, 두 엔티티가 임베디드 타입의 값을 공유하고 한쪽에서 set으로 고치게 된다면 데이터를 공유한 다른 엔티티 DB 값도 변경되는 문제가 발생한다.

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

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

Member member2 = new Member();
member2.setUsername("member2");
member2.setHomeAddress(address);
em.persist(member2);
// 첫 번째 member의 Address(city) 속성만 변경하고 싶다.
//그런데 결과적으로 member2의 값도 변하게 된다. 
//update query 2번 나간다.
member.getHomeAddress().setCity("new city");
tx.commit();

해결 방법

값 타입을 Wrapper 클래스 ,Stirng class 같은 불변 객체로 설계 하면된다.

생성자나 빌더를 통해서 생성을 가능하게 하지만, 값을 변경하는 메소드는 만들지 않는 것이다. 그럼 객체 특성상 공유는 가능하지만, 값 변경을 원천적으로 차단하여 예상치 못한 데이터 변경 부작용을 막을 수 있다.

객체 값 타입은 비교에서 ==가 아닌, equals를 사용하여, 내용물을 비교하게 해야 한다.

기본 값 타입은 인스턴스가 달라도 값이 같으면 같은 것으로 판단된다.

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

반면에 객체는 값이 같아도, 주소가 다르기 때문에 == 비교시 false를 반환한다.

Address address1 = new Address("서울시");
Address address2 = new Address("서울시");
System.out.println("address1 == address2: " + (address1 == address2)); // false

해결 방법

자바에서 제공하는 eqauls() 메소드를 만들어서, 안에 있는 내용물을 비교하게 해야 한다.

System.out.println("address1 == address2: " + (address1.equals(address2)));

3. 값 컬렉션 타입 사용

자바에는 리스트같은 컬렉션 구조가 있지만, DB에는 한 컬럼에 한 개의 밸류만 넣을 수 있기 때문에, 리스트 구조를 가지는 것은 불가능하다. 그것을 가능하게 하는 방법을 그림과 코드를 통해서 알아보자.

컬렉션 타입 엔티티 적용하기

아래 그림 구조를 사용하는 엔티티 코드를 작성해보자.

엔티티 구성 코드

@Entity
@Table
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    @Column(name = "USERNAME")
    private String name;
    @Embedded
    private Address address;
    
    //이부분을 설정해서 
    //컬렉션을 관리할 테이블 이름과, 외래키를 어떤 것으로할 것인지
    //설정한다.
    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOOD",
    joinColumns = @JoinColumn(name = "MEMBER_ID"))
    public Set<String> favoriteFoods = new HashSet<>();
    @ElementCollection
    @CollectionTable(name = "ADDRESS",
        joinColumns = @JoinColumn(name = "MEMBER_ID"))
    private List<Address> addressHistory = new ArrayList<>();
...
}

컬랙션 타입 값 저장

관리하는 DB 테이블이 다르다고 컬랙션들을 따로 persit해줄 필요없이, 그저 멤버에 세팅만 해주면 된다. 값 타입 컬렉션은 영속성 전이(Cascade) + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다

Address address = new Address("homeCity", "street", "zipcode");
Member member = new Member();
member.setName("username");
member.setAddress(address);

//컬랙션 저장
member.getAddressHistory().add(new Address("OLDCity", "street", "zipcode"));
member.getAddressHistory().add(new Address("NEWCity", "street", "zipcode"));

member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("붕어빵");
member.getFavoriteFoods().add("국수");

em.persist(member);
tx.commit();

컬랙션 타입 값 조회

값 타입 컬랙션도 조회하려면 다른 테이블을 join해야 하기 때문에, 기본적으로 LAZY 지연 로딩이 사용된다.

Member findMember = em.find(Member.class, member.getId());

Set<String> favoriteFoods = findMember.getFavoriteFoods();
for (String favoriteFood : favoriteFoods) {
    //실제 값을 가져 올때 select 쿼리를 날리는 지연로딩을 사용한다.
    System.out.println(favoriteFood);
}
List<Address> addressHistory = findMember.getAddressHistory();
for (Address address1 : addressHistory) {
    //실제 값을 가져 올때 select 쿼리를 날리는 지연로딩을 사용한다.
    System.out.println(address1.getCity());
}

컬랙션 타입 값 수정

컬랙션 테이블에는 id가 존재하지 않는다.그렇기 때문에 값이 중간에 변경되었을 때 DB가 해당 row만을 찾아서 변경할 수 없다. 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.

Member findMember = em.find(Member.class, member.getId());

//@embaded 타입 수정
// homeCity -> newCity
// findMember.getHomeAddress().setCity("newCity"); // 틀린 방법
Address findAddress = findMember.getAddress();
findMember.setAddress(new Address("newCity", findAddress.getStreet(),
    findAddress.getZipcode())); // 새로 생성

//컬랙션(기본 값) 타입 수정
//치킨 -> 한식
// 치킨 -> 한식
//완전히 삭제한 후에, 새롭게 값을 넣어준다.
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");

//컬랙션(객체) 타입 수장
// old1 -> newCity1
// equals(), hashCode() 에 대한 제대로 정의가 필요하다. 
// 컬랙션에서 같은 값을 가진 Address를 찾기 위해서
findMember.getAddressHistory().remove(new Address("OLDCity", "street1", "10001"));  
findMember.getAddressHistory().add(new Address("newCity1", "street1", "10001"));

그런데 결론이 컬랙션대신 일대다 관계를 사용하자

실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려하는 것이 낫다.

일대다 관게 + 영속성 전이(Cascade) + 고아 객체 제거를 사용해서 값 타입 컬렉션처럼 사용하자.

식별자가 필요하고, 지속해서 값을 추적하고 변경해야 한다면 그것은 값 타입이 아닌 엔티티이다.

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<>();
    
    // 일대다 관계를 사용하자
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "MEMBER_ID")
    private List<AddressEntity> addressHistory = new ArrayList<>();
}