Java 기초: 타입·문자열·==와 equals 차이 (JDK 26 실행)
OpenJDK 26에서 직접 돌린 Java 초급 강좌. int 오버플로, 정수 나눗셈, double과 BigDecimal, ==와 equals, Integer 캐시(127과 128), 실제 javac 에러.
시리즈: Java 강좌 1/6 (초급). 다른 언어로 반복문 정도는 써 봤고 이제 Java를 시작하는 분을 위한 글입니다. 다 읽으면 어떤 비교·숫자 타입·문자열 연산이 안전한지 판단할 수 있고, 초보자가 반드시 만나는 javac 에러를 보고 바로 고칠 수 있게 됩니다.
Java를 처음 배울 때 나오는 버그는 논리 오류보다 "숫자가 조용히 넘쳤다", "테스트에선 맞던 ==가 실서비스에서 틀렸다", "컴파일 에러 영어 문장이 뭘 고치라는 건지 모르겠다" 세 가지가 대부분입니다. 이 글은 그 세 가지를 일부러 일으키고, OpenJDK 26(Temurin 26+35), Apple Silicon Mac (M1 Pro)에서 나온 실제 출력을 그대로 보여 줍니다.
코드는 모두 파일 하나짜리이고 java 파일명.java로 바로 실행됩니다. Maven도 IDE도 필요 없습니다.
빌드 없이 실행: java Hello.java와 JShell
Java 11부터는 소스 파일 하나를 그대로 실행할 수 있습니다. 런처가 메모리에서 컴파일해 실행하므로 .class 파일이 디스크에 남지 않습니다. 학교 과제나 코딩 테스트 연습에 딱 맞습니다.
public class HelloClassic {
public static void main(String[] args) {
System.out.println("Hello, " + (args.length > 0 ? args[0] : "world"));
}
}
$ java HelloClassic.java Duke
Hello, Duke
Java 25부터는 클래스 선언까지 생략하는 "컴팩트 소스 파일"이 정식 기능이 됐고 IO.println도 생겼습니다.
void main() {
IO.println("Hello, Java " + Runtime.version().feature());
}
$ java Hello.java
Hello, Java 26
$ javac --release 21 Hello.java
Hello.java:1: error: implicitly declared classes are not supported in -source 21
(use -source 25 or higher to enable implicitly declared classes)
같은 폴더에 App.java와 Greeter.java를 두고 java App.java를 실행하면 Greeter도 알아서 찾았습니다(여러 파일 소스 실행, Java 22 이상). 이 시리즈는 Java 21에서도 돌도록 전통적인 public class 형태를 씁니다.
한 줄만 확인하고 싶을 때는 JDK에 들어 있는 REPL인 JShell이 가장 빠릅니다.
jshell> int x = Integer.MAX_VALUE + 1
x ==> -2147483648
jshell> "a" + 1 + 2
$5 ==> "a12"
jshell> 1 + 2 + "a"
$6 ==> "3a"
+는 왼쪽부터 계산되고, 한쪽이 String이 되는 순간부터 문자열 연결로 바뀝니다. 정보처리기사 실기에도 자주 나오는 유형입니다.
기본형과 참조형, 그리고 조용히 틀리는 숫자
Java의 타입은 값 자체를 담는 8개의 기본형(byte short int long float double char boolean)과, 힙에 있는 객체를 가리키는 참조형(String, Integer, 배열, 직접 만든 클래스)으로 나뉩니다. 이 글의 내용은 거의 전부 이 차이에서 나옵니다.
기본형 연산은 오버플로를 검사하지 않습니다.
int max = Integer.MAX_VALUE;
System.out.println("max + 1 = " + (max + 1));
long bad = 1_000_000 * 3_000; // int끼리 계산한 뒤 long으로
long good = 1_000_000L * 3_000;
System.out.println("bad = " + bad + ", good = " + good);
try { Math.addExact(max, 1); }
catch (ArithmeticException e) { System.out.println("addExact: " + e.getMessage()); }
System.out.println("-7 / 2 = " + (-7 / 2) + ", -7 % 2 = " + (-7 % 2));
System.out.println("0.1 + 0.2 = " + (0.1 + 0.2));
double sum = 0;
for (int i = 0; i < 10; i++) sum += 0.1;
System.out.println("0.1 x10 = " + sum + " == 1.0? " + (sum == 1.0));
System.out.println("new BigDecimal(0.1) = " + new BigDecimal(0.1));
System.out.println("1.10 equals 1.1? " + new BigDecimal("1.10").equals(new BigDecimal("1.1"))
+ ", compareTo = " + new BigDecimal("1.10").compareTo(new BigDecimal("1.1")));
max + 1 = -2147483648
bad = -1294967296, good = 3000000000
addExact: integer overflow
-7 / 2 = -3, -7 % 2 = -1
0.1 + 0.2 = 0.30000000000000004
0.1 x10 = 0.9999999999999999 == 1.0? false
new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625
1.10 equals 1.1? false, compareTo = 0
long bad = 1_000_000 * 3_000이 가장 놓치기 쉬운 줄입니다. 오른쪽이int로 계산되어 넘친 다음에long으로 넓혀집니다. 받는 변수가long이어도 소용없고, 한쪽을1_000_000L로 써야 합니다.javac -Xlint:all도 경고하지 않았습니다.Math.addExact,multiplyExact는 넘치면 예외를 던집니다. 재고 수량·파일 크기·금액처럼 넘치면 버그인 값에 씁니다.- 정수 나눗셈은 0 방향으로 버림이라
-7 / 2는-3입니다. 정수1 / 0은ArithmeticException이지만1.0 / 0은Infinity였습니다(javac는warning: [divzero] division by zero를 냅니다). - 금액에
double을 쓰지 않습니다.BigDecimal은 문자열로 만들고compareTo로 비교합니다.equals는 스케일(소수 자릿수)까지 보기 때문에1.10과1.1이 다릅니다. 원화처럼 소수가 없으면long정수로 저장하는 쪽이 더 단순합니다.
형변환도 버림입니다. 같은 실행에서 (int) 2.7은 2, (int) -2.7은 -2, Math.round(2.5)는 3인데 Math.round(-2.5)는 -2, (int) 5_000_000_000L은 705032704였습니다.
==와 equals: 문자열과 Integer 캐시
참조형에서 ==는 "같은 객체인가", equals는 "같은 값을 뜻하는가"를 묻습니다. 문자열과 래퍼 타입에서는 첫 번째 질문의 답이 내가 통제할 수 없는 사정으로 바뀝니다.
String a = "hello";
String b = "hello";
String c = new String("hello");
String d = "hel" + "lo"; // 컴파일 타임 상수
String part = "hel";
String e = part + "lo"; // 실행 중에 생성
System.out.println("a == b : " + (a == b));
System.out.println("a == c : " + (a == c));
System.out.println("a == d : " + (a == d));
System.out.println("a == e : " + (a == e));
System.out.println("a.equals(e) : " + a.equals(e));
Integer i1 = 127, i2 = 127;
Integer j1 = 128, j2 = 128;
System.out.println("127 == 127 : " + (i1 == i2));
System.out.println("128 == 128 : " + (j1 == j2));
Long big = 128L;
System.out.println("Long(128).equals(128) : " + big.equals(128));
a == b : true
a == c : false
a == d : true
a == e : false
a.equals(e) : true
127 == 127 : true
128 == 128 : false
Long(128).equals(128) : false
리터럴은 상수 풀에서 공유되고, "hel" + "lo"도 컴파일러가 리터럴 하나로 합치므로 true입니다. 하지만 사용자 입력·파일·JSON으로 만든 문자열은 새 객체입니다. 테스트는 리터럴, 실서비스는 입력값. ==가 운영에서만 틀리는 전형적인 이유입니다.
Integer i1 = 127은 Integer.valueOf(127)로 바뀌고, -128~127은 캐시된 같은 객체가 나옵니다. 게다가 상한은 바꿀 수 있습니다. 같은 클래스를 java -XX:AutoBoxCacheMax=1000으로 실행했더니 128 == 128 : true가 나왔습니다. JVM 옵션에 따라 결과가 달라지는 코드는 맞는 코드가 아닙니다.
마지막 줄은 다른 함정입니다. big.equals(128)의 128은 Integer로 박싱되고, Long.equals는 Long이 아니면 무조건 false입니다. 에러도 경고도 없습니다.
| 비교 대상 | 쓸 방법 | 이유 |
|---|---|---|
int·long·double·char·boolean |
== |
값 자체를 담음 |
String·Integer·직접 만든 클래스 |
equals / Objects.equals(a, b) |
==는 동일성. Objects.equals는 null도 안전 |
BigDecimal |
compareTo(b) == 0 |
equals는 스케일까지 비교 |
계산 결과 double |
Math.abs(a - b) < 1e-9 |
반올림 오차 |
| enum | == |
상수는 객체가 하나뿐 |
String은 불변: 반복문에서는 StringBuilder
String 객체는 한번 만들면 바뀌지 않습니다. toUpperCase, replace는 새 문자열을 돌려줍니다. 반환값을 버리는 실수가 흔합니다.
String s = "java";
s.toUpperCase();
System.out.println(s); // java
s = s.toUpperCase();
System.out.println(s); // JAVA
불변이라서 공유하거나 HashMap 키로 써도 안전하지만, 반복문에서 s += x를 하면 매번 문자열 전체를 복사하므로 일이 길이의 제곱으로 늘어납니다. 직접 쟀습니다(코드는 영어판과 같은 Concat.java, JIT 워밍업 뒤 크기별 5회 중 최솟값).
static String plus(int n) {
String s = "";
for (int i = 0; i < n; i++) s += i % 10;
return s;
}
static String builder(int n) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) sb.append(i % 10);
return sb.toString();
}
n= 10,000 +=: 2.4 ms StringBuilder: 0.14 ms
n= 50,000 +=: 64.6 ms StringBuilder: 0.15 ms
n=100,000 +=: 248.7 ms StringBuilder: 0.32 ms
n=200,000 +=: 924.2 ms StringBuilder: 0.56 ms
javac로 컴파일하고 java -cp로 실행, OpenJDK 26, M1 Pro. 세 번 돌려도 차이는 5% 안팎이었습니다(200,000 줄은 924~976ms). n이 두 배가 되면 +=는 약 네 배, StringBuilder는 거의 비례합니다.
"안녕하세요, " + name + "님" 같은 한 줄까지 StringBuilder로 바꿀 필요는 없습니다. 컴파일러가 식 하나로 최적화합니다. 기준은 "여러 번 도는 반복문 안의 연결만 StringBuilder", 이미 컬렉션이 있으면 String.join이면 충분합니다.
var, 텍스트 블록, switch 식
새로 들어온 세 기능은 --release를 바꿔 컴파일해서 정식 버전을 확인했습니다.
var list = new ArrayList<String>(); // ArrayList<String>으로 추론
var ratio = 10 / 4; // int, 값은 2
String json = """
{
"name": "t.laboratory",
"path": "C:\\temp"
}
""";
static String kind(int day) {
return switch (day) {
case 6, 7 -> "weekend";
case 1, 2, 3, 4, 5 -> {
String s = "week";
yield s + "day";
}
default -> throw new IllegalArgumentException("day " + day);
};
}
var도 정적 타입입니다.var ratio = 10 / 4는int2입니다.var a;,var b = null;,var c = {1, 2};는 모두cannot infer type for local variable에러였습니다.- 텍스트 블록은 닫는
"""의 위치가 앞쪽 공백을 얼마나 지울지 정합니다.\\는 역슬래시 하나로 찍혔습니다. - 화살표
switch는 아래로 흘러내리지(fall-through) 않습니다. 예전 콜론 형식에서day = 1로 돌리니Monday,Tuesday,other가 모두 찍혔습니다.javac -Xlint:fallthrough를 주면warning: [fallthrough] possible fall-through into case가 나옵니다. - switch 식은 모든 값을 다뤄야 해서,
int에default가 없으면error: the switch expression does not cover all possible input values입니다.
흔한 실수: 초보자가 실제로 만나는 javac 에러
javac 26 출력 그대로입니다. javac는 타입 에러를 먼저, 흐름 에러(missing return 등)를 나중에 보고합니다. 첫 번째 에러를 고쳤더니 새 에러가 나오는 건 이 때문이고, 뭔가를 망가뜨려서가 아닙니다.
int count = "42";
long big = 5_000_000_000L;
int small = big;
double avg = 2.5;
int rounded = avg;
String s = null;
if (s = null) { }
Mistakes.java:11: error: incompatible types: String cannot be converted to int
Mistakes.java:13: error: incompatible types: possible lossy conversion from long to int
Mistakes.java:15: error: incompatible types: possible lossy conversion from double to int
Mistakes.java:17: error: incompatible types: String cannot be converted to boolean
| 에러 | 실제로 일어난 일 | 고치는 법 |
|---|---|---|
String cannot be converted to int |
문자열은 숫자로 자동 변환되지 않음 | Integer.parseInt("42") (잘못된 입력은 NumberFormatException) |
possible lossy conversion from long to int |
상위 비트가 잘릴 수 있음 | long으로 두거나 Math.toIntExact. (int) 캐스트는 조용히 705032704 |
possible lossy conversion from double to int |
소수부가 사라짐 | (int)는 버림, Math.round는 반올림(음수 주의) |
String cannot be converted to boolean |
=는 대입 |
if (s == null) |
흐름 에러는 이렇게 나왔습니다.
Flow.java:5: error: missing return statement
Flow.java:8: error: unreachable statement
Flow.java:17: error: variable total might not have been initialized
첫째는 if (x > 0) return 1; else if (x < 0) return -1;로 끝나는 메서드입니다. x == 0일 때 값 없이 빠져나가므로 닫는 } 위치를 가리킵니다. 둘째는 return 0; 뒤의 문장, 셋째는 if 안에서만 값을 넣는 지역 변수입니다.
NullPointerException 상세 메시지 읽기
Java 14에서 추가되고 15부터 기본으로 켜진 상세 메시지는 어떤 식이 null이었는지 알려 줍니다. 아래는 줄에 .이 하나도 없는데 터지는, 재고 맵 언박싱 NPE입니다.
Map<String, Integer> stock = new HashMap<>();
stock.put("apple", 3);
int n = stock.get("banana");
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null
at Npe.main(Npe.java:7)
의외였던 것은 지역 변수입니다. java Npe2.java로도, 그냥 javac로 컴파일해도 because "<local1>" is null이었고, javac -g로 컴파일했을 때만 because "name" is null이 나왔습니다. Maven이나 Gradle은 보통 -g를 붙여서 이름이 나오지만, 손으로 컴파일하면 안 나옵니다.
솔직한 한계와 버전 메모
- 모두 OpenJDK 26(Temurin 26+35), Apple Silicon Mac (M1 Pro)에서 실행했습니다. 시간은 한 대에서 잰 best-of-N이고 JMH가 아닙니다. 비율로 읽어 주세요.
javac --release N으로 확인:var는 9에서 실패·10에서 성공, switch 식은 13 실패·14 성공, 텍스트 블록은 14 실패·15 성공, 컴팩트 소스 파일은 24 실패·25 성공.--release 8은 JDK 26에서도 되지만 "obsolete" 경고가 나왔습니다.- Java 11의 소스 실행, Java 14의 NPE 메시지를 당시 JDK에서 돌려 보지는 않았고 26에서만 확인했습니다.
-XX:AutoBoxCacheMax는 HotSpot 옵션입니다.
다음 강좌: Java 강좌 2/6: 클래스·record·인터페이스. 같은 수준의 다른 언어: Python 강좌 1, VB.NET 강좌 1. C#을 아신다면 C# 변수와 데이터 타입과 비교해 보세요.
자주 묻는 질문
Integer 127 == 127은 true인데 128 == 128은 왜 false인가요?
오토박싱은 Integer.valueOf를 거치고, -128~127은 캐시된 같은 객체를 돌려줍니다. 128은 매번 다른 객체라서 참조를 비교하는 ==가 false입니다. 상한은 -XX:AutoBoxCacheMax로 바꿀 수 있고, 실제로 128 == 128이 true가 됐습니다. 래퍼 타입은 equals로 비교하세요.
돈 계산에는 double과 BigDecimal 중 무엇을 써야 하나요?
double로 0.1을 10번 더하면 0.9999999999999999가 나왔습니다. BigDecimal은 반드시 문자열로 만들고(new BigDecimal("0.1")) compareTo로 비교합니다. 1.10과 1.1은 equals로는 false입니다. 원화처럼 소수가 없는 통화는 long 정수로 저장하는 것도 확실한 방법입니다.
NullPointerException 메시지에 변수 이름 대신 <local1>이 나오는 이유는?
지역 변수 이름은 디버그 정보(javac -g)를 넣어 컴파일했을 때만 나옵니다. -g 없이는 because "<local1>" is null, -g를 주면 because "name" is null로 나왔습니다. 필드나 메서드 반환값은 -g 없이도 이름이 나옵니다.