Devin.KR
로그인

자바 문자열 - String 불변성과 == equals 차이 StringBuilder 를 쓰는 기준 (자바 초급 7단원)

개발자 조회 1

이 단원에서 배우는 것

6단원에서 만든 Book 에는 titleisbn 이 있다. 둘 다 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 == btrue 다. 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()비었거나 공백뿐이면 trueJava 11
repeat(n)n 번 반복한 문자열Java 11
String.join(구분자, ...)여러 문자열을 이어 붙인다Java 8

Java 11 이상이라면 trim() 대신 strip() 을 쓴다. 웹 폼이나 엑셀에서 들어온 데이터에는 trim() 이 못 떼는 공백 문자(줄바꿈 없는 공백 등)가 섞여 있는 경우가 흔하다. 5단원의 joinTitlesString.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 를 쓰는 편이 안전하다. replacereplaceAll 은 이름만 비슷하고 인자 해석이 다르다.

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 으로 자르면 글자가 반쪽으로 잘려 깨진다.

스스로 확인하기

  1. 다음 코드의 출력을 예측하고, 세 번째 줄이 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);
  2. 회원이 반납한 도서 제목 배열을 받아 "자바의 정석 / 클린 코드 / 이펙티브 자바" 형태의 문자열을 만드는 메서드를 두 가지 방법으로 쓰라. 하나는 StringBuilder, 하나는 String.join 이다.
  3. 다음 코드는 클린 코드3 을 찍을 것 같지만 전혀 다른 값이 나온다. 무엇이 나오고 왜 그런지 설명한 뒤 고쳐라.
    String line = "9788966260959|클린 코드|3";
    String[] parts = line.split("|");
    System.out.println(parts.length);
    System.out.println(parts[1]);
    System.out.println(parts[2]);

정답

  1. 차례로 true, 6, false 다. 내용은 같지만 c 는 실행 시점에 만들어진 별개의 객체라 ==false 다. 세 번째 줄을 true 로 만드는 올바른 방법은 == 를 쓰지 않는 것이다. System.out.println(c.equals(d)); 로 바꾼다. (abfinal 로 선언해도 true 가 되지만, 이런 성질에 기대는 코드를 쓰면 안 된다.)
  2. 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 은 이 처리를 이미 해 두었으므로 실무에서는 두 번째를 쓴다.
  3. 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 이 클래스 안을 막았듯, 패키지 사이를 막는 접근제어를 배운다.