자바에서 클래스를 만들다 보면 직접 상속을 쓰지 않았는데도 사용할 수 있는 메서드가 있습니다. 대표적으로 toString(), equals(), hashCode(), getClass()가 있습니다.

이 메서드들은 어디서 온 걸까요? 정답은 Object 클래스입니다.

자바의 모든 클래스는 최종적으로 Object 클래스를 부모로 가집니다. 이번 글에서는 Object가 왜 중요한지, 그리고 자주 만나는 toString(), equals(), hashCode()를 어떻게 이해하면 좋은지 정리합니다.

상속과 오버라이딩이 아직 헷갈린다면 자바 상속 정리 - extends와 오버라이딩 이해하기를 먼저 보면 더 자연스럽습니다.


Object 클래스란?

Object는 자바 클래스 계층의 가장 위에 있는 클래스입니다. 직접 부모 클래스를 지정하지 않은 클래스도 자동으로 Object를 상속받습니다.

예를 들어 아래 클래스가 있다고 하겠습니다.

class BlogPost {
    String title;

    BlogPost(String title) {
        this.title = title;
    }
}

겉으로는 아무 클래스도 상속하지 않는 것처럼 보입니다. 하지만 자바 입장에서는 다음과 비슷하게 볼 수 있습니다.

class BlogPost extends Object {
    String title;

    BlogPost(String title) {
        this.title = title;
    }
}

extends Object를 직접 적지 않아도 자바가 자동으로 연결해줍니다. 그래서 BlogPost 객체에서도 toString(), equals(), hashCode() 같은 메서드를 사용할 수 있습니다.


왜 모든 클래스가 Object를 상속할까?

모든 객체가 공통으로 가져야 하는 기본 기능이 있기 때문입니다.

예를 들어 객체를 출력할 때는 문자열 표현이 필요합니다. 객체끼리 같은지 비교할 때는 비교 기준이 필요합니다. 객체를 자료구조에서 빠르게 찾을 때는 해시값이 필요합니다.

이런 공통 기능을 모든 클래스마다 새로 만들면 불편합니다. 그래서 자바는 가장 위에 Object를 두고 기본 메서드를 제공합니다.

대표적인 메서드는 다음과 같습니다.

메서드역할
toString()객체를 문자열로 표현합니다.
equals()두 객체가 같은지 비교합니다.
hashCode()해시 기반 자료구조에서 사용할 정수 값을 반환합니다.
getClass()객체의 실제 클래스 정보를 반환합니다.

처음에는 모든 메서드를 깊게 외울 필요는 없습니다. 우선 toString(), equals(), hashCode()부터 익히면 충분합니다.


Object 타입으로 여러 객체 받기

Object는 모든 클래스의 부모입니다. 그래서 어떤 객체든 Object 타입 변수에 담을 수 있습니다.

class BlogPost {
    String title;

    BlogPost(String title) {
        this.title = title;
    }
}

class Member {
    String name;

    Member(String name) {
        this.name = name;
    }
}

public class Main {
    public static void main(String[] args) {
        Object post = new BlogPost("Object 클래스 정리");
        Object member = new Member("정민");

        System.out.println(post);
        System.out.println(member);
    }
}

이 코드는 가능합니다. BlogPostMember는 서로 다른 클래스지만 둘 다 Object의 자식이기 때문입니다.

하지만 한계도 있습니다.

Object post = new BlogPost("Object 클래스 정리");

// post.title; // 컴파일 에러

변수 타입이 Object이면 자바는 Object에 정의된 기능만 안다고 판단합니다. BlogPost에만 있는 title 필드는 바로 사용할 수 없습니다.

필요하다면 실제 타입으로 다시 확인하고 꺼내야 합니다.

Object value = new BlogPost("Object 클래스 정리");

if (value instanceof BlogPost post) {
    System.out.println(post.title);
}

즉, Object는 모든 객체를 담을 수 있지만, 구체 클래스의 기능까지 자동으로 사용할 수 있게 해주지는 않습니다.


toString()은 객체를 문자열로 보여줍니다

toString()은 객체를 문자열로 표현할 때 사용하는 메서드입니다. 객체를 System.out.println()에 넣으면 내부적으로 toString() 결과가 사용됩니다.

먼저 toString()을 오버라이딩하지 않은 경우를 보겠습니다.

class BlogPost {
    String title;

    BlogPost(String title) {
        this.title = title;
    }
}

public class Main {
    public static void main(String[] args) {
        BlogPost post = new BlogPost("Object 클래스 정리");

        System.out.println(post);
    }
}

실행하면 보통 다음과 비슷한 형태가 나옵니다.

BlogPost@2f92e0f4

클래스 이름과 알 수 없는 숫자처럼 보이는 값이 출력됩니다. 이 값은 사람이 읽기 좋은 정보라고 보기는 어렵습니다.

그래서 필요한 경우 toString()을 직접 오버라이딩합니다.

class BlogPost {
    String title;
    String category;

    BlogPost(String title, String category) {
        this.title = title;
        this.category = category;
    }

    @Override
    public String toString() {
        return "BlogPost{title='" + title + "', category='" + category + "'}";
    }
}

이제 객체를 출력하면 더 읽기 좋은 결과가 나옵니다.

BlogPost post = new BlogPost("Object 클래스 정리", "Java");

System.out.println(post);
BlogPost{title='Object 클래스 정리', category='Java'}

toString()은 디버깅할 때 특히 유용합니다. 객체 안에 어떤 값이 들어 있는지 빠르게 확인할 수 있기 때문입니다.


equals()는 객체의 같음을 비교합니다

객체 비교에서 가장 많이 헷갈리는 부분이 ==equals()입니다.

==는 두 변수가 같은 객체를 가리키는지 비교합니다. 반면 equals()는 두 객체를 같은 값으로 볼지 비교할 때 사용합니다.

class Member {
    String email;

    Member(String email) {
        this.email = email;
    }
}

public class Main {
    public static void main(String[] args) {
        Member member1 = new Member("deep@example.com");
        Member member2 = new Member("deep@example.com");

        System.out.println(member1 == member2);
        System.out.println(member1.equals(member2));
    }
}

결과는 둘 다 false입니다.

false
false

member1member2는 서로 다른 객체입니다. 그리고 equals()를 따로 오버라이딩하지 않으면 기본적으로 ==와 비슷하게 동작합니다.

하지만 회원을 이메일 기준으로 같은 사람이라고 보고 싶다면 equals()를 직접 정의해야 합니다.

import java.util.Objects;

class Member {
    String email;

    Member(String email) {
        this.email = email;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }

        if (other == null || getClass() != other.getClass()) {
            return false;
        }

        Member member = (Member) other;
        return Objects.equals(email, member.email);
    }
}

이제 같은 이메일을 가진 회원은 같은 값으로 비교됩니다.

Member member1 = new Member("deep@example.com");
Member member2 = new Member("deep@example.com");

System.out.println(member1 == member2);
System.out.println(member1.equals(member2));
false
true

중요한 차이는 다음과 같습니다.

비교기준
==같은 객체를 가리키는지
equals()같은 값으로 볼 수 있는지

hashCode()도 함께 생각해야 합니다

equals()를 오버라이딩할 때는 보통 hashCode()도 함께 오버라이딩합니다.

이유는 HashSet, HashMap 같은 해시 기반 자료구조가 hashCode()를 사용하기 때문입니다. 두 객체가 equals()로 같다면 hashCode()도 같아야 합니다.

import java.util.Objects;

class Member {
    String email;

    Member(String email) {
        this.email = email;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }

        if (other == null || getClass() != other.getClass()) {
            return false;
        }

        Member member = (Member) other;
        return Objects.equals(email, member.email);
    }

    @Override
    public int hashCode() {
        return Objects.hash(email);
    }
}

이렇게 하면 이메일이 같은 Member 객체는 같은 해시 기준을 갖습니다.

import java.util.HashSet;
import java.util.Set;

public class Main {
    public static void main(String[] args) {
        Set<Member> members = new HashSet<>();

        members.add(new Member("deep@example.com"));
        members.add(new Member("deep@example.com"));

        System.out.println(members.size());
    }
}
1

equals()hashCode()를 함께 맞춰두었기 때문에 같은 이메일의 회원이 중복으로 들어가지 않습니다.


getClass()는 실제 클래스 정보를 확인합니다

getClass()는 객체의 실제 클래스 정보를 반환합니다.

Member member = new Member("deep@example.com");

System.out.println(member.getClass());
System.out.println(member.getClass().getName());

실행 결과는 다음과 비슷합니다.

class Member
Member

보통 입문 단계에서 자주 직접 쓰지는 않습니다. 하지만 equals()를 구현할 때 두 객체가 정확히 같은 클래스인지 확인하는 데 사용할 수 있습니다.

if (other == null || getClass() != other.getClass()) {
    return false;
}

이 코드는 비교 대상이 null이거나, 현재 객체와 다른 클래스라면 같지 않다고 판단합니다.


Object와 다형성

Object는 모든 객체의 부모이기 때문에 여러 타입을 하나로 받을 수 있습니다. 이 점은 다형성과 연결됩니다.

예를 들어 객체를 출력하는 도구를 만든다고 하겠습니다.

class ObjectPrinter {
    static void print(Object value) {
        System.out.println(value);
    }
}

이 메서드는 어떤 객체든 받을 수 있습니다.

ObjectPrinter.print(new BlogPost("Object 클래스 정리", "Java"));
ObjectPrinter.print(new Member("deep@example.com"));

print()는 구체 클래스가 BlogPost인지 Member인지 몰라도 됩니다. 그저 Object 타입으로 받고, 각 객체의 toString() 결과를 출력합니다.

이 구조의 장점은 새 클래스를 추가해도 ObjectPrinter를 수정할 필요가 적다는 점입니다. 새 클래스에서 toString()만 잘 정의하면 같은 출력 도구를 그대로 사용할 수 있습니다.

ObjectPrinter는 Object만 바라봅니다.
각 클래스는 자기 toString()을 책임집니다.

이런 방식은 구체적인 클래스 하나하나에 덜 묶이게 만들어줍니다.


실수하기 쉬운 부분

Object를 배울 때는 다음 부분을 조심하면 좋습니다.

  • Object 타입 변수로는 구체 클래스의 필드나 메서드를 바로 사용할 수 없습니다.
  • 객체 정보를 보기 좋게 출력하려면 toString()을 오버라이딩해야 합니다.
  • 값 기준 비교가 필요하면 equals()를 오버라이딩해야 합니다.
  • equals()를 오버라이딩했다면 hashCode()도 함께 맞추는 것이 안전합니다.
  • 문자열 비교는 ==가 아니라 equals()를 사용합니다.

특히 equals()hashCode()는 직접 손으로 매번 작성하기보다 IDE의 생성 기능을 활용하는 편이 안전합니다. 오타나 규칙 누락을 줄일 수 있기 때문입니다.


정리

Object는 자바의 모든 클래스가 최종적으로 상속받는 공통 부모입니다. 그래서 모든 객체는 toString(), equals(), hashCode(), getClass() 같은 기본 메서드를 가질 수 있습니다.

toString()은 객체를 문자열로 표현합니다. equals()는 객체를 값 기준으로 비교할 때 사용합니다. hashCode()는 해시 기반 자료구조에서 객체를 다룰 때 중요합니다.

처음에는 이 문장만 기억해도 좋습니다.

Object는 모든 객체의 공통 출발점입니다.
toString은 출력 기준, equals는 비교 기준, hashCode는 해시 기준입니다.

Object를 이해하면 자바 객체가 기본적으로 어떤 기능을 가지고 있는지 더 선명하게 볼 수 있습니다.