본문 바로가기
빌드노트 검색

빌드노트

Integer를 ==로 비교했더니 값에 따라 결과가 달라진다

작은 Integer에서 통과한 == 비교가 큰 ID에서 어긋나는 이유를 JShell로 확인한다. 캐시의 관찰과 Java 17의 보장 범위를 나누고, null을 허용하는 값 비교와 언박싱 예외를 설명한다.

Integer로 선언한 ID 두 개를 ==로 비교하면, 같은 숫자인데도 127에서는 true, 128에서는 false가 나올 수 있다. 두 피연산자가 모두 Integer이면 ==는 같은 객체를 가리키는지 검사한다. ID의 숫자가 같은지 확인하려면 값 비교가 필요하다. null도 들어올 수 있는 두 Integer에는 Objects.equals(oldId, newId)를 쓸 수 있다.

먼저 문제가 있는 == 양옆의 선언 타입을 확인한다. Integer와 Integer의 비교인지, 한쪽이 원시형 int인지에 따라 실행되는 비교가 달라진다. 화면에 출력된 숫자만으로는 이 차이를 알 수 없다. Java 17 언어 명세의 참조 비교 규칙 (새 탭에서 열림)은 두 참조가 같은 객체를 가리키거나 둘 다 null일 때 ==가 참이라고 정의한다.

작은 값으로만 확인하면 놓치는 이유

JDK 17과 PATH에서 찾을 수 있는 jshell이 있으면 아래 입력을 실행할 수 있다. 첫 명령은 버전을 확인하고, 두 번째 명령은 예제를 입력할 JShell을 연다.

jshell --version
jshell --execution local --no-startup --feedback concise

같은 숫자를 각각 Integer 변수에 넣은 뒤 참조 비교와 값 비교를 나란히 확인한다. 아래부터는 같은 JShell 세션에 입력한다.

import java.util.Objects;
Integer smallA = 127;
Integer smallB = 127;
Integer largeA = 128;
Integer largeB = 128;
System.out.println("127: == " + (smallA == smallB) + ", equals " + Objects.equals(smallA, smallB));
System.out.println("128: == " + (largeA == largeB) + ", equals " + Objects.equals(largeA, largeB));

Windows의 OpenJDK 17.0.2, JShell 17.0.2에서 직접 얻은 출력이다. 변수 선언에 대한 JShell의 안내는 생략했다.

127: == true, equals true
128: == false, equals true

Integer smallA = 127에서는 원시형 값을 Integer 참조로 바꾸는 박싱이 일어난다. 이때 매번 새 객체를 만들 필요는 없다. 이번 실행에서 127의 두 변수는 객체를 공유했고, 128의 두 변수는 서로 다른 객체를 가리켰다. 값 비교는 양쪽 모두 참이었다.

여기서 128 이상이면 반드시 `false`라는 규칙을 만들면 안 된다. Integer.valueOf(int)의 Java 17 API 계약 (새 탭에서 열림)은 -128부터 127까지 양 끝을 포함해 캐시하며, 그 밖의 값도 캐시할 수 있다고 명시한다. 별도 실행에서 valueOf에 -128과 127을 각각 두 번 넘겼을 때도 참조 비교가 참이었다. 범위 밖의 128에서 얻은 거짓은 이번 환경의 관찰이다.

언어의 보장도 조건을 붙여 읽어야 한다. JLS 17의 박싱 규칙 (새 탭에서 열림)은 이 예제의 127처럼 상수식을 평가한 결과인 -128부터 127까지의 정수를 두 번 박싱하면 같은 참조임을 보장한다. 이 문장을 임의의 생성 경로로 얻은 모든 작은 Integer에 적용할 수는 없다. valueOf(int)의 캐시 계약과 언어의 상수식 조건은 각각 확인해야 한다.

작은 값이라도 새 객체 두 개를 명시적으로 만들면 참조 비교와 값 비교가 갈린다. 다음 입력은 그 차이를 확인하는 대조용이다.

Integer freshA = new Integer(127);
Integer freshB = new Integer(127);
System.out.println("new 127: == " + (freshA == freshB) + ", equals " + Objects.equals(freshA, freshB));

실제 출력은 new 127: == false, equals true였다. Java 17에서는 두 생성자 호출에 폐기 예정 경고도 나왔다. Integer(int) 생성자 (새 탭에서 열림)는 새 객체를 만들지만 Java 9부터 deprecated이고 제거 대상으로 표시돼 있다. 이 코드를 ID 생성 방법으로 채택할 이유는 없다. 반대로 Integer alias = largeA처럼 같은 참조를 대입한 대조에서는 128이어도 largeA == alias가 참이었다. 참조 공유 여부는 값 비교의 기준이 될 수 없다.

한쪽을 int로 바꾸면 null에서 예외가 난다

다음 입력은 한쪽이 int인 비교와 단순한 null 검사의 차이를 보여 준다.

int primitive = 128;
System.out.println(largeA == primitive); // true
Integer missing = null;
System.out.println(missing == null);     // true
System.out.println(missing == primitive);

앞의 두 출력은 주석대로 참이었고, 마지막 줄에서는 NullPointerException이 발생했다. 예외 메시지는 null인 missing에서 Integer.intValue()를 호출할 수 없다고 설명했다.

한쪽이 int이면 Integer를 원시형으로 꺼내는 언박싱을 거쳐 숫자를 비교한다. null에서는 꺼낼 값이 없다. 이는 숫자 동등 비교 규칙 (새 탭에서 열림)과 null 언박싱 규칙 (새 탭에서 열림)에 따른 동작이다. missing == null은 참조 비교라 언박싱하지 않는다.

따라서 ==를 유지하려고 무작정 .intValue()를 붙이면 null 문제는 그대로 남는다. 원시형 기준값과 비교하면서 null을 불일치로 처리하려면 missing != null && missing == primitive처럼 먼저 검사할 수 있다. 실제로 이 식은 null에서 예외 없이 false였고, 같은 조건에 값이 128인 largeA를 넣으면 true였다.

null 두 개를 같다고 볼지도 정한다

두 Integer의 값 비교에는 캐시 여부가 끼어들지 않아야 한다. 다음 입력으로 같은 숫자와 null의 결과를 확인할 수 있다.

System.out.println(Objects.equals(largeA, largeB)); // true
System.out.println(Objects.equals(missing, largeA)); // false
System.out.println(Objects.equals(missing, null));   // true

세 출력은 주석과 같았다. 추가 대조에서 숫자가 128과 129인 경우는 false였고, 값과 null의 순서를 바꿔도 false였다. Objects.equals의 계약 (새 탭에서 열림)은 둘 다 null이면 참, 한쪽만 null이면 거짓을 반환한다. 둘 다 값이 있으면 객체의 equals를 사용한다. Integer.equals의 계약 (새 탭에서 열림)은 상대도 Integer이고 담긴 int 값이 같아야 참이다. 여기서 다루는 비교는 양쪽이 Integer인 경우다.

두 ID가 모두 없을 때도 같은 값으로 취급한다면 Objects.equals가 맞는다. 반면 ID가 없는 두 대상을 같은 대상으로 판단하면 안 되는 흐름이라면 left != null && Objects.equals(left, right)처럼 존재 조건을 함께 둔다. 같은 조건을 null 두 개에 적용한 실행 결과는 false였다. null일 수 있는 변수에 바로 left.equals(right)를 호출하는 방법은 피해야 한다. 실험에서도 null에 대한 메서드 호출은 NullPointerException을 냈다.

이번 확인은 OpenJDK 17.0.2의 격리된 JShell 입력이며 JVM 캐시 옵션을 변경하지 않았다. 다른 JDK나 DB·ORM이 ID를 만드는 경로는 실행하지 않았다. 자신의 코드에서 결과가 다르면 비교 직전의 선언 타입과 참조 대입 경로를 확인한다. 128의 참조 비교가 참으로 나와도 값 비교가 되는 것은 아니다. 수정 후에는 같은 큰 값, 다른 값, 한쪽 null, 양쪽 null을 넣고 의도한 ID 비교 규칙이 유지되는지 확인한다. JShell은 /exit로 종료한다.

← 전체 글 보기