우리는 JAVA 개발을 하다 보면 문자열을 사용하는 String 객체를 정말 많이, 자주 사용합니다. 그러나 자주 사용하는 만큼 우리는 String에 대해서 얼마나 많이 알고있을까요? String 클래스의 내부 구조는 어떻게 생겼을까요?
저는String에 대해 깊게 공부를 해본 이후로 String을 대하는 방식이 많이 달라졌습니다. 그리고 그 이후로는 어떤 클래스를 쓸 때 거의 항상 클래스 내부를 뒤져보면서 내부 구조나 작동 방식을 알아가는 것이 즐거워졌습니다. 여러분도 이 글을 본 이후로 String 뿐만 아니라 클래스에 대해 보는 시각과 태도가 달라졌으면 좋겠습니다.
일단 String의 내부 코드를 보도록 하겠습니다. 클래스의 길이가 길기 때문에 일부만 발췌하도록 하겠습니다.
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
private final byte coder;
private int hash; // Default to 0
...
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String aString = (String)anObject;
if (coder() == aString.coder()) {
return isLatin1() ? StringLatin1.equals(value, aString.value)
: StringUTF16.equals(value, aString.value);
}
}
return false;
}
}
기본적으로 String 은 immutable, 즉 불변합니다. 이는 위에 있는 String 클래스 내부의 private final btye[] value; 를 보면 알 수 있기도 하지만 더 정확하게는 문서를 보면 알 수 있습니다.
The String class represents character strings. All string literals in Java programs, such as "abc", are implemented as instances of this class.
(String 클래스는 문자열을 표현한다. 자바 프로그램에서 "abc" 같은 모든 문자열 리터럴(변하지 않는 데이터)은 이 클래스의 인스턴스로 구현되어있다. )
Strings are constant; their values cannot be changed after they are created. String buffers support mutable strings. Because String objects are immutable they can be shared.
(String은 불변하다. 한번 만들어지면 변경이 불가능하다. String 버퍼는 가변 String을 지원한다. String 객체는 변경할 수 없기때문에 공유될 수 있다. )
출처 : 오라클 JAVA String 문서
문서에서 보신 것처럼 String은 불변합니다. 하지만 이상합니다. 분명 우리는 아래처럼 String 값을 변경할 수 있습니다.
String str = "abc";
str = "def";
"abc"라는 문자열은 어떻게 되었을까요? 왜 String은 불변하다면서 str의 값은 "def"로 변경이 되었을까요?
문서를 더 읽어봅시다.
For example:
String str = "abc";
is equivalent to:
char data[] = {'a', 'b', 'c'};
String str = new String(data);
위 문서에 따르면 "abc" 로 값을 할당할 때 새로운 객체가 만들어지고, "def"로 할당해줄 때 또 새로운 객체가 만들어집니다.
즉, String에 값을 할당해주는 모든 행위는 객체를 새롭게 만드는 것입니다. 물론 str = str + "def" 또한 값을 할당하는 것이므로 새로운 객체가 만들어질 것입니다. 그러면 이렇게 생각하실 수도 있습니다. '그냥 str 뒤에 "def"라는 문자열만 붙여주면 되는 것 아닌가?' 위에서 우리는 String의 값이 불변하다는 것을 알게 되었습니다. 그러면 뒤에 다른 문자열을 붙일 수 없다는 것 또한 알 수 있게 되겠죠.
만약에 String 객체에 빈번하게 값을 변경해주면 어떻게 될까요? 아래 소스코드처럼요.
String temp = "abc";
temp = temp + "def";
temp = temp + "ghi";
temp = temp + "jkl";
temp = temp + "123";
temp = temp + "456";
temp = temp + "789";
위 소스코드가 실행되면 매번 새로운 객체를 만들어서 할당해주겠죠. 매번 새로운 객체를 만들어서 할당해주면 그만큼 unreachable한 데이터만 쌓이고 가비지 컬렉터가 그만큼 자주 실행되게 됩니다. (가비지 컬렉터의 작동원리에 대해서는 나중에 더 자세히 알아보겠습니다. ) 가비지 컬렉터가 자주 실행되면 성능 이슈가 발생할 수밖에 없습니다.
그럼 여기서 우리는 의문이 듭니다. "왜 굳이 이렇게 만들었을까?", "왜 불변하게 만들었을까?", "JAVA를 만든 제임스 고슬링은 바보가 아닐텐데 왜 이렇게 구현했을까?" 하는 의문이 말이죠.
여러분은 처음에 JAVA를 배울 때 기본 자료형으로 int, boolean, long 은 클래스가 아닌데 왜 유독 String만 클래스인지 생각해보셨나요?
String은 특별한 클래스입니다. String 클래스는 JAVA에서 가장 많이 쓰이는 클래스 중 하나이기 때문에 특별취급을 받습니다. 그만큼 메모리 관리에도 신경을 써주어야 하고, 보안에 취약하면 안되고, 멀티 쓰레드 환경에서도 유연하게 사용될 수 있고, 이 조건을 충족하면서 성능도 좋아야 합니다.
아래는 Baeldung에서 설명하는 JAVA의 String이 왜 불변한지에 대한 4가지 이유입니다. (문서에서는 5가지로 적혀있지만, 마지막 이유는 정리하는 내용이므로 생략했습니다. )
우리는 생성자를 통해 클래스를 객체로 만들 수 있습니다. 아래처럼요.
Student student = new Student();
그러나 String은 위 방법 말고도 한 가지 더 있습니다. 바로 리터럴로 선언하는 방식인데요. 우리가 가장 많이 사용하는 방식일것입니다.
String str = "abc";
위에서 저는 String이 특별한 클래스라고 말씀드렸습니다. 왜 특별한 클래스일까요? String은 Heap 메모리 영역에 String만을 위한 전용 메모리 공간이 있습니다. 우리는 이를 String contatnt pool(문자열 상수 풀)이라고 부릅니다. 여기서 우리는 눈치채야 합니다. String만의 전용 공간이 있고, 클래스인 String의 선언 방식이 생성자를 통한 선언방식 말고 리터럴로 선언하는 방식이 있다면 둘이 연관이 있다는 것입니다.
String을 리터럴로 선언하면 JVM은 String constant pool에서 같은 값이 있는지 찾아봅니다. 만약 같은 값이 있다면 그 String은 기존에 있던 값을 바라보게 됩니다. 즉, 아래 이미지 처럼 되는 것이지요.
String을 일반 객체처럼 사용하게 된다면 같은 값을 가지게 되더라도 다른 메모리 영역을 차지하게 됩니다. 그렇게 되면 자주 사용하는 String인데 같은 값을 사용하는데도 다른 메모리영역을 가지게 된다면 이는 크나큰 메모리의 낭비가 됩니다. JAVA는 이를 방지하기 위해 리터럴로 String을 선언하여 String contant pool에 저장되어 있다면 이를 공유하는 방식으로 메모리 공간을 절약했습니다.
String은 JAVA에서 정말 광범위하게 사용되는 클래스이기 때문에 민감한 정보를 담을 수밖에 없습니다. 이 민감한 정보란, 인증을 위한 사용자 ID, 비밀번호, 네트워크 연결을 위한 url 등의 정보를 말합니다. 이런 이유로 인해 String 클래스의 보안이 자바 어플리케이션의 보안 중추 중 하나라고 볼 수 있습니다. 아래 코드를 예시로 들겠습니다.
void criticalMethod(String userName) {
// perform security checks
if (!isAlphaNumeric(userName)) {
throw new SecurityException();
}
// do some secondary tasks
initializeDatabase();
// critical task
connection.executeUpdate("UPDATE Customers SET Status = 'Active' " +
" WHERE UserName = '" + userName + "'");
}
만약 String이 가변이었다면 입력받은 데이터를 검증하더라도 업데이트 될때마다 데이터의 무결성을 보장할 수 없습니다. 그렇기 때문에 위의 코드처럼 크리티컬한 일을 하는 메소드에서 매번 검증을 해야 할수도 있고 심지어 검증을 해도 그 값이 검증 후에도 그 값인지 확신할 수 없습니다.
다중의 스레드가 접근할 때에도 변하지 않기 때문에 불변성은 String을 스레드 세이프하게 만들어줍니다. 이 스레드 세이프는 추후에 자세하게 설명하겠지만 지금으로서는 멀티 스레드 프로그래밍에서 여러 스레드가 한 변수나 함수, 객체에 접근할 때 프로그램의 실행에 문제가 없다는 의미로 해석해도 되겠습니다. 이걸 통해서 우리는 불변 객체는 일반적으로 여러 스레드에서 접근할 수 있고, 동시에 스레드 세이프 하다는 것을 알게 됩니다. 또한 스레드가 값을 변경하면 동일한 값을 수정하는 것 대신 새 문자열이 이전에 말했던 String constant pool 에 생성되기 때문에 스레드 세이프합니다. 따라서 문자열은 다중 스레딩에 스레드 세이프하다고 할 수 있습니다. 이는 추후에 멀티 스레드 프로그래밍을 배울 때 유용하게 써먹을 수 있을거라 생각합니다.
String 객체들은 자주 자료구조로 사용되기 때문에 HashMap이나 HashTable, HashSet 등의 해쉬 구현체에서 광범위하게 사용됩니다. 이러한 Hash 구현체에서 명령어를 수행할 때 bucket을 채우면서 매우 자주 hashCode() 메소드를 호출합니다. 여기서 bucket이란 Hash 구현체 클래스들에서 사용하는 개념으로, 간단하게 말해서 Hash가 가진 데이터를 저장하는 공간을 말합니다. 이 불변성은 String 객체의 값이 변하지 않음을 보장하므로 아래처럼 hashCode() 메소드를 오버라이딩 하여 최초로 hashCode() 메소드를 호출할 때 전역변수인 hash에 값을 할당하고 그 이후로는 전역변수 hash를 호출하여 해시코드에 대한 캐시 기능이 구현되어 있습니다. 모든 클래스는 Object 클래스를 상속받기 때문에 hashCode(), equals() 같은 메소드를 오버라이딩 할 수 있으므로 String도 마찬가지로 오버라이딩 할 수 있습니다.
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
hash = h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
}
return h;
}
이는 다시말해, String 객체를 사용하는 Hash 구현체를 사용하는 컬렉션의 성능을 향상시킬 수 있습니다.
오늘 우리는 가장 많이 사용하는 클래스 중 하나인 String 클래스에 대해서 알아보았습니다. String을 잘 쓰는 방법은 무엇일까요? 저는 각 클래스를 잘 쓰기 위해서는 그 클래스의 특성, 장/단점을 잘 파악하고 있어야 한다고 생각합니다. 추후에 더 자세히 설명하겠지만 가령 예를 들어서 String이 있는데 StringBuilder, StringBuffer는 왜 사용할까요? 어차피 String만 사용하면 문자열이 만들어지는데 왜 굳이 StringBuilder, StringBuffer를 만들어서 사용할까요? 그 이유는 위에서 말씀 드렸듯이 String은 값이 할당될 때마다 새로운 객체를 만들기 때문에 성능 이슈가 발생할 수 있습니다. 따라서 String 객체의 값이 빈번하게 바뀐다면 StringBuilder나 StringBuffer를 사용하는 것이 성능 이슈에 도움이 되기 때문입니다. 이런 경우 처럼 저는 클래스의 내부까지도 어느정도 파악을 해야 성능이라던지, 시간복잡도 등에 있어서 최적화된 프로그램을 만들 수 있다고 생각합니다.
여러분도 이 글을 보면서 String에 대한 시선이 달라졌을거라 생각합니다. 최근의 고도화된 개발툴은 클래스를 디컴파일 해주거나, 빌드 툴을 통해 소스코드를 통째로 다운로드할 수 있으니 내부 구조나 동작 원리가 궁금하다면 가끔씩 클래스 내부를 뒤져보는 것도 아주 좋은 생각입니다.
읽어주셔서 감사합니다.
감사합니다. 블로그에 포스팅할 때 참고하고 출처 남기겠습니다