자바 백엔드 입문 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 등) 를 박는 게 값 객체의 진짜 가치.
엔티티(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 조합이 자바 백엔드 모던 표준
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 42편 — JdbcTemplate으로 SQL 다루기
- 43편 — @Transactional의 원리
- 44편 — JPA Hibernate Spring Data JPA 셋의 관계
- 45편 — @Entity Repository JPA 두 축
- 46편 — JPA 연관관계 @OneToMany @ManyToOne
다음 글: