강의를 들으며 생각 정리 + "자바 ORM 표준 JPA 프로그래밍" 책 참고
JPA 타입을 가장 크게 분류하면 엔티티 타입과 값 타입으로 나눌 수 있다. 엔티티 타입은 @Entity로 정의하는 객체이다. 데이터가 변해도 식별자로 지속해서 추적이 가능하다. 값 타입은 int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말한다. 식별자가 없고 값만 있기 때문에 변경시 추적이 불가하다.
값 타입은 다음 3가지로 나눌 수 있다.
- 기본값 타입
- 자바 기본 타입(int, double)
- 래퍼 클래스(Integer, Long)
- String
- 임베디드 타입(embedded type, 복합 값 타입)
- 컬렉션 값 타입(collection value type)
기본값 타입
String name, int age에서 String, int와 같은 값 타입을 기본값 타입이라고 한다. 생명주기를 엔티티에 의존하기 때문에 엔티티가 사라지면 같이 사라진다.
또한 기본값 타입은 값을 공유하지 않아야 한다.
primitive 타입을 보면 int, double과 같은 타입은 값을 공유하지 않는다.
int a=10;
int b=a;
b=20;
예를 들어, 다음과 같이 b=a 연산을 해도 a의 값을 복사해서 b에 대입하기 때문에 b를 변경해도 후에 a가 같이 변경되지 않는다.
래퍼 클래스의 경우도 마찬가지이다.
Integer a=10;
Integer b=a;
b=20;
사실 Integer, String과 같은 래퍼 클래스는 객체지만 자바언어에서 기본 타입처럼 사용할 수 있게 지원하므로 기본값 타입으로 정의했다.
임베디드 타입
새로운 값 타입을 직접 정의해서 사용할 수 있는데, JPA에서는 이것을 임베디드 타입이라 한다. 회원이 주소에 관련된 데이터를 가지고 있을 때, 도시, 거리, 우편번호 등 주소에 관한 세부적인 필드로 세분화된다. 이렇게 상세한 데이터를 그대로 가지고 있는 것은 객체지향적이지 않으며 응집력이 떨어진다. 명확히 '주소'와 같은 타입이 있으면 코드가 더 명확해질 것이다.
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
}
위와 같이 @Embeddable 애노테이션으로 엠베디드 타입을 정의할 수 있다. 임베디드 타입에는 생성자, getter/setter, 메서드 등을 포함시킬 수 있다. 단, 기본 생성자는 필수이다.
임베디드 타입은 엔티티의 값일 뿐이기 때문에 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다. 즉, 객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능하다. 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래 스의 수가 더 많다.
+) 임베디드 타입은 다른 임베디드 타입을 포함하거나 다른 엔티티를 참조할 수도 있다.
+) 한 엔티티에 임베디드 타입에 정의한 매핑정보를 재정의하려면 엔티티에 @AttributeOverride를 사용하면 된다. 동일한 임베디드 값 타입이 같은 엔티티에 있으면 매핑하는 컬럼 정보가 동일하기 때문이다. 다음과 같이 컬럼명을 재정의할 수 있다.
@Embedded
@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;
그러나 @AttributeOverride를 너무 많이 사용하면 엔티티 코드가 지저분해진다. 다행히 한 엔티티에 같은 임베디드 타입을 중복해서 사용하는 일은 많지 않다.
추가로, 임베디드 타입이 null이면 매핑한 컬럼 값은 모두 null이 된다.
ex) member.setAddress(null)
값 타입과 불변 객체
임베디드 타입과 같은 값 타입을 여러 엔티티에서 공유하면 위험하다. 임베디드 타입도 결국 객체이기 때문에 기본값 타입과 달리 참조를 전달하게 되고, 한쪽에서 그 값을 바꾼다면 해당 객체를 공유하고 있는 다른 엔티티에도 영향이 있다. 현실적으로 객체의 공유 참조는 피할 수 없고 이러한 부작용은 잡기 매우 어렵다.
-> 그래서 값 타입을 불변 객체로 만든다.
객체를 불변하게 만들면 값을 수정할 수 없으므로 부작용을 원천 차단할 수 있다. 불변 객체를 구현하는 다양한 방법이 있지만 가장 간단한 방법은 생성자로만 값을 설정하고 수정자(setter)를 만들지 않으면 된다. (or 수정자를 private으로 설정한다.) 참고로, Integer, String은 자바가 제공하는 대표적인 불변 객체다.
이제 값 타입을 수정하고 싶다면 수정자가 따로 없기 때문에, 새로운 값 타입을 만들어 다시 할당한다.
-> 불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막을 수 있다.
값 타입의 비교
자바가 제공하는 객체 비교는 2가지다.
- 동일성(identity) 비교: 인스턴스의 참조 값을 비교, == 사용
- 동등성(equivalence) 비교: 인스턴스의 값을 비교, equals() 사용
기본값 타입의 경우 어느 비교 방식을 사용해도 상관없으나, 객체 형태의 값 타입은 동일성 비교를 하면 다른 인스턴스이기 때문에 false가 나온다. 그리고, equals도 기본은 '=='비교이기 때문에 false가 나온다.
그러나 값 타입은 비록 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 보기를 기대하기 때문에 객체의 equals를 오버라이드해야 한다.
+) 일반적으로 연관된 엔티티끼리는 동등성 비교를 하지 않는다.(무한루프 문제) -> 다른 비즈니스 키로 비교하는 것으로 충분하다.(ex. 회원 -> 주민번호)
값 타입 컬렉션
엔티티에서 값 타입을 하나 이상 저장하려면 컬렉션에 보관하고, @ElementCollection, @CollectionTable 어노테이션을 사용하면 된다.
@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();
데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다. 그래서 컬렉션을 저장하기 위한 별도의 테이블이 필요하다. 위와 같이 엔티티(회원)의 식별자를 외래 키 값으로 갖는 새로운 테이블을 만든다.
<값 타입 컬렉션 저장>
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 AddressEntity("old1", "street", "10000"));
member.getAddressHistory().add(new AddressEntity("old1", "street", "10000"));
em.persist(member);
위 코드를 돌리면 insert 쿼리가 총 6번(member, favoritefoods*3, addresshistory*2) 나간다. 회원 엔티티만 insert를 했는데 값 타입 컬렉션은 자동으로 같이 insert가 되는 것이다.(기본적으로 영속성전이 + 고아 객체 제거 기능과 유사하다.)
<값 타입 컬렉션 조회>
반대로 조회할 때 값 타입 컬렉션은 기본적으로 지연 로딩을 지원하기 때문에 회원 엔티티를 조회한다면 회원만 select해서 쿼리가 나간다. 후에 값 타입 컬렉션을 사용할 때 해당 컬렉션을 조회한다.
<값 타입 컬렉션 수정>
값 타입 컬렉션을 수정할 때도 다음과 같이 해당 값 타입을 제거하고 추가해야 한다.
findMember.getAddressHistory().remove(new AddressEntity("old1", "street", "10000"));
findMember.getAddressHistory().add(new AddressEntity("newCity1", "street", "10000"));
일반적으로 remove 메서드는 값 타입의 equals를 기반으로 제거하기 때문에 꼭 equals 메서드가 오버라이드 되어 있어야 한다.
JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면, 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스에 다시 저장한다. 따라서 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 그만큼 insert 쿼리도 많이 나가는 것이기 때문에 컬렉션 대신에 일대다 관계를 고려해야 한다.
@OneToMany(cascade = ALL,orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
위와 같이 값 타입을 필드로 갖는 엔티티(AddressEntity)와 일대다 매핑을 하고 영속성 전이 + 고아 객체 제거 기능을 적용하면 값 타입 컬렉션처럼 사용할 수 있다. @JoinColumn을 통해 일 쪽을 연관관계의 주인으로 설정하긴 했으나 값 타입 컬렉션의 비효율성을 고려한다면 차선책 정도는 될 수 있다.
실전 예제 6 - 값 타입 매핑
공부한 예제를 깃허브에 등록한다.
코드를 작성하며 배운 내용
equals를 오버라이드할 때, 프록시를 고려해서 getter로 비교하는 것이 좋다.(인텔리제이에서 옵션으로 제공한다.)
임베디드 값 타입 내에 의미 있는 메서드를 추가할 수 있고, 컬럼 속성도 사용할 수 있다. -> 한번 정의하면 해당 값 타입을 사용하는 필드에서 공통으로 사용한다.
'java > jpa' 카테고리의 다른 글
[JPA] 객체지향 쿼리 언어2 - 중급 문법 (0) | 2021.02.08 |
---|---|
[JPA] 객체지향 쿼리 언어1 - 기본 문법 (0) | 2021.02.03 |
[JPA] 프록시와 연관관계 관리 (0) | 2021.01.29 |
[JPA] 고급 매핑 (0) | 2021.01.27 |
[JPA] 다양한 연관관계 매핑 (0) | 2021.01.27 |