블랙잭 미션에서 배팅 금액의 1.5배가 잘 적용되었는지를 TDD로 진행하다가 생긴 의문에 대해서 조금 더 깊게 알아보았다. 내가 isEqualTo()
가 아닌 isSameAs()
를 사용한 이유는 primitive
타입끼리 비교를 하는 상황이었기 때문에 ==
비교 즉, 동일성 비교가 적합하다고 생각했기 때문이다.
@Test
void bonus() {
final Bet bet = Bet.of(10_000);
assertThat(bet.applyBonus())
.extracting("bet")
.isSameAs(15_000);
}
아니 이게 무슨일이야.. 15000 == 15000
이니까 당연히 isSameAs
를 쓰면 통과되어야 하는 것 아닌가? 참고로 배팅 금액은 Bet
클래스의 필드에 int bet
로 가지고 있다.
설상가상으로 다음의 테스트는 통과한다. 이게 무슨 일이야..
assertThat(1).isSameAs(1);
assertThat(15000).isNotSameAs(15000);
isSameAs
의 정의부를 확인해보자.
확실한건 isSameAs
는 동일성 비교라는 것이다. 맨 위의 주석에서 확인할 수 있다. 동일성 비교는 참조값을 비교한다. 원시값에 대해 동일성 비교를 하면 내용을 비교한다. 그렇다면 왜 1은 되고, 15000은 안될까?
캐싱과 박싱의 얽히고 섥힌 문제다. 논리적으로 정확하게 설명할 자신이 없어 문제들을 하나하나 찾고 해결하는 방식으로 설명해보겠다.
isSameAs
의 매개변수 타입
AssertJ
의 isSameAs
메서드 시그니처를 다시 확인해보자.
그렇다… 매개변수의 타입이 Object
여서 우리가 아무리 primitive
타입의 int
를 대입하더라도 오토 박싱이 일어난다.
(왜 이런 유명한 테스트 라이브러리에서 primitive
타입을 오버로딩 해놓지 않았는지는 의문이다!)
근데 잠깐 오토 박싱이
new Integer(1)
로 될까 아니면Integer.valueOf(1)
로 될까?
정답은 Integer.valueOf(1)
이다. 그 이유는 new
로 인한 무분별한 객체 생성을 막기 위해서다.
그럼
Integer.valueOf()
를 사용하면 무분별한 객체 생성이 안돼?
그렇다. Integer.valueOf()
의 정의부를 보도록 하자.
@HotSpotIntrinsicCandidate
public static Integer valueOf(int i) {
// low와 high는 -128과 127이다.
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
위의 코드처럼 자바는 메모리 성능을 끌어 올리기 위해 자주 사용되는 상수를 캐싱 처리하고 있다. 어떤 타입마다 얼마의 범위만큼 캐싱하고 있는지는 더 알아봐야겠지만, Integer
는 위의 코드상으로는 -128부터 127까지 캐싱을 하고 있는 것을 확인할 수 있다.
결국 -128 ~ 127 사이의 숫자들은 캐싱되어 Integer.valueOf()
로 호출하면 같은 메모리를 참조하게 되고, 그 외의 값들은 같은 메모리 주소값을 참조하지 않게 된다. 따라서 조금이나마 성능을 개선하기 위해 new Integer()
를 사용하기보다 Integer.valueOf()
를 사용해 박싱 처리를 하는 것 같다.
그렇다면
assertThat
메서드의 매개변수 타입은 어떻게 될까? 메서드의 정의부로 가보자.
IntegerAssert()
의 정의부로 가보자.
그렇다. 이 친구도 매개변수 타입이 래퍼 타입이다.
이제 다시 처음 문제를 보도록 하자. 모든 논란은 아래의 코드에서 발생했다.
assertThat(1).isSameAs(1);
assertThat(15000).isNotSameAs(15000);
위의 네 가지 문제점을 살펴보면서 박싱과 캐싱이 일어난다는 것을 알았다. 그렇다면 위의 코드는 결국 다음과 같이 박싱이 일어난다는 것을 알 수 있다.
assertThat(Integer.valueOf(1)).isSameAs(Integer.valueOf(1));
assertThat(Integer.valueOf(15000)).isNotSameAs(Integer.valueOf(15000));
그렇다면 우리는 Integer.valueOf
는 캐싱을 한다는 사실을 알고 있기에 1은 캐싱 범위 내에 있으므로 주소값이 같고, 15000은 범위 밖에 있으므로 주소값이 다르다는 것을 알 수 있다.
이렇게 보니, 합당한 코드인 것 같다.
이것 저것 테스트하다가 다음과 같은 상황을 만났다. 당연히 아래의 코드도 모두 성공하는 테스트들이다.
assertThat(15000).isNotSameAs(15000); // 1
assertThat(15000 == 15000).isTrue(); // 2
assertThat(15000 == new Integer(15000)).isTrue(); // 3
1번은 위에서 박싱된 객체로 바뀌고, 캐싱 범위를 벗어나기에 두 주소값이 다르다는 것은 알 수 있다. 근데 2번은 당연히 통과되는 게 맞는 것 같고… 3번은 왜 통과하지..??!?!
정신 차리자.
15000
과 new Integer(15000)
을 비교하기 위해서는 당연히 언박싱이 일어나야 한다. 결국 assertThat
의 매개변수? 내에서 이미 언박싱되어 3번은 assertThat(15000 == 15000).isTrue()
와 같아지게 된다. primitive
타입끼리의 동일성 비교는 언제나 true
이다.
이번 정리는 왜 저의의 배팅 금액이 15000인데 15000이 아니라고 하는지, 억울해서 여기까지 오게 되었습니다. 그 과정에서 믿었던 AssertJ
의 배신(왜 오버로딩 안해줘..)과 자바의 박싱 처리 방법, Integer
의 캐싱 처리를 배웠네요 ㅎㅎㅎ. 아무 생각 없이 테스트에서 isEqualTo
를 사용하다가 지적해준 지토 덕분에 이런 이상하고 재밌는 사실을 알게 되었습니다. 같이 고민해주신 우테코 크루 하디랑 레오, 파워, 마코, 히이로도 감사감사합니다! 그외의 분들도 모두 감사합니다!!