이 코드, true가 나올 것 같으신가요?
금액을 표현하는 작은 클래스를 하나 만들었다고 해봅시다. 금액이 같으면 두 객체를 같은 것으로 취급하고 싶어서 equals를 오버라이드했어요.
public class Money {
private final long amount;
public Money(long amount) { this.amount = amount; }
@Override
public boolean equals(Object o) {
return o instanceof Money m && this.amount == m.amount;
}
}
이제 Money를 HashSet에 넣고, 같은 금액이 들어있는지 물어봅니다.
Set<Money> wallet = new HashSet<>();
wallet.add(new Money(1000));
wallet.contains(new Money(1000)); // true일 것 같죠?
1000을 넣고 1000을 찾으니 당연히 true가 나와야 할 것 같습니다.
그런데 false가 나옵니다.
방금 넣은 값을, 같은 값으로 찾는데 못 찾아요. equals도 분명히 amount로 비교하게 고쳤는데 말이죠. 왜 이럴까요.
이 질문의 답을 따라가다 보면, 결국 “무엇을 같다고 볼 것인가” 라는 더 큰 질문에 닿습니다. 그리고 그 질문이 바로 값 객체(value object)와 엔티티(entity) 를 가르는 기준이에요.
범인은 빠뜨린 hashCode입니다
HashSet이 값을 어떻게 찾는지를 알면 답이 보입니다.
HashSet은 값을 넣을 때 통째로 한 줄로 늘어놓지 않습니다. 그러면 찾을 때마다 처음부터 전부 뒤져야 하니까요. 대신 서랍을 여러 개 두고, 값을 서랍에 나눠 담습니다. 그리고 어느 서랍에 넣을지는 그 객체의 hashCode()로 정해요.
hashCode가 뭔가요
hashCode()는 Object에 정의된 메서드로, 객체를 정수 하나로 요약한 값을 돌려줍니다. “이 객체는 몇 번 서랍” 하고 알려주는 번호표라고 보면 돼요.
HashSet(과 HashMap)은 이 번호표를 이렇게 씁니다.
hashCode()로 어느 서랍에 넣을지, 어느 서랍을 뒤질지 먼저 정한다- 그 서랍 안에서만
equals()로 진짜 같은 값인지 확인한다
전부 뒤지지 않고 서랍부터 좁힌 뒤, 그 안에서만 equals로 대조하는 겁니다. 빠른 대신, 번호표가 어긋나면 아예 다른 서랍을 뒤지게 된다는 함정이 있어요.
그래서 버그가 났다
위 Money는 equals만 오버라이드하고 hashCode는 건드리지 않았습니다. 그러면 hashCode는 Object의 기본 구현을 그대로 쓰는데, 이건 객체마다(인스턴스마다) 사실상 다른 값을 줍니다. 값이 같은지와는 무관해요.
그래서 이렇게 됩니다.
new Money(1000)을 넣을 때: 3번 서랍에 들어갔다고 합시다new Money(1000)으로 찾을 때: 이건 다른 인스턴스라hashCode가 달라, 예컨대 8번 서랍을 뒤진다- 8번 서랍엔 아무것도 없으니
equals는 불러보지도 못하고false
equals로는 분명 같은데, 서랍을 잘못 뒤져서 못 찾은 거예요.
이래서 자바에는 규칙이 하나 있습니다. Object의 javadoc에 이렇게 박혀 있어요.
equals 메서드로 같은 두 객체는, 각각 hashCode를 불렀을 때 반드시 같은 정수를 내야 한다.
그리고 같은 문서가 이렇게 덧붙입니다. “equals를 오버라이드할 때는 hashCode도 함께 오버라이드하는 것이 일반적으로 필요하다.” 둘은 항상 세트라는 뜻이에요.
사실 이건 자바에서 손꼽히게 흔한 실수예요. Effective Java가 아예 항목 하나(Item 11)를 “equals를 재정의하면 hashCode도 반드시 재정의하라”에 통째로 할애할 만큼요.
그러니 Money는 hashCode도 amount 기준으로 맞춰줘야 합니다.
public class Money {
private final long amount;
public Money(long amount) { this.amount = amount; }
@Override
public boolean equals(Object o) {
return o instanceof Money m && this.amount == m.amount;
}
@Override
public int hashCode() {
return Long.hashCode(amount); // equals와 같은 기준(amount)으로 번호표를 만든다
}
}
Long.hashCode(amount)는 “amount라는 값으로 번호표를 만들어라”라는 뜻입니다. 이제 값이 같은 두 Money는 같은 서랍으로 가고, contains도 true를 돌려줘요.
여기까지가 기계적인 규칙입니다. 그런데 한 걸음 물러서면 진짜 질문이 남아요. 애초에 Money는 왜 “값이 같으면 같다”로 정했을까요? 모든 객체를 그렇게 봐도 될까요?
무엇을 “같다”고 볼 것인가
답부터 말하면, 객체마다 다릅니다. 그리고 이 갈림에서 객체는 두 부류로 나뉘어요.
- 값 객체(value object): 담긴 값이 같으면 같은 것으로 본다
- 엔티티(entity): 값이 같아도 다른 것일 수 있고, 별도의 정체성(identity) 으로 구분한다
Martin Fowler는 이걸 “속성 값이 같아서 같다고 보는 객체를 값 객체라 부른다”라고 정리하면서, 객체를 값으로 구분하느냐, 정체성으로 구분하느냐로 나눴습니다. 이 분류가 우리 이야기의 뼈대예요.
값 객체, 값이 곧 정체성
Money(1000)을 생각해 보세요. 내 지갑의 1000원과 당신 지갑의 1000원은 같은 1000원입니다. “그 1000원”과 “이 1000원”을 굳이 구분할 이유가 없어요. 값이 같으면 그냥 같은 겁니다.
돈, 좌표, 날짜, 색깔 같은 게 그렇습니다. 이런 걸 값 객체라고 해요. 그래서 값 객체는 equals를 전체 필드로 비교합니다. 값이 전부니까요.
엔티티, 정체성이 값과 따로 논다
이번엔 은행 계좌입니다.
public class BankAccount {
private final String accountId; // 계좌를 구분하는 정체성
private final String owner;
private long balance; // 시간에 따라 변한다
}
잔액이 둘 다 1000원인 계좌 두 개가 있어도, 그건 다른 계좌예요. 하나는 내 계좌, 하나는 남의 계좌죠. 값이 우연히 같을 뿐입니다.
게다가 계좌는 상태가 변합니다. 입금하면 잔액이 100만원이 되지만, 그래도 “같은 계좌”예요. 어제의 내 계좌와 오늘의 내 계좌는 잔액이 달라도 같은 계좌죠.
값이 같아도 다를 수 있고, 값이 변해도 같을 수 있다. 그럼 무엇으로 구분하느냐. 바로 accountId 같은 식별자입니다. 이게 계좌의 정체성이에요. 이런 객체를 엔티티라고 합니다.
비유하면 이렇습니다. 사람은 엔티티예요. 몸무게가 바뀌고 개명을 해도, 주민등록번호라는 식별자로 이어지니 같은 사람입니다. 반면 1000원짜리 지폐는 값 객체에 가깝죠. 어느 1000원이든 그냥 1000원이지, “그 1000원”을 따지지 않으니까요.
흔한 오해 하나, 필드만 보면 값 객체인지 엔티티인지 알 수 있다
여기서 제가 처음에 걸렸던 지점을 그대로 옮겨 봅니다. 위 BankAccount에 accountId를 붙였는데, 만약 식별자가 없으면 그건 값 객체일까요?
아닙니다. 그리고 이게 핵심이에요. 필드 목록만 보고는 값 객체인지 엔티티인지 정할 수 없습니다.
값 객체냐 엔티티냐는 코드에서 읽어내는 게 아니라, 도메인에 질문을 던져서 정하는 설계 판단이거든요. 질문은 이거 하나예요.
값이 같은 둘을, 서로 다른 것으로 취급해야 하나?
“그렇다”면 엔티티입니다. 그러니 식별자를 줘야 하는 거예요. “값만 같으면 같은 걸로 봐도 된다”면 값 객체고요.
그래서 순서가 반대입니다. 식별자가 없어서 값 객체인 게 아니라, 엔티티라고 판단했으니 식별자를 붙여서 구분되게 만드는 겁니다. 판단이 먼저고, 식별자는 그 결과예요.
주민등록번호 칸이 비어 있는 서류라고 그게 “사람이 아닌 것”이 되진 않잖아요. 사람이라는 판단이 먼저고, 번호는 그래서 채워야 하는 칸입니다. 식별자를 빠뜨린 엔티티는 값 객체가 아니라, 그냥 덜 그려진 엔티티예요.
값 객체는 어떻게 짜나
값 객체라고 판단했다면, equals와 hashCode를 전체 필드 기준으로 짭니다. 값이 정체성이니까요. 앞의 Money가 그 예였습니다.
그런데 필드가 여러 개면 equals와 hashCode를 손으로 짜다 실수하기 쉬워요. 필드 하나를 빠뜨리거나, equals엔 넣고 hashCode엔 빠뜨리거나요. 그래서 Java 16부터 쓸 수 있는 레코드(record) 가 값 객체에 딱 맞습니다.
public record Money(long amount) { }
이 한 줄이 끝이에요. record는 equals와 hashCode를 모든 구성요소(필드) 기준으로 자동 생성합니다. Java 언어 명세(JLS 8.10.3)가 그렇게 규정하고 있어요. equals는 “모든 구성요소가 대응하는 구성요소와 같을 때만 true”, hashCode는 “모든 구성요소의 해시로부터 유도된 값”을 내도록요.
즉 값 객체가 필요로 하는 “전체 필드로 같음”이 문법 차원에서 보장됩니다. 게다가 record는 암묵적으로 final 이라(JLS 8.10) 상속으로 필드가 덧붙어 동일성이 흔들릴 일도 없어요.
트레이드오프: record는 순수한 데이터, 즉 값 객체에 맞습니다. 앞의 BankAccount처럼 상태가 변하는(balance가 바뀌는) 객체는 record로 만들 수 없어요. record의 필드는 불변이라 값이 안 바뀌거든요. 그러니 record는 값 객체의 도구지, 엔티티의 도구는 아닙니다.
값 객체는 어디에 쓰면 좋은가
값 객체가 특히 빛나는 자리가 있습니다. 원시 타입(primitive) 하나로 떠돌던 값을 감쌀 때예요.
전화번호를 그냥 String으로, 금액을 그냥 long으로 두면 어떻게 될까요. 형식이 틀린 번호나 음수 금액이 들어오는지 검사하는 코드가 여기저기 흩어집니다. 게다가 long 하나만 보고는 이게 금액인지 수량인지 구분할 수도 없죠.
이걸 Money 같은 작은 값 객체로 감싸면, 그 값이 지켜야 할 규칙(불변 조건)을 생성자 한 곳에 모을 수 있습니다.
public record Money(long amount) {
public Money {
if (amount < 0) throw new IllegalArgumentException("금액은 음수일 수 없습니다");
}
}
이제 음수 금액짜리 Money는 아예 만들어지지 않으니, Money를 받는 쪽은 값이 유효한지 다시 검사할 필요가 없어요. Effective Java도 “생성자 파라미터의 유효성을 검사하라”(Item 49)고 권합니다. (이렇게 검증을 생성자에 모으는 값 객체를 상속 공격으로부터 안전하게 만드는 방법은 생성자에서 예외를 던지는 글에서 더 다뤘어요.)
한 가지 더, 값 객체는 불변(immutable)으로 만드는 게 표준입니다(Effective Java Item 17). 불변이면 여러 곳에서 같은 인스턴스를 공유해도, 한쪽이 값을 바꿔 다른 쪽이 깨질 일이 없거든요. 그래서 앞서 본 record가 값 객체에 잘 맞아요. record는 필드를 불변으로 못 박으니까요.
다만 모든 값을 다 감싸라는 뜻은 아닙니다. 검증할 규칙이나 구분해야 할 의미가 있는 값에 쓰는 거예요. 아무 의미 없는 값까지 감싸면 코드만 늘어납니다.
흔한 오해 둘, 엔티티는 @Id를 붙이면 equals를 알아서 해준다
이제 엔티티 차례입니다. 그런데 JPA로 엔티티를 만들어 본 분이라면 이런 의문이 들 거예요.
“엔티티
equals를 손으로 짜본 적이 없는데?@Id붙이면 프레임워크가 알아서 해주는 거 아닌가?”
안 해줍니다. 이게 두 번째 오해예요.
@Entity와 @Id를 붙여도, JPA나 Hibernate가 equals와 hashCode를 만들어 주지 않습니다. 오버라이드하지 않으면 자바 기본값, 즉 Object의 참조 동일성을 그대로 씁니다. 참조 동일성은 두 변수가 물리적으로 같은 인스턴스를 가리킬 때만 같다고 보는 거예요. 값이 같은지, ID가 같은지는 따지지 않습니다.
그런데 왜 여태 문제가 없었을까
안 짰는데도 여태 멀쩡했다면, Hibernate의 동작 하나 덕분입니다.
Hibernate는 하나의 영속성 컨텍스트(persistence context, 대략 한 트랜잭션 안의 작업 단위) 안에서는, DB의 한 행(row)을 자바 인스턴스 하나에만 대응시킵니다. Hibernate 공식 문서도 “식별자는 테이블의 한 행을 자바 클래스의 인스턴스와 유일하게 연결한다, 적어도 주어진 영속성 컨텍스트 안에서는”이라고 적고 있어요.
그 안에서는 같은 계좌를 두 번 조회해도 같은 인스턴스가 돌아옵니다. 그러니 참조 동일성(==)만으로도 우연히 잘 맞아떨어져요. equals를 안 짜도 티가 안 나는 이유가 이겁니다.
문제는 그 경계를 벗어날 때예요.
- 영속성 컨텍스트가 다를 때 (다른 트랜잭션에서 조회한 같은 계좌)
- 엔티티가 준영속(detached) 상태가 됐을 때 (조회는 됐지만 영속성 컨텍스트가 닫혀, Hibernate가 더는 관리하지 않는 상태)
- 그 엔티티들을
Set이나Map에 담을 때
이때는 같은 계좌인데도 자바 인스턴스가 달라서, equals가 false가 나고 컬렉션에서 못 찾는 사고가 납니다. 맨 처음 봤던 그 “Set에서 못 찾는” 버그가, 엔티티에서는 이렇게 다시 나타나는 거예요.
엔티티는 어떻게 짜나
엔티티의 equals는 값 객체와 정반대입니다. 전체 필드가 아니라, 정체성 하나로만 비교해요. 잔액이 바뀌어도 같은 계좌여야 하니까요.
가장 먼저 떠오르는 건 @Id, 즉 DB 식별자로 비교하는 겁니다.
@Override
public boolean equals(Object o) {
return o instanceof BankAccount a && Objects.equals(this.accountId, a.accountId);
}
@Override
public int hashCode() {
return Objects.hashCode(accountId);
}
그런데 여기에 함정이 있어요. DB가 ID를 자동 생성(auto increment 등)하는 경우, 객체를 막 만든 시점엔 아직 저장 전이라 accountId가 null입니다. Hibernate 문서도 “Hibernate는 영속 상태가 된 객체에만 식별자 값을 부여한다, 새로 만든 인스턴스는 식별자 값이 없다”고 경고해요.
이게 왜 문제냐면, 저장 전에 이 엔티티를 Set에 넣었다가 저장하는 순간 accountId가 null에서 실제 값으로 바뀌고, 그러면 hashCode도 바뀝니다. 앞에서 봤듯 hashCode가 바뀌면 서랍이 바뀌어서, 그 Set 안에 있는데도 못 찾게 돼요.
그래서 Hibernate 공식 문서가 권하는 방식은 자동 생성 ID가 아니라 비즈니스 키(business key)로 equals를 짜라는 겁니다. 비즈니스 키란 그 도메인에서 객체를 유일하게 식별하는, 변하지 않는 자연스러운 값이에요. 계좌라면 자동 생성된 내부 PK 대신 계좌번호가 그런 값이 될 수 있죠. 처음 만들 때 정해지고, 이후 바뀌지 않으니까요.
트레이드오프: 비즈니스 키는 저장 전에도 값이 있고 바뀌지 않아 Set 문제를 피합니다. 대신 “바뀌지 않고 유일한 자연 키”가 도메인에 실제로 있어야 해요. 그런 키가 마땅치 않은 엔티티도 있고, 그때는 엔티티 equals를 어떻게 짜느냐가 실제로 까다로운 문제가 됩니다. (자바 커뮤니티에는 이럴 때 hashCode를 상수로 고정하는 등의 우회 패턴도 알려져 있지만, 이건 공식 문서의 권장이 아니라 별도로 판단할 영역이에요.)
핵심만 남기면 이렇습니다. 엔티티의 equals는 “무엇이 이 객체의 변하지 않는 정체성인가”를 정하는 일이고, 그 답은 자동으로 주어지지 않습니다. 직접 정해야 해요.
헷갈리는 이웃, @Data를 엔티티에 붙이지 마세요
마지막으로 실무에서 자주 밟는 지뢰 하나. Lombok의 @Data입니다.
@Data는 equals와 hashCode를 전체 필드 기준으로 생성해 줍니다. 값 객체엔 잘 맞아요. 그런데 이걸 엔티티에 붙이면 문제가 됩니다.
엔티티는 상태가 변하잖아요. balance 같은 필드가 바뀌면, 전체 필드로 만든 hashCode도 따라 바뀝니다. 그러면 방금 본 것처럼 Set 안에서 객체를 잃어버려요. 엔티티는 값 객체가 아닌데, @Data가 값 객체처럼 다뤄 버리는 거죠.
그래서 엔티티에는 @Data를 붙이지 않고, equals와 hashCode를 위에서 본 대로 정체성 기준으로 직접 짜는 게 안전합니다. 이름이 비슷한 도구라도, 값 객체용인지 엔티티용인지를 갈라서 써야 해요.
정리
처음의 “Set에서 못 찾는 버그”는 사실 표면이었습니다. 그 밑에는 “무엇을 같다고 볼 것인가” 라는 설계 질문이 있었어요.
- 값이 같으면 같다고 보면 값 객체입니다.
equals와hashCode를 전체 필드로 짜고, record가 이걸 자동화해 줍니다. - 값이 같아도 다를 수 있다면 엔티티입니다. 정체성(식별자)으로
equals를 짜야 하고, 이건 프레임워크가 대신 해주지 않습니다.
그리고 equals를 짤 때는 hashCode도 반드시 함께 짭니다. 둘이 어긋나면, 컬렉션이 값을 엉뚱한 서랍에서 찾다 놓치니까요.
무엇을 같다고 볼지는 코드가 아니라 도메인이 정합니다. 그 판단을 먼저 하고, equals와 hashCode는 그 결과로 따라오게 하세요. 순서가 반대가 되면, 코드는 컴파일되는데 컬렉션은 조용히 값을 잃어버립니다.
참고
- Object (Java SE 21 API): equals, hashCode 계약
- Record (Java SE 21 API): 자동 생성되는 equals, hashCode
- The Java Language Specification, §8.10 Record Classes (record는 implicitly final, equals/hashCode는 전체 component 기준)
- Hibernate ORM 6.6 Introduction: 영속성 컨텍스트 안의 식별성, equals/hashCode에 자연 키 권장
- Hibernate Reference: Implementing equals() and hashCode() (business key 권장, 생성 ID의 Set 함정)
- Martin Fowler: ValueObject (값으로 구분 vs 정체성으로 구분)
- Effective Java (Joshua Bloch), Item 11 (equals를 재정의하면 hashCode도 재정의), 17 (immutability), 49 (parameter validity)