String 클래스를 사용하면 아래와 같이 일련의 문자를 나타내는 문자열 리터럴을 담을 문자열 객체를 생성할 수 있습니다. 어렴풋이 짐작하신 분들도 있겠지만 문자열 리터럴은 큰따옴표(")로 둘러싸서 표현합니다. 소괄호 안에 들어간 문자열 리터럴은 생성자로 넘어가는데, 아직은 생성자를 배우지 않았으므로 가볍게 보고 넘어가주세요. 덧붙여서, 문자열을 저장할 때 아래와 같은 방법은 잘 사용하지 않습니다. 그 이유는 곧 밝혀집니다.

String a = new String("Hello");

아래와 같은 방식으로도 문자열 리터럴을 저장할 수 있습니다. 문자열이 상당히 많이 사용되기 때문에 특별하게 문자열에만 적용되는 규칙들이 많은데, 그 중에 하나가 아래와 같은 방식으로 문자열을 저장할 수 있다는 것입니다. 위의 방식과 아래의 방식의 차이점에 대해서 좀 더 자세히 알아보도록 합시다.

String a = "Hello";

문자열 풀

문자열 풀은 힙 영역 중 문자열 리터럴이 저장되는 특수 영역입니다. 문자열 내부 풀(string intern pool)이라고 부르기도 하고 문자열 상수 풀(string constant pool)이라고 부르기도 합니다. 문자열 객체를 생성하면 이 객체가 힙 영역의 공간을 차지하는데, 동일한 문자열 리터럴을 담은 문자열 객체가 여러 개 만들어지면 메모리 공간이 낭비될 수 있습니다. 이를 해결하기 위해서 문자열 객체를 생성하기 전에 먼저 문자열 풀에 이미 같은 값을 지닌 객체가 만들어졌는지 확인합니다. 이미 만들어진 객체가 있으면 해당 객체를 가리키도록 하고, 없으면 문자열 풀에 새로운 문자열 객체를 생성합니다.

String a = new String("A"); // (1)
String b = "A"; // (2)
String c = new String(b); // (3)
String d = "A"; // (4)

이해를 돕기 위해 이를 메모리 공간에서 확인해보도록 하겠습니다.

new 키워드를 사용하면 항상 힙 영역에 새로운 문자열 객체를 만듭니다. 따라서 (1)과 (3)은 힙 영역에 객체를 생성하지만 문자열 풀에 생성하지는 않습니다. (2)에선 "A"란 문자열이 문자열 풀에 없기 때문에 문자열 풀에 객체를 생성하고, (4)에서는 "A"란 문자열이 문자열 풀에 있기 때문에 객체를 새로 만들지는 않고 기존의 객체를 가리킵니다.

문자열 관련 메서드 살펴보기

자바에서는 다양한 문자열 관련 메서드를 지원합니다. String은 기본 타입이 아니라 참조 타입인 클래스이기 때문에 String 객체의 메서드를 호출할 수 있습니다. 여기서 모든 문자열 관련 메서드를 다루기에는 너무 많으므로, 자주 사용되는 메서드 위주로만 간단히 살펴보겠습니다.

부분 문자열 가져오기

지금은 인덱스(index)가 0부터 시작하는 위치라고 생각하셔도 됩니다. 인덱스는 배열 편 이후에서 밥 먹듯이 보게 되실 겁니다. 먼저, 아래의 코드를 컴파일 후 실행하여 결과를 확인해봅시다.

class StringExamples {
	public static void main(String[] args) {
		String str = "Write Once, Run Everywhere";
		
		System.out.println("str.substring(4): " + str.substring(4));
		System.out.println("str.substring(6, 10): " + str.substring(6, 10));
	}
}

str.substring(4)는 "e Once, Run Everywhere"를 출력하고, str.substring(6,10)은 "Once"를 출력하는 것을 확인하실 수 있습니다. 아래의 그림을 확인하면 왜 이런 결과가 출력되었는지 쉽게 확인하실 수 있습니다.

문자열 찾기

아래의 코드를 컴파일 후 실행하여 결과를 확인해봅시다.

class StringExamples {
	public static void main(String[] args) {
		String str = "Write Once, Run Everywhere";
		
		System.out.println("str.contains(\"very\"): " + str.contains("very")); // true
		System.out.println("str.contains(\"Very\"): " + str.contains("Very")); // false
		
		System.out.println("str.indexOf(\"Run\"): " + str.indexOf("Run")); // 12
		System.out.println("str.indexOf(\"Ruin\"): " + str.indexOf("Ruin")); // -1
		
		System.out.println("str.indexOf(\"er\"): " + str.indexOf("er")); // 18
		System.out.println("str.lastIndexOf(\"er\"): " + str.lastIndexOf("er")); // 23
	}
}

아래 그림을 보시면 왜 이런 결과가 나왔는지 알 수 있습니다.

문자열이 비어있는지 확인하기

아래의 코드를 컴파일 후 실행하여 결과를 확인해봅시다. 결과를 보시면 간단하게 이해하실 수 있습니다.

class StringExamples {
	public static void main(String[] args) {
		String str1 = "";
		String str2 = "This is not an empty string.";

		System.out.println("str1.isEmpty(): " + str1.isEmpty()); // true
		System.out.println("str2.isEmpty(): " + str2.isEmpty()); // false
	}
}

특정 문자열을 원하는 문자열로 교체하기

아래의 코드를 컴파일 후 실행하여 결과를 확인해봅시다.

class StringExamples {
	public static void main(String[] args) {
		String str = "Write Once, Run Everywhere";
		
		str = str.replace("Run Everywhere", "Debug Everywhere");
		System.out.println("str: " + str);
		
		str = str.replace("Run Everywhere", "Run Anywhere");
		System.out.println("str: " + str);
	}
}

8~9행의 결과를 보시면 아시겠지만 replace() 메서드는 대체할 문자열을 찾을 수 없으면 원본 문자열을 반환합니다.

기타 메서드

위에서 소개하지 못한 메서드들을 몇 개만 간단히 살펴보도록 하겠습니다.

class StringExamples {
	public static void main(String[] args) {
		String str = "Write Once, Run Everywhere";
		
		// charAt(): 해당 인덱스에 있는 문자를 반환합니다.
		System.out.println("str.charAt(3): " + str.charAt(3));
        
		// length(): 문자열의 길이를 반환합니다.
		System.out.println("str.length(): " + str.length());
		
		// toLowerCase(): 해당 문자열을 소문자로 변환 후 반환합니다.
		System.out.println("str.toLowerCase(): " + str.toLowerCase());
		// toUpperCase(): 해당 문자열을 대문자로 변환 후 반환합니다.
		System.out.println("str.toUpperCase(): " + str.toUpperCase());
	}
}

문자열 간의 동등 관계 따지기

기본 타입 간의 동등 관계를 따질 때는 연산자 편에서 봤던 것처럼 == 연산자나 != 연산자를 사용했습니다. 하지만 참조형 변수에는 전에 말했던 것처럼 데이터가 저장된 곳의 주소가 저장되기 때문에 동등 관계를 따질 때는 값을 서로 비교하는 게 아니라 주소를 비교하게 됩니다. 아래 코드를 직접 살펴보도록 합시다.

class StringExamples {
	public static void main(String[] args) {
		int a = 4;
		int b = 4;
		
		if (a == b) {
			System.out.println("변수 a와 b의 값이 서로 같습니다.");
		}
		
		String c = new String("Java");
		String d = new String("Java");
		
		// 주소값을 비교하게 되므로 아래 문장은 출력되지 않는다.
		if (c == d) {
			System.out.println("변수 c와 d가 서로 같습니다.");
		}
	}
}

그러면 문자열을 서로 비교하려면 어떤 방법을 사용해야 할까요? 바로 equals() 메서드를 사용합니다. equals() 메서드를 사용하면 String 객체가 담고있는 값을 기준으로 비교를 하게 됩니다. 문자열을 비교할 때는 == 연산자가 아니라 equals() 메서드를 사용해서 비교한다는 점 꼭 기억해주세요.

class StringExamples {
	public static void main(String[] args) {
		String c = new String("Java");
		String d = new String("Java");

		if (c.equals(d)) {
			System.out.println("문자열이 서로 같습니다.");
		}
	}
}

StringBuilder와 StringBuffer

기존 문제점 살펴보기

아래와 같이 문자열 배열이 주어졌을 때 모든 문자열을 하나로 연결하려면 어떻게 해야 할까요? 가장 간단하게는 아래와 같이 연산자를 사용해서 연결하는 것입니다.

private static String concat(String[] array) {
	String str = "";
	for (String s : array) {
		str += s;
	}
	return str;
}

아래의 예제 코드를 먼저 실행하고 소요 시간을 확인한 뒤에, (1)에 있는 concatenateStringWithOperator()을 concatenateStringWithBuilder()로 교체한 후 다시 실행해봅시다. 소요 시간을 비교해보면 상당한 차이를 확인할 수 있을 것입니다.

class StringBuilderExamples {
    public static void main(String[] args) {
	    // 길이가 100,000인 문자열 배열을 생성한다.
        String[] array = createStringArray(100_000);

        long startTime = System.nanoTime();
        concatenateStringWithOperator(array); // (1)
        long endTime = System.nanoTime();
        System.out.println("소요 시간: " + (endTime - startTime) / 1_000_000 + " ms");
    }

    private static String concatenateStringWithOperator(String[] array) {
        String str = "";
        for (String s : array) {
            str += s;
        }
        return str;
    }

    private static String concatenateStringWithBuilder(String[] array) {
        StringBuilder builder = new StringBuilder();
        for (String s : array) {
            builder.append(s);
        }
        return builder.toString();
    }

	// 단순히 길이가 긴 의미 없는 배열을 만드는 것으로 무시해도 된다.
    private static String[] createStringArray(int size) {
        return IntStream.rangeClosed(1, size)
                .mapToObj(Integer::toString)
                .toArray(String[]::new);
    }
}

왜 이런 일이 벌어질까요? 문자열은 불변이기 때문에 문자열 a와 b를 서로 연결하려고 할 때 연결된 문자열이 담길 새로운 문자열 객체를 만들어야 하기 때문입니다. 그리고 더 이상 사용되지 않는 기존의 문자열 객체는 가비지 컬렉션의 대상이 됩니다. 가비지 컬렉션은 지금은 자세히 알 필요는 없지만 간단히만 말하자면 말 그대로 쓰레기 수거로 더 이상 사용되지 않는 메모리를 회수하려고 하는 것을 말합니다.

StringBuilder

그러면 다른 대안은 무엇이 있을까요? 위 예제를 보시고 짐작하셨겠지만 바로 StringBuilder를 사용하면 됩니다. 이를 사용하면 내부에 어느 정도의 길이를 가진 문자 배열(char[])을 생성하여 미리 공간을 확보하고, 공간이 부족하면 자동으로 늘어나게 됩니다.

StringBuilder builder = new StringBuilder();
builder.append("abc"); // abc
builder.append(123); // abc123
builder.append(43.2); // abc12343.2
System.out.println(builder.toString()); // abc12343.2

위와 같이 append() 메서드로 원하는 값을 넘기면 됩니다. 마지막에는 toString()을 호출하여 문자열을 가져올 수 있습니다. 이외에도 indexOf(), lastIndexOf(), reverse(), replace() 같이 웬만한 문자열 관련 메서드는 StringBuilder에 정의되어 있습니다. contains()는 없으므로 대신 indexOf()를 사용합시다. 그리고 insert()나 delete()와 같이 문자열을 삽입하거나 삭제하는 메서드도 있습니다.

class StringBuilderExamples {
    public static void main(String[] args) {
        StringBuilder builder = new StringBuilder();
        builder.append("Hello"); // Hello
        builder.append(" "); // Hello
        builder.append("Java"); // Hello Java
        System.out.println("builder = " + builder);

        // (1) 문자열이 안에 들어 있는지 확인하기
        if (builder.indexOf("Java") != -1) { // builder.contains("Java")
            System.out.println("(1) 문자열 내에 \"Java\"가 있습니다.");
        }

        // (2) 문자열 제거하기
        // delete(start, end): start(포함)부터 end(제외)까지의 문자열을 제거한다.
        builder.delete(6, 10); // Hello
        System.out.println("(2) builder = " + builder);

        // (3) 문자열 삽입하기
        // insert(offset, str): 인덱스 offset에 문자열 str을 삽입한다.
        builder.insert(6, "World!!"); // Hello World!!
        System.out.println("(3) builder = " + builder);

        // (4) 문자 제거하기
        // deleteCharAt(offset): 인덱스 offset에 있는 문자를 제거한다.
        builder.deleteCharAt(builder.length() - 1); // Hello World!
        System.out.println("(4) builder = " + builder);

        // (5) 문자열 변경하기
        // setCharAt(index, ch): 인덱스 index에 있는 문자를 문자 ch로 변경한다.
        builder.setCharAt(builder.length() - 1, '?'); // 마지막 문자를 '?'로 변경한다.
        System.out.println("(5) builder = " + builder);

        // (6) 문자열 비교하기
        // ==나 equals()로는 비교할 수 없다. 비교하려면 아래와 같이 할 수 밖에 없다.
        String temp = builder.toString();
        if (temp.equals("Hello World?")) {
            System.out.println("(6) 문자열이 서로 일치합니다.");
        }
    }
}

컴파일러 최적화

컴파일러 최적화는 복잡한 영역입니다. 표준 자바 컴파일러는 여러 최적화를 수행하지만 대부분은 JIT에 맡기므로 런타임에는 우리가 생각하는 바와 다르게 동작할 수 있습니다. 어떤 JVM 구현을 사용하는가 혹은 그 버전에 따라서 달라질 수도 있습니다. 따라서 여기에서 설명하는 부분은 이해가 되지 않는 부분이 있어도 그저 가볍게 참고만 해주세요. 요약하면 '한 줄로 끝나는 단순한 문자열 연결이 아니라, 반복문 내에서와 같이 문자열을 빈번하게 연결(누적)할 필요가 있을 때는 StringBuilder를 사용하자' 정도가 되겠습니다.

JLS 15.18.1. 문자열 연결 연산자 +

JLS-15.18.1을 보면 다음과 같이 나와있습니다. 아래에서 보는 바와 같이 최적화는 선택 사항이므로 사용하고 있는 JVM 구현체에 따라 최적화 여부가 달라질 수도 있습니다.

구현체(implementation)는 중간 문자열 객체를 생성하고 버리는 것을 피하기 위해서 변환과 연결을 한 번에 수행하기로 결정할 수도 있다. 반복되는 문자열 연결의 성능을 높이기 위해서, 자바 컴파일러는 표현식(expression)을 평가할 때 생성되는 중간 문자열 객체의 수를 줄이기 위해 StringBuffer 같은 기법을 사용할 수도 있다.

자바 5 이후

아래와 같이 연산자로 문자열을 연결하는 단순한 코드가 있다고 해봅시다.

String a = "I'm";
String b = " a ";
String c = "string";
String result = a + b + c;

컴파일 후 생성된 바이트코드를 살펴보면 아래와 같이 내부적으로 StringBuilder를 사용하는 것을 볼 수 있습니다. 컴파일러가 문자열을 연결할 때 최적화를 수행하는 것을 알 수 있습니다.

실제로는 아래와 같이 동작하는 것입니다.

String result = new StringBuilder().append(a).append(b).append(c).toString();

아래와 같이 문자열 리터럴을 서로 연결하려고 하면 컴파일 후에는 하나로 연결됩니다. 그리고 여러 줄에 걸쳐서 문자열을 연결하려고 하면 우리가 바라는 대로 하나로 묶여서 동작하지는 않는다는 점에 주의해주세요.

// 최적화 전
String result = "I'm" + " a " + "string";
// 최적화 후
String result = "I'm a string";

// 최적화 전
String a = "I'm";  
String b = " a ";  
String c = "string";  
String result = "";  
result += a;  
result += b;  
result += c;

// 최적화 후
...
result = new StringBuilder().append(result).append(a).toString();  
result = new StringBuilder().append(result).append(b).toString();  
result = new StringBuilder().append(result).append(c).toString();

최적화가 이루어진다면 왜 반복문에서 느린 성능을 보였던 것일까요? 아래 코드를 보면 그 이유를 알 수 있습니다. 따라서 반복문을 사용할 때는 직접 StringBuilder를 사용해야 합니다.

String str = "";
for (String s : array) {
	// str += s;
	str = new StringBuilder().append(str).append(s).toString();
}

자바 9 이후

자바 9 이후로는 문자열을 연결하는 전략이 바뀌었습니다. 자바 8에서 invokedynamic이 도입되면서 StringBuilder가 아니라 StringConcatFactory.makeConcat()을 사용합니다. 이 방식은 별도의 바이트코드 변경 없이 향후에 문자열 최적화 전략을 변경할 수 있다는 장점이 있습니다. 세부적인 내용은 다루려는 범위를 넘어서므로 여기서는 설명하지 않습니다. 아직까지는 내부 동작을 살펴봤을 때 StringBuilder와 똑같은 전략을 사용합니다. 

String a = "I'm";
String b = " a ";
String c = "string";

// 컴파일러가 문자열 연결을 위해 사용하는 것으로 아래를 보면 알겠지만 프로그래머가 사용하라고 만든 게 아니다.
MethodHandle mh = StringConcatFactory.makeConcat(
	MethodHandles.lookup(),
	"meaninglessName",
	MethodType.methodType(String.class, String.class, String.class, String.class)).getTarget();
String result = (String) mh.invokeExact(a, b, c);

System.out.println("result = " + result);

하지만 자바 8이든 자바 9든 관계없이 반복문 내에서는 여전히 StringBuilder를 사용해야 합니다.

StringBuffer

여기서 설명하는 내용을 이해하려면 먼저 스레드(Thread)와 동기화에 대한 배경 지식이 있어야 합니다. 이는 나중에 설명하므로, 스레드를 보고 이 부분을 보시는 것을 권장드립니다. StringBuffer는 StringBuilder와 같지만 동기화를 제공합니다. 따라서 단일 스레드 환경에서는 동기화가 필요하지 않으므로 성능 저하를 피하기 위해서 StringBuilder를 사용해야 합니다. 지원하는 메서드는 StringBuilder와 같으므로 따로 설명하지는 않습니다.

public final class StringBuffer
	extends AbstractStringBuilder
	implements Serializable, Comparable<StringBuffer>, CharSequence
{
	public synchronized StringBuffer append(Object obj) { ... }

	public synchronized String substring(int start) { ... }

	...
}