자바의 연산자와 제어문은 파이썬과 겉모습이 비슷해도 세부 동작이 다른 지점이 여럿 있다. 문자열을 다루는 방식은 자바 초보가 가장 먼저 걸려 넘어지는 자리다. 문자열이 왜 못 바뀌는지, 반복문 안에서 이어붙이면 왜 느려지는지, switch 문이 요즘은 왜 다르게 쓰이는지를 짚어본다.
산술 연산자와 증감 연산자. 자바의 기본 산술 연산자는 파이썬과 크게 다르지 않다. 더하기 +, 빼기 -, 곱하기 *, 나누기 /, 나머지 %를 그대로 쓴다. 다만 자바는 += 같은 복합 대입 연산자와 증감 연산자 ++, --를 훨씬 자주 쓴다. 파이썬에는 이 연산자가 없어 i += 1로 풀어 써야 한다. 증감 연산자는 변수 앞뒤 어디에 붙이느냐에 따라 결과가 달라진다.
int i = 5;
System.out.println(i++); // 5
System.out.println(i); // 6
System.out.println(++i); // 7
i++는 후위 증가라 현재 값 5를 먼저 돌려준 뒤 i를 늘려 5가 찍힌다. ++i는 전위 증가라 먼저 늘리고 결과를 돌려주는데, 이미 6이 된 i를 다시 늘려 7이 찍힌다. 값을 바로 꺼내 쓰는 자리에서는 이 순서 차이가 출력에 그대로 드러난다.
비교와 논리 연산자, 그리고 단락 평가. 비교 연산자 ==, !=, >, <, >=, <=는 파이썬과 동일하게 쓴다. 논리 연산자 &&, ||, !에는 단락 평가(short-circuit evaluation)라는 성질이 있다. &&는 왼쪽이 거짓이면 오른쪽은 안 보고 거짓으로, ||는 왼쪽이 참이면 오른쪽 없이 참으로 끝난다. 이 성질로 null 체크를 안전하게 할 수 있다.
String name = null;
if (name != null && name.length() > 0) {
System.out.println("이름 있음");
} else {
System.out.println("이름 없음");
}
// 출력: 이름 없음
name이 null이라 첫 조건이 거짓인 순간 && 뒤의 name.length()는 실행되지 않는다. 실행됐다면 null에 length()를 부르는 순간 NullPointerException이 터졌을 자리다. 조건을 뒤집어 name.length() > 0 && name != null로 쓰면 그대로 예외가 난다. null 체크는 항상 && 앞쪽에 둬야 한다.
조건문과 삼항 연산자. if-else 구조는 파이썬과 같은 개념이지만 조건을 소괄호로 감싸고 블록을 중괄호로 묶는다. 콜론과 들여쓰기로 블록을 구분하는 파이썬과 다른 지점이다. 값을 하나만 골라 담는 짧은 상황이라면 삼항 연산자로 줄일 수 있다.
int a = 10;
int b = 20;
int max = (a > b) ? a : b;
System.out.println(max); // 20
조건 ? 참일때값 : 거짓일때값 형태로, 값 하나를 그 자리에서 바로 돌려줘 대입문 오른쪽에 곧장 쓸 수 있다. 삼항 연산자 안에 또 삼항 연산자를 겹쳐 넣으면 읽기 어려워지니 단순한 분기에만 쓰는 게 낫다.
while과 do-while. while문은 조건이 참인 동안 본문을 반복하지만 초기값과 증가식을 강제로 요구하지 않아 반복 횟수가 미리 정해지지 않은 상황에 잘 맞는다. do-while은 조건 검사보다 본문 실행이 먼저라 조건이 처음부터 거짓이어도 본문을 딱 한 번은 실행한다.
int m = 0;
do {
System.out.println("실행됨: " + m);
m++;
} while (m < 0);
// 출력: 실행됨: 0
조건 m < 0은 m이 0인 시점에 이미 거짓이다. while문이었다면 한 번도 안 돌았을 조건인데, do-while은 조건 검사보다 실행이 먼저라 딱 한 번은 돌아 실행됨: 0이 찍힌다.
switch, 옛날 방식의 함정. 전통적인 switch-case는 case마다 break를 적어야 하고, 빠뜨리면 다음 case까지 그대로 실행된다. 이를 fall-through라 부른다.
int day = 1;
switch (day) {
case 1:
System.out.println("월요일");
case 2:
System.out.println("화요일");
break;
default:
System.out.println("알 수 없음");
}
// 출력:
// 월요일
// 화요일
day는 1인데 화요일까지 찍힌다. case 1 끝에 break가 없어 안 맞는 case 2까지 실행됐기 때문이다. break 하나 빠뜨리는 실수가 switch에서 가장 흔한 함정이다.
switch 표현식, 화살표 문법. 자바 14부터 화살표(->)를 쓰는 switch 표현식이 들어왔다. case가 콜론과 break 대신 화살표와 값 하나로 끝나 fall-through가 일어날 수 없다.
int day3 = 3;
String name2 = switch (day3) {
case 1 -> "월요일";
case 2 -> "화요일";
case 3 -> "수요일";
default -> "알 수 없음";
};
System.out.println(name2); // 수요일
switch 자체가 값을 돌려주는 식이라 결과를 바로 변수에 담는다. case 뒤에 값 하나만 적으면 그 case가 곧바로 반환값이 되고 break가 필요 없다.
문자열은 불변이다. 자바의 String은 한 번 만들어지면 내부 값이 안 바뀌는 불변 객체(immutable)다. concat이나 +로 이어붙이는 코드는 원본을 고치는 게 아니라 매번 새 객체를 만든다.
String s1 = "hello";
String s2 = s1.concat(" world");
System.out.println(s1); // hello
System.out.println(s2); // hello world
s1.concat(" world")를 호출해도 s1이 고쳐지는 게 아니라 hello world라는 새 문자열이 만들어져 s2에 담긴다. s1은 여전히 hello다. 문자열 리터럴끼리는 자바가 문자열 풀(string pool)에 같은 내용은 하나만 두고 재사용한다.
String a = "java";
String b = "java";
System.out.println(a == b); // true
String c = new String("java");
System.out.println(a == c); // false
System.out.println(a.equals(c)); // true
리터럴로 만든 a와 b는 풀 안의 같은 객체를 가리켜 ==가 true다. new String("java")으로 만든 c는 풀 바깥에 별도 객체를 만들어 내용이 같아도 ==는 false다. 문자열 값 비교는 항상 equals를 쓰는 게 안전하다.
반복해서 이어붙일 때는 StringBuilder. 문자열이 불변이라는 사실은 반복문 안에서 문제가 된다. +로 계속 이어붙이면 반복마다 새 객체가 만들어지고 이전 객체는 버려져 느려진다. 이럴 때는 StringBuilder를 쓴다.
StringBuilder sb = new StringBuilder();
for (int k = 0; k < 5; k++) {
sb.append(k).append(",");
}
String result = sb.toString();
System.out.println(result); // 0,1,2,3,4,
append는 내부 버퍼에 문자를 채워 넣기만 할 뿐 반복마다 새 객체를 만들지 않는다. 다 채운 뒤 toString을 호출해야 String으로 바뀐다. 계속 조립한다면 +보다 StringBuilder가 훨씬 낫다.
이번 편에서 손에 쥐어야 할 감각은 세 가지다. 논리 연산자는 왼쪽만으로 결과가 정해지면 오른쪽은 평가하지 않는다는 것, switch는 화살표 문법을 쓰면 break를 깜빡해 다음 case까지 실행되는 실수가 사라진다는 것, 그리고 문자열을 바꾸는 것처럼 보이는 코드도 실제로는 새 객체를 만드는 것이라는 것. 문자열을 반복해서 조립할 일이 생기면 StringBuilder부터 떠올리면 된다.