JPA를 공부할 때 엔티티와 값 타입을 “식별자가 있느냐”로만 구분하면 설계 판단이 흐려진다. 더 실용적인 질문은 이 객체를 독립적으로 추적해야 하는지, 아니면 소유 엔티티의 한 속성으로 통째로 다룰지다.
이 글은 기존 강의 메모의 @Embeddable, 불변 객체, 동등성, @ElementCollection 내용을 다시 묶고, JPA 표준이 보장하는 부분과 Hibernate의 SQL 동작을 분리해 정리한다.
엔티티와 값 타입의 차이
| 구분 | 엔티티 | 값 타입·embeddable |
|---|---|---|
| 지속 식별자 | 자체 primary key로 추적 | 소유자와 독립된 persistent identity가 없음 |
| 생명주기 | 별도로 생성·조회·삭제 가능 | 소유 엔티티의 상태 일부로 관리 |
| 공유 | 여러 객체가 같은 엔티티를 참조할 수 있음 | 같은 인스턴스를 여러 managed entity가 공유하면 의미가 불명확해짐 |
| 변경 이력 | 독립적으로 추적하기 적합 | 보통 새 값으로 교체하는 모델이 단순함 |
| 예 | Member, Order, Product |
Money, Address, 좌표, 기간 |
Jakarta Persistence 3.2 명세는 embeddable을 소유 엔티티의 본질적인 일부로 저장되고 그 엔티티의 identity를 공유하는 타입으로 설명한다. “클래스 수가 테이블 수보다 많다”는 결과보다, 도메인에서 독립 identity가 필요한지를 먼저 봐야 한다.
@Embeddable은 여러 컬럼을 하나의 의미로 묶는다
주소의 도시와 도로명을 Member에 문자열 두 개로 흩어 놓는 대신 Address라는 값으로 묶을 수 있다.
@Embeddable
public class Address {
@Column(name = "city", nullable = false)
private String city;
@Column(name = "street", nullable = false)
private String street;
protected Address() {
}
public Address(String city, String street) {
this.city = Objects.requireNonNull(city);
this.street = Objects.requireNonNull(street);
}
public Address moveTo(String newCity, String newStreet) {
return new Address(newCity, newStreet);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Address other)) return false;
return city.equals(other.city) && street.equals(other.street);
}
@Override
public int hashCode() {
return Objects.hash(city, street);
}
}
소유 엔티티에서는 다음처럼 사용한다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Embedded
private Address homeAddress;
public void moveHome(Address newAddress) {
this.homeAddress = Objects.requireNonNull(newAddress);
}
}
Address 객체를 넣었다고 새 주소 테이블이 자동으로 생기는 것은 아니다. 기본적으로 embeddable의 필드가 소유 엔티티 테이블의 컬럼으로 펼쳐진다. 같은 타입을 한 엔티티에 두 번 쓰면서 컬럼명이 겹치면 @AttributeOverride로 매핑을 나눈다.
@Embedded
private Address homeAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "work_city")),
@AttributeOverride(name = "street", column = @Column(name = "work_street"))
})
private Address workAddress;
불변으로 다루면 공유 부작용을 줄일 수 있다
Java 객체 변수는 객체 자체가 아니라 참조값을 가진다.
Address shared = new Address("Seoul", "Teheran-ro");
memberA.moveHome(shared);
memberB.moveHome(shared);
Address에 setter가 있고 내부 상태를 바꾼다면 두 소유자가 같은 인스턴스를 보던 코드에서 의도하지 않은 변화가 번질 수 있다. JPA 표준이 모든 value object를 강제로 불변으로 만들라고 요구하는 것은 아니지만, 도메인 값은 생성 시 검증하고 변경 대신 새 값으로 교체하는 방식이 안전한 기본값이다.
다만 불변 설계와 ORM 생성 규칙은 함께 확인해야 한다. 접근 방식, 생성자 가시성, record 지원 여부는 JPA 버전과 provider에 따라 다를 수 있다. 예제처럼 일반 클래스와 field access를 쓸 때도 실제 프로젝트의 Jakarta Persistence·Hibernate 버전으로 테스트한다. 생성자 규칙은 JPA 엔티티 기본 생성자 정리와 연결해서 볼 수 있다.
equals와 hashCode는 값의 의미를 반영한다
==는 같은 객체 참조인지 비교하고, equals는 구현한 값의 동등성을 비교한다. 값 타입을 Set의 원소나 Map의 키로 사용한다면 equals와 hashCode가 같은 필드 집합을 사용해야 한다.
주의할 점은 hash 기반 collection에 들어간 뒤 동등성에 쓰는 필드를 바꾸지 않는 것이다. hash bucket이 달라져 contains나 remove가 실패할 수 있다. 불변 값이 이 문제를 줄여 준다.
모든 필드를 기계적으로 equals에 넣는 것도 답은 아니다. 값의 의미를 결정하는 필드가 무엇인지 도메인에서 정하고, 기술 필드나 표시용 값이 동등성에 들어가야 하는지 검토한다.
@ElementCollection은 값의 모음을 소유자에게 붙인다
기본 타입이나 embeddable의 collection은 @ElementCollection으로 매핑할 수 있다.
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "member_address",
joinColumns = @JoinColumn(name = "member_id")
)
private Set<Address> addresses = new HashSet<>();
Jakarta Persistence 3.2의 ElementCollection 정의는 원소가 basic type 또는 embeddable이라고 규정한다. collection table은 소유자의 key와 원소 컬럼을 저장한다. 원소 자체에는 엔티티처럼 독립된 persistent identity가 없다.
여기에는 몇 가지 오해가 잦다.
cascade = ALL이나orphanRemoval = true를 붙이는 매핑이 아니다. 값 collection의 생명주기는 소유자의 일부로 관리된다.- 기본 fetch가
LAZY이지만, JPA에서 LAZY는 provider에 대한 hint다. 쿼리 수와 로딩 시점은 실제 SQL로 확인한다. @OrderColumn은List의 순서를 저장하는 컬럼이지, 모든 변경 성능 문제를 해결하는 장치가 아니다.- collection의 모든 컬럼을 묶어 primary key로 만들어야 한다는 규칙은 JPA 표준의 보편적 요구가 아니다. 제약조건은 데이터 의미와 provider가 생성한 schema를 확인해 명시한다.
“항상 전체 삭제 후 재삽입”도 표준 규칙은 아니다
기존 메모에는 값 타입 collection이 변경되면 소유자와 연결된 모든 행을 삭제하고 현재 값을 다시 저장한다고 적혀 있었다. 이는 특정 provider와 collection semantics에서 실제로 볼 수 있는 SQL이지만, 모든 JPA 구현과 모든 List·Set 매핑의 표준 동작은 아니다.
Hibernate ORM User Guide는 값 collection의 데이터베이스 작업이 매핑 semantics에 따라 달라진다고 설명한다. bag처럼 행 하나를 안정적으로 식별하기 어려운 매핑에서는 전체 삭제와 재삽입이 나올 수 있다. 반면 다른 collection 유형과 제약에서는 SQL 형태가 달라질 수 있다.
따라서 성능 판단은 다음 순서가 좋다.
- 실제 추가·삭제·교체 use case를 테스트로 만든다.
- SQL logging이나 datasource proxy로 발생한 문장을 확인한다.
- collection 크기와 변경 빈도, batch 설정을 함께 측정한다.
- 독립 identity가 필요한지 다시 검토한다.
언제 엔티티로 승격할까
다음 질문 중 하나라도 중요하면 @ElementCollection보다 별도 엔티티와 관계 매핑이 자연스러울 수 있다.
- 각 원소를 독립적으로 조회하거나 수정해야 하는가?
- 원소마다 생성일·상태·작성자·감사 이력을 남겨야 하는가?
- 다른 엔티티가 같은 원소를 참조해야 하는가?
- 부분 update와 개별 delete가 빈번한가?
- 원소가 많고 pagination이 필요한가?
- 원소 하나를 API resource로 식별해야 하는가?
예를 들어 “현재 배송지”는 주문에 포함된 값일 수 있지만, 사용자의 “주소록 항목”은 이름과 상태, 사용 이력, 독립 수정이 필요해 엔티티가 될 수 있다. 주소라는 단어가 같아도 모델의 역할은 다르다.
JPA 학습 순서를 전체적으로 잡으려면 JPA 공부 개요에서 entity lifecycle과 association을 함께 보는 편이 좋다.
설계 체크리스트
- 독립 identity와 생명주기가 필요한가?
- 값의 유효성 검증을 생성 시점에 모았는가?
- 변경 대신 새 값으로 교체할 수 있는가?
equals와hashCode가 같은 의미 필드를 사용하는가?- collection 종류가 중복·순서 요구와 맞는가?
- provider가 만든 schema와 실제 변경 SQL을 확인했는가?
- 크기와 변경 빈도가 커질 때 별도 엔티티가 더 적합한가?
값 타입은 “작아서 값”이 아니라 소유자와 분리해 추적할 이유가 없어서 값이다. 이 경계를 먼저 정하면 @Embeddable과 @ElementCollection은 매핑 기술이 아니라 도메인 의도를 드러내는 도구가 된다.
참고 자료
'배움과 성장 > 백엔드·데이터' 카테고리의 다른 글
| MongoDB macOS 설치와 로컬 실행: Homebrew·mongosh·보안 확인 (0) | 2023.01.03 |
|---|---|
| JPA 영속성 컨텍스트: 엔티티 상태·flush·commit 구분하기 (0) | 2022.12.28 |
| JPA 프록시·지연 로딩·cascade·orphanRemoval: 헷갈리는 경계 (0) | 2022.12.28 |
| JPA 공부 순서: ORM·영속성 컨텍스트부터 Spring Data JPA까지 (0) | 2022.12.28 |
| MySQL 버전 선택 기준: 5.7·8.0 유지 단계와 8.4·9.7 LTS (0) | 2022.12.03 |
댓글