우선, 글을 작성하기 전 이 글의 모든 내용은 김영한님의 JAVA 강의를 바탕으로 함을 알립니다.
JAVA에서 문자는 대표적으로 기본형인char와 참조형인String 타입을 사용하여 표현한다. 특징은 다음과 같다.
char char[]를 사용Stringpublic final class String {
//문자열 보관
private final char[] value;// 자바 9 이전
private final byte[] value;// 자바 9 이후
//여러 메서드
public String concat(String str) {...}
public int length() {...}
...
}
String은 참조형이기에 클래스의 고유 속성(필드)와 기능(메서드)를 갖는다. (주요 메서드는 포스팅 후반부에 작성할 예정이다) 따라서, 참조형은 변수에 객체의 메모리 참조값이 저장되기에 +와 같은 사칙연산은 불가능하다. 하지만 String은 가능하다.
public class StringConcatMain {
public static void main(String[] args) {
String a = "hello";
String b = " java";
String result1 = a.concat(b);
String result2 = a + b;
System.out.println("result1 = " + result1);
System.out.println("result2 = " + result2);
}
}
String의 경우 원칙적으로는 클래스에서 제공하는 concat()이라는 메서드를 통해 문자열을 더해야하한다. 하지만 result2 = a + b에서 a와 b는 문자열임에도 불구하고 +연산을 통해 hello java라는 결과를 출력한다. 그 이유는 String을 너무 자주 사용하기 때문에 java가 편의상 + 연산을 제공하기 때문이다.
String 클래스는 ==가 아닌 항상 equals() 비교를 해야만한다.
동일성(identity)와 동등성(equality)에 대해 복습해보자.
동일성(Identity): ==연산자를 사용해서 두 객체의 참조가 동일한 객체를 가리키고 있는지 확인동등성(Equality): equals() 메서드를 사용하여 두 객체가 논리적으로 같은지 확인 그러면 String은 왜 반드시 동등성 비교를 해야만할까? 이를 알아보기 전에 짚고 넘어가야하는 사항이 있다.
String str1 = new String("hello");
String str2 = "hello";
참조형인 클래스는 다른 참조형과 동일하게 new String(문자열)을 통해 객체를 생성하는게 원칙이나, String str2 = "hello"이 가능하다. 어떻게 가능한걸까?

String은 자주 사용되는 만큼 JAVA에서 제공하는 편의가 많다. 문자열 풀에 대해 알아보자
String인스턴스를 미리 생성한다. 같은 문자열인 경우에는 새로 만들지 않는다String str2 = "hello";와 같이 문자열 리터럴을 사용하면 문자열 풀에서 미리 생성한 "hello"를 갖고 있는 인스턴스를 찾아 해당 인스턴스의 참조값(x003)을 반환한다. String str3 = "hello";는 str2와 동일하게 "hello" 문자열 리터럴 상수를 사용하기 때문에 동일한 참조값이 변수에 저장되어 str2와 str3는 같은 객체를 참조하게 된다. 위와 같은 원리로 문자열 풀에 의해 문자를 사용하는 경우 메모리를 효율적으로 사용할 수 있다.
문자열 리터럴과 문자열 풀의 개념을 바탕으로 다시 동일성과 동등성에 대한 예제를 살펴보자
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)); //new String() == 비교: false
System.out.println("new String() equals 비교: " + (str1.equals(str2))); //new String() equals 비교: true
String str3 = "hello";
String str4 = "hello";
System.out.println("리터럴 == 비교: " + (str3 == str4)); //리터럴 == 비교: true
System.out.println("리터럴 equals 비교: " + (str3.equals(str4))); //리터럴 equals 비교: true
}
}
위의 예제에서 문자열 리터럴을 사용한 문자열 활용 부분을 보자.
str3와 str4는 동일한 문자열 리터럴인 "hello"을 저장하고 있기에 문자열 풀에 의해 동일한 메모리 참조값을 갖게 된다.
즉, str3와 str4는 동일한 객체를 가리키고 이는 동일성 비교(identity)가 true임을 의미한다. 또한 두 문자열 모두 "hello"로 논리적으로도 동일하기에 동등성 비교(equality)도 true가 된다.
str1과 str2는 new를 통해 서로 다른 인스턴스를 생성하였기에 동일성 비교는 false, 동등성 비교는 true가 된다.

정리하자면, String은 new를 통해 인스턴스를 생성하는 정석적인 방법과 문자열 풀을 사용한 방법을 통해 인스턴스를 생성하고 문자열을 저장한다. 이때 문자열 풀을 사용한 방식은 동일성 비교와 동등성 비교를 구분할 수 없기 때문에 new를 통해 인스턴스를 생성하는 방식에 맞추어 동등성 비교를 해야한다는 결론이 나온다.
즉, String은 equals()를 통한 동등성 비교를 진행해야 한다.
String은 대표적으로 자주 사용되는 불변객체이다. 따라서, String은 내부의 문자열 값 변경이 불가능하다. (불변 객체 복습을 통해 복습하자!)
String 클래스 소스 코드의 일부를 확인해보자.
public final class String {
//문자열 보관
private final byte[] value;
//여러 메서드
public String concat(String str) {...}
public int length() {...}
...
}
String 클래스의 문자열 보관 부분은 final키워드를 통해 값이 고정된 것을 확인할 수 있다. 그렇다면 String 클래스의 concat() 메서드를 살펴보자
// concat()
public String concat(String str) {
if (str.isEmpty()) {
return this;
}
return StringConcatHelper.simpleConcat(this, str);
}
//simpleConcat()
static String simpleConcat(Object first, Object second) {
String s1 = stringOf(first);
String s2 = stringOf(second);
if (s1.isEmpty()) {
// newly created string required, see JLS 15.18.1
return new String(s2);
}
if (s2.isEmpty()) {
// newly created string required, see JLS 15.18.1
return new String(s1);
}
// start "mixing" in length and coder or arguments, order is not
// important
long indexCoder = mix(initialCoder(), s1);
indexCoder = mix(indexCoder, s2);
byte[] buf = newArray(indexCoder);
// prepend each argument in reverse order, since we prepending
// from the end of the byte array
indexCoder = prepend(indexCoder, buf, s2);
indexCoder = prepend(indexCoder, buf, s1);
return newString(buf, indexCoder);
}
concat() 메서드를 보면 String 타입의 문자열을 매개변수로 받고 최종적으로 두 문자열을 연결하는 로직을 거쳐서 반환값으로 new String(문자열)형태로 새로운 String타입의 객체를 생성하여 반환하는 것을 알 수 있다. 이는 대표적인 불변 객체에서 나타나는 특징이다. 불변 객체의 경우 기본적으로 객체 내의 속성과 필드가 고정되어 있기 때문에 메서드를 통해 불가피하게 값을 변경해야하는 로직을 구현해야하는 경우 클래스와 동일 타입의 새로운 객체를 통해 이를 실현한다.
그렇다면 String은 왜 불변으로 설계된 것일까?
String str3 = "hello";
String str4 = "hello";

Java는 문자열 풀을 제공하여 String을 최적화한다. 문자열 풀에 의해 str3와 str4는 문자열풀에 "hello"를 담고 있는 객체의 동일한 메모리 참조값을 저장한다. 만약 String이 가변 객체라면 str3가 참조하는 문자를 변경하면 str4가 참조하는 문자도 같이 변경되는 사이드 이펙트가 발생한다. 따라서 String은 불변 객체로 설계되었다.
불변인 String의 단점에 대해 살펴보자. 두 문자를 더하는 경우를 살펴보자
"A" + "B"
String("A") + String("B")
String("A").concat(String("B"))
new String("AB")
String은 불변이기에 내부의 값을 변경할 수 없고, 변경해야하는 사항은 새로운 String 타입으로 반환해주어야한다. 그렇다면 더 많은 문자를 연결해야하는 경우는 어떨까?
String str = "A" + "B" + "C" + "D";
String str = String("A") + String("B") + String("C") + String("D");
String str = new String("AB") + String("C") + String("D"); // 추가1
String str = new String("ABC") + String("D"); // 추가2
String str = new String("ABCD"); // 추가3
문자를 4개를 더하는데 객체만 3개가 만들어진다. 즉, 문자를 자주 더하는 과정은 많은 String 객체를 만들고 많은 GC(Garbage Collect) 과정을 거치게 되고 그 만큼 CPU, 메모리등의 리소스를 많이 사용해야함을 의미한다. 매우 비효율적이다. 이를 해결하기 위해 Java는 가변 String인 StringBuilder를 제공한다.
예제를 보자.
public class StringBuilderMain1 {
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"); // 4번 인덱스에 문자열을 삽입
System.out.println("insert = " + sb);
sb.delete(4,8);
System.out.println("delete = " + sb);
sb.reverse();
System.out.println("reverse = " + sb);
//StringBuilder -> String
String string = sb.toString();
System.out.println("string = " + string);
}
}
StringBuilder의 경우 하나의 객체 생성을 통해 append,insert,delete,reverse와 같은 제공되는 메서드를 통해 추가적인 객체 생성 없이 문자열 변경이 가능하다. 메모리 효율면에서 상당히 훌륭하지만 사이드 이펙트를 조심해야한다. 따라서, StringBuilder는 보통 문자열을 변경하는 동안 사용하고 toString()을 통해 최종적으로 String타입으로 변경하는 것을 권장한다.
Java는 문자열 리터럴 부분을 직접 더하는 것을 최적화 해준다.
//컴파일 전
String helloWorld = "Hello, " + " World!";
//컴파일 후
String helloWorld = "Hello, World!";
문자열 변수의 경우 문자열 리터럴과 달리 실행중인 runtime에서 변수에 어떤 문자열이 존재하는 지 알 수 없기에 단순한 +연산으로 문자열을 합칠 수 없다.
String result = str1 + str2;
String reuslt = new StringBuilder().append(str1).append(str2).toString();
따라서, String변수 최적화의 경우 내부적으로는 두 번째와 같이 StringBuilder를 사용하여 최적화를 진행한다.
반복문을 통해 지속적으로 문자열을 더하는 예제를 살펴보자.
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"); // 2490ms
}
}
위의 예제를 이해하기 쉽게 StringBuilder를 통해 최적화하는 것을 나타내면 다음과 같다
String result = "";
for (int i = 0; i < 100000; i++) {
result = new StringBuilder().append(result).append("Hello Java").toString();
}
코드를 보면 String 최적화가 잘 이루어지는 것처럼 보이지만, 반복문에 의해 한 사이클마다 StringBuilder의 객체가 생성되는 것을 알 수 있다. 즉, 연결될 문자열의 개수(100,000)만큼 객체가 생성되고 GC에의해 제거되고를 반복한다. 이 경우는 컴파일러가 최적화의 범위를 정확히 알 수 없고 결국 최적화가 최적화가 아닌 상황이 되버린다. 어떻게 해야할까?
StringBuilder를 직접 사용하자
public class LoopStringMain {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
StringBuilder sb = new StringBuilder(); // 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"); // 3ms
}
}
코드처럼 result을 String이 아닌 StringBuilder 타입으로 직접 생성하고 반복문에서 append()메서드를 통해 문자열을 반복문 횟수만큼 추가한다면 하나의 객체만 생성하고 추가가 가능하다.
String 최적화가 가능하기에 +연산을 사용한다.StringBuilder를 직접 사용해야하는 경우
StringBuilder의 append() 메서드의 소스 코드를 살펴보자.
@Override
@IntrinsicCandidate
public StringBuilder append(String str) {
super.append(str);
return this;
}
public AbstractStringBuilder append(String str) {
if (str == null) {
return appendNull();
}
int len = str.length();
ensureCapacityInternal(count + len);
putStringAt(count, str);
count += len;
return this;
}
append() 메서드를 살펴보면 최종적으로 this 즉, 자기 자신에 대한 참조값을 반환하는 것을 알 수 있다. 즉, 메서드를 호출할 때마다 append와 관련된 로직을 처리하고 다시 자기 자신에 대한 참조값을 통해 자기 자신을 가리켜 자기 자신 객체의 필드와 멤버에 접근할 수 있다.

StringBuilder의 대부분의 메서드는 반환값으로 자기자신을 반환하고 이러한 기능으로 인해 새로운 변수 선언 없이 메서드를 순서대로 호출할 수 있게 되었다. 이는 코드의 가독성을 높히고 메모리 사용도 효율적이다. 이를 메서드 체이닝 기법이라고 한다.
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
}
}