자바 백엔드 입문 47편 — JPA @Embedded @Embeddable 값 객체

2026-05-17자바 백엔드 입문

자바 백엔드 입문 47편. JPA @Embedded @Embeddable로 도메인 값 객체를 한 테이블 안에 묶는 표준 패턴을 주소 카드 비유로 풀어쓴 학습 노트.

📚 자바 백엔드 입문 · 47편 — JPA @Embedded @Embeddable 값 객체

이 글은 자바 백엔드 입문 시리즈 59편 중 47편이에요. 45편 Entity·Repository 에서 만든 엔티티의 "필드 묶음" — 주소·금액·기간 같은 값 객체를 깔끔하게 다루는 JPA @Embedded · @Embeddable 를 풀어 가요.

흩어진 필드들의 함정

User 엔티티에 "집 주소" 가 필요해요. 가장 직관적:

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

    private String name;

    // 주소 4필드
    private String zipCode;
    private String city;
    private String street;
    private String detail;
}

문제: - 주소 관련 4필드가 User 안에 흩어짐 — "주소만 다루는 로직" 위치 불명 - 회사 주소·배송 주소까지 추가되면 — 12필드 (4 × 3) - 주소 검증·포맷팅 코드가 User 안에 박혀 "단일 책임" 깨짐 - 주소 자체가 "무엇인지" 가 코드에 표현 안 됨

해결 = 값 객체(Value Object) 패턴. 주소를 한 개념으로 묶어 별도 클래스.

@Embeddable·@Embedded — 주소 카드 비유

@Embeddable = "한 테이블 안에 내장될 수 있는 값 묶음". 주소처럼 "여러 필드가 한 개념" 인 값을 표현.

비유 — User라는 큰 명함 안에 "주소 카드" 가 묶여 끼워져 있는 모양. 카드 자체는 독립적 식별자(ID) 없음 — 항상 User에 부속.

@Embeddable
public class Address {
    private String zipCode;
    private String city;
    private String street;
    private String detail;

    protected Address() { }    // JPA용

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

    public String fullAddress() {
        return "(" + zipCode + ") " + city + " " + street + " " + detail;
    }
}

엔티티 안에서 @Embedded 로 끼우기.

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

    private String name;

    @Embedded
    private Address address;       // 주소 객체 임베드
}

데이터베이스 테이블은 여전히 한 테이블.

CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    name VARCHAR(255),
    zip_code VARCHAR(20),         -- Address의 필드들
    city VARCHAR(100),
    street VARCHAR(255),
    detail VARCHAR(255)
);

JPA가 자동으로 — Address의 필드들을 users 테이블의 컬럼으로 펼침. 추가 테이블 X, JOIN X. 단일 테이블 안 깔끔한 묶음.

사용

User user = new User();
user.setName("홍길동");
user.setAddress(new Address("12345", "서울", "강남대로", "101호"));
userRepository.save(user);

// 조회
User found = userRepository.findById(1L).orElseThrow();
String fullAddr = found.getAddress().fullAddress();

도메인 메서드 (fullAddress()) 가 Address 안에 있어 — 일관된 책임. User는 "주소가 뭐든" 알 필요 X.

@AttributeOverride — 컬럼명 충돌 해결

User에 "집 주소" + "회사 주소" 두 개 박으면 — 컬럼명 충돌.

@Entity
public class User {
    @Embedded
    private Address homeAddress;

    @Embedded
    private Address workAddress;   // ❌ 같은 컬럼명 zip_code·city·street·detail 충돌
}

해결 — @AttributeOverride 로 컬럼명 재정의.

@Entity
public class User {
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "zipCode", column = @Column(name = "home_zip")),
        @AttributeOverride(name = "city",    column = @Column(name = "home_city")),
        @AttributeOverride(name = "street",  column = @Column(name = "home_street")),
        @AttributeOverride(name = "detail",  column = @Column(name = "home_detail"))
    })
    private Address homeAddress;

    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "zipCode", column = @Column(name = "work_zip")),
        @AttributeOverride(name = "city",    column = @Column(name = "work_city")),
        @AttributeOverride(name = "street",  column = @Column(name = "work_street")),
        @AttributeOverride(name = "detail",  column = @Column(name = "work_detail"))
    })
    private Address workAddress;
}

번거롭지만 명확. 동일 Embeddable을 여러 위치에 박는 표준 패턴.

자주 쓰는 값 객체 5가지

(1) Money — 금액

@Embeddable
public class Money {
    private long amount;
    private String currency;

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("통화 불일치");
        }
        return new Money(this.amount + other.amount, this.currency);
    }
}

@Entity
public class Order {
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "amount",   column = @Column(name = "total_amount")),
        @AttributeOverride(name = "currency", column = @Column(name = "total_currency"))
    })
    private Money totalPrice;
}

long + String 두 필드 흩뿌리기 vs Money 한 객체로 묶기 — 코드 일관성 천지 차이.

(2) Period — 기간

@Embeddable
public class Period {
    private LocalDateTime startAt;
    private LocalDateTime endAt;

    public boolean overlaps(Period other) { ... }
    public long durationDays() { ... }
}

이벤트·예약·구독 기간을 한 객체로.

(3) Email — 이메일

@Embeddable
public class Email {
    private String value;

    public Email(String value) {
        if (!value.matches(".+@.+\\..+")) {
            throw new IllegalArgumentException("이메일 형식 X");
        }
        this.value = value;
    }
}

타입 시스템에 "이건 이메일이다" 명시. 단순 String보다 안전.

(4) PhoneNumber — 전화번호

@Embeddable
public class PhoneNumber {
    private String value;

    public String formatted() {
        return value.replaceFirst("(\\d{3})(\\d{4})(\\d{4})", "$1-$2-$3");
    }
}

(5) Coordinate — 좌표

@Embeddable
public class Coordinate {
    private double latitude;
    private double longitude;

    public double distanceTo(Coordinate other) { ... }
}

값 객체의 핵심 룰 — 불변

값 객체는 불변(immutable) 으로 설계. 한번 생성하면 변경 X — 변경하려면 새 객체 생성.

@Embeddable
public class Money {
    private final long amount;        // final
    private final String currency;

    protected Money() {                // JPA용, final 필드 초기화 안 해도 됨 (리플렉션)
        this.amount = 0;
        this.currency = "KRW";
    }

    public Money(long amount, String currency) { ... }

    public Money add(Money other) {
        return new Money(this.amount + other.amount, this.currency);   // 새 객체
    }

    // setter 없음
}

이유 — "같은 값" 이면 항상 같은 의미여야 함. Money(1000, "KRW") 가 어디서든 "1000원" 이라는 보장. 변경 가능하면 — 한쪽에서 바꾸면 다른 곳에서 의미 깨짐.

equals·hashCode 구현 필수

값 객체는 값으로 비교. 두 Money가 같은가 = 두 객체의 amount·currency가 같은가.

@Embeddable
@Getter
@EqualsAndHashCode
public class Money {
    private long amount;
    private String currency;
}

Lombok @EqualsAndHashCode 한 줄. 모든 필드 기반 equals·hashCode 자동 생성.

값 객체 vs 엔티티 — 결정 룰

엔티티 값 객체
식별자 ID 있음 ID 없음
비교 ID로 동일성 모든 필드 값으로 동등성
라이프사이클 독립적 엔티티에 부속
변경 가변 불변
테이블 별도 테이블 엔티티 테이블에 임베드
User·Order·Product Address·Money·Period

룰 — "식별자가 의미 있나?" 가 핵심. User는 ID 1번과 2번이 다른 사람. Money는 "1000원" 객체가 두 개여도 같은 의미. 후자가 값 객체.

DDD와의 연결

값 객체 = 도메인 주도 설계(DDD) 의 핵심 빌딩 블록. "애그리거트 루트는 엔티티, 값은 값 객체" 가 원칙.

  • 애그리거트 루트 — User, Order, Product
  • 값 객체 — Address (User 안), Money (Order 안), Period (Subscription 안)

JPA @Embeddable 이 — 이 DDD 패턴을 자바로 표현하는 표준 도구.

함정 5가지

(1) 기본 생성자 누락

@Embeddable
public class Money {
    private final long amount;       // ❌ JPA가 리플렉션으로 생성 못함
    // 기본 생성자 없음
}

JPA가 객체 만들 때 매개변수 없는 생성자 필요. protected Money() {} 박기 (final이라도 — Hibernate가 리플렉션으로 우회).

(2) @Embedded 누락

@Entity
public class User {
    private Address address;     // ❌ @Embedded 없음 → JPA가 Address를 인식 못함
}

@Embedded 박지 않으면 — 임베드 안 됨 (또는 직렬화 오류). 명시 필수. (Hibernate는 자동 인식하기도 하지만 의존 X.)

(3) Setter 박기

@Embeddable
public class Money {
    @Setter
    private long amount;          // ❌ 가변 — 값 객체 의미 깨짐
}

값 객체는 불변. Setter 박는 순간 — 그냥 데이터 그릇이지 값 객체 아님.

(4) 컬렉션 안 값 객체 (@ElementCollection)

@Entity
public class User {
    @ElementCollection
    private List<Address> addresses;   // 별도 테이블 생성
}

이건 가능은 한데 — 별도 테이블 user_addresses 생성됨. Lazy Loading·N+1 문제 등 함정. 입문 단계에선 권장 X. 진짜 "여러 주소" 가 필요하면 별도 엔티티로.

(5) 비즈니스 로직 누락

@Embeddable
public class Money {
    private long amount;          // 그냥 데이터만
    private String currency;      // 로직 X
    // add·subtract·equals 메서드 없음
}

이러면 값 객체 의미 절반 사라짐. 도메인 메서드 (add·multiply·formatted 등) 를 박는 게 값 객체의 진짜 가치.

🎯 DDD 결과

엔티티(User)는 깔끔해지고 도메인 로직이 값 객체 안에 응집. order.getTotalPrice().add(...) 같은 표현이 자연스러워짐. 코드가 비즈니스 언어와 일치하는 한국 회사 백엔드 표준.

한 줄 정리 — @Embeddable·@Embedded = 값 객체(VO)를 한 테이블에 임베드. 주소·금액·기간을 깔끔하게 묶음. 불변·equals·도메인 메서드 박는 게 핵심. DDD 표준 빌딩 블록.

시험 직전 한 번 더 — @Embedded/@Embeddable 입문자가 매번 헷갈리는 것

  • @Embeddable = 다른 엔티티에 임베드되는 값 클래스
  • @Embedded = 엔티티에서 임베드 필드 표시
  • 한 테이블 안에 펼쳐짐 — 별도 테이블 X, JOIN X
  • 값 객체 = ID 없음, 모든 필드 값으로 동등성 비교
  • 불변 으로 설계 (final + setter 없음)
  • equals·hashCode 필수 — Lombok @EqualsAndHashCode 권장
  • 기본 생성자 필수 — protected XxxClass() { }
  • @AttributeOverride = 컬럼명 재정의 (같은 Embeddable 여러 위치 박을 때)
  • 자주 쓰는 값 객체 = Address·Money·Period·Email·PhoneNumber·Coordinate
  • 도메인 메서드 박기 = add·overlaps·formatted 등 (값 객체의 진짜 가치)
  • 엔티티 vs 값 객체 = ID 의미 있는가
  • DDD 핵심 빌딩 블록 = 애그리거트 루트(엔티티) + 값 객체
  • @ElementCollection = 값 객체 컬렉션 (별도 테이블 — 입문 단계 X)
  • Setter 박으면 값 객체 의미 깨짐
  • 통화 다른 Money 더하기 — 도메인 로직으로 막기 (add 안에 검증)
  • getAddress().fullAddress() 같은 표현 자연스러움
  • 한국 회사 표준 = 주소·금액·기간을 무조건 값 객체로
  • User·Order 엔티티가 깔끔해짐
  • 코드 = 비즈니스 언어 일치
  • Lombok + JPA + DDD 조합이 자바 백엔드 모던 표준

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!