[Basic-8] 값 타입

2026. 1. 6. 14:27·Spring/JPA

1. 기본값 타입

1.1. JPA 데이터 타입 분류

JPA의 데이터 타입은 크게 엔티티 타입과 값 타입으로 구분된다.

구분 엔티티 타입 값 타입
정의 @Entity로 정의하는 객체 단순 값으로 사용하는 타입
식별자 식별자 존재 식별자 없음
생명주기 독립적 관리 엔티티에 의존적
변경 추적 가능 불가능
공유 가능 불가능 (복사 사용)
예시 Member, Order String name, int age

1.2. 값 타입 분류

값 타입은 세 가지로 세분화된다:

  1. 기본값 타입
    • 자바 기본 타입: int, double, boolean
    • 래퍼 클래스: Integer, Long, Double
    • String
  2. 임베디드 타입 (복합 값 타입)
    • 사용자 정의 값 타입
    • @Embeddable로 정의
  3. 컬렉션 값 타입
    • 값 타입을 컬렉션으로 사용
    • @ElementCollection으로 정의

1.3. 기본값 타입 특징

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

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

특징

  • 생명주기를 엔티티에 의존
  • 회원을 삭제하면 이름, 나이 필드도 함께 삭제
  • 값 타입은 공유하지 않음
  • 값 변경 시 추적 불가능

자바 기본 타입의 특징

// 기본 타입: 절대 공유되지 않음 (값 복사)
int a = 10;
int b = a;  // 값을 복사
b = 20;
System.out.println(a);  // 10 (변경되지 않음)

// 객체 타입: 참조 공유 가능 (주의 필요)
Integer x = Integer.valueOf(10);
Integer y = x;  // 같은 객체 참조
// 하지만 Integer는 불변이므로 안전

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

2.1. 임베디드 타입의 개념

임베디드 타입은 기본 값 타입들을 모아서 새로운 값 타입을 정의하는 기능이다.

개선 전: 평범한 엔티티 설계

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    private LocalDateTime startDate;  // 근무 시작일
    private LocalDateTime 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;   // 집 주소
}

2.2. 임베디드 타입 정의와 사용

값 타입 정의 (@Embeddable)

@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)  // 기본 생성자 필수
public class Period {

    private LocalDateTime startDate;
    private LocalDateTime endDate;

    public Period(LocalDateTime startDate, LocalDateTime endDate) {
        this.startDate = startDate;
        this.endDate = endDate;
    }

    // 비즈니스 메서드
    public boolean isWork() {
        LocalDateTime now = LocalDateTime.now();
        return now.isAfter(startDate) && now.isBefore(endDate);
    }

    public long getWorkDays() {
        return Duration.between(startDate, endDate).toDays();
    }
}

@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Address {

    @Column(length = 10)  // 컬럼 속성 정의 가능
    private String city;

    @Column(length = 20)
    private String street;

    @Column(length = 5)
    private String zipcode;

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

    // 비즈니스 메서드
    public String getFullAddress() {
        return String.format("%s %s (%s)", city, street, zipcode);
    }
}

엔티티에서 사용 (@Embedded)

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @Embedded  // 임베디드 타입 사용
    private Period workPeriod;

    @Embedded  // 임베디드 타입 사용
    private Address homeAddress;

    // 생성자
    public Member(String name, Period workPeriod, Address homeAddress) {
        this.name = name;
        this.workPeriod = workPeriod;
        this.homeAddress = homeAddress;
    }
}

2.3. 임베디드 타입의 장점

  1. 재사용성: 여러 엔티티에서 동일한 값 타입 사용 가능
  2. 높은 응집도: 관련된 데이터를 하나의 객체로 묶음
  3. 의미 있는 메서드: 값 타입에 비즈니스 메서드 추가 가능
  4. 객체지향적 설계: 테이블 중심에서 객체 중심으로 전환

2.4. 테이블 매핑

임베디드 타입 사용 전후의 테이블 구조는 동일하다.

-- 임베디드 타입 사용 전/후 동일한 테이블 구조
CREATE TABLE MEMBER (
    ID BIGINT PRIMARY KEY,
    NAME VARCHAR(255),
    START_DATE TIMESTAMP,    -- Period의 startDate
    END_DATE TIMESTAMP,      -- Period의 endDate
    CITY VARCHAR(10),        -- Address의 city
    STREET VARCHAR(20),      -- Address의 street
    ZIPCODE VARCHAR(5)       -- Address의 zipcode
);

2.5. 속성 재정의 (@AttributeOverride)

한 엔티티에서 같은 임베디드 타입을 두 번 이상 사용할 경우 컬럼명이 중복되는 문제가 발생한다. 이때 @AttributeOverride를 사용한다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @Embedded
    private Address homeAddress;  // 기본 컬럼명: city, street, zipcode

    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "city", column = @Column(name = "work_city")),
        @AttributeOverride(name = "street", column = @Column(name = "work_street")),
        @AttributeOverride(name = "zipcode", column = @Column(name = "work_zipcode"))
    })
    private Address workAddress;  // 컬럼명 재정의
}

생성되는 테이블

CREATE TABLE MEMBER (
    ID BIGINT PRIMARY KEY,
    NAME VARCHAR(255),
    CITY VARCHAR(10),          -- homeAddress.city
    STREET VARCHAR(20),        -- homeAddress.street
    ZIPCODE VARCHAR(5),        -- homeAddress.zipcode
    WORK_CITY VARCHAR(10),     -- workAddress.city (재정의)
    WORK_STREET VARCHAR(20),   -- workAddress.street (재정의)
    WORK_ZIPCODE VARCHAR(5)    -- workAddress.zipcode (재정의)
);

2.6. 임베디드 타입과 null

임베디드 타입 자체가 null이면 매핑된 모든 컬럼 값이 null이 된다.

Member member = new Member();
member.setName("김철수");
member.setHomeAddress(null);  // city, street, zipcode 모두 null

em.persist(member);

3. 값 타입과 불변 객체

3.1. 값 타입 공유 참조 문제

값 타입을 여러 엔티티에서 공유하면 발생하는 문제점:

// 문제 상황: 공유 참조
Address address = new Address("서울", "강남구", "12345");

Member member1 = new Member("회원1", address);
Member member2 = new Member("회원2", address);

member1.getHomeAddress().setCity("부산");  // member2의 주소도 변경됨!

System.out.println(member1.getHomeAddress().getCity());  // "부산"
System.out.println(member2.getHomeAddress().getCity());  // "부산" (문제!)

3.2. 값 복사로 해결

값을 복사해서 사용하면 공유 참조 문제를 해결할 수 있다.

Address address = new Address("서울", "강남구", "12345");

Member member1 = new Member("회원1",
    new Address(address.getCity(), address.getStreet(), address.getZipcode()));
Member member2 = new Member("회원2",
    new Address(address.getCity(), address.getStreet(), address.getZipcode()));

member1.getHomeAddress().setCity("부산");

System.out.println(member1.getHomeAddress().getCity());  // "부산"
System.out.println(member2.getHomeAddress().getCity());  // "서울" (정상)

3.3. 객체 타입의 한계

그러나 객체 타입은 참조를 복사하는 것을 막을 방법이 없다:

// 컴파일러가 막을 수 없는 문제
Address a = new Address("서울", "강남구", "12345");
Address b = a;  // 참조 복사 (값 복사 아님)
b.setCity("부산");  // a도 영향을 받음

3.4. 불변 객체 (Immutable Object)

불변 객체: 생성 후 상태를 변경할 수 없는 객체

@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Address {

    @Column(length = 10)
    private final String city;      // final로 불변성 강제

    @Column(length = 20)
    private final String street;    // final로 불변성 강제

    @Column(length = 5)
    private final String zipcode;   // final로 불변성 강제

    // 생성자만 있고 Setter는 없음
    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }

    // Setter 대신 새로운 객체 반환
    public Address withCity(String newCity) {
        return new Address(newCity, this.street, this.zipcode);
    }

    public Address withStreet(String newStreet) {
        return new Address(this.city, newStreet, this.zipcode);
    }
}

사용 예시

// 기존 주소로부터 새 주소 생성
Address oldAddress = new Address("서울", "강남구", "12345");
Address newAddress = oldAddress.withCity("부산");  // 새 객체 생성

Member member = new Member("회원1", oldAddress);
// 주소 변경이 필요하면 새 객체 생성
member.setHomeAddress(newAddress);

자바에서 제공하는 불변 객체 예시

  • String: 문자열 변경 불가
  • Integer, Long, Double: 값 변경 불가
  • LocalDate, LocalDateTime: 날짜/시간 변경 불가

4. 값 타입의 비교

4.1. 동일성 vs 동등성

값 타입 비교 시 중요한 두 가지 개념:

비교 유형 의미 사용 방법
동일성 (Identity) 인스턴스의 참조 값 비교 == 연산자
동등성 (Equivalence) 인스턴스의 내용(값) 비교 equals() 메서드

4.2. 값 타입 비교 예시

Address address1 = new Address("서울", "강남", "12345");
Address address2 = new Address("서울", "강남", "12345");

// 동일성 비교: false (서로 다른 객체)
System.out.println(address1 == address2);  // false

// 동등성 비교: true (내용이 같음)
System.out.println(address1.equals(address2));  // true (equals() 재정의 필요)

4.3. equals()와 hashCode() 재정의

값 타입은 equals()와 hashCode()를 재정의해야 올바른 비교가 가능하다.

@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Address {

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

    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = 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);
    }
}

Lombok 사용 시

@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode  // Lombok이 equals()와 hashCode() 자동 생성
public class Address {
    private String city;
    private String street;
    private String zipcode;

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

5. 값 타입 컬렉션

5.1. 값 타입 컬렉션 개념

값 타입을 하나 이상 저장할 때 사용하며, 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없으므로 별도의 테이블이 필요하다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @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_HISTORY",
        joinColumns = @JoinColumn(name = "MEMBER_ID")
    )
    private List<Address> addressHistory = new ArrayList<>();
}

5.2. 테이블 구조

-- 회원 테이블
CREATE TABLE MEMBER (
    ID BIGINT PRIMARY KEY,
    NAME VARCHAR(255),
    CITY VARCHAR(10),
    STREET VARCHAR(20),
    ZIPCODE VARCHAR(5)
);

-- 값 타입 컬렉션 테이블 1: 단순 값
CREATE TABLE FAVORITE_FOODS (
    MEMBER_ID BIGINT NOT NULL,
    FOOD_NAME VARCHAR(255),
    PRIMARY KEY (MEMBER_ID, FOOD_NAME),
    FOREIGN KEY (MEMBER_ID) REFERENCES MEMBER(ID)
);

-- 값 타입 컬렉션 테이블 2: 임베디드 타입
CREATE TABLE ADDRESS_HISTORY (
    MEMBER_ID BIGINT NOT NULL,
    CITY VARCHAR(10),
    STREET VARCHAR(20),
    ZIPCODE VARCHAR(5),
    PRIMARY KEY (MEMBER_ID, CITY, STREET, ZIPCODE),  -- 복합키
    FOREIGN KEY (MEMBER_ID) REFERENCES MEMBER(ID)
);

5.3. 값 타입 컬렉션 사용

저장

Member member = new Member();
member.setName("회원1");
member.setHomeAddress(new Address("서울", "강남", "12345"));

// 값 타입 컬렉션에 데이터 추가
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("햄버거");

member.getAddressHistory().add(new Address("서울", "강남", "12345"));
member.getAddressHistory().add(new Address("부산", "해운대", "45678"));

em.persist(member);  // 한 번의 persist로 모든 데이터 저장

조회 (지연 로딩)

Member member = em.find(Member.class, memberId);

// 컬렉션 조회 (지연 로딩)
Set<String> favoriteFoods = member.getFavoriteFoods();  // 프록시
for (String food : favoriteFoods) {  // 실제 사용 시점에 초기화
    System.out.println("food = " + food);
}

List<Address> addressHistory = member.getAddressHistory();
for (Address address : addressHistory) {
    System.out.println("address = " + address.getCity());
}

수정 (주의사항)

// 값 타입 컬렉션 수정
Member member = em.find(Member.class, memberId);

// 기존 컬렉션을 완전히 대체해야 함
Address newAddress = new Address("대전", "유성", "78901");
List<Address> newAddressHistory = new ArrayList<>(member.getAddressHistory());
newAddressHistory.remove(0);
newAddressHistory.add(newAddress);

member.setAddressHistory(newAddressHistory);  // 완전히 새로운 컬렉션으로 교체

5.4. 값 타입 컬렉션의 제약사항

  1. 식별자 개념 없음: 값 변경 시 추적 어려움
  2. 변경 시 전체 삭제 후 재저장: 성능 문제 가능성
  3. 복합키 구성 필요: 모든 컬럼을 묶어서 기본키로 구성
  4. null 입력 불가: 복합키 특성상 null 불가
  5. 중복 저장 불가: 복합키 특성상 중복 불가

5.5. 값 타입 컬렉션 대안: 일대다 관계

실무에서는 값 타입 컬렉션 대신 일대다 관계를 사용하는 것을 권장한다.

대안: AddressEntity 생성

@Entity
@Table(name = "ADDRESS")
public class AddressEntity {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @Embedded
    private Address address;

    private LocalDateTime createdAt;

    // 생성자, getter, setter
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
    @OrderBy("createdAt desc")
    private List<AddressEntity> addressHistory = new ArrayList<>();

    // 편의 메서드
    public void addAddressHistory(Address address) {
        AddressEntity addressEntity = new AddressEntity(this, address, LocalDateTime.now());
        addressHistory.add(addressEntity);
    }
}

장점

  • 식별자가 있어 변경 추적 가능
  • 지연 로딩 최적화 용이
  • 필요한 경우에만 조회 가능
  • 유연한 쿼리 작성 가능

6. 실전 예제: 값 타입 매핑

6.1. 도메인 모델 개선

기존 주문 시스템에 값 타입을 적용하여 객체지향성을 높인다.

// 값 타입: 주소
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode
public class Address {

    @Column(length = 10)
    private final String city;

    @Column(length = 20)
    private final String street;

    @Column(length = 5)
    private final String zipcode;

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

    public String getFullAddress() {
        return String.format("%s %s (%s)", city, street, zipcode);
    }

    // 불변 객체이므로 새로운 객체 반환
    public Address withCity(String newCity) {
        return new Address(newCity, street, zipcode);
    }
}

// 값 타입: 금액 (Money 패턴)
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode
public class Money {

    private final int amount;
    private final String currency;

    public Money(int amount) {
        this(amount, "KRW");
    }

    public Money(int amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    // 비즈니스 메서드
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("통화 단위가 다릅니다.");
        }
        return new Money(this.amount + other.amount, this.currency);
    }

    public Money multiply(int multiplier) {
        return new Money(this.amount * multiplier, this.currency);
    }

    public boolean isGreaterThan(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("통화 단위가 다릅니다.");
        }
        return this.amount > other.amount;
    }

    @Override
    public String toString() {
        return String.format("%s %d", currency, amount);
    }
}

// 값 타입: 시간 범위
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode
public class DateTimeRange {

    private final LocalDateTime startDateTime;
    private final LocalDateTime endDateTime;

    public DateTimeRange(LocalDateTime startDateTime, LocalDateTime endDateTime) {
        if (startDateTime.isAfter(endDateTime)) {
            throw new IllegalArgumentException("시작시간은 종료시간보다 빨라야 합니다.");
        }
        this.startDateTime = startDateTime;
        this.endDateTime = endDateTime;
    }

    // 비즈니스 메서드
    public boolean contains(LocalDateTime dateTime) {
        return !dateTime.isBefore(startDateTime) && !dateTime.isAfter(endDateTime);
    }

    public boolean overlaps(DateTimeRange other) {
        return this.startDateTime.isBefore(other.endDateTime) &&
               other.startDateTime.isBefore(this.endDateTime);
    }

    public long getDurationInMinutes() {
        return Duration.between(startDateTime, endDateTime).toMinutes();
    }
}

6.2. 엔티티에 값 타입 적용

// 회원 엔티티
@Entity
@Table(name = "member")
public class Member extends BaseEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(unique = true, nullable = false)
    private String email;

    @Embedded
    private Address address;

    // 값 타입 컬렉션: 관심사항
    @ElementCollection
    @CollectionTable(
        name = "member_interests",
        joinColumns = @JoinColumn(name = "member_id")
    )
    @Column(name = "interest")
    private Set<String> interests = new HashSet<>();

    // 생성자
    public Member(String name, String email, Address address) {
        this.name = name;
        this.email = email;
        this.address = address;
    }

    // 비즈니스 메서드
    public void updateAddress(Address newAddress) {
        this.address = newAddress;
    }

    public void addInterest(String interest) {
        this.interests.add(interest);
    }

    public void removeInterest(String interest) {
        this.interests.remove(interest);
    }
}

// 상품 엔티티
@Entity
@Table(name = "item")
public class Item extends BaseEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Embedded
    @AttributeOverride(name = "amount", column = @Column(name = "price"))
    private Money price;

    @Column(name = "stock_quantity")
    private int stockQuantity;

    // 값 타입 컬렉션: 상품 이미지 URL들
    @ElementCollection
    @CollectionTable(
        name = "item_images",
        joinColumns = @JoinColumn(name = "item_id")
    )
    @Column(name = "image_url")
    private List<String> imageUrls = new ArrayList<>();

    // 생성자
    public Item(String name, Money price, int stockQuantity) {
        this.name = name;
        this.price = price;
        this.stockQuantity = stockQuantity;
    }

    // 비즈니스 메서드
    public void addStock(int quantity) {
        this.stockQuantity += quantity;
    }

    public void removeStock(int quantity) {
        int restStock = this.stockQuantity - quantity;
        if (restStock < 0) {
            throw new NotEnoughStockException("재고가 부족합니다.");
        }
        this.stockQuantity = restStock;
    }

    public void updatePrice(Money newPrice) {
        this.price = newPrice;
    }

    public void addImage(String imageUrl) {
        this.imageUrls.add(imageUrl);
    }
}

// 주문 엔티티
@Entity
@Table(name = "orders")
public class Order extends BaseEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private Member member;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    @Embedded
    @AttributeOverride(name = "startDateTime", column = @Column(name = "order_date"))
    @AttributeOverride(name = "endDateTime", column = @Column(name = "required_date"))
    private DateTimeRange orderPeriod;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    // 생성 메서드
    public static Order createOrder(Member member, Delivery delivery,
                                   DateTimeRange orderPeriod, OrderItem... orderItems) {
        Order order = new Order();
        order.member = member;
        order.delivery = delivery;
        order.orderPeriod = orderPeriod;
        order.status = OrderStatus.ORDER;

        for (OrderItem orderItem : orderItems) {
            order.addOrderItem(orderItem);
        }

        return order;
    }

    // 연관관계 편의 메서드
    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

    // 비즈니스 메서드
    public Money calculateTotalPrice() {
        return orderItems.stream()
                .map(OrderItem::getTotalPrice)
                .reduce(new Money(0), Money::add);
    }

    public void cancel() {
        if (delivery.getStatus() == DeliveryStatus.COMP) {
            throw new IllegalStateException("이미 배송완료된 주문은 취소가 불가능합니다.");
        }

        this.status = OrderStatus.CANCEL;
        for (OrderItem orderItem : orderItems) {
            orderItem.cancel();
        }
    }
}

// 주문상품 엔티티
@Entity
@Table(name = "order_item")
public class OrderItem extends BaseEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @Embedded
    @AttributeOverride(name = "amount", column = @Column(name = "order_price"))
    private Money orderPrice;

    private int count;

    // 생성 메서드
    public static OrderItem createOrderItem(Item item, Money orderPrice, int count) {
        OrderItem orderItem = new OrderItem();
        orderItem.item = item;
        orderItem.orderPrice = orderPrice;
        orderItem.count = count;

        item.removeStock(count);
        return orderItem;
    }

    // 비즈니스 메서드
    public void cancel() {
        getItem().addStock(count);
    }

    public Money getTotalPrice() {
        return orderPrice.multiply(count);
    }
}

6.3. 서비스 레이어 예시

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {

    private final EntityManager em;
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;

    // 주문 생성
    @Transactional
    public Long order(Long memberId, Long itemId, int count,
                     LocalDateTime requiredDate) {

        // 엔티티 조회
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new EntityNotFoundException("회원을 찾을 수 없습니다."));

        Item item = itemRepository.findById(itemId)
                .orElseThrow(() -> new EntityNotFoundException("상품을 찾을 수 없습니다."));

        // 배송 정보 생성
        Delivery delivery = new Delivery();
        delivery.setAddress(member.getAddress());  // 값 타입 사용
        delivery.setStatus(DeliveryStatus.READY);

        // 주문 기간 설정 (값 타입 사용)
        DateTimeRange orderPeriod = new DateTimeRange(
            LocalDateTime.now(),
            requiredDate
        );

        // 주문상품 생성 (값 타입 사용)
        Money orderPrice = item.getPrice();
        OrderItem orderItem = OrderItem.createOrderItem(item, orderPrice, count);

        // 주문 생성
        Order order = Order.createOrder(member, delivery, orderPeriod, orderItem);

        em.persist(order);
        return order.getId();
    }

    // 주문 조회 (값 타입 활용)
    public OrderDetailDto getOrderDetail(Long orderId) {
        Order order = em.find(Order.class, orderId);

        return OrderDetailDto.builder()
                .orderId(order.getId())
                .memberName(order.getMember().getName())
                .deliveryAddress(order.getDelivery().getAddress().getFullAddress())  // 값 타입 메서드
                .orderDate(order.getOrderPeriod().getStartDateTime())
                .requiredDate(order.getOrderPeriod().getEndDateTime())
                .totalPrice(order.calculateTotalPrice().toString())  // 값 타입 toString()
                .status(order.getStatus())
                .build();
    }

    // 주소 변경
    @Transactional
    public void updateMemberAddress(Long memberId, Address newAddress) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new EntityNotFoundException("회원을 찾을 수 없습니다."));

        // 값 타입은 불변이므로 완전히 새로운 객체 할당
        member.updateAddress(newAddress);
    }

    // 가격 범위로 상품 검색 (값 타입 비교)
    public List<Item> findItemsByPriceRange(Money minPrice, Money maxPrice) {
        return em.createQuery(
            "SELECT i FROM Item i " +
            "WHERE i.price.amount BETWEEN :minAmount AND :maxAmount " +
            "AND i.price.currency = :currency", Item.class)
            .setParameter("minAmount", minPrice.getAmount())
            .setParameter("maxAmount", maxPrice.getAmount())
            .setParameter("currency", minPrice.getCurrency())
            .getResultList();
    }
}

7. 정리

7.1. 엔티티 타입 vs 값 타입

특성 엔티티 타입 값 타입
식별자 있음 없음
생명주기 독립적 관리 엔티티에 의존
공유 가능 (참조) 불가능 (복사 사용)
동등성 동일성 비교 (==) 동등성 비교 (equals())
변경 추적 가능 불가능
사용 기준 지속적 추적 필요 단순 값 표현

7.2. 값 타입 사용 원칙

  1. 식별자가 필요하고 지속 추적이 필요한 것은 엔티티로
  2. 단순 값 표현이 목적이면 값 타입으로
  3. 값 타입은 불변 객체로 설계
  4. 동등성 비교를 위해 equals(), hashCode() 재정의
  5. 값 타입 컬렉션보다는 일대다 관계 고려

7.3. 값 타입 선택 기준 체크리스트

값 타입으로 적합한 경우 (✅)

  • 여러 속성을 하나의 개념으로 묶을 때 (주소, 금액, 기간 등)
  • 해당 데이터에 의미 있는 메서드를 추가할 수 있을 때
  • 데이터를 불변으로 관리해도 문제없을 때
  • 여러 엔티티에서 동일한 구조로 사용될 때

엔티티로 설계해야 하는 경우 (❌)

  • 독자적인 식별자가 필요할 때
  • 변경 이력을 추적해야 할 때
  • 여러 곳에서 공유되어야 할 때
  • 복잡한 비즈니스 로직을 포함할 때

값 타입은 객체지향 설계의 중요한 요소로, 적절히 사용하면 도메인의 의도를 명확히 표현하고 코드의 재사용성과 유지보수성을 크게 향상시킬 수 있다. 그러나 값 타입과 엔티티의 경계를 명확히 이해하고, 상황에 맞는 선택이 필요하다.

 

'Spring > JPA' 카테고리의 다른 글

[Advanced-1] Spring Data Jpa  (0) 2026.01.06
[Basic-9] 객체지향 쿼리 언어(JPQL)  (0) 2026.01.06
[Basic-7] 프록시와 연관관계 관리  (0) 2026.01.06
[Basic-6] 고급 관계 매핑  (0) 2026.01.06
[Basic-5] 연관관계 매핑 심화  (0) 2026.01.06
'Spring/JPA' 카테고리의 다른 글
  • [Advanced-1] Spring Data Jpa
  • [Basic-9] 객체지향 쿼리 언어(JPQL)
  • [Basic-7] 프록시와 연관관계 관리
  • [Basic-6] 고급 관계 매핑
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Basic-8] 값 타입
상단으로

티스토리툴바