교재: 자바 ORM 표준 JPA 프로그래밍
9장에서 다룰 내용:
기본값 타입은 자바에서 제공하는 기본타입과 유사하다.
예를 들어 int, double, String, Integer처럼 단순한 값을 저장하는 타입을 말한다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
// 근무 기간
@Embedded
private Period workPeriod;
// 집 주소
@Embedded
private Address homeAddress;
}
@Embeddable
public class Period {
private LocalDate startDate;
private LocalDate endDate;
// 의미 있는 메서드도 추가 가능
public boolean isWork() {
LocalDate now = LocalDate.now();
return !now.isBefore(startDate) && !now.isAfter(endDate);
}
}
@Embeddable
public class Address {
@Column(name = "city")
private String city;
private String street;
private String zipcode;
}
임베디드 타입을 사용하면 DB 테이블 구조는 그대로이고, 객체 모델만 더 명확해진다. isWork() 같은 유의미한 메서드를 값 타입 안에 정의할 수 있어 응집도가 높아진다.
임베디드 타입을 사용해도 테이블은 하나다. 임베디드 타입 필드들이 Member 테이블 컬럼으로 그냥 펼쳐진다.
CREATE TABLE MEMBER (
ID BIGINT NOT NULL,
NAME VARCHAR(255),
START_DATE DATE,
END_DATE DATE,
CITY VARCHAR(255),
STREET VARCHAR(255),
ZIPCODE VARCHAR(255),
PRIMARY KEY (ID)
);
임베디드 타입 안에 또 다른 임베디드 타입을 넣을 수 있다.
@Embeddable
public class Address {
private String city;
private String street;
@Embedded
private Zipcode zipcode; // 중첩 임베디드
}
@Embeddable
public class Zipcode {
private String zip;
private String plusFour;
}
하나의 엔티티에 같은 임베디드 타입을 두 번 사용하면 컬럼명이 충돌한다. 이때 @AttributeOverride로 컬럼명을 재정의한다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Embedded
private Address homeAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "COMPANY_CITY")),
@AttributeOverride(name = "street", column = @Column(name = "COMPANY_STREET")),
@AttributeOverride(name = "zipcode", column = @Column(name = "COMPANY_ZIPCODE"))
})
private Address companyAddress;
}
임베디드 타입을 null로 설정하면 매핑된 컬럼들도 모두 null이 된다.
member.setAddress(null);
em.persist(member); // city, street, zipcode 모두 null로 저장
값 타입은 공유하면 위험하다.
// 위험한 코드!
Address address = new Address("서울", "강남대로", "12345");
member1.setHomeAddress(address);
member2.setHomeAddress(address); // 같은 인스턴스 공유
member1.getHomeAddress().setCity("부산"); // member2도 부산이 되어버린다!
→ 해결책: 값 타입을 불변 객체로 만든다. setter를 제거하고 생성자로만 초기화한다.
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
// 기본 생성자 (JPA 필수)
protected Address() {}
// 값 변경은 새 객체 생성으로만 가능
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
// getter만 제공, setter 없음
}
값을 변경하고 싶으면 새 객체를 만들어서 교체하는 방식을 사용한다.
값 타입은 ==가 아니라 equals()로 비교해야 한다.
Address a1 = new Address("서울", "강남대로", "12345");
Address a2 = new Address("서울", "강남대로", "12345");
System.out.println(a1 == a2); // false (다른 인스턴스)
System.out.println(a1.equals(a2)); // true (내용이 같으면)
→ 임베디드 타입에는 반드시 equals()와 hashCode()를 재정의해야 한다.
@Embeddable
public class Address {
...
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Address)) 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);
}
}
값 타입을 컬렉션으로 가지고 싶으면 @ElementCollection과 @CollectionTable을 사용한다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Embedded
private Address homeAddress;
@ElementCollection
@CollectionTable(
name = "FAVORITE_FOODS",
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<>();
}
→ 값 타입 컬렉션은 별도의 테이블에 저장된다. 기본 페치 전략은 LAZY다.
-- FAVORITE_FOODS 테이블
CREATE TABLE FAVORITE_FOODS (
MEMBER_ID BIGINT NOT NULL,
FOOD_NAME VARCHAR(255)
);
-- ADDRESS 테이블
CREATE TABLE ADDRESS (
MEMBER_ID BIGINT NOT NULL,
CITY VARCHAR(255),
STREET VARCHAR(255),
ZIPCODE VARCHAR(255)
);
Member member = em.find(Member.class, 1L);
// 음식 수정: 치킨 → 한식
member.getFavoriteFoods().remove("치킨");
member.getFavoriteFoods().add("한식");
// 주소 수정: 기존 주소 삭제 후 새 주소 추가
member.getAddressHistory().remove(new Address("서울", "기존로", "11111"));
member.getAddressHistory().add(new Address("서울", "새로운로", "22222"));
값 타입 컬렉션은 값이 변경되면 해당 컬렉션 테이블의 데이터를 전부 삭제하고 다시 저장한다. 따라서 데이터가 많다면 성능 문제가 생길 수 있다.
값 타입 컬렉션은 편리하지만 제약이 있다.
실무에서의 대안: 값 타입 컬렉션 대신 일대다 관계 + 별도 엔티티를 사용한다.
@Entity
public class AddressEntity {
@Id @GeneratedValue
private Long id;
@Embedded
private Address address;
}
// Member에서는
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
→ 이렇게 하면 식별자가 생겨서 변경 추적이 가능하고, 성능도 안정적이다.