Java & Spring

[Spring] Equals와 HashCode를 재정의(Override)해야하는 이유

ju_young 2024. 1. 22. 14:51
728x90

Background

Java의 Class는 Equals 또는 HashCode로 서로 동일한지 판단할 수 있다. 만약 단순히 new 생성자로 Class를 생성 후 ==비교하게되면 서로 다른 주소값을 가지기때문에 두 Class는 다르다고 판단한다. 또한 Class 객체는Equals로 비교할 경우에도 주소값을 사용하여 비교한다. 그리고 hashcode는 객체의 주소값을 해싱한 값(해시 코드)이므로 아래처럼 새로운 Class를 생성하게되면 서로 다른 hashcode를 가진다.

public class Dummy {  
    private final int a;  
    private final int b;  

    public Dummy(int a, int b) {  
        this.a = a;  
        this.b = b;  
    }  
}

public class Main {
    public static void main(String[] args) {
        Dummy dummy1 = new Dummy(1, 2);  
        Dummy dummy2 = new Dummy(1, 2);  
        System.out.println(dummy1  dummy2); //false
        System.out.println(dummy1.equals(dummy2)); //false
        System.out.println(dummy1.hashCode()); //1149319664
        System.out.println(dummy2.hashCode()); //2093631819
    }
}

 

그렇기 때문에 Class들이 서로 동일한지 비교하기위해서 Equals와 HashCode를 Override(재정의)한다.

public class Dummy {  
    private final int a;  
    private final int b;  

    public Dummy(int a, int b) {  
        this.a = a;  
        this.b = b;  
    }  

    @Override  
    public boolean equals(Object o) {  
        if (this  o) return true;  
        if (!(o instanceof Dummy that)) return false;  
        return this.a  that.a && this.b  that.b;  
    }  

    @Override  
    public int hashCode() {  
        return Objects.hash(a, b);  
    }  
}

public class Main {
    public static void main(String[] args) {
        Dummy dummy1 = new Dummy(1, 2);  
        Dummy dummy2 = new Dummy(1, 2);  
        System.out.println(dummy1  dummy2); //false
        System.out.println(dummy1.equals(dummy2)); //true
        System.out.println(dummy1.hashCode()); //994
        System.out.println(dummy2.hashCode()); //994
    }
}

 

==은 여전히 주소값을 사용하여 비교하기때문에 생성된 Class들을 서로 다르다고 판단하지만 Equals, HashCode는 재정의함으로써 Class들이 서로 같다고 판단하게된다.

IMPORTANT
Equals와 HashCode는 같이 재정의(Override)되어야한다.


GC에도 영향을 줄까?

그렇다면 Garbage Collector(GC)도 Equals, HashCode를 재정의한 것에 따라 동작이 달라질까?

테스트를 위해 먼저 간단한 Spring Boot Application을 사용했고 JMeter, jstat을 사용하여 부하 테스트 및 모니터링을 수행했다.

테스트 환경

  • JDK 17(corretto) / G1 GC
  • Mac M1
  • Spring Boot 2.7.18
  • Heap Size: 1024MB

Application

먼저 테스트에 사용된 Application은 사용자와 은행 계좌 Entity가 있으며 사용자의 계좌를 조회하는 request를 모니터링헀다. 이때 DTO 객체 2개와 Entity 객체 2개가 사용되며 조회한 사용자의 계좌는 총 3개가 존재한다.

//사용자
@Entity  
public class AccountUser {  
    @Id  
    @GeneratedValue    
    private Long id;  
    private String name;
}

//계좌
@Entity  
public class Account {  
    @Id  
    @GeneratedValue    
    private Long id;  
    @ManyToOne  
    private AccountUser accountUser;  
    ...
}

//계좌 DTO
public class AccountDto {  
    private final Long userId;  
    private final String accountNumber;  
    ...
}

//계좌 정보(계좌 번호, 잔고) DTO
public class AccountInfo {  
    private final String accountNumber;  
    private final Long balance;
}

 

사용자의 계좌를 조회한 response는 다음과 같다.

[
    {
        "accountNumber": "111-111-111111",
        "balance": 1000
    },
    {
        "accountNumber": "111-111-111112",
        "balance": 2000
    },
    {
        "accountNumber": "111-111-111113",
        "balance": 3000
    }
]

JMeter

부하테스트를 위해 JMeter를 사용하였고 아래처럼 설정했다. 즉, 500명의 사용자가 1초간 2번씩 요청하도록 설정했다.

jstat

jstat을 사용하여 GC 모니터링을 수행했고 -gc command를 사용했다. 또한 jcmd로 GC.run, GC.run_finalization을 실행시킨 후 테스트를 수행했다.

jstat -gc {PID} 500
  • S0C: Survivor 0의 용량
  • S1C: Survivor 1의 용량
  • S0U: Survivor 0의 사용량
  • S1U: Survivor 1의 사용량
  • EC: 현재 Eden의 용량
  • EU: Eden의 사용량
  • OC: 현재 Old의 용량
  • OU: Old의 사용량
  • MC: Metaspace의 용량
  • MU: Metaspace의 사용량
  • CCSC: Compressed class의 용량
  • CCSU: Compressed class의 사용량
  • YGC: young generation GC 횟수
  • YGCT: young generation의 GC 시간
  • FGC: full GC 횟수
  • FGCT: full GC 시간
  • GCT: 총 GC 시간

equals, hashcode를 재정의하지 않은 경우

  • JMeter 첫 실행
    • Eden 영역: +139.264 KB
  • 첫 Young GC
    • Survivor1: +6548.9 KB
    • Metaspace: +4931.4 KB
    • Compressed Class: +741.2 KB

equals, hashcode를 재정의한 경우

  • JMeter 첫 실행
    • Eden 영역: +132,096 KB
  • 첫 Young GC
    • Survivor1: +6546.5 KB
    • Metaspace: +4850.5 KB
    • Compressed Class: +710.8 KB

결과

결론적으로 GC와 Equals, HashCode는 관련이 없었다. 위 jstat 결과처럼 각 영역에 증가하는 크기도 큰 차이가 없었다. 단지 Eden 영역에는 매번 새로운 객체가 쌓이게되고 EC만큼 다차게되면 YGC로 인해 살아남은 객체는 S1으로 옮겨진다. 왜 매번 새로운 객체를 생성하여 메모리에 쌓을까라는 질문을 던져보았을 때 상수를 생각해보면 될 것같다. 상수처럼 immutable하며 여러 곳에 공유하여 사용할 경우 Native Memory Heap 저장하여 GC 대상에서 벗어날 수 있지만 mutable하며 공유하면 안되는 객체는 어쩔 수 없이 Java Heap에 저장될 수 밖에 없다. (Java 8 이후 기준)

 

그렇다면 static class로 정의한다면 어떻게 될까?

public class AccountOuterDto {  

    public static class AccountDto {  
        private final Long userId;  
        private final String accountNumber;  
        ...
    }  

     public static class AccountInfo {  
        private final String accountNumber;  
        private final Long balance;  
        ...
    }  
}

 

바로 결과를 확인해보면 다음과 같이 메모리를 좀 더 소비하는 모습을 볼 수 있었다. 결국 메모리에 쌓이는 것은 새로운 객체이며 static class로 정의한다고 메모리를 더 절약하지는 못했다. class의 meta data는 여전히 metaspace에 저장될 뿐...

후기

의미가 없는 테스트라고 생각이 들긴하지만 이 테스트로 인해서 GC와 메모리, Object와의 관계에 대해서 더 깊이 생각할 수 있었던 것 같다. 재밌는 결과를 얻지 못해서 아쉽네...


[Reference]
https://stuefe.de/posts/metaspace/analyze-metaspace-with-jcmd/
https://12bme.tistory.com/382

728x90