이전 포스팅에서는 JPA의 프록시와 연관관계에 대해 알아보았다. 이번 포스팅에서는 JPA의 데이터 타입에 대해 포스팅하려 한다😀
개요
JPA의 데이터 타입을 가장 크게 분류하면 엔티티 타입과 값 타입으로 나눌 수 있다. 엔티티 타입은 @Entity로 정의하는 객체이고, 값 타입은 int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말한다.
📌 엔티티 타입은 식별자를 통해 지속해서 추적할 수 있지만, 값 타입은 식별자가 없고 숫자나 문자같은 속성만 있으므로 추적할 수 없다. 쉽게 비유하면 엔티티 타입은 살아 있는 생물이고 값 타입은 단순한 수치 정보다.
기본값 타입📙
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private int age;
}
위 예제 Member 클래스에서 String, int가 값 타입이다. Member 엔티티는 id라는 식별자 값도 가지고 생명주기도 있지만, 값 타입인 name, age 속성은 식별자 값도 없고 생명주기도 회원 엔티티에 의존한다. 따라서 회원 엔티티 인스턴스를 제거하면 name, age 값도 같이 제거 된다.
당연한 이야기지만 이러한 값 타입은 공유되면 안 된다. 회원 1의 이름을 변경하였다고, 회원 2의 이름이 변경되면 안 되기 때문이다.
임베디드 타입(복합 값 타입)📘
새로운 값 타입을 직접 정의해서 사용할 수 있는데, JPA에서는 이것을 임베디드 타입(embedded type)이라 한다. 중요한 것은 임베디드 타입 또한 값 타입이라는 것이다.
@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.Date startDate;
@Temporal (TemporalType.DATE) java.util.Date endDate;
public boolean isWork(Date date) {
// 값 타입을 위한 전용 메소드를 정의할 수 있다
}
}
@Embeddable
public class Address {
@Column(name="city") // 매핑할 컬럼 정의 가능
private String city;
private String street;
private String zipcode;
}
새로 정의한 값 타입들은 재사용할 수 있고 응집도도 높다. 또한 예제와 같이 Period.isWork()처럼 해당 값 타입만 사용하는 의미 있는 메서드도 만들 수 있다. 값 타입을 정의하는 곳에는 @Embeddable 어노테이션을, 값 타입을 사용하는 곳에는 @Embedded 어노테이션을 사용하면 된다.
📌 임베디드 타입은 기본 생성자가 필수다. 위 예시에 없는 이유는 자동으로 기본 생성자를 만들어 주기 때문에 쓰지 않았는데, 사용자가 임의의 생성자를 만든다면 기본 생성자도 같이 만들어주어야 한다.
@AttributeOverride
임베디드 타입에 정의한 매핑정보를 재정의하려면 엔티티에 @AttributeOverride를 사용하면 된다. 예를 들어 회원에게 주소가 하나 더 필요하면 아래와 같이 코드가 구성될 것이다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Embedded Address homeAddress;
@Embedded Address companyAddress; // 이렇게 정의 X
@Embedded // 이렇게 정의 O
@AttributeOverrides({
@AttributeOverride(name="city",
column=@Column(name="COMPANY_CITY")),
@AttributeOverride(name="street",
column=@Column(name="COMPANY_STREET")),
@AttributeOverride(name="zipcode",
column=@Column(name="COMPANY_ZIPCODE"))
})
}
@AttributeOverride는 엔티티에 설정해야 하며, 위 코드는 예시일 뿐 실무에서 한 엔티티에 같은 임베디드 타입을 중복해서 사용하는 일은 많지 않다.
또한, 임베디드 타입이 null이면 매핑한 칼럼 값은 모두 null이 된다. 만약 member.setAddress(null)이 입력되면 회원 테이블의 주소와 관련된 CITY, STREET, ZIPCODE 컬럼 값은 모두 null이 된다.
값 타입 복사&비교📒
만약 임베디드 타입 값은 값 타입을 여러 엔티티에서 공유하면 어떻게 될까? 아래 코드에서 확인해 보겠다.
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();
address.setCity("NewCity"); // 회원1의 address 값을 공유해서 사용
member2.setHomeAddress(address);
회원 2에 새로운 주소를 할당하기 위해 회원 1의 주소를 그대로 참조해서 사용했다. 만약 이 코드가 실행된다면, 우리가 기대하는 상황은 회원2의 주소만 'NewCity'로 변경되길 원하지만, 회원1의 주소도 같이 'NewCity'로 변경되는 것을 확인할 수 있을 것이다.
📌 임베디드 타입은 자바의 기본 타입이 아니라 객체 타입이다.
이유는 회원 1, 회원 2 모두가 같은 address 인스턴스를 참조하기 때문이다. 이런 부작용을 막기 위해서는 값을 복사해서 사용하면 된다.
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();
// 회원1의 address 값을 복사해서 새로운 newAddress 값을 생성
Address newAddress = address.clone();
newAddress.setCity("NewCity");
member2.setHomeAddress(newAddress);
사실 값 복사보다는 불변 객체로 만들어서 이러한 부작용을 원천 차단하는 것이 좋다. 불변 객체는 한 번 만들면 절대 변경할 수 없는 객체를 뜻한다. 불변 객체의 값은 조회할 수는 있지만 수정할 수는 없다. 아래 코드는 Address 임베디드 클래스를 불변 객체로 만드는 코드다.
@Embeddable
public class Address {
private String city;
protected Address() {} // JPA에서 기본 생성자는 필수
// 생성자로 초기 값을 설정
public Address(String city) {
this.city = city;
}
// 접근자 Getter만 노출
public String getCity() {
return city;
}
// 수정자 Setter는 만들지 않는다.
}
수정이 불가능하기 때문에 회원이 만약 주소를 변경할 때는 이미 저장되어 있는 Address를 삭제하고 새로운 값을 만들어서 다시 저장하는 로직을 구성해야 한다. 자세한 설명은 컬렉션 부분에서 하겠다.
값 타입은 동일성 비교(== 사용)와 동등성 비교(equals() 사용)가 있는데, Address 같은 값 타입은 동일성 비교를 하면 서로 다른 인스턴스로 거짓이라고 나온다. 값 타입을 비교할 때는 equals()를 사용해서 비교를 해야 한다.
값 타입 컬렉션📗
값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection, @CollectionTable 어노테이션을 사용하면 된다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Embedded
private Address homeAddress;
@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;
}
addressHistory는 임베디드 타입은 Address를 컬렉션으로 가진다. 이것은 별도의 테이블을 사용해야 하며, 테이블 매핑 정보는 @AttributeOverride를 사용해서 재정의 할 수 있다.
값 타입 컬렉션 사용
Member member = new Member();
// 임베디드 값 타입
member.setHomeAddress(new Address("서울","잠실","123-1234"));
// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("서울","강남","123-123"));
member.getAddressHistory().add(new Address("서울","강북","321-321"));
em.persist(member);
등록하는 코드를 보면 마지막에 member 엔티티만 영속화했다. JPA는 이때 member 엔티티의 값 타입도 함께 저장한다. 컬렉션이 아닌 임베디드 값 타입인 member.homeAddress는 회원 테이블에 저장하는 SQL에 포함되며, 컬렉션인 member.addressHistory는 INSERT 문이 2번 일어난 것을 확인할 수 있었다.
값 타입 컬렉션 조회
값 타입 컬렉션도 조회할 때 패치 전략을 선택할 수 있는데 LAZY가 기본이다.
// SQL : SELECT ID, CITY, STREET, ZIPCODE FROM MEMBER WHERE ID = 1
Member member = em.find(Member.class, 1L); // 1. member
// 2. member.homeAddress
Address homeAddress = member.getHomeAddress();
// 3. member.addressHistory
List<Address> addressHistory = member.getAddressHistory(); // LAZY
// SQL : SELECT MEMBER_ID, CITY, STREET, ZIPCODE FROM ADDRESS WHERE MEMEBER_ID = 1
addressHistory.get(0);
- member : 회원만 조회한다. 이 때 임베디드 값 타입인 homeAddress도 함께 조회한다.
- member.homeAddress : 회원을 조회할 때 같이 조회해 둔다.
- member.addressHistory : LAZY로 설정해서 실제 컬렉션을 사용할 때 SELECT SQL을 1번 호출한다.
값 타입 컬렉션 수정
Member member = em.find(Member.class, 1L);
// 1. 임베디드 값 타입 수정
member.setHomeAddress(new Addres("새로운 도시", "신도시", "1234"));
// 2. 임베디드 값 타입 컬렉션 수정
List<Address> addressHistory = member.getAddressHistory();
addressHistory.remove(new Address("서울", "기존 주소", "123-123"));
addressHistory.add(new Address("새로운 도시", "새로운 주소", "321-321"));
임베디드 값 타입 수정은 MEMBER 테이블과 매핑했으므로 MEMBER 테이블만 업데이트한다. 사실 Member 엔티티를 수정하는 것과 같다. 임베디드 값 타입 컬렉션 수정은 값 타입은 불변해야 하기 때문에 컬렉션에서 기존 주소를 삭제하고 새로운 주소를 등록한다.
📌 참고로 값 타입은 equlas, hashcode를 꼭 구현해야 한다
제약사항
엔티티는 식별자가 있으므로 엔티티의 값을 변경해도 식별자로 데이터베이스에 저장된 원본 데이터를 쉽게 찾아서 변경할 수 있다. 반면에 값 타입은 식별자라는 개념이 없고 단순한 값들의 모음이므로 값을 변경해 버리면 데이터베이스에 저장된 원본 데이터를 찾기는 어렵다.
이런 문제로 인해 JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면, 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스에 다시 저장한다.
하지만 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 새로운 엔티티를 만들어서 일대다 관계를 고려해 보는 것이 좋다😄
👨👩👦👦 오픈채팅방 운영
취업을 준비하는 예비 개발자분들을 위한 질문&답변할 수 있는 공간을 만들었습니다. 취업과 이직을 하기 위해서 어떤 걸 중점적으로 준비해야 하는지부터 포트폴리오&이력서 작성법 등 다양한 질문들을 받고 답변을 드립니다. 참여하셔서 다양한 정보 얻고 가시면 좋을 것 같네요😁
참여코드 : 456456
https://open.kakao.com/o/gVHZP8dg
👨💻 전자책 출간
아울러 제가 🌟비전공자에서 2년만에 보안 전문 중견기업으로 이직 한 방법들을 정리한 전자책을 출간 하게 되었습니다. 어떤 걸 공부해야 하는지, 이직을 위해서 무엇을 준비해야 하는지, 제가 받았던 기술 면접 리스트 등 다양한 목차로 구성되어 있습니다. 또한, 구매 시 1:1 채팅을 이용하여 포트폴리오 첨삭을 도와드리고 있습니다. 🐕전자책으로 얻은 모든 수익은 유기견 센터 '팅*벨 입양센터'에 후원될 예정입니다. 관심 있으신 분들은 아래 링크를 참고해주세요😁
마치며
값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다. 특히 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안 된다. 식별자가 필요하고 지속해서 값을 추적하고 구분하고 변경해야 한다면 그것은 값 타입이 아닌 엔티티다.
지금까지 값 타입에 대해 알아보았고, 다음 포스팅은 객체지향 쿼리 언어에 대해 포스팅하려 한다😊
'Book Review' 카테고리의 다른 글
유튜브의 신 - 대도서관 책 리뷰 (0) | 2024.02.16 |
---|---|
자바 ORM 표준 JPA 프로그래밍 - JPQL (0) | 2023.01.17 |
자바 ORM 표준 JPA 프로그래밍 - 프록시와 연관관계 (0) | 2022.12.03 |
자바 ORM 표준 JPA 프로그래밍 - 고급 매핑 (0) | 2022.11.06 |
자바 ORM 표준 JPA 프로그래밍 - 다양한 연관관계 (0) | 2022.10.30 |