==true이기도 하고 false이기도 한 이유 — Java 타입 시스템의 두 세계

다음 코드를 보자.

// Java 25
Integer a = 127;
Integer b = 127;
System.out.println(a == b);   // (1)

Integer c = 200;
Integer d = 200;
System.out.println(c == d);   // (2)

(1)은 true, (2)는 false다. 같은 == 연산자인데, 값이 127일 때와 200일 때 결과가 다르다. 이게 말이 되는가?

이 미스터리를 풀려면 한 가지를 알아야 한다 — Java에는 데이터를 저장하는 두 가지 방식이 있다는 것. 이 두 방식의 차이가 ==의 동작을 결정하고, null의 의미를 결정하고, 성능과 메모리 사용량까지 결정한다. 이 글은 이 두 세계를 하나씩 풀어가며, 처음의 미스터리를 해결한다.

두 가지 저장 방식 — "값"과 "주소"

Java의 모든 변수는 값을 직접 들고 있거나, 값이 있는 곳의 주소를 들고 있다. 이게 전부다. 이 두 가지를 Java는 각각 primitive 타입reference 타입이라 부른다.

쉽게 비유하자면:

  • primitive지갑에 현금을 넣는 것이다. int x = 5000이면 지갑에 5000원이 직접 들어있다. 돈을 확인하려면 지갑을 열면 된다.
  • reference지갑에 체크카드를 넣는 것이다. String name = "Alice"는 지갑에 카드가 있고, 카드는 계좌(객체)를 가리킨다. 잔액을 확인하려면 카드를 읽어서 계좌에 가봐야 한다.

이 차이가 왜 중요한가? ==로 두 변수를 비교할 때:

  • 현금(primitive)끼리 비교하면 → 금액이 같은지 본다. 5000원과 5000원은 같다.
  • 카드(reference)끼리 비교하면 → 같은 계좌를 가리키는지 본다. 다른 두 카드가 같은 잔액을 가져도, 다른 카드면 false.

이것이 ==가 혼란스러운 이유다. primitive에서는 "값이 같은가?"를 묻지만, reference에서는 "같은 객체인가?"를 묻는다.

primitive — 현금처럼 값 자체를 저장하는 8가지

Java는 8개의 primitive 타입을 정의한다. (JLS §4.1)

타입 크기 범위 언제 쓰나
int 32-bit 약 ±21억 정수의 기본. 웬만하면 이걸 쓴다
long 64-bit 약 ±920경 큰 숫자. L 붙임: 10000000000L
double 64-bit 소수 실수의 기본
boolean true/false 조건. 크기 명세 없음
char 16-bit 0~65535 글자 하나. 부호 없는 정수이기도 함
byte 8-bit -128~127 이진 데이터 (파일, 네트워크)
short 16-bit ±3만 거의 안 씀
float 32-bit 소수 거의 안 씀. double 쓰자

primitive 변수는 메모리에 값 자체를 저장한다. int x = 42라고 하면, 그 메모리 칸에는 42라는 숫자가 직접 들어간다. "42를 가리키는 주소"가 아니라 42 자체다.

정수에 끝이 있다 — 오버플로우

int의 최대값은 2,147,483,647(약 21억)이다. 이걸 넘으면 어떻게 될까?

int max = 2147483647;
System.out.println(max + 1);   // -2147483648

예외가 안 난다. 조용히 음수가 된다. 자동차 주행거리계가 999,999에서 0으로 돌아가는 것과 같다 — 한 바퀴 돌아서 최소값으로 간다.

이게 왜 위험한가? 은행에서 잔액이 21억에서 1원이 입금됐는데 -21억이 되는 상황을 상상해 보자. 금액 계산에는 Math.addExact()를 쓰면 — 초과 시 ArithmeticException을 던져서 즉시 알려준다.

0.1 + 0.20.3이 아니다

System.out.println(0.1 + 0.2 == 0.3);   // false
System.out.println(0.1 + 0.2);         // 0.30000000000000004

이건 Java의 버그가 아니다. 컴퓨터가 0.1을 이진수로 표현할 때 정확히 맞아떨어지지 않기 때문이다. 10진수로 1/3을 정확히 표현할 수 없는 것과 같은 이치다.

돈 계산에는 double을 쓰지 마라. BigDecimal을 쓴다:

// Java 25
import java.math.BigDecimal;

BigDecimal a = new BigDecimal("0.1");   // String으로!
BigDecimal b = new BigDecimal("0.2");
System.out.println(a.add(b));           // 0.3 (정확)

new BigDecimal("0.1")에서 문자열을 쓰는 이유: new BigDecimal(0.1)은 이미 부정확한 double 값을 받아들이기 때문이다. 0.1이라고 써도 0.1000000000000000055...로 해석된다.

reference — 카드처럼 주소를 저장하는 세계

String, ArrayList, 우리가 만든 클래스 — 이들은 모두 reference 타입이다. 변수가 객체 자체가 아니라 객체의 주소를 들고 있다.

flowchart LR
    subgraph stack["변수 공간 (Stack)"]
        X["int x = 42<br/>(값이 직접 있음)"]
        S["String s<br/>(주소만 있음)"]
    end
    subgraph heap["객체 공간 (Heap)"]
        O["String 객체<br/>값: &quot;Hello&quot;"]
    end
    S -->|"이쪽에 있음"| O

int x = 42는 x 칸에 42가 있다. String s = "Hello"는 s 칸에 "Hello"가 있는 게 아니라 — "Hello 객체는 heap의 이 주소에 있어"라는 안내판이 있다. 실제 "Hello"는 heap이라는 별도 영역에 있다.

==가 다시 문제된다

이제 ==가 왜 위험한지 보인다:

String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1 == s2);        // false — 다른 객체(다른 주소)
System.out.println(s1.equals(s2));   // true — 내용은 같음

s1s2는 둘 다 "hello"라는 같은 내용을 가진 객체를 가리킨다. 하지만 서로 다른 객체다. ==는 주소를 비교하므로 false. .equals()는 내용을 비교하므로 true.

규칙이 하나 있다: reference 타입을 비교할 때는 항상 .equals()를 쓴다. ==는 primitive에만.

미스터리 해결 — Integer 캐싱

이제 처음의 퍼즐로 돌아가자.

Integer a = 127;   // Integer는 reference 타입 (wrapper)
Integer b = 127;
System.out.println(a == b);   // true (?!)

Integer c = 200;
Integer d = 200;
System.out.println(c == d);   // false

Integerint의 wrapper 클래스다. int는 primitive(값)이고, Integer는 reference(객체)다. Java 5부터 Integer a = 127이라고 쓰면 컴파일러가 자동으로 Integer.valueOf(127)로 변환한다(오토박싱).

valueOf 메서드가 바로 범인이다. (Integer.valueOf API) 내부 코드를 보면:

// Integer.valueOf의 내부 동작 (단순화)
private static final Integer[] cache = new Integer[256];

public static Integer valueOf(int i) {
    if (i >= -128 && i <= 127) {
        return cache[i + 128];   // 미리 만들어둔 객체 재사용
    }
    return new Integer(i);        // 범위 밖 → 새 객체 매번 생성
}

즉, -128에서 127 사이의 숫자는 미리 객체를 만들어두고 재사용한다. 127을 요청하면 항상 같은 객체를 돌려준다. 그래서 a == btrue — 둘이 같은 객체를 가리키니까.

하지만 200은 캐시 범위 밖이다. valueOf(200)을 부를 때마다 새로운 객체를 만든다. cd는 다른 객체 → ==false.

비유: 편의점에서 "물"을 사면, 진열대에 미리 채워둔 물(캐시)을 준다 — 두 사람이 물을 사면 같은 진열대에서 줘서 "같은 물"이다(127). 하지만 "한정판 음료"(200)는 주문할 때마다 새로 만들어 주니까, 두 개는 "다른 병"이다.

왜 하필 -128~127인가? 이 범위의 숫자가 프로그래밍에서 가장 빈번하게 쓰이기 때문이다 (루프 인덱스, 작은 카운터 등). 자주 쓰는 객체를 미리 만들어두어 성능을 높이려는 설계다. (JLS §5.1.7)

실전에서 왜 문제인가

이 캐싱은 테스트에서는 통과하고 프로덕션에서 터진다. 테스트할 때는 ID가 1, 2, 3 같은 작은 값이니까 ==가 우연히 true. 프로덕션에서 ID가 9,999가 되면 갑자기 false. "테스트에서 됐는데요?"라는 말이 나오는 순간이다.

해결책은 단순하다: wrapper 타입(Integer, Long, Double)을 비교할 때는 항상 .equals(). 규칙은 하나다.

실전 시나리오 — ID 비교 버그

// 실제 발생할 수 있는 버그 패턴
class UserService {
    private Map<Long, User> cache = new HashMap<>();

    public boolean isCached(Long userId) {
        // 개발 중: userId가 1, 2, 3 → 캐시 범위 → == 로 우연히 동작
        // 프로덕션: userId가 98765 → 캐시 범위 밖 → == 실패
        return userId == getLastAccessedId();   // 버그!
    }

    public boolean isCachedSafe(Long userId) {
        return userId.equals(getLastAccessedId());   // 정확
    }
}

이 패턴이 위험한 이유는 — 개발 환경에서 ID가 작은 값일 때는 "잘 되는데?"라고 넘어가기 때문이다. 코드 리뷰에서도 ==.equals()의 차이를 놓치기 쉽다.

오토박싱의 숨은 비용

Long sum = 0L;   // Long (wrapper) — primitive long이 아님!
for (int i = 0; i < 1000000; i++) {
    sum += i;    // 매번: 객체 열기(언박싱) → 더하기 → 새 객체 만들기(박싱)
}

Longlong이 아니다. Long은 객체이므로, sum += i를 할 때마다 (1) 객체에서 long 값 꺼내기, (2) 더하기, (3) 결과로 Long 객체 생성이 일어난다. 100만 번의 루프에서 100만 개의 쓰레기 객체가 만들어진다 — GC가 청소하느라 CPU를 낭비한다.

해결: long sum = 0L; (primitive). 단 한 글자 차이지만 성능은 수십 배 다르다.

형변환 — 큰 그릇에서 작은 그릇으로

Java에서 타입을 바꾸는 것을 형변환(casting)이라 한다. (JLS §5)

작은 것 → 큰 것 (widening): 데이터 손실이 없으니 컴파일러가 알아서 해준다.

int i = 42;
long l = i;       // OK: int(32비트)를 long(64비트)에 넣기 — 남는 공간이 충분

큰 것 → 작은 것 (narrowing): 데이터가 잘릴 수 있으니 "정말 할 거야?"라고 명시해야 한다.

double d = 3.99;
int i = (int) d;   // (int) 캐스트: 소수점 버림 → i = 3

비유하자면: 1L 주전자의 물을 2L 주전자에 붓는 건 문제없다(widening). 2L 주전자의 물을 1L 주전자에 붓는 건 넘친다(narrowing) — "알고 있다"고 (int)로 명시해야 컴파일러가 허락한다.

참조 타입의 캐스트 — 안전하게

Object obj = "Hello";
// Integer num = (Integer) obj;   // ClassCastException! "Hello"는 Integer가 아니다

// Java 16+ 안전한 방법
if (obj instanceof String s) {   // 타입 확인 + 변수 바인딩 동시에
    System.out.println(s.length());
}

(JEP-394) instanceof String s는 "obj가 String이면 s에 넣어라" — 확인과 캐스트를 한 번에 한다.

var — 타입을 생략하면 컴파일러가 추론한다

Java 10부터 var 키워드로 타입을 생략할 수 있다. (JEP-286)

var name = "Alice";           // String으로 추론
var count = 42;               // int로 추론
var users = List.of("A","B"); // List<String>으로 추론

오해하지 마라: var은 JavaScript의 var과 다르다. 동적 타입이 아니다. 컴파일 타임에 타입이 고정된다. var name = "Alice"String name = "Alice"바이트코드가 동일하다. var은 단지 타입을 적기 귀찮을 때 쓰는 단축키일 뿐이다.

주의: 타입이 명확하지 않으면 var이 독이 된다.

var result = process(data);   // result의 타입이 뭔지 코드만 보고 모름
var x = 0;                    // int인데, long이 의도였다면?

초기화식의 타입이 한눈에 보이면 var을 쓰고, 메서드 반환값처럼 "뭔지 알아내야 하는" 경우는 명시적 타입을 쓴다.

초기화되지 않은 변수 — Java의 안전망

Java는 "초기화 안 된 변수"를 사용하지 못하게 막는다.

void method() {
    int x;
    // System.out.println(x);  // 컴파일 에러: x를 초기화 안 했음
    x = 42;
    System.out.println(x);      // OK
}

C/C++에서는 초기화 안 된 변수에 "쓰레기값"이 들어있어서 버그의 온상이었다. Java는 컴파일 타임에 이것을 막는다 — 안전망이다.

다만 필드(클래스의 멤버 변수)는 자동으로 기본값(0, false, null)으로 초기화된다. 지역 변수는 자동 초기화가 안 되므로 명시적으로 초기화해야 한다.

분류 기본값
숫자 (int, long, double 등) 0
boolean false
char '\u0000' (null 문자)
reference (String, List 등) null

reference 타입의 기본값이 null이라는 것이 함정이다. 필드로 private List<Order> orders;를 선언하고 초기화하지 않으면, ordersnull이다. 이 상태에서 orders.add(...)를 부르면 NullPointerException. "선언만 하고 안 넣었으면 빈 리스트여야 하는 거 아닌가?"라고 생각할 수 있지만 — Java는 null과 빈 컬렉션을 구분한다. private List<Order> orders = new ArrayList<>();로 명시적 초기화가 필요하다.

요약 — 이 글의 결론

  • Java에는 두 세계가 있다. primitive는 값 자체를, reference는 객체의 주소를 저장한다. ==는 primitive에서는 "값이 같나?"를, reference에서는 "같은 객체인가?"를 묻는다.
  • wrapper 비교에는 항상 .equals()를 쓴다. Integer ==가 127에서는 true, 200에서는 false인 이유는 캐싱 때문이다. 이 버그는 작은 숫자로 테스트하면 잡히지 않는다.
  • 정수에는 끝이 있다. 21억을 넘으면 조용히 음수가 된다. 금액 계산에는 Math.addExact()로 방어한다.
  • 0.1 + 0.2 != 0.3이다. 돈 계산에는 BigDecimal(String)을 쓴다.
  • var은 동적 타입이 아니다. 컴파일 타임에 타입이 고정되는 단축키다. 타입이 명확할 때만 쓴다.

생각해 볼 문제

  1. Double a = 0.0 / 0.0;의 값은 무엇인가? a == a의 결과는? (NaN은 자기 자신과도 같지 않다.)
  2. char c = 65; System.out.println(c);의 출력은? (힌트: char는 부호 없는 정수이기도 하다.)
  3. Long sum = 0L 루프와 long sum = 0L 루프의 실행 시간을 비교해 보자. 얼마나 차이가 나는가?
  4. var list = new ArrayList<>();에서 list의 원소 타입은 무엇으로 추론되는가?
  5. -XX:AutoBoxCacheMax=1000으로 캐싱 범위를 늘리면 Integer a = 500; Integer b = 500; a == b의 결과는?

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 01. intro  (0) 2026.07.10