1. 기본값 타입
JPA의 데이터 타입 분류
JPA는 최상위 레벨에서 보면 데이터를 크게 두 분류(엔티티 타입, 값 타입)로 나눈다.
- 엔티티 타입
- @Entity로 정의하는 객체를 말한다.
- 데이터가 변해도 식별자로 지속해서 추적할 수 있다.
- ex. 회원 엔티티의 키나 나이 값을 변경해도 식별자로 인식할 수 있다.
- 값 타입
- int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말한다.
- 식별자가 없고 값만 존재하므로 변경할 경우 추적할 수 없다.
- ex. 숫자 100을 200으로 변경하면 완전히 다른 값으로 대체된다.
값 타입은 크게 세 가지(기본값 타입, 임베디드 타입, 컬렉션 값 타입)로 구분한다.
- 기본값 타입
- 자바 기본 타입(int, double)
- 래퍼 class(Integer, Long)
- String
- 임베디드 타입 = embedded type; 복합 값 타입
- 좌표(position)에 x 값과 y 값을 묶어 저장하는 것처럼, 내가 정의해서 값으로 사용할 수 있는 타입
- 컬렉션 값 타입 = collection value type
- 자바 컬렉션에 기본값 타입이나 임베디드 타입을 넣어서 사용하는 타입
기본값 타입
String name이나 int age처럼 생명주기를 엔티티에 의존하는 타입을 말한다. 예를 들어 회원을 삭제하면 해당 회원에 존재하는 이름과 나이 필드도 함께 삭제된다. 여기서 값 타입의 value 자체는 절대 공유해서는 안 된다. 회원의 이름을 회원마다 공유한다고 치면, 특정 회원의 이름을 변경했을 때 다른 회원의 이름까지 변경되는 그런 경우가 생길 수도 있기 때문에 주의해야 한다.
참고
int나 double 같은 자바의 기본 타입(primitive type)은 절대 공유되지 않는다. 기본 타입은 항상 값을 복사하는 방식으로 동작하기 때문에 아래 코드 같은 상황에서도 값 타입의 value 자체를 유지할 수 있다.
Integer 같은 래퍼 class나 String 같은 특수한 class는 공유 가능한 객체지만 값을 변경할 수 없다.
public class Value {
public static void main(String[] args) {
int a = 10;
int b = a; // a의 값을 복사해 b에 저장
a = 20;
System.out.println("a = " + a); // 20 출력
System.out.println("b = " + b); // 10 출력
Integer c = new Integer(10);
Integer d = c; // c의 참조값을 넘겨 공유
// c.setValue(20); // 값을 변경할 수 없음
System.out.println("c = " + c); // 10 출력
System.out.println("d = " + d); // 10 출력
}
}
2. 임베디드 타입(복합 값 타입)
새로운 값 타입을 직접 정의하고 싶을 때 사용한다. JPA에선 임베디드 타입(embedded type)이라고 하고, 주로 기본 값 타입을 모아서 만들기 때문에 복합 값 타입이라고도 부른다. int나 String처럼 엔티티가 아닌 값 타입이기 때문에 변경할 경우 추적할 수 없다.
예시와 장점
a. 예시
예를 들어서 회원 엔티티가 아래처럼 이름(name), 근무 시작일(startDate), 근무 종료일(endDate), 주소 도시(city), 주소 번지(street), 주소 우편번호(zipcode)를 가진다고 하자. 여기서 공통되는 근무와 주소 필드를 묶어 임베디드 타입으로 만들 수 있다.
근무(시작일, 종료일)와 주소(도시, 번지, 우편번호)를 묶어 각각 workPeriod와 homeAddress 임베디드 타입으로 나타내고, 묶은 필드를 임베디드 타입으로 대체한다. 이제 회원 엔티티는 이름, 근무 기간, 집 주소를 가진다.
b. 장점
- 공통된 필드를 묶어뒀기 때문에 다양한 곳에서 재사용할 수 있고, class 내에서 응집도가 높다.
- Period.isWork()처럼 해당 값 타입만 사용하는 의미 있는 메서드를 만들 수 있다.
- 임베디드 타입을 포함한 모든 값 타입은 값 타입을 소유한 엔티티에 생명주기를 의존한다.
사용법과 테이블 매핑, 연관관계
a. 사용법
값 타입을 정의하는 곳에 @Embeddable 애노테이션을 붙이고, 값 타입을 사용하는 곳에 @Embedded 애노테이션을 붙이면 된다.
- 기본 생성자(NoArgsConstructor)는 필수로 만들어야 한다.
@Entity
public class Member {
...
@Embedded
private Period workPeriod;
@Embedded
private Address homeAddress;
...
}
@Embeddable
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
...
}
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
...
}
b. 테이블 매핑
임베디드 타입을 쓰든 안 쓰든 DB 입장에선 테이블에 변경되는 게 없다.
- 임베디드 타입은 엔티티의 값을 묶어둔 것일 뿐이다. 따라서 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다.
- 객체와 테이블을 아주 세밀하게(fine-grained) 매핑할 수 있다.
- 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 class의 수가 더 많아야 한다.
c. 연관관계
아래는 JPA 표준 스펙에 나와있는 내용이다. Member 엔티티가 Address와 PhoneNumber라는 임베디드 타입을 갖고 있다.
- Address를 살펴보면, 임베디드 타입 안에 임베디드 타입이 또 들어가 있다.
- PhoneNumber를 살펴보면, 임베디드 타입 안에 엔티티가 들어가 있다.
- PhoneNumber 입장에선 PhoneEntity의 FK 값만 갖고 있으면 된다.
기타
a. @AttributeOverride: 속성 재정의
한 엔티티에서 같은 값 타입을 사용하면 컬럼 명이 중복되는 문제가 발생한다.
- 이때 @AttributeOverrides나 @AttributeOverride 애노테이션을 사용해 컬럼명 속성을 재정의하면 된다.
@Entity
public class Member {
...
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column("HOME_CITY")),
@AttributeOverride(name = "street", column = @Column("HOME_STREET")),
@AttributeOverride(name = "zipcode", column = @Column("HOME_ZIPCODE"))})
private Address homeAddress;
@Embedded
private Address workAddress;
}
b. null
- 임베디드 타입의 값이 null이면, 매핑한 컬럼 값도 모두 null이 된다.
3. 값 타입과 불변 객체
값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다. 따라서 값 타입은 단순하고 안전하게 다룰 수 있어야 한다.
값 타입 복사
아래처럼 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하도록 구현하면 부작용이 발생할 수 있어 위험하다.
- 값 타입을 공유해서 사용하고 싶다면 값 타입 대신 엔티티로 만들어 사용해야 한다.
Address address = new Address("city", "street", "10000");
Member m1 = new Member();
m1.setUsername("m1");
m1.setHomeAddress(address);
em.persist(m1);
Member m2 = new Member();
m2.setUsername("m2");
m2.setHomeAddress(address); // 공유 참조
em.persist(m2);
m1.getHomeAddress().setCity("newCity"); // 두 회원의 주소 city가 모두 변경됨
따라서 아래처럼 값(인스턴스)을 복사해서 사용하도록 구현해야 한다.
Address address = new Address("city", "street", "10000");
Member m1 = new Member();
m1.setUsername("m1");
m1.setHomeAddress(address);
em.persist(m1);
Address addr = new Address(address.getCity(), address.getStreet(), address.getZipcode()); // 복사
Member m2 = new Member();
m2.setUsername("m2");
m2.setHomeAddress(addr); // 복사
em.persist(m2);
m1.getHomeAddress().setCity("newCity"); // m1의 주소 city만 변경됨
객체 타입의 한계
항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다. 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다.
- 자바 기본 타입에 값을 대입하면 해당 값을 복사한다.
- 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없다. 따라서 객체의 공유 참조를 피할 수 없다.
불변 객체
객체 타입을 수정할 수 없게 만들어 부작용을 처음부터 차단해야 한다. 따라서 값 타입은 불변 객체(immutable object)로 설계해야 한다.
- 불변 객체(immutable object): 생성 시점 이후 절대 값을 변경할 수 없는 객체
- 불변 객체로 만들기 위해 생성자로만 값을 설정하고 수정자(Setter)를 만들지 않으면 된다.
@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 제거 or private로 사용)
}
참고
Integer, String은 자바가 제공하는 대표적인 불변 객체다.
값을 바꾸고 싶다면 새로운 Address 인스턴스를 만들어 설정하면 된다.
Address address = new Address("city", "street", "10000");
Member m1 = new Member();
m1.setUsername("m1");
m1.setHomeAddress(address);
em.persist(m1);
Address newAddr = new Address("newCity", "newStreet", "20000");
m1.setHomeAddress(newAddr);
값 타입의 비교
값 타입은 인스턴스가 달라도 그 안에 있는 값이 같으면 같은 것으로 봐야 한다.
int a = 10;
int b = 10;
Address a = new Address("서울시");
Address b = new Address("서울시");
이때 값 타입을 비교하는 방법엔 두 가지가 있다.
- 동일성(identity) 비교
- ==를 사용해 인스턴스의 참조 값을 비교한다.
- 동등성(equivalence) 비교
- equals()를 사용해 인스턴스의 값을 비교한다.
- 값 타입을 비교할 땐 인스턴스의 값을 비교하는 동등성 비교를 해야 한다. 값 타입의 equals() 메서드를 적절하게 재정의해 사용하자.
@Embeddable
public class Address {
...
@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(this.getCity(), address.getCity()) &&
Objects.equals(this.getStreet(), address.getStreet()) &&
Objects.equals(this.getZipcode(), address.getZipcode();
}
@Override
public int hashCode() {
return Objects.hash(this.getCity(), this.getStreet(), this.getZipcode());
}
}
4. 값 타입 컬렉션
값 타입을 하나 이상 저장할 때 사용한다.
테이블 매핑
DB는 컬렉션을 같은 테이블에 저장할 수 없기 때문에 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
- 값 타입 컬렉션을 사용할 곳에 @ElementCollect을 붙이고, @CollectionTable 애노테이션을 붙여 매핑할 테이블을 표시하면 된다.
- @ElementCollect이 있는 테이블과 @CollectionTable에 설정한 테이블이 일대다의 관계를 이루게 된다.
@Entity
public class Member {
...
@Embedded
private Address homeAddress;
@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<>();
}
사용 예제
참고
값 타입 컬렉션은 영속성 전이(Cascade)에 고아 객체 제거 기능(orphanRemoval = true)을 필수로 가진다고 볼 수 있다.
a. 값 타입 저장
Member만 영속화해도 값 타입 컬렉션까지 한 번에 영속화된다.
- 값 타입 컬렉션이 Member 엔티티에 생명 주기를 의존하기 때문에 영속성이 전이된다.
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"));
em.persist(member);
// INSERT 6번: MEMBER 1번, FAVORITE_FOOD 3번, ADDRESS 2번
b. 값 타입 조회
위에서 이어서 작성한다. Member 엔티티에 소속된 값인 id, username, homeAddress는 find()를 호출하면 같이 불러와진다.
- 값 타입 컬렉션들은 지연 로딩(Lazy Loading)으로 가져온다. 따라서 실제 사용할 때 불러오게(SELECT SQL 실행) 된다.
- @ElementCollection의 기본값이 지연 로딩으로 설정돼 있다.
em.flush(); // SQL 실행
em.clear(); // 영속성 컨텍스트 비우기
Member findMember = em.find(Member.class, member.getId()); // 조회
c. 값 타입 수정
위에서 이어서 작성한다. 실행된 SQL을 살펴보면, 회원의 addressHistory를 remove()할 때 해당 회원의 ADDRESS 테이블을 DELETE 해버린다. 그리고 새로 추가한 인스턴스("newCity1")와 기존에 삭제하지 않은 인스턴스("old2")를 모두 INSERT 한다. 테이블의 데이터를 완전히 갈고 새로 만든다고 보면 된다.
- 값 타입은 식별자의 개념이 없기 때문에 데이터를 변경해도 추적하기 어렵다. 따라서 위에 적은 것처럼 관련된 데이터를 모두 지우고 새로 만드는 방식을 사용한다. 아래 [값 타입 컬렉션의 제약사항]을 참고하면 좋다.
// findMember.getHomeAddress().setCity("newCity);
// 값 타입은 불변 객체여야 하므로 수정할 수 있도록 구현하면 안 된다.
Address a = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", a.getStreet(), a.getZipcode()));
// 값 타입을 수정할 땐 새로운 인스턴스를 만들어 완전히 바꿔야 한다.
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
// 치킨을 한식으로 수정
findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
// 컬렉션은 equals로 찾음 = 인스턴스 값이 모두 같은 걸 찾아서 삭제
findMember.getAddressHistory().add(new Address("newCity1", "street", "10000"));
// 주소 수정
제약사항과 대안
a. 제약사항
값 타입은 엔티티와 다르게 식별자 개념이 없기 때문에 데이터를 변경하면 추적하기가 어렵다. 값 타입 컬렉션에 변경 사항이 생기면, 아래와 같이 처리해야 한다.
- 주인 엔티티와 연관된 모든 데이터를 삭제한다. → ex. 회원의 모든 데이터 DELETE
- 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다. → ex. 남은 데이터와 추가한 데이터 INSERT
값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다.
- 이때 null을 입력하거나 중복으로 저장하면 안 된다.
b. 대안
실무에서는 상황에 따라 값 타입 컬렉션 대신 일대다 관계를 고려한다. 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용하는 방식을 사용한다.
- 영속성 전이(Cascade)와 고아 객체 제거 기능(orphanRemoval = true)을 사용해 값 타입 컬렉션처럼 사용한다.
- 아래 Member와 AddressEntity는 일대다 단방향 연관관계를 맺고 있다.
- 값 타입인 Address를 엔티티로 wrapping 하기 때문에 값 타입을 엔티티로 승급한다고 표현하기도 한다.
- 실무에서 이 방식을 자주 사용한다.
@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address;
public AddressEntity(String city, String street, String zipcode) {
this.address = new Address(city, street, zipcode);
}
// Getter, Setter
...
}
@Entity
public class Member {
...
// @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<>();
}
5. 정리
값 타입은 정말 값 타입이라고 판단될 때만 사용해야 한다.
식별자가 필요하고, 지속해서 값을 추적하고 변경해야 한다면 엔티티 타입으로 만들어야 한다. 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들지 말자.
엔티티 타입의 특징
- 식별자가 존재한다.
- 생명 주기를 관리해야 한다.
- 공유해도 된다.
값 타입의 특징
- 식별자가 없다.
- 생명 주기를 엔티티에 의존한다.
- 공유하지 않는 것이 안전하므로 복사해서 사용한다.
- 불변 객체로 만드는 것이 안전하다.