문자열 뒤에 문자를 추가하는 가장 효율적인 방법은
현재 Java
로 코딩테스트 준비를 해보고 있습니다. 2025년의 목표가 아무래도 Java
와 Spring
에 대해서 깊게 알아보기 이기 때문에 가벼운 문제가 많은 Leetcode
문제들을 위주로 한번씩 풀어보고 있습니다.
아무래도 Java
에서는 문자열 처리가 최적화로 인해서 생기는 문제가 많다보니 이번 기회에 String
에 대해서 깊게 알아볼 생각 입니다.
String
단순히 문자열 이라고 합니다. 문자들의 배열 을 줄여서 이렇게 부르죠.
그렇다면, 이 자체로 배열이라는 뜻이 됩니다.
생각해봅시다. 배열을 따로 원시 자료형으로 둘 수 있을까요? 아닙니다. 그래서 C++
, Java
모두 자체적으로 원시타입이 아닙니다.
그렇기에 실제로 모든 문자열은 원시타입에서 떠나 이보다 더욱 복잡한 처리가 필요합니다.
C 에서의 문자열
C언어의 경우 이를 배열로 처리 하기 때문에, 최적화를 위해서 “abcd” 라는게 있다면 “abcd” 자체를 저장해서 처리합니다. C언어는 문자열을 const char* 또는 char[]로 처리합니다.
문자열 리터럴은 읽기 전용 데이터 영역에 저장됩니다. 따라서 수정이 필요한 경우 char 배열을 사용해야 합니다.
그래서 이를 통해 문자열을 처리하거나 제어하는 함수를 포함한 라이브러리 <string.h>
를 사용해야 합니다.
Java 에서의 String
Java 또한, String은 불변 객체로써 저장 됩니다.
하지만, 프로그램의 데이터영역에 저장되는 C와는 달리, 문자열 리터럴은 String Pool에 저장되어 재사용됩니다.
String Pool
String Pool
은 JVM 에서 문자열을 관리해주는 매커니즘입니다.
컴파일 시 프로그램 내 문자열 리터럴들을 클래스 파일의 상수 풀 (Constant Pool) 에 저장하며, JVM이 클래스를 로딩할 때 String Pool 에 문자열 객체로써 생성합니다.
중요한 점은 상수 풀이 런타임 영역이 아니라는 점입니다. Java 7
이후 버전 기준으로, 실제 String Pool 은 힙 영역에 저장하며, 클래스 로딩 시 해당 리터럴을 바탕으로 String 객체를 생성 후, String Pool에 저장하는 방식을 사용 중입니다.
1
2
3
4
5
6
// 소스코드
String str = "hello";
// 바이트코드
ldc #2 // #2는 상수 풀 인덱스
astore_1
String Pool
을 이용해 동일한 문자열 리터럴은 재사용을 하여 메모리를 절약할 수 있습니다.
문자열 리터럴에 대해서만 저장되고, 실제로 동적으로 생성되는 문자열은 힙 메모리에 저장됩니다.
단, intern()
메소드를 사용하면, 동적으로 생성한 문자열도 String Pool 에 저장 가능합니다. intern()
메소드는 String Pool 에서 리터럴 문자열이 이미 존재하는지 체크하고, 존재하면 해당 문자열을 반환함으로써 다시 메모리 절약을 실행할 수도 있습니다.
1. 단순 합산 연산
가장 일반적인 방법은 아래처럼 단순히 합연산을 시켜주면 됩니다.
1
String s = "aaa" + "b";
2. StringBuilder
두번째로, StringBuilder
를 사용하는 방식이 있습니다.
StringBuilder
는 말그대로, 문자열을 만들어주는 객체입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// String -> StringBuilder -> String 변환 예시
String original = "Hello";
StringBuilder sb = new StringBuilder(original); // String -> StringBuilder
sb.append(" World"); // 수정 작업
String result = sb.toString(); // StringBuilder -> String
// 체이닝 예시
String result2 = new StringBuilder("Hello")
.append(" ")
.append("World")
.reverse()
.toString();
// 문자열 처리 예시
String text = "Hello World";
String processed = new StringBuilder(text)
.reverse()
.delete(0, 2)
.insert(0, "Hi")
.toString();
- char[] 배열을 사용해 문자열 저장
- 배열이 가득 차면 더 큰 배열로 자동 확장 (보통 2배)
- 수정 가능한 문자 시퀀스 제공
- toString() 호출 시 최종 문자 배열로 불변 String 객체 생성
3. StringBuffer
문자열과 관련된 다른 객체로 StringBuffer
가 있습니다.
StringBuilder
와의 큰 차이점으로는 스레딩 지원 여부입니다.
StringBuilder
를 사용할 경우, 동기화를 지원하지 않고 비동기로 처리하므로, 단일 스레드만 사용하는 일반적인 환경에서는 더 빠른 편입니다. 동기화 오버헤드가 없기 때문입니다.
반대로 StringBuffer
는 동기화를 지원하며, 멀티스레딩 환경에서 사용됩니다. 아래 예시처럼 실제로 buffer 에 더하거나 하는 작업을 병렬로 수행할때 사용합니다.
1
2
3
4
5
6
7
8
// 멀티스레드 환경
StringBuffer buffer = new StringBuffer();
Thread t1 = new Thread(() -> buffer.append("Hello"));
Thread t2 = new Thread(() -> buffer.append("World"));
// 단일스레드 환경
StringBuilder builder = new StringBuilder();
builder.append("Hello").append("World");
놀라운 사실
모든 문자열 연산은 StringBuilder 로 컴파일
하지만, C++
과는 달리, Java 에서는 Operator 에 대한 연산 함수를 만들 수가 없습니다.
그렇다면, “aaa” + “b” 와 같은 문자열 에 대한 합산 연산은 어떻게 이루어지는 걸까요?
놀랍게도, String 연산자는 컴파일러가 자동으로 StringBuilder로 최적화 해줍니다. 컴파일러가 알아서 StringBuilder
/StringBuffer
로 효율적인 문자열 수정 지원이 가능하므로 일반적으로 이렇게 사용하면 됩니다.
하지만, JIT 컴파일러가 반복적인 문자열 연산을 추가 최적화해주므로 일반적인 상황에서는 딱히 크게 신경쓰지 않아도 됩니다.
결론
아무거나 사용해도 상관없습니다. 컴파일 단에서 모두 StringBuilder로 최적화 해주기 때문입니다.
다만, 경우의 수에 따라 아래와 같이 다양하게 활용하면 좋을 것입니다.
- 단순 문자열 연결: + 연산자가 가독성 좋음
- 반복문 내 문자열 조작: 명시적
StringBuilder
가 성능상 유리 (객체 재사용)