
자바에서 문자를 다루는 타입으로는 char, String 총 2가지가 있다.
package lang.string;
public class CharArrayMain {
public static void main(String[] args) {
char[] chars = new char[] { 'h', 'e', 'l', 'l', 'o' };
System.out.println(chars);
String str = "hello";
System.out.println("str = " + str);
}
}
/*
str1 = hello
str2 = hello
*/
기본형인 char 타입은 문자를 하나씩 다룰 때 사용하는데, 위처럼 여러 문자를 나열하려면 배열로 사용해야 하는 불편함이 있다. 따라서 자바는 문자열을 보다 편리하게 다룰 수 있도록 String 클래스를 제공하는 것이다.
String 클래스를 사용해서 문자열을 생성하는 방법에는 2가지가 있다.
package lang.string;
public class StringBasicMain {
public static void main(String[] args) {
String str1 = "hello";
String str2 = new String("hello"); // 문자도 객체로 생성 가능
System.out.println("str1 = " + str1);
System.out.println("str2 = " + str2);
}
}
String 클래스는 참조형이다. 따라서 str1과 같은 변수에 String 인스턴스의 참조값만 들어갈 수 있다. 근데 “hello” 처럼 문자열을 그대로 때려 박는게 가능한가? 결론만 말하면, 자바에서는 편의상 쌍따옴표로 문자열을 감싸면 new String("hello")와 같이 바꿔준다.
String str1 = "hello";
String str1 = new String("hello"); // 이처럼 변경해준다.
String 클래스는 대략 아래와 같이 생겼다.

원래 char[] 캐릭터 배열로 보관했다. 실제로 문자 하나하나를 가지고 있고, 기본형인 char를 배열로 모아 문자열이 되는 것이다. 기본형은 약간 생명이 없는 데이터 조각같은 느낌이다. 반면, String 클래스는 기본형 데이터 조각을 속성으로 가지면서 여러 기능을 부여해 마치 살아있는 것처럼 객체 지향적으로 사용할 수 있다. 그리고 자바 9 이후로는 위와 같이 char[] 대신, byte[]를 사용한다. char는 2byte를 차지한다. 근데 영어, 숫자는 보통 1byte로 표현이 가능한데, 단순 영어나 숫자로만 표현된 경우에 1byte를 사용하고, 그렇지 않은 경우는 2byte인 UTF-16 인코딩을 사용한다. 따라서 메모리를 더 효율적으로 사용할 수 있게 변경된 것이다.
String 클래스는 “클래스” 이기 때문에 문자열로 처리할 수 있는 다양한 기능을 제공한다. 주요 메서드는 아래와 같다. 가볍게 보고 넘어가도록 하자.
length(): 문자열의 길이를 반환한다.
charAt(int index): 특정 인덱스의 문자를 반환한다.
substring(int beginIndex, int endIndex): 문자열의 부분 문자열을 반환한다.
indexOf(String str): 특정 문자열이 시작되는 인덱스를 반환한다.
toLowerCase(), toUpperCase(): 문자열을 소문자 또는 대문자로 변환한다.
trim(): 문자열 양 끝의 공백을 제거한다.
concat(String str): 문자열을 더한다.
String 클래스는 기본형이 아닌 참조형이라고 했다. 참조형은 알다시피 변수에 계산할 수 있는 값이 들어 가는게 아니라 계산할 수 없는 참조값이 들어간다. 따라서 원칙적으로는 +와 같은 연산을 할 수 없다.
package lang.string;
public class StringConcatMain {
public static void main(String[] args) {
String str1 = "hello";
String str2 = " java";
String result1 = str1.concat(str2);
String result2 = str1 + str2; // + 연산 허용(원칙적으로는 참조값끼리 연산 불가)
System.out.println("result1 = " + result1);
System.out.println("result2 = " + result2);
}
}
/*
result1 = hello java
result2 = hello java
*/
자바에서는 문자열을 더할 때, String이 제공하는 concat()과 같은 메서드를 사용해야 한다. 하지만, 문자열은 자주 다루어지기 때문에 자바 언어에서 편의상 특별히 + 연산을 허용하는 것이다.
"
String클래스를 비교할 때는==비교가 아니라, 항상equals()메서드를 이용해서 비교해야 한다!"
동일성(Identity): == 연산자를 사용해서 두 객체의 참조가 동일한 객체를 가리키고 있는지 확인한다.동등성(Equality): equals() 메서드를 사용해서 두 객체가 논리적으로 같은지 확인한다.
package lang.string.equals;
public class StringEqualsMain1 {
public static void main(String[] args) {
String str1 = new String("hello");
String str2 = new String("hello");
System.out.println("new String() == 비교: " + (str1 == str2));
System.out.println("new String() equals 비교: " + (str1.equals(str2)));
/*
new String() == 비교: false
new String() equals 비교: true
*/
// 찾으면 찾은 인스턴스의 참조값을 반환
String str3 = "hello";
String str4 = "hello";
System.out.println("리터럴 == 비교: " + (str3 == str4)); // 다른 참조값 연산 아닌가? 왜 결과가 true지...?
System.out.println("리터럴 equals 비교: " + (str3.equals(str4)));
/*
리터럴 == 비교: true
리터럴 equals 비교: true
*/
}
}
str1과 str2는 각각 다른 인스턴스이기 때문에 당연히 참조값이 다르다. String 클래스 안에는 바이트 배열인 value가 있고 그 value 값으로 비교한다. 근데 equals() 메서드를 통한 비교는 Object 클래스가 제공하는 == 연산이 아닌가? 하지만 실제로 뜯어보면…

위와 같이 String 에 있는 equals() 메서드는 오버라이딩 되어 있는 것을 확인할 수 있다.
String str3 = "hello"와 같이 문자열 리터럴을 사용하는 경우, 자바는 메모리 효율성과 성능 최적화를 위해 “문자열 풀” 이라는 걸 사용한다. 자바가 실행되는 시점에 클래스에 문자열 리터럴이 있으면 문자열 풀에 String 인스턴스를 미리 만들어 두는 것이다. 이때 같은 문자열이 있으면 만들지 않는다는 점이 중요하다.

String str3 = "hello"와 같이 문자열 리터럴을 사용하면 문자열 풀에서 “hello” 라는 문자를 가진 String 인스턴스를 찾는다. 그리고 찾은 인스턴스의 참조값을 반환한다. String str4 = "hello" 의 경우에는, 동일한 "hello" 문자열 리터럴을 사용하므로 문자열 풀에서 str3과 같은 참조값을 통해 접근한다. 이처럼 문자열 풀 덕분에 문자를 사용하는 경우, 메모리 사용을 줄이고 문자를 만드는 시간도 줄어들기 때문에 성능 최적화를 꾀할 수 있다.
풀(Pool)은 자원이 모여있는 곳을 의미한다. 프로그래밍에서 "풀(Pool)은 공용 자원을 모아둔 곳" 을 뜻한다. 여러 곳에서 함께 사용할 수 있는 객체를 필요할 때마다 생성하고, 제거하는 것은 매우 비효율적일 것이다. 대신, 이렇게 문자열 풀에 필요한 String 인스턴스를 미리 만들어두고 여러 곳에서 재사용할 수 있게 한다면 성능과 메모리를 더 최적화 시킬 수 있다. 참고로 문자열 풀은 힙 영역을 사용한다. 그리고 문자열 풀에서 문자를 찾을 때는 해시 알고리즘을 사용하기 때문에 매우 빠른 속도로 원하는 String 인스턴스를 찾을 수 있다.
그렇다면 문자열 리터럴을 사용하면 ==로 비교하고, new String()을 직접 사용하는 경우에는 equals() 메서드를 통한 비교만 하면 될까? 이러면 안 된다. 항상 equals() 메서드를 사용해야 한다!
package lang.string.equals;
public class StringEqualsMain2 {
public static void main(String[] args) {
String str1 = new String("hello");
String str2 = new String("hello");
System.out.println("메서드 호출 비교1: " + isSame(str1, str2));
String str3 = "hello";
String str4 = "hello";
System.out.println("메서드 호출 비교2: " + isSame(str3, str4));
}
private static boolean isSame(String str1, String str2) {
// return str1 == str2;
return str1.equals(str2);
}
}
/*
str1 == str2일 경우
메서드 호출 비교1: false
메서드 호출 비교2: true
str1.equals(str2)일 경우
메서드 호출 비교1: true
메서드 호출 비교2: true
*/
main() 메서드를 만드는 개발자와 isSame() 메서드를 만드는 개발자가 다르다고 가정해보자. isSame() 메서드의 경우, 개발자 입장에서 매개 변수로 넘어오는 String 인스턴스가 new String()으로 만들어진 것인지, 문자열 리터럴로 만들어진 것인지 알 도리가 없다. 따라서 문자열 비교는 항상 equals() 메서드를 통해 확인해야 하는 것이다.
"String은 불변 객체다." 그 말은 생성 이후에 절대 값을 변경할 수 없다는 뜻이다.
package lang.string.immutable;
public class StringImmutable1 {
public static void main(String[] args) {
String str1 = "hello";
str1.concat(" java");
System.out.println(str1); // 결과는 hello 출력된다. 왜 합쳐지지 않는거지?
}
}
String.concat() 메서드를 사용하면 기존 문자열에 새로운 문자열을 연결해서 합칠 수 있다. 하지만, 위와 같이 작성하면, 합쳐진 결과가 아닌 “hello” 텍스트만 출력된다. 어떻게 된 걸까?
package lang.string.immutable;
public class StringImmutable2 {
public static void main(String[] args) {
String str1 = "hello";
String str2 = str1.concat(" java");
System.out.println(str1); // hello
System.out.println(str2); // hello java
}
}
String이 불변 객체이기 때문이다. 따라서 변경이 필요할 때 기존 값을 변경하지 않는 대신, 새로운 결과를 만들어서 반환한다.

String str1을 통해 x001을 찾아가면, 안에 “hello” 텍스트가 들어 있다. 그럼 내부에서 String 객체로 합쳐서 "새로운 객체의 참조값(x002)" 을 리턴한다. 그 객체의 참조값을 str2에 넣어주면, 새로운 객체를 사용하는 것이다. 기존 객체의 값은 유지되는 것이다.
String이 불변으로 설계된 이유는 앞서 불변 객체에서 배운 내용에 추가로 아래와 같은 이유도 있다.

문자열 풀에 있는 String 인스턴스의 값이 중간에 변경되면 같은 문자열을 참고하는 다른 변수의 값도 함께 변경되는 문제가 발생한다. String은 자바 내부에서 위와 같이 문자열 풀을 통해 최적화를 하는데, String 내부의 값을 변경할 수 있다면 기존의 문자열 풀에서 같은 문자를 참조하는 변수의 모든 문자가 함께 변경되어 버리는 것이다. str3이 참조하는 “hello” 텍스트를 변경하면 str4의 문자도 함께 변경되는 Side Effect가 발생한다. 이러한 이유로 String 클래스는 불변으로 설계된 것이다.
<문자열 정보 조회>
length(): 문자열의 길이를 반환한다.isEmpty(): 문자열이 비어 있는지 확인한다. (길이가 0)isBlank(): 문자열이 비어 있는지 확인한다. (길이가 0이거나 공백(Whitespace)만 있는 경우), 자바 11charAt(int index): 지정된 인덱스에 있는 문자를 반환한다.
<문자열 비교>
equals(Object anObject): 두 문자열이 동일한지 비교한다.equalsIgnoreCase(String anotherString): 두 문자열을 대소문자 구분 없이 비교한다.compareTo(String anotherString): 두 문자열을 사전 순으로 비교한다.compareToIgnoreCase(String str): 두 문자열을 대소문자 구분 없이 사전적으로 비교한다.startsWith(String prefix): 문자열이 특정 접두사로 시작하는지 확인한다.endsWith(String suffix): 문자열이 특정 접미사로 끝나는지 확인한다.
<문자열 검색>
contains(CharSequence s): 문자열이 특정 문자열을 포함하고 있는지 확인한다.indexOf(String ch) / indexOf(String ch, int fromIndex): 문자열이 처음 등장하는 위치를 반환한다.lastIndexOf(String ch): 문자열이 마지막으로 등장하는 위치를 반환한다.
<문자열 조작 및 변환>
substring(int beginIndex) / substring(int beginIndex, int endIndex): 문자열의 부분 문자열을 반환한다.concat(String str): 문자열의 끝에 다른 문자열을 붙인다.replace(CharSequence target, CharSequence replacement): 특정 문자열을 새 문자열로 대체한다.replaceAll(String regex, String replacement): 문자열에서 정규 표현식과 일치하는 부분을 새 문자열로 대체한다.replaceFirst(String regex, String replacement): 문자열에서 정규 표현식과 일치하는 첫 번째 부분을 새 문자열로 대체한다.toLowerCase() / toUpperCase(): 문자열을 소문자나 대문자로 변환한다.trim(): 문자열 양쪽 끝의 공백을 제거한다. 단순 Whitespace 만 제거할 수 있다.strip(): Whitespace 와 유니코드 공백을 포함해서 제거한다. 자바 11
<문자열 분할 및 조합>
split(String regex): 문자열을 정규 표현식을 기준으로 분할한다.
join(CharSequence delimiter, CharSequence... elements): 주어진 구분자로 여러 문자열을 결합한다.
<기타 유틸리티>
valueOf(Object obj): 다양한 타입을 문자열로 변환한다.toCharArray(): 문자열을 문자 배열로 변환한다.format(String format, Object... args): 형식 문자열과 인자를 사용하여 새로운 문자열을 생성한다.matches(String regex): 문자열이 주어진 정규 표현식과 일치하는지 확인한다.
참고로, CharSequence는 String, StringBuilder의 상위 타입이다. 문자열을 처리하는 다양한 객체를 받을 수 있다.
package lang.string.method;
public class StringInfoMain {
public static void main(String[] args) {
String str = "Hello, Java!";
System.out.println("문자열의 길이: " + str.length());
System.out.println("문자열이 비어 있는가: " + str.isEmpty());
System.out.println("문자열이 비어 있거나 공백인지1: " + str.isBlank());
System.out.println("문자열이 비어 있거나 공백인지2: " + " ".isBlank());
char c = str.charAt(7);
System.out.println("7번 인덱스의 문자: " + c);
}
}
/*
문자열의 길이: 12
문자열이 비어 있는가: false
문자열이 비어 있거나 공백인지1: false
문자열이 비어 있거나 공백인지2: true
7번 인덱스의 문자: J
*/
package lang.string.method;
public class StringComparisonMain {
public static void main(String[] args) {
String str1 = "Hello, Java!";
String str2 = "hello, java!";
String str3 = "Hello, Spring!";
System.out.println("str1 equals str2: " + str1.equals(str2));
System.out.println("str1 equalsIgnoreCase str2: " + str1.equalsIgnoreCase(str2));
System.out.println("'b' compareTo 'a': " + "b".compareTo("a"));
System.out.println("str1 compareTo str3: " + str1.compareTo(str3)); // 사전적으로 얼마나 차이나는가
System.out.println("str1 compareToIgnoreCase str2: " + str1.compareToIgnoreCase(str2));
System.out.println("str1 starts with 'Hello': " + str1.startsWith("Hello"));
System.out.println("str1 ends with 'Java!': " + str1.endsWith("Java!"));
}
}
/*
str1 equals str2: false
str1 equalsIgnoreCase str2: true
'b' compareTo 'a': 1
str1 compareTo str3: -9
str1 compareToIgnoreCase str2: 0
str1 starts with 'Hello': true
str1 ends with 'Java!': true
*/
package lang.string.method;Add commentMore actions
public class StringSearchMain {
public static void main(String[] args) {
String str = "Hello, Java! Welcome to Java World.";
System.out.println("문자열에 'Java'가 포함되어 있는지: " + str.contains("Java"));
System.out.println("'Java'의 첫 번째 인덱스: " + str.indexOf("Java"));
System.out.println("인덱스 10부터 'Java'의 인덱스: " + str.indexOf("Java", 10));
System.out.println("'Java'의 마지막 인덱스: " + str.lastIndexOf("Java"));
}
}
/*
문자열에 'Java'가 포함되어 있는지: true
'Java'의 첫 번째 인덱스: 7
인덱스 10부터 'Java'의 인덱스: 24
'Java'의 마지막 인덱스: 24
*/
substring(int beginIndex) / substring(int beginIndex, int endIndex): 문자열의 부분 문자열을 반환한다.concat(String str): 문자열의 끝에 다른 문자열을 붙인다.replace(CharSequence target, CharSequence replacement): 특정 문자열을 새 문자열로 대체한다.replaceAll(String regex, String replacement): 문자열에서 정규 표현식과 일치하는 부분을 새 문자열로 대체한다.replaceFirst(String regex, String replacement): 문자열에서 정규 표현식과 일치하는 첫 번째 부분을 새 문자열로 대체한다.toLowerCase() / toUpperCase(): 문자열을 소문자나 대문자로 변환한다.trim(): 문자열 양쪽 끝의 공백을 제거한다. 단순 Whitespace만 제거할 수 있다.strip(): Whitespace와 유니코드 공백을 포함해서 제거한다.
package lang.string.method;
public class StringChangeMain1 {
public static void main(String[] args) {
String str = "Hello, Java! Welcome to Java World";
System.out.println("인덱스 7부터의 부분 문자열: " + str.substring(7));
System.out.println("인덱스 7부터 12까지의 부분 문자열: " + str.substring(7, 12));
System.out.println("문자열 결합: " + str.concat("~~~"));
System.out.println("'Java'를 'Spring'으로 대체: " + str.replace("Java", "Spring"));
System.out.println("첫 번째 'Java'를 'Mac'으로 대체: " + str.replaceFirst("Java", "Mac"));
}
}
/*
인덱스 7부터의 부분 문자열: Java! Welcome to Java World
인덱스 7부터 12까지의 부분 문자열: Java!
문자열 결합: Hello, Java! Welcome to Java World~~~
'Java'를 'Spring'으로 대체: Hello, Spring! Welcome to Spring World
첫 번째 'Java'를 'Mac'으로 대체: Hello, Mac! Welcome to Java World
*/
package lang.string.method;
public class StringChangeMain2 {
public static void main(String[] args) {
String strWithSpaces = " Java Programming is awesome! ";
System.out.println("소문자로 변환: " + strWithSpaces.toLowerCase());
System.out.println("대문자로 변환: " + strWithSpaces.toUpperCase());
System.out.println("공백 제거(trim): '" + strWithSpaces.trim() + "'");
System.out.println("공백 제거(strip): '" + strWithSpaces.strip() + "'");
System.out.println("앞 공백 제거(strip): '" + strWithSpaces.stripLeading() + "'");
System.out.println("뒤 공백 제거(strip): '" + strWithSpaces.stripTrailing() + "'");
}
}
/*
소문자로 변환: java programming is awesome!
대문자로 변환: JAVA PROGRAMMING IS AWESOME!
공백 제거(trim): 'Java Programming is awesome!'
공백 제거(strip): 'Java Programming is awesome!'
앞 공백 제거(strip): 'Java Programming is awesome! '
뒤 공백 제거(strip): ' Java Programming is awesome!'
*/
split(String regex): 문자열을 정규 표현식을 기준으로 분할한다.join(CharSequence delimiter, CharSequence... elements): 주어진 구분자로 여러 문자열을 결합한다.package lang.string.method;
public class SpringSplitJoinMain {
public static void main(String[] args) {
String str = "Grape,Strawberry,Melon";
// split
String[] splitStr = str.split(",");
for (String s : splitStr) {
System.out.println(s);
}
// join
String joinedStr = String.join("-", "A", "B", "C");
System.out.println("연결된 문자열: " + joinedStr);
// 문자열 배열 연결
String result = String.join("-", splitStr);
System.out.println("result = " + result);
}
}
/*
Grape
Strawberry
Melon
연결된 문자열: A-B-C
result = Grape-Strawberry-Melon
*/
valueOf(Object obj) : 다양한 타입을 문자열로 변환한다.toCharArray(): 문자열을 문자 배열로 변환한다.format(String format, Object... args) : 형식 문자열과 인자를 사용하여 새로운 문자열을 생성한다.matches(String regex) : 문자열이 주어진 정규 표현식과 일치하는지 확인한다.package lang.string.method;
public class StringUtilsMain1 {
public static void main(String[] args) {
int number = 100;
boolean bool = true;
Object obj = new Object();
String str = "Hello, Java!";
// valueOf 메서드
String numString = String.valueOf(number);
System.out.println("숫자의 문자열 값 " + numString);
String boolString = String.valueOf(bool);
System.out.println("불리언의 문자열 값: " + boolString);
String objString = String.valueOf(obj);
System.out.println("객체의 문자열 값: " + objString);
// 아래와 같이 간단히 변환할 수 있음
String numString2 = "" + number;
System.out.println("빈 문자열 + number: " + numString2);
// toCharArray 메서드
char[] strCharArray = str.toCharArray();
System.out.println("문자열을 문자 배열로 변환: " + strCharArray);
for (char c : strCharArray) {
System.out.print(c);
}
System.out.println();
}
}
/*
숫자의 문자열 값 100
불리언의 문자열 값: true
객체의 문자열 값: java.lang.Object@2a84aee7
빈 문자열 + number: 100
문자열을 문자 배열로 변환: [C@a09ee92
Hello, Java!
*/
package lang.string.method;
public class StringUtilsMain2 {
public static void main(String[] args) {
int number = 100;
boolean bool = true;
String str = "Hello, Java!";
// format 메서드
String format1 = String.format("num: %d, bool: %b, str: %s", number, bool, str);
System.out.println(format1);
String format2 = String.format("숫자: %.2f", 10.1234);
System.out.println(format2);
// printf
System.out.printf("숫자: %.2f\n", 10.1234);
// matches 메서드
String regex = "Hello, (Java!|World!)";
System.out.println("'str'이 패턴과 일치하는가? " + str.matches(regex));
}
}
/*
num: 100, bool: true, str: Hello, Java!
숫자: 10.12
숫자: 10.12
'str'이 패턴과 일치하는가? true
*/
format 메서드에서 %d는 숫자, %b는 boolean, %s는 문자열을 뜻한다.
불변 객체인 String도 단점은 있다. 불변 객체기 때문에 변경된 값을 기반으로 새로운 String 객체를 생성한다. 문자를 더하거나 변경할 때마다 계~속해서 새로운 객체를 만들어야 한다는 것이다. String 객체를 무지하게 많이 만들고, 그만큼 가비지 컬렉션을 해야 할 것이다. 결과적으로 컴퓨터의 CPU, 메모리 자원을 더 많이 사용하게 된다. 문자열의 크기가 클수록, 문자열을 더 자주 변경해야 할수록 시스템의 자원이 많이 소모되는 것이다.
이런 문제를 해결하기 위해 자바는 StringBuilder라는 가변 String을 제공한다. 물론 가변이라고 해도 Side Effect에 주의해서 사용해야 한다. 그럼 StringBuilder가 실제로 어떻게 사용되는지 확인해보자.
package lang.string.builder;
public class StringBuilderMain1_1 {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
System.out.println("sb = " + sb);
sb.insert(4, "Java");
System.out.println("insert = " + sb);
sb.delete(4, 8);
System.out.println("delete = " + sb);
System.out.println("reverse = " + sb.reverse());
// StringBuilder -> String
String str = sb.toString(); // 다시 불변으로 바꿀 수 있음
System.out.println("str = " + str);
}
}
/*
sb = abcd
insert = abcdJava
delete = abcd
reverse = dcba
str = dcba
*/
StringBulider 객체를 생성해서, append() 메서드를 통해 여러 문자열을 추가할 수 있고, insert() 메서드를 통해 문자열을 삽입할 수도 있다. delete() 메서드로 특정 범위의 문자열을 삭제할 수도 있고, reverse()로 문자열을 뒤집는 것도 가능하다. 근데 주의할 점이 있는데, 항상 마지막에 toString() 메서드로 StringBuilder의 결과를 기반으로 String으로 생성해서 반환해줘야 한다는 점이다. 왜냐하면, 불변성과 안정성 때문이다. 가변적 이점을 얻기 위해 StringBuilder를 사용한건데, 모든 작업을 마친 후에는 문자열의 값이 변해서는 안 될 것이다. 추가적으로 StringBuilder는 String과 API가 다르다. 대부분의 문자열 관련 메서드는 String에서만 사용 가능하기 때문에 toString() 메서드로 바꿔주는 것이 좋다.
String은 불변하다. 즉, 한 번 생성되면 그 내용을 변경할 수 없다는 것이다. 따라서 문자열에 변화를 주려고 할 때마다 새로운 String 객체가 생성되고, 기존 객체는 버려진다. 이 과정에서 메모리와 처리 시간을 더 많이 소모한다. 반면, StringBuilder는 가변적이다. 하나의 StringBuilder 객체 안에서 문자열을 추가, 삭제, 수정할 수 있으며, 이때마다 새로운 객체를 생성하지 않는다. 이로 인해 메모리 사용을 줄이고 성능을 향상시킬 수 있다. 단 Side Effect를 주의해야 한다.
자바 컴파일러는 문자열 리터럴을 더하는 부분을 자동으로 합쳐준다.
<컴파일 전>
String helloWorld = "Hello, " + "World!";
<컴파일 후>
String helloWorld = "Hello, World!";
이처럼 런타임에 별도의 문자열 결합 연산을 수행하지 않기 때문에 성능이 향상된다. 문자열 변수의 경우, 그 안에 어떤 값이 들어 있는지 알 방법이 없기 때문에 문자열 리터럴처럼 단순하게 합칠 수 없다.
String result = str1 + str2;
위와 같은 경우, 아래와 같이 최적화된다.
String result = new StringBuilder().append(str1).append(str2).toString();
참고로, 자바 9부터는 StringConcatFactory를 사용해서 최적화를 수행한다. 아무튼 요지는 자바가 최적화를 처리해주기 때문에 지금처럼 간단한 경우에는 StringBuilder를 굳이 사용할 필요가 없다는 것이다. 문자열 더하기 연산 +을 사용해도 충분하다는 것이다.
하지만, 아래와 같이 문자열이 루프 안에 돌아가면서 문자열을 더하는 상황에서는 최적화가 이루어지지 않는다.
package lang.string.builder;
public class LoopStringMain {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
String result = "";
for (int i = 0; i < 100000; i++) {
result += "Hello, Java!";
}
long endTime = System.currentTimeMillis();
System.out.println("result = " + result);
System.out.println("time = " + (endTime - startTime) + "ms");
}
}
// time = 1626ms
왜냐하면, 대략 아래와 같이 최적화되기 때문이다.
String result = "";
for (int i = 0; i < 100000; i++) {
result = new StringBuilder().append(result).append("Hello Java
").toString();
}
정상적으로 최적화되고 있는 것처럼 보이지만, 실은 매우 끔찍한 일이 벌어지고 있다. 반복 횟수만큼 객체를 생성해야 하기 때문이다. 반복문 내에서의 문자열 연결은 런타임에 연결할 문자열의 개수와 내용이 결정된다. 이런 경우에 컴파일러는 얼마나 많은 반복이 일어날지, 각 반복에서 문자열이 어떻게 변할지 예측할 수 없기 때문에 최적화가 어려운 것이다. 직접 확인해봐도 1626ms만큼 시간이 걸린다. 이런 경우에 StringBuilder를 사용하는 것이다.
package lang.string.builder;
public class LoopStringBuilderMain {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
sb.append("Hello, Java! ");
}
String result = sb.toString();
long endTime = System.currentTimeMillis();
System.out.println("result = " + result);
System.out.println("time = " + (endTime - startTime) + "ms");
}
}
// time = 3ms
<정리>
문자열을 합칠 때, 대부분의 경우에는 최적화가 되므로 + 연산을 사용하면 된다. 그리고 경우에 따라 StringBuilder를 직접 사용하는 것이 더 좋을 때가 있다. “반복문에서 반복해서 문자를 연결할 때”, “조건문을 통해 동적으로 문자열을 조합할 때”, “복잡한 문자열의 특정 부분을 변경해야 할 때”, “매우 긴 대용량 문자열을 다룰 때” 이다.
StringBuilder와 똑같은 기능을 수행하는 StringBuffer 클래스도 있다. StringBuffer는 내부에 동기화가 되어 있어서, 멀티 스레드 상황에 안전하지만 동기화 오버헤드로 인해 성능이 느리다. StringBuilder는 멀티 쓰레드 상황에 안전하지 않지만 동기화 오버헤드가 없으므로 속도가 빠르다. StringBuffer와 동기화에 관한 내용은 이후에 멀티 스레드를 학습해야 이해할 수 있다. 지금은 이런 것이 있구나 정도만 알아두도록 하자.
머릿 속에 떠오르는 그 느낌… 그거 맞다. 거두절미하고 아래 코드를 바로 보자.
package lang.string.chaining;
public class ValueAdder {
private int value;
public ValueAdder add(int addValue) {
value += addValue;
return this; // 나의 참조를 반환
}
public int getValue() {
return value;
}
}
위 코드는 값을 누적해서 더하는 기능을 제공하는 단순한 클래스다. 근데 add() 메서드를 보면, 자기 자신(this)의 참조값을 반환한다.
package lang.string.chaining;
public class MethodChainingMain1 {
public static void main(String[] args) {
ValueAdder adder = new ValueAdder();
adder.add(1);
adder.add(2);
adder.add(3);
int result = adder.getValue();
System.out.println("result = " + result);
}
}
// result = 6
여기서는 add() 메서드를 여러 번 사용해서 값을 누적해서 더했지만, 메서드의 반환값은 사용하지 않았다. 이번에는 메서드의 반환값을 사용하는 코드를 살펴보자.
package lang.string.chaining;
public class MethodChainingMain2 {
public static void main(String[] args) {
ValueAdder adder = new ValueAdder();
ValueAdder adder1 = adder.add(1);
ValueAdder adder2 = adder1.add(2);
ValueAdder adder3 = adder2.add(3);
int result = adder3.getValue();
System.out.println("result = " + result);
System.out.println("adder = " + adder);
System.out.println("adder1 = " + adder1);
System.out.println("adder2 = " + adder2);
System.out.println("adder3 = " + adder3);
}
}
/*
result = 6
adder = lang.string.chaining.ValueAdder@3f99bd52
adder1 = lang.string.chaining.ValueAdder@3f99bd52
adder2 = lang.string.chaining.ValueAdder@3f99bd52
adder3 = lang.string.chaining.ValueAdder@3f99bd52
*/
보다시피, result 값의 출력은 전과 동일하다.


add() 메서드는 자기 자신(this)의 참조값을 반환한다. 해당 반환값을 adder1, adder2, adder3에 보관했다. 따라서 add() 메서드가 자기 자신을 참조하고 있기 때문에 이 셋은 모두 같은 참조값을 사용하고 있는 것이다. 너무 불편해 보인다...
이번엔 반환된 참조값을 새로운 변수에 담아서 보관하지 않고, 바로 메서드 호출에 사용해보도록 하자.
package lang.string.chaining;
public class MethodChainingMain3 {
public static void main(String[] args) {
ValueAdder adder = new ValueAdder();
int result = adder.add(1).add(2).add(3).getValue();
System.out.println("result = " + result);
}
}
// result = 6
메서드 호출의 결과로 자기 자신의 참조값을 반환하면, 반환된 참조값을 사용해서 메서드 호출을 계속 이어갈 수 있다. 코드를 보면 . 을 찍고 메서드를 계속 연결해서 사용한다. 마치 메서드가 체인으로 연결된 것처럼 보인다. 이러한 기법을 “메서드 체이닝” 이라 한다. 물론 실행 결과도 기존과 동일하다. 기존에는 메서드를 호출할 때 마다 계속 변수명에 . 을 찍어야 했다.
ex) adder.add(1), adder.add(2)
메서드 체이닝 방식은 메서드가 끝나는 시점에 바로 .을 찍어서 변수명을 생략할 수 있다. 메서드 체이닝이 가능한 이유는 자기 자신의 참조값을 반환하기 때문이다. 이 참조값에 .을 찍어서 바로 자신의 메서드를 호출할 수 있다.
메서드 체이닝 기법은 코드를 간결하고 읽기 쉽게 만들어준다.
StringBuilder는 메서드 체이닝 기법이라는 것을 제공한다. StringBuilder의 append() 메서드를 보면 자기 자신의 참조값을 반환하는 것을 볼 수 있다.
public StringBuilder append(String str) {
super.append(str);
return this;
}
StringBuilder에서 문자열을 변경하는 대부분의 메서드(insert(), delete(), reverse()등)도 메서드 체이닝 기법을 제공하기 위해 자기 자신을 반환한다.
앞서 StringBuilder를 사용한 코드는 아래와 같이 개선할 수 있다.
package lang.string.builder;
public class StringBuilderMain1_2 {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
String string = sb.append("a").append("b").append("c").append("d")
.insert(4, "Java")
.delete(4, 8)
.reverse()
.toString();
System.out.println("string = " + string);
}
}
// string = dcba