JPA 값 타입 정리: @Embeddable과 @ElementCollection의 경계

반응형

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의 키로 사용한다면 equalshashCode가 같은 필드 집합을 사용해야 한다.

주의할 점은 hash 기반 collection에 들어간 뒤 동등성에 쓰는 필드를 바꾸지 않는 것이다. hash bucket이 달라져 containsremove가 실패할 수 있다. 불변 값이 이 문제를 줄여 준다.

모든 필드를 기계적으로 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로 확인한다.
  • @OrderColumnList의 순서를 저장하는 컬럼이지, 모든 변경 성능 문제를 해결하는 장치가 아니다.
  • collection의 모든 컬럼을 묶어 primary key로 만들어야 한다는 규칙은 JPA 표준의 보편적 요구가 아니다. 제약조건은 데이터 의미와 provider가 생성한 schema를 확인해 명시한다.

“항상 전체 삭제 후 재삽입”도 표준 규칙은 아니다

기존 메모에는 값 타입 collection이 변경되면 소유자와 연결된 모든 행을 삭제하고 현재 값을 다시 저장한다고 적혀 있었다. 이는 특정 provider와 collection semantics에서 실제로 볼 수 있는 SQL이지만, 모든 JPA 구현과 모든 List·Set 매핑의 표준 동작은 아니다.

Hibernate ORM User Guide는 값 collection의 데이터베이스 작업이 매핑 semantics에 따라 달라진다고 설명한다. bag처럼 행 하나를 안정적으로 식별하기 어려운 매핑에서는 전체 삭제와 재삽입이 나올 수 있다. 반면 다른 collection 유형과 제약에서는 SQL 형태가 달라질 수 있다.

따라서 성능 판단은 다음 순서가 좋다.

  1. 실제 추가·삭제·교체 use case를 테스트로 만든다.
  2. SQL logging이나 datasource proxy로 발생한 문장을 확인한다.
  3. collection 크기와 변경 빈도, batch 설정을 함께 측정한다.
  4. 독립 identity가 필요한지 다시 검토한다.

언제 엔티티로 승격할까

다음 질문 중 하나라도 중요하면 @ElementCollection보다 별도 엔티티와 관계 매핑이 자연스러울 수 있다.

  • 각 원소를 독립적으로 조회하거나 수정해야 하는가?
  • 원소마다 생성일·상태·작성자·감사 이력을 남겨야 하는가?
  • 다른 엔티티가 같은 원소를 참조해야 하는가?
  • 부분 update와 개별 delete가 빈번한가?
  • 원소가 많고 pagination이 필요한가?
  • 원소 하나를 API resource로 식별해야 하는가?

예를 들어 “현재 배송지”는 주문에 포함된 값일 수 있지만, 사용자의 “주소록 항목”은 이름과 상태, 사용 이력, 독립 수정이 필요해 엔티티가 될 수 있다. 주소라는 단어가 같아도 모델의 역할은 다르다.

JPA 학습 순서를 전체적으로 잡으려면 JPA 공부 개요에서 entity lifecycle과 association을 함께 보는 편이 좋다.

설계 체크리스트

  1. 독립 identity와 생명주기가 필요한가?
  2. 값의 유효성 검증을 생성 시점에 모았는가?
  3. 변경 대신 새 값으로 교체할 수 있는가?
  4. equalshashCode가 같은 의미 필드를 사용하는가?
  5. collection 종류가 중복·순서 요구와 맞는가?
  6. provider가 만든 schema와 실제 변경 SQL을 확인했는가?
  7. 크기와 변경 빈도가 커질 때 별도 엔티티가 더 적합한가?

값 타입은 “작아서 값”이 아니라 소유자와 분리해 추적할 이유가 없어서 값이다. 이 경계를 먼저 정하면 @Embeddable@ElementCollection은 매핑 기술이 아니라 도메인 의도를 드러내는 도구가 된다.

참고 자료

반응형
KEEP READING
카테고리 전체 보기 →

댓글