코드 한 줄의 기록

자바 equals/hashCode/toString 설계 원칙 총정리 + Lombok 사용 시 주의점까지 한 번에 정리 본문

JAVA

자바 equals/hashCode/toString 설계 원칙 총정리 + Lombok 사용 시 주의점까지 한 번에 정리

CodeByJin 2025. 10. 12. 08:08
반응형

가장 먼저 결론부터 말하면, “도메인 모델의 동등성은 일관되고 예측 가능해야 하며, 컬렉션에 넣는 순간부터 계약을 끝까지 지켜야 한다.” 이 문장을 머리에 박아두면 equals/hashCode/toString을 설계할 때 흔들리지 않는다. 아래 내용은 실무에서 부딪혔던 함정과 Lombok으로 편하게 작성할 때 생기는 미묘한 위험까지 담았다.

왜 equals/hashCode/toString이 중요한가

자바에서 객체는 기본적으로 참조 동일성(==)을 기준으로 비교된다. 하지만 비즈니스 로직에서는 “같은 의미”를 갖는 객체를 값으로 다루고 싶을 때가 많다. 예를 들어, 같은 주민등록번호의 User는 같은 사람이다. 이때 equals와 hashCode를 “의미 기반”으로 재정의하면, Set이나 Map 같은 컬렉션이 의도대로 동작한다. toString은 디버깅과 로깅에서 큰 가치를 주는데, 민감 정보 노출과 순환 참조에 주의해야 한다.

 

핵심은 다음 세 가지다.
- equals: 동치성의 “의미”를 정의한다.
- hashCode: 같은 객체라면 같은 해시값을 보장한다.
- toString: 사람이 읽기 쉬운 상태 표현을 제공한다.

 

이 셋은 따로 놀면 안 된다. 특히 equals/hashCode는 계약을 이룬다. equals를 재정의하면 hashCode도 반드시 재정의해야 한다는 유명한 규칙은 “해시 기반 컬렉션(Set, Map)”을 안전하게 쓰기 위한 하한선이다.

equals 설계 원칙: 무엇을 기준으로 같은가

equals 구현 시 지켜야 할 원칙(계약)은 다음과 같다.
- 반사성: x.equals(x)는 항상 true
- 대칭성: x.equals(y)면 y.equals(x)도 true
- 추이성: x=y이고 y=z면 x=z
- 일관성: 비교 대상의 상태가 바뀌지 않으면 결과는 항상 같다
- null 비교: x.equals(null)은 항상 false

 

여기에 실무 체크리스트를 얹자.
- 비교 기준 필드는 “동등성의 본질”만 넣는다. 예: User는 id(불변 식별자)만으로 비교. 이름/나이는 변할 수 있으므로 제외.
- 가변 필드를 equals 기준에 넣으면, 컬렉션에 넣은 뒤 값이 바뀌었을 때 문제를 일으킨다. Set.contains가 갑자기 false가 되는 현상 같은 것들.
- 상속 구조라면, 대칭성과 추이성을 해치기 쉬워진다. equals는 종종 final 클래스로 닫거나, 클래스가 다르면 무조건 false로 처리하는 전략을 택한다.

 

간단 예시(불변 식별자 기반)

public final class UserId {
    private final String value;

    public UserId(String value) {
        if (value == null || value.isBlank()) throw new IllegalArgumentException("id required");
        this.value = value;
    }

    public String value() { return value; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof UserId)) return false;
        UserId userId = (UserId) o;
        return value.equals(userId.value);
    }

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

    @Override
    public String toString() {
        return "UserId(" + value + ")";
    }
}

 

포인트는 equals 기준이 “불변”이라는 점이다. 이러면 컬렉션에서도 안전하다.

hashCode 설계 원칙: equals와 함께 움직여라

hashCode의 유일한 목적은 해시 기반 컬렉션에서 버킷을 잘 나누는 것이다. 하지만 계약상 중요 사항이 있다.
- equals가 true이면 hashCode는 반드시 동일해야 한다.
- equals가 false라고 해서 hashCode가 반드시 달라야 하는 건 아니다. 다만 충돌이 많으면 성능이 급격히 떨어진다.
- equals 기준이 되는 필드만 hashCode 계산에 포함한다.
- 가변 필드를 포함하면, 컬렉션에 넣은 후 값 변경 시 원소가 “유령”이 되는 문제가 생긴다.

 

좋은 습관은 “equals와 hashCode는 같이 보고 같이 고친다.”이며, 둘을 생성자 이후 불변인 필드로만 구성하는 것이다.

toString 설계 원칙: 사람을 위해, 하지만 안전하게

toString은 디버깅·로깅·모니터링의 첫 줄이다. 다음을 지키면 실수가 줄어든다.
- 민감 정보(비밀번호, 토큰, 카드 번호)는 절대 노출하지 않는다. 마스킹을 사용한다.
- 너무 장황하면 로그가 망가진다. 핵심 필드 중심으로 간결하게.
- 순환 참조(양방향 연관)로 무한 재귀에 빠지지 않도록 주의한다.
- 포맷을 고정하면 로그 필터링이 쉬워진다. key=value 형태가 실무에서는 읽기 좋다.

@Override
public String toString() {
    return "User{id=" + id + ", name=" + name + ", email=" + maskEmail(email) + "}";
}

private String maskEmail(String email) {
    if (email == null) return null;
    int idx = email.indexOf('@');
    if (idx <= 1) return "***" + email.substring(idx);
    return email.charAt(0) + "***" + email.substring(idx);
}

상속 구조에서의 equals: 될 수 있으면 피하고, 한다면 전략을 명확히

equals를 상속 구조에서 안전하게 유지하는 것은 어렵다. 대표 전략은 두 가지다.

  • 클래스 비교 엄격화 전략: getClass()로 같은 클래스일 때만 비교. 대칭성과 추이성을 지키기 쉽다. 단, 하위 클래스가 같은 “의미”를 공유하더라도 equals는 false가 된다.
  • instanceof 허용 전략: 상위 타입과 하위 타입을 같은 범주로 본다. 그러나 하위 클래스가 필드를 추가하면 추이성 위반 가능성이 커진다.

일반적으로 도메인 모델에서는 “동치성은 닫힌 세계”로 보는 편이 실수를 줄인다. 즉, equals는 final 클래스에서 정의하거나, getClass() 비교를 택하는 것이 안전하다. 반대로 JPA 엔티티처럼 프록시가 개입되는 환경에서는 getClass() 비교가 문제를 일으킬 수 있어 주의가 필요하다. 이 경우 식별자(id) 기반 equals/hashCode를 도입하고, 프록시 클래스도 통과시키는 별도 전략이 필요하다.

컬렉션과 equals/hashCode: 타이밍과 가변성의 함정

다음 상황을 상상해보자. HashSet에 객체를 넣는다. 그 다음 equals/hashCode 기준 필드 값을 바꾼다. Set.contains가 false로 나오고, remove도 실패한다. 버킷이 달라졌기 때문이다. 이 문제는 정말 자주 발생한다.

 

실전 원칙
- equals/hashCode 기준 필드는 “처음부터 불변”으로 만들자.
- 불변이 어렵다면, 컬렉션에 넣기 전에 값이 확정되도록 설계를 바꾸자.
- 엔티티의 자연키와 대체키를 혼용하지 말자. 초기엔 null인 id를 equals 기준으로 쓰면 안 된다. 생성 시점부터 존재하는 비즈니스 키(자연키)나, 완전히 값 객체(Value Object)로 분리하는 해법을 고려한다.

Lombok으로 편하게 작성할 때: @EqualsAndHashCode, @Data, @ToString의 진짜 의미

Lombok은 생산성을 폭발적으로 끌어올리지만, 자동 생성이 “정답”은 아니다. 몇 가지 주의점과 권장 사용법을 정리한다.

  • @Data는 편하지만 무겁다. equals, hashCode, toString, getter/setter까지 한 번에 만들어 준다. 가변 필드가 equals/hashCode에 들어가면 컬렉션 문제가 터질 수 있다. 실무에선 @Data를 신중하게 쓰자. 특히 JPA 엔티티에는 비추천.
  • @EqualsAndHashCode는 of/exclude로 기준 필드를 명시하자. “무엇으로 같은지”를 코드에 분명히 남기는 습관이 중요하다.
@Getter
@EqualsAndHashCode(of = "id") // 동등성 기준을 명시
public class User {
    private final UserId id;
    private String name;
    private String email;
}
  • 상속 시 callSuper 옵션에 주의. 상위 클래스도 동등성 기준을 갖고 있다면 @EqualsAndHashCode(callSuper = true)로 합성해야 한다. 반대로 상위가 단순 기술 필드라면 false가 맞을 수 있다. 실수하면 대칭/추이성 깨진다.
  • @ToString은 exclude로 민감 정보나 순환 참조를 피하자. 특히 양방향 연관관계에서는 한쪽을 exclude하는 컨벤션을 팀 단위로 운영하는 게 안전하다.
@ToString(exclude = "password") // 민감정보 제외
public class Account {
    private String username;
    private String password;
}

 

JPA와 함께 쓸 때: equals/hashCode에 지연 로딩 프록시가 끼어드는 케이스를 고려해야 한다. 대체로 영속성 컨텍스트 밖에서도 안전하려면, 영속화 전후 일관성을 유지할 수 있는 “자연키 또는 별도 불변 값객체” 기반 비교가 유리하다.

값 객체(Value Object)에서의 모범 사례

값 객체는 가변성이 없고, “값이 같으면 같은 객체”인 타입이다. Money, Email, Coordinate 같은 것들. 여기서는 equals/hashCode가 쉽다.
- 모든 필드는 불변
- 모든 필드를 equals/hashCode에 포함
- toString은 간결하게, 포맷은 일관되게

public final class Money {
    private final long amount;   // minor unit (e.g., KRW won)
    private final String currency;

    // 생성 시 검증
    public Money(long amount, String currency) {
        if (currency == null || currency.isBlank()) throw new IllegalArgumentException("currency");
        this.amount = amount;
        this.currency = currency;
    }

    public long amount() { return amount; }
    public String currency() { return currency; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money)) return false;
        Money money = (Money) o;
        return amount == money.amount && currency.equals(money.currency);
    }

    @Override
    public int hashCode() {
        int result = Long.hashCode(amount);
        result = 31 * result + currency.hashCode();
        return result;
    }

    @Override
    public String toString() {
        return currency + " " + amount;
    }
}

 

값 객체를 적극적으로 도입하면 equals/hashCode의 고민을 엔티티에서 분리할 수 있다. 엔티티는 식별자 중심, 값은 값대로 정확하게.

실제 코드 리뷰에서 보는 냄새와 리팩터링 포인트

  • 모든 필드를 equals/hashCode에 넣은 @Data: 가변 필드가 섞여 있으면 위험 신호. 기준 필드를 줄이고, of/exclude로 명시하자.
  • toString에 비밀번호, 토큰 노출: 즉시 수정. 마스킹 유틸을 라이브러리화해 팀 공용으로 쓰자.
  • 컬렉션에 넣은 뒤 필드 변경: 테스트로 재현 가능. 불변성 강화 또는 추가 업데이트 로직(재삽입/재색인)이 필요.
  • 상속 구조 equals: 테스트에서 대칭/추이성 케이스를 반드시 추가. 가능하면 상속 대신 합성으로 설계를 단순화.
  • JPA 엔티티 equals에 영속화 전 null id 사용: 자연키 기반 값 객체를 도입하거나, 비즈니스 키로 비교하도록 전환.

팀 컨벤션으로 굳히기: 실패를 줄이는 체크리스트

  • equals/hashCode 기준은 “불변 필드”로 한정한다.
  • equals를 재정의하면 hashCode도 재정의한다.
  • 상속에서는 getClass() 비교(동질 타입만 동등) 또는 final 클래스 원칙을 기본으로 한다.
  • Lombok 사용 시 of/exclude와 callSuper를 반드시 명시한다. @Data는 기본값이 아니다.
  • toString에는 민감정보, 대용량 필드, 양방향 연관 한쪽을 제외한다.
  • 해시 기반 컬렉션에 넣기 전, 동등성 기준 필드 값이 확정되었는지 확인한다.
  • 테스트에 동등성 계약(반사/대칭/추이/일관/널)을 자동 검증하는 케이스를 넣는다.

예시 테스트 스케치

// 대칭성
assertThat(a).isEqualTo(b);
assertThat(b).isEqualTo(a);

// 추이성
assertThat(a).isEqualTo(b);
assertThat(b).isEqualTo(c);
assertThat(a).isEqualTo(c);

// 일관성
assertThat(a).isEqualTo(b);
assertThat(a).isEqualTo(b);

// null
assertThat(a.equals(null)).isFalse();

// hashCode 일관성
assertThat(a.hashCode()).isEqualTo(b.hashCode());

Lombok을 쓸 때의 균형감: “자동 생성”을 팀의 의도에 맞춰라

Lombok은 기본값이 아니라 “팀 규칙을 빠르게 구현하는 템플릿”이다.

다음과 같이 쓰면 도움이 된다.
- @Value를 값 객체에 적극 사용. 모든 필드 final, 불변, equals/hashCode 자동 생성이 안전하다.
- 엔티티나 가변 객체엔 @Getter + @EqualsAndHashCode(of = “…”) + @ToString(exclude = “…”) 식으로 명시.
- @Data는 DTO나 테스트 픽스처처럼 “동등성 의미가 약하거나 중요하지 않은 객체”에 제한적으로 사용.

 

추가로, Lombok이 생성한 메서드는 IDE에서 바로 보이지 않으므로 코드 리뷰 시 diff만 보고 넘어가지 말고, “생성될 메서드 시그니처와 포함 필드”를 PR 템플릿에 체크하도록 만드는 것이 효과적이었다.

한 줄 요약

- equals는 “무엇이 같은가”를 정의한다. 가변성을 배제하고, 계약을 지켜라.
- hashCode는 equals와 함께 움직인다. 같은 건 같게, 충돌은 적게.
- toString은 유용하지만 안전하게. 민감정보와 순환 참조를 조심하라.
- Lombok은 빠르지만 기본은 아니다. 기준 필드와 제외 필드를 명시하라.

 

결국 좋은 설계는 “동등성의 기준을 불변으로 단순화”하는 데서 출발한다. 값 객체로 의미를 분리하고, 엔티티는 식별자 중심으로 가져가면 컬렉션과 프레임워크 환경에서의 함정이 급격히 줄어든다. 팀 차원 컨벤션과 테스트로 계약을 문서화하면, equals/hashCode/toString은 더 이상 “신경 쓰기 귀찮은 보일러플레이트”가 아니라, 시스템의 일관성을 지켜주는 든든한 안전 장치가 된다.

 

 

Java 내부 클래스 완전 정복: 멤버/로컬/익명 클래스 실무 활용법

Java 개발을 하다 보면 클래스 안에 또 다른 클래스를 정의하는 경우를 종종 만나게 됩니다. 이런 구조를 바로 '중첩 클래스'라고 하는데요, 처음 접할 때는 좀 복잡해 보이지만 실제로는 코드를

byteandbit.tistory.com

 

반응형