기본값 타입
JPA는 데이터 타입을 크게 2가지, 엔티티 타입과 값 타입으로 분류한다.
엔티티 타입은 `@Entity`로 정의하는 객체를 말하며 데이터가 변해도 식별자로 지속해서 추적이 가능하다. 예를 들어 회원 엔티티의 내부 속성이 모두 변하더라도 식별자는 그대로기 때문에 변경을 추적할 수 있고 회원 엔티티 임을 알 수 있다.
그에 반해 값 타입은 int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말하며 식별자가 존재하지 않아 엔티티와 달리 추적이 불가능하다. 아래 [1]은 [2]로 아예 대체 되는 것이다.
int aHundred = 100 ---[1]
aHundred = 200 ---[2]
값 타입
값 타입은 다시 3가지, 기본값 타입, 임베디드 타입, 컬렉션 값 타입으로 분류된다.
기본값 타입
기본값 타입은 자바의 Premitive 타입과 같은 것들을 말하며, 자바 기본타입(int, double), 래퍼 클래스(Integer, Long), String을 말한다. 이 타입은 기본적으로 생명주기를 엔티티에 의존하게 된다. 따라서 엔티티인 회원을 삭제하게 되면 이름, 나이와 같은 기본값 타입의 필드도 삭제가 된다. 또한, 값 타입은 공유하면 안된다. 값 타입을 공유할시 공유하는 값을 변경하게 되면 다른 엔티티의 값도 변경되기 때문에 side effect가 일어나게 된다.
참고로 자바의 기본 타입(int, double)은 절대 공유되지 않는다.
int a = 10;
int b = a;
a = 20;
a를 10으로 초기화하고 b를 a로 초기화할 때 자바는 int와 같은 원시 타입에 대해서 b에 a의 주소를 넣는 것이 아닌 값을 복사하여 넣는다. 즉 b는 10으로 초기화 된다. 그러므로 a를 20으로 다시 변경해도 b는 이미 10이 들어간 상태이기 때문에 20이 아닌 10을 가지고 있다.
단, Integer와 같은 래퍼(wrapper) 클래스는 공유가 가능하지만 변경되지는 않는다. 내부 int값을 변경하는 메소드 자체가 없기 때문이다.
임베디드 타입
임베디드 타입(embedded type, 복합 값 타입)은 기본값 타입의 묶음을 클래스로 만들어 사용하는 것을 말한다 예를들어, x,y 좌표에 대한 값을 position으로 묶는 다던지 위도, 경도 값을 하나의 클래스로 묶는다던지 하여 사용하게 되면 임베디드 타입이 된다.
예) x,y 좌표 / 위도, 경도 / 시, 동, 상세주소 - 집주소 / 출근시간, 퇴근시간
class Address {
String city;
String street;
String zipcode;
}
임베디드 타입은 재사용할 수 있고 높은 응집도를 갖출 수 있게하는 장점이 생긴다. 또한, 해당 값타입이 사용하는 의미 있는 메소드를 만들 수 있는 장점이 있다.
즉, 실제 테이블에는 기본값 타입의 내용들이 포함되지만 객체 세상에서는 객체를 분리하여 사용할 수 있다.
테이블 | 객체 |
create table Member ( MEMBER_ID bigint not null, age integer, USERNAME varchar(255), LOCKER_ID bigint, TEAM_ID bigint, city varchar(255), street varchar(255), zipcode varchar(255), endDate timestamp, startDate timestamp, primary key (MEMBER_ID) ) |
class Member { @Column(name = "MEMBER_ID") private Long id; @Column(name = "USERNAME") private String username; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "TEAM_ID") private Team team; @Embedded private Period workPeriod; @Embedded private Address homeAddress; } @Embeddable class Address { String city; String street; String zipcode; } |
임베디드 타입을 사용할 때는 위와 같이 `@Embeddable`, `@Embedded` 어노테이션을 통해 사용할 수 있다. 사용하기 전과 후의 테이블이 같다.
이를 통해 객체와 테이블을 아주 세밀하게(fine-grained) 매핑하는 것이 가능하다. 따라서 잘 설계한 ORM 애플리케이션은 매핑한 테이블 수 보다 클래스의 수가 더 많다.
임베디드 타입은 엔티티의 값일 뿐이며 임베디드 타입을 포함한 모든 값 타입은 값 타입을 소유한 엔티티의 생명주기에 의존한다.
임베디드 타입이 엔티티를 가지고 있을 수도 있다.
그런데 만약 회원 엔티티가 집 주소와 회사 주소를 모두 가지고 있어야 한다면 어떻게 해야할까? 이 집과 회사 모두 Address 클래스를 사용해야 하고 이렇게 되면 테이블 상에서 컬럼명이 겹치게 된다.
이럴 때 필요한 어노테이션이 따로 있다.
@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_ZIPCODE"))
})
private Address workAddress;
위와 같이 `@AttributeOverrides` 어노테이션을 활용하여 중복되는 칼럼에 대해 정의해줄 수 있다.
임베디드 타입을 null로 설정하게 되면 내부 필드 값 또한 null로 초기화되어 저장된다.
컬렉션 값 타입
컬렉션 값 타입(collection value type)은 자바 컬렉션 타입에 위의 기본값, 임베디드 타입을 넣을 수 있는 객체를 말한다.
값 타입과 불변 객체
값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다. 따라서 값 타입은 단순하고 안전하게 다룰 수 있어야 한다.
임베디드 타입과 같은 값 타입을 여러 엔티티에서 공유하면 위험한 side effect를 발생시킨다. 예를 들어 2명의 회원이 회사가 같아 같은 회사 주소를 가지게 되는 상황에서 같은 값 타입을 member1, 2에게 초기화시키게 되면 member1이 퇴사하여 회사가 바뀔 경우 member2도 같이 회사 주소가 바뀌게 되는 부작용이 발생된다. 만약 함께 바뀌는 결과를 원했다면 값 타입을 사용하는 것이 아니라 엔티티로 승격시켜 함께 변하도록 만들어야 한다.
이런 부작용을 해결하기 위한 방법으로는 값 타입 복사가 있다.
Member member1 = new Member();
member1.setUsername("1st member");
Address workAddress = new Address("city", "street", "zipcode");
member1.setWorkAddress(workAddress);
em.persist(member1);
Address copyAddress = new Address(workAddress.getCity(), workAddress.getStreet(), workAddress.getZipcode());
Member member2 = new Member();
member2.setUsername("2st member");
member2.setWorkAddress(copyAddress);
em.persist(member2);
하지만, 이 경우 컴파일러 수준에서 개발자가 실수로 copyAddress가 아닌 member1의 address를 넣은 것인지 확인할 수가 없으므로 객체의 공유 참조를 막는 부분에서 취약하므로 완벽한 해결책은 아니다.
이 부분에서 자바의 경우 원시 타입은 값 초기화시 참조가 아닌 값 복사가 되어 사용이 가능하지만 객체 타입은 불가능한 한계를 가지고 있다.
이 한계점을 해결하기 위해 객체를 불변 객체로 만들면 된다. 객체를 Integer, String과 같은 불변 객체를 만들시 부작용을 원천 차단할 수 있게 된다.(불변 객체: 생성 시점 이후 절대 값을 변경할 수 없는 객체 - 생성자로만 값을 설정하고 수정자(Setter)를 만들지 않은 것)
만약 값 타입 객체의 필드 하나를 바꾸고 싶다면 예를들어 기존 address의 city만 바꾸고 싶다면 새로운 address 객체를 만들어 변경해주어야 한다.
Address newAddress = new Address("NewCity", workAddress.getStreet(), workAddress.getZipcode());
member1.setWorkAddress(newAddress);
em.persist(member1);
물론 address에서 값을 바로 변경하여 사용할 수 있지만 부작용을 없애기 위해서는 값 객체를 통째로 바꾸어 사용하는 것이 옳은 방법이라고 할 수 있다.
값 타입의 비교
값 타입의 비교에서는 그 안의 값이 같으면 같은것으로 판단해야 하지만 일반적은 객체는 그렇지 않다.
int a = 10;
int b = 10;
Address A = new Address("서울시");
Address B = new Address("서울시");
위의 예시를 보자.
`a==b`는 원시타입의 비교이기 때문에 true를 반환한다. 하지만 `A==B`의 경우에는 내부의 값은 같지만 인스턴스화 된 객체가 다르기 때문에 다른 값이라도 판단하게 된다.
이 때 언급되는 것이 동일성(identity) 비교와 동등성(equivalence) 비교이다.
- 동일성(identity) 비교 : 인스턴스의 참조 값을 비교, == 비교
- 동등성(equivalence) 비교 : 인스턴스의 값을 비교, equals() 사용
제대로 된 값 타입의 비교를 위해서는 동등성 비교가 되어야 하므로 비교를 위해서 값 타입의 equals() 메소드를 주로 모든 필드에 대해서 적절하게 재정의 해주는 작업을 해야한다.
public class Address {
@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);
}
}
값 타입의 컬렉션
값 타입의 컬렉션은 엔티티가 아닌 값 타입을 하나 이상 저장할 때 사용한다.
`@ElementCollection`, `@CollectionTable` 어노테이션을 사용해 값 타입 테이블을 만들 수 있다.
데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없으므로 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
public class Member {
...
@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();
@ElementCollection
@CollectionTable(name = "ADDRESS",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();
...
}
`@ElementCollection`을 통해 컬렉션을 값 타입의 테이블을 만들어 보관할 수 있도록 해주고 `@CollectionTable`을 통해 만들어질 테이블의 상세 속성을 정의할 수 있다. `@Column(name = "FOOD_NAME")`의 경우에는 값 타입 테이블에서 사용될 컬럼명을 정의해준다.
값 타입은 엔티티의 생명주기에 의존하므로 소유하고 있는 엔티티가 persist될시 함께 변경점들이 더티체킹되어 DB에 적용된다. 따라서 값 타입 컬렉션은 영속성 전이(Cascade)와 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
또한, 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. 이 부분에서 엔티티는 id라는 pk값을 가지는 특징과 다르다. 따라서 값 타입 컬렉션의 테이블에는 null 입력이 불가능하고, 중복 저장도 불가능하다.
이제 실제로 값 타입 컬렉션의 값을 변경해보자.
//값 타입의 컬렉션
member1.setHomeAddress(new Address("city", "street", "zipcode"));
member1.getFavoriteFoods().add("치킨");
member1.getFavoriteFoods().add("피자");
member1.getFavoriteFoods().add("족발");
member1.getAddressHistory().add(new Address("old1", "street", "10000"));
member1.getAddressHistory().add(new Address("old2", "street", "10000"));
em.flush();
em.clear();
Member findMember = em.find(Member.class, member1.getId());
//집주소 변경하고 싶은 경우 통째로 값타입 인스턴스를 변경해야함.
Address homeAddress = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("NewCity", homeAddress.getStreet(), homeAddress.getZipcode()));
//치킨 -> 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
//주소 컬렉션 변경
findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressHistory().add(new Address("new1", "street", "10000"));
엔티티처럼 값 타입에 대해서도 더티체킹 후 반영을 해주며 반드시 값 타입 인스턴스 통째로 갈아끼워야 하므로 equals() 메서드를 잘 정의해놓을 필요가 있다.
위 코드를 실행해보면 다음과 같이 주소 컬렉션을 변경할 때 다음과 같은 SQL이 나가는 것을 확인할 수 있다.
가장 효율적으로 작동하려면 주소 중 old1인 값 타입 주소를 찾아 삭제하는 delete문과 새로운 주소를 저장하기 위한 한 개의 insert문이 나가 총 2개가 나가야하지만 위 SQL을 보게 되면 DELETE시 모든 주소를 삭제하고 다시 collection을 한 개씩 insert하게 된다. 결과적으로는 원하는 대로 작동하였지만 효율적으로 작동하지는 않은 것처럼 보인다.
이런 문제가 발생하는 이유는 값 타입 컬렉션에는 다음과 같은 제약사항을 가지고 있기 때문이다.
값 타입은 엔티티와 다르게 식별자 개념이 없으므로 'old1'에 대한 추적을 하고 있지 않다. 따라서 값을 변경하게 되면 추적이 어렵고 JPA는 어쩔 수 없이 모든 데이터를 삭제하고 값 타입 컬렉션에 있는 현재 상태를 다시 저장하게 된다.
`@OrderColumn()`과 같은 어노테이션으로 추적가능하게끔 설정할 수 있으나 이 또한 삭제 후 null을 넣는 등의 부작용을 야기하므로 값 타입 컬렉션을 사용하기 위한 정석적인 방법은 아니다.
이런 값 타입 컬렉션의 문제를 해결하기 위한 대안으로는 다음과 같다.
실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려한다. 컬렉션을 엔티티로 승격시키고 엔티티 내부에서 값 타입을 사용한다. 또한, 위에서 설명했듯 값 타입 컬렉션은 영속성 전이(Cascade)와 고아 객체 제거 기능을 가지고 있으므로 엔티티 생성시 해당 기능을 사용해서 값 타입 컬렉션처럼 사용할 수 있도록 만들 수 있다. 주소를 엔티티로 만들게 되면 `AddressEntity`를 만드는 것이다.
// @ElementCollection
// @CollectionTable(name = "ADDRESS",
// joinColumns = @JoinColumn(name = "MEMBER_ID"))
// private List<Address> addressHistory = new ArrayList<>();
||
\/
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
대부분의 경우 엔티티를 사용하게 되고 실무에서 정말 간단한 경우에만 값 타입 컬렉션이 사용될 수 있다고 생각하면 된다. 예를 들어 정말 간단한 선호도 조사시 다중 체크 박스로 데이터를 받을 경우가 있을 것이다.
정리
엔티티 타입 | 값 타입 |
- 식별자 존재 - 생명주기 관리 가능 - 공유 가능 |
- 식별자 없음 - 생명주기를 엔티티에 의존 - 공유하지 않는 것이 안전(복사해서 사용) - 불변 객체로 만드는 것이 안전 |
값 타입은 정말 값 타입이라고 판단될 때만 사용한다. 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다. 식별자가 필요하고, 지속해서 값을 추적 혹은 변경해야 한다면 값 타입이 아닌 엔티티로 만들어야 한다.
'노트 정리 > 자바 ORM 표준 JPA 프로그래밍' 카테고리의 다른 글
[JPA이론] 11. 객체지향 쿼리 언어2 - 중급 문법 (0) | 2022.10.23 |
---|---|
[JPA이론] 10. 객체지향 쿼리 언어1 - 기본 문법 (0) | 2022.10.21 |
[JPA이론] 8. 프록시와 연관관계 관리 (0) | 2022.10.19 |
[JPA이론] 7. 고급매핑 (0) | 2022.10.14 |
[JPA이론] 6. 다양한 연관관계 매핑 (0) | 2022.10.13 |