1. 기초 지식
1) 동일성이란?
동일성(identity)은 객체가 동일한 인스턴스인지를 나타내는 개념이다. 즉, 두 참조 변수가 동일한 객체를 가리키는지를 확인하는 것이다. 이는 객체의 메모리 주소가 같은지를 비교하는 것이다.
자바에서는 동일성을 확인하기 위해 == 연산자를 사용한다. 이 연산자는 비교하는 두 참조 변수가 같은 객체를 가리키는지를 확인한다. 두 참조 변수가 같은 객체를 가리키는 경우에만 true를 반환한다.
- 예시 코드
위 코드에서 str1과 str2는 동일한 문자열 리터럴을 참조하므로 동일한 객체를 가리킨다. 따라서 str1 == str2는 true가 된다. 그러나 str3는 새로운 String 객체를 생성하므로 str1과는 다른 객체를 가리킨다. 따라서 str1 == str3는 false가 된다. 동일성은 객체가 같은 인스턴스를 가리키는지를 판별하는 것이며, 이는 객체의 메모리 주소를 기반으로 한다. 이와 대조적으로, 동등성은 객체의 내용이 같은지를 판별하는 것이며, 내용이 같은지를 확인하기 위해 equals() 메서드를 사용한다.String str1 = "hello"; String str2 = "hello"; String str3 = new String("hello"); System.out.println(str1 == str2); // true System.out.println(str1 == str3); // false
2) 동등성이란?
동등성(equality)은 객체들 간의 비교를 의미한다. 자바에서는 두 객체가 동등한지를 판별하기 위해 두 가지 메서드를 사용한다.
- 동등성 비교 연산자 (==)
- 동등성 비교 연산자는 비교하는 두 객체의 메모리 주소를 비교한다.
- 즉, 두 객체가 완전히 동일한 객체인지를 판별한다. 두 참조 변수가 동일한 객체를 가리키는 경우에만 true를 반환한다.
- 동등성 비교 메서드 (equals())
- equals() 메서드는 객체의 내용을 비교한다.
- 객체의 내용이 동일한 경우에만 true를 반환한다. 내용이 동일한 경우, 두 객체는 동등하다고 판단된다.
문자열의 경우, == 연산자를 사용하면 두 문자열이 동일한 메모리 주소를 참조하는지를 확인하고, equals() 메서드를 사용하면 두 문자열의 내용이 동일한지를 확인한다. 이러한 특성은 문자열 리터럴의 풀(pool)과 관련이 있다. 문자열 리터럴은 JVM 내에서 관리되는 문자열 상수 풀에 저장되며, 동일한 문자열 리터럴은 동일한 메모리 주소를 가리킨다. 반면에 new String()을 사용하여 문자열 객체를 생성하면 매번 새로운 객체가 생성되므로, 동일한 문자열이라도 다른 메모리 주소를 가지게 된다. 따라서, 문자열을 비교할 때는 문자열의 내용이 동일한지를 판별하기 위해 equals() 메서드를 사용하는 것이 일반적이다.
3) hashCode & equals 메서드
- hashCode: 객체 저장 위치를 결정하는 역할
- equals: 동등성 확인을 통한 객체 비교하는 역할
해당 메서드는 각각 위와 같은 역할을 가지고 있으며, hash Collection의 객체 삽입, 객체 검색, 객체 삭제의 동작 원리는 아래와 같다.
- 객체 삽입
- 해시 컬렉션에 객체를 추가할 때, hashCode 메서드를 이용하여 해시 코드를 얻는다.
- 해시 코드를 사용하여 객체가 저장될 버킷을 결정한다.
- 해시 코드를 가진 버킷이 이미 존재하면, 동등 객체 존재를 확인한다.
- 동등 객체가 이미 존재하면, 기존 객체를 유지한다.
- 동등 객체가 아닌 경우, 객체를 추가한다.
- 객체 검색
- 검색할 객체의 hashCode를 호출하여 객체 버킷을 결정한다.
- equals 메서드를 이용하여 동등한 객체를 확인한다.
- 객체 삭제
- 객체 검색과 동일한 로직으로 움직이되, 삭제 기능이 추가된다.
2. 메서드 상세 분석: equals()
1) what?
Java에서 equals() 메소드는 두 객체가 "논리적으로 동등한지"를 판단하는 데 사용된다. 즉, 두 객체가 동일한 값을 나타내는지를 비교하는 데 사용된다. 기본적으로 이 메소드는 객체의 참조를 비교하지만, 필요에 따라 사용자 정의 클래스에서 재정의할 수 있다. 기본적으로 equals() 메소드는 Object 클래스에서 상속된다. 이 메소드의 기본 동작은 두 객체의 참조가 동일한지(즉, 같은 메모리 위치를 참조하는지)를 확인하는 것이다. 이 동작은 객체의 물리적인 동등성을 검사한다. 그러나 많은 경우 객체의 논리적인 동등성을 판단해야 한다. 예를 들어, 문자열이나 숫자와 같은 원시 타입의 경우 값의 동일성을 비교하는 것이 더 의미가 있다. 따라서 대부분의 클래스에서는 equals() 메소드를 재정의하여 이러한 동등성을 정의한다.
2) 오버라이딩 시 준수 원칙
- 일관성(Consistency): 동일한 객체들 간에는 항상 같은 결과 반환
- 대칭성(Symmetry): 두 객체 A와 B가 있을 때, A.equals(B)가 true라면, B.equals(A)도 true
- 추이성(Transitivity): A.equals(B)와 B.equals(C)가 모두 true이면, A.equals(C)도 true
- 일치하지 않는 객체와의 비교(Comparison with null): null과의 비교는 항상 false
- 같은 타입의 객체인지 확인(Type check): equals() 메소드는 보통 다른 타입의 객체와 비교할 수 없도록 구현되어야 함
3. 메서드 상세 분석: hashCode()
1) 기초지식
해시(hash)는 임의 크기의 데이터를 고정된 크기의 값으로 매핑하는 것을 의미한다. 이때 매핑된 값을 해시 값(hash value) 또는 해시 코드(hash code)라고 한다. 해시 함수(hash function)는 이 매핑 과정을 수행하는 함수이다. 해시 함수는 다양한 분야에서 사용되는데, 주로 아래와 같은 목적으로 사용된다.
- 해시 테이블: 데이터를 빠르게 검색하고 저장하기 위해 사용된다. 해시 함수는 키를 해시 값으로 변환하여 해시 테이블의 인덱스를 결정한다.
- 데이터 무결성 검사: 데이터의 무결성을 보호하기 위해 해시 값을 사용하여 데이터의 일부 또는 전체에 대한 해시 값을 계산한다. 이를 통해 데이터가 변경되지 않았는지를 확인할 수 있다.
- 암호학: 해시 함수는 암호학적 해싱에 사용되어 메시지나 데이터의 무결성을 보호하고, 디지털 서명에 사용될 수 있다.
즉, 해시는 데이터를 효율적으로 저장하고 검색하는 데 중요한 역할을 한다. Java에서는 해시 함수를 사용하여 객체의 해시 코드를 생성하고, 이를 해시 기반의 자료구조에서 검색 용도로 많이 사용한다.
2) what?
Java에서 hashCode() 메소드는 객체의 해시 코드를 반환하는 메소드이다. 해시 코드는 일반적으로 동등성 비교를 통해 객체를 식별하는 데 사용된다. 해시 코드는 객체의 메모리 주소나 다른 고유한 식별자를 기반으로 생성된다. hashCode() 메소드의 주요 목적은 해시 기반 컬렉션(HashMap, HashSet 등)에서 객체를 빠르게 저장, 검색 및 삭제하기 위해 사용된다. 해시 기반 컬렉션은 객체의 해시 코드를 사용하여 객체를 내부적으로 저장하고, 동일한 해시 코드를 갖는 객체들을 묶어서 저장하므로 빠른 검색 속도를 제공할 수 있다.
3) 오버라이딩 시 준수 원칙
- 일관성(Consistency): 같은 객체에 대해서는 여러 번 호출되어도 항상 동일한 결과를 반환
- 동치성(Equivalence): equals() 메소드가 두 객체를 동등하다고 판단한다면, 두 객체의 해시 코드도 동일해야 함. 즉, equals()가 true를 반환하는 두 객체는 반드시 동일한 해시 코드를 가져야 함. 그러나 두 객체의 해시 코드가 같다고 해서 equals()가 반드시 true를 반환하는 것은 아님.
- 성능(Peformance): 객체의 해시 코드 계산은 빠르게 이루어져야 함. 따라서 복잡한 연산이나 많은 리소스를 필요로 하지 않아야 함.
- 균등 분포(Uniform Distribution): 서로 다른 객체에 대해서는 가능한 한 서로 다른 해시 코드를 반환해야 함.
4. 예시 코드
일반적으로 equals() 와 hashCode() 이 두 메소드는 서로 관련되어 있으며, 같은 필드들을 사용하여 구현한다.
1) User 코드: 필드 정의 및 eqauls, hashCode 메서드 재정의
package org.example;
import java.util.Objects;
class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object obj) {
if (this == obj){
return true;
}
if (obj == null || getClass() != obj.getClass()){
return false;
}
User user = (User) obj;
return age == user.age && Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
2) Test 코드
package org.example;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class UserTest {
// HashMap 테스트
@Test
void testHashMap() {
User user1 = new User("th", 30);
User user2 = new User("sh", 30);
User user3 = new User("hh", 25);
User user4 = new User("hh", 25);
User user5 = new User("ch", 25);
Map<User, String> userMap = new HashMap<>();
userMap.put(user1, "User 1");
userMap.put(user2, "User 2");
userMap.put(user3, "User 3");
userMap.put(user4, "User 4");
// 각 키에 대해 HashMap에 존재 여부를 확인하여 값을 출력하거나 "not found" 메시지를 출력
User[] users = {user1, user2, user3, user4, user5};
for (User user : users) {
System.out.println(userMap.containsKey(user) ? userMap.get(user)
: user + " not found");
}
}
// equals, hashCode 커스텀 테스트
@Test
void testEqualsAndHashCode() {
User user1 = new User("th", 30);
User user2 = new User("th", 30);
User user3 = new User("hh", 25);
// 동일한 객체는 equals가 true여야 함
assertTrue(user1.equals(user1));
// 동일한 내용의 객체는 equals가 true여야 함
assertTrue(user1.equals(user2));
// 동일한 내용의 객체는 hashCode 값도 동일해야 함
assertEquals(user1.hashCode(), user2.hashCode());
// 다른 객체는 equals가 false여야 함
assertFalse(user1.equals(user3));
// 다른 객체는 hashCode 값도 다를
assertNotEquals(user1.hashCode(), user3.hashCode());
}
}
*Test 결과
5. 세부 코드 분석
1) HashMap Put 메서드
public V put(K key, V value) {
return this.putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node[] tab;
int n;
if ((tab = this.table) == null || (n = tab.length) == 0) {
n = (tab = this.resize()).length;
}
Object p;
int i;
if ((p = tab[i = n - 1 & hash]) == null) {
tab[i] = this.newNode(hash, key, value, (Node)null);
} else {
Object e;
Object k;
if (((Node)p).hash == hash && ((k = ((Node)p).key) == key || key != null && key.equals(k))) {
e = p;
} else if (p instanceof TreeNode) {
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
} else {
int binCount = 0;
while(true) {
if ((e = ((Node)p).next) == null) {
((Node)p).next = this.newNode(hash, key, value, (Node)null);
if (binCount >= 7) {
this.treeifyBin(tab, hash);
}
break;
}
if (((Node)e).hash == hash && ((k = ((Node)e).key) == key || key != null && key.equals(k))) {
break;
}
p = e;
++binCount;
}
}
if (e != null) {
V oldValue = ((Node)e).value;
if (!onlyIfAbsent || oldValue == null) {
((Node)e).value = value;
}
this.afterNodeAccess((Node)e);
return oldValue;
}
}
++this.modCount;
if (++this.size > this.threshold) {
this.resize();
}
this.afterNodeInsertion(evict);
return null;
}
- put 메소드
- put 메소드는 HashMap에 새로운 키-값 쌍을 추가하는 역할을 함.
- 이 메소드는 해시 값을 계산하고, putVal 메소드를 호출하여 실제로 값을 추가함.
- putVal 메소드
- putVal 메소드는 실제로 HashMap에 값을 추가하는 메인 로직을 담고 있음.
- 먼저, 현재 HashMap의 테이블이 초기화되어 있지 않다면, resize 메소드를 호출하여 테이블을 초기화함.
- 다음으로, 키의 해시 값을 사용하여 해당 키의 위치를 찾음.
- 해당 위치에 이미 값이 없을 경우: 새로운 노드를 생성하여 값을 저정함. 값이 이미 존재할 경우: 해당 위치에 연결된 노드들을 탐색하여 키가 이미 존재하는지를 확인하고, 이미 존재한다면 값을 업데이트함.
- 키가 존재하지 않는 경우에는 새로운 노드를 생성하여 연결 리스트의 끝에 추가함.
- 추가된 후에는 모든 연결 리스트의 길이가 8 이상인 경우, treeifyBin 메소드 호출하여 해당 연결 리스트를 트리 구조로 변환함.
- 마지막으로, HashMap의 크기가 임계치를 초과하는지를 확인하고, 초과한다면 resize 메소드를 호출하여 테이블의 크기를 조정함.
- 동일한 key 값에 동일한 value가 들어올 경우, 기존 값을 유지함.
위의 방식으로, put 메소드와 putVal 메소드는 HashMap에 키-값 쌍을 추가하는 데에 사용된다. 해시 충돌이 발생하거나 트리 구조로 변환해야 하는 경우에도 적절한 처리를 수행하여 HashMap의 성능을 유지한다.
Tip> 해시 충돌
hash collection 설계에는 두 가지 전제 조건이 성립된다. 1) equals 메소드에 의해 동등하면, 반드시 동일한 해시 코드를 반환한다. 2) 해시 코드가 동일하면, equals는 동일할 수도 있다.
전제 조건에 따르면, 해시 코드가 동일하더라도 equals로 객체가 동등하지 않는 경우의 수도 존재한다. 이는 hashCode 메서드의 논리적 오류라고 볼 수도 있다. 이상적으로는 동일한 객체일 때만 같은 hash 값을 생성해야하지만, 로직 상 고려할 수 있는 상황적 한계 때문에 다른 객체여도 동일한 hash값이 생성될 가능성이 열려 있다. Java Hash Collection은 해시충돌을 해결하기 위해, hashCode및 equals 메서드를 이용하여 객체를 검증하고, 연결 리스트 방식과 트리구조를 이용하여 해시충돌을 해결하도록 설계하였다.
*세부 코드는 위의 putVal 메서드 분석 내용 확인
2) HashMap get 메서드
public V get(Object key) {
Node e;
return (e = this.getNode(key)) == null ? null : e.value;
}
public boolean containsKey(Object key) {
return this.getNode(key) != null;
}
static class Node<K, V> implements Map.Entry<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next;
Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() {
return this.key;
}
public final V getValue() {
return this.value;
}
public final String toString() {
return this.key + "=" + this.value;
}
public final int hashCode() {
return Objects.hashCode(this.key) ^ Objects.hashCode(this.value);
}
public final V setValue(V newValue) {
V oldValue = this.value;
this.value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this) {
return true;
} else {
boolean var10000;
if (o instanceof Map.Entry) {
Map.Entry<?, ?> e = (Map.Entry)o;
if (Objects.equals(this.key, e.getKey()) && Objects.equals(this.value, e.getValue())) {
var10000 = true;
return var10000;
}
}
var10000 = false;
return var10000;
}
}
}
final Node<K, V> getNode(Object key) {
Node[] tab;
Node first;
int n;
int hash;
if ((tab = this.table) != null && (n = tab.length) > 0 && (first = tab[n - 1 & (hash = hash(key))]) != null) {
Object k;
if (first.hash == hash && ((k = first.key) == key || key != null && key.equals(k))) {
return first;
}
Node e;
if ((e = first.next) != null) {
if (first instanceof TreeNode) {
return ((TreeNode)first).getTreeNode(hash, key);
}
do {
if (e.hash == hash && ((k = e.key) == key || key != null && key.equals(k))) {
return e;
}
} while((e = e.next) != null);
}
}
return null;
}
- get 메소드
- get 메소드는 주어진 키에 해당하는 값을 찾아 반환함.
- getNode 메소드를 호출하여 특정 키에 해당하는 노드(Node)를 찾음.
- 찾은 노드의 값을 반환하거나, 노드가 null인 경우에는 null을 반환함.
- getNode 메소드
- getNode 메소드는 주어진 키에 해당하는 노드를 찾는 역할을 함.
- 먼저, HashMap의 내부 테이블(table)이 존재하고, 테이블의 크기가 0보다 큰지를 확인함.
- 이후, 주어진 키의 해시 값을 사용하여 해당 키가 위치할 버킷(bucket)을 결정함.
- 해당 버킷에 저장된 첫 번째 노드를 가져옴.
- 가져온 첫 번째 노드가 주어진 키와 일치하는지를 확인하고, 일치한다면 해당 노드를 반환.
- 일치하지 않는 경우, 연결 리스트를 따라가며 다음 노드를 탐색하고, 일치하는 노드를 찾을 때까지 반복.
- 만약 버킷이 트리 구조로 변환되어 있다면(treeifyBin 메소드로 인해), 해당 트리에서도 키를 찾음.
- 모든 버킷을 확인하고도 일치하는 노드를 찾지 못한 경우에는 null 반환.
위의 방식으로, get 메소드는 주어진 키에 해당하는 값을 찾는 데 사용된다. 키를 해시 값으로 변환하고, 해당 해시 값이 위치한 버킷에서부터 연결 리스트 또는 트리를 따라가며 값을 찾는다. 만약 값을 찾지 못한다면 null을 반환한다.
3) Array.class: hashCode 메서드
public static int hashCode(Object[] a) {
if (a == null) {
return 0;
} else {
int result = 1;
Object[] var2 = a;
int var3 = a.length;
for(int var4 = 0; var4 < var3; ++var4) {
Object element = var2[var4];
result = 31 * result + (element == null ? 0 : element.hashCode());
}
return result;
}
}
- 먼저, 주어진 배열이 null인지 확인한다. 만약 null이라면, 해시 코드로 0을 반환함.
- 만약 배열이 null이 아니라면, 초기 결과 값을 1로 설정함. 이는 아래에서 곱셈 연산을 수행할 때 0을 곱하는 것을 피하기 위함으로 보여짐.
- 배열의 각 요소에 대해 반복문을 실행함. 이때, 배열의 각 요소에 대해 해시 코드를 계산하고 이를 현재 결과 값에 추가함.
- 배열의 각 요소의 해시 코드를 현재 결과 값에 더할 때마다, 현재 결과 값에 31을 곱하고 해당 요소의 해시 코드를 더함. 이 과정을 배열의 모든 요소에 대해 반복함.
- 최종 결과 값으로 계산된 해시 코드를 반환함.
여기서 31이라는 상수는 소수(prime number)로 지정되어 있는데, 이는 해시 함수의 곱셈 연산을 근사적으로 2의 거듭제곱 연산으로 바꿔주고, 동시에 해시 코드의 분포를 더 균일하게 만들어주는데 도움이 된다고 함.
5. equals와 containskey 사용하기 적합한 상황
package org.example;
import java.util.HashMap;
import java.util.Map;
public class UserDatabase {
private Map<String, String> userMap = new HashMap<>();
public void addUser(String userId, String password) {
userMap.put(userId, password);
}
public boolean authenticate(String userId, String password) {
if (userMap.containsKey(userId)) {
return userMap.get(userId).equals(password);
} else {
return false; // 아이디가 데이터베이스에 존재하지 않음
}
}
}
package org.example;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class UserDatabaseTest {
@Test
void testAuthenticate() {
// 사용자 데이터베이스 생성
UserDatabase userDatabase = new UserDatabase();
// 사용자 추가
userDatabase.addUser("th", "password123");
userDatabase.addUser("hh", "password456");
// 로그인 인증 테스트
String userId = "th";
String password = "password123";
boolean authenticated = userDatabase.authenticate(userId, password);
// 로그인 결과 확인
assertTrue(authenticated, "Login successful");
// 로그인 실패 테스트
userId = "invalid";
password = "invalid";
authenticated = userDatabase.authenticate(userId, password);
// 로그인 결과 확인
assertFalse(authenticated, "Invalid username or password");
}
}
'자바(Java)' 카테고리의 다른 글
코드로 보는 SOLID 원칙 (0) | 2025.02.06 |
---|---|
Java - 엑셀 업로드 후 DB 저장 (0) | 2023.08.25 |
Java - Stream (0) | 2023.08.17 |
Java - 메소드 참조와 Optional 클래스 (0) | 2023.08.11 |
Java - 람다식 (0) | 2023.08.11 |