[자바 ORM 표준 JPA 프로그래밍] Chapter 09

YUSHIN KIM·2025년 11월 5일

Chapter 09. 값 타입

JPA의 데이터 타입은 크게 엔터티 타입과 값 타입으로 분류할 수 있다. 엔터티 타입은 @Entity 애너테이션으로 정의하는 객체, 값 타입은 int, Integer, String처럼 단순히 값으로 사용하는 Java의 기본 타입이나 객체를 의미한다.

엔터티 타입은 식별자를 통해 지속적으로 추적할 수 있지만 값 타입은 식별자가 없고 숫자나 문자 같은 속성만 있으므로 추적할 수 없다. 예를 들어 회원 엔터티는 일반 속성을 변경하더라도 식별자가 보존되면 같은 회원 엔터티이지만, 숫자 타입 데이터는 값을 변경하면 완전히 다른 데이터로 대체된다.

값 타입은 다음의 세 가지로 분류할 수 있다.

  • 기본 값 타입(basic value object): Java가 제공하는 기본 데이터 타입
    • 자바 기본 타입(int, double 등)
    • 래퍼 클래스(Integer 등)
    • String
  • 임베디드 타입(embedded type, 복합 값 타입): JPA에서 사용자가 직접 정의한 값 타입
  • 컬렉션 값 타입(collection value object): 하나 이상의 값 타입을 저장하는 값 타입

9.1 기본 값 타입

예제 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 값도 제거된다.

9.2 임베디드 타입(복합 값 타입)

사용자 정의 값 타입을 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() 메서드처럼 해당 값 타입만 사용하는 의미 있는 메서드도 정의 가능하다.

임베디드 타입을 사용하기 위해 다음 두 개의 애너테이션을 사용할 수 있다. 하나는 생략 가능하다.

  • @Embeddable: 값 타입을 정의하는 곳을 지정
  • @Embedded: 값 타입을 사용하는 곳을 지정

또한 임베디드 타입은 기본 생성자가 필수이다.

임베디드 타입을 포함한 모든 값 타입은 엔터티의 생명주기에 의존하므로 엔터티와 임베디드 타입의 관계를 UML에서는 합성 관계(composition relationship)라고 한다. 하이버네이트는 임베디드 타입을 컴포넌트(component)라고 한다.

9.2.1 임베디드 타입과 테이블 매핑

  • 임베디드 타입은 엔터티의 값일 뿐이기 때문에 값이 속한 엔터티의 테이블에 매핑된다.
  • 임베디드 타입 덕분에 객체와 테이블을 아주 세밀하게(fine-grained) 매핑하는 것이 가능하다.
  • 잘 설계된 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.

9.2.2 임베디드 타입과 연관 관계

임베디드 타입은 또 다시 값 타입을 포함하거나 엔터티를 참조하여 보다 복잡한 연관 관계 구성이 가능하다.

예제 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를 참조한다.

9.2.3 @AttributeOverride: 속성 재정의

같은 임베디드 타입이 엔터티에 여러 개 정의되어 있으면 임베디드 타입에 정의한 매핑 정보를 재정의해야 한다. 이때 @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처럼 표기해야 한다.

9.2.4 임베디드 타입과 null

임베디드 타입의 값이 null이면 해당 타입에 매핑된 컬럼 값이 모두 null이 된다.

임베디드 타입의 값을 null로 설정

member.setAddress(null);
em.persist(member);

회원 테이블의 주소와 관련된 모든 컬럼의 값이 null이 된다.

9.3 값 타입과 불변 객체

값 타입은 복잡한 객체 세상을 조금이라도 단순화하기 위해 고안된 개념이기 때문에 단순하고 안전하게 다룰 수 있어야 한다.

9.3.1 값 타입 공유 참조

같은 임베디드 타입 인스턴스를 여러 엔터티에서 공유하면 위험하다. 위 그림의 코드는 다음과 같다.

값 타입 공유 참조

member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();

address.setCity("NewCity");
member2.setHomeAddress(address);

두 회원 엔터티가 같은 임베디드 타입 인스턴스를 참조하고 있기 때문에 내부 속성이 변경되면 영속성 컨텍스트의 변경 감지 기능으로 인해 다른 엔터티에도 영향을 미치는 부작용(side effect)이 발생한다.

9.3.2 값 타입 복사

위 그림의 코드는 다음과 같다.

값 타입 복사

member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();

Address newAddress = address.clone();

newAddress.setCity("NewCity");
member2.setHomeAddress(newAddress);

공유 참조로 인해 발생하는 부작용을 피하기 위해서는 clone() 메서드 등을 구현하여 인스턴스를 복사해 사용해야 한다. 임베디드 타입은 Java의 기본 타입(primitive type)이 아닌 객체 타입이다. 객체의 공유 참조는 피할 수 없다. 그렇기 때문에 비정상적인 접근 자체를 차단해야 한다면 setter 메서드를 제거하거나 객체를 불변 객체로 설계해야 한다.

9.3.3 불변 객체

객체를 불변으로 설계하면 부작용을 원천 차단할 수 있다. 따라서 값 타입은 가능하면 불변 객체(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);
}

9.4 값 타입의 비교

자바가 제공하는 객체 비교는 2가지이다.

  • 동일성(identity) 비교: 인스턴스의 참조 값을 비교, == 사용
  • 동등성(equivalence) 비교: 인스턴스의 필드 값을 비교, equals() 사용

임베디드 값 타입은 객체이므로 동일성 비교 시 false가 반환되지만 내부 필드 값이 같으면 같은 객체로 보아야 한다. 그러므로 값 타입 비교 시 equals() 메서드로 동등성 비교를 수행해야 한다. 이때 임베디드 타입 클래스 정의에 equals() 메서드의 재정의가 필요하고 hashCode()도 함께 재정의하는 것이 권장된다.

9.5 값 타입 컬렉션

관계형 데이터베이스는 컬럼 안에 컬렉션을 포함할 수 없기 때문에 다수의 값 타입 인스턴스를 저장하려면 위와 같이 다중 값을 저장하기 위한 테이블을 별도로 생성하고 컬렉션에 보관해야 한다. 이때 @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.5.1 값 타입 컬렉션 사용

예제 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이 호출된다.

  1. member: 1회
  2. member.homeAddress: 임베디드 값 타입이므로 회원 엔터티 조회 시 함께 조회
  3. member.favoriteFoods: 페치 전략이 LAZY이므로 실제 컬렉션 사용 시 1회
  4. 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 엔터티를 수정하는 것과 동일
  • 기본 값 타입 컬렉션 수정: Java String 타입은 수정 불가능하기에 컬렉션에서 객체를 제거한 후 추가
  • 임베디드 값 타입 컬렉션 수정: 값 타입 객체는 불변 객체여야 하기 때문에 컬렉션에서 객체를 제거한 후 추가, 컬렉션의 추가/제거 연산을 위해 equals(), hashCode() 메서드 정의 필수

9.5.2 값 타입 컬렉션의 제약 사항

엔터티는 식별자를 통해 데이터베이스에 저장된 원본 데이터를 쉽게 찾아서 변경할 수 있지만 값 타입은 식별자가 없는 단순한 값들의 집합이기 때문에 값 변경 시 데이터베이스에 저장된 원본 데이터를 찾기 어렵다.

엔터티에 직접 매핑된 값 타입은 값이 변경되어도 엔터티를 통해 조회하고 값을 변경할 수 있지만, 값 타입 컬렉션에 보관된 값들은 별도의 테이블에 보관되기 때문에 변경 시 데이터베이스에서 원본 데이터를 추적하기 어렵다.

이로 인해 발생하는 문제를 예방하고자 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 구현체들은 테이블의 기본 키를 식별해서 변경된 내용만 반영하려고 노력한다. 하지만 사용하는 컬렉션이나 특정 조건에 따라 기본 키를 식별하지 못하게 될 수 있다. 따라서 값 타입 컬렉션 사용 시 레코드가 모두 삭제되고 다시 삽입되는 최악의 시나리오를 고려해야 한다.

9.6 정리

엔터티 타입(Entity Type)의 특징

  • 식별자(@Id)가 있고, 식별자로 구별할 수 있다.
  • 생성하고, 영속화하고, 소멸하는 생명주기가 있다.
  • em.persist(entity)로 영속화한다.
  • em.remove(entity)로 제거한다.
  • 참조 값을 공유(공유 참조)할 수 있다.

값 타입(Value Object)의 특징

  • 식별자가 없다.
  • 엔터티의 생명주기에 의존한다.
  • 불변 객체(Immutable object)로 설계하여 공유하지 않는 것이 안전하다.
profile
안녕하세요

0개의 댓글