1. 기본값 타입
1) JPA의 데이터 타입 분류
> 엔티티 타입
- @Entity로 정의하는 객체
- 데이터가 변해도 식별자로 지속해서 추적 가능하다.
ex) 회원 엔티티의 키, 나이의 값을 변경해도 식별자로 인식 가능하여 추적이 가능하다.
> 값 타입
- int, Integer, String처럼 단순히 값으로 사용하는 Java 기본 타입이나 객체
- 식별자가 없고 값만 있으므로 변경시 추적이 불가능하다.
ex) 숫자 100을 500으로 변경하면 완전히 다른 값으로 대체
2) 값 타입 분류
- 기본값 타입
- 자바 기본 타입(int, double)
- 래퍼 클래스(Integer, Long)
- String
- 임베디드 타입(embedded type, 복합 값 타입)
- JPA에서 정의를 해서 사용해야 한다. (ex. X, Y에 대한 좌표가 있으면 Position이라는 클래스를 만들어서 값처럼 사용)
- 컬렉션 값 타입(collection value type)
- 자바 컬렉션에 기본 값이나 임베디드 타입의 값을 넣어서 사용하는 것
3) 기본값 타입 설명
- String name, int age와 같은 것이 기본 값타입이다.
- 생명주기를 엔티티의 의존한다.
ex) 회원을 삭제하면 이름, 나이 필드에 있는 값들도 함께 삭제가 됨
- 값 타입은 공유하면 안된다.
ex) 회원 이름 변경시 다른 회원의 이름도 함께 변경되면 안됨(부수 효과가 일어나면 안된다)
* 참고: 자바의 기본 타입은 절대 공유되지 않는다.
int, double 같은 기본 타입(primitive type)은 절대 공유되지 않는다.
기본 타입은 항상 값을 복사하기 때문이다.(ex. int a = 10, int b = 20, a = 20 ---> 결과는 a = 20, b = 20)
Integer 같은 래퍼 클래스나 String 같은 특수한 클래스는 공유 가능한 객체(참조값을 공유함)이지만 변경 자체를 불가능하게 만들어 사이드 이펙트가 일어나지 않게 해야한다.
2. 임베디드 타입(복합 값 타입)**
임베디드 타입이란?? JPA에서는 새로운 값 타입을 직접 정의할 수 있다. 이걸 임베디드 타입(embedded type)이라 한다.
주로 기본 값 타입을 모아서 만들어서 복합 값 타입이라고도 한다. int, String과 같은 '값 타입'이다.
예를 들어 회원 엔티티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 가진다고하자
회원 엔티티는 이름, 근무 기간, 집 주소를 가진다라고 추상화하여 말할 수 있다.
위의 그림에서 Period에 있는 필드와 Address에 있는 필드는 Member 엔티티에 있었던 컬럼이었다.
해당 부분은 Period와 Address 클래스로 만들고 임베디드 타입으로 설정한 것이다.
임베디드 타입 사용법
@Embeddable : 값 타입을 정의하는 곳에 표시
@Embedded : 값 타입을 사용하는 곳에 표시
기본 생성자는 필수이다.
(위의 설정 정의를 하는 곳 또는 사용하는 곳 1곳에만 표시를 해도 되는데 양쪽에 다 설정하는 것이 보기 좋다!)
예시 코드)
Member 대신 Muser라는 이름의 클래스로 사용하였다.
임베디드 타입으로 만든 Period와 Address에는 @Embeddable 어노테이션을 붙여주었고, 해당 임베디드 타입을 사용하는 Muser에는 해당 필드를 선언하고 @Embedded 어노테이션을 붙여주었다.
@Entity
public class Muser {
@Id
@GeneratedValue
@Column(name = "M_ID")
private Long id;
@Column(name = "USERNAME")
private String name;
// Period
@Embedded // 한쪽만 설정해줘도 되는데 둘다 넣는 것을 권장!
private Period workPeriod;
// Address
@Embedded
private Address homeAddress;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Period getWorkPeriod() {
return workPeriod;
}
public void setWorkPeriod(Period workPeriod) {
this.workPeriod = workPeriod;
}
public Address getHomeAddress() {
return homeAddress;
}
public void setHomeAddress(Address homeAddress) {
this.homeAddress = homeAddress;
}
}
@Embeddable
public class Address {
private String city;
private String street;
@Column(name = "ZIPCODE")
private String zipcode;
public Address() {}
// 아래와 같이 생성자를 만들면 위에 기본 생성자를 추가해줘야한다.
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public String getStreet() {
return street;
}
public void setStreet(String street) {
this.street = street;
}
public String getZipcode() {
return zipcode;
}
public void setZipcode(String zipcode) {
this.zipcode = zipcode;
}
}
@Embeddable
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
public LocalDateTime getStartDate() {
return startDate;
}
public void setStartDate(LocalDateTime startDate) {
this.startDate = startDate;
}
public LocalDateTime getEndDate() {
return endDate;
}
public void setEndDate(LocalDateTime endDate) {
this.endDate = endDate;
}
}
임베디드 타입 장점
- 재사용이 가능하며, 높은 응집도를 가진다.
- Period.isWork()처럼 해당 값 타입에서만 사용하는 의미있는 메서드를 만들 수 있다.
- 임베디드 타입을 포함한 모든 값 타입은 값 타입을 소유한 엔티티에 생명주기를 의존한다.
임베디드 타입과 테이블 매핑
임베디드 타입은 엔티티의 값일 뿐이다.
임베디드 타입을 사용하기 전과 후에는 매핑하는 테이블은 같기 때문에 컬럼에는 변화가 없다.
객체와 테이블을 아주 세밀하게 매핑하는 것이 가능하며, 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.
임베디드 타입과 연관관계
임베디드 타입은 임베디드 타입을 가질 수 있고, 엔티티도 가질 수 있다.
@AttributeOverride: 속성 재정의
만약 Muser 엔티티에서 Address 임베디드 타입을 2번이나 사용하고 싶다면 어떻게 풀어야할까??
컬럼명이 중복되기 때문에 그냥 사용할 수 없고, @AttributeOverrides, @AttributeOverride를 사용해서 컬럼명 속성을 재정의해주어야 한다.
아래 예시 코드를 보면 금방 확인할 수 있다.
임베디드 타입의 값이 null이면 매핑한 컬럼 값은 모두 null로 들어간다.
3. 값 타입과 불변 객체
값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다.
따라서 값 타입은 단순하고 안전하게 다룰 수 있어야 한다.
값 타입 공유 참조
임베디드 타입 같은 값 타입을 여러 엔티티에서 공유할 수 있지만 공유하면 위험하다. Side Effect가 발생한다.
예를들어 회원1과 회원2가 같은 값 타입의 주소를 바라보는 경우 주소 데이터를 다른 데이터를 바꾸면 회원1과 회원2가 바라보는 주소 값이 똑같이 바뀌게 된다.
아래 DB를 살펴보면 똑같은 주소를 가지고 있는 것을 확인할 수 있다.
아래와 같이 똑같은 임베디드 타입 값을 가지고 주소를 추가한 뒤 muser1의 주소를 가져와서 city 값을 수정하게 되면
UPDATE 쿼리를 2번 날리는 것을 확인할 수 있고
DB에 저장된 값도 2개가 바뀐 것을 확인할 수 있다.
값 타입 복사
값 타입의 실제 인스턴스인 값을 공유하는 것은 위험하기 때문에 값(인스턴스)을 복사해서 사용해야 한다.
아래 예시 코드 처럼 Address를 새롭게 만들어서 기존의 address에서 get해서 값을 가져와서 복사 후 muser2의 address에 set해주어야 한다.
객체 타입의 한계
항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다.
문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다.
자바 기본 타입에 값을 대입하면 값을 복사하지만, 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없다.
객체의 공유 참조는 피할 수 없다.
// 기본 타입(primitive type)
int a = 10;
int b = a; //기본 타입은 값을 복사
b = 4;
// 객체 타입
Address a = new Address("Old");
Address b = a; //객체 타입은 참조를 전달
b.setCity("New");
불변 객체
위와 같이 객체 타입의 한계를 극복하기 위해서! 객체 타입을 수정할 수 없게 만들면 부작용을 원천 차단할 수 있다.
값 타입은 불변 객체(immutable object)로 설계해야한다.
불변 객체란? 생성 시점 이후 절대 값을 변경할 수 없는 객체
생성자로만 값을 설정하고 수정자(Setter)를 만들지 않으면 된다.
(참고: Integer, String은 자바가 제공하는 대표적인 불변 객체)
불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막을 수 있다!
만약 불변 객체로 만들고 나서 값을 바꾸고 싶으면 여러가지 방법이 있겠지만 아래와 같이 적용해 볼 수 있다.
4. 값 타입의 비교
값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야한다.
기본 타입은 값이 같으면 true로 나오는데
int a = 10;
int b = 10;
a == b // true
참조 값을 비교하는 경우에는 false가 나온다.
Address a = new Address("수원시");
Address b = new Address("수원시");
a == b // false
그래서 비교를 할 때는 동일성과 동등성에 대해서 알고 사용해야한다.
- 동일성(identity)비교 : 인스턴스의 참조 값을 비교, == 사용
- 동등성(equivalence) 비교 : 인스턴스의 값을 비교, equals() 사용
값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야한다.
값 타입의 equals() 메소드를 적절하게 재정의해야한다. 주로 모든 필드를 재정의 해야한다.
equals를 재정의하지 않으면 기본은 == 비교를 하고 있다.
equals를 재정의 할 때는 자동으로 생성해주는 것을 사용하자!
자동으로 생성할 때 아래와 같이 'Use getters during code generation' 옵션을 선택해 주는 것이 좋다.
해당 옵션을 선택하면 getter를 가지고 호출한다.(선택하지 않으면 필드에 직접 접근)
필드에 직접 접근할 때 문제되는 부분은 프록시일 때는 계산이 안된다.
getter로 호출을 해야 프록시일 때도 진짜에게 접근하여 계산이 가능하다.
5. 값 타입 컬렉션**
값 타입에 컬렉션을 담아서 사용하는 것을 말한다.
위와 같이 값 타입을 컬렉션에 담는 예제를 구현할것인데 이걸 테이블로 구현할 때 문제가 있다.
컬렉션이 DB에 들어가야 하는데 기본적으로 관계형 데이터베이스는 컬렉션을 테이블 안에 넣을 수 있는 구조가 없다.
그래서 favoriteFood와 addressHistory는 별도의 테이블로 뽑아야 한다(1:N)
그럼 코드로 어떻게 구현을 하는지 살펴보자
먼저 임베디드 타입의 Address는 기존에 사용하던걸 그대로 사용한다.
@Embeddable
public class Address {
private String city;
private String street;
@Column(name = "ZIPCODE")
private String zipcode;
public Address() {}
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
public String getCity() {
return city;
}
public String getStreet() {
return street;
}
public String getZipcode() {
return 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);
}
}
Member는 새롭게 만들기 위해서 이름을 Member2라고 새로운 엔티티를 만들었다.
컬렉션을 사용하기 위해서는 @ElementCollection 어노테이션을 붙여주면 된다.
그리고 해당 컬렉션을 테이블로 뽑아주기 위해서 @CollectionTable 어노테이션을 사용하여 테이블 이름과 외래키로 잡을 컬럼을 @JoinColum을 이용해서 설정해 주면된다.
@Entity
public class Member2 {
@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")) // joinColumns = @JoinColumn(name = "MEMBER_ID") 이걸로 FK를 잡게됨
@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<>();
}
그리고 실행을 시키면 아래와 같이 테이블을 만드는 쿼리문을 날리는 것을 확인할 수 있다.
값 타입 컬렉션 1차 정리
- 값 타입을 하나 이상 저장할 때 사용한다.
- 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다.
- 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
값 타입 컬렉션 사용(저장, 조회, 수정)
public class DataTypeCollectionMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Member2 member = new Member2();
member.setUsername("member1");
member.setHomeAddress(new Address("수원시", "길거리", "111222"));
member.getFavoriteFoods().add("우동");
member.getFavoriteFoods().add("국밥");
member.getFavoriteFoods().add("커피");
member.getAddressHistory().add(new Address("서울시", "길거리", "333222"));
member.getAddressHistory().add(new Address("제주시", "길거리", "556677"));
em.persist(member);
tx.commit();
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
em.close();
}
emf.close();
}
}
member만 persist를 했는데 컬렉션이 다른 테이블인데도 불구하고 라이프 사이클이 같이 돌아갔다.
왜냐!! 이것은 값 타입 컬렉션이고, 값 타입 컬렌션 생명주기가 없고 Member에 소속되어 있기 때문이다.
member를 DB에 저장시키고 flush(), clear()를 한 뒤에 find를 해서 데이터를 가져와보면 컬렉션은 지연 로딩 전략을 사용하는 것을 확인할 수 있다. 값 타입 컬렉션은 영속성 전이(Cascade) + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
public class DataTypeCollectionMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Member2 member = new Member2();
member.setUsername("member1");
member.setHomeAddress(new Address("수원시", "길거리", "111222"));
member.getFavoriteFoods().add("우동");
member.getFavoriteFoods().add("국밥");
member.getFavoriteFoods().add("커피");
member.getAddressHistory().add(new Address("서울시", "길거리", "333222"));
member.getAddressHistory().add(new Address("제주시", "길거리", "556677"));
em.persist(member);
em.flush();
em.clear();
System.out.println("========================");
Member2 findMember = em.find(Member2.class, member.getId()); // 컬렉션은 지연로딩
for (Address address : findMember.getAddressHistory()) {
System.out.println("address = " + address.getCity());
}
for (String favoriteFood : findMember.getFavoriteFoods()) {
System.out.println("favoriteFood = " + favoriteFood);
}
tx.commit();
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
em.close();
}
emf.close();
}
}
컬렉션 값 타입의 수정은 어떻게 할까?
위의 결과를 보면 ADDRESS 테이블에 있는 데이터를 다 삭제하고 INSERT 쿼리 문을 2번 날리는 것을 볼 수 있다.
의도한 대로 결과는 가졌지만 동작하는게 뭔가 이상하다. 왜이런걸까??
값 타입은 엔티티와 다르게 식별자 개념(PK)이 없어서 값을 변경하면 추적이 어렵다.
값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다. 이게 기본값이다. 그래서 위와 같이 동작하는 것이였다.
값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야한다. null 입력을 할 수 없고, 중복 저장하면 안된다.
값 타입 컬렉션 대안
실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다.
일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용하는 방식이다.
영속성 전이(Casecade) + 고아 객체 제거를 사용해서 값 타입 컬렉션 처럼 사용할 수 있다.
즉, 엔티티로 래핑해서 값 타입을 엔티티로 승급해서 사용하는 것이다.
코드로 살펴보면 아래와 같이 AddressEntity를 하나 만들어서 값 타입을 넣고 래핑을 해준다.
@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
@Id
@GeneratedValue
private Long id;
private Address address; // 값 타입
public AddressEntity(String city, String street, String zipcode) {
this.address = new Address(city, street, zipcode);
}
public AddressEntity() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Address getAddress() {
return address;
}
public void setAddress(Address address) {
this.address = address;
}
}
그리고 Member 엔티티에서는 List<Address> 대신 List<AddressEntity>로 변경한 뒤 1:N 연관관계를 갖도록 수정해준다.
@Entity
public class Member2 {
@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> addressHistory = new ArrayList<>();*/
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID") // 일대다 단방향 매핑
private List<AddressEntity> addressHistory = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Address getHomeAddress() {
return homeAddress;
}
public void setHomeAddress(Address homeAddress) {
this.homeAddress = homeAddress;
}
public Set<String> getFavoriteFoods() {
return favoriteFoods;
}
public void setFavoriteFoods(Set<String> favoriteFoods) {
this.favoriteFoods = favoriteFoods;
}
public List<AddressEntity> getAddressHistory() {
return addressHistory;
}
public void setAddressHistory(List<AddressEntity> addressHistory) {
this.addressHistory = addressHistory;
}
}
그리고 실행을 해보면 아래와 같이 잘 동작하는 것을 확인할 수 있다.
값 타입 정리
- 엔티티 타입의 특징
- 식별자가 존재한다.
- 생명 주기 관리된다.
- 공유할 수 있다.
- 값 타입의 특징
- 식별자 존재하지 않는다.
- 생명 주기를 엔티티에 의존한다.
- 공유하지 않는 것이 안전하다.(복사해서 사용)
- 공유를 해야한다면 불변 객체로 만드는 것이 안전하다
값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다.(정말 단순한 것은 값 타입으로 사용~)
엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다.
식별자가 필요하고, 지속해서 값을 추적하고 변경해야 한다면 그것은 값 타입이 아닌 엔티티이다.
'TIL > JPA' 카테고리의 다른 글
[TIL/JPA] 기본개념 : JPQL(Java Persistence Query Language) (0) | 2024.09.09 |
---|---|
[TIL/JPA] 기본개념 : JPA의 다양한 쿼리 방법 소개 (0) | 2024.09.04 |
[TIL/JPA] 기본개념 : 영속성전이(CASCADE), 고아 객체 (0) | 2024.08.27 |
[TIL/JPA] 기본개념 : 프록시와 연관관계 관리[즉시(EAGER)로딩, 지연(LAZY)로딩] (0) | 2024.08.27 |
[TIL/JPA] 기본개념 : 고급 매핑(상속관계 매핑, @MappedSuperclass) (0) | 2024.08.24 |