자바에서 String 다룰 때 오해와 진실
Tags: java, korean
Published: 2021-04-12 05:39:11

> 2018년 5월 18일 from [blog.hazard.kr](https://web.archive.org/web/20180704210203/http://blog.hazard.kr/archives/937)

어자피 다들 먹고사는데 자바 안다뤄본 사람 있나? 어? 자바 안해봤어? 넌 그럼 축복받은 거야. 축하한다.
어쨌든, 자바에서 `String` 을 다룰 때 여러 조언들이 있다. 초보부터 중고급까지 짚어야 할 것들을 추려서 간단하게 소개하겠다.
나중에 문자열을 잘 다뤄서 자바 앱 성능 잘 나온다고 고마워하고 싶으면 언제든 해라.

이 글은 자바 1.8을 기준으로 작성하였다. 9 이상에서는 아래 내용에서 꽤 달라질 수 있으니 주의를 요한다.
라고 하기엔 당장에 `StringConcatFactory` 클래스가 뭔지 소개하는 한글 문서조차도 없어 시발놈들…

##자바에서 `+` 연산을 통한 문자열 합치기를 지양하라!

반은 맞고 반은 틀리다.
내가 전에도 관련해서 블로그 포스트에 올렸었다. 자바는 `+` 연산을 사용하면 `String.concat` 메소드를 쓰는 게 아니라,
컴파일 전 내부적으로 `StringBuilder` 클래스를 만든 후 다시 문자열로 돌려준다.
전 포스트에선 `StringBuffer`로 기재했는데, `StringBuilder`가 [맞다고 한다](https://docs.oracle.com/javase/6/docs/api/java/lang/String.html).
참고로 닷넷은 `+` 연산 시 연산자 오버로딩 본문에 내부적으로 `String.Concat` 메소드를 사용하여 합치도록 구현되어 있다.

```java
String a = "hello" + "world";
// 는 아래와 같다.
String b = new StringBuilder("hello").append("world").toString();
```

이런 특성 때문에 문자열을 합치는 일이 많을 경우 단순히 `+` 연산을 쓰면 성능이 떨어질 수밖에 없고, 메모리 비효율은 덤이다.

```java
String a = "";

for(int i = 0; i < 10000; i++) {
    a = a + i;
}

// 이런 짓거리 하면 이렇게 구현하는 것과 같다.

String b = "";

for(int i = 0; i < 10000; i++) {
    b = new StringBuilder(b).append(i).toString();
}
```

이런 경우에는 주저없이 처음부터 `StringBuilder` 클래스를 사용하여 문자열을 합치는 게 더 좋다.

```java
final StringBuilder a = new StringBuilder();

for(int i = 0; i < 10000; i++) {
    a.append(i);
}

final String b = a.toString();
```

위와 같이 코딩하면 특히 비동기에 대응하기 수월하다는 장점도 가질 수 있다.

그렇다면, 다음과 같은 궁금증이 생길 것이다.
`StringBuffer` 또는 `StringBuilder` 와 `String.concat` 중 어느게 더 좋을까다.
당연히 승자는 `StringBuffer`와 `StringBuilder` 이다. 왜냐? `String.concat` 내용을 보면 바로 답이 나온다.

```java
    public String concat(String var1) {
        int var2 = var1.length();
        if (var2 == 0) {
            return this;
        } else {
            int var3 = this.value.length;
            char[] var4 = Arrays.copyOf(this.value, var3 + var2);
            var1.getChars(var4, var3);
            return new String(var4, true);
        }
    }
```

여기서 핵심이 새 배열에 복사하는 행위인데, 이거나 `+` 나 겉보기엔 원리에 별 차이가 없다.
단지, `+` 연산은 내부적으로 위에서 언급했던 과정을 거치기 때문에 조금 더 느리고,
이 메소드는 호출할 때마다 원체(문자열)의 매번 배열을 재구성 하는 과정을 거치기 때문에 당연히 느릴 수밖에 없다.
`StringBuilder`나 `StringBuffer`는 처음부터 배열 크기를 일정하게 잡고 시작하기 때문에 합치는 과정이 `concat` 보다 월등히 빠르다.

참고: [String concatenation with Java 8](http://www.pellegrino.link/2015/08/22/string-concatenation-with-java-8.html)

다시한번 강조하지만, 단순히 문자열을 합친다면 그냥 `+` 연산이 효율적이고, 반복문 등 합치는 일이 잦을 경우 `StringBuilder` 나 `StringBuffer`를 써라. 꼭이다.

## 자바에서는 문자열을 효율적으로 합치는 클래스가 `StringBuffer` 뿐이다?

어디 학원에서 배우셨어요?

[Difference Between String , StringBuilder And StringBuffer Classes With Example : Java](http://javahungry.blogspot.com/2013/06/difference-between-string-stringbuilder.html)
오라클 직원이었던 글을 참고하여 설명 들어가겠다.
사실 둘의 차이는 하나 말고는 없다.
바로 쓰레드에서 안전하냐 아니냐 이 차이 뿐이다.

`StringBuffer` 클래스는 쓰레드에서 안전하다.
`StringBuilder` 클래스는 쓰레드에서 안전하지 않다.
기본 성능은 당연히 쓰레드 안전성을 버린 StringBuilder 클래스가 우월하다. `+` 연산 시 `StringBuilder` 쓰는 이유가 있다.

만약 평소처럼 쓸 때, 특히 비동기에 사용할 일이 없으면, + 연산이나 `StringBuilder`를 써라.
하지만 통신이나, 다른 쓰레드와 공유하거나, 비동기 작업이 필요하다면 `StringBuffer`가 효율적이라고 보면 된다.

뭐… 이것도 적재적소에 쓰는 게 맞다고 보면 된다. 예제는 어자피 위에서 클래스 바꾸면 다를 게 하나도 없으므로 안 쓰겠다.

위 둘의 성격을 잘 설명한 글이 있으니 참고하라.
[Java에서 String, StringBuilder, StringBuffer의 차이](https://novemberde.github.io/2017/04/15/String_0.html)

혹시 `StringBuilder`가 닷넷스럽다고 쓰지 말라는 미친새끼 있으면 나한테 불러와라. 뚝배기 깨버려줄테니.

## 자바에서 문자열을 비교할 때 반드시 `.equals()` 메소드를 써야 한다!

어느정도 맞다.

내가 왜 애매하게 반드시 그렇게 해야 한다도 아니고 어느정도 맞다고 했는가?
아마 자배 배울 때 아래와 같이 배웠을 것이다.

자바의 `==` 연산자는 레퍼런스 비교이다.
문자열은 클래스이기 때문에 레퍼런스가 서로 달라 문자열을 아무리 비교해도 반드시 `false`가 나온다.
따라서 문자열 비교할 때는 반드시 `.equals()` 메소드를 써서 비교해야 한다.
일단은 맞다. 내가 이거 부정한 적은 없다. 하지만 2번째 가르침은 잘못 가르친 거다. 왜냐?
당연하겠지만 정말 개념찬 자바 강의가 아니면, 대학교조차 `String.intern()` 메소드를 그냥 지나가는 경우가 많다.
하지만 자바나 닷넷은 문자열을 관리할 때, 반드시 거쳐가야 한다. 안그러면 자바 최적화에서 놓치기 쉽기 때문이다.

문자열 Pool이라 들어봤을 것이다. 보통 우리는 문자열을 정의할 때, `"a"` 처럼 쌍따옴표를 쓰지 `new String` 쓰지 않는다.
그렇다고 `new String("a")` 이렇게 선언한다고? 세상에 미친 짓도 이런 미친 짓은 없을 것이다. 메모리 2번 먹는다 생각하면 된다.
어쨌든, 쌍따옴표로 문자열을 정의하면, 아래와 같이 해석된다.

```java
String a = "hello";
// 위 구문은 아래 구문으로 해석한다.
String b = new String(new char[]{'h', 'e', 'l', 'l', 'o'}).intern();
```

`intern` 메소드의 쓰임새는 그다지 어렵지 않은데, 문자열을 메모리에 담는다. 그리고 서로 다른 문자열끼리 메모리에 담게 된다.
따라서 아래와 같이 정의하면 새로이 문자열이 추가되는 일은 없다.

```java
String a = "hello"; // 새로운 hello 문자열을 저장소에 담는다.
String b = "hello"; // 이미 담았으므로 다시 불러와 정의된다.
```

즉, 자바로 치자면 중복없는 컬렉션을 담당하는 `Set` 기반 클래스에 `String.equals()` 메소드로 비교하여 담는다고 생각하면 쉬울 것이다.
그렇기 때문에, 자바의 문자열 비교는 의외로 놀라운 결과를 보여주기도 한다.

```java
String a = "a";
String b = "a";
String c = "b";
String d = new String(new char[]{'b'});

System.out.println(a == a); // true
System.out.println(a == b); // true
System.out.println(b == c); // false
System.out.println("b" == c); // true
System.out.println(c == d); // false
```

변수 `a`와 `b`의 경우, `a` 변수에 선언할 때, 풀에 문자열을 담게 되고, `b` 변수에 선언할 땐 풀에 있으므로 다시 불러오게 된다.
그렇기 때문에 `String` 클래스의 레퍼런스조차 같다. 그렇기 때문에 `a == b` 의 결과는 `true`가 되는 것이다.
하지만 `"a"` 와 `"b"` 는 딱 봐도 다른 문자열이기 때문에 레퍼런스도 틀리다. 당연히 결과는 `false`가 된다.
하지만 `d` 변수는 아예 클래스로 선언했다. 풀에 담지 않았기 때문에 문자열이 같아도 레퍼런스는 틀리다. 그래서 `c == d`의 결과는 false가 된다.

여기까지 하면 굳이 `equals` 안써도 된다고 생각하기 쉬운데… 안타깝게도 **자바는 자비로운 놈이 아니다.**
그 이유인 즉슨, 바로 `String` 클래스의 멤버부터 `StringBuilder` 및 `StringBuffer`, `Stream` 계열의 문자열 버퍼 스트림 클래스 등이 문제가 되는데,
이들은 반환 값이 모두 `new String` 이렇게 문자열 클래스로 선언하여 보내준다. 왠만한 클래스 내의 `toString` 메소드 오버라이딩도 마찬가지다.
굳이 이유를 알려주자면, 문자 열 임을 잊지 말자. `String` 클래스 소스 까보면 알겠지만, 문자열을 다룰 때 문자열 자체를 다루는 게 아니라 내부적인 `char[]`에 담아 처리하게 된다.
당연하겠지만 결국 번거롭게 작업하는 대신 성능을 높이려는 신의 한수라고 생각하면 된다. 이건 버퍼나 스트림 처리할 때도 마찬가지이다.
그래서 같은 문자열 내용이라도, 레퍼런스 비교는 틀리다 보니 결국 이런 결과가 나온다.

```java
System.out.println("abc".substring(0,1) == "a"); // false
```

만약 굳이 `==` 비교를 통해 비교하려면, `.intern()` 메소드를 써야 가능해진다.

```java
System.out.println("abc".substring(0,1).intern() == "a"); // true
```

하지만 누가 번거롭게 일일이 `intern` 메소드를 쓰는가? 게다가 메모리 효율성에 전혀 좋지 않는 짓이다.
왜냐면 문자열을 `intern` 으로 호출하면, 그 문자열은 풀에 담게 되는데, 다들 불변성(Immutable)이라 들어봤을 것이다.
게다가 `intern` 메소드는 네이티브 메소드다. 네이티브에서 메모리를 직접 관리한다. 그래서 불멸성이 가능한 것이다.
대신, 이로 인해 개발자는 자바 내에서 저 문자열에 대한 메모리 관리는 영영 빠이빠이 되는 것이다.

문자열 관리의 3규칙, 명심하자. 다른 글에서도 질리도록 강조한다.

- 따옴표로 감싼 정적 문자열은 적당히 쓰자. 너무 많으면 선언부터 사용까지 골치아픈 일이 발생한다.
- 암호화나 해시처럼 문자를 세부적으로 다루지 않는 이상 new String 처럼의 클래스 초기화는 필요 없다.
- 동적으로 생성된 문자열은 대부분 풀에 담지 않은 문자열 클래스이기 때문에, 값 비교엔 `equals` 메소드로 비교하라.
그래서 문자열을 비교할 때 결국 `equals` 메소드를 쓰라는 결과가 나오는 것이다.
내부적으로 `String.equals` 메소드가 값을 비교하도록 정의되어 있기에, 문자열 비교에 효율적일 수밖에 없을 것이다.

자, 물론 결론은 진실은 맞지만, 아는 사람들은 다 아는 뻔한 얘기지만, 이런 불편한 진실이 있다는 것을 상기시켜 주기 위해 추가했다.
참고로 닷넷 또한 자바의 문자열 관리와 비슷하다. 대신 `==` 비교 시 연산자 오버로딩에 의해 내부적으로 `String.Equals` 메소드로 비교하기 때문에 레퍼런스로 비교되는 일은 없다.

만약 더 있으면 추가하도록 하겠다.
끗.