Java

[Effective Java] 아이템 40 : @Override 애너테이션을 일관되게 사용하라

jwKim96 2023. 2. 7. 02:58

예제 코드는 여기서 보실 수 있습니다.

0. 들어가며

보통 IDE 에서 재정의 기능을 사용하면 자동으로 @Override 어노테이션을 붙여줍니다.
그래서 저는 @Override 에 대해 깊게 고민해본적이 없는데요.

이번 아이템은 개발도구가 도와주지 않다고 가정하고 자세히 알아보겠습니다.

1. @Override 를 붙이지 않는다면

@Override 어노테이션의 가장 중요한 역할은 해당 메서드가 정확하게 오버라이드하고 있는지 확인해주는 것 입니다.

class Something {
    private final int num;
    public Something(int num) { ... }

    // equals, hashCode 는 문제없다고 가정
    public boolean equals(Something obj) { ... } // num 이 같으면 같은 객체
    public int hashCode() { ... } // num 을 이용한 hashCode
}

public class SomethingSet {
    public static void main(String[] args) {
        Set<Something> set = new HashSet<>();
        for (int i = 0; i < 5; i++) {
            for (int j = 0; j < 5; j++) {
                set.add(new Something(j));
            }
        }
        System.out.println(set.size()); // 25 출력
    }
}

위 예제는 컴파일 에러가 전혀 없는 코드입니다.
2중 for 문을 이용하여 set 에 5x5번 추가하니 총 25번 추가 작업을 합니다.
그렇지만 같은 번호는 0~4만 추가하기 때문에 출력 결과는 5를 예상할 수 있습니다.

하지만 이 코드를 실제로 실행하면 25가 나오게 됩니다.

1.1 예기치 않은 오버로딩

HashSet 은 equals 를 호출하여 입력된 객체가 기존 객체와 같은 객체인지 확인합니다.

class Something {
    private final int num;
    public Something(int num) { ... }

    // equals, hashCode 는 문제없다고 가정
    public boolean equals(Something obj) { ... } // num 이 같으면 같은 객체
    public int hashCode() { ... } // num 을 이용한 hashCode
}

하지만 위 코드에서는 Object 클래스의 equals 와는 다르게 파라미터가 Something 타입입니다.
그래서 오버라이딩하려던 의도였지만 예기치 않게 오버로딩되어버린 것이죠.
결국 HashSet 에서는 Something 의 equals 는 사용하지 않고, Object 의 equals 를 사용합니다.

1.2 해시 충돌

Object 의 equals 는 인스턴스의 동일성을 비교하기 때문에 생성된 객체는 모두 서로 다릅니다.

class Something {
    private final int num;
    public Something(int num) { ... }

    // equals, hashCode 는 문제없다고 가정
    public boolean equals(Something obj) { ... } // num 이 같으면 같은 객체
    public int hashCode() { ... } // num 을 이용한 hashCode
}

public class SomethingSet {
    public static void main(String[] args) {
        Set<Something> set = new HashSet<>();
        for (int i = 0; i < 5; i++) {
            for (int j = 0; j < 5; j++) {
                set.add(new Something(j));
            }
        }
        System.out.println(set.size()); // 25 출력
    }
}

그래서 위 코드에서 set.add(new Something(j)) 에서 문제가 발생합니다.
내부적으로 다른 객체라고 판명된 경우 set 에 입력하게 되는데요.
하지만 Something 클래스의 hashCode 는 제대로 오버라이딩되어 문제가 발생합니다.

그건 바로 서로 다른 개체가 같은 해시값을 가지게 되는 것, 즉 해시충돌입니다.
그래서 HashSet 은 해시충돌에 대한 대응 로직을 수행하게되고, 성능에도 악영향을 끼치게 됩니다.

2000 * 500 번 실행한 결과(equals 잘못 재정의)

위는 잘못 작성된 코드 실행 결과로 100만번 실행하여 약 11초가 걸렸지만, 심지어 원하는 결과 500 이 아닌 1,000,000이 나왔습니다.

2000 * 500 번 실행한 결과(equals 제대로 재정의)

위는 제대로 작성된 코드 실행 결과로 100만번 실행하여 약 0.5초 이하,원하는 결과 500을 얻었습니다.

하지만 이 모든 문제들은 @Override 만 잘 붙였다면 발생하지 않을 문제였기 때문에 어노테이션을 잘 붙이는것으로 간단하게 해결할 수 있습니다.

2. 붙이는 경우

붙이는 경우에도 신경쓸 부분이 있습니다.
그건 바로 @Override 가 생략 가능한점 때문인데요.

다음은 재정의시 어떤 클래스를 상속했느냐에 따라 달라지는 생략가능 여부 입니다.

  • 클래스 상속 : 무조건 붙여야함(안붙이면 컴파일 에러)
  • 추상 클래스 상속 : 생략 가능
  • 인터페이스 상속 : 생략 가능
public class Child {

    public static void main(String[] args) {
        ChildClass childClass = new ChildClass();
        childClass.test(); // ChildClass.Test() 출력

        ChildAbstractClass childAbstractClass = new ChildAbstractClass();
        childAbstractClass.test(); // ChildAbstractClass.Test() 출력

        ChildInterface childInterface = new ChildInterface();
        childInterface.test(); // ChildInterface.Test() 출력
    }
}

class ChildClass extends ParentClass {

    @Override // 없으면 컴파일 에러 발생
    void test() {
        System.out.println("ChildClass.Test()");
    }
}

class ChildAbstractClass extends ParentAbstractClass {

    // @Override // 어노테이션 생략 가능
    void test() {
        System.out.println("ChildAbstractClass.Test()");
    }
}

class ChildInterface implements ParentInterface {

    /// @Override // 어노테이션 생략 가능
    public void test() {
        System.out.println("ChildInterface.Test()");
    }
}

하지만 어떤 클래스에는 붙이고, 어떤 클래스에는 붙이지 않는다면 코드에 일관성이 떨어지게 됩니다.
그렇게되면 다른사람이 해당 클래스를 분석할 때 새로 추가한 기능인지, 상속한 기능을 재정의한건지 확인하기 힘들 수 있습니다.

그래서 책에서는 일관되게 모두 붙이는 것을 권장하고 있습니다.