자바 문자열 - String 불변성과 == equals 차이 StringBuilder 를 쓰는 기준 (자바 초급 7단원)
이 단원에서 배우는 것
6단원에서 만든 Book 에는 title 과 isbn 이 있다. 둘 다 String 이다. 그런데 지금까지 String 을 그냥 "글자를 담는 것"으로만 쓰고 넘어갔다. 이번 단원에서 정면으로 다룬다.
String 은 자바에서 특이한 위치에 있다. 기본형이 아닌데 + 로 이어 붙일 수 있고, new 없이 만들 수 있고, 한 번 만들면 바뀌지 않는다. 이 특이함이 실무 버그의 상당 부분을 만든다. 이번 단원은 그 특이함이 어디서 오는지부터 본다.
String이 불변이라는 사실에서==비교 함정과 성능 문제가 어떻게 파생되는지 설명한다.- 대출 영수증을 만들면서
StringBuilder를 언제 써야 하는지 기준을 세운다. - ISBN 문자열을 다루면서
split·substring·strip등 자주 쓰는 메서드의 함정을 안다.
왜 필요한가
불변이 아니라면 이런 코드가 안전할 수 없다
6단원 Book 의 게터를 다시 본다.
public String getTitle() {
return title; // 배열이었다면 방어적 복사가 필요했다
}
5단원과 6단원에서 배열을 그냥 반환하면 밖에서 내용을 고칠 수 있어 Arrays.copyOf 로 복사해야 했다. 그런데 String 은 그대로 반환해도 안전하다. 받은 쪽에서 고칠 방법이 아예 없기 때문이다. String 에는 내용을 바꾸는 메서드가 하나도 없다. toUpperCase(), replace(), trim() 전부 새 문자열을 만들어 돌려준다.
String title = "clean code";
title.toUpperCase();
System.out.println(title); // clean code 그대로다
System.out.println(title.toUpperCase()); // CLEAN CODE
불변이라서 얻는 것은 안전만이 아니다. 값이 안 바뀌니 같은 내용의 문자열을 여기저기서 공유해도 된다. 자바는 이걸 이용해 소스에 그대로 적은 문자열 리터럴을 한곳(문자열 풀)에 모아 두고 재사용한다. 그 최적화가 == 비교라는 함정을 만든다.
그리고 불변이라서 느려지는 지점이 있다
영수증을 만든다고 하자. 도서 목록을 돌면서 문자열을 이어 붙인다.
String receipt = "";
for (int i = 0; i < books.length; i++) {
receipt += books[i].getTitle() + "\n"; // 매 회차 새 문자열이 만들어진다
}
String 은 바뀌지 않으므로 += 는 기존 문자열을 늘리는 게 아니라 합쳐진 새 문자열을 만들고 그쪽을 가리키게 한다. 100개짜리면 새 문자열이 100개 만들어지고 99개가 즉시 쓰레기가 된다. 길이가 길수록 복사량도 늘어난다. 이 단원 뒤쪽에서 실제 측정치를 본다.
문법과 예제
만드는 방법 두 가지
String a = "클린 코드"; // 리터럴. 문자열 풀에 들어간다
String b = new String("클린 코드"); // 힙에 새 객체를 만든다
결과가 다르다.
public class StringPool {
public static void main(String[] args) {
String a = "978-89-6626-228-1";
String b = "978-89-6626-228-1";
String c = new String("978-89-6626-228-1");
System.out.println(a == b); // true 같은 풀 객체
System.out.println(a == c); // false c 는 별개의 객체
System.out.println(a.equals(c)); // true 내용은 같다
System.out.println(a == c.intern()); // true 풀에 있는 것을 찾아 준다
}
}
같은 내용의 리터럴을 두 번 적으면 컴파일러가 하나로 합친다. 그래서 a == b 가 true 다. new String(...) 은 "굳이 새 객체를 만들라"는 지시라서 풀을 쓰지 않는다. 실무에서 new String() 을 쓸 일은 사실상 없다.
== 와 equals
규칙은 단순하다. == 는 같은 객체인지, equals 는 내용이 같은지를 묻는다. 4단원의 배열 비교와 정확히 같은 구도다. 문자열 비교는 언제나 equals 를 쓴다.
문제는 == 가 어떤 때는 맞는 답을 낸다는 점이다. 그래서 테스트에서는 통과하고 운영에서 실패한다.
public class EqualsTrap {
public static void main(String[] args) {
String z = "978-89";
String x = "978";
String y1 = x + "-89"; // 실행 시점에 합친다
System.out.println(y1 == z); // false
final String xf = "978";
String y2 = xf + "-89"; // 컴파일 시점에 합쳐진다
System.out.println(y2 == z); // true
}
}
같아 보이는 두 코드의 결과가 다르다. final 이 붙은 상수끼리의 연결은 컴파일러가 미리 계산해 리터럴로 만들어 버리기 때문이다. 이 차이를 외울 필요는 없다. 외울 것은 문자열에 == 를 쓰지 않는다는 것 하나다.
ISBN 다루기 — 자주 쓰는 메서드
public class IsbnParser {
public static void main(String[] args) {
String raw = " 978-89-6626-228-1 ";
String isbn = raw.strip();
System.out.println("[" + isbn + "]"); // [978-89-6626-228-1]
System.out.println(isbn.length()); // 17
System.out.println(isbn.charAt(0)); // 9
System.out.println(isbn.substring(0, 3)); // 978
System.out.println(isbn.indexOf("6626")); // 7
System.out.println(isbn.contains("6626")); // true
System.out.println(isbn.startsWith("978")); // true
System.out.println(isbn.replace("-", "")); // 9788966262281
String[] parts = isbn.split("-");
System.out.println(parts.length); // 5
System.out.println(String.join("/", parts)); // 978/89/6626/228/1
System.out.println(" ".isEmpty()); // false
System.out.println(" ".isBlank()); // true
System.out.println("=".repeat(20)); // ====================
}
}
| 메서드 | 하는 일 | 추가된 버전 |
|---|---|---|
trim() | 앞뒤에서 공백(U+0020 이하)을 뗀다 | 초창기부터 |
strip() | 앞뒤에서 유니코드 공백까지 뗀다 | Java 11 |
isBlank() | 비었거나 공백뿐이면 true | Java 11 |
repeat(n) | n 번 반복한 문자열 | Java 11 |
String.join(구분자, ...) | 여러 문자열을 이어 붙인다 | Java 8 |
Java 11 이상이라면 trim() 대신 strip() 을 쓴다. 웹 폼이나 엑셀에서 들어온 데이터에는 trim() 이 못 떼는 공백 문자(줄바꿈 없는 공백 등)가 섞여 있는 경우가 흔하다. 5단원의 joinTitles 는 String.join(" | ", titles) 한 줄로 대체된다.
텍스트 블록
Java 15 부터 정식이다. 여러 줄 문자열에서 \n 과 따옴표 이스케이프를 없애 준다.
String notice = """
=== 대출 안내 ===
연체료는 7일까지 하루 100원,
8일부터 하루 200원입니다.
""";
System.out.print(notice);
여는 """ 뒤에는 반드시 줄바꿈이 와야 한다. 들여쓰기는 가장 왼쪽에 있는 줄을 기준으로 공통 부분이 잘려 나가므로, 코드 들여쓰기가 결과에 섞이지 않는다.
StringBuilder 로 영수증 만들기
StringBuilder 는 내용을 바꿀 수 있는 문자열 버퍼다. append 로 계속 붙이고 마지막에 toString() 으로 String 을 뽑는다. append 가 자기 자신을 돌려주므로 점을 이어 쓸 수 있다.
public class ReceiptPrinter {
public static void main(String[] args) {
Book[] books = {
new Book("자바의 정석", "9788994492032"),
new Book("이펙티브 자바", "9788966262281", 12),
new Book("클린 코드", "9788966260959", 3)
};
StringBuilder sb = new StringBuilder();
sb.append("=== 대출 영수증 ===\n");
int sum = 0;
for (Book book : books) {
sb.append(book.getTitle())
.append(" : ")
.append(book.lateFee())
.append("원\n");
sum += book.lateFee();
}
sb.append("합계 : ").append(sum).append("원");
System.out.println(sb.toString());
System.out.println("길이 " + sb.length());
}
}
=== 대출 영수증 ===
자바의 정석 : 0원
이펙티브 자바 : 1700원
클린 코드 : 300원
합계 : 2000원
길이 66
그래서 언제 StringBuilder 를 쓰는가
기준은 하나다. 반복문 안에서 문자열을 누적하면 StringBuilder, 그 외에는 +.
이유는 컴파일러가 하는 일에 있다. 한 줄 안의 "a" + b + "c" 는 컴파일러가 알아서 효율적인 연결로 바꿔 준다(Java 9 부터는 StringConcatFactory 를 통해 실행 시점에 최적 코드를 만든다). 그래서 한 줄짜리 연결을 굳이 StringBuilder 로 바꾸면 오히려 읽기만 나빠진다.
하지만 반복문은 다르다. 컴파일러는 회차 사이를 이어서 최적화하지 못한다. 매 회차마다 새로 붙이고 새 문자열을 만든다. 차이가 얼마나 되는지 직접 재 본다.
public class ConcatBenchmark {
public static void main(String[] args) {
int n = 100_000;
long t1 = System.currentTimeMillis();
String s = "";
for (int i = 0; i < n; i++) {
s += i;
}
long t2 = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(i);
}
String s2 = sb.toString();
long t3 = System.currentTimeMillis();
System.out.println("+= : " + (t2 - t1) + "ms");
System.out.println("StringBuilder: " + (t3 - t2) + "ms");
System.out.println(s.length() == s2.length());
}
}
+= : 1175ms
StringBuilder: 3ms
true
Java 17 에서 측정한 값이다. 400배 차이다. 그리고 이 차이는 데이터가 늘수록 선형이 아니라 더 가파르게 벌어진다. 결과 길이가 같다는 것도 확인해 두었다. 느린 쪽이 다른 일을 하는 게 아니라, 같은 결과를 400배 비싸게 만들고 있다.
StringBuffer라는 것도 있다.StringBuilder와 기능이 같고 여러 스레드가 동시에 써도 안전하도록 되어 있는 대신 느리다. 지역 변수로 쓰는 문자열 버퍼는 어차피 한 스레드만 건드리므로StringBuilder를 쓴다.
실무에서 자주 틀리는 것
1. 문자열을 == 로 비교한다
가장 유명하지만 여전히 가장 많이 나온다. 특히 사용자 입력이나 DB 에서 온 문자열은 실행 시점에 만들어져 풀에 없으므로 == 가 반드시 false 다. 하드코딩한 값끼리 비교하는 테스트에서는 true 가 나와서 통과한다.
String input = new String("STUDENT"); // DB 나 입력에서 온 문자열이라고 치자
System.out.println(input == "STUDENT"); // false
System.out.println(input.equals("STUDENT")); // true
2. null 인 변수에 equals 를 부른다
equals 는 메서드다. 부르는 쪽이 null 이면 NullPointerException 이다.
String memberType = null;
// memberType.equals("STUDENT") NullPointerException
System.out.println("STUDENT".equals(memberType)); // false 안전하다
확실히 null 이 아닌 쪽을 앞에 둔다. 보통 리터럴이나 상수다. 이 관용구 하나로 null 검사 한 줄이 사라진다. 3단원에서 && 로 null 을 먼저 거르는 방법도 배웠는데, 상수가 있으면 그쪽이 더 짧다.
String title = null;
System.out.println(title != null && title.length() > 0); // false, 예외 없음
3. split 의 인자가 정규식이라는 걸 잊는다
split, replaceAll, matches 는 일반 문자열이 아니라 정규식을 받는다. 그래서 . 이나 | 로 자르면 결과가 이상해진다.
String fileName = "9788966260959.txt";
System.out.println(fileName.split(".").length); // 0 전부 사라진다
System.out.println(fileName.split("\\.").length); // 2 [9788966260959, txt]
System.out.println("a|b|c".split("|").length); // 5 [a, |, b, |, c]
System.out.println("a|b|c".split("\\|").length); // 3 [a, b, c]
., |, *, +, ?, (, ), [, ], ^, $, \ 는 정규식에서 특별한 의미를 가지므로 \\ 를 앞에 붙여 이스케이프한다. 단순 치환이라면 정규식을 안 쓰는 replace 를 쓰는 편이 안전하다. replace 와 replaceAll 은 이름만 비슷하고 인자 해석이 다르다.
split 에는 함정이 하나 더 있다. 맨 뒤의 빈 문자열은 기본적으로 버린다. CSV 를 파싱할 때 컬럼 개수가 틀어지는 원인이다.
String csv = "자바의 정석,,클린 코드,";
System.out.println(csv.split(",").length); // 3 뒤의 빈 칸이 사라졌다
System.out.println(csv.split(",", -1).length); // 4 두 번째 인자를 -1 로
4. 메서드를 부르고 대입을 안 한다
5단원에서 예고한 실수다. String 은 불변이므로 부르기만 해서는 아무 일도 안 일어난다.
String isbn = " 978-89 ";
isbn.strip(); // 결과를 버렸다
System.out.println("[" + isbn + "]"); // [ 978-89 ]
isbn = isbn.strip(); // 대입해야 한다
System.out.println("[" + isbn + "]"); // [978-89]
같은 이유로 title.replace("-", ""), title.toUpperCase() 도 전부 결과를 받아야 한다. "분명히 공백을 없앴는데 그대로다"의 원인은 대부분 이것이다.
5. substring 의 두 번째 인자를 길이로 착각한다
substring(begin, end) 에서 end 는 길이가 아니라 끝 인덱스이고, 그 자리는 포함되지 않는다. 그리고 문자열 길이보다 크면 예외다.
String isbn = "978-89-6626-228-1";
System.out.println(isbn.substring(0, 3)); // 978 0,1,2 번째
System.out.println(isbn.substring(4)); // 89-6626-228-1
// isbn.substring(0, 100);
// StringIndexOutOfBoundsException: begin 0, end 100, length 17
길이가 들쭉날쭉한 입력을 자를 때는 Math.min(n, s.length()) 로 상한을 걸어 둔다.
6. 한글에서 자릿수 정렬과 길이가 어긋난다
String.format("%-16s", ...) 의 16은 화면 폭이 아니라 글자 수다. 한글은 폭이 두 배라서 영문과 섞이면 표가 어긋난다.
String[] titles = {"자바의 정석", "클린 코드", "Effective Java"};
for (String t : titles) {
System.out.println(String.format("%-16s|", t));
}
// 자바의 정석 |
// 클린 코드 |
// Effective Java |
콘솔 표를 맞추려면 한글을 2로 세는 폭 계산을 직접 하거나, 애초에 표 정렬을 포기하고 구분자를 쓴다. 실무에서는 후자를 택하는 경우가 많다.
length() 자체에도 함정이 있다. length() 가 세는 것은 글자 수가 아니라 char 개수다. 2단원에서 char 가 2바이트라고 했는데, 이모지처럼 그 범위를 넘는 문자는 char 두 개를 차지한다.
String s = "책📚";
System.out.println(s.length()); // 3 (책 1 + 이모지 2)
System.out.println(s.codePointCount(0, s.length())); // 2 실제 글자 수
System.out.println(s.charAt(1)); // 깨진 문자 하나
한글은 char 하나에 들어가므로 대부분 문제가 없지만, 사용자가 이모지를 넣는 입력값(닉네임, 리뷰)을 substring 으로 자르면 글자가 반쪽으로 잘려 깨진다.
스스로 확인하기
- 다음 코드의 출력을 예측하고, 세 번째 줄이
true가 되도록 고쳐라.String a = "978"; String b = "89"; String c = a + "-" + b; String d = "978-89"; System.out.println(c.equals(d)); System.out.println(c.length()); System.out.println(c == d); - 회원이 반납한 도서 제목 배열을 받아
"자바의 정석 / 클린 코드 / 이펙티브 자바"형태의 문자열을 만드는 메서드를 두 가지 방법으로 쓰라. 하나는StringBuilder, 하나는String.join이다. - 다음 코드는
클린 코드와3을 찍을 것 같지만 전혀 다른 값이 나온다. 무엇이 나오고 왜 그런지 설명한 뒤 고쳐라.String line = "9788966260959|클린 코드|3"; String[] parts = line.split("|"); System.out.println(parts.length); System.out.println(parts[1]); System.out.println(parts[2]);
정답
- 차례로
true,6,false다. 내용은 같지만c는 실행 시점에 만들어진 별개의 객체라==가false다. 세 번째 줄을true로 만드는 올바른 방법은==를 쓰지 않는 것이다.System.out.println(c.equals(d));로 바꾼다. (a와b를final로 선언해도true가 되지만, 이런 성질에 기대는 코드를 쓰면 안 된다.)
구분자를 원소 앞에 붙이되 첫 원소만 건너뛰는 것이 요령이다. 뒤에 붙이면 마지막에static String joinWithBuilder(String[] titles) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < titles.length; i++) { if (i > 0) sb.append(" / "); sb.append(titles[i]); } return sb.toString(); } static String joinWithJoin(String[] titles) { return String.join(" / ", titles); }" / "가 남는다.String.join은 이 처리를 이미 해 두었으므로 실무에서는 두 번째를 쓴다.split의 인자는 정규식이고|는 정규식에서 "또는"을 뜻하는 특수문자다."|"는 "빈 문자열 또는 빈 문자열"로 해석되어 글자 하나하나가 잘린다. 그래서parts.length는 21,parts[1]은7,parts[2]는8이 나온다. 예외가 안 나기 때문에 더 위험하다. 이스케이프하면 된다.String[] parts = line.split("\\|"); System.out.println(parts.length); // 3 System.out.println(parts[1]); // 클린 코드 System.out.println(parts[2]); // 3
마지막 단원에서는 지금까지 한 폴더에 늘어놓은 Book, LoanDesk, ReceiptPrinter 를 패키지로 나눈다. private 이 클래스 안을 막았듯, 패키지 사이를 막는 접근제어를 배운다.