Chapter 11. 값 타입

Chapter 11. 값 타입

JPA의 데이터 타입은 크게 엔티티 타입값 타입으로 나눌 수 있다.

엔티티 타입 vs 값 타입

┌─────────────────────────────────────────────────────────────────────┐
│                        JPA 데이터 타입                               │
├─────────────────────────────┬───────────────────────────────────────┤
│       엔티티 타입            │              값 타입                   │
│   (Entity Type)             │          (Value Type)                 │
├─────────────────────────────┼───────────────────────────────────────┤
│ • @Entity로 정의            │ • 기본값 타입 (int, String 등)         │
│ • 식별자(@Id)로 추적 가능    │ • 임베디드 타입 (복합 값)              │
│ • 독립적인 생명주기          │ • 컬렉션 값 타입                       │
│ • 변경 이력 추적 가능        │ • 식별자 없음                          │
│                             │ • 엔티티에 의존하는 생명주기            │
└─────────────────────────────┴───────────────────────────────────────┘
구분엔티티 타입값 타입
정의@Entity자바 기본 타입, 객체
식별자있음 (@Id)없음
생명주기독립적엔티티에 의존
추적가능불가능
비유살아있는 생물단순한 수치 정보

9.1 기본값 타입 (Basic Value Type)

자바가 제공하는 기본 데이터 타입들이다.

┌─────────────────────────────────────────────────────────────────┐
│                       기본값 타입                                │
├─────────────────┬───────────────────┬───────────────────────────┤
│  자바 기본 타입  │    래퍼 클래스     │        문자열            │
├─────────────────┼───────────────────┼───────────────────────────┤
│  int, long      │  Integer, Long    │        String            │
│  double, float  │  Double, Float    │                          │
│  boolean        │  Boolean          │                          │
└─────────────────┴───────────────────┴───────────────────────────┘

예제

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;           // 식별자 (엔티티 타입의 속성)

    private String name;       // 값 타입
    private int age;           // 값 타입
}

기본값 타입의 특징

┌─────────────────────────────────────────────────────────────────┐
│                    Member 엔티티 구조                            │
├─────────────────────────────────────────────────────────────────┤
│  id (식별자)                                                     │
│  ├── 엔티티 추적에 사용                                          │
│  └── 독립적 의미                                                 │
│                                                                 │
│  name, age (값 타입)                                             │
│  ├── 식별자 없음                                                 │
│  ├── Member 엔티티에 생명주기 의존                                │
│  └── Member 삭제 시 함께 삭제                                    │
└─────────────────────────────────────────────────────────────────┘

참고: 자바 기본 타입(primitive type)은 절대 공유되지 않는다. a = b 코드는 b의 값을 복사해서 a에 입력한다.

int a = 10;
int b = a;    // 값 복사 (공유 X)

b = 20;       // a는 여전히 10

9.2 임베디드 타입 (Embedded Type)

사용자가 직접 정의한 새로운 값 타입. 복합 값 타입이라고도 한다.

임베디드 타입 적용 전/후 비교

┌─────────────────────────────────────────────────────────────────┐
│                    적용 전 (플랫 구조)                           │
├─────────────────────────────────────────────────────────────────┤
│  Member                                                         │
│  ├── id                                                         │
│  ├── name                                                       │
│  ├── startDate    ─┐                                            │
│  ├── endDate      ─┘ 근무 기간 (논리적 그룹)                     │
│  ├── city         ─┐                                            │
│  ├── street       ─┤ 집 주소 (논리적 그룹)                       │
│  └── zipcode      ─┘                                            │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    적용 후 (임베디드 타입)                        │
├─────────────────────────────────────────────────────────────────┤
│  Member                                                         │
│  ├── id                                                         │
│  ├── name                                                       │
│  ├── workPeriod ──────► Period (startDate, endDate)             │
│  └── homeAddress ─────► Address (city, street, zipcode)         │
└─────────────────────────────────────────────────────────────────┘

코드 예제

// 적용 전 - 플랫 구조
@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String name;

    // 근무 기간
    @Temporal(TemporalType.DATE)
    private java.util.Date startDate;
    @Temporal(TemporalType.DATE)
    private java.util.Date endDate;

    // 집 주소
    private String city;
    private String street;
    private String zipcode;
}
// 적용 후 - 임베디드 타입 사용
@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String name;

    @Embedded
    private Period workPeriod;      // 근무 기간

    @Embedded
    private Address homeAddress;    // 집 주소
}
// 기간 임베디드 타입
@Embeddable
public class Period {

    @Temporal(TemporalType.DATE)
    private java.util.Date startDate;

    @Temporal(TemporalType.DATE)
    private java.util.Date endDate;

    // 값 타입을 위한 메소드 정의 가능
    public boolean isWork(Date date) {
        // 현재 근무 중인지 확인하는 로직
        return startDate.before(date) && endDate.after(date);
    }
}
// 주소 임베디드 타입
@Embeddable
public class Address {

    @Column(name = "city")  // 매핑할 컬럼 정의 가능
    private String city;
    private String street;
    private String zipcode;

    // 기본 생성자 필수
    public Address() {}

    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
}

필수 어노테이션

어노테이션위치설명
@Embeddable값 타입 정의 클래스이 클래스가 임베디드 타입임을 표시
@Embedded값 타입 사용하는 필드임베디드 타입을 사용함을 표시

참고: 둘 중 하나는 생략 가능하지만, 명확성을 위해 둘 다 표시하는 것을 권장한다.

임베디드 타입의 장점

┌─────────────────────────────────────────────────────────────────┐
│                   임베디드 타입의 장점                           │
├─────────────────────────────────────────────────────────────────┤
│  1. 재사용성                                                     │
│     └── Address를 다른 엔티티에서도 사용 가능                    │
│                                                                 │
│  2. 높은 응집도                                                  │
│     └── 관련 있는 속성들을 논리적으로 그룹화                     │
│                                                                 │
│  3. 의미있는 메소드                                              │
│     └── Period.isWork() 처럼 해당 값 타입만을 위한 메소드 정의   │
│                                                                 │
│  4. 객체지향적 설계                                              │
│     └── 도메인 모델을 더 명확하게 표현                           │
└─────────────────────────────────────────────────────────────────┘

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

임베디드 타입은 엔티티의 값일 뿐이다. 테이블에는 동일하게 매핑된다.

┌─────────────────────────────────────────────────────────────────┐
│  객체 모델                           │    테이블 구조            │
├─────────────────────────────────────────────────────────────────┤
│                                      │                          │
│  ┌─────────────┐                     │   MEMBER                 │
│  │   Member    │                     │  ┌─────────────┐         │
│  ├─────────────┤                     │  │ ID          │         │
│  │ id          │──────────────────►  │  │ NAME        │         │
│  │ name        │                     │  │ START_DATE  │         │
│  │ workPeriod ─┼──► Period           │  │ END_DATE    │         │
│  │ homeAddress ┼──► Address          │  │ CITY        │         │
│  └─────────────┘                     │  │ STREET      │         │
│                                      │  │ ZIPCODE     │         │
│                                      │  └─────────────┘         │
└─────────────────────────────────────────────────────────────────┘
        객체는 임베디드로 분리            테이블은 그대로

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

임베디드 타입은 다음을 포함할 수 있다:

  • 다른 값 타입 (임베디드 타입 중첩)
  • 엔티티 참조 (@ManyToOne 등)
┌─────────────────────────────────────────────────────────────────┐
│                   임베디드 타입의 연관관계                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Member                                                         │
│  ├── address ─────────► Address (임베디드 타입)                  │
│  │                       ├── street                             │
│  │                       ├── city                               │
│  │                       ├── state                              │
│  │                       └── zipcode ──► Zipcode (임베디드 중첩) │
│  │                                        ├── zip               │
│  │                                        └── plusFour          │
│  │                                                              │
│  └── phoneNumber ─────► PhoneNumber (임베디드 타입)              │
│                          ├── areaCode                           │
│                          ├── localNumber                        │
│                          └── provider ──► PhoneServiceProvider  │
│                                           (엔티티 참조!)         │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

코드 예제

@Entity
public class Member {

    @Embedded
    private Address address;           // 임베디드 타입 포함

    @Embedded
    private PhoneNumber phoneNumber;   // 임베디드 타입 포함
}

@Embeddable
public class Address {
    private String street;
    private String city;
    private String state;

    @Embedded
    private Zipcode zipcode;           // 임베디드 타입 중첩
}

@Embeddable
public class Zipcode {
    private String zip;
    private String plusFour;
}

@Embeddable
public class PhoneNumber {
    private String areaCode;
    private String localNumber;

    @ManyToOne
    private PhoneServiceProvider provider;  // 엔티티 참조
}

@Entity
public class PhoneServiceProvider {
    @Id
    private String name;
}

9.2.3 @AttributeOverride: 속성 재정의

같은 임베디드 타입을 한 엔티티에서 여러 번 사용할 때, 컬럼명 충돌을 해결한다.

문제 상황

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String name;

    @Embedded
    private Address homeAddress;      // city, street, zipcode

    @Embedded
    private Address companyAddress;   // city, street, zipcode  ← 컬럼명 충돌!
}
┌─────────────────────────────────────────────────────────────────┐
│                        컬럼명 충돌!                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   MEMBER 테이블                                                 │
│  ┌──────────────────────────────────────┐                       │
│  │ ID                                   │                       │
│  │ NAME                                 │                       │
│  │ CITY      ←── homeAddress.city      │                       │
│  │ CITY      ←── companyAddress.city   │  ← 중복!              │
│  │ STREET    ←── homeAddress.street    │                       │
│  │ STREET    ←── companyAddress.street │  ← 중복!              │
│  │ ZIPCODE   ←── ...                   │  ← 중복!              │
│  └──────────────────────────────────────┘                       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

해결: @AttributeOverrides

@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;
}
┌─────────────────────────────────────────────────────────────────┐
│                   @AttributeOverride 적용 후                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   MEMBER 테이블                                                 │
│  ┌──────────────────────────────────────┐                       │
│  │ ID                                   │                       │
│  │ NAME                                 │                       │
│  │ CITY           ←── homeAddress      │                       │
│  │ STREET         ←── homeAddress      │                       │
│  │ ZIPCODE        ←── homeAddress      │                       │
│  │ COMPANY_CITY   ←── companyAddress   │  ← 재정의됨           │
│  │ COMPANY_STREET ←── companyAddress   │  ← 재정의됨           │
│  │ COMPANY_ZIPCODE←── companyAddress   │  ← 재정의됨           │
│  └──────────────────────────────────────┘                       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

주의: @AttributeOverrides는 반드시 엔티티에 설정해야 한다. 임베디드 타입이 다른 임베디드 타입을 포함해도, 최종 엔티티에서 설정한다.


9.2.4 임베디드 타입과 null

임베디드 타입이 null이면 매핑한 컬럼 값은 모두 null이 된다.

member.setHomeAddress(null);
em.persist(member);
┌─────────────────────────────────────────────────────────────────┐
│                      결과 테이블                                 │
├─────────────────────────────────────────────────────────────────┤
│  MEMBER                                                         │
│  ┌─────────────────────────────┐                                │
│  │ ID      │ 1                 │                                │
│  │ NAME    │ 홍길동            │                                │
│  │ CITY    │ NULL              │  ← homeAddress가               │
│  │ STREET  │ NULL              │     null이므로                 │
│  │ ZIPCODE │ NULL              │     모든 컬럼이 NULL           │
│  └─────────────────────────────┘                                │
└─────────────────────────────────────────────────────────────────┘

9.3 값 타입과 불변 객체

값 타입은 단순하고 안전하게 다룰 수 있어야 한다.

9.3.1 값 타입 공유 참조 문제

임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다.

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

address.setCity("NewCity");  // 회원1의 address를 공유해서 사용
member2.setHomeAddress(address);
┌─────────────────────────────────────────────────────────────────┐
│                     공유 참조 문제                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│    member1                        member2                       │
│  ┌─────────────┐                ┌─────────────┐                │
│  │ homeAddress │────┐     ┌─────│ homeAddress │                │
│  └─────────────┘    │     │     └─────────────┘                │
│                     ▼     ▼                                     │
│                  ┌──────────────┐                               │
│                  │   Address    │                               │
│                  │ city="NewCity│  ← 변경!                      │
│                  └──────────────┘                               │
│                                                                 │
│  기대: member2의 city만 "NewCity"로 변경                        │
│  실제: member1, member2 모두 "NewCity"로 변경됨                 │
│                                                                 │
│  → 영속성 컨텍스트가 둘 다 변경되었다고 판단                     │
│  → UPDATE SQL 2개 실행!                                         │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

이런 현상을 **부작용(Side Effect)**이라 한다.


9.3.2 값 타입 복사

공유 참조 문제를 피하려면 값을 복사해서 사용해야 한다.

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

// 새로운 인스턴스를 생성하여 복사
Address newAddress = new Address(address.getCity());
newAddress.setCity("NewCity");

member2.setHomeAddress(newAddress);
┌─────────────────────────────────────────────────────────────────┐
│                     값 복사 (안전)                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│    member1                        member2                       │
│  ┌─────────────┐                ┌─────────────┐                │
│  │ homeAddress │────┐           │ homeAddress │────┐           │
│  └─────────────┘    │           └─────────────┘    │           │
│                     ▼                              ▼           │
│              ┌──────────────┐              ┌──────────────┐    │
│              │   Address    │              │   Address    │    │
│              │ city="OldCity│              │ city="NewCity│    │
│              └──────────────┘              └──────────────┘    │
│                    ↑                              ↑            │
│              별도 인스턴스                   별도 인스턴스       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

불변 객체 (Immutable Object)

근본적인 해결책: 값 타입을 불변 객체로 설계한다.

@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 없음
    public String getCity() { return city; }
    public String getStreet() { return street; }
    public String getZipcode() { return zipcode; }

    // 수정이 필요하면 새 인스턴스 생성
    public Address withCity(String newCity) {
        return new Address(newCity, this.street, this.zipcode);
    }
}
┌─────────────────────────────────────────────────────────────────┐
│                   불변 객체 사용 패턴                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  // 잘못된 방법 (Setter 사용)                                   │
│  address.setCity("NewCity");  // 컴파일 에러! Setter 없음       │
│                                                                 │
│  // 올바른 방법 (새 인스턴스 생성)                               │
│  Address newAddress = new Address("NewCity", "street", "zip");  │
│  member.setHomeAddress(newAddress);                             │
│                                                                 │
│  // 또는 복사 메소드 사용                                        │
│  Address newAddress = address.withCity("NewCity");              │
│  member.setHomeAddress(newAddress);                             │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

값 타입 비교

값 타입의 동등성 비교를 위해 equals()hashCode()를 재정의해야 한다.

@Embeddable
public class Address {

    private String city;
    private String street;
    private String zipcode;

    @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);
    }
}

정리

┌─────────────────────────────────────────────────────────────────┐
│                     값 타입 핵심 정리                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. 값 타입 분류                                                │
│     ├── 기본값 타입: int, String 등                             │
│     ├── 임베디드 타입: @Embeddable, @Embedded                   │
│     └── 컬렉션 값 타입: @ElementCollection                      │
│                                                                 │
│  2. 임베디드 타입                                                │
│     ├── 재사용성 향상                                           │
│     ├── 높은 응집도                                             │
│     ├── 테이블 매핑은 동일                                       │
│     └── 기본 생성자 필수                                        │
│                                                                 │
│  3. 속성 재정의                                                  │
│     └── @AttributeOverrides로 컬럼명 충돌 해결                   │
│                                                                 │
│  4. 값 타입 공유 주의                                            │
│     ├── 공유 참조 시 부작용(Side Effect) 발생                   │
│     ├── 불변 객체로 설계 권장                                   │
│     └── Setter 제거, 생성자로만 값 설정                          │
│                                                                 │
│  5. 동등성 비교                                                  │
│     └── equals(), hashCode() 재정의                             │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Hibernate 용어: 임베디드 타입을 **컴포넌트(Components)**라고 부른다.