JPA의 데이터 타입은 크게 엔티티 타입
과 값 타입
이 있다.
엔티티 타입은 @Entity
로 정의하는 객체이고, 값 타입은 int, Integer, String
처럼 단순 값으로 사용하는 자바 기본 타입이나 객체를 말한다.
(영한님은 여기서 엔티티 타입은 살아있는 생물이고, 값 타입은 단순한 수치 정보라고 표현했다.)
JPA는 값 타입을 표현하는 방법을 몇가지 더 제공한다.
아래는 JPA에서 제공하는 값 타입의 목록이다.
- 기본값 타입(primitive, wrapper, String)
- 임베디드 타입
- 값 타입 컬렉션
값 타입은 기본적인 특징은 아래와 같다.
- 식별자가 없다
- 생명주기가 엔티티에 의존한다
- 공유하면 안된다
값 타입
자바의 primitive 타입, Wrapper 클래스, String 클래스를 말한다.
1 |
|
임베디드 타입(복합 값 타입)
여러개의 값 타입을 묶어서 하나의 값 타입으로 정의하는 방법이다.
우선 값 타입을 적용하기 전의 코드는 아래와 같다.
1 |
|
위 처럼 엔티티가 모든 속성을 flat 하게 가지는 것은 객체지향적이지 않다.
근무기간
, 집주소
로 묶을 수 있다면 더 좋을 것이다.
1 |
|
작성한 값 타입은 다른 곳에서 재사용 될수도 있고, 값 타입만을 위한 메서드도 작성 가능하다.
엔티티가 더욱 의미있고 응집력있게 변했다
이러한 임베디드 타입을 정의하려면 아래의 2가지 어노테이션이 필요하다.
- @Embeddable : 값 타입을 정의하는 곳에 표시
- @Embedded : 값 타입을 사용하는 곳에 표시
임베디드 타입과 테이블 매핑
이렇게 작성한 임베디드 타입은 테이블에 아래와 같이 매핑된다.
임베디드 타입은 엔티티의 값일 뿐이다. 그러므로 보다시피 임베디드 타입을 사용하기 전과 후에 매핑되는 테이블은 같다.
ORM을 사용하지 않았더라면 객체와 테이블은 대부분 1:1로 매핑되었을 것을,
ORM을 사용함으로써 객체와 테이블을 더 세밀하게 매핑할 수 있다.
(잘 설계한 ORM 어플리케이션은 매핑힌 클래스의 수가 테이블의 수보다 더 많다)
임베디드 타입의 포함과 연관관계
임베디드 타입은 다른 임베디드 타입을 포함
할 수 있고, 다른 엔티티를 참조
할 수도 있다.
1 |
|
속성 재정의: @AttributeOverride
아래와 같이 정의하고 싶을 수 있다.
1 | class Member{ |
ORM 에서만 객체로 묶을 뿐, 테이블 레벨에선 flat하게 펴지므로 위와 같이 정의하는 것은 불가능하다.
컬럼명이 중복되기 때문이다.
이럴땐 @AttributeOverride
를 통해 컬럼명을 재정의해줘야 한다.
1 | class Member{ |
name에는 Address 내의 필드명
을 써주고, column에는 @Column 어노테이션을 써서 재정의 해주면 된다.
어노테이션을 너무 많이 사용되서 지저분하긴 하지만, 다행히(?) 이렇게 한 엔티티에 중복해서 임베디드를 사용할 일이 많이 없다.
@AttributeOveride
는 엔티티에 설정해야 한다. 임베디드 타입이 임베디드 타입을 가지고 있어도 엔티티에 설정해야 한다.
임베디드 타입과 null
임베디드 타입이 null 이면 매핑한 컬럼 값을 모두 null 이 된다(!!)
1 | // city, street, zipcode가 모두 null이 됨 |
임베디드 타입의 딜레마
값
을 다룰때는 기본적으로 참조가 아닌 복제의 형태를 따른다.
하지만 여기서 문제는, JPA에서는 임베디드 타입이 값 타입
인데, 형태는 일반적인 클래스라 참조 방식으로 동작한다는 것이다.
불변성
참조 방식으로 동작하므로 아래와 같은 상황을 막을 수 없다.
1 | Address address1 = new Address("city", "street", "zipcode1"); |
기대하는 것은 member1에 zipcode1, member2에 zipcode2가 저장되어야 하는 것이지만(값 타입의 특성상),
당연히 그렇게 처리되지 않는다. 참조 방식으로 동작하기 때문이다.
때문에 JPA에서 값 타입을 사용할때는 setter 등을 모두 제거한 불변객체로 다루어야 하고,
(자바에서 불변 객체로 만드는 가장 간단한 방법은 setter 제거이다)
값을 재사용 할 때는 deep copy를 수행해서 절대 재사용 되는 일이 없도록 해야한다.
1 | // 전체 프로퍼티를 받는 생성자 |
1 | Address address1 = new Address("city", "street", "zipCode1"); |
현재는 Address 내부가 flat해서 간단히 Object.clone()의 호출만으로도 클로닝이 되지만,
다른 임베디드 타입을 사용하거나 배열을 사용하고 있었을 경우 해당 필드까지 전부 deep clone을 해줘야한다.
하지만 또 반대로 임베디드 타입이 엔티티를 가지고 있을 경우 deep clone 하면 안된다.
이렇듯이 골치아픈게 deep clone이기 때문에, 가급적이면 @Embeddable
내부는 flat 하게 유지해주는 것이 좋다.
비교
값 타입이라면 동일성 비교(==)나 동등성 비교(equals)가 동작해야 한다.
하지만 현재 Address는 그것이 보장되지 않으므로, equals 메서드를 재정의 해줘야한다.
1 |
|
임베디드 타입의 equals 메서드를 재정의 할 때는 보통 모든 필드의 값을 비교하도록 구현한다.
그리고 equals 메서드를 재정의하면 hashCode까지 같이 재정의 해주는 것이 좋다.
그렇지 않으면 해시를 사용하는 컬렉션(HashSet, HashMap)에서 문제가 발생할 수 있기 때문이다.
값 타입 컬렉션
여러개의 값 타입을 저장할 떄 사용한다. 그러려면 컬렉션에 저장해야 하는데, RDB에서는 필드에 컬렉션을 저장할 수 없다.
그러므로 값 만을 저장하는 테이블을 따로 만들어서 사용해야 한다.
위 ERD를 엔티티에서 매핑하면 아래와 같다.
1 |
|
@ElementCollection
으로 값 타입 컬렉션 인것을 알려주고,
@CollectionTable
로 해당 값들을 저장한 테이블을 알려주면 된다(외래키랑 같이).
favorite_food
처럼 값으로 사용되는 컬럼이 하나일 경우 @Column을 사용해서 컬럼명을 지정할 수 있다.
값 타입 컬렉션은 무조건적으로 CascadeType.ALL
, orphanRemoval = true
가 붙은것 처럼 동작한다.
값 타입 컬렉션 사용
저장
1 | Member member = new Member(); |
member : insert 1번
homeAddress : 임베디드 값 타입이므로 member에 포함됨
favoriteFoodList : insert 2번
addressHistory : insert 3번
조회
값 타입 컬렉션도 조히할 때 패치 전략을 사용할 수 있다. Default는 LAZY
이다.
1 | (fetch = FetchType.LAZY) |
조회 방식은 일반적인 @OneToMany 조회 할때와 동일하다.
직접 사용할 때 조회된다.
수정
1 | Member member = em.find(Member.class, 1); |
값 타입은 식별자가 없는 단순한 값들의 모음으로 테이블에 저장된다.
그래서 여기에 저장된 값이 변경되면 데이터베이스에 저장된 원본 데이터의 값을 찾기 어렵게 된다.
이러한 문제로 인해 JPA는 값 타입 컬렉션에 변경사항이 발생하면,
값 타입 컬렉션에 매핑된 테이블의 모든 데이터를 삭제하고, 현재 값 타입 컬렉션에 있는 모든 값을 다시 데이터베이스에 저장한다.
즉, 위와 같은 코드에서는 아래와 같이 쿼리가 발생한다.
1 | /** address_history **/ |
이러한 비효율적인 특징이 있으므로 만약 값 타입 컬렉션에 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신 일대다 관계를 고려해보는 것이 좋다.
게다가 값 타입 컬렉션은 모든 컬럼을 묶어서 기본키를 구성하므로, 컬럼에 null을 입력할 수 없고, 중복된 값을 입력할 수 없는 제약조건도 있다.