김영한님의 인프런 강의 '자바 ORM 표준 JPA 프로그래밍'을 참고했습니다.
JPA의 데이터 타입을 가장 크게 분류하면 엔티티 타입과 값 타입으로 나눌 수 있다.
엔티티 타입
@Entity
로 정의하는 객체값 타입
값 타입에 대해 자세히 알아보자.
값 타입은 기본값 타입, 임베디드 타입, 컬렉션 값 타입으로 분류할 수 있다.
기본값 타입 종류
기본 값 타입들은 모두 다음을 만족한다.
값 타입 공유와 관련하여 좀 더 알아보자.
자바 기본 타입들은 절대 공유되지 않는다. 기본 타입은 항상 값을 복사한다.
int a = 10;
int b = a;
a = 20;
System.out.println("a = " + a);
System.out.println("b = " + b);
출력 결과
a = 20
b = 10
반면 래퍼 클래스나 String은 공유는 가능하지만 변경되지는 않는다. 값을 변경할 수 있는 방법이 없다.
Integer a = new Integer(10);
Integer b = a;
a = 20;
System.out.println("a = " + a);
System.out.println("b = " + b);
출력 결과
a = 20
b = 10
🤔 공유는 되는데 왜 b값이 변경되지 않지??
a와 b는 참조를 통해 공유는 한다. 하지만 a = 20은 사실 a = Integer.parseInt(20)과 같다. 이 과정에서 새로운 Integer 객체가 생성되며 b와는 관계없는 객체가 된다. 따라서 a의 값이 바뀌어도 b의 값은 그대로 10을 유지한다.
예를 들어서 다음과 같은 회원 엔티티가 있다.
@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;
//...
}
이 엔티티를 설명하려면
라고 해야한다. 이런 설명은 단순히 정보를 나열한 것일 뿐 직관적이지 않다. 이것보다는 다음처럼 설명하는 것이 더 명확하다.
회원이 상세한 데이터를 그대로 가지고 있는 것은 객체지향적이지 않으며 응집력만 떨어뜨린다. 대신에 근무 기간, 주소 같은 타입이 있다면 코드가 더 명확해질 것이다. JPA에서는 임베디드 타입을 통해 이를 해결할 수 있다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Embedded Address homeAddress; //집 주소
@Embedded Period workPeriod; //근무 기간
//....
}
@Embedded
: 값 타입을 사용하는 곳에 표시
@Embeddable
public class addresss {
private String Street;
private String city;
private String zipcode;
...
}
@Embeddable
: 값 타입을 정의하는 곳에 표시
@Embeddable
public class Period {
private LocalDateTime startDate;;
private LocalDateTime endDate;
...
}
@Embeddable
: 값 타입을 정의하는 곳에 표시
임베디드 타입과 테이블 매핑
임베디드 타입은 엔티티의 값일 뿐이다.
임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다.
객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능하다.
잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.
임베디드 타입의 장점
재사용성이 좋다.
높은 응집도를 가진다.
Period.xxx()처럼 해당 값 타입만 사용하는 의미 있는 메소드를 만들 수 있다.
임베디드 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명주기를 의존한다.
임베디드 타입과 연관관계
속성 재정의
@AttributeOverrides
, @AttributeOverride
를 사용해서 컬럼 명 속성을 재정의할 수 있다.임베디드 타입과 null
기본 타입은 항상 값을 복사해서 사용하므로 문제없지만 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 부작용이 발생할 수 있다. 아래 코드를 보자.
개발자는 member 객체의 도시를 newcity로 바꾸려 했지만 의도치 않게 member2마저 newcity로 바뀌게 된다. by 객체의 공유 참조
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.getHomeAddress().setCity("newcity");
참고로 address는 임베디드 타입!
따라서 다음과 같이 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다. 하지만 개발자가 복사하는 것을 까먹으면 문제가 되고 이는 찾기 힘든 에러가 되버린다.
Address address = new Address("city", "street", "10000");
Member member = new Member;
member.setUsername("member1");
member.setHomeAddress(address);
em.persist(member);
Address copyAddress = new Address(address.getCity(), address.getStreet(), address.getZipcode());
Member member2 = new Member;
member2.setUsername("member2");
member2.setHomeAddress(copyAddress); // address를 복사한 copyAddress를 사용.
em.persist(member2);
불변 객체는 조회는 가능하지만 수정할 수 없는 객체를 의미한다. 불변 객체로 만들면 값을 수정할 수 없으므로 부작용을 원천 차단할 수 있다. 불변 객체를 구현하는 다향한 방법이 있지만 가장 간단한 방법은 생성자로 초기값을 설정하고 수정자를 만들지 않으면 된다(즉 setter를 생략). 참고로 Integer, String는 자바가 제공하는 대표적인 불변 객체이다.
📌 임베디드 타입을 사용할때는 수정자를 생략하자!
값 타입 : 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야한다.
Integer a = 10;
Integer b = 10;
Address a = new Address("서울시", "종로구", "1번지");
Address b = new Address("서울시", "종로구", "1번지");
자바가 제공하는 객체 비교는 2가지다.
Address의 값 타입을 a == b로 동일성 비교하면 둘은 서로 다른 인스턴스이므로 결과는 거짓이다. 하지만 이는 기대하는 결과가 아니다. 값 타입은 비록 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 한다. 따라서 값 타입을 비교할 때는 a.equals(b)를 사용해서 동등성 비교를 해야 한다. 물론 equals() 메서드를 재정의해야 한다.
📌 값 타입을 사용할때 반드시 equals, hashcode를 구현하자!!
값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection
, @CollectionTable
어노테이션을 사용하면 된다. 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없기 때문에 별도의 테이블을 만들고 @CollectionTable
의 정보를 토대로 매핑해야 한다. 데이터베이스 스키마 자동생성 기능을 사용하면 @CollectionTable
에 만들고자 하는 테이블명을 매핑하면 된다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Embedded //임베디드 타입
private Address homeAddress;
@ElementCollection //기본 값 타입 컬렉션
@CollectionTable(name = "FAVORITE_FOOD",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME") //favoriteFoods처럼 값으로 사용되는 컬럼이 하나면 @Column을 사용해서 컬럼명을 지정할 수 있다.
private Set<String> favoriteFoods = new Hashset<String>();
@ElementCollection //임베디드 값 타입 컬렉션
@CollectionTable(name = "ADDRESS", joinColumns
= @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<Address>();
//...
}
@Embeddable
public class Address {
@Column
private String city;
private String street;
private String zipcode;
//...
}
값 타입 컬렉션 ERD
등록
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);
흥미로운 점은 값 타입 컬렉션을 따로 persist 안해도 된다는 점이다. 간단히 member만 persist 했더니 다른 테이블임에도 불구하고 같이 저장된다. 이는 값 타입이라서 라이프사이클을 엔티티에 의존하기 때문이다.
조회
Member member = em.find(Member.class, 1L); //1
Address homeAddress = member.getHomeAddress();
Set<String> favoriteFoods = member.getFavoriteFoods(); //2
List<Address> addressHistory = member.getAddressHistory(); //3
수정
Member memeber = em.find(Member.class, 1L);
//1. 임베디드 값 타입 수정
member.setHomeAddress(new Address("새로운도시", "신도시1", "123456"));
//member.getHomeAddress().setCity("newCity") XXX by 불변객체
//2. 기본값 타입 컬렉션 수정
Set<String> favoriteFoods = member.getFavoriteFoods();
favoriteFoods.remove("탕수육");
favoriteFoods.add("치킨");
//3. 임베디드 값 타입 컬렉션 수정
List<Address> addressHistory = member.getAddressHistory();
addressHistory.remove(new Address("서울", "기존주소", "123-123"));
addressHistory.add(new Address("새로운도시", "새로운주소", "123-456"));
값 타입은 엔티티와 다르게 식별자 개념이 없어서 값 추적이 어렵다. 만약 값 타입 컬렉션에 변경 사항이 발생하면 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고 현재 값 타입 컬렉션에 있는 모든 값을 다시 저장한다. 이렇게 되면 수정할 때마다 의도치 않게 많은 쿼리들이 발생된다. 따라서 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. 하지만 이러면 null과 중복 저장이 허용 안된다는 단점이 있다.
따라서 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다. 일대다 관계를 위한 엔티티를 만들고 여기에서 값 타입을 사용하면 된다. 추가로 영속성 전이(Cascade) + 고아 객체 제거를 사용하면 값 타입 컬렉션처럼 사용 가능하다.
일대다 vs 값 타입 컬렉션
- 값 타입 컬렉션 : 단순하고 추적할 필요가 없을때. case가 적다.
- 일대다(엔티티) : 식별자가 필요하고 지속해서 값을 추적, 변경해야 할 경우. case가 많음.