JPA의 데이터 타입
@Entity로 정의하는 객체값 타입의 종류
기본값 타입: 자바 기본 타입(int, double), 래퍼 클래스(Integer), String
임베디드 타입: 복합 값 타입
컬렉션 값 타입
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private int age;
}
임베디드 타입: 새로운 값 타입을 정의해서 사용하는 것을 말함.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Temporal(TemporalType.DATE) java.util.Date startDate;
@Temporal(TemporalType.DATE) java.util.Date endDate;
private String city;
private String street;
private String zipcode;
}
위의 회원 엔티티는 단순히 정보를 풀어둔 것에 불과한다. 회원이 상세한 데이터를 그대로 가지고 있는 상태는 객체지향적이지 않아 응집력을 떨어뜨린다.
해결: 근무기간, 주소 타입 같은 타입을 가지도록 임베디드 타입을 사용한다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Embedded Period workPeriod;
@Embedded Address homeAddress;
}
//기간 임베디드 타입
@Embeddable
public class Period {
@Temporal (TemporalType.DATE) java.util.startDate;
@Temporal (TemporalType.DATE) java.util.Date endDate;
public boolean isWork (Date dat) {}
}
//주소 임베디드 타입
@Embeddable
public class Address {
@Column (name="city") //매핑할 컬럼 정의 가능
private String city;
private String city;
private String zipcode;
}
startDate와 endDate를 합해서 Period(기간) 클래스를 만듦city, street, zipcode를 합해 Address(주소) 클래스를 만듦새로 정의한 값 타입들을 재사용을 할 수 있고 응집도 역시 높음.
해당 값 타입만 사용하는 메소드 역시 만들 수 있음.
@Embeddable: 값 타입을 정의하는 곳에 표시@Embedded: 값 타입을 사용하는 곳에 표시또한 임베디드 타입은 기본 생성자가 필수적이다. 임베디드 타입을 포함한 모든 값 타입은 엔티티의 생명주기에 의존하기 때문에 UML로 엔티티-임베디드 타입의 관계를 표현하면 컴포지션 관계가 됨.
임베디드 타입 = 엔티티의 값. 따라서 값이 속한 엔티티의 테이블에 매핑.
임베디드 타입은 객체와 테이블을 세밀하게 매핑하는 것이 가능하다. 잘 설계한 ORM 애플리케이션의 경우는 매핑한 테이블의 수보다 클래스의 수가 더 많다.
ORM을 사용하지 않고 개발하게되면 테이블 컬럼과 객체 필드를 대부분 1:1로 매핑하게 된다. 근무기간이나 주소같은 값 타입 클래스를 만들어 개발하기에는 SQL을 직접 다룰 때 테이블 하나에 여러 클래스를 매핑하는 등 복잡한 과정이 발생하기 때문이다. 다만, ORM을 사용해 이런 반복적인 작업을 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 @GeneratedValue
privaet Long id;
private String name;
@Embedded Address homeAddress;
@Embedded Address companyAddress;
}
위의 코드에서 주소를 추가하는 것은 쉽지만 문제는 **테이블에 매핑하는 컬럼명이 중복**된다. 이때는 `@AttributeOverrides`를 사용해 매핑정보를 재정의해야한다.
```java
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Embedded 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(nmae="COMPANY_ZIPCODE")
})
Address companyAddress;
}
@AttributeOverride를 사용했을 때, 어노테이션을 너무 많이 사용하여 엔티티 코드가 지저분해진다는 점이 있다. 하지만 한 엔티티에 같은 임베디드 타입을 중복해서 사용하는 일은 많지 않다.
임베디드 타입이 null일 경우에는 매핑한 컬럼 값이 모두 null이 된다.
member.setAddress(null);
em.persist(member);
값 타입은 복잡한 객체를 단순화하기 위해 만든 개념.
임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하게되면 위험하다.
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();
address.setCity("NewCity");
member2.setHomeAddress(address);
문제점: 회원2에 새로운 주소를 할당할 때 회원1의 주소를 그대로 참조하여 사용. -> 회원2의 주소만 NewCity로 바뀌는 것이 아니라 회원1의 주소 역시 NewCity로 변경되어버림.
이유: 회원1과 회원2가 같은 인스턴스를 참조하기 때문에 영속성 컨텍스트는 회원1과 회원2 둘 모두 city 속성이 변경되었다고 판단하여 각각을 UPDATE SQL을 실행한다. 이런 공유 참조로 인한 버그는 찾아내기가 어렵다.
부작용: 뭔가 수정했는데 전혀 예상하지 못한 곳에서 문제가 발생하는 것. 방지를 위해서는 값을 복사해 사용하면 된다.
값 타입의 실제 인스턴스 값을 공유하는 것은 위험하기에 값을 복사해서 사용해야한다.
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();
Address newAddress = address.clone();
newAddress.setCity("NewCity");
member2.setHomeAddress(newAddress);
회원2에 새로운 주소를 할당하기 위해 clone() 메소드를 만들었는데, 이 메소드는 스스로를 복사해 반환하도록 구현되어있다. 즉, 회원1의 주소 인스턴스를 복사하여 사용한다.
위의 코드는 의도대로 회원2의 주소만 NewCity로 변경하게된다. 또한 영속성 컨텍스트 역시 회원2의 주소만 변경된 것으로 판단하여 회원2에 대해서만 UPDATE SQL을 실행하게된다.
이렇게 값을 항상 복사해서 사용하게되면 공유 참조로 인한 부작용을 피할 수 있다.
하지만, 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이라는 점이다.
자바의 기본 타입은 항상 값을 복사해서 전달하지만, 객체 타입의 경우에는 항상 참조값을 전달한다. 즉, 두 객체가 같은 인스턴스를 공유 참조하는 일이 발생한다.
객체를 대입할 때마다 인스턴스를 복사해 대입하면 공유 참조를 피할 수 있다.
복사하지 않고 원본의 참조값을 직접 넘기는 것을 막을 수 있는 방법이 없다. 자바에서는 대입하려는 것이 값 타입인지의 여부를 신경 쓰지 않고, 자바 기본 타입인 경우 값을 복사하고 객체일 경우 참조를 넘긴다.
즉, 객체의 공유 참조는 피할 수 없다. 따라서 해결책이 필요한데 가장 단순한 방법은 객체의 값을 수정하지 못하게 막으면 된다.
예를 들면 Address 객체의 setCity() 같은 수정자 메소드를 모두 제거하는 것이다. 이렇게 되면 공유 참조를 해도 값을 변경하지 못하기에 부작용의 발생을 막을 수 있다.
객체를 불변하게 만들면 값을 수정할 수 없기에 부작용을 원천 차단할 수 있다. 따라서 값 타입은 될 수 있으면 불변 객체로 설계해야한다.
불변 객체: 한 번 만들면 절대 변경할 수 없는 객체로 조회는 가능하지만 수정은 불가능.
하지만 불변 객체 역시 객체이기에 인스턴스의 참조 값 공유를 피할 수는 없음. 다만, 참조값을 공유하더라도 인스턴스의 값을 수정할 수 없기에 부작용은 발생하지 않음.
구현방법: 생성자로만 값을 설정하고 수정자를 만들지 않는다.
예) Address
@Embeddable
public class Address {
private String city;
protected Address () {this.city = city}
//접근자(Getter)는 노출.
public String getCity() {return city;}
}
불변 객체의 사용
Address address = member1.getHomeAddress();
Address newAddress = new Address(address.getCity());
member2.setHomeAddress(newAddress);
위의 Address는 불변객체로 값을 수정할 수 없기에 공유해도 부작용이 발생하지 않는다. 만약 값을 수정해야한다면, 새로운 객체를 만들어 사용해야한다.
+) Integer, String은 자바가 제공하는 대표적인 불변 객체.
int a = 10;
int b = 10;
Address a = new Address("서울시", "종로구", "1번지");
Address b = new Address("서울시", "종로구", "1번지");
자바가 제공하는 객체 비교
==를 사용.equals() 사용.위의 코드에서 Address 값 타입을 a==b로 동일성 비교를 하는 경우 둘은 서로 다른 인스턴스 이기에 결과가 거짓이된다.
다만, 값 타입의 경우는 인스턴스가 달라도 그 안의 값이 같으면 같은 것으로 봐야한다. 그렇기에 값 타입 비교를 위해서는 동등성 비교를 해야한다. 이를 위해서는 Address의 equals() 메소드를 재정의해야한다.
값 타입의 equals() 메소드를 재정의할 때는 보통 모든 필드의 값을 비교하도록 구현한다.
값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection, @CollectionTable 어노테이션을 사용하면 된다.
@Entity
public class Member {
@Id @GeneratedValue
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<String>();
@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<Address>();
}
값 타입 컬렉션을 사용하는 favoriteFoods, addressHistory에 @ElementCollection을 지정하였다.
favoriteFoods의 경우 기본값 타입인 String 컬렉션을 가진다. 이를 데이터베이스 테이블로 매핑해야하는데 관계형 데이터베이스의 테이블은 컬럼 안에 컬렉션을 포함할 수는 없다.
따라서 별도의 테이블을 추가하고 @CollectionTable를 사용해 추가한 테이블을 매핑해야한다. 또한 만약 favoriteFoods처럼 값으로 사용되는 컬럼이 하나일 때는 @Column을 사용해 컬럼명을 지정할 수 있다.
addressHistory는 임베디드 타입인 Address를 컬렉션으로 가진다. 이것 역시 마찬가지로 별도의 테이블을 사용해야한다. 테이블의 매핑정보는 @AtrributeOverride를 사용해서 재정의할 수 있음.
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("서울", "강북", "000-000"));
em.persist(member);
등록하는 코드를 보면 마지막에 member 엔티티만 영속화하였다. JPA는 이때 member 엔티티의 값 타입 역시 함께 저장한다. 실제 데이터베이스에 실행되는 INSERT SQL은 아래와 같다.
따라서 em.persist() 한 번으로 총 6번의 INSERT SQL을 실행하게 된다.
값 타입 컬렉션 역시 조회 시 fetch 전략을 선택할 수 있는데, 기본은 LAZY이다.
예) 지연로딩으로 모두 설정했다고 가정하고 아래킝 코드를 실행
Member member = em.find(Member.class, 1L);
Address homeAddress = member.getHomeAddress();
Set<String> favoriteFoods = member.getFavoriteFoods(); //LAZY
for (String favoriteFood : favoriteFoods) {
System.out.println("favFood: "+favoriteFood);
}
List<Address> addressHistory = member.getAddressHistory(); //LAZY
addressHistory.get(0);
예) 값 타입 컬렉션의 수정
Member member = em.find(Member.class, 1L);
member.setHomeAddress(new Address("새로운도시", "신도시1", "123456");
Set<String> favoriteFoods = member.getFavoriteFoods();
favoriteFoods.remove("탕수육");
favoriteFoods.add("치킨");
List<Address> addressHistory = member.getAddressHistory();
addressHistory.remove(new Address("서울", "기존 주소", "123-123-");
addressHistory.add(new Address("새로운 도시", "새로운 주소", "123-456");
equals()와 hashcode를 반드시 구현해야한다.엔티티는 식별자가 있어 값을 변경하여도 식별자로 데이터베이스에 저장된 원본 데이터를 쉽게 찾아 변경할 수 있지만, 값 타입은 식별자 개념이 없고 단순한 값들의 모음이기에 값을 변경해버리면 데이터베이스에 저장된 원본 데이터를 찾기 어렵다.
특정 엔티티 하나에 소속된 값 타입은 값이 변경되어도 자신이 소속된 엔티티를 데이터베이스에서 찾고 값을 변경하면된다. 다만, 값 컬렉션은 보관된 값 타입들을 별도의 테이블에 보관하기에 여기에 보관된 값 타입의 값이 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵다.
따라서 JPA 구현체들을 값 타입 컬렉션에 변경 사항이 발생하면 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스에 다시 저장한다.
따라서 실무에서 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신 일대다 관계를 고려해야함.
+) 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야한다. 따라서 데이터베이스 기본 키 제약 조건으로 인해 컬럼으로 null을 입력할 수 없고, 같은 값을 중복해서 저장할 수 없는 제약도 존재한다.
위의 문제를 해결하기 위해서는 값 타입 컬렉션을 만들기 위해서는 새로운 엔티티를 만들어 일대다 관계로 설정하면 된다. 여기에 추가로 영속성 전이 + 고아 객체 제거 기능을 적용하여 값 타입 컬렉션처럼 사용할 수 있다.
@Entity
public class AddressEntity {
@Id
@GeneratedValue
private Long id;
@Embedded Address address;
}
설정은 아래와 같이 하면 된다.
@OneToMany (cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<AddressEntity>();