자바에서 String 다룰 때 오해와 진실

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

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

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

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

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

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

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 클래스를 사용하여 문자열을 합치는 게 더 좋다.


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

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

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

    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);
        }
    }

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

참고: String concatenation with Java 8

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

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

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

Difference Between String , StringBuilder And StringBuffer Classes With Example : Java
오라클 직원이었던 글을 참고하여 설명 들어가겠다.
사실 둘의 차이는 하나 말고는 없다.
바로 쓰레드에서 안전하냐 아니냐 이 차이 뿐이다.

  • StringBuffer 클래스는 쓰레드에서 안전하다.
  • StringBuilder 클래스는 쓰레드에서 안전하지 않다.

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

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

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

위 둘의 성격을 잘 설명한 글이 있으니 참고하라.
Java에서 String, StringBuilder, StringBuffer의 차이

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

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

어느정도 맞다.

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

  • 자바의 == 연산자는 레퍼런스 비교이다.
  • 문자열은 클래스이기 때문에 레퍼런스가 서로 달라 문자열을 아무리 비교해도 반드시 false가 나온다.
  • 따라서 문자열 비교할 때는 반드시 .equals() 메소드를 써서 비교해야 한다.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • 따옴표로 감싼 정적 문자열은 적당히 쓰자. 너무 많으면 선언부터 사용까지 골치아픈 일이 발생한다.
  • 암호화나 해시처럼 문자를 세부적으로 다루지 않는 이상 new String 처럼의 클래스 초기화는 필요 없다.
  • 동적으로 생성된 문자열은 대부분 풀에 담지 않은 문자열 클래스이기 때문에, 값 비교엔 equals 메소드로 비교하라.

그래서 문자열을 비교할 때 결국 equals 메소드를 쓰라는 결과가 나오는 것이다.
내부적으로 String.equals 메소드가 값을 비교하도록 정의되어 있기에, 문자열 비교에 효율적일 수밖에 없을 것이다.

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

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

composite / 2018년 5월 18일 / Piss Development / 0 Comments

[JS] String.prototype.replace() 문자열 함수 대체를 허하노라!

제목은 좀 거창하겠지만 그냥 String.prototype.replace 메소드의 함수 사용법 정리다.

먼저 콜백 함수 구조는 아래와 같다.

function(match, p1, [p2... p9, offset, string){
    // insert your code here.
}

인자는 이렇게 알면 된다.

  • match: RegExp.prototype.match() 메소드 실행 후 나오는 배열의 첫자리[0]에서 정규식에 매치된 문자열 전체를 불러온다. 그걸 가져다 쓴다. ($0)
  • p1, p2... p9: 정규식의 그룹 매치 ($1 ~ $9) 인자이다. 그룹 매치는 최대 9개 불러올 수 있으며, 늘어날수록 두번째 기준 인자에서 늘어난다.
  • offset: 정규식이 매치된 원래 문자열 대비 위치이다. 예를 들어 abcd 에서 bcd 매치가 될 경우 offset 값은 1이 된다. 당연히 0부터 시작이다.
  • string: 정규식을 검색하기 위해 사용된 문자열 전체를 불러온다.

여기서 주의해야 할 점은 p1, p2... 인자인데, 이들 때문에 offset이 몇번째 인자인지를 가늠할 수 없다. 당신이 그룹 매치를 몇번 하냐에 따라 인자 위치가 달라지기 때문이다.

이제 거청하지도 않은 설명 끝났으니 예제를 통해 사용해보도록 하겠다.
간단한 템플릿 치환 예제이다.

var tmpl = '내 이름은 [[name]] 이며 나이는 [[age]] 이다.',
    binding = {name: '홍길동', age: 20},
    regex = /\[\[(\w+)\]\]/g,
    callback = function(match, name, offset, string){
        return binding[name] || '그딴거없다';
    };

console.log(tmpl.replace(regex, callback));
//결과: "내 이름은 홍길동 이며 나이는 20 이다."

간단하지 아니한가?

여담:

String.prototype.replace 메소드는 원체 느리다. 가장 빠른 방법이 일반 문자열 replace이다. 어자피 한번만 갈아 끼우기 때문에 빠를 수밖에 없다.
이는 문자열 대체냐 함수 대체나 상관없이 비슷한 성능을 내준다.
그러니 업무상 템플릿 엔진에 성능 신경쓸 시간에 걍 체념하고 이거 쓰거나 어떤 이가 만들어준 템플릿 엔진을 쓰자.

ECMAScript 6 의 새로운 문법인 템플릿 문자열이 있다. 사용법은 아래와 같다.

var binding = {name: '홍길동', age: 20},
    tmpl = `내 이름은 ${binding.name} 이며 나이는 ${binding.age} 이다.`;

console.log(tmpl);

PHP 쓰거나 .NET 4.6 쓴 사람에겐 익숙한 문법이긴 하지만 역따옴표(`)를 쓴다는 게 특이점이라 하겠다.
ES6 트랜스파일러인 Babel.js 는 위 문법을 쓰면 아래와 같이 변환한다.

'use strict';

var binding = { name: '홍길동', age: 20 },
    tmpl = '내 이름은 ' + binding.name + ' 이며 나이는 ' + binding.age + ' 이다.';

console.log(tmpl);

그렇다. 문자열 합치기(string concatenation)를 사용하여 간단하고 성능 좋게 해석했다.
아직은 이 기능이 네이티브로 들어간 모습은 못봤다. 그래서
트랜스파일링 시간과 표현 시간이 합쳐져서 아직까지 성능은 느리게 나온다.
직접 테스트하고 싶으면 http://jsperf.com/es6-string-literals-vs-string-concatenation 가면 된다.

그럼 이만.

composite / 2015년 12월 23일 / Piss Development / 0 Comments