Nextree

헷갈리기 쉬운 “동등비교” 와 “정렬”

Nextree Aug 11, 2014 0 Comments

입사하여 처음으로 참여하게 된 프로젝트는 보험회사 직원들의 업무 관리 프로그램을 구축하는 것이었습니다. 자바로 개발하는 프로젝트는 처음이여서 어떻게 구현해야하는지에 대한 걱정이 제일 컸습니다. 특히, 새로 구현하는 부분보다 구현되어 있는 부분을 보완하여 만드는 부분이 어려웠습니다. 기존에 구현 되어있는 코드를 보면서 기존 업무를 정확히 분석해야하기 때문입니다. 직접 코딩한 것이 아니어서 한 눈에 코드가 들어오지 않았고, 함축적으로 코딩이 되어 있어 숨어 있는 로직이 있나 살펴보아야 했으며, 구현방법이 과거와 달라 제대로 해석을 하고 있는지 의문이 많이 들었습니다. 이런 코드들을 보면서 책으로 공부 할 때보다 현장에서 직접 겪어보니 공부했던 것이 머릿속에 쏙쏙 들어오면서 혼란스러웠던 점들이 정리가 되었습니다.

그 중에 한 부분은 동등비교와 정렬에 관한 것입니다. 저는 프로그램을 사용하는 사용자 role를 구현할 때 객체들을 비교해야하는 경우가 많이 있었습니다. 어떻게 구현을 해야할까 생각하던 중 JAVA 책에서 배운 여러 가지가 생각났습니다. "equals()", "==", "compare to()", "comparable", "comparator" 등 입니다. 이 중 어떤 경우에 어떤 것을 써야할까 하는 많은 고민을 하였습니다. 프로젝트에서 수석님께 배우며 큰 깨달음을 얻었고 덕분에 비교와 정렬에 대해서 정리를 할 수 있었습니다. 어떠한 것을 동등비교 한다고 생각하면 제일 처음 생각나는 방법은 "==" 입니다. 이 방법으로 구현을 하다가 예상치 못한 결과를 얻게 되었습니다. 그래서 동등비교에 관한 세 가지 방법을 자세히 살펴보았습니다.

1. equals() , == , compareTo() 는 어떻게 다를까?

위의 세 가지 방법이 헷갈리는 이유는 우리말로는 “같다” 라는 것들이 영어 사전에서 찾아보면 same, identical, equal, equivalent, common 등 이렇게 다양한 언어로 표현되는 것을 볼 수 있습니다. 우리나라에서는, “같다”라는 한 가지 단어로 모든 표현이 가능하지만 영어 단어들을 자세히 살펴보면 세 가지의 방법이 있는 이유를 알 수 있습니다.

<그림1> 동일한 객체

<그림1>에서 A 와 B는 “같은” 객체를 참조하고 있습니다. 이 경우 객체를 참조한 다는 것은 한 메모리 주소에 있는 ‘동일한, 똑같은(same, identical)’ 객체를 참조한다는 뜻입니다.

<그림2> 동등한 객체

하지만 <그림2>에서는 A 와 B는 동일한 객체를 참조하고 있지 않습니다. 내용은 같지만 다른 메모리 주소를 가르키고 있습니다. 이 경우는 두 객체는 동등한(equivalent) 이라고 표현합니다. 그래서 <그림1>은 A==B와 A.equals(B)는 true이지만 <그림2>의 A==B 결과는 false 이며 A.equals(B)는 true입니다. 즉 "==" 는 연산자 이고 equals()는 메소드입니다. "=="은 객체를 직접 접근 할 수 없습니다. "=="는 대상 객체를 비교하는 것이고 equals()는 객체의 값을 비교합니다.

int x = 100;  
int y = 200;  
int z = 100; 

String name1 = "eunsun";  
String name2 = "EUNSUN";  
String name3 = new String("eunsun");  
String name4 = "eunsun";

System.out.println(x==y); //false  
System.out.println(x==z); //true  
System.out.println(name1==name2); //false  
System.out.println(name1==name3); //false  
System.out.println(name1==name4); //true  

위의 코드를 수행하면 메모리에는 다음과 같은 상태가 됩니다.

변수명 변수값 실제값
1 x 100 (100)
2 y 200 (200)
3 name1 0x3a3…(?) [String]eunsun
4 name2 0xe1e…(?) [String]EUNSUN
5 name3 0xe2b…(?) [String]eunsun
6 name4 0x3a3…(?) [String]eunsun
7 z 100 (100)

결과를 보면 4과 5번에서 헷갈릴 수 있습니다. String은 call by reference이기 때문에 new 연산자를 사용하지 않아도 객체가 생성됩니다. 그래서 name4는 name1의 메모리 주소에 할당되고 new 연산자를 사용하면 새로운 객체가 생성됩니다. 즉 메모리 값이 새로 생겨 name1과 메모리 주소가 다르기 때문에 nam1 == name3 는 false가 되는 것입니다. equals()는 재정의 하지 않으면 객체의 내용이 동등한지 검사 할 수 없습니다. 그 이유는 무엇일까요?? 먼저 equals()에 대해 살펴 봅시다.

  1. 반사적(reflexive) : 모든 참조 값 x에 대해 x.equals(x)는 true를 리턴해야합니다
  2. 대칭적(symmetric) : 모든 참조 값 x와 y에 대해 y.equals(x)가 true를 리턴할 때만 x.equals(y)는 true를 리턴해야합니다.
  3. 이행적,추이적(transitive) : 모든 참조값 x,y,z에 대해 만약 x.equals(y)와 y.equals(z)가 true를 리턴한다면 x.equals(z)도 true를 리턴해야합니다.
  4. 일관적(consistent) :모든 참조 값 x,y 에 대해 만약 equals()가 비교할 떄 쓰는 정보가 변하지 않는다면 x.equals(y)의 결과는 항상 일관성이 있어야합니다. 5.null이 아닌 모든 참조x에 대해 ,x.equls(null)은 반드시 false를 리턴합니다.

equals()의 내용은 위와 같은데 구현을 하다보면 위의 규칙을 어길 수 있습니다. 예를 들어 봅시다.

CaseInsensitiveString x = new CaseInsensitiveString("Apple");  
String y = "apple";

System.out.println(x.equals(y)); //true  
System.out.println(y.equals(x)); //false  

CaseInsensitiveString은 대소문자를 구분하지 않는 클래스입니다. x , y 가 동등하다고 비교하는건 틀릴 수도 맞을 수도 있습니다. 구분하지 않는 다면 true가 나와야 된다고 생각할 수 있습니다. 하지만 결과는 false입니다. 그렇지 않은 이유를 알아보겠습니다. CaseInsensitiveString 객체를 컬렉션에 넣어보겠습니다.

List list = new ArrayList();  
list.add(newCaseInsensitiveString("KoReA");  
list.contains("Korea")  

위의 결과는 무엇일까요? 예외가 발생할 것입니다. 이렇게 어떤 클래스가 구현 계약을 어기면 다른 클랙스는 예측할 수 없는 행동을 합니다. 즉, 다른 타입의 객체와 동등성을 비교하지 말아야 합니다. 위의 2번내용을 쉽게 어길 수 있습니다. 3번도 2와 같이 어길 수 있는 경우가 있습니다. 예를 들어 보겠습니다.

3DPoint x = new 3DPoint(10,20,3);  
2DPoint y = new 2DPoint(10,20);  
3DPoint z = new 3DPoint(10,20,5);

 System.out.println(x.equals(y)); //true
 System.out.pringln(y.equals(z)); //true
 System.out.pringln(x.equals(z)); //false

x.equals(y)와 y.equals(z)는 true를 리턴하지만 x.equals(z)는 false를 리턴합니다. x.equals(y)와 y.equals(z)은 z를 뺀 위치만 비교하고 x.equals(z)는 z까지 모두 비교하기 때문에 문제가 생긴 것입니다. 이문제는 그럼 어떻게 해결해야할까요? 컴포지션을 써야합니다.

public class 3DPoint{  
    //2DPoint 객체와 컴포지션 관계를 가진다. 
    private 2DPoint 2Dpoint; 
    private Z z; 

    public 3Dpoint(int x, int y, int z) { 
        2Dpoint = new 2DPoint(x,y); 
        this.z=z;
    } 

    //3DPoint 인스턴스의 2DPoint 로서의 모습을 리턴한다. 
    public PointasPoint(){ 
       return point; 
    } 

    public boolean equals(Object o) {     
       if(!(o instanceof 3DPoint)) return false; 
       3DPoint p = (3DPoint)o; 
       return p.2Dpoint.equals(2Dpoint) && p.z.equals(z); 
    } 
}

3DPoint 는 2DPoint타입이 아니기때문에 equals()를 하게 되면 false를 리턴합니다. 정확한 타입의 객체를 비교 할 수 있도록 구현해야합니다. 또 3DPoint가 2DPoint로 어떻게 표현되는지 알아야 한다면 PointasPoint()와 같은 뷰 메소드를 구현하면 됩니다. 이 모든 구현 계약을 지키는 equals()를 만드는 비법은 다음과 같습니다. (이 비법은 Joshua Bloch의 [Effective Java Programming LanguageGuide]에 나오는 것을 인용했습니다]

  1. 연산자를 써서 인자가 this 객체를 참조하면 true를 리턴합니다. 이것은 성능을 최적화하기 위해 수행하는 작업입니다. 비교 작업이 복잡하다면 이 방법을 하는 것이 좋습니다.

  2. instanceof 연산자를 써서 인자의 타입이 올바른지 검사합니다. 만약 타입이 틀리다면 false를 리턴합니다. 이때, null이 인자로 넘어오면 instanceof 연산자는 항상 false를 리턴하므로 null검사를 하지 않아도 됩니다. 보통 올바른 타입은 호출하는 equals()를 정의한 클래스 타입입니다. 하지만, 이 클래스가 인터페이스를 구현한다면 이 인터페이스도 올바른 타입이 될 수 있습니다. 즉 같은 인터페이스를 구현한 클래스들은 서로 비교할 수 있습니다. 컬렉션 프레임워크의 Set, Map, Map.Entry, List와 같은 인터페이스를 구현한 클래스들은 이 이인터페이스의 타입인지 비교합니다.

  3. 인자를 정확한 타입으로 변환합니다. 이 타입 변환은 이미 instanceof로 타입을 검사했기 때문에 항상 성공합니다.

  4. 주요필드(significant field)에 대해 인자의 필드와 this 객체의 해당필드의 값이 동등한지 검사합니다. 모든 필드가 동등하다면 true를 리턴하고 동등하지 않는것이 있다면 false를 리턴합니다. 해당 필드가 float나 double이 아닌 기본 타입이라면 == 연산자로 비교합니다.
    Float와 double 타입은 각각 Float.floatToIntBits()와 Double.doubleToLongBits()로 int 와 long 값으로 변환한 다음 == 연산자로 비교합니다. 객체 참조 필드의 경우 에는 그객체의 equals 메소드로 비교합니다. 배열필드의 경우 모든 각 구성요소에 대해 지금까지 설명한 작업을 수행합니다. null에 대한 참조가 허용된 객체 참조 필드에 대한 비교는 NullPointerException을 막기 위해 다음과 같은 구현패턴을 써서 비교합니다.(field == null ? o.field == null : field.equals(o.field)) 만약, this 객체의 필드와 인자가 가리키는 객체의 필드가 동일한(identical)객체를 참조하는 경우라면 다음과 같이 비교하는 것이 더 빠릅니다.(field == o.field || (field != null && field.equals(o.field)))

equals()를 제대로 구현하는 일은 생각보다 어려웠습니다. 그리고 중요하였습니다. 이것을 제대로 구현하지 않으면 이 클래스를 쓰는 다른 클래스들이 문제를 일으키게 되고 만약 어려워서 구현하지 않는 다면 equals() 규칙에 맞게 같은 타입만 비교할 수 있는데 프로젝트에서 내가 원하는 타입만 비교하는 것이 아니기 때문입니다. 어떤 타입을 만나게 될지 모르기 때문에 equals()에 대해 정확히 알아야 했습니다.

2. comparable 과 comparator 는 언제, 어떻게 사용하는 것일까?

정렬을 해야할 경우는 아주 많았습니다. 고객의 입장에서 편하게 보기 위함입니다. 또한 여러가지를 조합하여 하나의 규칙을 정할 때 정렬을 사용합니다. 프로젝트에서는 상담원들이 고객들에게 질문하는 스크립트를 만들 때 사용하였습니다. 상품마다 질문스크립트가 다르기는 하지만 비슷한 부분이 많기 때문에 여러가지 설정을 조합해서 질문 스크립트를 만들게 됩니다. 그 경우 정렬을 이용해서 조합을 하게 됩니다. 또한 어떠한 데이터를 볼 때 10건 ,20건이 아닌 몇 천건에 해당하기 때문입니다. 정렬은 오름차순, 내림차순 등 일반적인 수준으로 정렬을 하는 경우가 있고, 가,나,다 순서가 아닌 문자열 길이 순서로 정렬을 할 수 있습니다. 이런 것은 java 에서 Comparable 과 Comparator라는 인터페이스를 통해 객체 순서 결정을 지원합니다. 이 두 인터페이스를 쓰게 되면 Collections.binary.Search, Collections.binarySearch, Collections.max, Collections.min, Collections.sort, Arrays.binarySearch,Arrays.sort와 같은 순서를 다루는 유틸리티 메소드와 TreeMap, TreeSet과 같이 자동 정렬 맵이나 집합을 다루는 컬렉션을 마음대로 쓸 수 있습니다.

  • Comparable 인터페이스

객체들 사이의 오름차순, 내림차순 등 일반적인 순서를 결정할 때 씁니다. 이 인터페이스에는 compareTo() 가 있습니다. java.lang.Comparable 의 compareTo()를 살펴 보겠습니다.

Modifier and Type Method and Description
int compareTo(T o)//Compares this object with the specified object for order.

다음 설명에서 sgn(표현식)은 signum 함수를 나타내며, 표현식의 값이 음수면 -1를, 0이면 0,양수면 1을 반환합니다.

  1. 모든 x와 y에 대하여 sgn(x, compareTo(y) == -sgn(y.compareTo(x))이 되어야 합니다. (이것은 y.compareTo(x)가 예외를 발생시킬 때만 x.compareTo(y)가 예외를 발생시켜야 한다는 의미를 가집니다.)
  2. 이행적,추이적 관계가 보장되어야 합니다. 즉(x.compareTo(y)>0 && y.compareTo(z)>0) 이면 x.compareTo(z)>0 이어야 합니다.
  3. x.compareTo(y)==0이라면 모든 z에 대해 sgn(x.compareTo(z)) == sgn(y.compareTo(z))`이 되어야 합니다.
  4. 반드시 요구되는 것은 아니지만 (x.compareTo(y) == 0) == (x.equals(y))가 되도록 하는 것이 좋습니다. 그리고 Comparable인터페이스를 구현하면서 이 조항을 지키지 않는 클래스에서는 API문서에 나타나도록 그 사실을 분명하게 표시해야 할 것입니다. 즉, 다음과 같이 하길 바란다. "주의 : 이 클래스는 equals()와 다른 순서를 가집니다"
A.compareTo(B)
A < B return 음수
A == B return 0
A > B return 양수

비교할 수 없는 타입이라면 ClassCastException 예외가 발생됩니다.

쉽게 해석하자면 String을 비교 할 경우 길이가 같다면, 두 가지 객체의 비교는 사전에 나와 있는 알파벳 순서(유니코드 값 기반)로 비교를 하는 것입니다.

인수로 받은 문자열이 사전 순서 상 앞에 있다면 결과는 음수가 나오고 사전 순서 상 뒤에 있다면 양의 정수 입니다.

문자열 길이가 다르다면 길이 값으로 비교를 합니다.

  • 문자열 길이가 같은 경우: return 값 = A.charAt()-B.charAt()
  • 문자열 길이가 다른 경우: 길이return 값 = A.length()-B.length()

예를 들어 보겠습니다.

public class compareTo {  
    public static void main(String[] args) { 
        String string1 = "abcdefg"; 
        String string2 = "abcedfghi"; 

        System.out.println(string1.compareTo(string2)); //결과: -2

        String s1 ="A"; 
        String s2 = "Z"; 

        System.out.println(s2.compareTo(s1)); //-1
        System.out.println(s1.compareTo(s2)); //25
    } 
} 

compareTo()는 equals()와 비슷하지만 다른 클래스의 객체를 비교할 수 없습니다. 그래서 equals()와 같이 클래스를 상속받아 새로운 필드에 추가하는 방법은 불가합니다. 하지만 상속 대신 클래스의 인스턴스에 대한 참조를 멤버 필드로 가지는 클래스를 만들고 이 필드를 리턴하는 뷰 메소드를 제공할 수 있습니다. 또한 보통 컬렉션은 equals()를 써서 구성요소의 동등성을 비교하지만 자동정렬 컬렉션(예 : TreeSet)은 compareTo()를 써서 비교하기 때문에 일관성 없는 결과가 나올 수 있습니다.

  • Comparator 인터페이스

일반적인 순서가 아닌 문자열 길이 등 사용자가 직접 순서의 규칙을 정하고 싶을 때 씁니다. 특히 부동 소수점 값을 비교할 때 관계연산자를 사용하면 compareTo()의 보편적 구현을 따르지 않기 때문에 이 인터페이스에는 compare()이 있습니다.

Modifier and Type Method and Description
int compare(T o1, T o2)//Compares its two arguments for order.
boolean equals(Object obj)//Indicates whether some other object is "equal to" this comparator.
int compare(T o1, T o2)
첫 번째 파라미터 o1이 기준
o1 < o2 return 음수
o1 == o2 return 0
o1 > o2 return 양수

리스트 같은 객체들을 인자로 받아서 TreeSet을 만들면 알아서 표준기본순서로 정렬됩니다. 정렬 기준 순서를 바꾸고 싶다면 Comparator 를 구현한 객체를 생성자로 만든 후 리스트를 집어 넣어 줍니다.

Set set = new TreeSet(list);  
Set set2 = new TreeSet(new OrderComparator());  
set2.addAll(list);  

Comparator는 인자로 받은 두 객체를 비교하고 Comparable는 this객체와 인자로 받은 객체를 비교한다는 점만 빼고는 같습니다.

마지막으로 comparable 과 comparator는 어떻게 쓰이는지 간략히 예제를 들어보겠습니다.

상품을 상품번호로 정렬하는 기능과 이름으로 정렬하는 기능을 구현하였습니다.

public class Prdouct implements Comparable<Product> {

   private String productName;
   private int productNum;       

   public Person(String productName, int productNum) {

      this.productName = productName;
      this.productNum = productNum;

   } 

   public String getProductNum() {

      return productNum;

   } 

   public int getProductNum() {

      return productNum;

   }       

   public int compareTo(Product compareProduct) { // override

      // ascending order
      return this.productNum - compareProduct.productNum;

      // descending order
      // return compareProduct.productNum - this.productNum;

   }       

   public String toString() {

      return "productName: " + productName + "productNum: " + productNum;

   }

}
import java.util.Comparator;

public class ProductNameComparator implements Comparator<Product> {

   @Override
   public int compare(Product p1, Product p2) {
       String productName1 = p1.getProductName();
       String productName2 = p2.getProductName();            

       //대소문자 무시 정렬
       return productName1.compareToIgnoreCase(productName2); 

   }
}
import java.util.Arrays; 

public class SortProductObject { 

   public static void main(String[] args) {

       Product[] product = new Product[5];
       product[0] = new Product("A", 11);
       product[1] = new Product("Z", 13);
       product[2] = new Product("X", 10);             

       System.out.println("[compareTo sort productNum]");
       Arrays.sort(people);  // compareTo() sort
       printProduct(people);          

       System.out.println("[compare sort prouctName]");
       Arrays.sort(product, new ProductNameComparator());
       printProduct(product);
       }      

   public static void printProduct(Product[] product) {

       for(Product product : product)
       System.out.println(product); 
   }
}
//result
[compareTo sort productNum]
productName:X  productNum:10  
productName:A  productNum:11  
productName:Z  productNum:13  
[compareTo sort productName]
productName:A  productNum:11  
productName:X  productNum:10  
productName:Z  productNum:13  

맺음말

프로젝트에서 익힌 것들은 많았지만 흔히 구현하면서도 헷갈렸던 것들을 정리했습니다. 지나치기 쉬운 내용이지만 정리를 하고나니 명확합니다. 기억이 나지 않을 경우 이 글을 보면 검색하는 시간이 줄어들겠지요. 정렬을 생각할 때 기본 정렬 기준이라면 Comparable , 다른 기준으로 정렬 하고 싶다면 Comparator는 항상 기억하려 합니다. 감사합니다.

참고 문헌

  1. Effective Java Programming Language Guide, Joshua Bloch, Addison-Wesley
  2. http://docs.oracle.com/javase/7/docs/api/

Nextree

Read more posts by this author.