[Java] Object Class

양성욱·2023년 9월 20일
0
post-thumbnail

이 글은 프로그래머스 - 실무 자바 개발을 위한 OOP와 핵심 디자인 패턴 강의를 정리한 내용입니다.

Objcet는 자바에서 아주 특별한 클래스입니다. 자바의 모든 클래스는 Object 클래스의 자손입니다.

public class SomeObject {}

// 암묵적으로 Object 상속
public class SomeObject extends Object {}

따라서 모든 클래스는 Object 클래스가 가지고있는 메서드들을 상속받고 있고, Object 클래스가 가지고 있는 메서드들은 자바 문법 전반에서 특별한 기능을 수행하고있습니다.

Object 클래스는 다음과 같은 메서드들을 가지고 있습니다.

  • clone()
  • equals()
  • finalize()
  • getClass()
  • hashCode()
  • notify()
  • notifyAll()
  • toString()
  • wait()

꽤 많은 메서드를 가지고 있는데 여기서 중요하게 생각할 메서드는 다음 세 가지 입니다.

  • equals()
  • hashCode()
  • toString()

위 메서드들은 다른 메서드들에 비해 압도적으로 많이 사용되고, 오버라이딩(재정의)하여 사용할 일도 많습니다.

equals() - 동일성과 동등성

equals() 메서드를 얘기하다보면 반드시 따라오는 얘기가 있습니다. 바로 동일성과 동등성에 대한 내용입니다.

여기서 동일성과 동등성의 차이는 다음과 같습니다.

  • 동일성 : 비교 대상이 실제로 '똑같은'대상이어야 함 (둘은 실제론 하나임)
  • 동등성 : 비교 대상이 같은 값이라고 우리가 정의한 것

참고로 자바에서는 동일성을 확인하기 위해서는 ==연산자를 사용하고, 동등성을 확인하기 위해서는 equals() 메서드를 오버라이딩하여 사용합니다.

코드로 확인해보겠습니다.

SomeObject

public class SomeObject {
    private int intField;
    private String stringField;

    public SomeObject(int intField, String stringField) {
        this.intField = intField;
        this.stringField = stringField;
    }

    public int getIntField() {
        return intField;
    }

    public String getStringField() {
        return stringField;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        SomeObject that = (SomeObject) o;
        return intField == that.intField && Objects.equals(stringField, that.stringField);
    }
}

SomeObject의 동등성 검사를 위해 equals 메서드를 오버라이딩 하였는데, 사실 여기에는 동일성 검사 역시 수행되고 있습니다.

첫 라인에서 == 연산자를 통해 두 객체의 동일성을 검사합니다. 두 객체가 동일한 인스턴스라면 true가 반환됩니다. 동일하다는건 동등하기도 하다는 의미이니까요.

다음은 onull체크와 클래스 타입 비교를 수행합니다. null이거나 두 객체의 클래스 타입이 다르다면 두 객체는 동등하지 않다고 판단하고 false를 반환합니다.

그 후 o의 클래스 타입을 비교 대상의 클래스 타입으로 캐스팅 연산 후 두 객체의 intFieldstringField의 값이 같은지 비교합니다. 이 두 값이 같다면 두 객체는 동등한 인스턴스라고 판단합니다.

Main

public class EqualsExampleMain {
    public static void main(String[] args) {
        SomeObject sameObject1 = new SomeObject(1, "programmers");
        SomeObject sameObject2 = new SomeObject(1, "programmers");

        SomeObject anotherObject = new SomeObject(100, "edgar");

        // 동일성 비교
        System.out.println(sameObject1 == sameObject2);

        // 동등성 비교
        System.out.println(sameObject1.equals(sameObject2));

        // 동등성 비교
        System.out.println(anotherObject.equals(sameObject1));
    }
}

위 코드를 실행하면 다음과 같은 결과를 확인할 수 있습니다.

false
true
false

첫 번째 로직은 sameObject1sameObject2동일성 검사를 진행하고 있습니다. 두 객체는 클래스 타입은 같지만 서로 다른 인스턴스입니다. 따라서 false가 출력됩니다.

두 번째 로직은 sameObject1sameObject2동등성 검사를 진행하고 있습니다.

	@Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        SomeObject that = (SomeObject) o;
        return intField == that.intField && Objects.equals(stringField, that.stringField);
    }

SomeObject는 두 인스턴스의 intFieldstringField의 값이 같을 경우 동등하다고 판단합니다.

따라서 sameObject1sameObject2는 같은 필드 값을 가지므로 동등합니다. 따라서 true가 출력됩니다.

마지막으로 anotherObjectsameObject1의 동등성 검사를 진행합니다. 두 인스턴스의 필드 값이 다르므로 서로 동등하지 않습니다. 따라서 false가 출력됩니다.

equals() 메서드를 오버라이딩 하지 않았을 경우

만약 equals() 메서드를 오버라이딩하지 않고 동등성 검사를 진행하면 어떻게 될까요?

public class SomeObject {
    private int intField;
    private String stringField;

    public SomeObject(int intField, String stringField) {
        this.intField = intField;
        this.stringField = stringField;
    }

    public int getIntField() {
        return intField;
    }

    public String getStringField() {
        return stringField;
    }

//    @Override
//    public boolean equals(Object o) {
//        if (this == o) return true;
//        if (!(o instanceof SomeObject that)) return false;
//        return intField == that.intField && Objects.equals(stringField, that.stringField);
//    }
}

오버라이딩한 equals() 메서드를 주석 처리하고 코드를 실행해보겠습니다.

false
false
false

동등성 검사가 모두 false로 출력됩니다.

이는 두 인스턴스가 ~상태에서 동등하다라고 따로 정의해주지 않았기 때문입니다. equals() 메서드를 오버라이딩 하는건 같은 클래스 타입의 두 인스턴스가 어떤 기준으로 동등한지를 정의하는 것입니다. 따라서 두 인스턴스간 동등성 기준이 존재하고 검사가 필요하다면 꼭 equals() 메서드를 오버라이딩 해주고 동등성 검사를 진행해야합니다.

hashCode() - 인스턴스의 해시 값을 정의

hashCode()는 오버라이딩 하지 않았을 때 임의의 정수 값을 반환합니다. 이때 반환되는 정수 값은 JVM구현에 따라 달라지고, 그 값이 인스턴스의 메모리 주소라고 얘기하는 경우도 있습니다. 하지만 이건 중요한 내용이 아닙니다. (자바 자체가 메모리 주소를 코드로 핸들링하도록 설계된 언어가 아니니까요.)

우리가 집중해야할 포인트는 hashcode()가 언제, 어떻게 사용되는지입니다. hashCode()equals()와 마찬가지로 두 인스턴스의 동등성을 정의하기 위해 사용됩니다. 따라서 두 메서드는 보통 함께 오버라이딩 하여 사용합니다.

hashCode()와 HashMap

HashMap같은 Hash관련 컬렉션은 hashCode() 메서드가 아주 중요하게 사용됩니다.

❗️ Map은 특정 키를 기준으로 데이터를 저장하고, 그 키를 기준으로 값을 찾는 방식의 자료구조입니다.

HashMap에서 hash는 어떻게 사용될까요? HashMap은 키로 매핑된 값을 조회하려 할 때 다음과 같은 과정을 거칩니다.

HashMap은 키를 그냥 저장하는게 아니라, hashCode()로 연산한 결과를 저장합니다.

⚠️ 엄밀히 말하면 hashCode() 연산한 값을 capacity(용량)로 나눈 값의 나머지를 저장합니다. 예제에서는 이해를 돕기 위해 이 과정을 생략합니다.

만약 키가 2인 값을 조회한다고 가정하면, 우선 키로 들어온 2를 hashCode() 메서드로 연산한 후 그 값과 일치하는 데이터를 조회합니다. 만약 매칭되는 결과가 하나만 있다면 그 키에 매칭되는 데이터를 반환합니다.

하지만 이 hashCode() 연산을 통해 나온 키 값이 중복될 수 있다는 문제가 있습니다. 위에서 설명하였듯 hashCode() 연산의 결과를 16같이 작은 수로 나눈 나머지를 사용하는데, 이렇게 되면 0 ~ 15 사이에 값이 나오므로 충분히 겹치는 값이 존재할 수 있습니다.


만약 중복되는 키의 데이터가 두 개 조회된다면, 이때 equals() 메서드를 사용해서 두 데이터를 비교합니다. 이 과정을 통해 입력된 키와 매핑되는 데이터를 반환합니다.

😡 아니 그럼 처음부터 equals()로 비교하면 되는거 아니에요? 왜 굳이 hashCode()로 먼저 비교해요???

그 이유는 상대적으로 가벼운 hashCode 연산을 통해 찾으려는 값의 후보군을 대폭 줄일 수 있기 때문입니다. 내가 찾으려는 값이 16개의 공간 중 어디에 있는지 먼저 알아내고, 그 안에 존재하는 값을 찾아내면 훨씬 빠르게 찾을 수 있기 때문입니다.

hashCode()를 활용한 동등성 검사

SomeObject

public class SomeObject {

    private int intField;
    private String stringField;

    public SomeObject(int intField, String stringField) {
        this.intField = intField;
        this.stringField = stringField;
    }

    public int getIntField() {
        return intField;
    }

    public String getStringField() {
        return stringField;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof SomeObject that)) return false;
        return intField == that.intField && Objects.equals(stringField, that.stringField);
    }

    @Override
    public int hashCode() {
        return Objects.hash(intField, stringField);
    }
}

Main

public class HashCodeExampleMain {
    public static void main(String[] args) {
        // Hash 관련 컬렉션이 같은 인스턴스를 구분하는 방법
        // hashCode 비교 -> equals 비교

        SomeObject sameObject1 = new SomeObject(1, "programmers");
        SomeObject sameObject2 = new SomeObject(1, "programmers");

        System.out.println(sameObject1.hashCode());
        System.out.println(sameObject2.hashCode());

        Set<SomeObject> set = new HashSet<>();

        set.add(sameObject1);
        set.add(sameObject2);

        System.out.println(set.size());
    }
}

Hash를 활용하는 컬렉션 중 하나인 HashSet을 사용해서 동등성 검사를 진행해보겠습니다.

❗️ Set은 중복된 데이터를 저장하지 않는 자료구조입니다.

우선 위 코드에서 sameObject1sameObject2는 같은 필드 값을 가진, 즉 우리가 정의해준 동등성 기준에 부합하는 동등한 인스턴스라고 할 수 있습니다.

1011308093
1011308093
1

동등한 인스턴스이므로 중복된 데이터를 저장하지 않는 Set에는 하나의 값만 삽입되는것을 확인할 수 있습니다.

// @Override
// public int hashCode() {
//	 return Objects.hash(intField, stringField);
// }

그럼 SomeObject에 오버라이딩 되어있는 hashCode() 메서드를 주석 처리하면 어떤 결과가 나올까요?

531885035
1418481495
2

두 인스턴스의 해시코드 출력 값이 다르고, Set에도 두 인스턴스를 중복된 값이 아니라고 판단하여 모두 삽입되었습니다.

hashCode()를 오버라이딩 하여 임의적으로 정의해주지 않았기 때문에, 두 인스턴스는 같은 필드 값을 가졌어도 서로 다른 해시코드를 가지고 되고, Hash를 사용해서 데이터의 동등성을 판별하는 HashSet은 두 인스턴스를 동등하지 않다고 인식하여 둘 다 저장합니다.

//    @Override
//    public boolean equals(Object o) {
//        if (this == o) return true;
//        if (!(o instanceof SomeObject that)) return false;
//        return intField == that.intField && Objects.equals(stringField, that.stringField);
//    }

hashCode 비교 다음으로 equals 비교까지 진행된다는걸 명확하게 확인하기 위해, 이번에는 equals()를 주석 처리하고 실행해보겠습니다.

1011308093
1011308093
2

이번에는 해시코드 출력 값은 같지만, equals() 메서드로 두 인스턴스의 동등성 정의를 해주지 않아 Set이 서로 다른 인스턴스로 인식하고 둘 다 저장하는것을 확인할 수 있습니다.

toString() - 인스턴스를 문자열로 표현

toString()은 인스턴스를 문자열로 표현할 수 있게 해주는 메서드입니다.


주로 시스템상에 로그를 남길 때 인스턴스를 문자열로 표현해야 할 필요성이 발생합니다. 로그를 남길 때 핵심이 되는건 시스템에서 어떤 데이터가 처리되었나?이기 때문입니다.

특정 데이터를 처리하다 오류가 발생한 경우 동일한 데이터로 재현해야 문제를 해결하기 쉬운 경우가 많습니다. 이때 데이터는 자바 애플리케이션 안에서 인스턴스 형태로 존재합니다. 즉, 데이터를 로그로 남긴다는건 인스턴스를 문자열로 변환해서 로그로 남긴다는 것을 의미합니다.

toString() 메서드가 호출되는 시점

우리가 직접 toString()을 호출하지 않아도 자동으로 호출되는 경우가 있습니다. 바로 레퍼런스 변수를 문자열과 더하기 연산해줄 때입니다. 이것은 toString()이 가지고 있는 독특한 특징 중 하나입니다.

SomeObject

public class SomeObject {
    private int intField;
    private String stringField;

    public SomeObject(int intField, String stringField) {
        this.intField = intField;
        this.stringField = stringField;
    }

    @Override
    public String toString() {
        return "SomeObject{" +
                "intField=" + intField +
                ", stringField='" + stringField + '\'' +
                '}';
    }
}

Main

public class ToStringExampleMain {
    public static void main(String[] args) {
        SomeObject someObject1 = new SomeObject(1, "programmers");
        SomeObject someObject2 = new SomeObject(100, "foo");

        System.out.println(someObject1);
        System.out.println(someObject2);
    }
}

위 코드와 같이 System.out.println()으로 레퍼런스 변수를 출력하는 경우에도 자동으로 toString()이 호출됩니다.

SomeObject{intField=1, stringField='chicken'}
SomeObject{intField=100, stringField='edgar'}

Objects

번외로 Object 클래스가 가지고 있는 모든 메서드를 가지고 있는 Objects 클래스도 있습니다.

// 방어 코드 X
public static void main(String[] args) {
	SomeObject someObject = returnNullMethod();
    
    someObject.hashCode();	// NPE!!!!!!
}

// 방어 코드 O
public static void main(String[] args) {
	SomeObject someObject = returnNullMethod();
    
    if (someObject != null)
    	someObject.hashCode();
}

위 코드를 그냥 실행하면 NPE가 발생합니다. 따라서 아래처럼 방어 코드를 작성해줘야합니다.

이때 Objects 클래스를 활용하면 다음과 같이 코드를 변경할 수 있습니다.

public static void main(String[] args) {
	SomeObject someObject = returnNullMethod();
    
    Objects.hashCode(someObject);	
}

이렇게 하면 if문을 활용하는 Null Check 코드를 작성할 필요가 없기 때문에 더욱 가독성 높은 코드를 작성할 수 있게 해주므로 Objects를 적극적으로 활용하는게 좋습니다.

profile
개발의 신이시여... 제게 집중할 수 있는 ㅎ... 네? 맥주요?

0개의 댓글