==가 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.2는 0.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/>값: "Hello""]
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 — 내용은 같음
s1과 s2는 둘 다 "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
Integer는 int의 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 == b가 true — 둘이 같은 객체를 가리키니까.
하지만 200은 캐시 범위 밖이다. valueOf(200)을 부를 때마다 새로운 객체를 만든다. c와 d는 다른 객체 → ==가 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; // 매번: 객체 열기(언박싱) → 더하기 → 새 객체 만들기(박싱)
}
Long은 long이 아니다. 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;를 선언하고 초기화하지 않으면,orders는null이다. 이 상태에서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은 동적 타입이 아니다. 컴파일 타임에 타입이 고정되는 단축키다. 타입이 명확할 때만 쓴다.
생각해 볼 문제
Double a = 0.0 / 0.0;의 값은 무엇인가?a == a의 결과는? (NaN은 자기 자신과도 같지 않다.)char c = 65; System.out.println(c);의 출력은? (힌트:char는 부호 없는 정수이기도 하다.)Long sum = 0L루프와long sum = 0L루프의 실행 시간을 비교해 보자. 얼마나 차이가 나는가?var list = new ArrayList<>();에서list의 원소 타입은 무엇으로 추론되는가?-XX:AutoBoxCacheMax=1000으로 캐싱 범위를 늘리면Integer a = 500; Integer b = 500; a == b의 결과는?
참고
- JLS §4 - Types, Values, and Variables - 접근 2026-07-10
- JLS §5 - Conversions and Contexts - 접근 2026-07-10
- Integer.valueOf API (Java 25) - 접근 2026-07-10
- JEP-286: Local-Variable Type Inference - 접근 2026-07-10
- JEP-394: Pattern Matching for instanceof - 접근 2026-07-10
'Develop Artifacts > Java' 카테고리의 다른 글
| Java - 01. intro (0) | 2026.07.10 |
|---|
