[아이티센 부트캠프] (Object 클래스)

이언덕·2026년 3월 28일

아이티센 부트캠프

목록 보기
26/115
post-thumbnail

Object 클래스와 객체 비교

자바에서 만드는 모든 클래스는 결국 Object 클래스를 부모로 가진다.
다른 클래스를 직접 상속하지 않아도 컴파일 과정에서 extends Object가 붙는다고 이해하면 된다.
그래서 Object는 단순히 가장 위에 있는 클래스가 아니라, 모든 객체가 공통으로 가지는 기능의 출발점이다.


객체를 공부할 때 Object를 먼저 보는 이유는 분명하다.
객체를 같은 것으로 볼지 판단할 때는 equals()가 필요하고, 해시 기반 컬렉션에서 객체를 빠르게 찾을 때는 hashCode()가 필요하며, 객체를 출력했을 때 어떤 문자열로 보일지는 toString()이 결정한다.
이 메서드들은 모두 Object에서 시작한다.


Object를 이해한다는 것은 객체를 비교하고, 저장하고, 출력하는 기본 원리를 이해하는 것과 같다.


이번 글에서는 Object의 모든 메서드를 넓게 다루지 않는다.
객체 비교와 출력에서 가장 자주 만나는 equals(), hashCode(), toString()을 중심으로 정리한다.


핵심 특징

Object 클래스의 핵심은 모든 객체가 공통으로 사용할 수 있는 기본 메서드를 제공한다는 점이다.
초보자는 이 메서드들을 처음부터 외우려고 하기보다 “객체를 어떻게 비교하고 출력하는가” 흐름으로 이해하는 것이 좋다.

  • Object는 모든 클래스의 공통 부모다.
  • 다른 클래스를 상속하지 않아도 결국 Object를 상속받는다.
  • equals()는 두 객체를 같은 것으로 볼 기준을 정한다.
  • hashCode()는 해시 기반 컬렉션에서 객체를 빠르게 찾기 위한 정수값을 만든다.
  • toString()은 객체를 출력했을 때 어떤 문자열로 보일지 정한다.
  • 직접 만든 객체를 값 기준으로 비교하려면 equals()와 hashCode()를 함께 재정의해야 한다.

정리하면 Object는 “모든 객체가 기본으로 물려받는 공통 기능 모음”이다.
그중 객체 비교와 컬렉션 저장 흐름에서는 equals()와 hashCode()가 특히 중요하고, 출력 확인 흐름에서는 toString()이 중요하다.



Object 클래스에서 자주 보는 메서드

Object에는 여러 메서드가 있지만, 처음에는 세 가지를 먼저 잡으면 된다.
바로 equals(), hashCode(), toString()이다.
이 세 메서드는 서로 따로 떨어진 기능처럼 보이지만 실제로는 자주 함께 연결된다.

equals()

equals()는 두 객체를 같은 것으로 볼지 판단하는 메서드다.
여기서 “같다”는 말은 무조건 안에 든 값이 같다는 뜻이 아니다.
기본 상태의 equals()는 내용 비교가 아니라, 두 참조변수가 같은 객체를 가리키는지 확인하는 방식이다.


Object의 기본 equals() 구조를 단순하게 보면 아래와 같다.
이 코드는 실제 실행용 파일이 아니라, Object 안의 기본 비교 구조를 초보자 기준으로 단순화한 조각 코드다.

// ObjectEqualsStructure.java
public boolean equals(Object obj) {
	// this와 obj가 같은 객체를 가리키는지 확인함
	return (this == obj);
}

기본 equals()는 내부적으로 this == obj를 비교한다.
즉, 객체 안의 값이 같은지 보는 것이 아니라 참조값이 같은지, 쉽게 말해 같은 객체를 가리키는지 본다.


예를 들어 사람 두 명이 같은 이름표를 들고 있다고 해서 같은 사람은 아니다.
이름표의 글자는 같을 수 있지만 실제 사람은 다를 수 있다.
객체도 마찬가지다.
객체 안의 값이 같아 보여도 new로 따로 만들었다면 서로 다른 객체다.


hashCode()

hashCode()는 객체를 정수값으로 바꿔 돌려주는 메서드다.
이 값은 HashSet, HashMap처럼 해시 기반 컬렉션에서 객체를 빠르게 찾기 위한 기준으로 먼저 사용된다.
다만 이 정수값을 실제 메모리 주소 그 자체라고 외우면 안 된다.
초보자 단계에서는 객체를 빠르게 분류하고 찾기 위해 사용하는 번호라고 이해하면 충분하다.


쉽게 말하면 hashCode()는 물건을 창고에 넣기 전에 붙이는 분류 번호와 비슷하다.
창고에 물건이 아주 많을 때 물건 하나하나를 처음부터 끝까지 다 뒤지면 오래 걸린다.
그래서 먼저 분류 번호로 비슷한 위치를 찾고, 그다음 진짜 같은 물건인지 확인한다.


이때 최종 확인을 맡는 것이 equals()다.
그래서 해시 기반 컬렉션은 보통 아래 흐름으로 객체를 판단한다고 보면 된다.

  • 먼저 hashCode()로 비교 후보 위치를 찾는다.
  • 후보가 있으면 equals()로 정말 같은 객체인지 확인한다.
  • 새 객체라고 판단되면 저장하고, 이미 같은 객체라고 판단되면 저장하지 않는다.

값 기준으로 같은 객체라고 판단하고 싶다면 equals()와 hashCode()의 기준을 반드시 맞춰야 한다.
equals()는 이름과 나이를 비교하는데 hashCode()는 전혀 다른 기준으로 만들면, 해시 기반 컬렉션에서 중복 판단이 어긋날 수 있다.


toString()

toString()은 객체를 문자열처럼 보여줄 때 어떤 형태로 출력할지 정하는 메서드다.
객체를 System.out.println()에 넣으면 내부적으로 그 객체의 toString() 결과가 출력된다.


Object의 기본 toString() 구조를 단순하게 보면 아래와 같다.
이 코드도 실제 실행용 파일이 아니라, 기본 출력 문자열이 어떻게 만들어지는지 보여주는 조각 코드다.

// ObjectToStringStructure.java
public String toString() {
	// 클래스 이름과 16진수 해시코드를 합쳐 문자열로 돌려줌
	return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

그래서 toString()을 재정의하지 않은 객체를 출력하면 클래스이름@해시값처럼 보인다.
예를 들어 Card@452b3a41 같은 형태다.
이 출력은 객체 안의 값을 보여주는 것이 아니라, 기본 형태의 객체 식별 문자열에 가깝다.


반대로 toString()을 재정의하면 객체 안에 들어 있는 값을 사람이 읽기 쉬운 문자열로 보여줄 수 있다.
그래서 출력 결과를 확인하거나 디버깅할 때 유용하다.


예제별 역할

이 글의 예제는 단순히 코드를 많이 보여주기 위한 것이 아니다.
객체 비교가 어떤 단계로 확장되는지 순서대로 확인하기 위한 예제다.

  • ObjectTest1 : 같은 객체와 같은 내용의 차이를 확인한다.
  • EqualsTest1 : 문자열 비교에서 equals()가 왜 필요한지 확인한다.
  • ObjectTest2 : 직접 만든 객체에서 equals()만 재정의했을 때의 한계를 확인한다.
  • ObjectTest3 : equals()와 hashCode()를 함께 재정의해 HashSet 중복 판단을 완성한다.
  • ToStringExample : toString()을 재정의하면 객체 출력이 어떻게 달라지는지 확인한다.
  • HashCodeExample : 클래스마다 hashCode() 기준이 다를 수 있음을 보충한다.

이 흐름을 따라가면 “객체 안의 값이 같은데 왜 다르다고 나오지?”에서 시작해서 “직접 만든 객체를 값 기준으로 비교하려면 무엇을 고쳐야 하지?”까지 자연스럽게 이어진다.



같은 객체와 같은 내용은 다르다

객체 비교에서 가장 먼저 잡아야 하는 개념은 같은 내용과 같은 객체가 다르다는 점이다.
값이 같아 보여도 new를 두 번 사용하면 서로 다른 객체가 만들어진다.
반대로 새 객체를 만들지 않고 기존 객체를 다른 변수에 대입하면 두 변수는 같은 객체를 함께 가리킨다.


쉽게 말하면 같은 정보가 적힌 회원 가입 신청서가 두 장 있을 수 있다.
두 신청서에 적힌 내용은 같지만 종이는 두 장이다.
이때 “내용이 같은가”와 “종이 자체가 같은가”는 다른 질문이다.
객체 비교도 이와 비슷하다.

예제: ObjectTest1

// ObjectTest1.java
package day10;
class Member1 {
	private int id;
	private String name;
	private String password;
	Member1(int id, String name, String password) {
		// 회원 정보를 객체 안에 저장함
		this.id = id;
		this.name = name;
		this.password = password;
	}
	@Override
	public String toString() {
		// 객체를 출력할 때 읽기 쉬운 형태로 보여줌
		return "Member1 [id=" + id + ", name=" + name + ", password=" + password + "]";
	}
}
public class ObjectTest1 {
	public static void main(String[] args) {
		Member1 obj1 = new Member1(10, "자바", "duke");
		Member1 obj2 = new Member1(10, "자바", "duke");
		// 값은 같아 보이지만 new를 두 번 사용했으므로 서로 다른 객체임
		System.out.println(obj1.equals(obj2));
		System.out.println(obj1 == obj2);
		Member1 obj3 = obj2;
		// 새 객체를 만든 것이 아니라 obj2가 가리키는 객체를 obj3도 함께 가리킴
		System.out.println(obj3.equals(obj2));
		System.out.println(obj3 == obj2);
	}
}
// 출력결과
// false
// false
// true
// true

obj1과 obj2는 안에 들어 있는 값이 같아 보인다.
둘 다 id는 10, name은 "자바", password는 "duke"다.
하지만 new를 두 번 사용했기 때문에 메모리에는 서로 다른 객체가 만들어진다.


그래서 obj1 == obj2는 false다.
==는 두 참조변수가 같은 객체를 가리키는지 묻기 때문이다.
그리고 Member1은 아직 equals()를 직접 재정의하지 않았으므로 obj1.equals(obj2)도 기본 equals()처럼 같은 객체인지 확인한다.
결과는 false다.


반대로 Member1 obj3 = obj2;는 새 객체를 만든 것이 아니다.
obj2가 가리키던 객체를 obj3도 같이 가리키게 만든 것이다.
그래서 obj3.equals(obj2)와 obj3 == obj2는 둘 다 true가 된다.


여기서 Member1 안에 toString()이 들어 있지만, 이 예제의 핵심은 toString()이 아니다.
toString()은 객체를 출력할 때 어떤 문자열로 보여줄지 정하는 메서드이고, 뒤에서 따로 다시 확인한다.
지금은 먼저 equals()와 ==가 같은 객체를 기준으로 판단한다는 점에 집중하면 된다.


이 예제에서 꼭 잡아야 하는 기준은 아래와 같다.

  • ==는 두 변수가 같은 객체를 가리키는지 확인한다.
  • 기본 equals()도 내용 비교가 아니라 같은 객체인지 확인하는 쪽이다.
  • 직접 만든 객체에서 내용 비교를 원하면 equals()를 재정의해야 한다.

equals()라는 이름만 보고 무조건 내용 비교라고 생각하면 안 된다.
기본 상태에서는 ==와 비슷하게 같은 객체인지 확인한다고 먼저 이해하면 된다.



문자열 비교에서는 왜 equals()가 필요할까

문자열을 함께 보는 이유는 내용 비교가 가장 분명하게 드러나기 때문이다.
String은 Object의 기본 equals()를 그대로 쓰지 않는다.
문자열 내용을 비교하도록 equals()가 이미 재정의되어 있다.


그래서 String에서는 서로 다른 객체로 만들어져도 글자 내용이 같으면 equals() 결과가 true가 나온다.
이 부분은 직접 만든 객체와 비교해서 봐야 이해가 쉽다.

예제: EqualsTest1

// EqualsTest1.java
package day9;
import java.util.Scanner;
public class EqualsTest1 {
	public static void main(String[] args) {
		String s3 = new String("가나다");
		String s4 = new String("가나다");
		// 같은 문자열이지만 new를 두 번 사용했으므로 서로 다른 객체임
		System.out.println(s3 == s4);
		System.out.println(s3.equals(s4));
		// String은 equals()가 내용 비교로 재정의되어 있음
		Scanner scan = new Scanner(System.in);
		System.out.print("문자열 입력 : ");
		String inputStr = scan.next();
		if (s3.equals(inputStr)) {
			// 입력한 문자열 내용이 같으면 실행됨
			System.out.println("\"가나다\"를 입력했군요...");
		} else {
			System.out.println("\"가나다\"가 아니군요...");
		}
		scan.close();
	}
}
// 출력결과
// false
// true
// 문자열 입력 : 가나다
// "가나다"를 입력했군요...

s3와 s4는 new를 두 번 사용해서 만들었기 때문에 서로 다른 객체다.
그래서 s3 == s4는 false다.


하지만 두 객체가 담고 있는 문자열 내용은 "가나다"로 같다.
String은 문자열 내용을 비교하도록 equals()가 재정의되어 있으므로 s3.equals(s4)는 true가 된다.


입력값 비교도 같은 원리다.
사용자가 "가나다"를 입력했다고 해서 그 입력값이 s3와 같은 객체라는 뜻은 아니다.
그래서 문자열 내용 비교는 ==가 아니라 equals()를 사용해야 한다.


여기서 초보자가 많이 헷갈리는 질문이 나온다.
“왜 Member1은 내용이 같아도 false인데, String은 내용이 같으면 true일까?”
답은 단순하다.
Member1은 equals()를 재정의하지 않았고, String은 이미 재정의해 두었기 때문이다.


즉, equals()는 자동 내용 비교 메서드가 아니다.
클래스가 어떤 기준으로 equals()를 다시 만들어 두었는지에 따라 결과가 달라진다.



직접 만든 객체에서는 왜 equals()를 다시 만들어야 할까

직접 만든 클래스는 그냥 두면 Object의 기본 equals()를 사용한다.
기본 equals()는 this == obj를 비교하므로 객체 안의 값이 같아도 서로 다른 객체면 false가 나온다.


그래서 직접 만든 객체를 값 기준으로 비교하고 싶다면 equals() 안에 “어떤 값이 같으면 같은 객체로 볼 것인지”를 직접 적어야 한다.
회원 객체라면 id, name, password가 모두 같을 때 같은 회원 정보로 볼 수 있다.
이 기준은 클래스마다 달라질 수 있다.

예제: ObjectTest2

// ObjectTest2.java
package day10;
import java.util.HashSet;
class Member2 {
	private int id;
	private String name;
	private String password;
	Member2(int id, String name, String password) {
		// 회원 정보를 객체에 저장함
		this.id = id;
		this.name = name;
		this.password = password;
	}
	@Override
	public boolean equals(Object o) {
		if (this == o) {
			// 완전히 같은 객체면 바로 true
			return true;
		}
		if (!(o instanceof Member2)) {
			// null이거나 Member2 타입이 아니면 비교 대상이 아님
			return false;
		}
		Member2 m = (Member2) o;
		// 세 필드가 모두 같으면 같은 값의 객체로 판단함
		return id == m.id && name.equals(m.name) && password.equals(m.password);
	}
}
public class ObjectTest2 {
	public static void main(String[] args) {
		Member2 obj1 = new Member2(10, "자바", "duke");
		Member2 obj2 = new Member2(10, "자바", "duke");
		Member2 obj3 = new Member2(20, "자바", "duke");
		System.out.println(obj1.equals(obj2));
		// 값 기준으로 같으므로 true
		System.out.println(obj1 == obj2);
		// 서로 다른 객체이므로 false
		System.out.println(obj1.equals(null));
		// null은 비교 대상이 아니므로 false
		System.out.println(obj1.equals(new java.util.Date()));
		// 다른 타입 객체이므로 false
		System.out.println(obj1.equals(obj3));
		// id가 다르므로 false
		HashSet<Member2> set = new HashSet<>();
		System.out.println(set.add(obj1));
		System.out.println(set.add(obj2));
		System.out.println(set.add(obj3));
		System.out.println(set.size());
		// hashCode()를 재정의하지 않았으므로 obj1과 obj2가 함께 저장될 수 있음
	}
}
// 출력결과
// true
// false
// false
// false
// false
// true
// true
// true
// 3

이 예제에서는 equals() 안에 비교 기준을 직접 넣었다.
id, name, password가 모두 같으면 같은 값의 객체로 보겠다는 뜻이다.
그래서 obj1.equals(obj2)는 true가 된다.


하지만 obj1 == obj2는 여전히 false다.
왜냐하면 obj1과 obj2는 new를 두 번 사용해서 만든 서로 다른 객체이기 때문이다.


여기서 equals() 코드 흐름을 번호로 보면 더 쉽다.

  • this == o이면 같은 객체이므로 바로 true를 반환한다.
  • o가 Member2 타입이 아니면 비교할 수 없으므로 false를 반환한다.
  • 같은 타입이면 Member2로 형변환한 뒤 필드 값을 비교한다.
  • id, name, password가 모두 같으면 true를 반환한다.

이제 equals()는 값 기준 비교가 된다.
하지만 아래쪽의 HashSet 결과를 보면 obj1, obj2, obj3가 모두 저장된다.
obj1과 obj2는 equals()로 보면 같은데도 HashSet에서는 둘 다 들어간다.


이유는 아직 hashCode()를 재정의하지 않았기 때문이다.
즉, 해시 기반 컬렉션에서는 equals()만 재정의하면 부족하다.



HashSet에서는 왜 hashCode()까지 필요할까

HashSet은 중복을 허용하지 않는 컬렉션이다.
다만 저장 순서를 보장하지는 않는다.
이번 글에서는 순서보다 중복 판단 기준이 핵심이므로, 어떤 기준으로 같은 객체를 찾는지에 집중하면 된다.


HashSet은 중복인지 확인할 때 눈에 보이는 값을 바로 비교하는 것이 아니다.
먼저 hashCode()로 저장 위치와 비교 후보를 찾고, 그다음 equals()로 최종 확인한다.


이 흐름은 사물함 번호로 생각하면 쉽다.
사물함이 아주 많을 때 어떤 물건이 이미 있는지 확인하려면 모든 사물함을 다 열어보는 것보다 먼저 사물함 번호를 확인하는 편이 빠르다.
hashCode()는 이 사물함 번호 같은 역할을 한다.
그리고 같은 번호 쪽에서 실제 물건이 같은지 확인하는 과정이 equals()다.


HashSet.add()의 반환값도 같이 알아야 한다.

  • 새 객체로 판단되어 저장되면 true를 반환한다.
  • 이미 같은 객체가 있다고 판단되어 저장하지 않으면 false를 반환한다.

그래서 add() 결과를 보면 HashSet이 그 객체를 새 데이터로 봤는지, 중복 데이터로 봤는지 확인할 수 있다.


hashCode()를 재정의할 때 지켜야 하는 핵심 규칙은 아래 세 가지다.

  • 같은 객체에서 hashCode()를 여러 번 호출하면 같은 값이 나와야 한다.
  • equals()가 true인 두 객체는 hashCode()도 같아야 한다.
  • hashCode()가 같다고 해서 반드시 equals()가 true인 것은 아니다.

첫 번째 규칙에는 전제가 있다.
equals() 비교에 사용되는 필드 값이 바뀌지 않았을 때 같은 hashCode()가 나와야 한다는 뜻이다.
프로그램을 새로 실행했을 때까지 무조건 같은 값이 나와야 한다는 뜻은 아니다.


두 번째 규칙이 가장 중요하다.
equals()에서 id, name, password를 기준으로 비교한다면 hashCode()도 같은 필드를 기준으로 만들어야 한다.
그래야 HashSet이 두 객체를 같은 후보로 보고 equals()까지 비교할 수 있다.
반대로 equals()와 hashCode()의 기준이 서로 다르면 값이 같아 보이는 객체가 중복으로 저장될 수 있다.



equals()와 hashCode()를 함께 맞춘 완성 예제

이제 앞의 문제를 해결한 완성 예제를 보자.
이번에는 equals()와 hashCode()를 같은 필드 기준으로 함께 재정의한다.


Objects.hash()는 여러 값을 넣으면 그 값들을 기준으로 해시코드를 만들어 주는 메서드다.
직접 복잡한 계산을 만들 필요 없이, equals()에서 비교한 필드를 그대로 넣으면 기준을 맞추기 쉽다.

예제: ObjectTest3

// ObjectTest3.java
package day10;
import java.util.HashSet;
import java.util.Objects;
class Member3 {
	private int id;
	private String name;
	private String password;
	Member3(int id, String name, String password) {
		// 회원 정보를 객체에 저장함
		this.id = id;
		this.name = name;
		this.password = password;
	}
	@Override
	public boolean equals(Object o) {
		if (this == o) {
			// 완전히 같은 객체면 바로 true
			return true;
		}
		if (!(o instanceof Member3)) {
			// null이거나 Member3 타입이 아니면 false
			return false;
		}
		Member3 m = (Member3) o;
		// equals()의 비교 기준은 id, name, password임
		return id == m.id && name.equals(m.name) && password.equals(m.password);
	}
	@Override
	public int hashCode() {
		// equals()에서 비교한 필드와 같은 기준으로 hashCode()를 만듦
		return Objects.hash(id, name, password);
	}
	@Override
	public String toString() {
		// HashSet 출력 시 객체 내용을 읽기 쉽게 보여줌
		return "(" + id + ":" + name + ":" + password + ")";
	}
}
public class ObjectTest3 {
	public static void main(String[] args) {
		Member3 obj1 = new Member3(10, "자바", "duke");
		Member3 obj2 = new Member3(10, "자바", "duke");
		Member3 obj3 = new Member3(20, "자바", "duke");
		HashSet<Member3> set = new HashSet<>();
		System.out.println(set.add(obj1));
		System.out.println(set.add(obj2));
		System.out.println(set.add(obj3));
		System.out.println("저장된 데이터의 개수 : " + set.size());
	}
}
// 출력결과
// true
// false
// true
// 저장된 데이터의 개수 : 2

이번에는 obj1과 obj2가 같은 값의 객체로 판단된다.
둘 다 id, name, password가 같고, equals()와 hashCode()가 같은 필드 기준으로 만들어졌기 때문이다.


그래서 set.add(obj1)은 처음 저장이므로 true다.
set.add(obj2)는 이미 같은 값의 객체가 있다고 판단되므로 저장하지 않고 false다.
set.add(obj3)은 id가 다르므로 새 객체로 판단되어 true다.


결과적으로 HashSet에 저장된 데이터 개수는 2다.
이 예제의 핵심은 하나다.


직접 만든 객체를 HashSet에서 값 기준으로 중복 제거하려면 equals()와 hashCode()를 같은 기준으로 함께 재정의해야 한다.



toString()은 객체 출력 결과를 읽기 쉽게 만든다

toString()은 객체를 출력했을 때 어떤 문자열로 보여줄지 정하는 메서드다.
객체를 그냥 출력하면 내부적으로 toString()이 호출된다.
그래서 객체가 null이 아니라면 System.out.println(obj)와 System.out.println(obj.toString())은 같은 결과처럼 이해하면 된다.


기본 toString()은 객체의 중요한 값을 보여주지 않는다.
클래스이름@해시값 형태에 가까운 문자열을 보여준다.
그래서 객체 안에 어떤 값이 들어 있는지 확인하기 어렵다.


직접 만든 클래스에서는 보통 toString()을 재정의해서 사람이 읽기 쉬운 형태로 바꾼다.

예제: ToStringExample

// ToStringExample.java
package day10;
class Card {
	private int number;
	private String kind;
	Card(int number, String kind) {
		// 카드 숫자와 모양을 저장함
		this.number = number;
		this.kind = kind;
	}
	@Override
	public String toString() {
		// 객체 안의 값을 사람이 읽기 쉬운 문자열로 돌려줌
		return "Card[number=" + number + ", kind=" + kind + "]";
	}
}
public class ToStringExample {
	public static void main(String[] args) {
		Card card = new Card(7, "HEART");
		System.out.println(card);
		// println()은 내부적으로 card.toString() 결과를 출력함
		System.out.println(card.toString());
	}
}
// 출력결과
// Card[number=7, kind=HEART]
// Card[number=7, kind=HEART]

System.out.println(card)를 실행했는데도 객체 주소처럼 보이지 않고 카드 정보가 출력된다.
그 이유는 Card 클래스에서 toString()을 재정의했기 때문이다.


이 메서드를 재정의하지 않았다면 day10.Card@해시값 같은 형태로 보였을 수 있다.
그 출력만 보고는 카드 번호가 7인지, 모양이 HEART인지 바로 알기 어렵다.


그래서 toString()은 디버깅할 때 특히 유용하다.
객체를 출력하기만 해도 현재 객체가 어떤 값을 가지고 있는지 확인할 수 있기 때문이다.


또 하나 기억할 점이 있다.
Object의 toString()은 public 메서드다.
오버라이딩할 때는 조상 메서드보다 접근 범위를 좁힐 수 없다.
그래서 직접 만든 클래스에서 toString()을 재정의할 때도 public으로 작성해야 한다.



hashCode()를 보충 예제로 한 번 더 확인하기

hashCode()는 클래스마다 기준이 다를 수 있다.
직접 만든 클래스가 hashCode()를 재정의하지 않으면 Object의 기본 hashCode()를 사용한다.
이 기본값은 보통 객체마다 다르게 보이는 정수값으로 나오지만, 실제 메모리 주소 그 자체라고 외우는 것은 정확하지 않다.
초보자 단계에서는 “서로 다른 객체를 구분하는 데 쓰이는 기본 번호” 정도로 이해하면 된다.
반면 String, Integer 같은 클래스는 내용이나 값 기준으로 hashCode()가 이미 재정의되어 있다.

예제: HashCodeExample

// HashCodeExample.java
package day10;
class Name {
	String firstName;
	String lastName;
	Name(String firstName, String lastName) {
		// 이름과 성을 저장함
		this.firstName = firstName;
		this.lastName = lastName;
	}
}
public class HashCodeExample {
	public static void main(String[] args) {
		Name name1 = new Name("길동", "고");
		Name name2 = new Name("길동", "고");
		// 직접 만든 Name은 equals()와 hashCode()를 재정의하지 않음
		System.out.println(name1 == name2);
		System.out.println(name1.equals(name2));
		String str1 = new String("길동고");
		String str2 = new String("길동고");
		// String은 내용 기준 equals()와 hashCode()가 재정의되어 있음
		System.out.println(str1 == str2);
		System.out.println(str1.equals(str2));
		System.out.println(str1.hashCode() == str2.hashCode());
		Integer num1 = Integer.valueOf(100);
		Integer num2 = Integer.valueOf(100);
		// Integer도 값 기준 hashCode()가 정의되어 있음
		System.out.println(num1.hashCode() == num2.hashCode());
	}
}
// 출력결과
// false
// false
// false
// true
// true
// true

name1과 name2는 안에 들어 있는 값이 같아 보이지만 직접 만든 Name 클래스에서 equals()를 재정의하지 않았다.
그래서 name1.equals(name2)는 기본 equals()처럼 같은 객체인지 확인하고, 결과는 false가 된다.


반면 str1과 str2는 new를 두 번 사용했으므로 서로 다른 객체다.
그래서 str1 == str2는 false다.
하지만 문자열 내용이 같으므로 str1.equals(str2)는 true이고, String의 hashCode()도 내용 기준으로 만들어져 있으므로 두 해시코드 비교 결과도 true다.


Integer도 값 기준으로 hashCode()가 정의되어 있다.
그래서 같은 값 100을 가진 Integer 값은 같은 해시코드를 가진다.
여기서는 두 변수가 같은 객체인지보다, 같은 값이면 hashCode() 기준도 같아진다는 점을 확인하면 된다.


이 예제에서 중요한 결론은 이것이다.
모든 클래스가 자동으로 내용 기준 비교를 해주는 것은 아니다.
String과 Integer처럼 이미 잘 재정의된 클래스도 있지만, 직접 만든 클래스는 필요한 기준에 맞게 직접 재정의해야 한다.



헷갈리기 쉬운 부분

객체 비교는 처음 볼 때 이름 때문에 헷갈리기 쉽다.
특히 equals()라는 이름 때문에 자동으로 내용 비교를 해준다고 생각하기 쉽다.
하지만 실제 기준은 클래스마다 다르다.

1. 같은 내용과 같은 객체는 다르다

값이 같아 보여도 new를 두 번 사용하면 서로 다른 객체다.
==는 두 객체의 내용이 같은지 보는 것이 아니라 같은 객체를 가리키는지 본다.


2. 기본 equals()는 내용 비교가 아니다

Object의 기본 equals()는 this == obj를 반환한다.
그래서 직접 만든 클래스에서 equals()를 재정의하지 않으면 기본적으로 같은 객체인지 확인하는 방식으로 동작한다.


3. String은 이미 equals()가 재정의되어 있다

String은 문자열 내용을 비교하도록 equals()가 재정의되어 있다.
그래서 문자열 비교에서는 ==가 아니라 equals()를 사용해야 한다.


4. equals()만 재정의하면 HashSet에서는 부족할 수 있다

HashSet은 먼저 hashCode()로 비교 후보를 찾고, 그다음 equals()로 최종 확인한다.
그래서 값 기준 중복 제거를 원한다면 equals()와 hashCode()를 함께 재정의해야 한다.
그리고 HashSet은 저장 순서를 보장하지 않으므로, 이 글에서는 출력 순서보다 중복 판단 기준을 중심으로 보면 된다.


5. hashCode()가 같다고 무조건 같은 객체는 아니다

hashCode()는 비교 후보를 빠르게 찾기 위한 값이다.
최종 판단은 equals()가 한다.
따라서 hashCode()가 같아도 equals()가 false이면 같은 객체가 아니다.


6. toString()은 객체 값을 읽기 쉽게 보여주기 위한 메서드다

객체를 출력했을 때 클래스이름@해시값처럼 보이면 객체 안의 값을 알기 어렵다.
toString()을 재정의하면 객체 안의 중요한 값을 읽기 쉬운 문자열로 확인할 수 있다.


7. Object의 모든 메서드를 한 번에 다 외울 필요는 없다

Object에는 clone(), getClass(), wait(), notify() 같은 메서드도 있다.
하지만 지금 글의 핵심은 객체 비교와 출력이다.
따라서 먼저 equals(), hashCode(), toString()을 정확히 잡는 것이 좋다.


참고

Object를 이해할 때는 메서드 이름을 외우는 방식보다 흐름으로 잡는 것이 좋다.


1. 모든 객체는 결국 Object에서 시작한다.
2. 그래서 모든 객체는 equals(), hashCode(), toString()을 물려받는다.
3. 기본 equals()는 내용 비교가 아니라 같은 객체인지 확인한다.
4. 직접 만든 객체를 값 기준으로 비교하려면 equals()를 재정의해야 한다.
5. 해시 기반 컬렉션에서 값 기준 중복 제거까지 하려면 hashCode()도 함께 재정의해야 한다.
6. 객체를 출력했을 때 읽기 쉽게 보이게 하려면 toString()을 재정의한다.


즉, Object 클래스는 모든 객체가 공통으로 가지는 기능의 출발점이다.
이번 글에서는 그중에서도 객체 비교와 출력에 직접 연결되는 equals(), hashCode(), toString()이 핵심이다.


직접 만든 객체를 제대로 비교하고 컬렉션에서 중복까지 처리하려면 equals()와 hashCode()를 같은 기준으로 함께 맞춰야 한다.

0개의 댓글