[9] 값 타입

ttt-1-2·2026년 5월 17일

교재: 자바 ORM 표준 JPA 프로그래밍 

9장에서 다룰 내용:

  • 기본값 타입
  • 임베디드 타입
  • 컬렉션 타입

1. 기본값 타입

기본값 타입은 자바에서 제공하는 기본타입과 유사하다.

예를 들어 int, double, String, Integer처럼 단순한 값을 저장하는 타입을 말한다.

2. 임베디드 타입(복합 값 타입)

  • 새로운 값 타입을 직접 정의해서 사용할 수 있다. JPA에서는 이를 임베디드 타입이라고 한다.
  • 기본 사용법:
@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

하나의 엔티티에 같은 임베디드 타입을 두 번 사용하면 컬럼명이 충돌한다. 이때 @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로 설정하면 매핑된 컬럼들도 모두 null이 된다.

member.setAddress(null);
em.persist(member); // city, street, zipcode 모두 null로 저장

3. 값 타입과 불변 객체

값 타입은 공유하면 위험하다.

  • 위험한 코드 예제:
// 위험한 코드!
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 없음
}

값을 변경하고 싶으면 새 객체를 만들어서 교체하는 방식을 사용한다.

4. 값 타입의 비교

값 타입은 ==가 아니라 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);
    }
}

5. 값 타입 컬렉션

값 타입을 컬렉션으로 가지고 싶으면 @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<>();

→ 이렇게 하면 식별자가 생겨서 변경 추적이 가능하고, 성능도 안정적이다.

0개의 댓글