String 클래스는 잘 사용하면 상관이 없지만, 잘못 사용하면 메모리에 많은 영향을 준다.
대부분의 웹 기반 시스템은 DB에서 데이터를 갖고 와서 그 데이터를 화면에 출력하는 시스템이기 때문에, 쿼리 문장을 만들기 위한 String 클래스와 그 결과를 처리하기 위한 Collection 클래스를 가장 많이 사용하게 된다.
다음은 일반적으로 사용하는 쿼리 작성 문장이다.
String strSQL = "";
strSQL += "select * ";
strSQL += "from ( ";
strSQL += "select A_column, ";
strSQL += "B_column ,";
// 중간 생략
요즘은 myBatis, Hibernate와 같은 데이터 메핑 프레임워크를 사용하지만, 예전에는 보통 이렇게 쿼리를 작성했다. 꼭 쿼리 문장이 아니더라도, 문자열을 다루는 경우는 많을 것이다. 그런데 이렇게 쿼리를 작성하면, 개발 시에는 좀 편할지 몰라도 메모리를 많이 사용하게 된다는 문제가 있다. 총 400라인을 수행한다고 가정하면 실행 결과는 다음과 같다.
구분 | 결과 |
메모리 사용량 | 10회 평균 약 5MB |
응답 시간 | 10회 평균 약 5ms |
이 소스 코드를 StringBuilder로 변경하면 다음과 같다.
String Builder = new StringBuilder();
strSQL.append("select * ");
strSQL.append("from ( ");
strSQL.append("select A_column, ");
strSQL.append("B_column, ");
// 중간 생략
변경 후 수행 결과는 다음과 같다.
구분 | 결과 |
메모리 사용량 | 10회 평균 약 371KB |
응답 시간 | 10회 평균 약 0.3ms |
간단하게 두 클래스의 생성자와 메서드를 정리해보자.
생성자 | 설명 |
StringBuffer() | 아무 값도 없는 StringBuffer() 객체를 생성한다. 기본 용량은 16개의 char이다. |
StringBuffer(CharSequence seq) | CharSequence를 매개변수로 받아 그 seq 값을 갖는 StringBuffer를 생성한다. |
StringBuffer(int capacity) | capacity에 지정한 만큼의 용량을 갖는 StringBuffer를 생성한다. |
StringBuffer(String str) | str의 값을 갖는 StringBuffer를 생성한다. |
package com.perf.string;
public class StringBufferTest1 {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
sb.append("ABCDE");
StringBufferTest1 sbt = new StringBufferTest1();
sbt.check(sb);
}
public void check(CharSequence cs) {
StringBuffer sb = new StringBuffer(cs);
System.out.println("sb.length=" + sb.length());
}
}
주로 사용하는 두 개의 메서드를 알아보자. 바로 append() 메서드와 insert() 메서드이다. 이 두 가지 메서드는 여러 가지 타입의 매개변수를 수용하기 위해서 다음의 타입들을 매개변수로 사용할 수 있다.
append() 메서드는 말 그대로 기존 값의 맨 끝 자리에 넘어온 값들을 덧붙이는 작업을 수행하고, insert() 메서드는 지정된 위치 이후에 넘어온 값들을 덧붙이는 작업을 수행한다. 만약 insert() 메서드를 수행할 때 지정한 위치까지 값이 할당되지 않으면 StringIndexOutOfBoundsException이 발생한다.
package com.perf.string;
public class StringBufferTest2 {
public static void main(String[] args) {
StringBuffer sb = new StringBuffer();
// 이렇게 사용해도 되고
sb.append("ABCDE");
sb.append("FGHIJ");
sb.append("KLMNO");
// 이렇게 사용해도 된다.
sb.append("ABCDE")
.append("FGHIJ")
.append("KLMNO");
// 하지만 이렇게 사용하면 안된다.
sb.append("ABCDE"+"="+"FGHIJ");
sb.insert(3, "123");
System.out.println(sb);
}
}
append() 메서드를 사용할 때 위의 코드처럼 append() 메서드 내에서 +를 이용해 문자열을 더하면 StringBuffer를 사용하는 효과가 전혀 없다. 그러므로 되도록으로 append() 메서드를 이용하여 문자열을 더해야 한다.
<%
final String aValue = "abcde";
for(int outLoop=0;outLoop<10;outLoop++) {
String a = new String();
StringBuffer b = new StringBuffer();
StringBuilder c = new StringBuilder();
for(int loop=0;loop<10000;loop++) {
a+=aValue;
}
for(int loop=0;loop<10000;loop++) {
b.append(aValue);
}
String temp = b.toString();
for(int loop=0;loop<10000;loop++) {
c.append(aValue);
}
String temp2 = c.toString();
}
%>
OK
<%= System.currentTimeMillis() %>
소스를 JSP로 만든 이유는 이 코드를 java 파일로 만들어 반복 작업을 수행할 경우, 클래스를 메모리로 로딩하는 데 소요되는 시간이 발생하기 때문이다. 그래서 JSP로 만들어서 최초에 이 화면을 호출했을 때의 응답 시간 및 메모리 사용량은 측정에서 제외하고, 두 번째 호출부터 10회 반복 수행한 결과의 누적 값을 구하였다.
위 코드에 대해 간단히 살펴보자.
String, StringBuffer, StringBuilder 셋 중 어느 것이 가장 빠르고 메모리를 적게 사용할까? 총 반복 횟수를 알아보면 다음과 같다.
그러므로 각 문자열을 더하는 라인은 총 100만 번씩 수행된다. 프로파일링 툴을 사용하여 위의 코드를 실행한 결과는 다음과 같다.
주요 소스 부분 | 응답 시간(ms) | 비고 |
a+=aValue; | 95,801.41ms | 95초 |
b.append(aValue); String temp=b.toString() |
247.48ms 14.21ms |
0.24초 |
c.append(aValue); String temp2=b.toString() |
174.17ms 13.38ms |
0.17초 |
메모리 사용량은 다음과 같다.
주요 소스 부분 | 메모리 사용량(bytes) | 생성된 임시 객체 수 | 비고 |
a+=aValue; | 100,102,000,000 | 4,000,000 | 약 95Gb |
b.append(aValue); String temp=b.toString |
29,493,600 10,004,000 |
1,200 200 |
약 28Mb 약 9.5Mb |
c.append(aValue); String temp2=b.toString() |
29,493,600 10,004,000 |
1,200 200 |
약 28Mb 약 9.5Mb |
응답 시간은 String보다 StringBuffer가 약 367배 빠르며, StringBuilder가 약 512배 더 빠르다. 메모리는 StringBuffer와 StringBuilder보다 String에서 약 3,390배 더 사용된다. 이러한 결과가 왜 발생하는지 알아보자.
a += aValue;
이 소스 라인이 수행되면 어떻게 될까? a에 aValue를 더하면 새로운 String 클래스의 객체가 만들어지고, 이전에 있던 a 객체는 필요 없는 쓰레기 값이 되어 GC 대상이 되어 버린다.
a += aValue 값(첫 번째 수행) : abcde
a += aValue 값(두 번째 수행) : abcdeabcde
a += aValue 값(세 번째 수행) : abcdeabcdeabcde
그림으로 나타내면 다음과 같다.
가장 처음에 a 객체에는 'abcde' 값이 저장되어 있었다. 값이 저장되기 전의 a 객체는 a+=aValue;를 수행하면서 사라지고(쓰레기가 되고), 새로운 주소와 'abcdeabcde'라는 값을 갖는 a 객체가 생성된다. 세 번째 수행되면 'abcdeabcdeabcde'라는 값을 갖는 또 다른 새로운 객체가 만들어진다.
이런 작업이 반복 수행되면서 메모리를 많이 사용하게 되고, 응답 속도에서 많은 영향을 미치게 된다. 앞에서도 보았지만, GC를 하면 할수록 시스템의 CPU를 사용하게 되고 시간도 많이 소요된다. 그래서 프로그래밍을 할 때, 메모리 사용률을 최소화하는 것은 당연한 일이다.
그러면 StringBuffer나 StringBuilder는 어떻게 작동되는지 알아보자. 이 두 가지 클래스가 동작하는 원리는 다음 그림과 같이 나타낼 수 있다.
StringBuffer나 StringBuilder는 String과는 다르게 새로운 객체를 생성하지 않고, 기존에 있는 객체의 크기를 증가시키면서 값을 더한다.
그렇다면 String을 쓰는 것은 무조건 나쁘고, 무조건 StringBuffer와 StringBuilder 클래스만을 사용해야 하는 것일까?
만약 JDK 5.0 이상을 사용한다면 결과가 약간 달라진다. 아래와 같은 소스 코드가 있다고 하자.
package com.perf.string;
public class VersionTest {
String str = "Here " + "is " + "a " + "sample.";
public VersionTest() {
int i = 1;
String str2 = "Here " + "is " + "a " + "samples.";
}
}
먼저 JDK 1.4를 사용해서 컴파일해 보자. 일반적으로 많이 쓰이는 JAD를 사용하여 역 컴파일한 소스는 다음과 같다.
package com.perf.string;
public class VersionTest {
public VersionTest() {
str = "Here is a sample."
int i = 1;
String s = "Here " + "is " + "a " + "samples.";
}
String str;
}
역 컴파일한 소스를 보면 자바 컴파일러가 문자열 더한 것을 컴파일할 때 알아서 더해 놓고 있다. 그래도 중간에 int나 다른 객체가 들어가게 되면 위의 예와 같이 그대로 더하도록 되어 있다. 어차피 필요 없는 객체는 생성이 된다는 의미이다. 그럼 JDK 5.0에서는 얼마나 달라졌는지 알아보자.
package com.perf.string;
public class VersionTest {
public VersionTest() {
str = "Here is a sample."
int i = 1;
String str2= (new StringBuilder("Here is "))
.append(i).append(" samples.").toString();
}
String str;
}
만약 WAS나 시스템이 JDK 5.0 이상을 사용한다면, 컴파일러에서 자동으로 StringBuilder로 변환하여 준다.
하지만 반복 루프를 사용해서 문자열을 더할 때에는 객체를 계속 추가한다는 사실에는 변함이 없다. 그러므로 String 클래스를 쓰는 대신, 스레드와 관련이 있으면 StringBuffer를, 스레드 안전 여부와 상관이 없으면 StringBuilder를 사용하는 것을 권장한다.
참고