값타입은 기본값타입, 객체타입, 임베디드타입, 컬렉션값타입이 있다.

다음과 같이 있을 때 OldCity를 NewCity로 변경하면 회원1과 회원2 모두 NewCity로 값이 바뀌게 된다.
Address address = new Address("city", "address", "10000");
Member member1 = new Member();
member1.setName("member1");
member1.setHomeAddress(address);
em.persist(member1);
Member member2 = new Member();
member2.setName("member2");
member2.setHomeAddress(address);
em.persist(member2);
위 코드는 member1이나 member2나 컬럼에 모두 같은 값이 들어간다.
어느날 member1만 컬럼 값을 수정하고 싶어서
member1.getHomeAddress().setCity("newCity");
위 코드를 실행해서 member1의 값만 바뀌는 것을 원했지만, member2도 똑같이 newCity라는 값으로 업데이트 되었다.
이럴 때는 어떻게 해결해야 할까?
값 타입의 실제 인스턴스인 값을 공유하는 것은 위험하기 때문에 값(인스턴스)를 복사해서 사용하자.

Address address = new Address("city", "address", "10000");
Member member1 = new Member();
member1.setName("member1");
member1.setHomeAddress(address);
em.persist(member1);
Address copyAddress = new Address(address.getCity(), address.getStreet(), address.getZipcode())
Member member2 = new Member();
member2.setName("member2");
member2.setHomeAddress(copyAddress);
em.persist(member2);
member1.getHomeAddress().setCity("newCity");
이렇게 실행하면 member1의 값만 바뀌게 된다.
하지만, 실수로 copyAddress를 사용하지 않고 그대로 address를 사용했을 때 컴파일러 단계에서 오류를 띄워줄 수 있는 방법이 있을까?
-> 없다.
int a = 10;
int b = a; // 기본 타입은 값을 복사
b = 4;
// a = 10, b = 4
Address a = new Address("old");
Address b = a; // 객체 타입은 참조를 전달
b.setCity("new");
// a.city = new, b.city = new
객체 타입을 수정할 수 없게 만들면 부작용을 원천 차단할 수 있다.
기본 타입(int, double..)을 제외한 값 타입은 불변 객체(immutable object)로 설계해야함
참고로 Integer, String은 자바가 제공하는 대표적인 불변 객체다.
String str = "hello"; str = str + " world";
- 이 코드에서 "hello"라는 문자열 객체는 한 번 생성되면 바뀌지 않는다.
- str + " world"를 하면 기존의 문자열을 수정하는 게 아니라, 새로운 "hello world" 문자열 객체를 생성하고, str이 그걸 참조하게 된다.
- str이 참조하는 "hello" 문자열이 + 연산에 사용되고, 결과로 "hello world"가 만들어지고, 그 후에 str이 새 문자열을 참조하게 된다.
- 그래서 기존에 hello를 참조하던 str이 hello world를 참조하게 된다.
Integer a = 10; a = a + 1;
- 위 String과 원리는 동일하다.
불변이라는 제약으로 부작용이라는 큰 재앙을 막을 수 있게 됐다!
생성자를 통해서만 값을 세팅할 수 있기 때문에 통으로 갈아내야 한다.
Address address = new Address("city", "address", "10000");
Member member1 = new Member();
member1.setName("member1");
member1.setHomeAddress(address);
em.persist(member1);
Address newAddress = new Address("newCity", address.getStreet(), address.getZipcode());
member.setHomeAddress(newAddress);
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
private LocalDateTime startDate;
private LocalDateTime endDate;
private String city;
private String street;
private String zipcode;
... getter and setter
}
@Embeddable
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
... getter and setter
}
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
... getter and setter
}
@Entity
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;
... getter and setter
}
Member member = new Member();
member.setUsername("hello");
member.setHomeAddress(new Address("city", "street", "address"));
member.setWorkPeriod(null);
em.persist(member);
- 새로운 값 타입을 정의해서 사용한다. 즉, 임베디드 타입도 값 타입이다.
- int, String과 같은 값 타입을 모아서 만든다.
- @Embeddable : 값 타입을 정의하는곳에 표시
- @Embedded : 값 타입을 사용하는곳에 표시
- 임베디드 타입을 사용하든 안하든, 매핑하는 테이블 구조는 동일하다. 매핑만 잘 해주면 된다.
- 결국, 임베디드 타입은 엔티티의 값일 뿐이다.
- 엔티티 클래스와 마찬가지로, 생성자를 별도로 정의하고 싶으면 반드시 파라미터 없는 기본 생성자도 함께 정의해야 한다. 생성자를 별도로 정의하고 싶지않으면 기본생성자를 정의 안해도된다. 이것은 생성자를 별도로 정의하지않으면 자바 특성상 기본 생성자를 자동으로 생성해준다.
- 임베디드 타입의 값이 null이면(=member.setWorkPeriod(null)) 매핑한 컬럼(startDate, endDate)의 값은 모두 null이 들어간다.
@Entity
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;
=> 컬럼명이 중복되서 오류가 발생한다. 그래서 @AttributeOverrides를 사용해서 컬럼명 속성을 재정의 해야한다.
cf)
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
- @JoinColumn(name = "TEAM_ID")
@ManyToOne과 같은 연관관계 매핑 어노테이션과 사용하는 @JoinColumn
= 매핑할 외래 키 컬럼 이름을 적는다. 테이블에서 외래키로 사용되는 컬럼이름을 적는다. @JoinColumn을 통해 Member엔티티의 team 필드와 MEMBER 테이블의 외래키 컬럼을 매핑한다.
(즉, 객체의 참조와 테이블의 외래키를 매핑한다.)
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();
}
member_id라는 이름으로 외래키 컬럼을 생성한다. (엔티티의 클래스명_엔티티의 기본키필드명)Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "zipcode"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("족발");
member.getAddressHistory().add(new Address("old1", "street", "zipcode"));
member.getAddressHistory().add(new Address("old2", "street", "zipcode"));
em.persist(member); // 값 타입 컬렉션 저장
tx.commit();
- member만 저장해도, FAVORITE_FOOD테이블과 ADDRESS테이블에 데이터가 저장된다.
- 값 타입 컬렉션은 Member에 있는 필드이므로, JPA가 Member를 persist할때 자동으로 함께 저장된다. 값 타입 및 값 타입 컬렉션은 소유 엔티티의 생명주기에 따라 함께 관리된다. 즉, 영속성 전이 + 고아객체 제거 기능을 가진다고 볼 수 있다.
- 그래서 em.persist(member)를 하면 MEMBER테이블에 데이터를 저장하고 FAVORITE_FOOD테이블과 ADDRESS테이블에 데이터가 저장된다.
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "zipcode"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("족발");
member.getAddressHistory().add(new Address("old1", "street", "zipcode"));
member.getAddressHistory().add(new Address("old2", "street", "zipcode"));
em.persist(member); // 값 타입 컬렉션 저장
em.flush();
em.clear();
System.out.println("===============START============");
Member findMember = em.find(Member.class, member.getId());
System.out.println("===============지연로딩============");
List<Address> addressHistory = findMember.getAddressHistory();
for (Address address : addressHistory){
System.out.println("address = " + address.getCity());
} // 값 타입 컬렉션 조회
Set<String> favoriteFoods = findMember.getFavoriteFoods();
for (String favoriteFood : favoriteFoods){
System.out.println("favoriteFood = " + favoriteFood);
}
일반적인 값 타입은 즉시로딩개념이지만, 값 타입 컬렉션은 지연로딩개념이다.
그래서 Member findMember = em.find(Member.class, member.getId());만 했을때는 아래와 같은 쿼리를 발생시킨다.select member0_.MEMBER_ID as member_i1_4_0_, member0_.city as city2_4_0_, member0_.street as street3_4_0_, member0_.zipcode as zipcode4_4_0_, member0_.USERNAME as username5_4_0_ from Member member0_ where member0_.MEMBER_ID=?
- Member의 데이터를 조회해서 Member객체를 생성한다. 값타입컬렉션의 데이터는 조회하지않고 Member 데이터만 조회한다.
- 이때 조회된 Member객체의 값타입컬렉션은 프록시 컬렉션 객체이다. 즉 addressHistory와 favoriteFoods는 프록시 컬렉션 객체를 참조한다.
- 이후에 for (Address address : addressHistory)을 호출하면 쿼리가 실행되고, 프록시 컬렉션 객체가 초기화된다.
===============지연로딩============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=? address = old1 address = old2 select favoritefo0_.MEMBER_ID as member_i1_2_0_, favoritefo0_.FOOD_NAME as food_nam2_2_0_ from FAVORITE_FOOD favoritefo0_ where favoritefo0_.MEMBER_ID=? favoriteFood = 족발 favoriteFood = 치킨 favoriteFood = 피자
findMember.getHomeAddress().setCity("newCity");
// 값 타입을 수정할땐, 이렇게 해도 되긴하지만,
Address a = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", a.getStreet(), a.getZipcode()));
// 이런식으로 해야한다. 새로운 인스턴스를 생성해서 기존 인스턴스를 교체해야한다.
// 값 타입 컬렉션을 수정할땐, 기존 데이터를 삭제하고 새로운 데이터를 추가해야한다.
findMember.getAddressHistory().remove(new Address("old1", "street", "zipcode"));
findMember.getAddressHistory().add(new Address("new1", "street", "zipcode"));
Set<Address>를 사용할 경우, 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); }
참고로 String이나 Integer, Long 등등 은 이미 자바에서 기본적으로 equals()와 hashCode()가 재정의되어있다. 그래서 별도의 equals()/hashCode() 재정의 없이도 Set<String> 이나 List<String>의 add()나 remove()가 정상 동작한다.
참고로, 값타입 컬렉션은 remove()후 add()를 하게되면 JPA가 delete쿼리와 insert쿼리를 실행한다. 하지만 값 타입 컬렉션의 값의 타입에 따라 delete쿼리와 insert쿼리의 동작 방식이 다르다.
1. 값 타입 컬렉션에 들어있는 값의 타입이 기본값 타입
Integer, String같은 기본값 타입은 자바에서 제공하는 대표적인 불변객체이므로 JPA가 변경 여부를 감지하고 삭제된 값에 대해서만 delete쿼리를 실행하고, 추가된 값에 대해서만 insert쿼리를 실행한다.
delete from favorite_foods where member_id = ? and FOOD_NAME=?
insert into FAVORITE_FOOD (MEMBER_ID, FOOD_NAME) values (?, ?)
2. 값타입 컬렉션에 들어있는 값의 타입이 객체타입이나 임베디드타입
먼저, 해당 값 타입 컬렉션에 들어있는 값을 전부 delete하고, 현재 컬렉션에 있는 전체 값들을 각각 insert한다.
delete from address where member_id = ?
insert into address (member_id, city, street, zipcode) values (?, ?, ?, ?)
insert into address (member_id, city, street, zipcode) values (?, ?, ?, ?)
참고로, Member만 영속상태인데 값 타입과 값 타입 컬렉션이 수정되면 쿼리가 발생하고 db에 반영되는 이유가 무엇일까?
=> 값 타입 및 값 타입 컬렉션도 Member 엔티티의 필드이기 때문이다. 따라서 JPA는 Member만 영속 상태여도, 그 필드인 값 타입과 값 타입 컬렉션까지도 함께 관리한다. 그래서 값타입수정 및 값타입컬렉션수정을 통해 참조를 변경해서 이 필드들에 변경이 감지되면, JPA는 DB에 반영하기위해 쿼리를 실행한다.
값 타입 및 값 타입 컬렉션은 소유 엔티티의 생명주기(영속 상태)에 따라 함께 관리된다. 따라서 소유 엔티티(Member)가 영속 상태이고, 값타입수정 및 값타입컬렉션수정을 통해 참조를 변경해서 값 타입 또는 값 타입 컬렉션에 변경이 감지되면, JPA는 DB에 반영하기 위해 쿼리를 실행한다. 즉, JPA는 값 타입 및 값 타입 컬렉션을 포함한 Member 전체를 하나의 단위로 관리한다.
@Entity
@Table(name = "ADDRESS")
public class AddressEntity { // 엔티티
@ID @GeneratedValue
private Long id;
private Address address; // 값 타입
... getter and setter
}
그래서 Member 클래스에서 일대다 연관관계를 사용한다.
@Entity
public class Member{
...
/*@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<>();
...
}
Member member = new Member();
member.setUsername("member1");
member.getAddressHistory().add(new AddressEntity("old1", "street1", "zipcode1"));
member.getAddressHistory().add(new AddressEntity("old2", "street2", "zipcode2"));
member.getAddressHistory().add(new AddressEntity("old3", "street3", "zipcode3"));
em.persist();
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
AddressEntity addressEntity = findMember.getAddressHistory().get(0);
addressEntity.setAddress(new Address("new1", "street1", "zipcode1")); // 값타입 수정(=참조변경)
이렇게 하면 기존 delete로 다 삭제하고 insert를 각각 해주는걸 단순히 update쿼리 하나로 해당 데이터만 수정하게 하기위해 사용한다.
결국 AddressEntity 엔티티 안에 있는 값타입을 수정하는것이기 때문에 값 타입을 수정하는것처럼 addressEntity.setAddress(new Address("new1", "street1", "zipcode1")); 이렇게 해주면된다.
AddressEntity는 엔티티이기때문에 JPA가 변경여부를 감지하긴 하지만, 값 타입은 JPA에서 엔티티가 아니기 때문에 변경 여부가 감지되지 않는다. 그래서 필드 값을 setCity()처럼 직접 바꾸면 JPA는 무슨 값이 바뀌었는지 모른다. JPA는 엔티티가 아닌 객체에 대해 변경여부를 감지하지않기 때문이다.
즉, 엔티티의 값타입컬렉션 필드의 값이 수정(=삭제되고 추가)되면, 참조가 변경되었으므로 JPA가 변경을 감지해서 수정쿼리가 발생하는것이고, 엔티티의 값타입 필드의 값이 수정(=참조변경)되면, 참조가 변경되었으므로 JPA가 변경을 감지해서 수정쿼리가 발생하는것이다.
그리고, addressEntity의 필드(=여기서는 값타입 필드)의 값이 수정되면 Member엔티티와는 상관없이 AddressEntity엔티티의 필드의 값이 수정됬기 때문에 JPA가 변경을 감지해서 수정 쿼리가 발생하는것이다. Member가 엔티티라서 변경여부를 감지하는게 아니라, AddressEntity가 엔티티라서 변경여부를 감지하는것이다.
다대일 양방향 연관관계를 사용하지않고 일대다 단방향 연관관계를 사용한 이유
=> AddressEntity에서 Member에 대해서 조회할 일이 없고 Member에서만 AddressEntity에 대해서 조회할 일이 있으므로
cascade와 orphanRemoval을 사용하는 이유
=> cascade는 특정 엔티티를 저장하거나 삭제할때, 연관된 엔티티(=연관관계 매핑 @OneToMany, @ManyToOne 등을 통해 서로 연결되어 있는 엔티티)에도 같은 작업을 하도록 설정하는 기능이다. 그래서 em.persist(member)를 하면 member.getAddressHistory()의 주소들도 같이 저장된다. 그리고 orphanRemoval을 사용하면 Member의 addressHistory에서 어떤 AddressEntity를 제거하면, 해당 AddressEntity가 db에서 자동으로 삭제된다.
즉, cascade를 사용하지않으면 별도로 주소엔티티를 저장해야된다. orphanRemoval을 사용하지않으면 컬렉션에서만 제거되고, db에는 남아있어서 데이터 불일치가 발생할 수 있다.
참고로 엔티티클래스에 생성자를 정의하고 싶으면 반드시 파라미터 없는 기본 생성자도 함께 정의해야 한다. 이는 JPA 스펙에 의한 필수 조건이다.
생성자를 별도로 정의하고 싶지않으면 기본생성자를 정의 안해도된다. 이것은 생성자를 별도로 정의하지않으면 자바 특성상 기본 생성자를 자동으로 생성해준다.