JPA의 데이터 타입은 크게 엔터티 타입과 값 타입으로 분류할 수 있다. 엔터티 타입은 @Entity 애너테이션으로 정의하는 객체, 값 타입은 int, Integer, String처럼 단순히 값으로 사용하는 Java의 기본 타입이나 객체를 의미한다.
엔터티 타입은 식별자를 통해 지속적으로 추적할 수 있지만 값 타입은 식별자가 없고 숫자나 문자 같은 속성만 있으므로 추적할 수 없다. 예를 들어 회원 엔터티는 일반 속성을 변경하더라도 식별자가 보존되면 같은 회원 엔터티이지만, 숫자 타입 데이터는 값을 변경하면 완전히 다른 데이터로 대체된다.
값 타입은 다음의 세 가지로 분류할 수 있다.
int, double 등)Integer 등)String예제 9.1. 기본 값 타입
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name; // 기본 값 타입
private int age; // 기본 값 타입
// getter, setter ...
}
Member 엔터티는 식별자 값 id가 있고 자신만의 생명주기도 있지만 값 타입인 name, age 속성은 식별자 값도 없고 생명주기도 엔터티에 의존한다. 따라서 회원 엔터티 인스턴스 제거 시 name, age 값도 제거된다.
사용자 정의 값 타입을 JPA에서는 임베디드 타입(embedded type)이라 한다. 임베디드 타입도 int, String처럼 값 타입에 포함된다.
예제 9.2. 기본 회원 엔터티
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
// 근무 기간
@Temporal(TemporalType.DATE) java.util.Date startDate;
@Temporal(TemporalType.DATE) java.util.Date endDate;
// 자택 주소
private String city;
private String street;
private String zipcode;
// getter, setter ...
}
위 엔터티는 다음과 같이 설명될 수 있다.
회원 엔터티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 갖는다.
이는 단순히 정보를 풀어 쓴 것이고 근무 시작일과 우편번호는 아무 관련이 없다. 회원이 상세한 데이터를 그대로 가지고 있는 것은 객체지향적이지 않으며 응집력을 떨어뜨리기 때문에 다음과 같이 근무 기간, 자택 주소를 속성으로 가지도록 임베디드 타입을 사용할 수 있다.
예제 9.3. 값 타입 적용 회원 엔터티
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@Embedded Period workPeriod; // 근무 기간
@Embedded Address homeAddress; // 자택 주소
// getter, setter ...
}
예제 9.4. 근무 기간 임베디드 타입
@Embeddable
public class Period {
@Temporal(TemporalType.DATE) java.util.Date startDate;
@Temporal(TemporalType.DATE) java.util.Date endDate;
// getter, setter ...
// 값 타입을 위한 메서드 정의
public boolean wasWorking(Date date) {
return startDate != null && endDate != null && startDate.before(date) && endDate.after(date);
}
}
예제 9.5. 자택 주소 임베디드 타입
@Embeddable
public class Address {
@Column(name = "city") // 매핑할 컬럼 정의 가능
private String city;
private String street;
private String zipcode;
// getter, setter ...
}

이제 회원 엔터티를 다음과 같이 설명할 수 있다.
회원 엔터티는 이름, 근무 기간, 자택 주소를 갖는다.
회원 엔터티와 임베디드 타입 간의 관계를 통해 회원 엔터티가 더욱 의미 있고 응집력 있게 변한 것을 확인할 수 있다. 새로 정의한 값 타입들은 재사용성 있고 응집도도 아주 높다. 또한 Period.wasWorking() 메서드처럼 해당 값 타입만 사용하는 의미 있는 메서드도 정의 가능하다.
임베디드 타입을 사용하기 위해 다음 두 개의 애너테이션을 사용할 수 있다. 하나는 생략 가능하다.
또한 임베디드 타입은 기본 생성자가 필수이다.
임베디드 타입을 포함한 모든 값 타입은 엔터티의 생명주기에 의존하므로 엔터티와 임베디드 타입의 관계를 UML에서는 합성 관계(composition relationship)라고 한다. 하이버네이트는 임베디드 타입을 컴포넌트(component)라고 한다.



임베디드 타입은 또 다시 값 타입을 포함하거나 엔터티를 참조하여 보다 복잡한 연관 관계 구성이 가능하다.
예제 9.6. 임베디드 타입과 연관 관계
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@Embedded Address address;
@Embedded PhoneNumber phoneNumber;
// getter, setter ...
}
@Embeddable
public class Address {
private String street;
private String city;
private String state;
@Embedded Zipcode zipcode;
// getter, setter ...
}
@Embeddable
public class Zipcode {
String zip;
String additional;
// getter, setter ...
}
@Embeddable
public class PhoneNumber {
String areaCode;
String localNumber;
@ManyToOne PhoneServiceProvider provider;
// getter, setter ...
}
@Entity
public class PhoneServiceProvider {
@Id String name;
// getter, setter ...
}
값 타입인 Address가 값 타입인 Zipcode를 포함하고, 값 타입인 PhoneNumber가 엔터티 타입인 PhoneServiceProvider를 참조한다.
같은 임베디드 타입이 엔터티에 여러 개 정의되어 있으면 임베디드 타입에 정의한 매핑 정보를 재정의해야 한다. 이때 @AttributeOverride 애너테이션을 사용한다.
예제 9.7. 같은 임베디드 타입을 가지고 있는 회원
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@Embedded Address homeAddress;
@Embedded Address companyAddress;
}
예제 9.8. 임베디드 타입 재정의
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@Embedded Address homeAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "COMPANY_CITY")),
@AttributeOverride(name = "street", column = @Column(name = "COMPANY_STREET")),
@AttributeOverride(name = "state", column = @Column(name = "COMPANY_STATE")),
@AttributeOverride(name = "zipcode.zip", column = @Column(name = "COMPANY_ZIP")),
@AttributeOverride(name = "zipcode.additional", column = @Column(name = "COMPANY_ADDITIONAL"))
})
Address companyAddress;
}
예제 9.9. 생성된 테이블
Hibernate:
create table member (
id bigint not null,
additional varchar(255),
city varchar(255),
company_additional varchar(255),
company_city varchar(255),
company_state varchar(255),
company_street varchar(255),
company_zip varchar(255),
name varchar(255),
state varchar(255),
street varchar(255),
zip varchar(255),
primary key (id)
)
임베디드 타입에서 임베디드 타입을 사용하고 있는 경우 임베디드 타입 재정의 시 속성명을 zipcode.zip처럼 표기해야 한다.
임베디드 타입의 값이 null이면 해당 타입에 매핑된 컬럼 값이 모두 null이 된다.
임베디드 타입의 값을 null로 설정
member.setAddress(null);
em.persist(member);
회원 테이블의 주소와 관련된 모든 컬럼의 값이 null이 된다.
값 타입은 복잡한 객체 세상을 조금이라도 단순화하기 위해 고안된 개념이기 때문에 단순하고 안전하게 다룰 수 있어야 한다.

같은 임베디드 타입 인스턴스를 여러 엔터티에서 공유하면 위험하다. 위 그림의 코드는 다음과 같다.
값 타입 공유 참조
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();
address.setCity("NewCity");
member2.setHomeAddress(address);
두 회원 엔터티가 같은 임베디드 타입 인스턴스를 참조하고 있기 때문에 내부 속성이 변경되면 영속성 컨텍스트의 변경 감지 기능으로 인해 다른 엔터티에도 영향을 미치는 부작용(side effect)이 발생한다.

위 그림의 코드는 다음과 같다.
값 타입 복사
member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();
Address newAddress = address.clone();
newAddress.setCity("NewCity");
member2.setHomeAddress(newAddress);
공유 참조로 인해 발생하는 부작용을 피하기 위해서는 clone() 메서드 등을 구현하여 인스턴스를 복사해 사용해야 한다. 임베디드 타입은 Java의 기본 타입(primitive type)이 아닌 객체 타입이다. 객체의 공유 참조는 피할 수 없다. 그렇기 때문에 비정상적인 접근 자체를 차단해야 한다면 setter 메서드를 제거하거나 객체를 불변 객체로 설계해야 한다.
객체를 불변으로 설계하면 부작용을 원천 차단할 수 있다. 따라서 값 타입은 가능하면 불변 객체(immutable Object)로 설계해야 한다.
불변 객체를 구현하는 가장 간단한 방법은 생성자로만 값을 설정하고 수정자를 만들지 않는 것이다.
예제 9.10. 주소 불변 객체
@Embeddable
public class Address {
private String city;
private String street;
private String state;
@Embedded Zipcode zipcode;
protected Address() {}
public Address(String city, String street, String state, Zipcode zipcode) {
this.city = city;
this.street = street;
this.state = state;
this.zipcode = zipcode;
}
// getter ...
}
예제 9.11. 불변 객체 사용
@Test
@Transactional
void immutableUsage() {
Member member1 = new Member();
Address address = new Address("Gunpo-si", "Surisan-ro", "South Korea", new Zipcode("15822", "X"));
member1.setAddress(address);
em.persist(member1);
Member member2 = new Member();
Address newAddress = new Address("Seoul-si", address.getStreet(), address.getState(), address.getZipcode());
member2.setAddress(newAddress);
em.persist(member2);
}
자바가 제공하는 객체 비교는 2가지이다.
== 사용equals() 사용임베디드 값 타입은 객체이므로 동일성 비교 시 false가 반환되지만 내부 필드 값이 같으면 같은 객체로 보아야 한다. 그러므로 값 타입 비교 시 equals() 메서드로 동등성 비교를 수행해야 한다. 이때 임베디드 타입 클래스 정의에 equals() 메서드의 재정의가 필요하고 hashCode()도 함께 재정의하는 것이 권장된다.

관계형 데이터베이스는 컬럼 안에 컬렉션을 포함할 수 없기 때문에 다수의 값 타입 인스턴스를 저장하려면 위와 같이 다중 값을 저장하기 위한 테이블을 별도로 생성하고 컬렉션에 보관해야 한다. 이때 @ElementCollection, @CollectionTable 애너테이션을 사용해 테이블을 매핑하고 다중 값을 저장할 것임을 알려야 한다.
예제 9.12. 값 타입 컬렉션
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@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<>();
// getter, setter ...
}
예제 9.13. 값 타입 컬렉션 등록
@Test
@Transactional
void persistValueObjectCollection() {
Member member = new Member();
// embedded value object
member.setHomeAddress(new Address("Gunpo-si", "Surisan-ro", "Republic of Korea", "15822"));
// value object collection
member.getFavoriteFoods().add("Hamburger");
member.getFavoriteFoods().add("Chicken");
member.getFavoriteFoods().add("Pizza");
// embedded value object collection
member.getAddressHistory().add(new Address("Seoul-si", "Baekbeom-ro", "Republic of Korea", "-----"));
member.getAddressHistory().add(new Address("Seoul-si", "Huiujeong-ro", "Republic of Korea", "-----"));
em.persist(member);
em.flush();
}
member 엔터티 영속화 시 값 타입도 함께 저장한다. 영속성 컨텍스트 플러시 시 다음과 같이 INSERT SQL이 총 6회 호출된다.
member: 1회member.homeAddress: 회원 테이블 저장 SQL에 포함member.favoriteFoods: 3회member.addressHistory: 2회예제 9.14. 실행된 SQL
Hibernate:
/* insert for
jpabook.domain.Member */insert
into
member (city, state, street, zipcode, name, id)
values
(?, ?, ?, ?, ?, ?)
Hibernate:
/* insert for
jpabook.domain.Member.addressHistory */insert
into
address (member_id, city, state, street, zipcode)
values
(?, ?, ?, ?, ?)
Hibernate:
/* insert for
jpabook.domain.Member.addressHistory */insert
into
address (member_id, city, state, street, zipcode)
values
(?, ?, ?, ?, ?)
Hibernate:
/* insert for
jpabook.domain.Member.favoriteFoods */insert
into
favorite_food (member_id, food_name)
values
(?, ?)
Hibernate:
/* insert for
jpabook.domain.Member.favoriteFoods */insert
into
favorite_food (member_id, food_name)
values
(?, ?)
Hibernate:
/* insert for
jpabook.domain.Member.favoriteFoods */insert
into
favorite_food (member_id, food_name)
values
(?, ?)
값 타입 컬렉션은 영속성 전이(Cascade)와 고아 객체 제거(ORPHAN REMOVE) 기능을 필수로 가진다고 볼 수 있다. 값 타입 컬렉션도 @ElementCollection(fetch = FetchType.LAZY) 매핑 애너테이션을 활용해 페치 전략을 선택할 수 있는데, 기본 값은 LAZY이다.
예제 9.15. 조회
@Test
@Transactional
void findValueObjectCollection() {
logger.info("FIND MEMBER");
Member member = em.find(Member.class, 1L);
logger.info("FIND MEMBER.HOME_ADDRESS");
Address homeAddress = member.getHomeAddress();
logger.info("FIND MEMBER.FAVORITE_FOODS");
Set<String> favoriteFoods = member.getFavoriteFoods();
logger.info("FIND FOOD_NAME FROM MEMBER.FAVORITE_FOODS");
for (String favoriteFood : favoriteFoods) {
logger.info("favorite food: " + favoriteFood);
}
logger.info("FIND MEMBER.ADDRESS_HISTORY");
List<Address> addressHistory = member.getAddressHistory();
logger.info("FIND SINGLE INSTANCE IN MEMBER.ADDRESS_HISTORY");
addressHistory.get(0);
}
테스트 실행 시 다음과 같은 순서로 SELECT SQL이 호출된다.
member: 1회member.homeAddress: 임베디드 값 타입이므로 회원 엔터티 조회 시 함께 조회member.favoriteFoods: 페치 전략이 LAZY이므로 실제 컬렉션 사용 시 1회member.addressHistory: 페치 전략이 LAZY이므로 실제 컬렉션 사용 시 1회로그
2025-11-10T22:19:57.211+09:00 INFO 583311 --- [ch09-jpa-type] [ main] jpabook.Ch09JpaTypeApplicationTests : FIND MEMBER
Hibernate:
select
m1_0.id,
m1_0.city,
m1_0.state,
m1_0.street,
m1_0.zipcode,
m1_0.name
from
member m1_0
where
m1_0.id=?
2025-11-10T22:19:57.274+09:00 INFO 583311 --- [ch09-jpa-type] [ main] jpabook.Ch09JpaTypeApplicationTests : FIND MEMBER.HOME_ADDRESS
2025-11-10T22:19:57.274+09:00 INFO 583311 --- [ch09-jpa-type] [ main] jpabook.Ch09JpaTypeApplicationTests : FIND MEMBER.FAVORITE_FOODS
2025-11-10T22:19:57.274+09:00 INFO 583311 --- [ch09-jpa-type] [ main] jpabook.Ch09JpaTypeApplicationTests : FIND FOOD_NAME FROM MEMBER.FAVORITE_FOODS
Hibernate:
select
ff1_0.member_id,
ff1_0.food_name
from
favorite_food ff1_0
where
ff1_0.member_id=?
2025-11-10T22:19:57.281+09:00 INFO 583311 --- [ch09-jpa-type] [ main] jpabook.Ch09JpaTypeApplicationTests : favorite food: Hamburger
2025-11-10T22:19:57.281+09:00 INFO 583311 --- [ch09-jpa-type] [ main] jpabook.Ch09JpaTypeApplicationTests : favorite food: Pizza
2025-11-10T22:19:57.281+09:00 INFO 583311 --- [ch09-jpa-type] [ main] jpabook.Ch09JpaTypeApplicationTests : favorite food: Chicken
2025-11-10T22:19:57.281+09:00 INFO 583311 --- [ch09-jpa-type] [ main] jpabook.Ch09JpaTypeApplicationTests : FIND MEMBER.ADDRESS_HISTORY
2025-11-10T22:19:57.281+09:00 INFO 583311 --- [ch09-jpa-type] [ main] jpabook.Ch09JpaTypeApplicationTests : FIND SINGLE INSTANCE IN MEMBER.ADDRESS_HISTORY
Hibernate:
select
ah1_0.member_id,
ah1_0.city,
ah1_0.state,
ah1_0.street,
ah1_0.zipcode
from
address ah1_0
where
ah1_0.member_id=?
마지막으로 값 타입 컬렉션 수정 시의 예제이다.
예제 9.16. 수정
@Test
@Transactional
void updateValueObjectCollection() {
Member member = em.find(Member.class, 1L);
member.setHomeAddress(new Address("Yongin-si", "Seocheonseo-ro", "Republic of Korea", "-----"));
Set<String> favoriteFoods = member.getFavoriteFoods();
favoriteFoods.remove("Hamburger");
favoriteFoods.add("Snack");
List<Address> addressHistory = member.getAddressHistory();
addressHistory.remove(new Address("Seoul-si", "Huiujeong-ro", "Republic of Korea", "-----"));
addressHistory.add(new Address("Yongin-si", "Seocheonseo-ro", "Republic of Korea", "-----"));
}
임베디드 값 타입과 값 타입 컬렉션 수정 시의 동작은 다음과 같다.
MEMBER 테이블과 직접 매핑되었으므로 MEMBER 테이블에 대한 UPDATE SQL에 포함되며 Member 엔터티를 수정하는 것과 동일equals(), hashCode() 메서드 정의 필수엔터티는 식별자를 통해 데이터베이스에 저장된 원본 데이터를 쉽게 찾아서 변경할 수 있지만 값 타입은 식별자가 없는 단순한 값들의 집합이기 때문에 값 변경 시 데이터베이스에 저장된 원본 데이터를 찾기 어렵다.
엔터티에 직접 매핑된 값 타입은 값이 변경되어도 엔터티를 통해 조회하고 값을 변경할 수 있지만, 값 타입 컬렉션에 보관된 값들은 별도의 테이블에 보관되기 때문에 변경 시 데이터베이스에서 원본 데이터를 추적하기 어렵다.
이로 인해 발생하는 문제를 예방하고자 JPA 구현체들은 값 타입 컬렉션에 변경 사항 발생 시 매핑 테이블의 연관 데이터를 모두 삭제하고 현재 값 타입 컬렉션 객체에 포함된 모든 값을 데이터베이스에 다시 저장한다.
따라서 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많아지면 값 타입 컬렉션 대신 일대다 관계를 맺는 별도의 엔터티를 정의하고 영속성 전이(Cascade) 및 고아 객체 제거(ORPHAN REMOVE) 기능을 적용하는 것을 고려할 수 있다.
또한 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어 기본 키를 구성해야 한다. 데이터베이스의 기본 키 제약 조건으로 인해 컬럼에는 NOT NULL, UNIQUE 제약이 따라 붙는다.
예제 9.17. 값 타입 컬렉션 대신에 일대다 관계 사용
@Entity
public class AddressEntity {
@Id
@GeneratedValue
private Long id;
@Embedded Address address;
// getter, setter ...
}
관계 매핑
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory;
값 타입 컬렉션 변경 시 JPA 구현체들은 테이블의 기본 키를 식별해서 변경된 내용만 반영하려고 노력한다. 하지만 사용하는 컬렉션이나 특정 조건에 따라 기본 키를 식별하지 못하게 될 수 있다. 따라서 값 타입 컬렉션 사용 시 레코드가 모두 삭제되고 다시 삽입되는 최악의 시나리오를 고려해야 한다.
em.persist(entity)로 영속화한다.em.remove(entity)로 제거한다.