JPA는 최상위 데이터 타입을 2가지로 분류한다.
값 타입은 크게 세가지로 분류할 수 있다.
String name과 int age같은 것들이다.
생명주기가 엔티티에 의존한다. 따라서 만약 회원을 삭제하면 안에 name과 age 필드도 함께 삭제 된다.
값 타입은 공유하면 안된다.
예를 들어 회원 이름을 변경할 때 다른 회원의 이름이 변경 되면 안된다.
int a = 10;
int b =a;
a = 20;
위와 같은 상황이라고 했을 때 b는 a가 복사되어서 값이 들어가는 것이다. 따라서 둘은 다른 저장공간을 가지고 있다. 그러므로 a와 b는 공유되고 있지 않다.
참고 : 자바의 기본 타입은 절대 공유 되지 않는다.
기본 타입은 항상 값을 복사한다.
Integer같은 래퍼 클래스나 String 같은 특수한 클래스는 공유 가능한 객체지만 변경되지 않는다.
Integer나 String은 클래스이므로 reference를 긁어간다. 따라서 공유 된다.
Integer a = new Integer(10);
Integer b = a;
위의 상황에선 Integer는 클래스이기 때문에 b에는 a값이 복사되는 게 아닌 a의 주소값이 들어가게 된다.
그러면 a가 변경될 경우 b도 변경된다.
Integer a = new Integer(10);
Integer b = a;
a.setValut(20);
위와 같이 a의 값을 설정하는 setValue라는 메소드가 있다고 하자. 그러면 a의 값을 20으로 설정하면 b의 값도 20으로 바뀌게 된다.
같은 인스턴스를 공유하기 때문이다. 하지만 변경이 불가능하므로 공유로 일어날 문제를 사전에 차단한다.
이것이 기본 값으로 개발을 할 수 있는 이유이다.
위와 같은 회원 엔티티가 있다고 하자.
그렇다면 이 회원 엔티티에서 공통으로 관리할 수 있을 것 같은 컬럼들이 보일 것이다.
startDate와 endDate를 날짜로 묶고, city, street, zipcode를 주소로 묶을 수 있을 것이다. 그리서 날짜를 workPeriod로, 주소는 homeAddress로 묶어보자.
그렇다면 아래와 같은 엔티티가 될 것이다.
위의 그림을 보면 workPeriod의 타입은 Period로, homeAddress의 타입은 Address로 자바가 기본으로 제공하는 타입이 아니다. 우리가 이 타입들을 만들어 볼 것이다.
Member엔티티를 풀면 위와 같이 연결되어있는 것이다.
재사용이 가능하다. 기간이나 주소같은 경우엔 이 시스템 전체에서 재사용이 가능할 것이다.
높은 응집도, 클래스 내에선 응집도가 높다.
Period.isWork()처럼 해당 값 타입만 사용하는 의미 있는 메소드를 만들 수 있다.
임베디드 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명주기를 의존한다.
실제 테이블 매핑시에는 기간과 주소가 처음과 같이 들어간다. 데이터베이스는 데이터를 잘 보관하는 것이 목적이기 때문이다.
그럼 왜 사용하는 것일까 ?
객체는 데이터 뿐만아니라 메서드 같은 기능도 가지고 있기 때문에 공통된 것들 끼리 묶었을 때 이점이 많다.
예제를 통해 알아보자.
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id; //PK
@Column(name = "USERNAME")
private String username;//객체는 username db엔 name이라고 쓰고 싶을 때
private LocalDateTime startDate;
private LocalDateTime endDate;
private String city;
private String street;
private String zipcode;
public Member() {
}
}
위와 같이 Member엔티티를 만들면 아래와 같이 모든 필드로 데이터베이스가 만들어지는 것을 볼 수 있다.
Hibernate:
create table Member (
MEMBER_ID bigint not null,
city varchar(255),
endDate timestamp,
startDate timestamp,
street varchar(255),
USERNAME varchar(255),
zipcode varchar(255),
primary key (MEMBER_ID)
)
임베디드를 활용해보자.
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id; //PK
@Column(name = "USERNAME")
private String username;//객체는 username db엔 name이라고 쓰고 싶을 때
//Period
@Embedded
private Period workPeriod;
//Address
@Embedded
private Address homeAddress;
public Member() {
}
}
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
public Address() {
}
}
@Embeddable
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
public Period() {
}
@Embeddable과 @Embeded는 둘 중 하나만 써도 되지만 두개 다 쓰는 것을 권장한다.
위와 같이 엔티티를 설계하면 어떤 쿼리가 나가게 될까.
Hibernate:
create table Member (
MEMBER_ID bigint not null,
city varchar(255),
street varchar(255),
zipcode varchar(255),
USERNAME varchar(255),
endDate timestamp,
startDate timestamp,
primary key (MEMBER_ID)
)
위에서와 같이 모든 필드가 데이터베이스에 쿼리로 나가는 것을 볼 수 있다.
객체는 객체 지향스럽게 설계하고, 데이터베이스는 데이터 베이스의 목적에 맞게 설계가 된것이다.
Member member = new Member();
member.setUsername("hello");
member.setAddress(new Address("city", "street", "zipcode"));
member.setWorkPeriod(new Period());
em.persist(member);
위와 같이 활용할 수 있다.
Member는 Address와 PhoneNumber 임베디드 타입을 가질 수 있다. 임베디드 타입은 임베디드 타입을 가질 수 있으므로 Address는 Zipcode를 가지고 있는데 임베디드 타입은 엔티티 또한 가질 수 있다. 따라서 PhoneNumber는 PhoneEntity를 가진다. 이는 foreign key만 가지고 있으면 되기때문에 가능하다.
만약 한 엔티티에서 같은 값 타입을 사용한다면 어떻게 될까. 예를 들어 Member가 타입이 Address인 homeAddress와 workAddress를 가진다면 어떻게 해야할까.
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id; //PK
@Column(name = "USERNAME")
private String username;//객체는 username db엔 name이라고 쓰고 싶을 때
@Embedded
private Period workPeriod;
@Embedded
private Address homeAddress;
@Embedded
private Address workAddress;
public Member() {
}
}
위와 같이 작성하게 되면 오류가 난다. 중복 매핑 되었기 때문이다.
이때 사용할 수 있는 것이 AttributeOverride이다.
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id; //PK
@Column(name = "USERNAME")
private String username;//객체는 username db엔 name이라고 쓰고 싶을 때
@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_ZIPCODE"))
})
private Address workAddress;
public Member() {
}
}
위와 같이 수정해보자. 그렇다면 쿼리는 어떻게 나갈까.
Hibernate:
create table Member (
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_ZIPCODE varchar(255),
endDate timestamp,
startDate timestamp,
primary key (MEMBER_ID)
)
위와 같이 workAddress도 잘 추가된 것을 확인할 수 있다.
임베디드 타입의 값이 null이면 매핑한 컬럼 값은 모두 null이다.
@Embedded
private Period workPeriod = null;
로 수정하면 wordkPeriod의 모든 값인 startDate와 endDate모두 null값이 들어간다.
값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다. 따라서 값 타입은 단순하고 안전하게 다룰 수 있어야한다.
개발할 때 크게 신경쓰지 않는 것이 있다. 바로 값을 복사하는 것이다. 이 이유는 자바에서 단순하고 안전하게 다룰 수 있도록 설계가 되어있기 때문이다.
임베디드 타입 같은 값 타입을 여러 엔티티에서 공유할 수 있다. 때문에 이것을 공유하면 위험하다.
회원 1과 회원 2가 같은 값타입인 주소를 공유할 때, 주소에 있는 city가 NewCity로 변경된다고 해보자. 그러면 회원 1과 회원 2의 테이블이 모두 NewCity로 바뀌게 된다.
Address address = new Address("city", "street", "10000");
Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("member2");
member2.setHomeAddress(address);
em.persist(member2);
member1과 member2가 같은 address를 가지고 있다. 이때 DB는 아래와 같다.
문제 없이 값이 들어간 것을 볼 수 있다.
하지만 이때 member1의 address를 변경하고 싶어서 변경해보면 어떻게 될까.
Address address = new Address("city", "street", "10000");
Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("member2");
member2.setHomeAddress(address);
em.persist(member2);
member1.getHomeAddress().setCity("newCity");
위의 코드를 실행하면 update 쿼리가 아래와 같이 두번 나가는 것을 확인할 수 있다.
Hibernate:
/* update
hellojpa.Member */ update
Member
set
city=?,
street=?,
zipcode=?,
USERNAME=?,
endDate=?,
startDate=?
where
MEMBER_ID=?
Hibernate:
/* update
hellojpa.Member */ update
Member
set
city=?,
street=?,
zipcode=?,
USERNAME=?,
endDate=?,
startDate=?
where
MEMBER_ID=?
데이터베이스의 값을 확인해보자.
위와 같이 member1 뿐만 아니라 member2의 address 값도 변경 된 것을 볼 수 있다.
이러한 side effect로 인한 버그는 잡기가 굉장히 어렵다.
따라서 값을 공유하는 것은 위험하므로 값을 복사해서 사용해야한다.
Address address = new Address("city", "street", "10000");
Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);
em.persist(member1);
Address address1 = new Address(address.getCity(), address.getStreet(), address.getZipcode());
Member member2 = new Member();
member2.setUsername("member2");
member2.setHomeAddress(address1);
em.persist(member2);
member1.getHomeAddress().setCity("newCity");
위와 같이 address의 값을 복사해서 address1에 넣고, member2가 address1를 사용하면 member1의 address값이 변경되어도 member2의 address값이 변경되지 않는다.
따라서 정리하자면 위의 그림과 같이 기본 타입은 값을 복사해서 위험하지 않으나 객체 타입은 참조를 복사해서 전달해 변경시에 위험이 있다.
a와 b모두 같은 인스턴스를 가리키기 때문이다.
따라서 불변객체를 만들어서 사용해야한다.
setter를 만들지 않으면 값을 변경할 수 없으므로 이러한 위험이 없어진다. 아예 만들지 말던지 setter를 private으로 만들면 된다.
불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막을 수 있다.
그렇다면 값을 바꾸고 싶을 땐 어떻게 해야할까.
Address address = new Address("city", "street", "10000");
Member member1 = new Member();
member1.setUsername("member1");
member1.setHomeAddress(address);
em.persist(member1);
Address newAddress = new Address("NewCity", address.getStreet(), address.getZipcode());
member1.setHomeAddress(newAddress);
address를 새로 만들어서 member1에 넣으면 된다.
값 타입 : 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야한다.
예를 들어
int a = 10;
int b = 10;
일 때 a와 b는 같다.
Address address1 = new Address("서울시");
Address address2 = new Address("서울시");
와 같이 만들어도 address1과 address2는 ==비교를 하면 false가 나온다. address1과 address2의 참조값이 각각 다르기 때문이다.
그냥 equals를 사용하면 안된다. equal는 기본으로 == 비교하게 되어있기 때문이다.
@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);
}
위와 같이 오버라이드해서 사용한다.
값타입을 컬렉션에 담아서 사용하는 것을 말한다.
객체입장에서 컬렉션을 사용하는 것은 자연스럽지만, DB에선 따로 테이블 내에 컬렉션을 저장할 수 없다.
따라서 Member에 favoriteFoods 테이블을 별도로 뽑아 일대 다 관계로 매핑해야한다.
테이블을 뽑을 때 따로 식별자를 pk로 지정하지 않는 이유는, 값 타입이기 때문이다. 식별자로 추정이 가능해지면 그것은 값 타입이 아닌 엔티티 타입이 되어버린다.
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id; //PK
@Column(name = "USERNAME")
private String username;//객체는 username db엔 name이라고 쓰고 싶을 때
@Embedded
private Address homeAddress;
@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<>();
public Member() {
}
}
위와 같이 작성하면 된다.
쿼리를 확인해보자.
Hibernate:
create table Member (
MEMBER_ID bigint not null,
city varchar(255),
street varchar(255),
zipcode varchar(255),
USERNAME varchar(255),
primary key (MEMBER_ID)
)
Hibernate:
create table FAVORITE_FOOD (
MEMBER_ID bigint not null,
FOOD_NAME varchar(255)
)
Hibernate:
create table ADDRESS (
MEMBER_ID bigint not null,
city varchar(255),
street varchar(255),
zipcode varchar(255)
)
Member 테이블 외에도 FAVORITE_FOOD와 ADDRESS 테이블이 생성된 것을 확인할 수 있다.
그렇다면 컬렉션에 값을 넣어보자
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 Address("old1", "street", "10000"));
member.getAddressHistory().add(new Address("old2", "street", "10000"));
em.persist(member);
위와 같이 값을 넣으면 아래와 같이 저장된다.
기대했던 대로 잘 동작하는것을 확인할 수 있다.
값 타입 컬렉션을 따로 persist하지 않고 member만 persist해도 각각의 테이블에 값이 들어간다. 값 타입이기 때문에 라이프 사이클(생명 주기)이 member에 의존한다.
값 타입 컬렉션은 영속성 전이 + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
그럼 Member를 조회해보자.
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
Member를 조회하면 과연 FAVORITE_FOOD와 ADDRESS까지 모두 조회될까?
실행 결과는 아래와 같다.
Hibernate:
select
member0_.MEMBER_ID as MEMBER_I1_6_0_,
member0_.city as city2_6_0_,
member0_.street as street3_6_0_,
member0_.zipcode as zipcode4_6_0_,
member0_.USERNAME as USERNAME5_6_0_
from
Member member0_
where
member0_.MEMBER_ID=?
실행 결과를 보면 Member만 조회하는 것을 알 수 있다. 따라서 컬렉션은 모두 지연로딩하는 것이다.
Member findMember = em.find(Member.class, member.getId());
List<Address> addressHistory = findMember.getAddressHistory();
for (Address address : addressHistory) {
System.out.println("address = " + address.getCity());
}
그럼 getAddressHistory를 통해 Address 테이블을 건드리면 ? 그제서야 쿼리가 나가는 것을 확인할 수 있다.
Hibernate:
select
member0_.MEMBER_ID as MEMBER_I1_6_0_,
member0_.city as city2_6_0_,
member0_.street as street3_6_0_,
member0_.zipcode as zipcode4_6_0_,
member0_.USERNAME as USERNAME5_6_0_
from
Member member0_
where
member0_.MEMBER_ID=?
Hibernate:
select
addresshis0_.MEMBER_ID as MEMBER_I1_0_0_,
addresshis0_.city as city2_0_0_,
addresshis0_.street as street3_0_0_,
addresshis0_.zipcode as zipcode4_0_0_
from
ADDRESS addresshis0_
where
addresshis0_.MEMBER_ID=?
따라서 컬렉션은 지연로딩 전략을 사용한다.
조회한 findMember의 address에 city를 수정해보자. 어떻게 하면 될까
findMember.getHomeAddress().setCity("newCity");
와 같이 하면 된다고 생각할 수 있다. 하지만 앞에서도 이야기 했듯이 임베디드 타입은 불변해야하므로 setter를 없애거나, private으로 제약을 걸어야한다. 따라서 위의 코드는 사용불가하다.
Address a = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", a.getStreet(), a.getZipcode());
위와 같이 새로운 인스턴스를 만들어서 넣어야한다.
그럼 컬렉션은 어떻게 업데이트하는지 알아보자.
//치킨 -. 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
위의 코드와 같이 변경하려는 항목을 삭제 후 추가해야한다. 그럼 아래와 같이 delete 쿼리한번 insert 쿼리한번으로 수정이 이루어지는 것을 볼 수 있다.
Hibernate:
/* delete collection row hellojpa.Member.favoriteFoods */ delete
from
FAVORITE_FOOD
where
MEMBER_ID=?
and FOOD_NAME=?
Hibernate:
/* insert collection
row hellojpa.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
그럼 임베디드 타입으로 된 address를 업데이트 해보자.
findMember.getAddressHistory().remove(new Address("old1", "street","10000"));
findMember.getAddressHistory().add(new Address("newCity1","street","10000"));
컬렉션은 remove할 대상을 찾을 때 기본적으로 equals를 사용한다. 이때 equals는 기본적으로 == 비교를 하기 때문에 address에 equals를 오버라이드해야한다. equals가 구현되어있지 않으면 위의 코드 처럼 remove에 똑같은 값을 가진 인스턴스를 넣어도, 다른 인스턴스이기 때문에 remove하지 못한다.
remove한 다음엔 새로운 인스턴스를 생성해 add해주면 된다.
그런데 위의 코드를 실행했을 경우 쿼리를 한번 살펴보자.
Hibernate:
/* delete collection hellojpa.Member.addressHistory */ delete
from
ADDRESS
where
MEMBER_ID=?
Hibernate:
/* insert collection
row hellojpa.Member.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row hellojpa.Member.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
address 테이블을 모두 삭제 하고 insert 쿼리가 두번 나간다. 우리는 분명히 add를 한번하고 특정 주소를 remove했는데 결과는 우리가 의도한대로 되었으나, 과정이 우리가 의도한 바와는 다르게 진행된것이다.
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressEntity = new ArrayList<>();
Member에선 AddressEntity로 리스트를 만들고 OneToMany로 매핑한다.
@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address;
}
그리고 AddressEntity를 만들어 id를 만들고 address를 한번 감싸면 된다.
member.getAddressHistory().add(new AddressEntity("old1", "street", "10000"));
member.getAddressHistory().add(new AddressEntity("old2", "street", "10000"));
와 같이 사용하자. 이렇게 되면 식별자가 있기 때문에 찾아서 수정이 가능하다.
값 타입 컬렉션은 언제 사용하는가 ? 진짜 단순할 때. select 박스에 치킨과 피자를 멀티로 선택할 수 있을 때, 이렇게 추정할 필요도 없고 값이 바뀌어도 update할 필요가 없을 때 사용한다.
값 타입은 정말 값 타입이라 판단될 때만 사용
엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안됨
식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아닌 엔티티