1. 기본값 타입
1) JPA의 타입 분류
JPA에서는 타입을 크게 2가지로 분류하는데
@Entity로 정의 하는 객체인 엔티티 타입
하나는 int, Integer, String 처럼 단순히 값으로 사용하는 자바 기본타입이나 객체인 값 타입이다.
값 타입은 기본값 타입, 임베디드 타입, 컬렉션 값 타입으로 나뉜다.
기본값 타입은 자바 기본타입, 래퍼클래스, String을 포함 하는 값 타입이고
임베디드 타입은 복합 값 타입 이라고 하며, 사용자가 직접 정의하는 값 타입이며
컬렉션 값 타입은 기본값, 임베디드 타입을 컬렉션으로 묶은것이 컬렉션 값 타입이다.
2) 값 타입 분류
1 - 기본값 타입
- 예 : String name, int age
- 생명주기를 엔티티에 의존
- 예 : 회원을 삭제하면 이름, 나이 필드도 제거됨
- 값 타입은 공유하면 x
- 예: 회원 이름 변경 시 다른 회원의 이름도 함께 변경되면 안됨
- 참고 : 자바의 기본 타입은 절대 공유X
- - int double 같은 기본 타입은 절대 공유 X
- - 기본 타입은 항상 값을 복사함
- - Integer 같은 래퍼 클래스나 String 같은 특수한 클래스는 공유 가능하능한 객체이지만 변경 X
int, double같은 기본 타입은 절대 공유되지 않는다. 그렇기에 값타입으로 썼을 때 안전하다.
@Test
void valueTest() {
int a = 10;
int b = a;
a = 20;
System.out.println("a = " + a);
System.out.println("b = " + b);
}
------------------------------------
a = 20
b = 10
a와 b는 저장공간을 따로 가지고 있어서
b의 값은 a가 10일때 초기화되고
a는 20으로 할당되어
a = 20 , b = 10이 된다.
이렇듯 기본형은 값이 공유되지 않는다.
그래서 부수효과가 일어나지 않는다.
래퍼 클래스같은 경우, 참조값을 공유하지만, 변경이 불가능하기때문에 안전하다.
2 - 임베디드 타입
- 새로운 값 타입을 직접 정의할 수 있음
- JPA는 임베디드 타입(embedded type) 이라고 함
- 주로 기본 값 타입을 모아 만들어서 복합 값 타입이라고도 함
- int, String과 같은 값 타입
- @Embeddable : 값 타입을 정의하는 곳에 표시
- @Embedded : 값 타입을 사용하는 곳에 표시
- 기본 생성자 필수
2-1 임베디드 타입의 장점
- 재사용
- 높은 응집도
- Period.isWork() 처럼 해당 값 타입만 사용하는 의미 있는 메서드를 만들 수 있음
- 임베디드 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명주기를 의존함 (값 타입이기 때문에)
임베디드 타입은 객체이기 때문에 데이터 뿐만 아니라 메서드까지 가지고 있기 때문에 거기서 오는 이득이 많다.
임베디드 타입 자체에는 @Embeddable을, 임베디드 타입을 사용할 엔티티의 속성에는 @Embedded를 붙인다.
@Entity
@Getter
@Setter
public class Member {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
@Column(name = "username")
private String username;
@Embedded
private Period workPeriod;
@Embedded
private Address homeAddress;
@Embeddable
@Getter
@Setter
@NoArgsConstructor
public class Address {
private String city;
private String street;
private String zipcode;
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
}
@Embeddable
@Setter
@Getter
@NoArgsConstructor
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
public Period(LocalDateTime startDate, LocalDateTime endDate) {
this.startDate = startDate;
this.endDate = endDate;
}
}
이러한 값 타입은 불변객체여야 하기 때문에 Setter대신 생성자를 통해 새로운 값을 만들어야 한다.
int 같은 기본형 타입은 애초에 참조가 존재하지 않아서 늘 새로운 값이 저장되므로 중간에 어떠한 값이 바뀌더라도 그것을 이용한 다른 값들이 변경되지 않지만,
임베디드 타입은 객체이므로 참조를 통해 값을 가져오기 때문에 중간에 값이 변경되면 그것과 연관되는 다른값들도 부수효과가 일어나기 때문에, 이러한 값타입들은 불변객체여야 한다.
Member member =new Member();
member.setHomeAddress(new Address("city","street","1000"));
member.setWorkPeriod(new Period(LocalDateTime.now(),LocalDateTime.now()));
em.persist(member);
이처럼 따로 테이블이 생성되지 않고 member의 컬럼으로써 동작한다.
Hibernate:
create table member (
end_date datetime(6),
member_id bigint not null,
start_date datetime(6),
team_id bigint,
city varchar(255),
street varchar(255),
username varchar(255),
zipcode varchar(255),
primary key (member_id)
) engine=InnoDB
Hibernate:
/* insert for
com.example.jpa.domain.Member */insert
into
member (city,street,zipcode,username,end_date,start_date,member_id)
values
(?,?,?,?,?,?,?)
2-2 임베디드 타입과 테이블 매핑
- 임베디드 타입은 엔티티의 값일 뿐이다.
- 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다.
- 객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능
- 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많음
2-3 @AttributeOverride
@AttributeOverride는 임베디드타입의 속성을 재정의하며, 엔티티 내에서 같은 임베디드 타입을 쓸 때 컬럼이 중복되지 않게 컬럼명을 변경할 수 있다.
@Entity
@Getter
@Setter
public class Member {
@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_zipcode"))
})
private Address workAddress;
}
이렇게 임베디드 타입인 homeAddress의 컬럼과, workAddress의 컬럼이 구분되어 쿼리가 작성된다.
Hibernate:
/* insert for
com.example.jpa.domain.Member */insert
into
member (city,street,zipc5de,username,work_city,work_street,work_zipcode,end_date,start_date,member_id)
values
(?,?,?,?,?,?,?,?,?,?)
참고 : 임베디드 타입의 값이 null 이면, 매핑한 컬럼 값은 null이 된다.
3) 값 타입과 불변 객체
1 - 값 타입 공유 참조
임베디드 타입은 여러 엔티티에서 동시에 같은 인스턴스를 참조할 수 있다.
Address address = new Address("city", "street", "1000");
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");
member1의 Address의 값을 변경했으나, address라는 참조를 member2도 같이 사용하므로 값이 공유된다.
결론적으로 같은 임베디드 타입 값을 공유하였기 때문에 사이드 이팩트가 난 것이다.
Hibernate:
/* update
for com.example.jpa.domain.Member */update member
set
city=?,
street=?,
zipc5de=?,
username=?,
end_date=?,
start_date=?
where
member_id=?
Hibernate:
/* update
for com.example.jpa.domain.Member */update member
set
city=?,
street=?,
zipc5de=?,
username=?,
end_date=?,
start_date=?
where
member_id=?
그렇기에 임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 굉장히 위험하다.
만약 이렇게 2개가 동시에 수정되는것을 의도하고자 한다면 임베디드 타입이 아닌 엔티티로 승격시켜야 한다.
2 - 값 타입 복사
위처럼 같은 실제 인스턴스를 공유하는것은 위험하기 때문에
같은 값을 넣으려면 새로운 인스턴스를 만들어서 기존의 값을 복사해야 한다.
Address address1 = new Address("city", "street", "1000");
Member member1 =new Member();
member1.setUsername("member1");
member1.setHomeAddress(address1);
em.persist(member1);
Address address2 = new Address(address1.getCity(), address1.getStreet(), address1.getZipcode());
Member member2 =new Member();
member2.setUsername("member2");
member2.setHomeAddress(address2);
em.persist(member2);
member1.getHomeAddress().setCity("newCity");
3 - 객체 타입의 한계
- 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다.
- 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다.
- 자바 기본 타입에 값을 대입하면 값을 복사한다.
- 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없다.
- 객체의 공유 참조는 피할 수 없다.
기본 타입
int a = 10;
int b= a; // 기본 타입은 값을 복사
b = 4;
기본타입은 = 을 하면 값이 복사가 된다.
그래서 b의 값을 4로 변경하여도 a의 값은 유지된다.
객체 타입
Address a = new Address("old");
Address b = a; // 객체 타입은 참조를 전달
b. setCity("New")
여기서는 a,b 모두 같은 인스턴스를 가르키기 때문에 값이 변경되는 순간 a의 값도 변경된다.
4 - 불변 객체
- 객체 타입을 수정할 수 없게 만들면 부작용을 원천 차단
- 값 타입은 불변 객체(immutable object)로 설계해야 함
- 불변 객체 : 생성 시점 이후 절대 값을 변경할 수 없는 객체
- 생성자로만 값을 설정하고 수정자(Setter)를 만들지 않으면 됨
- 참고 : Integer, String은 자바가 제공하는 대표적인 불변 객체
값을 변경하려면
address 자체를 통으로 새로 만들고, homeAddress 자체를 newAddress로 변경한다.
ddress address1 = new Address("city", "street", "1000");
Member member1 =new Member();
member1.setUsername("member1");
member1.setHomeAddress(address1);
em.persist(member1);
Address newAddress = new Address("newCity", address1.getStreet(), address1.getZipcode());
member1.setHomeAddress(newAddress);
4) 값 타입의 비교
값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 한다.
- 동일성(identity) 비교 : 인스턴스의 참조 값을 비교, == 사용
- 동등성(equivalence) 비교 : 인스턴스의 값을 비교, equals() 사용
- 값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야함
- 값 타입의 equals() 메서드를 적절하게 재정의(주로 모든 필드 사용)
address1.equals(address2)는 true가 나올까? false가 나온다.
Address address1 = new Address("city", "street", "1000");
Address address2 = new Address("city", "street", "1000");
System.out.println("(address1 == address2) = " + (address1 == address2));
System.out.println("(address1 eq address2) = " + (address1.equals(address2)));
왜냐하면 equals의 디폴트 옵션이 == 비교이기 때문이다.
(address1 == address2) = false
(address1 eq address2) = false
우리는 지금까지 String을 사용했을 때 equals로 비교하면 비교가 잘 되었는데 어째서 false가 나온것일까?
이유는 String 객체 내부에는 이미 equals 가 오버라이드 되어 있지만, 우리가 커스텀한 임베디드 타입은
오버라이드 되어있지 않기 때문이다.
그래서 String내부의 equals ()를 보면
아래처럼 == 비교로 구성된 것을 알 수 있다.
== 비교는 기본형을 비교 할 때에는 값을 비교하기 때문에 가능하다.
public static boolean equals(byte[] value, byte[] other) {
if (value.length == other.length) {
for (int i = 0; i < value.length; i++) {
if (value[i] != other[i]) {
return false;
}
}
return true;
}
return false;
}
그러므로 Address 임베디드 타입을 비교하고자 한다면 equals를 오버라이딩 해야한다.
또한 hashCode도 같이 오버라이딩을 해줘야 한다.
A와 B의 값이 동등하다면, 이 둘의 해쉬코드도 같다고 봐야 하기 때문이다.
또한, HashMap, HashSet 같은 컬렉션들은 해당 타입의 해쉬코드 값을 기준으로 객체를 저장하기 때문이다.
참고로 A와 B가 서로 상관없는 타입이어도 해쉬코드는 같을 수 있다.
@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);
}
5) 값 타입 컬렉션
- 값 타입을 하나 이상 저장할 때 사용
- @ElementCollection, @CollectionTable 사용
- 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다.
- 컬렉션을 저장하기 위한 별도의 테이블이 필요함
- 참고 : 값 타입 컬렉션은 영속성 전이(CASCADE) + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
관계형데이터베이스는 기본적으로 컬렉션을 담을수 있는 구조가 없다.
그래서 이러한 컬렉션 구조를 표현하려면
개별적으로 테이블을 생성해야 한다
값타입 테이블에는 식별자가 따로 존재하면 엔티티가 되어버리기 때문에 따로 두지 않는다.
@ElementCollection과 @CollectionTable을 이용한 엔티티구현
@Entity
@Getter
@Setter
public class Member {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
@Column(name = "username")
private String username;
@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> addressesHistory = new ArrayList<>();
}
실행시켜보면 다음처럼 쿼리가 나온다
member
Hibernate:
create table member (
member_id bigint not null,
team_id bigint,
city varchar(255),
street varchar(255),
username varchar(255),
zipcode varchar(255),
primary key (member_id)
) engine=InnoDB
favoriteFoods
Hibernate:
create table favorite_food (
member_id bigint not null,
food_name varchar(255)
) engine=InnoDB
address
Hibernate:
create table address (
member_id bigint not null,
city varchar(255),
street varchar(255),
zipcode varchar(255)
) engine=InnoDB
다음과 같이 테스트 코드를 작성한다.
값 타입 저장
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity","street","1000"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");
member.getAddressesHistory().add(new Address("oldCity1","street","1000"));
member.getAddressesHistory().add(new Address("oldCity2","street","1000"));
em.persist(member);
흥미로운 점은 값 타입 컬렉션을 따로 영속하지 않아도 member만 영속하니까 다른 테이블들도 저장이 되었다. 즉 라이프사이클이 다른 테이블임에도 불구하고 member와 같이 돌아가고 있는것이다.
값 타입 컬렉션도 본인 스스로 라이프 사이클이 없다.
값 타입 컬렉션도 결국은 값 타입이기 때문에 라이프 사이클을 엔티티에 의존한다.
또한 이런 값 타입 컬렉션은 기본이 지연로딩이라서 실제 데이터를 요청하기 전 까지는 데이터를 부르지 않는다.
참고로 컬렉션들은 대부분 대상을 찾을 때 equals 를 사용한다. 그래서 완전히 똑같은 equals 대상을 넣어주면 해당 값을 지울 수 있다.
그렇기 때문에 값 타입은 equals와 hashcode를 구현해 놓아야 한다.
값 타입 리스트 수정
Member member = new Member();
member.setUsername("member1");
member.getAddressesHistory().add(new Address("oldCity1","street","1000"));
member.getAddressesHistory().add(new Address("oldCity2","street","1000"));
em.persist(member);
em.flush();
em.clear();
System.out.println("========================================");
Member findMember = em.find(Member.class, member.getId());
findMember.getAddressesHistory().remove(new Address("oldCity1","street", "1000"));
-------------------------------------------------------------------------------------
HashSet(FavoriteFoods) 경우 수정 코드 :
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
쿼리를 보면 해당 멤버에 소속된 addressHistory의 값 전체를 다 지웠다. 그리고 이상하게도 insert문이 2번 나왔다.
Hibernate:
select
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zipcode,
m1_0.username
from
member m1_0
where
m1_0.member_id=?
Hibernate:
select
a1_0.member_id,
a1_0.city,
a1_0.street,
a1_0.zipcode
from
address a1_0
where
a1_0.member_id=?
Hibernate:
/* one-shot delete for com.example.jpa.domain.Member.addressesHistory */delete
from
address
where
member_id=?
Hibernate:
/* insert for
com.example.jpa.domain.Member.addressesHistory */insert
into
address (member_id,city,street,zipcode)
values
(?,?,?,?)
Hibernate:
/* insert for
com.example.jpa.domain.Member.addressesHistory */insert
into
address (member_id,city,street,zipcode)
values
(?,?,?,?)
1 - 값 타입 컬렉션의 제약사항
- 값 타입은 엔티티와 다르게 식별자 개념이 없다.
- 값은 변경하면 추적이 어렵다.
- 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 데이터를 모두 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
- 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야 함: null 입력 x, 중복 저장 x
값 타입 컬렉션에서 수정을 하려면 결국 해당 id에 대한 값을 모두 지우고 다시 리스트에 있는 값을 저장하기 때문에 효율적이지 않다.
결론적으로 정말 단순한 것 외에는 실무에서 그대로 사용하기는 어렵다
2 - 값 타입 컬렉션 대안
- 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대 다 관계를 고려
- 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용
- 영속성 전이(CASCADE) + 고아 객체 제거를 사용해서 값 타입 컬렉션처럼 사용
임베디드 타입 Address를 Entity로 승격
@Entity
@Getter
@Setter
@Table(name = "address")
@NoArgsConstructor
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address;
public AddressEntity(Address address) {
this.address = address;
}
public AddressEntity(String city, String street, String number) {
this.address = new Address(city,street,number);
}
}
addressHistory에 @CollectionTable 대신 @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) 설정
@Entity
@Getter
@Setter
public class Member {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
@Column(name = "username")
private String username;
@Embedded
private Address homeAddress;
@ElementCollection
@CollectionTable(name = "favorite_food", joinColumns =
@JoinColumn(name = "member_id"))
@Column(name = "food_name")
private Set<String> favoriteFoods = new HashSet<>();
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "member_id")
private List<AddressEntity> addressesHistory = new ArrayList<>();
}
'Java > JPA' 카테고리의 다른 글
JPA - 즉시로딩과 지연로딩, CASCADE와 고아객체 (1) | 2023.10.10 |
---|---|
JPA - 프록시 (0) | 2023.10.10 |
JPA - 상속관계 매핑, @MappedSuperclass (0) | 2023.10.04 |
JPA - 연관관계 (0) | 2023.10.01 |
JPA - 기본 키 매핑 : @GeneratedValue (0) | 2023.09.23 |