15000원은 15000원이 아닌가?

헙크·2023년 9월 16일
2

글을 쓰게 된 배경

블랙잭 미션에서 배팅 금액의 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의 매개변수 타입

AssertJisSameAs 메서드 시그니처를 다시 확인해보자.

그렇다… 매개변수의 타입이 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번은 왜 통과하지..??!?!

결론

정신 차리자.

15000new Integer(15000)을 비교하기 위해서는 당연히 언박싱이 일어나야 한다. 결국 assertThat의 매개변수? 내에서 이미 언박싱되어 3번은 assertThat(15000 == 15000).isTrue()와 같아지게 된다. primitive 타입끼리의 동일성 비교는 언제나 true이다.

마치며

이번 정리는 왜 저의의 배팅 금액이 15000인데 15000이 아니라고 하는지, 억울해서 여기까지 오게 되었습니다. 그 과정에서 믿었던 AssertJ의 배신(왜 오버로딩 안해줘..)과 자바의 박싱 처리 방법, Integer의 캐싱 처리를 배웠네요 ㅎㅎㅎ. 아무 생각 없이 테스트에서 isEqualTo를 사용하다가 지적해준 지토 덕분에 이런 이상하고 재밌는 사실을 알게 되었습니다. 같이 고민해주신 우테코 크루 하디랑 레오, 파워, 마코, 히이로도 감사감사합니다! 그외의 분들도 모두 감사합니다!!

0개의 댓글