← Posts

Value Object · Design Pattern · Spring

Entity와 Value Object

무엇으로 같음을 판단하나. 식별자로 같은 것과 값이 같으면 같은 것, 그 둘을 갈라 쓰는 이유.

목차
  1. 같은 주소인데 다른 주소다
  2. 두 종류가 있다
  3. 값이면 바뀌지 않는다
  4. 감싸면 무엇이 좋은가
  5. 원시 타입에 붙들리면
  6. Entity의 같음은 어디서 오나
  7. JPA에서는 어떻게 쓰나
  8. 어디까지 감쌀 것인가
  9. 정리

같은 주소인데 다른 주소다

배송지를 비교하는 코드를 짰다. 주문서에 적힌 주소와 회원이 저장해둔 주소가 같은지 보려는 것이다.

java
Address a = new Address("서울시 강남구 테헤란로 1", "06234");
Address b = new Address("서울시 강남구 테헤란로 1", "06234");

a.equals(b);   // false

글자 하나 안 다른데 다르다고 한다. 자바의 기본 equals같은 객체인지를 보기 때문이다. 내용이 아니라 정체를 본다.

그럼 이건 어떤가.

java
Member m1 = memberRepository.findById(1L);
Member m2 = memberRepository.findById(1L);

m1.getName();   // "김철수"
m2.getName();   // "김철수" - 이름을 "김영희"로 바꾸면?

회원이 이름을 바꿔도 같은 회원이다. 여기서는 내용이 달라져도 같다고 해야 한다. 주소와는 정반대다.

두 경우가 왜 반대인가. 같음을 판단하는 기준이 애초에 다르기 때문이다.

두 종류가 있다

도메인 모델을 이루는 객체는 크게 둘로 나뉜다. 무엇으로 같음을 판단하느냐가 가른다.

diagramdiagram

Entity는 식별자로 구별한다. 회원, 주문, 상품처럼 살아가며 상태가 변하는 것들이다. 이름이 바뀌고 등급이 바뀌어도 회원 1번은 회원 1번이다.

Value Object는 값 자체로 구별한다. 주소, 금액, 기간, 좌표처럼 무엇인지가 곧 값인 것들이다. “서울시 강남구 테헤란로 1”이라는 주소는 어디에 적혀 있든 같은 주소다. 이 주소에 “3번 주소”라는 번호를 붙일 이유가 없다.

가려내는 질문은 하나다. “값이 전부 같은데도 서로 다른 것이라고 해야 하나?”

  • 같은 이름·같은 생일인 회원 둘 - 다른 사람이다. 동명이인이 있다 → Entity
  • 같은 금액 1,000원 둘 - 같은 1,000원이다. 구별할 이유가 없다 → Value Object

값이면 바뀌지 않는다

Value Object에는 따라오는 성질이 하나 있다. 한 번 만들면 안 바꾼다.

1,000원짜리 금액 객체의 값을 2,000원으로 바꾼다고 생각해보자. 그건 이미 다른 금액이다. 값을 바꾼 게 아니라 다른 값이 된 것이므로, 새로 만드는 게 맞다.

java
public class Money {

    private final long amount;          // final - 만들고 나면 못 바꾼다

    public Money(long amount) {
        if (amount < 0) {
            throw new IllegalArgumentException("금액은 음수가 될 수 없다");
        }
        this.amount = amount;
    }

    /** 더한 결과를 '새로' 만들어 돌려준다. 자기 값은 그대로. */
    public Money plus(Money other) {
        return new Money(this.amount + other.amount);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money other)) return false;
        return amount == other.amount;   // 값으로 비교한다
    }

    @Override
    public int hashCode() {
        return Long.hashCode(amount);
    }
}

plus가 자기를 바꾸지 않고 새 객체를 돌려주는 것이 핵심이다. 그래서 이 객체는 여러 곳에서 공유해도 안전하다. 남이 들고 있는 값을 내가 바꿔버릴 수가 없다.

equalshashCode를 값 기준으로 재정의한 것도 필수다. 안 하면 맨 처음 봤던 그 문제가 그대로 남는다.

위 코드의 o instanceof Money other(꺼내면서 검사하기)도, 아래 recordJava 16부터 쓸 수 있다. record로 쓰면 훨씬 짧아진다. 필드가 자동으로 final이 되고 equals·hashCode도 값 기준으로 만들어진다. 불변과 값 비교라는 Value Object의 두 성질이 기본값인 문법이라 잘 맞는다.

java
public record Money(long amount) {
    public Money {                                   // 검증은 이 자리에
        if (amount < 0) throw new IllegalArgumentException();
    }
    public Money plus(Money other) { return new Money(amount + other.amount); }
}

감싸면 무엇이 좋은가

long으로 충분한 걸 왜 굳이 객체로 감싸나. 셋을 얻는다.

검증할 자리가 하나 생긴다. “금액은 음수가 될 수 없다”를 생성자에 한 번 적으면, 코드가 새로 만드는 금액은 전부 그 검사를 거친다. long이었다면 금액을 다루는 모든 자리에서 각자 검사해야 하고, 한 곳만 빠뜨려도 음수가 스며든다.

참고

“전부”에는 조건이 있다. 생성자 검증은 new로 만드는 길에만 걸린다. DB에서 읽어오거나 JSON을 객체로 바꿀 때는 도구가 기본 생성자로 만들고 필드에 값을 직접 넣는 경우가 많아 이 검사를 지나친다. 그래서 정말 음수가 없어야 하는 값은 DB 쪽에도 제약(CHECK)을 같이 건다. 객체의 검증은 들어오는 값을 거르는 것이지, 이미 저장된 값을 고쳐주지는 않는다.

타입이 의미를 말한다.

java
// 이 메서드는 무엇을 받는가?
void charge(long a, long b, long c);

// 이 메서드는 무엇을 받는가?
void charge(Money amount, Money discount, Money shipping);

앞은 주석을 읽어야 하고, 뒤는 이름과 타입이 이미 말한다.

틀린 조합을 컴파일러가 막는다.

java
long price = 10000;
long weight = 500;
long total = price + weight;      // 컴파일된다. 값이 완전히 틀렸는데

MoneyWeight로 나눠뒀다면 이 줄은 애초에 컴파일되지 않는다. 실행해봐야 알던 버그가 작성 시점에 막힌다.

원시 타입에 붙들리면

위 문제를 반대편에서 보면 이렇게 된다. 도메인의 개념을 전부 String·long·int로만 표현하는 것이다.

java
public class Order {
    private String email;          // 형식이 맞는지는 누가 보장하나
    private long amount;           // 원인가 달러인가, 음수여도 되나
    private String phone;          // "010-1234-5678"인가 "01012345678"인가
    private LocalDate from;        // from > to 인 상태를 막는 건 누구인가
    private LocalDate to;
}

각 필드가 지켜야 할 조건이 있는데, 그 조건이 코드 어디에도 없다. 그래서 이메일을 받는 자리마다 형식 검사를 다시 적고, 기간을 받는 자리마다 from <= to를 다시 검사한다. 앞 글에서 본 “같은 규칙이 흩어지고 미묘하게 달라진다”가 여기서도 똑같이 일어난다.

기간을 객체로 묶으면 이렇게 정리된다.

java
public record Period(LocalDate from, LocalDate to) {
    public Period {
        if (from.isAfter(to)) {
            throw new IllegalArgumentException("시작이 끝보다 늦다");
        }
    }

    public boolean contains(LocalDate date) {
        return !date.isBefore(from) && !date.isAfter(to);
    }

    public long days() {
        return ChronoUnit.DAYS.between(from, to) + 1;
    }
}

뒤집힌 기간은 만들어질 수 없고, “이 날짜가 기간 안인가”라는 질문의 답도 한 곳에 있다. 필드 두 개가 하나의 개념이었다는 사실이 코드에 드러난 것이다.

Entity의 같음은 어디서 오나

Value Object는 값이 같음을 정한다. 그럼 Entity는 무엇이 같음을 정하나. 식별자다.

java
public class Member {

    private Long id;             // 저장되기 전에는 비어 있다
    private String name;         // 바뀔 수 있다
    private Grade grade;         // 바뀔 수 있다

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Member other)) return false;
        return id != null && id.equals(other.id);   // 오직 id로만 판단한다
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();               // id에 기대지 않는다 - 아래 NOTE
    }
}

이름도 등급도 비교하지 않는다. id가 같으면 같은 회원이다. 이게 Entity의 정의다.

hashCode가 조금 이상해 보일 것이다. id를 안 쓰고 클래스 고정값을 돌려준다.

이유가 있다. 저장하기 전에는 id가 없기 때문이다. id가 없는 객체를 Set에 넣고 나서 저장하면 id가 생기는데, 그때 hashCode가 바뀐다. 해시 자료구조는 넣을 때의 값으로 자리를 잡아뒀으니, 방금 넣은 객체를 자기 자신으로 못 찾는 상태가 된다.

java
Set<Member> set = new HashSet<>();
set.add(member);        // 아직 id 없음
memberRepository.save(member);   // 여기서 id가 생긴다
set.contains(member);   // id 기반 hashCode였다면 false

그래서 Entity의 hashCode변하지 않는 것에 기대야 한다. 클래스 고정값을 쓰면 해시 성능은 포기하지만 계약은 안 깨진다. 어느 쪽이든 equals만 재정의하고 hashCode를 빼두면 안 된다 - 그러면 equals가 true인 둘이 Set에 나란히 들어간다.

그래서 Entity에는 조건이 하나 붙는다. 그 식별자가 안 바뀌어야 한다. 식별자가 바뀌면 같은 대상이 다른 것으로 취급되고, 그 순간 이 객체를 가리키던 모든 참조가 길을 잃는다.

이건 저장 계층에서 이미 만나본 문제다. DB 키 설계에서 “무엇으로 행을 가리킬 것인가”를 다뤘고, 결론은 의미가 있는 값(자연키)은 언젠가 바뀌므로 안 바뀌는 대리키를 쓰라는 것이었다.

여기서도 같은 판단이 반복된다. 층만 다르다. (그 글은 “대리키를 쓴다고 자연키를 버리는 건 아니다”까지 갔는데, 여기서 빌려오는 건 안 바뀌는 것으로 가리킨다는 한 줄이다.)

DB 키 설계도메인 모델
질문무엇으로 을 가리키나무엇으로 객체가 같음을 정하나
안 바뀌는 대리키안 바뀌는 식별자
어기면참조가 깨진다같은 대상이 달라진다

**“안 바뀌는 것으로 가리킨다”**는 원칙 하나가 저장과 모델 양쪽에서 같은 모양으로 나타나는 것이다.

JPA에서는 어떻게 쓰나

Value Object를 만들면 저장할 때 문제가 생길 것 같다. 테이블은 평평한 칼럼의 나열인데 객체는 안에 객체를 품고 있으니까.

JPA는 이걸 위한 자리를 준비해뒀다.

java
@Embeddable                                   // 이 객체는 남의 테이블에 얹힌다
public class Address {

    private String street;
    private String zipcode;

    protected Address() { }                   // JPA가 쓰는 빈 생성자 (아래 NOTE)

    public Address(String street, String zipcode) {
        // 검증은 여기에
        this.street = street;
        this.zipcode = zipcode;
    }

    // equals · hashCode는 값 기준으로
}
java
@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    @Embedded                                 // 별도 테이블이 아니라 이 테이블의 칼럼으로
    private Address address;
}
참고

이름이 겹쳐서 헷갈리기 쉬운데, @Entity와 이 글에서 말한 Entity는 다른 말이다. @Entity는 JPA에게 “이 클래스는 테이블에 매핑된다”고 알리는 표시일 뿐이다. @Entity를 붙였다고 그 클래스가 식별자로 같음을 판단하는 도메인 객체가 되는 것도 아니고, 반대로 @Entity 없이도 도메인 Entity일 수 있다. @Entity를 붙여놓고 안에 getter·setter만 있으면 그건 앞 글에서 본 빈약한 모델이다.

MEMBER 테이블에 street·zipcode 칼럼이 그대로 생긴다. 테이블은 평평한 채로 두고 객체만 묶은 것이다. 저장 구조를 안 바꾸고도 도메인의 개념을 드러낼 수 있다.

주의

여기서 record가 아니라 클래스로 돌아온 이유가 있다. JPA는 객체를 만들 때 인자 없는 생성자로 빈 객체를 만든 뒤 필드를 채우는 방식을 쓴다. 그런데 record는 인자 없는 생성자가 없고 필드도 못 바꾼다. 그래서 전통적인 매핑 방식에서는 record를 그대로 못 얹는다(요즘 버전에는 우회 수단이 생겼지만 설정이 따로 필요하다).

클래스로 둘 때도 인자 없는 생성자를 남겨둬야 한다. 위에서 protected Address()를 둔 게 그것이다. 생성자를 하나라도 직접 선언하면 자바가 만들어주던 빈 생성자가 사라지는데, 그 상태로 두면 JPA가 객체를 못 만든다. protected로 두면 JPA는 쓸 수 있고 우리 코드는 실수로 못 부른다.

주의

한 엔티티에 같은 Value Object를 둘 이상 넣으면 칼럼 이름이 부딪힌다(집 주소와 회사 주소를 둘 다 Address로 두는 경우). 이때는 @AttributeOverride로 칼럼 이름을 따로 지정한다. 칼럼이 겹치는 걸 빌드가 알려주지 않고 실행 시점에 터지는 경우가 있으니 두 개째를 넣을 때 확인한다.

그리고 이 대응은 ORM이 하는 일의 좋은 예이기도 하다. 객체 쪽 모양과 테이블 쪽 모양이 다를 때, 그 차이를 메우는 게 매핑의 역할이다.

어디까지 감쌀 것인가

값이란 값을 전부 객체로 만들면 클래스가 폭발한다. Name·Email·Phone·Age·Title·Description… 파일만 늘고 읽기는 더 어려워진다.

감쌀 값어치가 있는지는 이렇게 판단한다.

감싼다:

  • 지켜야 할 규칙이 있다 - 이메일 형식, 금액은 음수 불가, 기간은 순서가 맞아야 함
  • 여러 필드가 한 개념이다 - from/to, amount/currency, street/zipcode
  • 그 값으로 하는 계산이 있다 - 기간의 일수, 금액의 합계와 할인

안 감싼다:

  • 규칙도 계산도 없는 단순 문자열 - 게시글 제목, 메모, 설명
  • 그냥 통과만 하는 값

기준을 한 줄로 줄이면 **“이 값에 붙일 규칙이나 계산이 있는가”**다. 없으면 원시 타입으로 두는 게 낫다. 감싸는 것 자체가 목적이 되면 그것도 비용이다.

이 글의 Money도 실은 덜 감싼 예다. 금액만 들고 있고 통화가 없다. 한 나라 안에서만 파는 서비스라면 그걸로 충분하고, 여러 통화를 다루기 시작하면 Money(amount, currency)로 묶고 “다른 통화끼리는 더할 수 없다”를 plus에서 막게 된다. 어디까지 감쌀지는 도메인이 정한다 - 지금 필요 없는 것까지 미리 넣을 이유는 없다.

정리

가르는 질문값이 전부 같은데도 서로 다른 것이라고 해야 하나
Entity식별자로 같음을 정한다. 내용이 바뀌어도 같은 대상
Value Object값으로 같음을 정한다. 값이 다르면 다른 것
Value Object의 성질불변 + 값 기준 equals/hashCode. record가 잘 맞는다
감싸서 얻는 것검증이 한 곳에 · 타입이 의미를 말함 · 틀린 조합을 컴파일러가 막음
Entity의 조건식별자가 안 바뀌어야 한다 - DB 키 설계와 같은 원칙
JPA@Embeddable/@Embedded로 테이블은 평평한 채 객체만 묶는다
어디까지붙일 규칙이나 계산이 있으면 감싼다. 없으면 두는 게 낫다

모델의 재료를 둘로 갈라 봤다. 다음 글에서는 이것들이 어디까지 한 덩어리로 묶이는지를 본다.

관련 글