안녕하세요.
자바 코드를 테스트하면서 assetj의 isSameAs와 isEqualTo의 차이가 궁금해서 찾아보다가, 깊은 내용이 있음을 알게 되어 정리합니다.
참고로
테스트하실 때 밑에 있는 assertj로 사용하셔야 합니다!
그리고 Assertions에 Alt 누르고 static import 하면 Assertions 안 치고도 사용할 수 있습니다!
본론으로 들어와서, inSameAs와 isEqualTo의 차이를 알아봅시다.
결론부터 말씀드리면, 주소 값 비교(==, same)와 값 비교(equals)입니다.
스택오버플로우의 답변을 아래에 첨부합니다[1].
isSameAs - checks if objects are same (e.g. checking if objects point to same reference)
isEqualTo - checks if objects are equal (e.g. checking if objects are equal based on value)
그렇구나! 하고 넘어가려고 하다가
isSameAs의 설명이 인텔리제이에 떠서 그대로 따라 해보았습니다.
tyrion과 alias는 같은 주소를 참조하고 있고, clone은 필드 값은 같은데 다른 주소를 참조합니다.
public class SameTest {
@Test
public void sameTest(){
Name tyrion = new Name("Tyrion", "Lannister");
Name alias = tyrion;
Name clone = new Name("Tyrion", "Lannister");
Assertions.assertThat(clone).isNotSameAs(tyrion)
.isEqualTo(tyrion);
}
static class Name{
public String firstName;
public String lastName;
public Name(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
}
}
그런데 테스트에 실패했습니다!!!!!! not equal이라고 합니다. 아래와 같은 에러 메시지를 받았습니다.
Expected가 tyrion과 alias와 같은 객체로 나옵니다!
equal은 주소가 아니고 값 비교라고 했는데 말이죠!
Object 클래스에 가보면 equals를 ==으로 주소 값 비교를 하고 있습니다.
음 그렇군요. 그럼 객체는 equal도 주소 값 비교를 한다고 치죠.
그럼 Integer로 해봐야겠다! 싶어서 Integer로 비교해봤습니다.
Integer integerA = 1;
Integer integerB = 1;
Integer integerC = 128;
Integer integerD = 128;
integerA, integerB는 equal이고 same이며, integerC, integerD는 equal이고 not same입니다.
??????????????????????????????????????????????
결과를 보고 놀라서
머릿속에서 생각나는 모든 비교를 해보고 정리해보기로 다짐했습니다.
하나씩 알아봅시다.
Object, Integer, int, String 순으로 4가지를 알아보겠습니다.
Object
모든 객체의 조상인 Object 클래스에 가보면, equals 함수를 주소(==)로 비교하고 있습니다.
그래서 객체는 same과 equal 둘 다 주소 값이 같은지로 검사됩니다.
클래스를 만들었는데, equal은 필드 값이 다 같은지 비교해서 출력하고 싶다면 equals를 오버라이드해서 재정의하면 됩니다. 아래 티스토리에서 상세한 설명과 equals 재정의 예제 코드를 보실 수 있습니다.
https://codevang.tistory.com/104
그런데 더 복잡하게 들어가면, hashcode 메소드도 재정의해주는 것이 좋습니다.
hashcode도 Object의 메소드인데 객체의 메모리를 이용해서 해시코드를 만들어 리턴합니다[2].
equals와 hashcode는 동시에 재정의해주는 것이 좋은데, collection을 사용할 때 에러가 날 수 있기 때문입니다.
아래 사이트에 동시에 재정의해야 하는 이유와 재정의 코드가 상세히 서술되어 있으므로 참고하시면 좋을 것 같습니다.
https://tecoble.techcourse.co.kr/post/2020-07-29-equals-and-hashCode/
그렇다면 반드시 객체를 equals로 비교하기 위해서 이렇게 복잡한 과정을 거쳐야하는가? 라는 궁금증이 드실 텐데,
롬복에서 간편한 어노테이션을 제공합니다.
클래스 위에 롬복의 @EqualsAndHashCode 어노테이션을 달면 equal로 필드 값이 모두 같은지 비교할 수 있습니다.
아래 예시처럼 사용하면 됩니다.
@EqualsAndHashCode
static class Name{
public String firstName;
public String lastName;
public Name(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
}
@EqualsAndHashCode는 모든 필드를 사용해서 equals(), hashcode()를 생성합니다[3].
모든 필드의 값이 같다면 a.equals(b)의 결과가 참이 됩니다.
초반에 본 아래 테스트가 @EqualsAndHashCode 어노테이션을 달면 통과합니다.
public class SameTest {
@Test
public void sameTest(){
Name tyrion = new Name("Tyrion", "Lannister");
Name alias = tyrion;
Name clone = new Name("Tyrion", "Lannister");
Assertions.assertThat(clone).isNotSameAs(tyrion);
.isEqualTo(tyrion);
드디어 equal이 필드 비교로 이용됩니다!
정리하자면, Object는 same과 equal 모두 주소 값 비교이므로, equal을 필드 비교로 사용하고 싶다면 (equals()와 hashcode()를 오버라이드 해도 되며) @EqualsAndHashCode 어노테이션을 붙이면 됩니다.
그러면 이제 Object는 끝났고, Integer를 살펴봅시다.
Integer(Wrapper Class)
다음으로 Integer입니다. Integer는 Wrapper Class이죠. 기본형을 객체로 감싸준 것이 Wrapper Class입니다.
Wrapper Class가 무엇인지 생소하신 분은 아래 제가 작성한 게시글을 참고해주세요.
https://passionate.tistory.com/10?category=1251446
Integer에서는 isSameAs와 isEqualTo가 어떻게 적용될까요?
Integer도 Object이니, 둘 다 주소 값 비교로 검사될까요?
결론부터 말씀드리면 그렇지 않습니다. equals를 오버라이드해서 값이 같은지로 비교합니다.
따라서 Integer는 same은 주소 값 비교이고, equals는 값 비교입니다.
그러나 범위에 따라서 의외의 결과를 얻을 수도 있습니다. 같이 알아봅시다!
아래 코드의 1~4중에서 한 개만 테스트를 통과하지 못합니다. 무엇이 통과하지 못할까요?
Integer integerA = 1;
Integer integerB = 1;
assertThat(integerA).isEqualTo(integerB); // 1
assertThat(integerA).isSameAs(integerB); // 2
Integer integerC = 128;
Integer integerD = 128;
assertThat(integerC).isEqualTo(integerD); // 3
assertThat(integerC).isSameAs(integerD); // 4
4번에서 테스트를 통과하지 못합니다.
즉, integerA와 integerB는 equal이고 same 이지만,
integerC와 integerD는 equal이지만 same 은 아닙니다.
앞에서 same은 주소 값 비교라며 라고 생각하실 수 있습니다.
그렇다면 2번도 테스트를 통과하지 못해야 할 것 같습니다.
integerA~D는 모두 다른 주소 값을 가져야 할 것으로 보이기 때문입니다.
Integer 객체의 생성을 생각해봅시다[4].
// auto-boxing의 예
Integer integerA = 1; // 컴파일러가 다음과 같이 변환합니다 : Integer integerA = Integer.valueOf(1);
Integer integerA =1; 구문은 auto-boxing의 예입니다. auto-boxing은 primitive type인 int 1을 Wrapper Class인 Integer 객체로 자동으로 변환해줌을 의미합니다.
컴파일러가 자동으로 이 라인을 Integer integerA = Integer.valueOf(1);로 변환합니다.
중요한 것은 valueOf(); 메소드입니다.이 메소드는 모든 범위에서 객체를 만들어서 반환해 주지 않습니다. 정수의 범위가 -128~127이라면, IntegerCache 로부터 Integer 객체들을 리턴합니다. 그 외의 범위에서만 Integer 객체들을 만들어 줍니다.
따라서 integerA와 integerB는 IntegerCache의 같은 Integer 객체의 주소를 가집니다.
따라서 두 객체는 same 입니다.
그러나 integerC와 integerD의 값은 128이므로, 서로 다른 Integer 객체를 가집니다.
따라서 두 객체는 same 이 아닙니다.
위 문단의 내용(정수의 값이 -128~127일 때 캐시에서 Integer 객체를 가져옴)은 아래 블로그에서 참고하였습니다.
설명이 상세해서 참고하시면 좋을 듯합니다.
https://meetup.toast.com/posts/185
정리하자면, Integer 객체는 same은 주소 값 비교, equal은 값 비교입니다.
정수의 값이 -128~127 범위라면 따로 객체를 만든 것 같아도 같은 객체를 참조하기 때문에 same으로 나올 수 있습니다.
Integer 값을 비교할 때 주소 값을 비교하는 경우는 거의 없을 것이므로, equal을 사용하는 것을 추천해 드립니다.
다음으로 Integer의 친구인 int를 알아봅시다.
int(primitive type)
다음으로 int입니다. int는 primitive type입니다. 값이 그대로 스택에 저장되는 변수가 primitive type이며, short, int, long, float, double, byte, char, boolean 총 8개가 지원됩니다. primitive type이 무엇인지 생소하시다면 위에서 언급한 제가 작성한 Primitive Type/Wrapper Class 게시글(https://passionate.tistory.com/10?category=1251446 )을 보시면 도움이 될 것입니다.
int는 비교할 일이 정말 많죠.
int는 어떻게 same과 equals가 적용될까요?
int는 Integer와 비슷하지만, ==과 same의 결과가 다릅니다. ???????????
예시 코드를 먼저 봅시다.
int intA = 1;
int intB = 1;
assertThat(intA).isEqualTo(intB); // true
assertThat(intA).isSameAs(intB); // true
assertThat(1).isSameAs(1); // true
System.out.println(intA == intB); // true
int intC = 128;
int intD = 128;
assertThat(intC).isEqualTo(intD); // true
assertThat(intC).isSameAs(intD); // false
assertThat(128).isSameAs(128); // false
System.out.println(intC == intD); // true
System.out.println(128 == 128); // true
int로 isEqualTo, isSameAs를 적용하면 Integer와 결과가 같습니다.
값이1인 intA, intB는 same이고 equal이지만, 값이 128인 intC와 intD는 same하지 않으나 equal합니다.
그런데 호기심이 생겨서 assertThat(128).isSameAs(128)을 해보고 놀랐습니다.
결과가 false입니다... ... 128과 128이 same이 아니라니! 아주 놀라운 결과였는데요.
그런데 코딩을 할 때, 128==128은 많이 썼던 것 같아서 검사해보니 true가 나오더라고요.
제가 이 글의 초반에, 주소 값 비교(==, same)와 값 비교(equals)라고 말씀드렸습니다.
==과 same은 같음을 가정하고 말씀드렸습니다.
isSameAs 메소드의 설명을 보면 'Verifies that the actual value is the same as the given one, ie using == comparison.'이라고 되어 있습니다. 아래 사진처럼요.
그리고 실제로 isSameAs 메소드는 Object의 assertSame 메소드를 사용하고 있고, == 으로 비교합니다.
그런데 int는 same과 ==의 결과가 다르죠.
그래서 왜 그럴까 생각을 해봤습니다.
읽고 계신 분들은 눈치채셨나요?
'isSameAs 메소드는 Object의 assertSame 메소드를 사용하고' 있습니다.
즉 int는 객체가 아니기 때문에, isSameAs 메소드를 쓰기 위해서는 Object로 boxing 합니다.
따라서 same의 결과는 Integer의 결과와 같은 것이죠.
그러나 ==은 Object로 boxing 하지 않고 그대로 수행되고, 값으로 비교됩니다.
참고로 여기서 ==는 값 비교가 맞지만, 주소 비교도 맞습니다[5]. 아래 인용문을 참고해주세요.
이를 더 엄밀하게 서술하면 Primitive Type의 객체는 Constant Pool의 특정한 값을 참조하는 변수이기에, 결국 Constant Pool 내의 동일한 주소를 비교한다. (해당 주소가 동일하기에 ==을 사용해서 비교가 가능)
정리하자면, int는 Integer와 same, equal은 동일한 방식으로 수행됩니다
그러나, ==은 다릅니다. ==은 same과 같이 주소로 비교되지 않고, equal과 같이 값으로 비교됩니다.
너무 복잡하니까 값을 비교하고 싶을 때는 equal을 사용합시다.
아래 블로그에서 Integer와 int의 == 비교를 설명해주고 계십니다. int와 int는 value로, int와 Integer도 value로, Integer와 Integer는 address로 비교된다고 합니다. 세부적인 사항이 궁금하신 분은 아래 게시글을 참고해주세요!
https://marobiana.tistory.com/130
이제 드디어 Object, Integer, int 3개를 알아보았습니다!
마지막으로 String을 살펴보겠습니다.
String
드디어 마지막 네 번째 String입니다.
String도 Object를 상속받고 있지만, Integer처럼 equals를 오버라이드해서 값 비교가 가능하도록 정의하고 있습니다.
따라서 기본적으로 Integer와 같은 방식으로 동작합니다. same은 주소를, equal은 값을 비교합니다.
그런데 String에서도 알아야 할 문법이 있습니다.
String 관련 내용과 예제 코드를 아래 티스토리에서 참고하였습니다.
String 비교에 대한 내용이 상세하게 서술되어있으니 참고하시면 많은 도움이 되실 것입니다.
https://coding-factory.tistory.com/536
아래의 String 비교에서, 1~4중에서 한 개만 테스트를 통과하지 못합니다.
무엇에서 오류가 발생할까요?
String str1 = "apple"; //리터럴을 이용한 방식
String str2 = "apple"; //리터럴을 이용한 방식
String str3 = new String("example"); //new 연산자를 이용한 방식
String str4 = new String("example"); //new 연산자를 이용한 방식
assertThat(str1).isEqualTo(str2); // 1
assertThat(str1).isSameAs(str2); // 2
assertThat(str3).isEqualTo(str4); // 3
assertThat(str3).isSameAs(str4); // 4
바로 4에서 동작하지 않습니다.
그렇다면 str1, str2는 값과 주소가 같고, str3, str4는 값은 같으나 주소는 다른 것이겠죠.
String 변수 할당에는 두 개의 방식이 있습니다.
리터럴을 이용한 방식과 new를 이용한 방식입니다.
String은 리터럴로 변수를 할당하면 값이 같으면 같은 주소를 가지고, new로 변수를 할당하면 무조건 객체가 새로 생성됩니다.
어떠한 과정을 거치는지 알아봅시다.
자바에서 객체를 형성하면 stack에 주소를 저장합니다. 그리고 실제 데이터는 Heap에 저장됩니다.
String을 리터럴로 생성하면, Heap의 String Constant Pool에 데이터가 저장됩니다.
그리고 같은 값을 갖는 String을 리터럴로 선언하면, 같은 주소를 참조하게 됩니다. 아래와 같은 과정으로 작업이 수행됩니다[6].
String을 리터럴로 선언할 경우 내부적으로 String의 intern() 메서드가 호출되게 되고 intern() 메서드는 주어진 문자열이 string constant pool에 존재하는지 검색하고 있다면 그 주소 값을 반환하고 없다면 string constant pool에 넣고 새로운 주소 값을 반환합니다.
그러나 new로 String 객체를 형성하면 무조건 Heap에 데이터를 새로 만듭니다.
따라서 같은 "example" 값을 가지더라도, str3과 str4는 다른 주소를 참조합니다.
정리하자면, String은 Integer와 같이 same은 주소를, equal은 값을 비교하는 데 사용됩니다.
그러나 리터럴로 String 객체를 생성하면 코드의 작성자도 모르게 같은 주소를 가질 수 있습니다.
String도 주소를 비교하는 경우는 잘 없을 테니, 두 String의 내용이 같은지 비교하고 싶다면 equal을 사용합시다.
정리
여기까지 4개 타입을 하나씩 알아보았습니다.
표로 정리해보았습니다.
기본값 | Object | Integer | int | String | |
same | 주소 | 주소 | 주소 | 주소 | 주소 |
equals | 값 | 주소 | 값 | 값 | 값 |
== | 주소(same) | 주소(same) | 주소(same) | 값(equals) & 주소(same) |
주소(same) |
참고 사항 | equals를 필드 값 비교로 사용하고 싶다면, @EqualsAndHashCode 이용 | 정수 값의 범위가 -128~127이면 값이 같은 객체가 주소도 같음 | Integer의 참고 사항 & ==을 값 비교로 사용 | 리터럴로 객체 형성 시 값이 같은 객체가 주소도 같음 |
드디어 다 정리를 해보았습니다!
결론을 내려보자면,
주소를 비교할 때는 same을 사용하고 값을 비교할 때는 equals를 사용합시다. 객체를 필드 값으로 비교하고 싶으면 @EqualsAndHashCode를 붙여줍니다.
Integer, int, String은 따로 선언해서 주소가 달라 보이는데 사실 주소가 같은 경우가 발생하기도 합니다.
다 외우지 못해도, 보통 값을 비교할 텐데 equals는 잘 동작할 것이니 equals를 사용합시다.
잘못된 부분은 피드백 주시면 감사하겠습니다.
글 읽어주셔서 감사합니다 :-)
참고자료
[3] 스프링 핵심 원리 - 기본편, 섹션 8. 빈 생명주기 콜백 https://www.inflearn.com/course/스프링-핵심-원리-기본편
[4] https://meetup.toast.com/posts/185
[5] https://hongchangsub.com/java1/
[6] https://coding-factory.tistory.com/536
'Java' 카테고리의 다른 글
[Java] Primitive Type/Wrapper Class (0) | 2022.01.05 |
---|---|
[Java] 상속 extends, implements, abstract (0) | 2022.01.04 |
댓글