9.1 Object 클래스 (The Object Class)
안내: 이 문서는 Java 21 버전을 기준으로 작성되었습니다. 심도 있는 세부 내용은 공식 Java Documentation을 참고해주세요.
자바에서는 여러분이 무언가를 만들기 위해 class를 선언하면, 자동으로 숨겨진 상속이 하나 발생합니다. 프로그래머가 명시적으로 extends 키워드를 사용해 다른 부모를 상속받지 않는 한, 자바의 모든 클래스는 기본적으로 java.lang.Object 클래스를 상속받습니다.
결론적으로 자바의 모든 객체는 이 Object 클래스의 자손이며, Object가 제공하는 유용한 메서드들을 공통적으로 사용할 수 있습니다.
1. 객체의 정보를 문자열로 반환: toString()
toString() 메서드는 생성된 객체에 대한 간략한 정보를 문자열(String) 로 반환해줍니다.
보통 System.out.println(객체이름)을 실행하면 화면에 알 수 없는 영어와 숫자의 조합(예: Person@15db9742)이 뜨는 것을 보셨을 텐데, 이는 println() 내부에서 객체의 toString()을 자동으로 호출하기 때문입니다.
원래 Object의 toString()은 클래스이름@객체의해시코드(16진수)를 반환하게 설계되어 있지만, 보통 클래스를 설계할 때 이 메서드를 내가 원하는 정보가 나오게끔 오버라이딩(Overriding) 해서 씁니다.
class Book {
String title;
String author;
int year;
Book(String title, String author, int year) {
this.title = title;
this.author = author;
this.year = year;
}
// Object 클래스의 toString() 메서드를 재정의
@Override
public String toString() {
return String.format("Book{title='%s', author='%s', year=%d}",
title, author, year);
}
}
public class ToStringExample {
public static void main(String[] args) {
Book myBook = new Book("자바의 정석", "남궁성", 2016);
// 오버라이딩 전: "Book@15db9742" 같은 값 출력
// 오버라이딩 후: toString()이 자동으로 호출됨
System.out.println(myBook); // Book{title='자바의 정석', author='남궁성', year=2016}
// 문자열 연결 시에도 toString() 자동 호출
String info = "내 책: " + myBook;
System.out.println(info); // 내 책: Book{title='자바의 정석', author='남궁성', year=2016}
}
}
System.out.println(객체)호출 시- 문자열 연결 연산자
+와 함께 사용 시 String.valueOf(객체)호출 시
2. 객체의 내용물 비교: equals(Object obj)
자바에서 쌍둥이처럼 똑같이 생긴 두 객체가 있다고 칩시다.
이때 == 연산자로 두 객체를 비교하면 자바는 "메모리 주소"가 같은지를 비교하기 때문에 100% false라고 대답합니다 (서로 다른 곳에 각각 집을 지었으므로).
"안에 든 내용물(데이터)이 같은지" 를 비교하고 싶다면 equals() 메서드를 써야 합니다. 단, 이 기능도 기본적으로는 ==와 똑같이 주소값을 비교하도록 동작하기 때문에, 프로그래머가 자신의 기준에 맞게 오버라이딩 해야 합니다.
class User {
long id;
String username;
User(long id, String username) {
this.id = id;
this.username = username;
}
// Object의 equals()를 오버라이딩하여 내용물을 비교하도록 수정
@Override
public boolean equals(Object obj) {
// 1. 자기 자신과 비교하면 무조건 true
if (this == obj) return true;
// 2. null이거나 다른 타입이면 false
if (obj == null || getClass() != obj.getClass()) return false;
// 3. 같은 타입으로 캐스팅
User other = (User) obj;
// 4. 핵심 필드 비교 (id가 같으면 같은 사용자로 판단)
return this.id == other.id;
}
}
public class EqualsExample {
public static void main(String[] args) {
User u1 = new User(1001L, "김철수");
User u2 = new User(1001L, "김철수");
User u3 = new User(1002L, "이영희");
System.out.println(u1 == u2); // false (메모리 주소가 다름)
System.out.println(u1.equals(u2)); // true (오버라이딩 덕분에 id가 같아서 true)
System.out.println(u1.equals(u3)); // false (id가 다름)
System.out.println(u1.equals(null)); // false (null 안전)
}
}
3. 객체의 고유 번호: hashCode()
hashCode() 메서드는 객체를 식별하기 위한 하나의 고유한 정수값(해시코드)을 반환합니다. 자바 시스템이 메모리 저장 속도를 극대화할 때(특히 HashMap, HashSet 등에서) 데이터를 찾아가는 열쇠(Key)로 사용됩니다.
equals + hashCode 계약(Contract) 규칙
자바의 공식 계약에 따르면:
equals()가true를 반환하는 두 객체는 반드시 동일한hashCode()를 반환해야 합니다.hashCode()가 같다고 해서 반드시equals()가true인 것은 아닙니다. (해시 충돌은 허용)equals()를 오버라이딩했다면 반드시hashCode()도 함께 오버라이딩해야 합니다.
class Product {
private String code;
private String name;
Product(String code, String name) {
this.code = code;
this.name = name;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Product other = (Product) obj;
return java.util.Objects.equals(code, other.code); // code가 같으면 같은 상품
}
@Override
public int hashCode() {
// equals에서 code를 기준으로 비교하므로, hashCode도 code 기반으로 생성
return java.util.Objects.hash(code);
}
@Override
public String toString() {
return "Product[" + code + ": " + name + "]";
}
}
import java.util.HashSet;
import java.util.HashMap;
public class HashCodeExample {
public static void main(String[] args) {
Product p1 = new Product("A001", "노트북");
Product p2 = new Product("A001", "노트북"); // p1과 동일한 상품
Product p3 = new Product("B002", "마우스");
System.out.println("p1.equals(p2): " + p1.equals(p2)); // true
System.out.println("p1.hashCode(): " + p1.hashCode());
System.out.println("p2.hashCode(): " + p2.hashCode()); // p1과 동일!
// HashSet은 hashCode() + equals()를 조합해 중복 판단
HashSet<Product> set = new HashSet<>();
set.add(p1);
set.add(p2); // p1과 동일하므로 추가 안 됨
set.add(p3);
System.out.println("Set 크기: " + set.size()); // 2 (p1, p3)
// HashMap 키로 사용
HashMap<Product, Integer> stock = new HashMap<>();
stock.put(p1, 100);
// p2는 p1과 equals가 true이므로 같은 키로 인식
System.out.println("p2로 조회: " + stock.get(p2)); // 100
}
}
equals가 true인 두 객체가 다른 hashCode를 가지면, HashMap/HashSet에서 같은 키/요소로 인식하지 않습니다. 이는 아주 미묘한 버그를 유발하므로 반드시 함께 오버라이딩해야 합니다.
4. getClass(): 런타임 클래스 정보
getClass()는 객체의 런타임 타입 정보를 담은 Class 객체를 반환합니다. 주로 리플렉션(Reflection)이나 타입 비교에 사용합니다.
public class GetClassExample {
public static void main(String[] args) {
Object obj1 = new String("Hello");
Object obj2 = new java.util.ArrayList<>();
Object obj3 = new int[]{1, 2, 3};
System.out.println(obj1.getClass()); // class java.lang.String
System.out.println(obj1.getClass().getName()); // java.lang.String
System.out.println(obj1.getClass().getSimpleName()); // String
System.out.println(obj2.getClass().getSimpleName()); // ArrayList
System.out.println(obj3.getClass().getSimpleName()); // int[]
// 타입 비교 (정확히 같은 타입인지)
String s1 = "Hello";
String s2 = "World";
System.out.println(s1.getClass() == s2.getClass()); // true (둘 다 String)
// instanceof vs getClass() 차이
Number n = new Integer(42);
System.out.println(n instanceof Number); // true (상속 관계 포함)
System.out.println(n.getClass() == Number.class); // false (정확히 Number가 아님)
System.out.println(n.getClass() == Integer.class); // true
}
}
5. clone(): 얕은 복사와 깊은 복사
clone() 메서드는 객체를 복제합니다. 사용하려면 Cloneable 인터페이스를 구현(implement)해야 합니다.
얕은 복사 (Shallow Copy)
기본 clone()은 얕은 복사(Shallow Copy) 를 수행합니다. 기본 타입 필드는 값이 복사되지만, 참조 타입 필드는 같은 객체를 참조 합니다.
import java.util.Arrays;
class ShallowExample implements Cloneable {
int value;
int[] data; // 참조 타입
ShallowExample(int value, int[] data) {
this.value = value;
this.data = data;
}
@Override
public ShallowExample clone() {
try {
return (ShallowExample) super.clone(); // 얕은 복사
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
}
public class CloneDemo {
public static void main(String[] args) {
ShallowExample original = new ShallowExample(10, new int[]{1, 2, 3});
ShallowExample copy = original.clone();
System.out.println("원본 value: " + original.value); // 10
System.out.println("복사 value: " + copy.value); // 10
// 복사본의 value 변경
copy.value = 99;
System.out.println("변경 후 원본: " + original.value); // 10 (영향 없음)
System.out.println("변경 후 복사: " + copy.value); // 99
// 복사본의 배열 요소 변경 - 얕은 복사의 문제점!
copy.data[0] = 999;
System.out.println("원본 data[0]: " + original.data[0]); // 999 (같이 바뀜!)
System.out.println("같은 배열? " + (original.data == copy.data)); // true
}
}
깊은 복사 (Deep Copy)
참조 타입 필드까지 완전히 독립적으로 복사하려면 깊은 복사(Deep Copy) 를 직접 구현해야 합니다.
class DeepExample implements Cloneable {
int value;
int[] data;
DeepExample(int value, int[] data) {
this.value = value;
this.data = data;
}
@Override
public DeepExample clone() {
try {
DeepExample copy = (DeepExample) super.clone();
// 참조 타입 필드는 직접 새 배열 생성 (깊은 복사)
copy.data = Arrays.copyOf(this.data, this.data.length);
return copy;
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
}
public class DeepCloneDemo {
public static void main(String[] args) {
DeepExample original = new DeepExample(10, new int[]{1, 2, 3});
DeepExample copy = original.clone();
copy.data[0] = 999;
System.out.println("원본 data[0]: " + original.data[0]); // 1 (영향 없음!)
System.out.println("복사 data[0]: " + copy.data[0]); // 999
System.out.println("같은 배열? " + (original.data == copy.data)); // false
}
}
clone()은 설계 결함이 많아 현업에서는 복사 생성자(Copy Constructor) 또는 정적 팩토리 메서드 를 더 많이 사용합니다.
// 복사 생성자 방식 (권장)
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
Point(Point other) { this.x = other.x; this.y = other.y; } // 복사 생성자
}
6. finalize() 사용 금지
finalize() 메서드는 객체가 가비지 컬렉터(GC)에 의해 수거되기 직전에 호출되도록 설계되었습니다. 그러나 Java 9부터 Deprecated(사용 권장하지 않음) 로 표시되었고, Java 18에서는 완전히 제거 예정입니다.
- GC가 호출할지, 언제 호출할지 전혀 보장이 없습니다.
- 성능 저하와 예측 불가능한 동작을 유발합니다.
- 대신
AutoCloseable+try-with-resources또는Cleaner클래스를 사용하세요.
// 나쁜 예
class BadResource {
@Override
protected void finalize() {
// 절대 사용하지 마세요!
}
}
// 좋은 예 - AutoCloseable 구현
class GoodResource implements AutoCloseable {
@Override
public void close() {
System.out.println("리소스 해제");
}
}
// try-with-resources로 자동 close() 호출
// try (GoodResource r = new GoodResource()) {
// // 사용
// } // 블록 종료 시 r.close() 자동 호출
7. Objects 유틸리티 클래스
java.util.Objects는 Object 관련 작업을 안전하게 처리하는 정적 유틸 메서드 모음입니다. 특히 null 안전성에 탁월합니다.
import java.util.Objects;
public class ObjectsUtilDemo {
public static void main(String[] args) {
String s1 = "Hello";
String s2 = null;
String s3 = "Hello";
// Objects.equals: null 안전한 동등 비교
System.out.println(Objects.equals(s1, s3)); // true
System.out.println(Objects.equals(s1, s2)); // false (NPE 없이!)
System.out.println(Objects.equals(s2, s2)); // true (null == null)
// s2.equals(s3) 는 NullPointerException 발생!
// Objects.toString: null 안전한 toString
System.out.println(Objects.toString(s1)); // "Hello"
System.out.println(Objects.toString(s2)); // "null" (문자열)
System.out.println(Objects.toString(s2, "기본값")); // "기본값"
// Objects.hash: 여러 필드로 hashCode 생성
int hash = Objects.hash("김철수", 25, "kim@example.com");
System.out.println("해시코드: " + hash);
// Objects.requireNonNull: null이면 즉시 예외 발생 (방어적 프로그래밍)
try {
String result = Objects.requireNonNull(s2, "값이 null일 수 없습니다.");
} catch (NullPointerException e) {
System.out.println("에러: " + e.getMessage()); // 에러: 값이 null일 수 없습니다.
}
// Objects.isNull / nonNull
System.out.println(Objects.isNull(s2)); // true
System.out.println(Objects.nonNull(s1)); // true
}
}
8. 실전 예제: Student 클래스 완전 구현
Student 클래스에 equals(), hashCode(), toString()을 모두 오버라이딩하고 HashSet에서 제대로 동작하는지 테스트합니다.
import java.util.Objects;
public class Student {
private final String studentId; // 학번 (식별자)
private String name;
private int grade;
private double gpa;
public Student(String studentId, String name, int grade, double gpa) {
this.studentId = Objects.requireNonNull(studentId, "학번은 필수입니다.");
this.name = Objects.requireNonNull(name, "이름은 필수입니다.");
this.grade = grade;
this.gpa = gpa;
}
// --- equals: 학번이 같으면 같은 학생 ---
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Student other = (Student) obj;
return Objects.equals(studentId, other.studentId);
}
// --- hashCode: equals의 기준(studentId)으로 생성 ---
@Override
public int hashCode() {
return Objects.hash(studentId);
}
// --- toString: 읽기 쉬운 형식 ---
@Override
public String toString() {
return String.format("Student{학번='%s', 이름='%s', 학년=%d, GPA=%.2f}",
studentId, name, grade, gpa);
}
// Getter
public String getStudentId() { return studentId; }
public String getName() { return name; }
public int getGrade() { return grade; }
public double getGpa() { return gpa; }
}
import java.util.HashSet;
import java.util.HashMap;
import java.util.Set;
import java.util.Map;
public class StudentTest {
public static void main(String[] args) {
Student s1 = new Student("2024001", "김철수", 2, 3.8);
Student s2 = new Student("2024001", "김철수", 2, 3.8); // s1과 동일 학번
Student s3 = new Student("2024002", "이영희", 1, 4.0);
Student s4 = new Student("2024003", "박민준", 3, 3.2);
// equals 테스트
System.out.println("=== equals 테스트 ===");
System.out.println("s1 == s2: " + (s1 == s2)); // false (다른 객체)
System.out.println("s1.equals(s2): " + s1.equals(s2)); // true (학번 같음)
System.out.println("s1.equals(s3): " + s1.equals(s3)); // false (학번 다름)
// hashCode 테스트
System.out.println("\n=== hashCode 테스트 ===");
System.out.println("s1.hashCode(): " + s1.hashCode());
System.out.println("s2.hashCode(): " + s2.hashCode()); // s1과 동일!
System.out.println("s3.hashCode(): " + s3.hashCode()); // 다름
// HashSet 테스트: 중복 제거 확인
System.out.println("\n=== HashSet 중복 제거 ===");
Set<Student> studentSet = new HashSet<>();
studentSet.add(s1);
studentSet.add(s2); // s1과 동일하므로 추가 안 됨
studentSet.add(s3);
studentSet.add(s4);
System.out.println("Set 크기: " + studentSet.size()); // 3 (s1, s3, s4)
studentSet.forEach(System.out::println);
// HashMap 테스트: 학번으로 성적 관리
System.out.println("\n=== HashMap 성적 관리 ===");
Map<Student, String> grades = new HashMap<>();
grades.put(s1, "A+");
grades.put(s3, "A0");
grades.put(s4, "B+");
// s2는 s1과 동일한 hashCode + equals이므로 같은 키로 조회 가능
System.out.println("s2로 s1의 성적 조회: " + grades.get(s2)); // A+
// toString 테스트
System.out.println("\n=== toString 테스트 ===");
System.out.println(s1);
System.out.println(s3);
// Objects 유틸 활용
System.out.println("\n=== Objects 유틸 ===");
System.out.println("s1과 s2 equals: " + Objects.equals(s1, s2)); // true
System.out.println("null 안전 비교: " + Objects.equals(null, s1)); // false
}
}
[실행 결과]
=== equals 테스트 ===
s1 == s2: false
s1.equals(s2): true
s1.equals(s3): false
=== hashCode 테스트 ===
s1.hashCode(): (동일한 값)
s2.hashCode(): (동일한 값)
s3.hashCode(): (다른 값)
=== HashSet 중복 제거 ===
Set 크기: 3
Student{학번='2024001', 이름='김철수', 학년=2, GPA=3.80}
Student{학번='2024002', 이름='이영희', 학년=1, GPA=4.00}
Student{학번='2024003', 이름='박민준', 학년=3, GPA=3.20}
=== HashMap 성적 관리 ===
s2로 s1의 성적 조회: A+
=== toString 테스트 ===
Student{학번='2024001', 이름='김철수', 학년=2, GPA=3.80}
Student{학번='2024002', 이름='이영희', 학년=1, GPA=4.00}
=== Objects 유틸 ===
s1과 s2 equals: true
null 안전 비교: false
마치며
Object 클래스의 핵심 메서드를 정리하면 다음과 같습니다.
| 메서드 | 기본 동작 | 오버라이딩 이유 |
|---|---|---|
toString() | 클래스명@해시코드 | 의미 있는 문자열 표현 |
equals() | 주소 비교 (==) | 내용(값) 기반 동등 비교 |
hashCode() | 주소 기반 해시코드 | HashMap/HashSet 일관성 |
getClass() | 런타임 클래스 반환 | 오버라이딩 불필요 (final) |
clone() | 얕은 복사 | 깊은 복사 필요 시 |
finalize() | GC 전 호출 | 사용 금지(Deprecated) |
equals()와hashCode()는 반드시 쌍으로 오버라이딩 합니다.Objects유틸 클래스를 활용하면null안전한 코드를 쉽게 작성할 수 있습니다.clone()대신 복사 생성자나 정적 팩토리 메서드를 사용하는 것이 현대 자바 스타일입니다.