💡 본 게시글은 이상민 저자의 '자바 성능 튜닝 이야기' 교재를 공부하고, 이에 대해 정리한 내용입니다.
들어가며
제가 코딩 테스트를 준비하던 중에, 'String을 어떻게 하면 효율적으로 사용할 수 있을까' 고민을 하게 된 적이 있습니다. 해당 문제에는 문자열을 조작하는 문제가 포함되어 있었고, 처음에는 String만으로 문제를 해결하려고 했습니다. 그러나 예상치 못하게 많은 실행 시간과 메모리를 소비하는 것이 발견되었습니다. 특히 문자열을 변경해야 할 때 성능 저하가 두드러졌습니다. 이런 경험을 통해 'String을 사용하면 왜 성능이 떨어질까?'라는 의문이 생겼습니다.
1) String 클래스의 잘못된 사용 사례
웹 기반 시스템 개발에서는 데이터베이스(DB)로부터 데이터를 가져와 화면에 표시하는 작업이 일반적입니다. 이 과정에서 쿼리 문장을 생성하기 위한 String 클래스와 결과를 처리하기 위한 Collection 클래스의 사용이 빈번합니다.
다음은 쿼리 작성을 위해 String 클래스를 사용하는 전형적인 예시입니다:
String strSQL = "";
strSQL += "select * ";
strSQL += "from ( ";
strSQL += "select A_column, ";
strSQL += "B_column ,";
// ... 중간 생략 ...
현재는 myBatis, Hibernate와 같은 데이터 매핑 프레임워크를 많이 사용하지만, 과거에는 위와 같은 방식으로 쿼리를 작성하는 것이 일반적이었습니다. 이러한 문자열 조작 방식은 개발시 편리할 수 있지만, 메모리 사용량이 증가하는 문제가 있습니다.
400라인의 쿼리를 생성한다고 가정했을 때, String을 사용한 쿼리 생성 코드는 다음과 같은 결과가 나타납니다:
- 메모리 사용량: 10회 평균 약 5MB
- 응답 시간: 10회 평균 약 5ms
(1) String 클래스를 StringBuilder로 변경하면 어떻게 될까요?
위에서 작성된, String을 사용한 쿼리 생성 코드를 StringBuilder를 이용하여 수정하면 다음과 같습니다:
StringBuilder strSQL = new StringBuilder();
strSQL.append("select * ");
strSQL.append("from ( ");
strSQL.append("select A_column, ");
strSQL.append("B_column, ");
// ... 중간 생략 ...
코드를 StringBuilder로 수정 후의 수행 결과는 다음과 같습니다:
- 메모리 사용량: 10회 평균 약 371KB
- 응답 시간: 10회 평균 약 0.3ms
확연히 StringBuilder를 사용하면 같은 작업을 수행해도, 훨씬 적은 메모리를 사용하고, 응답 시간도 단축된다는 것을 알 수 있습니다. 그렇다면 왜 이러한 결과가 나오는 지 알아보았습니다.
2) `StringBuffer` 클래스와 `StringBuilder` 클래스
Java에서 문자열을 만드는 데 자주 사용되는 클래스로 String, StringBuffer, 그리고 JDK 5.0에서 추가된 StringBuilder가 있습니다.
StringBuffer 클래스의 특징
- StringBuffer는 멀티 스레드 환경에서 안전하게 사용할 수 있도록 설계되었습니다(Thread-safe).
- 여러 스레드가 동시에 하나의 StringBuffer 객체에 접근하더라도 문제가 발생하지 않습니다.
- 내부적으로 동기화(synchronization)를 사용하여 스레드 안전성을 보장합니다.
StringBuilder 클래스의 특징
- StringBuilder는 StringBuffer와 같은 메서드를 제공하지만, 스레드 안전성은 보장하지 않습니다.
- 이는 StringBuilder가 단일 스레드 환경에서 사용하기 위해 설계되었기 때문입니다.
- 멀티 스레드 환경에서 StringBuilder 객체를 공유할 경우 문제가 발생할 수 있습니다.
- 반면, StringBuilder는 StringBuffer보다 더 빠른 성능을 제공합니다, 이는 동기화 오버헤드가 없기 때문입니다.
(1) StringBuffer와 StringBuilder 클래스의 주요 생성자
① StringBuffer() 생성자
- 초기 내용이 없는 `StringBuffer` 객체를 생성합니다. 기본적으로 16개의 문자(char)를 수용할 수 있는 용량을 가집니다.
② StringBuffer(CharSequence seq) 생성자
- `CharSequence` 인터페이스를 매개변수로 받아, 해당 시퀀스의 내용을 가진 `StringBuffer` 객체를 생성합니다.
③ StringBuffer(int capacity) 생성자
- 지정된 용량(capacity)을 갖는 `StringBuffer` 객체를 생성합니다. 이 용량은 객체가 저장할 수 있는 최대 문자 수를 의미합니다.
④ StringBuffer(String str)` 생성자
- 문자열 `str`의 내용을 가진 `StringBuffer` 객체를 생성합니다.
여기서 CharSequence는 인터페이스로, 직접적으로 객체를 생성할 수 없습니다.
이 인터페이스는 CharBuffer, String, StringBuffer, StringBuilder 등 여러 클래스에 의해 구현됩니다. StringBuffer나 StringBuilder 객체를 생성할 때, CharSequence 인터페이스를 구현한 클래스의 객체를 매개변수로 전달할 수 있습니다.
그렇다면 StringBuffer와 StringBuilder 에서 주로 사용하는 두 개의 메서드를 알아보고자 합니다.
(2) StringBuffer와 StringBuilder 클래스의 주요 메서드:
StringBuffer와 StringBuilder 클래스에서 가장 자주 사용되는 두 메서드는 append()와 insert()입니다. 이 두 메서드는 다양한 타입의 매개변수를 처리할 수 있으며, 다음과 같은 매개변수 타입들을 지원합니다:
- boolean
- char
- char[] (문자 배열)
- CharSequence
- double
- float
- int
- long
- Object
- String
- StringBuffer
append() 메서드의 역할
- append() 메서드는 기존 값의 맨 뒤에 새로운 값을 덧붙이는 작업을 수행합니다.
- 예를 들어, sb.append("ABCDE")는 sb 객체의 현재 값 끝에 "ABCDE" 문자열을 추가합니다.
insert() 메서드의 역할
- insert() 메서드는 지정된 위치에 새로운 값을 덧붙이는 작업을 수행합니다.
- 예를 들어, sb.insert(2, "XYZ")는 sb 객체의 2번째 위치에 "XYZ" 문자열을 삽입합니다.
- 지정된 위치가 현재 문자열의 범위를 벗어나면 `StringIndexOutOfBoundsException`이 발생합니다.
다음은 `StringBuffer`와 `StringBuilder`를 사용하는 간단한 예시입니다:
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());
}
}
이 예시에서는 StringBuilder 객체에 "ABCDE"를 덧붙인 후, check 메서드를 통해 StringBuffer 객체로 변환합니다. 그리고 StringBuffer 객체의 길이를 출력합니다.
(3) append()와 insert() 메서드의 올바른 사용법
StringBuffer와 StringBuilder 클래스에서 append()와 insert() 메서드는 문자열 조작에 매우 유용합니다. 그러나 이들을 사용할 때 주의해야 할 사항이 있습니다.
append() 메서드
- append() 메서드는 기존 문자열의 끝에 새로운 문자열을 추가합니다.
- 연결된 메서드 호출(chaining)을 통해 여러 문자열을 연속해서 덧붙일 수 있습니다.
- 예: sb.append("ABCDE").append("FGHIJ").append("KLMNO");
잘못된 append() 사용 예
- append() 메서드 내에서 `+` 연산자를 사용하여 문자열을 결합하면, StringBuffer의 이점이 사라집니다.
- 예: `sb.append("ABCDE" + "=" + "FGHIJ");`는 `StringBuffer`를 사용하는 목적을 무색하게 합니다.
insert() 메서드
- insert() 메서드는 지정된 위치에 문자열을 삽입합니다.
- 지정된 위치가 문자열의 길이를 초과하면 `StringIndexOutOfBoundsException`이 발생합니다.
- 예: `sb.insert(3, "123");`는 sb의 3번째 위치에 "123"을 삽입합니다.
다음은 `StringBuffer`를 사용하는 예시 코드입니다:
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()` 메서드를 사용하여 문자열을 연결하는 다양한 방법을 보여줍니다. 하지만 `+` 연산자를 사용하여 문자열을 결합하는 방법은 피해야 합니다.
3) String vs StringBuffer vs StringBuilder
그렇다면 왜 `append()` 메서드를 이용하여 문자열을 더해야 하는지 알아보고자 합니다.
(1) `append()` 메서드를 이용해 문자열을 더하는 이유
`String`, `StringBuffer`, `StringBuilder`의 성능을 비교하기 위한 실험을 진행했습니다. 이 실험은 JSP 파일로 구현되었으며, 클래스 로딩에 소요되는 시간을 제외하고 실제 문자열 연산 시간과 메모리 사용량을 측정하기 위한 목적이었습니다.
실험은 다음과 같은 방법으로 진행되었습니다:
1. `final String aValue = "abcde"`를 사용하여 임시 객체 생성을 방지했습니다.
2. `String`, `StringBuffer`, `StringBuilder`를 이용하여 "abcde" 문자열을 10,000회 반복 추가했습니다.
3. `toString()` 메서드를 호출하여 `StringBuffer`와 `StringBuilder`의 결과를 `String`으로 변환했습니다.
4. 위 과정을 10회 반복하고, 전체 화면을 10회 호출하여 총 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 = c.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 = c.toString()` |
29,493,600 10,004,000 |
1,200개와 200개 |
약 28MB와 약 9.5MB |
결과적으로 `String`을 사용할 때보다 `StringBuffer`와 `StringBuilder`를 사용하는 경우:
응답 시간은 각각 약 367배, 512배 빠르고, 메모리 사용량은 약 3,390배 절감되었습니다. 이는 `StringBuffer`와 `StringBuilder`가 문자열 연산을 훨씬 효율적으로 처리하며, 특히 메모리 사용량을 크게 줄여준다는 것을 의미합니다.
그렇다면 왜 이런 결과가 발생했는지 알아보겠습니다.
(2) String 클래스에서 += 연산자 사용의 문제점
a += aValue;라는 코드 라인의 실행이 메모리 사용과 응답 속도에 미치는 영향을 디버깅을 통해 이해해 보겠습니다.
a += aValue;
이 연산은 문자열을 더할 때마다, 새로운 String 객체를 생성하고, 이전 객체는 쓰레기 값으로 변해 가비지 컬렉터(GC)의 대상이 됩니다.

a += aValue 값(첫 번째 수행) : abcde
a += aValue 값(두 번째 수행) : abcdeabcde
a += aValue 값(세 번째 수행) : abcdeabcdeabcde
이 과정에서 각 수행마다 새로운 String 객체가 생성되고, 이전 객체는 더 이상 사용되지 않습니다. 이러한 작업이 반복될수록 불필요한 객체가 증가하여 메모리 사용량이 크게 늘어납니다.
이 과정을 시각적으로 나타내면 다음과 같습니다:
(1) 첫 번째 a += aValue: 'abcde' 값을 가진 a 객체 생성.
(2) 두 번째 a += aValue: 'abcdeabcde' 값을 가진 새로운 a 객체 생성, 이전 객체는 쓰레기가 됨.
(3) 세 번째 a += aValue: 'abcdeabcdeabcde' 값을 가진 또 다른 새로운 a 객체 생성.
이런 반복적인 과정은 시스템의 메모리 사용량을 증가시키고, 가비지 컬렉터의 활동을 늘리며, 결국 응답 시간에 부정적인 영향을 미칩니다. 가비지 컬렉션은 시스템의 CPU 자원을 사용하고 시간을 소모하기 때문에, 메모리 사용을 최소화하는 것은 효율적인 프로그래밍의 중요한 부분입니다.
(3) `StringBuffer`와 `StringBuilder`의 작동 원리
`StringBuffer`와 `StringBuilder`는 `String`과는 다르게 동작합니다.

이 클래스들은 새로운 객체를 생성하는 대신 기존 객체의 크기를 증가시키며 값을 추가합니다. 이는 메모리 사용량과 성능에 큰 이점을 제공합니다.
그렇다면 모든 상황에서 `String` 대신 `StringBuffer`나 `StringBuilder`만 사용해야 할까요? 사실 그렇지는 않습니다. 각각의 사용 사례에 따라 적합한 클래스를 선택해야 합니다:
(4) 상황에 따라 적합한 String 클래스
`String` 사용 사례
- 짧은 문자열을 더할 때:
`String`은 짧은 문자열을 더하는데 적합합니다. 이 경우 `String`의 사용이 성능상 큰 문제를 일으키지 않습니다.
`StringBuffer` 사용 사례
- 스레드 안전이 필요한 상황:
`StringBuffer`는 멀티 스레드 환경에서 안전하게 사용할 수 있습니다. 만약 스레드에 안전해야 하는 상황이거나, 스레드 안전성에 대해 확신할 수 없는 경우 `StringBuffer`를 사용하는 것이 좋습니다.
- 클래스 레벨에서 문자열을 변경해야 하는 경우:
`static`으로 선언된 문자열을 변경하거나, 싱글톤(singleton) 패턴으로 구현된 클래스에서 문자열을 변경해야 하는 경우 `StringBuffer`를 사용해야 합니다.
`StringBuilder` 사용 사례
- 스레드 안전성이 문제되지 않는 상황:
단일 스레드 환경이나, 스레드 안전성이 중요하지 않은 경우 `StringBuilder`를 사용하는 것이 좋습니다. `StringBuilder`는 `StringBuffer`보다 성능상 이점이 있습니다.
- 메서드 내부에서의 문자열 조작:
메서드 내부에서 지역 변수로 문자열을 조작하는 경우 `StringBuilder`를 사용하면 효율적입니다.
(5) JDK 버전에 따른 문자열 처리의 차이
JDK 버전에 따라 문자열 처리 방식이 약간 달라집니다. 특히 JDK 5.0 이상에서는 컴파일러가 문자열 결합을 다루는 방식이 개선되었습니다. JDK 1.4와 JDK 5.0 이상에서 문자열 처리 방식의 차이를 이해하기 위해, 각각의 예시 코드와 컴파일러 최적화 결과를 살펴보겠습니다.
JDK 1.4에서의 문자열 처리
- JDK 1.4에서는 문자열 결합이 최적화되지 않고, 각 결합마다 새로운 String 객체가 생성됩니다.
package com.perf.string;
public class VersionTest1 {
String str = "Here " + "is " + "a " + "sample.";
public VersionTest1() {
int i = 1;
String str2 = "Here " + "is " + "a " + "samples.";
}
}
- 역 컴파일 결과:
public class VersionTest1 {
String str = "Here is a sample.";
public VersionTest1() {
int i = 1;
String str2 = "Here " + "is " + "a " + "samples.";
}
}
JDK 5.0 이상에서의 문자열 처리
- JDK 5.0 이상에서는 컴파일러가 자동으로 `StringBuilder`를 사용하여 문자열을 최적화합니다. 이는 불필요한 객체 생성을 줄이고 성능을 향상시킵니다.
public class VersionTest2 {
String str = "Here is a sample.";
public VersionTest2() {
int i = 1;
String str2 = (new StringBuilder("Here is ")).append(i).append(" samples.").toString();
}
}
- 컴파일러 최적화 결과:
public class VersionTest2 {
String str = "Here is a sample.";
public VersionTest2() {
int i = 1;
String str2 = (new StringBuilder("Here is ")).append(i).append(" samples.").toString();
}
}
위 예시를 통해 JDK 5.0 이상에서의 문자열 처리 방식이 얼마나 효율적인지 확인할 수 있습니다. 반복적인 문자열 결합 작업에서 StringBuilder의 사용은 메모리 사용량과 성능에 큰 이점을 제공합니다.
4) 정리하며
JDK 5.0 이상의 문자열 처리와 최적의 사용법
JDK 5.0 이상을 사용하는 경우, 컴파일러는 자동으로 `StringBuilder`를 사용하여 문자열 결합을 최적화합니다. 이는 문자열을 더할 때마다 새로운 객체를 생성하는 대신 `StringBuilder`의 효율적인 메커니즘을 활용하여 성능을 향상시킵니다.
그러나 주의해야 할 점은 반복 루프를 사용하여 문자열을 결합할 경우입니다. 이 경우에도 여전히 여러 개의 객체가 생성될 수 있습니다. 따라서, 문자열을 더하는 적절한 방법을 선택하는 것이 중요합니다.
- 스레드 안전이 중요한 경우: `StringBuffer` 사용
- 스레드 안전과 무관한 경우: `StringBuilder` 사용
이렇게 문자열 처리 방법을 적절히 선택하면, 효율적인 메모리 사용과 성능 향상을 기대할 수 있습니다.
'Java > 자바 성능 개선' 카테고리의 다른 글
| 05. 지금까지 사용하던 for 루프를 더 빠르게 사용할 수 있다고? (0) | 2024.01.01 |
|---|---|
| 04. 어떤 객체에 데이터를 담아야 하는지... (0) | 2023.12.31 |
| 02. 내가 만든 프로그램의 속도를 알고 싶다. (0) | 2023.12.19 |
| 01. 디자인 패턴은 꼭 써야한다 - (2) J2EE 디자인 패턴 (0) | 2023.12.19 |
| 01. 디자인 패턴은 꼭 써야한다 - (1) MVC 모델 (1) | 2023.12.17 |