티스토리 뷰
문자열 처리 클래스 String, StringBuilder, StringBuffer에 대해 깊이 있게 파헤쳐 보겠습니다.
개발자분들이 반복문 내에서 무심코 + 연산자로 문자열을 이어 붙이는 경우를 자주 봅니다.
트래픽이 몰리는 운영 환경에서는 이런 작은 습관이 시스템에 치명적인 OutOfMemoryError나 심각한 GC(Garbage Collection) 병목을 유발할 수 있습니다.
단순히 "문자열을 더할 때는 StringBuilder를 써라"라는 표면적인 암기식 지식을 넘어, JVM 메모리 구조, 바이트코드 레벨의 컴파일러 최적화, 그리고 멀티스레드 환경의 동기화 메커니즘까지, 아키텍트의 시각에서 이 세 가지 클래스의 근본적인 차이를 확인해 보겠습니다.
1. String: 불변의 미학과 함정
Java에서 String 객체의 가장 중요한 특징은 바로 불변성(Immutable)입니다.
한 번 생성된 String 객체는 메모리 공간 안에서 절대 그 값이 변하지 않습니다.
1.1 메모리 구조: String Constant Pool과 Heap
문자열을 생성하는 방식에는 리터럴 방식과 new 키워드를 사용하는 방식 두 가지가 있으며, JVM 내부에서 저장되는 위치가 다릅니다.
📄 [StringCreation.java - 리터럴과 new 연산자의 차이]
// Good Practice: 리터럴 방식 (String Constant Pool 사용)
String str1 = "Hello";
String str2 = "Hello";
// Bad Practice: new 키워드 방식 (Heap 영역에 매번 새로운 객체 생성)
String str3 = new String("Hello");
String str4 = new String("Hello");
System.out.println(str1 == str2); // true (동일한 메모리 주소 참조)
System.out.println(str3 == str4); // false (서로 다른 힙 메모리 주소 참조)
System.out.println(str1 == str3); // false
- 리터럴 방식 (""): JVM의 Heap 영역에 있는 String Constant Pool에 저장됩니다. 동일한 값을 가진 문자열을 생성하면, 새로운 객체를 만들지 않고 풀(Pool)에 있는 기존 객체의 참조를 반환하여 메모리를 절약합니다.
- new String() 방식: String Constant Pool의 내용물과 상관없이 일반 객체처럼 무조건 Heap 영역에 새로운 메모리를 할당합니다.
1.2 더하기(+) 연산의 숨겨진 비용
String이 불변이라는 것은, str = str + "World"라는 코드가 실행될 때 기존 str 객체의 내용이 바뀌는 것이 아니라, "HelloWorld"라는 완전히 새로운 String 객체가 힙 메모리에 생성된다는 것을 의미합니다.
📄 [Bad Practice - StringConcat.java]
String result = "";
for (int i = 0; i < 10000; i++) {
// 매 반복마다 새로운 String 객체가 생성되고 버려짐 -> GC(Garbage Collector) 증가
result += "data" + i;
}
위 코드를 실행하면 반복당 여러 개의 임시 객체가 생성되어, 총 수만 개 이상의 쓰레기 객체(Garbage)가 만들어집니다.
이는 시스템 성능을 심각하게 저하시키는 원인이 됩니다.
2. StringBuilder: 가변성을 통한 성능 극대화
앞서 살펴본 String의 불변성으로 인한 성능 문제를 해결하기 위해 등장한 것이 바로 가변(Mutable) 클래스인 StringBuilder와 StringBuffer입니다.
2.1 내부 배열을 통한 효율적인 메모리 관리
이 두 클래스는 내부에 변경 가능한 문자 배열을 버퍼로 가지고 있습니다. 문자열을 추가할 때 새로운 객체를 생성하는 대신, 이 내부 배열의 크기를 동적으로 늘려가며 문자를 이어 붙입니다.
📄 [Good Practice - UsingStringBuilder.java]
// 초기 용량(Capacity)을 설정하면 배열 재할당 비용도 줄일 수 있습니다.
StringBuilder sb = new StringBuilder(100); // 가변 객체
for (int i = 0; i < 10000; i++) {
// 새로운 객체를 생성하지 않고, 내부 배열에 값을 추가함
sb.append("data").append(i);
}
String result = sb.toString(); // 마지막에 단 한 번 String으로 변환
2.2 컴파일러의 마법
"어? 저는 그냥 + 기호로 더해도 성능 문제가 없던데요?"라고 반문하실 수도 있습니다.
이는 Java 컴파일러가 코드를 최적화하기 때문입니다.
3. StringBuffer: 멀티스레드 환경의 견고한 방패
StringBuilder가 성능상 가장 우수하다면, StringBuffer는 왜 존재하는 것일까요? 바로 스레드 안전성 때문입니다.
3.1 Synchronized 키워드의 힘
웹 애플리케이션은 기본적으로 수많은 사용자의 요청을 동시에 처리하는 멀티스레드 환경입니다.
여러 스레드가 동시에 하나의 자원에 접근하여 데이터를 수정하려고 할 때, 데이터의 정합성이 깨지는 문제가 발생할 수 있습니다.
StringBuffer의 모든 주요 메서드에는 synchronized 키워드가 선언되어 있습니다.
이는 한 번에 하나의 스레드만 해당 메서드를 실행할 수 있도록 Lock을 거는 전통적인 동기화 방식을 의미합니다.
📄 [ConcurrencyExample.java - 스레드 환경 비교]
// 멀티스레드 환경에서 안전하지 않음 (데이터 유실 가능성 존재)
StringBuilder notSafeBuilder = new StringBuilder();
// 멀티스레드 환경에서 안전함 (Lock 오버헤드로 인해 단일 스레드에서는 속도가 느림)
StringBuffer safeBuffer = new StringBuffer();
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
notSafeBuilder.append("A"); // 경쟁 상태(Race Condition) 발생 가능
safeBuffer.append("A"); // 내부적으로 Lock을 획득하여 안전하게 처리됨
}
};
4. 개념 및 지표 총정리
지금까지 배운 내용을 한눈에 들어오도록 표로 정리해 드리겠습니다.
4.1 핵심 개념 비교
| 클래스명 | 불변성 | 스레드 안전성 | 내부 동기화 방식 |
| String | 불변 (Immutable) | O (불변이므로 본질적 안전) | 없음 |
| StringBuffer | 가변 (Mutable) | O (안전함) | synchronized 블록 사용 |
| StringBuilder | 가변 (Mutable) | X (안전하지 않음) | 동기화 처리 없음 |
4.2 메모리 및 성능 지표 비교
| 구분 | String (+ 연산) | StringBuffer | StringBuilder |
| 단일 연산 속도 | 리터럴 상수 결합 시 매우 빠름 변수 결합 시 내부적으로 StringBuilder 생성 비용 발생 |
느림 (Lock 오버헤드) | 가장 빠름 |
| 반복문 내 결합 성능 | 매우 나쁨 (GC 과부하) | 우수 | 가장 우수 |
| 메모리 할당 | 매번 새로운 Heap 영역 할당 | 가변 배열 버퍼 재사용 | 가변 배열 버퍼 재사용 |
| 메모리 누수 위험 | 높음 | 낮음 | 낮음 |
4.3 상황별 권장 사용 시나리오
| 상황 | 권장 클래스 | 선택 이유 및 아키텍처 관점 |
| 단순 조회 및 짧은 문자열 결합 | String | 가독성이 뛰어나며, 소규모 결합은 컴파일러가 자동 최적화함. Constant Pool의 이점을 살림. |
| 단일 스레드 내 대규모 반복 결합 | StringBuilder | 알고리즘 문제 풀이, 로컬 변수 내에서의 복잡한 문자열 조합 시 성능(CPU/Memory)이 가장 좋음. |
| 멀티스레드 환경 공유 변수 조작 | StringBuffer | 클래스의 전역 변수나 싱글톤 빈(Bean)에서 여러 스레드가 동시에 문자열을 수정해야 하는 특수한 상황. |
5. 결론
정리해 보겠습니다.
Java에서 String은 데이터의 신뢰성(불변성)을 보장하기 위해 존재하며, StringBuilder와 StringBuffer는 성능 극대화(가변성)를 위해 존재합니다.
Java 백엔드 개발에서 비즈니스 로직을 작성할 때, 메서드 내부의 지역 변수로 문자열을 조작하는 경우가 대부분입니다. 지역 변수는 스레드마다 독립적인 스택(Stack) 영역에 할당되므로 멀티스레드 동시성 문제가 발생하지 않습니다. 따라서 대규모 문자열 조작이 필요하다면 기본적으로 StringBuilder를 사용하는 것을 실무 원칙으로 삼으시길 바랍니다.
다만 현대 Java에서 StringBuffer가 필요한 상황은 극히 드뭅니다. 공유 자원에 대한 동시성 제어가 필요하다면 ConcurrentHashMap, Atomic 계열 클래스, 또는 명시적 Lock 등 더 세밀한 동시성 도구를 활용하는 것이 일반적입니다. StringBuffer의 synchronized 방식은 메서드 단위의 거친(coarse-grained) 잠금이므로, 복잡한 동시성 시나리오에서는 이러한 도구들이 더 유연하고 효율적인 제어를 제공합니다.
오늘 다룬 내용을 통해 단순히 문법을 아는 정도가 아닌 메모리 공간과 JVM의 동작 원리까지 고려하여 적절한 자료구조를 선택할 수 있는 탄탄한 기본기를 갖춘 엔지니어로 한 걸음 성장하셨기를 바랍니다.
'IT > Java' 카테고리의 다른 글
| [Java] 얕은 복사(Shallow Copy) vs 깊은 복사(Deep Copy) (0) | 2026.03.08 |
|---|---|
| [Java] 자바 객체 지향(OOP) (0) | 2026.03.07 |
| [Java] 추상 클래스(Abstract Class) vs 인터페이스(Interface) (0) | 2026.03.06 |
| Java 자주 쓰는 문법·메소드 정리 | String, List, Map, Stream, Arrays까지 한 번에 (0) | 2022.08.22 |
| Java 17 설치 및 환경변수 설정 방법 | JDK 17 다운로드부터 java -version 확인까지 (1) | 2022.08.09 |

