이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.
JPA에서 값 타입(Value Type)은 엔티티(Entity)와는 다른 개념으로, 데이터베이스의 테이블에 독립적으로 저장되지 않으며 엔티티에 포함되는 속성을 말한다.
JPA 값 타입은 크게 세 가지로 구분된다
- 기본 값 타입
-자바 기본 타입(int, double)
-래퍼 클래스(Integer, Long)
-String - 임베디드 값 타입(embedded type, 복합 값 타입)
- 컬렉션 값 타입(collection value type)
이를 통해 객체지향적으로 데이터를 더 효율적으로 관리할 수 있다.
엔티티 타입과 값 타입의 구분
엔티티 타입 | 값 타입 |
@Entity로 정의하는 객체 | int, Integer, String처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체 |
데이터가 변해도 식별자로 지속해서 추적 가능 | 식별자가 없고 값만 있으므로 변경시 추적 불가 |
예)회원 엔티티의 키나 나이 값을 변경해도식별자로 인식가능 | 예) 숫자 100을 200으로 변경하면 완전히 다른 값으로 대체 |
기본값 타입
기본 값 타입들은 생명주기가 엔티티에 의존되어 있다.
예를 들어 Student라는 엔티티에 있는 int age, String name은 student1객체가 삭제 되면 같이 삭제 된다.
자바의 기본 타입은 절대 공유되지 않는다. int, double과 같은 기본 타입(primitive type)은 항상 값을 복사하기 때문에 절대 공유해서는 안되고, Integer같은 래퍼 클래스나 String 같은 특수한 클래스는 공유는 가능한 객체이지만 변경 할 수 없다.
임베디드 타입(embedded type, 복합 값 타입)
주로 기본 값 타입을 모아서 만들기 때문에 복합 값 타입이라고도 한다.
복합 값타입으로 새로운 값타입을 직접 정의할 수 있다.
예를들어 회원 정보에서 비슷한 정보끼리 묶어 관리하고 싶다면 그런 묶음을 임베디드 타입으로 만들어 주고 사용하면 된다.
위와 같이 회원 정보 중 startDate와 endDate를 묶어 Period로 city, street, zipcode를 묶어 Address로 관리하려면, 아래와 같이 Address와 Period 클래스에(값 타입을 정의하는 곳에) @Embeddable을 붙여주고, 값 타입을 사용하는 곳이 Member에는 @Embedded를 붙여주면 된다.
이 때 임베디드타입들은 기본생성자가 필수적으로 있어야한다.
@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() { // 기본생성자 필수
}
}
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
//period
@Embedded
private Period period;
//address
@Embedded
private Address homeAddress;
}
이런 임베디드 타입의 장점은 재사용, 높은 응집도, 그리고 Period.isWork()처럼 임베디드 타입에 사용할 특정 메소드를 따로 관리할 수 있다는 점이다.
임베디드 타입으로 빼서 관리를 해도 결국 Member 테이블에 변화는 없다.
임베디드 타입으로 만들어준 Period와 Address가 모두 Member테이블에 들어가 있다.
임베디드 타입은 엔티티의 값일 뿐이고, 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다. 객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능하다.
값 타입과 불변 객체
값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다.
따라서 최대한 단순하고 안전하게 다룰 수 있어야 한다. 우선 값 타입들은 서로 공유를 하면 안된다.
다른 회원의 나이가 변경되었다고 다른 회원의 이름도 변경되면 안되는 것처럼 기본값 타입은 애초에 값을 복사하기 때문에 공유를 할 수 없고, 래퍼 클래스나 String은 참조값을 복사하기 때문에 공유가 가능하지만 수정이 불가능하기 때문에 괜찮다.
하지만 임베디드 타입은 직접 정의한 객체 타입이기 때문에 기본값 타입과는 다르게 공유가 가능하고 수정 또한 가능해서 유의해야한다.
위 그림과 같이 회원1과 회원2의 주소가 같아서, 하나의 Address객체를 만든 뒤 같이 넣어주면 값을 공유하게 된다.
// 위 그림에 대한 코드
Address address = new Address("city","street","zipcode");
Member member1 = new Member();
member1.setUsername("A");
member1.setAddress(address);
Member member2 = new Member();
member2.setUsername("B");
member2.setAddress(member1.getAddress());
이렇게되면 회원1이나 2가 이후 주소를 변경해도 함께 변경되기 때문에 문제가 생길 수 있다.
그래서 값 타입의 실제 인스턴스인 값을 공유하는 방식 대신, 값(인스턴스)를 복사해서 사용한다.
더 정확히 말하면 실제 인스턴스 값을 공유하는 것이 아니라 새로운 인스턴스를 만들어서 사용해야 한다.
Address copyAddress = member1.getAddress();
member1.setAddress(new Address(
copyAddress.getCity(), copyAddress.getStreet(), copyAddress.getZipCode()
))
다른 해결방법은 이를 불변객체로 만드는 것이다.
불변객체란 생성 시점이후에 변경할 수 없는 객체로 Integer, String 등이 이에 속한다.
불변객체로 만드는 방법은 setter를 정의하지 않거나 private로 정의하면 된다. (생성자로만 모든 값 처리를 하는 방법)
값 타입 비교
동일성 비교
인스턴스의 참조 값을 비교한다. == 을 이용해서 비교하는 방법. 자바의 기본타입은 값이 같으면 같은 공간을 쓰기 때문에 아래와 같은 경우 최종적으로 b와 c는 같은 값, 같은 주소를 갖게 된다.
int a = 10;
int b = a; // 기본타입으로, 공유 x
int c = 10;
a = 20;
System.out.println("a = " + a); // a = 20
System.out.println("b = " + b); // b = 10
System.out.println("c = " + c); // c = 10
System.out.println("b == c : " + (b == c)); // b == c : true
System.out.println("b = " + System.identityHashCode(b)); // b = 157627094
System.out.println("c = " + System.identityHashCode(c)); // c = 157627094
동등성 비교
equals() 사용해서 인스턴스의 값을 비교한다.
자바의 기본타입을 제외하고는 동등성 비교를 해줘야 값만 비교가 가능하다.
따라서 값 타입은 자바의 기본타입을 제외하고는 모두 equals연산을 사용해야하며, 오브젝트 타입 같이 따로 정의해준 타입은 equals를 따로 정의해줘야한다.
Address같은 경우 이렇게 equals와 hashCode를 오버라이딩해서 각각의 요소마다 비교를 할 수 있도록 해야한다.
@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);
}
equals와 hashCode 오버라이딩에 대한 자세한 내용은 아래의 포스팅 참고
2023.01.12 - [Java Category/Java] - [JAVA] Object 클래스(euqals(), hashCode(), toString())
컬렉션 값 타입 (collection value type)
값 타입을 여러 개 저장해야할 때 사용한다. (ex. Member마다 여러 Address를 가지는 경우)
만약 Member 마다 여러개의 선호 음식을 가진다면 다음과 같이 만들어 줄 수 있다.
@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();
컬렉션 값 타입 동작 방식
joinColumns에는 FK로 쓸 값을 넣어주고 식별자는 모든 컬럼의 PK의 조합이 된다.
컬렉션을 위한 별도의 테이블이 만들어진다.
지연 로딩을 사용해서 Member를 가져올 때 FAVORITE FOOD는 프록시 객체로 들어오고 실제로 쓰일 때 쿼리가 날아간다.
영속성 전이(Cascade)와 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
값 타입 컬렉션에는 제약사항
값을 수정할 시 추적이 어렵다. 값 타입 컬렉션에 변경 사항이 발생하면 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
지금 Favorite food는 자료형이 Set이기 때문에 최적화가 되어 있어서 delete 쿼리 하나와 insert 쿼리 하나가 나가지만 List와 같이 그렇지 않은 다른 자료형이었다면 전부 지워지고 남은 것들이 다시 추가된다.
예를 들어 기존에 3개가 있었고 1개를 지우고 1개를 넣는다면 delete 전체삭제 쿼리 + insert 3개가 나가는 것이다.
이런 상황이 생기는 이유는 값 타입이 엔티티와 다르게 식별자 개념이 없기 때문이다. 식별자가 PK가 조합으로 이뤄져있어서 조회가 번거롭다.
위와 같은 제약사항 때문에 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려해야한다.
일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용해서 영속성 전이(Cascade) + 고아 객체 제거로 값 타입 컬렉션'처럼' 사용하자.
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long ID;
@Column(name = "USERNAME")
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID", insertable = false, updatable = false)
private Team team;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
@Embedded
private Address homeAddress;
@ElementCollection
@CollectionTable(name = "FAVORIT_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();
//getter and setter...
}
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
public Address() {
}
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
//getter and setter...
}
@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address;
public AddressEntity() {
}
public AddressEntity(String city, String street, String zipcode) {
this.address = new Address(city, street, zipcode);
}
//getter and setter...
}
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 AddressEntity("old1", "street", "10000"));
member.getAddressHistory().add(new AddressEntity("old2", "street", "10000"));
em.persist(member);
em.flush();
em.clear();
System.out.println("====================");
em.find(Member.class, member.getID());
식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아닌 엔티티로 구현해야 한다.
'Back-End > JPA' 카테고리의 다른 글
[JPA] JPQL 고급 (0) | 2024.08.03 |
---|---|
[JPA] JPQL 기본 문법 (0) | 2024.07.29 |
[JPA] 즉시 로딩, 지연 로딩, 영속성 전이, 고아 객체 (0) | 2024.07.27 |
[JPA] 프록시(Proxy) (0) | 2024.07.26 |
[JPA] 상속관계 매핑 (0) | 2024.07.25 |