개요
JPA의 값 타입은 크게 기본값 타입, 임베디드 타입이 있고 이들로 이루어진 컬렉션을 값 타입 컬렉션이라고 합니다.
이번 게시글에서는 이 타입들에 대해서 알아보겠습니다.
1. 엔티티 타입 vs 값 타입
1.1 엔티티 타입
- @Entity 어노테이션으로 정의된 객체
- 내부 데이터 즉, 속성이 변하더라도 식별자로 인해 지속해서 추적이 가능한 객체
- 사람 엔티티의 나이와 몸무게가 변하더라도 식별자로 인식 가능
1.2 값 타입
- Primitive Type, Reference Type처럼 단순히 값으로 사용하는 자바 기본 타입 혹은 객체
- 식별자가 없고 값만 존재
- 따라서 값이 변하면 추적 불가
- 숫자 1을 2로 변경할 경우 완전히 다른 값으로 대체
2. 값 타입
2.1 기본값 타입
- int와 String 같은 타입
- 생명주기를 엔티티에 전적으로 의존
- 계정을 삭제할 경우 이름, 이메일, 나이와 같은 정보 모두 같이 삭제됨
- 값 타입은 공유하면 side effect 발생하므로 절대 공유하면 안됨
- A 회원의 잔고 변경 시 다른 회원의 잔고도 변경되면 안됨
- int와 double과 같이 NULL 타입을 허용하지 않은 primitive type 같은 경우 항상 값을 복사하므로 공유될 일이 없음
- 하지만, Integer, String과 같이 NULL 타입을 허용하는 reference type 같은 경우 레퍼런스 즉, 참조를 복사하므로 같은 인스턴스를 공유하므로 공유를 할 수 없도록 별도 작업을 해줘야 함
- 변경 자체를 불가능하게 setter를 생성하지 않거나 private으로 선언해서 side effect 방지
2.2 임베디드 타입
- 클래스와 같이 새로운 값 타입을 직접 정의한 타입
- 주로 기본 값 타입을 조합해서 만든 타입을 임베디드 타입이라고 함
- 직원 엔티티가 이름, 근무 시작일, 근무 종료일, 주소에 기입된 도시, 주소에 기입된 번지, 주소에 기입된 우편번호를 가진다고 가정
- 근무 시작일, 근무 종료일은 비슷한 성격을 가지고 도시, 번지, 우편번호 또한 비슷한 성격을 가짐
- 따라서, 각각을 조합해 임베디드 타입을 생성 가능
2.2.1 임베디드 타입을 사용하면서 생기는 장점
- 재사용 가능 및 높은 응집도
- 해당 값만 사용하는 의미 있는 메서드 즉 행위를 만들 수 있으므로 객체지향적으로 설계가 가능
- 객체는 데이터뿐만 아니라 메서드라는 기능 혹은 행위까지 가지고 있으므로 묶는 게 유리함
- 이때, DB 입장에서는 바뀔 게 없으므로 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 동일
- 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능하고 용어 공통화 및 코드 공통화가 가능
- 따라서, 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많음
- 임베디드 타입을 포함한 모든 값 타입은 해당 엔티티에 생명주기를 의존하므로 관리하기 용이함
2.2.2 임베디드 타입 사용 방법
- @Embedabble 어노테이션을 값 타입을 정의하는 곳에 지정
- @Embedded 값 타입을 사용하는 곳에 지정
- 기본 생성자는 필수로 있어야 함
2.2.3 임베디드 타입 지정 전
@Entity
@Getter
class Employee {
@Id
@GeneratedValue
private Long id;
private String name;
private LocalDateTime workStartAt;
private LocalDateTime workEndAt;
private String city;
private String street;
private String zipCode;
}
2.2.4 임베디드 타입 지정 후
@Entity
@Getter
class Employee {
@Id
@GeneratedValue
private Long id;
private String name;
@Embedded
private WorkTime workTime;
@Embedded
private Address address;
}
@Embeddable
@Getter
class WorkTime {
private LocalDateTime workStartAt;
private LocalDateTime workEndAt;
}
@Embeddable
@Getter
class Address {
private String city;
private String street;
private String zipCode;
}
2.2.5 하나의 엔티티 내 동일한 임베디드 타입을 여러 개 사용해야 할 경우?
- 위 Address 임베디드 타입 같은 경우 하나의 엔티티 내 여러 개 사용 가능
- 집 주소와 근무지 주소를 속성으로 가질 경우
- 이럴 경우 칼럼명이 중복되어 에러가 발생
- 그렇다면 어떻게 해결할 수 있을까?
- @AttributeOverride 어노테이션을 통해 칼럼명을 변경해주면 됨
- 여러 개의 칼럼명을 재정의하기 위해서는 @AttributeOverrides 사용
- 하나의 칼럼명만 재정의하는 경우 @AttributeOverride 사용
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* create table SampleMember ( | |
* MEMBER_ID bigint not null, | |
* city varchar(255), | |
* street varchar(255), | |
* zipCode varchar(255), | |
* USERNAME varchar(255), | |
* WORK_CITY varchar(255), | |
* WORK_STREET varchar(255), | |
* WORK_ZIP_CODE varchar(255), | |
* endDate timestamp, | |
* startDate timestamp, | |
* primary key (MEMBER_ID) | |
* ) | |
*/ | |
@Entity | |
@Getter | |
@Setter | |
public class SampleMember { | |
@Id | |
@GeneratedValue | |
@Column(name = "MEMBER_ID") | |
private Long id; | |
@Column(name = "USERNAME") | |
private String username; | |
@Embedded | |
private Period workPeriod; | |
@Embedded | |
private Address homeAddress; | |
@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_ZIP_CODE")) | |
}) | |
private Address workAddress; | |
} |
3. 값 타입과 불변 객체
- 앞서 언급했듯이 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 side effect가 발생할 수 있으므로 위험함
- 따라서, 인스턴스의 값을 복사해서 사용하는 것이 안전
- 하지만, 객체 타입의 경우 primitive type과 다르게 무조건 복사가 아닌 reference 즉, 참조값을 복사
- 정리하자면 객체 타입은 참조 값을 직접 대입하는 것을 막을 수 없으므로 컴파일 단계에서 방지 불가능하므로 객체의 공유 참조를 피할 수 없음
// primitive type
int a = 1;
int b = a;
b = 2;
결과 값: a = 1, b = 2
// 객체 타입
Address address = new Address("Old Address");
Address newAddress = a; // 객체 타입은 참조를 복사
newAddress.setCity("New Address");
결과 값: address.getCity(): "New Address", newAddress.getCity(): "New Address"
따라서, 객체 타입의 경우 불변 객체로 선언하여 수정할 수 없게 만들어 side effect를 원천 차단해야 함
- 정리를 하자면 객체 값 타입은 immutable object로 설계해야 함
- immutable object란 생성 시점 이후 절대 값을 변경할 수 없는 객체
- String과 같은 객체 타입
- String의 경우 값이 변경될 경우 주소 값도 변경되므로 불변 객체
- 불변 객체의 경우 생성자로만 값을 설정하고 getter만 제공해야 함
- setter는 선언하지 않거나 private으로 지정해야 함
- 정리를 하자면, 객체 타입의 경우 불변 객체로 선언해야 하고 이 말은 즉슨 값을 변경하기 위해서는 새로운 객체를 생성해야 함
3.1 객체 값 타입을 비교하는 방법
- 값 타입을 비교할 때는 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 함
- 여기서 우리는 동일성(identity)과 동등성(equivalence)의 차이를 알아야 함
- 동일성은 인스턴스의 참조 값을 비교하므로 == 기호를 통해 비교
- 동등성은 인스턴스의 값을 비교하므로 equals() 메서드를 통해 값을 비교
- 따라서, 객체 값 타입의 경우 equals() 메서드를 통해 값을 비교해야 함
- 즉, 객체를 선언할 때 equals() 메서드를 적합하게 재정의하고 equals() 메서드를 사용하기 위해서는 hash() 메서드 또한 구현해줘야 함
@Embeddable
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Address {
private String city;
private String street;
private String zipCode;
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Address address = (Address) o;
// JPA에서는 proxy 때문에 getter로 해야함
return Objects.equals(getCity(), address.city)
&& Objects.equals(getStreet(), address.street)
&& Objects.equals(getZipcode(), address.zipcode);
}
@Override
public int hashCode() {
return Objects.hash(city, street, zipCode);
}
}
4. 값 타입 컬렉션
- 값 타입을 하나 이상 저장할 때 사용하는 컬렉션
- @ElementCollectoin, @CollectionTable 어노테이션 사용
- DB 같은 경우 JSON 타입으로 저장하지 않는 이상 컬렉션을 같은 테이블 내 저장할 수 없음
- 따라서, 컬렉션을 저장하기 위한 별도의 테이블이 필요함
- 값 타입 컬렉션을 조회 시 즉시 로딩 전략을 취할 경우 실행 속도가 엄청 느려질 가능성 높음
- 따라서, 지연 로딩 전략을 취함
- 값 타입의 경우 엔티티에 종속적이므로 값 타입 컬렉션은 영속성 전이(Cascade) + 고아 객체 제거 기능을 필수로 지닌다고 생각해도 됨
- 관련해서는 이전 게시글 https://jaimemin.tistory.com/1920 참고
4.1 값 타입 컬렉션의 제약사항
- 앞서 언급했듯이 값 타입은 식별자 개념이 없기 때문에 변경 시 추적이 어려움
- 따라서, 값 타입 컬렉션에 변경 사항이 발생할 경우 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장하는 과정을 거쳐야 함
- 실행 속도에 큰 영향을 미침
- 또한, 값 타입 컬렉션을 매핑하는 테이블은 모든 칼럼을 묶어서 기본키를 구성해야 하는 제약사항이 있음
- NULL X, 중복 저장 X
4.2 값 타입 컬렉션 대안
- 값 타입 컬렉션을 사용하는 대신 OneToMany 관계를 사용하는 것이 실무에서 나을 수도 있음
- 일대다 관계를 위한 엔티티를 별도로 생성하고 여기에서 사용하려던 값 타입을 사용
- 영속성 전이 및 고아 객체 제거를 사용해서 값 타입 컬렉션처럼 사용하면 됨
값 타입 컬렉션 엔티티 예시
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class Address {
private String city;
private String street;
private String zipCode;
@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(city, address.city)
&& Objects.equals(street, address.street)
&& Objects.equals(zipCode, address.zipCode);
}
@Override
public int hashCode() {
return Objects.hash(city, street, zipCode);
}
}
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "ADDRESS")
public class AddressEntity {
@Id
@GeneratedValue
private Long id;
private Address address;
}
샘플 멤버를 통한 예시
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* create table SampleMember ( | |
* MEMBER_ID bigint not null, | |
* city varchar(255), | |
* street varchar(255), | |
* zipCode varchar(255), | |
* USERNAME varchar(255), | |
* primary key (MEMBER_ID) | |
* ) | |
* | |
* create table ADDRESS ( | |
* MEMBER_ID bigint not null, | |
* city varchar(255), | |
* street varchar(255), | |
* zipCode varchar(255) | |
* ) | |
*/ | |
@Entity | |
@Getter | |
@Setter | |
public class SampleMember { | |
@Id | |
@GeneratedValue | |
@Column(name = "MEMBER_ID") | |
private Long id; | |
@Column(name = "USERNAME") | |
private String username; | |
@Embedded | |
private Address homeAddress; | |
@ElementCollection // 디폴트로 Lazy | |
@CollectionTable(name = "ADDRESS", joinColumns = | |
@JoinColumn(name = "MEMBER_ID") | |
) | |
private List<Address> addressHistory = new ArrayList<>(); | |
} | |
public static void main(String[] args) { | |
EntityManagerFactory entityManagerFactory | |
= Persistence.createEntityManagerFactory("hello"); | |
EntityManager entityManager = entityManagerFactory.createEntityManager(); | |
EntityTransaction transaction = entityManager.getTransaction(); | |
transaction.begin(); | |
try { | |
// 값 타입 저장 예제 | |
SampleMember sampleMember = new SampleMember(); | |
sampleMember.setUsername("member1"); | |
sampleMember.setHomeAddress(new Address("homeCity" | |
, "street" | |
, "100000")); | |
sampleMember.getAddressHistory().add(new AddressEntity("old1" | |
, "street" | |
, "100000")); | |
sampleMember.getAddressHistory().add(new AddressEntity("old2" | |
, "street" | |
, "100000")); | |
// addressHistory 값 타입이므로 sampleMember에 종속적 | |
entityManager.persist(sampleMember); | |
entityManager.flush(); | |
entityManager.clear(); | |
/** | |
* select | |
* samplememb0_.MEMBER_ID as member_i1_11_0_, | |
* samplememb0_.city as city2_11_0_, | |
* samplememb0_.street as street3_11_0_, | |
* samplememb0_.zipCode as zipcode4_11_0_, | |
* samplememb0_.USERNAME as username5_11_0_ | |
* from | |
* SampleMember samplememb0_ | |
* where | |
* samplememb0_.MEMBER_ID=? | |
* | |
* 지연로딩 | |
*/ | |
SampleMember foundSampleMember | |
= entityManager.find(SampleMember.class, sampleMember.getId()); | |
List<AddressEntity> addressHistory = foundSampleMember.getAddressHistory(); | |
for (AddressEntity addressEntity : addressHistory) { | |
System.out.println("address = " + addressEntity.getAddress().getCity()); | |
} | |
// homeCity -> newCity | |
// 절대 이렇게 하면 안됨 | |
// foundSampleMember.getHomeAddress().setCity("newCity"); | |
Address oldAddress = foundSampleMember.getHomeAddress(); | |
// 갈아껴야함 | |
foundSampleMember.setHomeAddress(new Address("newCity" | |
, oldAddress.getCity() | |
, oldAddress.getZipCode())); | |
// 앞선 문제 | |
// 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장 | |
// 결론: 이렇게 쓰면 안된다 (실행속도 저하) | |
// -> 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야함 | |
// 앞선 문제의 대안 | |
// Row update: update ADDRESS set where id=? | |
// 일대다에서 update일 수 밖에 없음 | |
// 자체적인 엔티티가 생겨 마음껏 수정할 수 있음 | |
AddressEntity foundAddressEntity = foundSampleMember.getAddressHistory().get(0); | |
foundSampleMember.getAddressHistory().remove(foundAddressEntity); | |
transaction.commit(); | |
} catch (Exception e) { | |
transaction.rollback(); | |
e.printStackTrace(); | |
} finally { | |
entityManager.close(); | |
} | |
entityManagerFactory.close(); | |
} |
5. 최종 정리
엔티티 타입
- 식별자가 있으므로 값이 변경되더라도 식별할 수 있음
- 엔티티가 생명주기를 직접 관리
- 공유해도 무방함
값 타입
- 식별자가 없으므로 값이 변경되면 추적 불가능
- 생명주기를 엔티티에 의존
- 공유할 경우 side effect 발생할 수 있으므로 복사해서 사용하는 것이 안전
- 객체 타입의 경우 참조값을 복사하므로 이런 케이스는 복사하는 것이 적합하지 않음
- 따라서, 값을 오직 생성자에서 설정을 해서 불변 객체(immutable object)로 선언하는 것이 중요
출처
자바 ORM 표준 JPA 프로그래밍 - 기본편 (김영한 강사님)
반응형
'DB > JPA' 카테고리의 다른 글
[JPA] JPQL 추가 정리 (0) | 2021.10.18 |
---|---|
[JPA] JPQL 간단 정리 (0) | 2021.10.16 |
[JPA] 프록시와 연관관계 관리 정리 (0) | 2021.09.14 |
[JPA] @MappedSuperclass (0) | 2021.09.07 |
[JPA] 상속관계 매핑 (2) | 2021.09.07 |