Java 개발자가 JPA 를 쓸 때 이정도는 알고 쓰자 - 엔티티 기본 설계2
Java 와 Spring 프레임워크 생태계를 실무에서 사용하는 웹 백엔드 개발자들이 JPA 를 사용할때 종종하는 실수들이 있습니다.
백엔드 개발자라면 JPA 를 사용할 때 이정도는 알고 쓰자는 의미에서 글을 작성합니다.
JPA 구현체로는 Hibernate 를 사용하고 DB 는 MySQL 을 사용하는 것을 전제로 합니다.
값 객체
JPA 에서 사용하는 값 객체의 의미와 그 사용법에 대해 알기전에 참고 객체를 안짚을 수 없을 것 같습니다.
Java 에서는 참조 객체(Reference Object)라는 개념이 있습니다. 참조 객체는 객체가 메모리에 위치하는 그 주소자체를 참조하는 객체를 말합니다.
class Person {
String name;
}
public class Main {
public static void main(String[] args) {
Person p1 = new Person(); // 참조 변수 p1이 Person 객체를 참조
p1.name = "John";
Person p2 = p1; // p2도 같은 객체를 참조, 얕은 복사 방식(Shallow Copy)
System.out.println(p1.name); // John
System.out.println(p2.name); // John
p2.name = "Doe"; // p2가 수정하면 p1도 영향받음
System.out.println(p1.name); // Doe
doSomething(p1);
System.out.println(p2.name); // 프로그래머는 Doe 를 원했으나..Trump
}
private void doSomething(Person person) {
person.name = "Trump";
System.out.println(person.name); // Trump
}
}
간단한 예제 코드로 보자면 위와 같습니다. p1, p2 변수는 같은 메모리 주소를 바라보고 있는 참조객체입니다.
따라서 p2 객체를 수정하면 p1 객체에도 영향을 받습니다.
이러한 현상을 참조객체의 별칭(동일한 객체를 가리키는 여러 개의 참조 변수가 있는 경우)이라 하며, 만약 이러한 개념을 알지 못한다면, 또는 개념을 알더라도 프로그래머의 실수로 인해 버그를 양산할 수 있습니다.
참조객체의 별칭 문제를 피하기 위해, 깊은 복사(Deep Copy) 를 사용하기도 하지만, 값 객체를 활용한다면 손쉽게 문제를 피할 수 있습니다.
값 객체(Value Object)란 불변 객체로서, 한번 생성되면 변경되지 않으며 두 객체가 동일한 속성을 가지면 두 객체는 동일한 객체로 간주할 수 있는 객체를 말합니다.
이번에도 간단한 예제 코드로 보겠습니다.
import java.math.BigDecimal;
import java.util.Objects;
public final class Money {
private final BigDecimal amount;
private final String currency;
// 생성자
public Money(BigDecimal amount, String currency) {
if (amount == null || currency == null) {
throw new IllegalArgumentException("금액과 통화는 null일 수 없습니다.");
}
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("금액은 음수가 될 수 없습니다.");
}
this.amount = amount;
this.currency = currency;
}
// 금액 가져오기
public BigDecimal getAmount() {
return amount;
}
// 통화 가져오기
public String getCurrency() {
return currency;
}
// 더하기 (새로운 객체 반환)
public Money add(Money other) {
validateSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
// 빼기 (새로운 객체 반환)
public Money subtract(Money other) {
validateSameCurrency(other);
return new Money(this.amount.subtract(other.amount), this.currency);
}
// 곱하기 (새로운 객체 반환)
public Money multiply(BigDecimal multiplier) {
return new Money(this.amount.multiply(multiplier), this.currency);
}
// 동일한 통화인지 검증
private void validateSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("통화가 다르면 연산할 수 없습니다.");
}
}
// 값 비교 (동등성)
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Money money = (Money) obj;
return amount.equals(money.amount) && currency.equals(money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
@Override
public String toString() {
return amount + " " + currency;
}
}
add, subtract, multiply 를 보시면 아시겠지만 항상 새로운 객체를 반환하고 있습니다. 그리고 값 객체의 비교를 위해 equals 와 hashCode 를 재정의(Override) 한 코드를 보면 모든 속성이 같으면 같은 객체로 취급하게끔 하고 있습니다.
이것이 값 객체의 핵심입니다.
참조 객체와 다르게 값 객체는 불변성을 유지하므로 참조 객체의 별칭 문제를 방지 할 수 있게 됩니다.
JPA 에서의 값 객체
위에서 확인한 값객체의 특성은 아래와 같습니다.
- 속성 기반으로 객체 동등성 비교
- 불변 객체로 구현
- 참조 객체 별칭 문제 방지
이러한 특성 때문에 JPA 에서 값 객체는 유용하게 활용될 수 있습니다.
비슷한 속성들을 묶어 작은 단위의 속성 그룹화를 하는데 값 객체를 사용하면 좋습니다.
JPA 는 이를 구현할 수 있도록 @Embedded 와 @Embeddable 애노테이션을 제공합니다.
간단한 예제 코드를 보겠습니다.
@Embeddable // 값 객체 지정
public class Money {
private BigDecimal amount;
private String currency;
protected Money() {} // JPA용 기본 생성자 (lombok 사용해도 무관)
public Money(BigDecimal amount, String currency) {
if (amount == null || currency == null) {
throw new IllegalArgumentException("금액과 통화는 null일 수 없습니다.");
}
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("금액은 음수가 될 수 없습니다.");
}
this.amount = amount;
this.currency = currency;
}
public BigDecimal getAmount() {
return amount;
}
public String getCurrency() {
return currency;
}
// 더하기 (새로운 객체 반환)
public Money add(Money other) {
validateSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
// 빼기 (새로운 객체 반환)
public Money subtract(Money other) {
validateSameCurrency(other);
return new Money(this.amount.subtract(other.amount), this.currency);
}
// 곱하기 (새로운 객체 반환)
public Money multiply(BigDecimal multiplier) {
return new Money(this.amount.multiply(multiplier), this.currency);
}
// 동일한 통화인지 검증
private void validateSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("통화가 다르면 연산할 수 없습니다.");
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money money = (Money) o;
return amount.equals(money.amount) && currency.equals(money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}
위와 같이 값 객체가 될 Money 클래스를 구현하였습니다.
이제 Money 값 객체를 사용하는 엔티티 예제 코드를 보겠습니다.
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Embedded // Money 값 객체 사용
private Money price;
protected Product() {} // JPA 기본 생성자
public Product(String name, Money price) {
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public Money getPrice() {
return price;
}
}
@Embedded 를 사용하여 값 객체를 사용하게 구현하였고, 불변 객체인 Money 값 객체를 사용한 price 필드는 참조 객체의 별칭 문제에서 자유로울 수 있어 버그 발생의 가능성이 없습니다.
JPA 값 객체의 DDL 과 생명주기
CREATE TABLE Product (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255),
amount DECIMAL(19,2), -- Money.amount
currency VARCHAR(3) -- Money.currency
);
Money 값 객체를 사용한 Product 는 위와 같은 DDL 로 표현할 수 있습니다.
별도의 테이블이 아닌 Product 엔티티의 내부에 포함되는 것을 볼 수 있죠.
즉, JPA 에서의 값 객체는 독립적인 생명주기를 갖지 않고, 엔티티의 생명주기에 포함되어 집니다.
따라서 엔티티와 함께 저장/삭제되는 객체입니다.
JPA 값 객체 - @ElementCollection
@ElementCollection 을 이용해, 엔티티 1: 값 객체 N 의 관계로 구현하는 방법도 있습니다.
다만, 이 방식은 별도의 테이블을 생성하여 Insert 하며, 값 객체 컬렉션 중 변경사항이 발생하면, 엔티티와 연관된 모든 컬렉션 데이터를 지우고 다시 Insert 하는 동작 방식을 가지고 있어 값 객체 컬렉션의 변경사항이 자주 발생한다면, 성능 이슈가 발생할 가능성이 커 사용하지 않는 것이 좋습니다.
가급적이면 엔티티 1 : 엔티티 N 의 매핑으로 구현하기를 추천드립니다.
JPA 값 객체를 사용 시 주의점 및 결론
주의점
- 값 객체는 반드시 불변(Immutable)으로 유지해야 합니다
- 필드에 final을 사용할 수 없지만, setter를 만들지 않고 생성자로 값을 설정해야 합니다.
- @Embeddable은 JPA가 리플렉션을 사용할 수 있도록 기본 생성자 필요합니다 (protected로 설정).
- equals() & hashCode() 재정의
- 값 객체는 동등성(Equality)을 비교해야 하므로, 반드시 equals()와 hashCode()를 재정의해야 합니다.
결론
JPA에서 값 객체를 사용하면 아래와 같은 이점이 있으니 반드시 사용할 것을 권장드립니다.
- 객체지향적인 설계 가능 → 공통점이 있는 속성들을 묶어, 도메인 로직을 값 객체로 분리 가능.
- 데이터 일관성 유지 → 엔티티와 함께 저장/삭제되므로 독립적인 생명 주기를 가질 필요가 없는 경우 적합.
- 불변성을 유지하여 안정적인 데이터 처리 가능.
JPA에서 @Embeddable을 활용하여 Money, Address, Name 등 공통점이 있는 속성들을 묶어 값을 값 타입으로 만들면
불변성을 유지하면서도 엔티티 내에서 안전하게 공유할 수 있습니다.