1. 기본값 타입
1.1. JPA 데이터 타입 분류
JPA의 데이터 타입은 크게 엔티티 타입과 값 타입으로 구분된다.
| 구분 | 엔티티 타입 | 값 타입 |
| 정의 | @Entity로 정의하는 객체 | 단순 값으로 사용하는 타입 |
| 식별자 | 식별자 존재 | 식별자 없음 |
| 생명주기 | 독립적 관리 | 엔티티에 의존적 |
| 변경 추적 | 가능 | 불가능 |
| 공유 | 가능 | 불가능 (복사 사용) |
| 예시 | Member, Order | String name, int age |
1.2. 값 타입 분류
값 타입은 세 가지로 세분화된다:
- 기본값 타입
- 자바 기본 타입: int, double, boolean
- 래퍼 클래스: Integer, Long, Double
- String
- 임베디드 타입 (복합 값 타입)
- 사용자 정의 값 타입
- @Embeddable로 정의
- 컬렉션 값 타입
- 값 타입을 컬렉션으로 사용
- @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. 임베디드 타입의 장점
- 재사용성: 여러 엔티티에서 동일한 값 타입 사용 가능
- 높은 응집도: 관련된 데이터를 하나의 객체로 묶음
- 의미 있는 메서드: 값 타입에 비즈니스 메서드 추가 가능
- 객체지향적 설계: 테이블 중심에서 객체 중심으로 전환
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. 값 타입 컬렉션의 제약사항
- 식별자 개념 없음: 값 변경 시 추적 어려움
- 변경 시 전체 삭제 후 재저장: 성능 문제 가능성
- 복합키 구성 필요: 모든 컬럼을 묶어서 기본키로 구성
- null 입력 불가: 복합키 특성상 null 불가
- 중복 저장 불가: 복합키 특성상 중복 불가
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. 값 타입 사용 원칙
- 식별자가 필요하고 지속 추적이 필요한 것은 엔티티로
- 단순 값 표현이 목적이면 값 타입으로
- 값 타입은 불변 객체로 설계
- 동등성 비교를 위해 equals(), hashCode() 재정의
- 값 타입 컬렉션보다는 일대다 관계 고려
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 |
