2022-08-14 작성

문자열은 불변한다(String is Immutable)

자바에서 문자열을 만들 때 아래처럼 2가지 방법을 이용할 수 있다.

String str1 = "개발새발";              // ""을 이용하여 문자열 생성 (String literal)  
String str2 = new String("개발새발");  // new 연산자로 문자열 생성 (String Object)

위의 출력결과는 동일하며, 사람들은 흔히 첫번째 방법으로 많이 쓴다. 그런데 메모리에 저장되는 공간은 엄연히 다르다는 것을 알고 있는가?

첫번째 방식인 String literal으로 생성하면 문자열은 힙영역의 String Pool에 저장되어 문자열이 동일하면 저장소를 공유할 수 있다. 반면 두번째 방식인 new 연산자로 생성하면 문자열은 힙영역에 저장되어 문자열이 같더라도 저장소 공유가 불가능하다. 이 차이점을 기억하며 아래 예제를 살펴보자.

문자열을 저장하는 공간

모든 변수들은 "개발새발"이라는 동일한 문자열을 만들고 있지만, 실제로 힙 메모리에 저장되는 공간은 다르다.

str1, str2, str3 변수는 string literal 방식으로 생성했기 때문에 공통의 String Pool에 저장되었으며 str4, str5 변수는 new 연산자로 생성했기 때문에 문자열이 동일함에도 불구하고 저장소에 공유되지 않고 힙 영역에 각각 별도로 저장되는 모습이다. 다른 예제를 살펴보자.

이번에는 str6, str7 변수에 다른 문자열을 추가한 모습이다. 하나씩 차분히 살펴보면 어렵지 않게 이해할 수 있을 것이다. 만약 new 연산자를 통해 문자열을 생성하더라도 String Pool에 등록하고자 한다면 intern() 메서드를 이용하여 등록하면 된다.

문자열 비교시 ==와 equals() 차이점

동일한 문자열이라고 해도, 문자열을 생성하는 방식에 따라 저장되는 공간이 다를 수 있음을 알게 되었다. 따라서 위 예제처럼 두 변수를 '==' 연산자를 이용하여 비교하면 false가 출력이 된다. 이 결과를 통해 문자열은 비교할 때 '==' 연산자를 이용하면 안 된다는 것을 알 수 있다.

문자열을 비교하려면 equals() 메서드를 사용해야 한다. 위 예제에서 문자열을 비교할 때 '=='가 아닌 'equals()'을 이용하면 결과는 전부 true가 나오게 된다.

 '=='는 두 객체가 동일한 객체인지 주소값을 비교하지만 'equals()'는 두 객체의 값 자체를 비교하기 때문이다.

equals() 사용시 리스크 줄이기

String str = "개발새발";

if ("개발새발".equals(str)) { ... }  // 좋은 예
if (str.equals("개발새발")) { ... }  // 나쁜 예

문자열을 비교할 때 equals() 사용시 기억해야 할 점이 있다. equals() 메서드의 앞쪽에는 하드코딩같이 확실한 값을 배치하고, 뒤쪽에는 null일 수도 있는 값을 배치해야 한다.

만약 str 변수에 값을 할당하지 않는다면 NullPointerException 에러가 발생한다. (실제로 NullPointerException 에러가 발생하는지 테스트하려면 str 변수에 null을 할당하면 된다)

이를 방지하기 위한 손쉬운 방법은 "하드코딩".equals(str) 같은 형태로 코딩하는 것이다. 이런 형태로 구현한다면 equals()함수에 들어가는 파라미터 값을 equals 함수 내부적으로 널체크(null check)를 해주기 때문에 런타임시 Exception을 방지하게 된다. 

실제 개발을 하면서 생각보다 이를 지키지 않는 소스가 꽤 많았다. 별거 아니지만 혹시 모르는 일을 방지하기 위해 꼭 습관화해두자.

String, StringBuilder, StringBuffer 차이

String 말고도 StringBuilder, StringBuffer를 이용하여 문자열을 저장할 수 있다. 3개 클래스의 차이점을 알기 위해 문자열을 합쳐보고 어떤 클래스의 성능이 더 유용한지 살펴보자.

1) 불변의 차이

먼저 String 클래스는 불변하지만 StringBuilder, StringBuffer 클래스는 변할 수 있다는 차이점이 있다. 아래는 String 클래스의 concat 메서드를 이용하여 문자열을 합친 예제이다.

String str = "개발";
str = str.concat("새발");
str = str.concat("하이");

System.out.println(str); // 출력 결과 : 개발새발하이

String 클래스는 문자열을 합칠 때마다 기존의 "개발" 객체를 바꾸는 게 아니라 새로운 "개발새발하이" 객체를 만든다. 객체가 한번 생성되면, 할당된 메모리 공간이 변하지 않고 연산할 때마다 새로운 문자열로 저장하고 그 객체를 참조한다. 따라서 문자열 연산이 많으면 성능이 좋지는 않지만 간단하게 사용할 수 있고 동기화에 대해 신경쓰지 않아도 된다.

이번에는 StringBuilder, StringBuffer 클래스를 살펴보자.

StringBuffer sb = new StringBuffer();  // StringBuffer 객체 sb 생성
sb.append("개발");
sb.append("새발");
sb.append("하이");

System.out.println(sb.toString()); // 출력 결과 : 개발새발하이

StringBuilder, StringBuffer 클래스는 연산시 주소의 변경 없이 기존 객체의 공간이 부족하게 되면 기존 버퍼의 크기를 증가 시키면서 새로운 문자열을 더한다. 따라서 문자열 연산이 많은 경우 String 클래스보다 성능이 나은 편이다.

2) 동기화의 차이

그렇다면 StringBuilder, StringBuffer 클래스의 차이는 무엇일까? 위 예제에서는 StringBuilder를 이용했지만 StringBuffer로 변경해도 무관하다. 또한 둘 다 객체의 공간이 부족해지면 기존의 버퍼 크기를 늘리면서 유연하게 동작하는 것도 동일하다.

StringBuffer는 멀티스레드 환경에서의 동기화를 지원하지만 StringBuilder는 단일스레드 환경에서만 동기화를 지원한다. 단일스레드 환경에서 StringBuffer를 사용해서 문제가 되는 것은 아니지만 동기화와 관련된 처리로 인해 StringBuilder에 비해 성능이 좋지 않다.

※ JDK 1.5 버전 이후에는 String 객체를 사용하더라도 컴파일 단계에서 StringBuilder로 컴파일되도록 변경되었다. 따라서 일반적으로 String 클래스를 활용해도 StringBuilder와 성능상으로 차이가 없다고 한다.

References