글 작성자: 자바니또

개요

public class Main{
    public static final void main(String[] args){
        StringBuilder sb1 = new StringBuilder("apple");
        StringBuilder sb2 = new StringBuilder("apple");
        if(sb1.equals(sb2)){
            System.out.println("같다");
        }
        System.out.println("다르다");
    }
}

얼마 전에 같이 공부하던 동생이 한 실수를 간단히 코드로 나타내었다. 동생은 String과 똑같게 생각하여 "같다"의 결과를 생각했지만.. 결과는 "다르다"가 나온다. 왜 이런 실수를 했는지, 그 원인과 실수를 하지 않기 위한 개념을 다져보도록 한다.

물리적 동치성('==')과 논리적 동치성('equals')

물리적 동치성 비교('==')

물리적 동치성 비교(==)란 메모리에 저장된 변수가 가지는 값이 서로 같은지 비교하는 것이다.

설명하기에 앞서 참조 타입 변수와 기본 타입 변수에 대해 간단히 설명하자면 참조 타입(Reference Type) 변수는 참조하는 객체의 주소 값을 가지고 있고, 기본 타입(Primitive Type) 변수는 기본 타입의 값을 가지고 있다.

(자바의 메모리 구조에 대해 잘 모른다면 [ JAVA ] Java의 메모리(준비중)를 먼저 보는 것을 권장한다.)

int a = 10;
int b = 10;
String s1 = "ten";
String s2 = "ten";
String s3 = new String("ten");
StringBuilder sb1= new StringBuilder("ten");
StringBuilder sb2 = new StringBuilder("ten");
// ------------비교 결과 ----------------
// 1.  a==b    ------------> True
// 2.  s1==s2 ----------> True
// 3.  s2==s3 ----------> False
// 4.  sb1==sb2 --------> False

위의 코드가 메모리에 저장되는 모습을 Stack과 Heap의 관점에서 간단히 그려보았다. 비교 결과 1번부터 4번까지 살펴보자.

1.a==b

a와 b는 기본 타입으로서 Stack에 변수와 값이 같이 저장된다. '=='는 이 값을 비교하고 둘 다 10이므로 결과는 `참`이다

2.s1==s2

JVM은 메모리의 효율을 위해 문자열 클래스인 String객체에 대하여 특별한 메모리 구조를 가지고 있다. 문자열을 리터럴로 넣을 시 JVM의 heap에 있는 String constant pool에서 해당 문자열을 찾는다. StringPool에 존재한다면 인스턴스를 생성하지 않고 재활용한다. 반면에 존재하지 않을 경우 String constant pool에 생성한다.

s1에 리터럴로 문자열 "ten"을 넣고 이때 String constant pool에 "ten"의 값을 가진 String객체가 생성된다. s2에 같은 문자열인 "ten"을 리터럴로 넣을 시 JVM은 String constant pool에서 미리 생성된 "ten"을 가진 String객체의 주소를 돌려준다.

기본 타입 변수와 다르게 참조 타입 변수는 값이 아닌 참조할 객체의 주소 값을 저장하고 있으며 '=='연산자는 메모리에 저장된 이 주소 값을 비교하게 된다. 즉 s1==s2는 1200으로 메모리에 저장된 주소 값이 동일(물리적 동치) 하므로 이다.

3.s2==s3

s1, s2와는 다르게 s3는 new연산자를 사용하여 객체를 생성했다. new연산자는 Heap메모리에 메모리 할당을 요청하는 연산자로서 String Constant Pool이 아닌 힙 영역에서 메모리를 할당받는다. 만약 s4라는 변수를 만들고 한 번 더 new String("ten")을 한다면 역시 s3와는 다른 주소로 메모리가 할당되어 객체가 생성된다.

그럼 s2==s3를 보자. s2와 s3는 String타입의 참조 변수이다. 이때 메모리에 저장된 값을 보면 참조하는 객체가 다르므로 당연히 값도 1200, 1500으로 서로 다르다. 따라서 s2==s3는 거짓이다.

4.sb1==sb2

이제 눈에 new연산자부터 보일 것이다. sb1과 sb2는 모두 new연산자를 사용하였으므로 3번과 마찬가지로 sb1==sb2는 거짓이다.


논리적 동치성 비교(equals)

논리적 동치성 비교(equals)란 참조 타입(Reference Type) 변수를 비교하는 것이다. 더 정확히 말하면 비교할 핵심 값을 정하고, 핵심 값을 비교하여 두 객체가 서로 동등(equal)하다면 "논리적으로 같다"라고 한다.

String s1 = "ten";
String s2 = "ten";
String s3 = new String("ten");
String s4 = new String("ten");
StringBuilder sb1= new StringBuilder("ten");
StringBuilder sb2 = new StringBuilder("ten");
// ------------비교 결과 ----------------
// 1. s1.equals(s2) -----> True
// 2. s2.equals(s3) -----> True
// 3. s3.equals(s4) -----> True
// 4. sb1.equals(sb2) ---> False

1.s1.equals(s2)

s1과 s2는 "ten"이라는 문자열 리터럴을 값으로 가지고 있다. 이제 우리는 s1과 s2가 가리키는 String객체가 String constant pool에 있는 같은 객체라는 것을 안다. 이 두 객체는 서로 동일(Same)하고 동등(equal)하다. 당연히 이다.

2.s2.equals(s3)

s2와 s3는 서로 다른 객체를 가리키고 있기 때문에 동일하진 않지만(Not Same) 그 객체들이 가진 상태 값인 "ten"이 논리적으로 서로 같기 때문에 논리적으로 동등(equal)하다. 따라서 결과는 이다.

3.s3.equals(s4)

s3와 s4 모두 new를 통해 메모리를 각자 할당 받고 인자로 받은 "ten"을 상태 값으로 가지는 String객체를 생성한다. 이 두 객체는 동일하진 않지만(Not Same) 상태 값이 서로 논리적으로 같기 때문에 논리적으로 동등(equal)하다. 따라서 결과는 이다.

4.sb1.equals(sb2)

동생이 했던 실수와 직결되는 문제이다. sb1과 sb는 3번과 마찬가지로 "ten"을 인자로 받는다. 얼핏 보면 3번과 같은 문제로 보이지만 결과는 거짓으로서 확연히 다르다. 왜 sb1, sb2 모두 같은 값인 "ten"을 인자로 받아 생성된 객체를 가리키는데 equals()의 결과는 거짓일까?

지금까지 아무 설명 없이 equals()를 사용했다. 이 equals()는 어떻게 String객체도 사용할 수 있고 StringBuilder객체도 사용할 수 있는 걸까? 이 두 객체가 사용하는 equals()는 같은 equals()일 까?

public boolean equals(Object anObject) {
    if (this == anObject) {    //동일성 체크
        return true;
    }
    if (anObject instanceof String) {    //동등성 체크
        String aString = (String)anObject;
        if (coder() == aString.coder()) {
            return isLatin1() ? StringLatin1.equals(value, aString.value)
                              : StringUTF16.equals(value, aString.value);
        }
    }
    return false;
}

위의 코드는 String Class에 정의된 메서드이다. 첫 번째 if문에서 동일한지 비교하고 두 번째 if문에서 동등한 지 비교하는 코드이다. 아주 깔끔한 코드이다. 그렇다면 StringBuilder의 equals() 코드를 보자.

public boolean equals(Object obj) {
    return (this == obj);
}

놀랍게도 StringBuilder클래스의 equals는 이 코드가 끝이다. 정확히 말하면 StringBuilder클래스의 메서드가 아니라 Object클래스의 메서드다. 코드를 보면 동일성만 체크하여 결과를 리턴하기 때문에 4번의 답이 거짓일 수밖에 없다.

Java의 Object클래스는 최상위 클래스로서 모든 클래스는 Object클래스를 상속하도록 되어있다. 따라서 equals()를 오버 라이딩하지 않는다면 Object의 equals()를 사용하게 된다. 예외적으로 Java에서 기본적으로 제공하는 String과 Integer, Double과 같은 값 클래스들은 equals()가 이미 오버 라이딩되어있다.

논리적 동치성 비교가 무엇인지 다시 한번 보도록 하자.

논리적 동치성 비교(equals)란 참조 타입(Reference Type) 변수를 비교하는 것이다. 더 정확히 말하면 비교할 핵심 값을 정하고, 핵심 값을 비교하여 두 객체가 서로 동등(equal)하다면 "논리적으로 같다"라고 한다.

즉, 사용자가 만든 클래스에 논리적 동치성 비교(equal)가 필요하다면 직접 오버 라이딩하여 코드를 작성하여야 한다.


모든 클래스는 equals를 재정의 해야 하나?

equals메서드는 재정의하기 쉬워 보이지만 컬렉션 클래스를 포함하여 수많은 클래스들에서 호출되기 때문에 자칫 잘못했다가는 끔찍한 결과를 초래한다. 문제를 회피하는 가장 쉬운 길은 아예 재정의 하지 않는 것이다.

Effective Java에서는 다음에서 열거한 상황 중 하나라도 해당한다면 재정의 않는 것이 최선이라 말한다.

  • 각 인스턴스가 본질적으로 고유하다. 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다. Thread가 좋은 예로 Object의 equals 메서드는 이러한 클래스에 딱 맞게 구현되었다.
  • 인스턴스의 논리적 동치성(logical equality)을 검사할 일이 없다.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다. 예컨대 대부분의 Set구현체는 AbstractSet이 구현한 equals를 상속받아 쓰고 List구현체들은 AbstactList로부터, Map구현체들은 AbstractMap으로부터 상속을 받아 그대로 쓴다.
  • 클래스가 private이거나 package-private이고 equals메서드를 호출할 일이 없다.

StringBuilder는 String을 가변 배열에 담아 메모리를 아끼고 더 조작하기 쉽게 도와주는 클래스로 첫 번째 상황에 해당된다.


equals메서드를 재정의 하는 방법

equals메서드를 재정의 할 때는 반드시 다음에 나오는 Object명세에 적힌 일반 규약을 따라야 한다.

equals 메서드는 동치관계(equivalence relation)을 구현하며,
다음을 만족한다.

- 반사성(reflexivity): null이 아닌 모든 참조 값 x에 대해,
  x.equals(x)는 true다.
- 대칭성(symmetry): null이 아닌 모든 참조 값 x,y에 대해,
  x.equals(y)가 true면 y.equals(x)도 true다.
- 추이성(transitivity): null이 아닌 모든 참조 값 x,y,z에 대해,
  x.equals(y)가 true이고 y.equals(z)도 true면 x.equals(z)도
  true다.
- 일관성(consitency): null이 아닌 모든 참조 값x,y에 대해,
  x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 
  false를 반환한다.
- null-아님: null이 아닌 모든 참조 값 x에 대해, x.equals(null)
  은 false다.

예전 학교에서 배운 수학 중 명제의 참, 거짓과 비슷하다. 천천히 읽어보면 당연한 내용들이다. 많은 클래스들이 인자로 받은 객체가 이 규약을 지킨다고 가정하고 동작하기 때문에 꼭 지켜야 하는 규약이다.

StringBuilder에 equals를 재정의 하고 싶다면?

StringBuilder기능도 사용하고 싶고 논리적 동치성 비교도 가능하게 하고 싶다면 어떻게 구현해야 할까? 가장 먼저 상속이 떠오른다. 하지만 equals를 재정의하기 위해 StringBuilder를 상속하기에는 내부 구현을 분석해야 하는 게 굉장히 번거로워 보인다. 이럴 때는 컴포지트 패턴을 사용하면 편하다.

class MyStringBuilder{
    private StringBuilder sb;
    private String s;

    public MyStringBuilder(String s) {
        sb = new StringBuilder(s);
        this.s = s;
    }

    @Override
    public boolean equals(Object obj) {
        if(obj instanceof MyStringBuilder) {
            MyStringBuilder mb = (MyStringBuilder)obj;
            if(getString().equals(mb.getString()))
                return true;
        }
        return false;
    }

    public String getString() {
        return this.s;
    }

    public MyStringBuilder append(String s) {
        sb.append(s);
        this.s = this.s + s;
        return this;
    }
    //...append 그 외의 필요한 메서드
}
MyStringBuilder mb = new MyStringBuilder("hello");
mb.append("world");
MyStringBuilder mb2 = new MyStringBuilder("helloworld");

// mb.equals(mb2) -----> 참

컴포지트 패턴을 간단하게 설명하자면 어떤 구현 객체의 기능을 그대로 사용하면서 부가기능을 더하고 싶을 때 사용하는 패턴이다. 특징으로는 필드로 구현 객체에 대한 레퍼런스를 가지고 있고 위임을 사용하여 상속의 오버 라이딩과 비슷한 효과를 낸다. append 메서드가 그러하다.

MyStringBuilder는 StringBuilder와 String을 가지고 있고 String을 핵심 필드로서 논리적 비교 시 이것을 비교한다. String을 상태 값으로 가지고 있어서 append나 다른 메서드를 사용 시 메모리적으로 비효율적이긴 하지만 설명을 위해 간단히 만들었다.

* 주의 사항 *

  • equals를 재정의 할 땐 hashCode도 반드시 재정의하자.
  • 너무 복잡하게 해결하려 들지 말자. 필드들의 동치 성만 검사해도 equals규약을 지킬 수 있다.
  • Object 외의 타입을 매개변수로 받는 equals메서드는 선언하지 말자. 이것은 메서드 오버 라이딩(재정의)이 아니라 오버 로딩(다중 정의)이다. 이것을 어긴다면 equals를 호출하는 컬렉션과 같은 클래스를 사용 시 원치 않는 결과를 받게 될 수 있다. @Override 애너테이션을 사용하면 컴파일 단계에서 잡아주기 때문에 이러한 실수를 예방할 수 있다.

결론

  • '=='연산자는 물리적 동치성을 비교하는 것으로서 동일성 비교라고 봐도 무방하다.
  • 'equals'는 논리적 동치성을 비교하는 메서드로서 동등성 비교라고 할 수 있다.