F-lab Java 1주차 / Phase 6 / Unit 6.2 본격 학습 자료
9-섹션 마스터 프롬프트 형식으로 깊이 파헤친다.선수 지식: Unit 6.1 (String 과 Constant Pool)
다음 Unit: 6.3 — ArrayList vs LinkedList이 Unit의 의미: String 의 불변성을 보완하는 가변 버퍼.
면접 단골 (String + vs StringBuilder) + 실무 필수 (로그, SQL, 보고서).
동시성 측면에서 둘의 정확한 차이.
String result = "";
for (int i = 0; i < 1000; i++) {
result += "item" + i; // 매번 새 종이!
}
// → 1000장의 String 객체 → GC 부담
toString())StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append("item").append(i); // 같은 보드에 추가
}
String result = sb.toString(); // 마지막에 종이로
StringBuffer sb = new StringBuffer(); // 동기화됨
// 여러 스레드가 안전하게 사용 가능
"String 은 불변, StringBuilder/Buffer 는 가변. 단일 스레드 = StringBuilder, 멀티 스레드 = StringBuffer (그러나 거의 안 씀)."
비유 정리:
| 비유 | 자바 클래스 | 특성 |
|---|---|---|
| 인쇄된 종이 | String | 불변, 안전 |
| 개인 화이트보드 | StringBuilder | 가변, 빠름 |
| 공용 화이트보드 | StringBuffer | 가변, 동기화, 느림 |
자바 초기 (1.0):
문제 발견:
// 자바 1.0 시대 코드
String log = "";
for (int i = 0; i < 100000; i++) {
log = log + "[" + i + "] event\n"; // 매번 새 String!
}
실제 동작:
1. log + "[" + i + "] event\n" → 새 String 생성
2. 기존 log 는 Garbage
3. 100,000 번 반복 → 100,000 개 String 생성 → 99,999 개 Garbage
시간 복잡도:
자바 1.0 부터 StringBuffer 등장:
그러나 한계:
synchronizedJava 5 (2004):
StringBuilder sb = new StringBuilder(); // 단일 스레드용
sb.append("hello");
현재 표준:
Java 5+ 컴파일러:
String result = "Hello" + name + ", welcome";
→ 내부적으로 변환:
String result = new StringBuilder()
.append("Hello")
.append(name)
.append(", welcome")
.toString();
그러나 한계 — 반복문에서는 자동 변환 X:
String result = "";
for (String s : list) {
result = result + s; // 매 반복마다 새 StringBuilder!
}
// → 사실상 String 만 쓴 것과 동일한 비효율
→ 반복문에서는 직접 StringBuilder 사용 필수.
Java 9 부터 String + 의 컴파일 결과가 더 효율적:
invokedynamic 으로 런타임 최적화그러나 — 반복문은 여전히 직접 StringBuilder 권장.
"가변 버퍼는 String 의 불변성을 보완하는 도구다."
String 의 불변성은 안전, StringBuilder/Buffer 는 효율. 둘이 공존하는 이유.
자바는 읽기/공유는 String, 만들기는 StringBuilder 의 패턴 권장.
단일 스레드 = StringBuilder, 멀티 스레드 (거의 없는 케이스) = StringBuffer.
// ❌ 비효율 코드 — 운영에서 자주 보임
public String generateReport(List<Cargo> cargos) {
String report = "=== 운송 보고서 ===\n";
for (Cargo c : cargos) {
report += c.getId() + " | "
+ c.getOrigin() + " → "
+ c.getDestination() + " | "
+ c.getStatus() + "\n";
}
report += "=== 끝 ===";
return report;
}
현실 영향:
해결:
public String generateReport(List<Cargo> cargos) {
StringBuilder sb = new StringBuilder();
sb.append("=== 운송 보고서 ===\n");
for (Cargo c : cargos) {
sb.append(c.getId()).append(" | ")
.append(c.getOrigin()).append(" → ")
.append(c.getDestination()).append(" | ")
.append(c.getStatus()).append("\n");
}
sb.append("=== 끝 ===");
return sb.toString();
}
→ 메모리 ↓, 속도 ↑.
// ❌ 비효율
public String buildQuery(SearchCriteria c) {
String sql = "SELECT * FROM cargos WHERE 1=1";
if (c.getOrigin() != null) {
sql += " AND origin = '" + c.getOrigin() + "'";
}
if (c.getDestination() != null) {
sql += " AND destination = '" + c.getDestination() + "'";
}
if (c.getStatusList() != null) {
for (String status : c.getStatusList()) {
sql += " AND status = '" + status + "'"; // 반복!
}
}
return sql;
}
문제:
// ❌ 위험: 멀티 스레드에서 StringBuilder
public class LogCollector {
private static StringBuilder log = new StringBuilder();
public static void append(String msg) {
log.append(msg); // ❌ 동기화 X → 데이터 손실
}
}
문제:
해결책 1: StringBuffer
private static StringBuffer log = new StringBuffer(); // ✓ 동기화
해결책 2: 더 좋은 방법
// 스레드별 독립 StringBuilder
private static ThreadLocal<StringBuilder> log = ThreadLocal.withInitial(StringBuilder::new);
// 또는 동시성 컬렉션 사용
private static Queue<String> messages = new ConcurrentLinkedQueue<>();
"String, StringBuilder, StringBuffer 의 차이는?"
모르면:
알면:
String + = O(n²) vs StringBuilder = O(n))// 일견 문제 없어 보이는 코드
public String formatPhoneNumber(String raw) {
String result = "";
for (char c : raw.toCharArray()) {
if (Character.isDigit(c)) {
result += c; // 짧은 문자열이지만...
}
}
return formatHyphens(result);
}
누적 영향:
해결:
public String formatPhoneNumber(String raw) {
StringBuilder sb = new StringBuilder(raw.length());
for (char c : raw.toCharArray()) {
if (Character.isDigit(c)) {
sb.append(c);
}
}
return formatHyphens(sb.toString());
}
// 심각하게 비효율
public String buildJson(List<Customer> customers) {
String json = "[";
for (int i = 0; i < customers.size(); i++) {
Customer c = customers.get(i);
json += "{\"id\":" + c.getId()
+ ",\"name\":\"" + c.getName() + "\""
+ ",\"grade\":\"" + c.getGrade() + "\"}";
if (i < customers.size() - 1) {
json += ",";
}
}
json += "]";
return json;
}
더 좋은 방법:
public String buildJson(List<Customer> customers) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < customers.size(); i++) {
Customer c = customers.get(i);
sb.append("{\"id\":").append(c.getId())
.append(",\"name\":\"").append(c.getName()).append("\"")
.append(",\"grade\":\"").append(c.getGrade()).append("\"}");
if (i < customers.size() - 1) {
sb.append(',');
}
}
sb.append("]");
return sb.toString();
}
| 시나리오 | StringBuilder 모르면 | 알면 |
|---|---|---|
| 보고서 생성 | 메모리 폭발 | 효율적 |
| SQL 빌딩 | 누적 GC 부담 | 안정 |
| 멀티 스레드 | race condition | StringBuffer 또는 ThreadLocal |
| 면접 | 탈락 | 시니어 답변 |
| 작은 메서드 누적 | 잠재 성능 문제 | 처음부터 최적 |
→ 자바 시니어의 기본 도구.
public final class StringBuilder
extends AbstractStringBuilder
implements Serializable, Comparable<StringBuilder>, CharSequence {
// 부모 클래스 AbstractStringBuilder 의 필드
// byte[] value; (Java 9+) 또는 char[] (Java 8 이하)
// int count; 현재 길이
// capacity = value.length
}
핵심:
StringBuilder sb = new StringBuilder();
// === 추가 ===
sb.append("hello") // 끝에 추가
.append(' ')
.append(42) // 자동 변환 (int → "42")
.append(true) // 자동 변환 (boolean → "true")
.append(123.45); // 자동 변환 (double → "123.45")
// === 삽입 ===
sb.insert(0, "Start: "); // 0번 위치에 삽입
// === 삭제 ===
sb.delete(0, 7); // 0~7 인덱스 삭제
sb.deleteCharAt(0); // 0번 문자 삭제
// === 치환 ===
sb.replace(0, 5, "World"); // 0~5 를 "World" 로
// === 뒤집기 ===
sb.reverse();
// === 정보 ===
sb.length(); // 현재 문자 수
sb.capacity(); // 현재 버퍼 크기
sb.charAt(0); // 특정 위치 문자
// === 변환 ===
String result = sb.toString(); // String 으로
String result = new StringBuilder()
.append("Hello")
.append(", ")
.append(name)
.append("!")
.toString();
이게 가능한 이유:
this 반환public StringBuilder append(String str) {
super.append(str);
return this; // ← 자기 자신 반환
}
public final class StringBuffer extends AbstractStringBuilder ... {
@Override
public synchronized StringBuffer append(String str) { // ← synchronized!
toStringCache = null;
super.append(str);
return this;
}
// 거의 모든 public 메서드가 synchronized
}
핵심:
synchronized → Thread-Safe| 클래스 | 시간 | 사용 시점 |
|---|---|---|
| String + | ~5초 | 절대 X |
| StringBuilder | ~5ms | 단일 스레드 (99%) |
| StringBuffer | ~10ms | 멀티 스레드 (드묾) |
| String | StringBuilder | StringBuffer | |
|---|---|---|---|
| 불변/가변 | 불변 | 가변 | 가변 |
| 동기화 | (해당 없음) | X | ✓ |
| 속도 | 매우 느림 (변경 시) | 빠름 | 보통 |
| Thread-Safe | ✓ (불변) | ✗ | ✓ |
| 시간 복잡도 | O(n²) | O(n) | O(n) |
| 언제 | 읽기/공유 | 단일 스레드 빌딩 | 멀티 스레드 공유 |
| 등장 | Java 1.0 | Java 5 | Java 1.0 |
문자열을 어떻게 사용?
├── 읽기/공유만 → String
├── 만들기 (반복 등)
│ ├── 단일 스레드 → StringBuilder ⭐
│ └── 멀티 스레드 공유 (드묾)
│ ├── StringBuffer
│ └── 또는 ThreadLocal<StringBuilder>
└── 비교/조회 → String + equals
현실 — 99% 의 경우:
String greeting = "Hello, " + name + "!"; // 컴파일러가 변환
Java 8 까지:
String greeting = new StringBuilder()
.append("Hello, ")
.append(name)
.append("!")
.toString();
Java 9+ (invokedynamic):
// StringConcatFactory.makeConcatWithConstants() 사용
// 더 효율적, 런타임 최적화
그러나 한계 — 반복문은 자동 변환 X:
String result = "";
for (String s : list) {
result += s; // 매 반복마다 새 StringBuilder
}
// → 사실상 O(n²)
→ 반복문에선 직접 StringBuilder 사용.
abstract class AbstractStringBuilder {
byte[] value; // 실제 문자 데이터 (Java 9+, 이전엔 char[])
byte coder; // LATIN1 or UTF16 (Java 9+)
int count; // 현재 사용 중인 길이
// capacity = value.length / (coder == UTF16 ? 2 : 1)
}
핵심 개념:
count — 현재 문자열 길이 (length() 가 반환)capacity — 버퍼 전체 크기 (count 보다 큼)new StringBuilder() // capacity = 16 (기본)
new StringBuilder(100) // capacity = 100
new StringBuilder("hello") // capacity = 5 + 16 = 21
new StringBuilder(initialString) // capacity = initialString.length() + 16
왜 16?:
public void ensureCapacity(int minimumCapacity) {
if (minimumCapacity > value.length) {
int newCapacity = (value.length << 1) + 2; // 현재 * 2 + 2
if (newCapacity < minimumCapacity) {
newCapacity = minimumCapacity;
}
value = Arrays.copyOf(value, newCapacity);
}
}
확장 규칙:
1. 새 capacity = 현재 * 2 + 2 (보통)
2. 그래도 부족하면 = 필요한 크기
예시:
StringBuilder sb = new StringBuilder(); // capacity = 16
sb.append("a".repeat(20)); // 16 → 34
sb.append("a".repeat(50)); // 34 → 84
비용:
Arrays.copyOf)시간 복잡도:
// ❌ 확장 여러 번
StringBuilder sb = new StringBuilder(); // 16
for (int i = 0; i < 1000; i++) {
sb.append("x"); // 16 → 34 → 70 → ... 여러 번 확장
}
// ✓ 한 번에 충분히
StringBuilder sb = new StringBuilder(1024); // 1024
for (int i = 0; i < 1000; i++) {
sb.append("x"); // 확장 없음
}
→ 예상 크기를 알면 초기 capacity 지정.
// StringBuffer 의 메서드 (간략화)
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
public synchronized String toString() {
if (toStringCache == null) {
return toStringCache = new String(value, 0, count);
}
return new String(toStringCache);
}
lock 메커니즘:
성능 영향:
public String toString() {
return new String(value, 0, count);
}
핵심:
StringBuilder sb = new StringBuilder("hello");
String s = sb.toString();
sb.append(" world");
System.out.println(s); // "hello" (변하지 않음)
System.out.println(sb); // "hello world"
abstract class AbstractStringBuilder {
byte[] value;
byte coder; // LATIN1 (1 byte/char) or UTF16 (2 bytes/char)
}
효과:
ILIC 같은 한글/영문 혼용 시스템:
+ vs append 의 컴파일 결과 (Java 9+)// 소스 코드
String a = "Hello, " + name;
// Java 8 컴파일 결과 (StringBuilder 사용)
String a = new StringBuilder()
.append("Hello, ")
.append(name)
.toString();
// Java 9+ 컴파일 결과 (invokedynamic)
String a = StringConcatFactory.makeConcatWithConstants(
MethodHandles.lookup(),
"concat",
MethodType.methodType(String.class, String.class),
"Hello, \u0001" // 패턴
).invoke(name);
→ Java 9+ 는 더 효율적이지만, 반복문에선 여전히 직접 StringBuilder 권장.
@Service
public class ReportService {
public String generateMonthlyReport(List<Cargo> cargos) {
// 예상 크기 미리 계산 (선택적, 더 효율적)
int expectedSize = cargos.size() * 100; // 평균 100 자/줄
StringBuilder sb = new StringBuilder(expectedSize);
// 헤더
sb.append("=== 월간 운송 보고서 ===\n")
.append("총 화물 수: ").append(cargos.size()).append("\n")
.append("생성 시각: ").append(LocalDateTime.now()).append("\n\n");
// 본문
for (Cargo c : cargos) {
sb.append(String.format("%5d", c.getId()))
.append(" | ").append(c.getOrigin())
.append(" → ").append(c.getDestination())
.append(" | ").append(c.getStatus())
.append("\n");
}
// 푸터
sb.append("\n=== 끝 ===");
return sb.toString();
}
}
public class CargoQueryBuilder {
public String build(SearchCriteria c) {
StringBuilder sql = new StringBuilder(
"SELECT * FROM cargos WHERE 1=1"
);
List<Object> params = new ArrayList<>();
if (c.getOrigin() != null) {
sql.append(" AND origin = ?");
params.add(c.getOrigin());
}
if (c.getDestination() != null) {
sql.append(" AND destination = ?");
params.add(c.getDestination());
}
if (c.getStatusList() != null && !c.getStatusList().isEmpty()) {
sql.append(" AND status IN (");
for (int i = 0; i < c.getStatusList().size(); i++) {
if (i > 0) sql.append(",");
sql.append("?");
params.add(c.getStatusList().get(i));
}
sql.append(")");
}
return sql.toString();
}
}
보너스 — 더 안전: PreparedStatement + JOIN 등 ORM 활용 권장. 위는 학습용 예시.
public class CsvService {
// ✓ StringBuilder 로 CSV 빌딩
public String toCsv(List<Customer> customers) {
StringBuilder sb = new StringBuilder();
// 헤더
sb.append("ID,Name,Email,Grade\n");
// 데이터
for (Customer c : customers) {
sb.append(c.getId()).append(',')
.append(escape(c.getName())).append(',')
.append(escape(c.getEmail())).append(',')
.append(c.getGrade())
.append('\n');
}
return sb.toString();
}
// 간단한 CSV 이스케이프
private String escape(String value) {
if (value == null) return "";
if (value.contains(",") || value.contains("\"")) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
return value;
}
}
// ❌ StringBuilder 사용 시 위험
public class UnsafeLogger {
private static final StringBuilder log = new StringBuilder();
public static void append(String msg) {
log.append(msg).append('\n'); // race condition!
}
}
// ✓ StringBuffer 사용
public class SafeLogger {
private static final StringBuffer log = new StringBuffer();
public static void append(String msg) {
log.append(msg).append('\n'); // synchronized
}
}
// ✓ 더 좋은 방법: ThreadLocal
public class BetterLogger {
private static final ThreadLocal<StringBuilder> log =
ThreadLocal.withInitial(StringBuilder::new);
public static void append(String msg) {
log.get().append(msg).append('\n'); // 스레드별 독립
}
public static String getLog() {
return log.get().toString();
}
}
@Benchmark
public class ConcatBenchmark {
private static final int N = 100_000;
@Benchmark
public String stringConcat() {
String result = "";
for (int i = 0; i < N; i++) {
result += "x";
}
return result;
// 결과: ~수 분 (O(n²))
}
@Benchmark
public String stringBuilder() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < N; i++) {
sb.append("x");
}
return sb.toString();
// 결과: ~5ms (O(n))
}
@Benchmark
public String stringBuilderWithCapacity() {
StringBuilder sb = new StringBuilder(N); // 초기 capacity
for (int i = 0; i < N; i++) {
sb.append("x");
}
return sb.toString();
// 결과: ~3ms (확장 X)
}
@Benchmark
public String stringBuffer() {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < N; i++) {
sb.append("x");
}
return sb.toString();
// 결과: ~10ms (synchronized)
}
@Benchmark
public String streamCollect() {
return IntStream.range(0, N)
.mapToObj(i -> "x")
.collect(Collectors.joining());
// 결과: ~10ms (내부적으로 StringBuilder 사용)
}
}
@Service
public class NotificationService {
public String buildShipmentNotification(Shipment shipment) {
StringBuilder sb = new StringBuilder();
sb.append("[ILIC 운송 알림]\n\n");
sb.append("화물 번호: ").append(shipment.getId()).append("\n");
sb.append("현재 상태: ").append(shipment.getStatus()).append("\n");
if (shipment.getCurrentLocation() != null) {
sb.append("현재 위치: ").append(shipment.getCurrentLocation()).append("\n");
}
sb.append("출발지: ").append(shipment.getOrigin()).append("\n");
sb.append("도착지: ").append(shipment.getDestination()).append("\n");
if (shipment.getEstimatedArrival() != null) {
sb.append("예상 도착: ")
.append(shipment.getEstimatedArrival().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
.append("\n");
}
sb.append("\n자세한 정보는 ILIC 앱에서 확인하세요.");
return sb.toString();
}
}
// 두 가지 방법 비교
public class FormattingDemo {
// 방법 1: String.format (간결, 약간 느림)
public String formatV1(Customer c) {
return String.format("[%d] %s (%s)",
c.getId(), c.getName(), c.getGrade());
}
// 방법 2: StringBuilder (장황, 빠름)
public String formatV2(Customer c) {
return new StringBuilder()
.append('[').append(c.getId()).append("] ")
.append(c.getName()).append(" (")
.append(c.getGrade()).append(')')
.toString();
}
// 방법 3: + 연산 (Java 9+ 에선 비슷)
public String formatV3(Customer c) {
return "[" + c.getId() + "] " + c.getName() + " (" + c.getGrade() + ")";
}
}
선택 기준:
+ 또는 String.format// ✓ Stream + Collectors.joining
public String getCustomerNames(List<Customer> customers) {
return customers.stream()
.map(Customer::getName)
.collect(Collectors.joining(", "));
}
// 결과: "Alice, Bob, Charlie"
// 내부적으로 StringBuilder 사용 — 효율적
// 더 정교한 형태
public String formatCustomerList(List<Customer> customers) {
return customers.stream()
.map(c -> String.format("[%d] %s", c.getId(), c.getName()))
.collect(Collectors.joining(
"\n", // 구분자
"=== 고객 목록 ===\n", // 시작
"\n=== 끝 ===" // 끝
));
}
// ❌ O(n²)
String result = "";
for (String s : list) {
result += s;
}
// ✓ O(n)
StringBuilder sb = new StringBuilder();
for (String s : list) {
sb.append(s);
}
String result = sb.toString();
// ❌ 불필요한 동기화 비용
StringBuffer sb = new StringBuffer();
// 단일 스레드에서만 사용
// ✓
StringBuilder sb = new StringBuilder();
// ❌ race condition!
public class Logger {
private static StringBuilder log = new StringBuilder();
public static void log(String msg) {
log.append(msg); // 여러 스레드에서 호출 시 위험!
}
}
// ✓ StringBuffer 또는 ThreadLocal
public class Logger {
private static StringBuffer log = new StringBuffer();
// 또는
private static ThreadLocal<StringBuilder> log =
ThreadLocal.withInitial(StringBuilder::new);
}
// ❌ 확장 여러 번
StringBuilder sb = new StringBuilder(); // 기본 16
for (int i = 0; i < 10000; i++) {
sb.append(largeData); // 여러 번 확장
}
// ✓ 예상 크기 지정
StringBuilder sb = new StringBuilder(10000 * 100); // 100자/줄 가정
for (int i = 0; i < 10000; i++) {
sb.append(largeData);
}
StringBuilder sb = new StringBuilder();
// ... 빌딩 ...
String s1 = sb.toString(); // 새 String 생성
String s2 = sb.toString(); // 또 새 String 생성!
// ✓ 한 번만
String result = sb.toString();
String s1 = result;
String s2 = result;
// ❌ 과한 사용
public String greet(String name) {
StringBuilder sb = new StringBuilder();
sb.append("Hello, ");
sb.append(name);
sb.append("!");
return sb.toString();
}
// ✓ 단순한 결합은 + 로 충분 (Java 9+ 효율적)
public String greet(String name) {
return "Hello, " + name + "!";
}
원칙: 반복문이 아니면 + 도 OK.
StringBuilder sb1 = new StringBuilder("hello");
StringBuilder sb2 = new StringBuilder("hello");
sb1.equals(sb2); // ❌ false! (Object.equals — 참조 비교)
이유:
equals() 오버라이드 XtoString().equals() 또는 compareTo()// ✓
sb1.toString().equals(sb2.toString()); // true
// ❌ StringBuffer 라도 다음은 안전 X
StringBuffer log = new StringBuffer();
// Thread 1
log.append("event1");
String snapshot = log.toString(); // 다른 스레드가 수정 중일 수 있음
// 즉, 개별 메서드는 동기화되지만 여러 메서드 간은 X
해결: 외부 동기화 또는 다른 패턴.
[Unit 6.1: String + Constant Pool] ✓
↓
[Unit 6.2: StringBuilder vs StringBuffer] ← 지금 여기 ★
↓
[Unit 6.3: ArrayList vs LinkedList] (다음)
↓
[Unit 6.4: HashMap 내부 구조] ★★★
↓
[Unit 6.5: TreeMap, LinkedHashMap]
| 학습 | 연결 |
|---|---|
| Unit 4.1 (Heap) | StringBuilder 의 char[] 도 Heap |
| Unit 5.1 (GC) | String + 의 Garbage 폭발 = GC 부담 |
| Unit 6.1 (String) | 가변 vs 불변의 대조 |
// Stream + Collectors.joining 은 내부적으로 StringBuilder 사용
list.stream().collect(Collectors.joining(","))
// JDK 의 StringJoiner 도 내부에 StringBuilder
StringJoiner sj = new StringJoiner(", ", "[", "]");
sj.add("a").add("b").add("c");
sj.toString(); // "[a, b, c]"
| 방식 | 설명 | 적합 |
|---|---|---|
| StringBuffer | synchronized 메서드 | 옛 코드 |
| ThreadLocal | 스레드별 독립 | 일반적 |
| ConcurrentLinkedQueue + 나중 합치기 | 비동기 수집 후 일괄 | 고성능 |
| Reactive (Flux 등) | 스트림 기반 | 최신 |
| 언어 | 가변 String |
|---|---|
| Java | StringBuilder, StringBuffer |
| C# | StringBuilder (Java 와 비슷) |
| Python | list + ''.join() (관용 패턴) |
| JavaScript | Array + .join() 또는 += (V8 이 최적화) |
| C++ | std::string (가변, +=) |
| Rust | String (가변), &str (불변) |
| 질문 | 이 Unit 에서의 답 |
|---|---|
| String, StringBuilder, StringBuffer 차이? | 불변 / 가변 / 동기화 |
| 시간 복잡도? | String + = O(n²), StringBuilder = O(n) |
| 언제 어떤 거 쓰나? | 단일=Builder, 멀티=Buffer (드묾) |
| capacity 와 length 차이? | 버퍼 크기 vs 실제 길이 |
| 자동 확장은? | * 2 + 2 |
1️⃣ String 은 불변, StringBuilder/Buffer 는 가변. 셋의 정확한 차이를 알아야.
String = 불변, 비교/공유에 적합, 매 변경 시 새 객체 (O(n²)). StringBuilder = 가변, 단일 스레드, 빠름, 동기화 X (Java 5+, 표준 선택). StringBuffer = 가변, 멀티 스레드, synchronized, 느림 (Java 1.0+, 거의 안 씀). 모두 같은 인터페이스 (append, insert, delete, replace).
2️⃣ 반복문에선 무조건 StringBuilder. 단순 결합은 + 도 OK.
반복문에서
String +는 매번 새 객체 → O(n²). StringBuilder 는 같은 버퍼에 추가 → O(n). 컴파일러가 단순 결합 (a + b + c) 은 자동 변환 (Java 9+ invokedynamic 으로 더 효율적). 그러나 반복문은 자동 변환 X. 예상 크기 알면 초기 capacity 지정 (확장 비용 절약).3️⃣ 단일 스레드 = StringBuilder, 멀티 스레드 = ThreadLocal 권장.
StringBuffer 의 synchronized 는 단일 스레드에서도 비용. 멀티 스레드에서도 StringBuffer 보다 ThreadLocal\<StringBuilder> 또는 ConcurrentLinkedQueue + 일괄 처리 가 보통 더 효율적. toString() 은 내부 배열 복사 → 새 String. ILIC 의 보고서/SQL/JSON 빌딩에서 StringBuilder 가 정답. Stream + Collectors.joining 도 내부에 StringBuilder.
Q1: String result = result + "x"; 를 1만 번 반복하면 왜 비효율적인지 메모리 관점에서 설명하라.
한 줄 답: 매번 새 String 객체 생성 + 기존 객체 복사 → O(n²) + GC 부담.
상세 설명:
String result = ""; // 빈 String 객체 #1
result = result + "x"; // 어떻게 동작?
실제 동작:
1. result + "x" 실행
2. 컴파일러가 → new StringBuilder(result).append("x").toString() 으로 변환
3. → 새 String 객체 #2 ("x") 생성
4. result 가 #2 를 가리킴
5. 객체 #1 ("") 은 Garbage
메모리 상태:
[Heap]
├── String #1 "" (Garbage)
└── String #2 "x" ← result
result = result + "x"; // result 는 99자 String
동작:
1. result (99자) + "x" → 새 String 100자
2. 99자 String 은 Garbage
3. 매번 n 글자 복사 발생
시간:
result = result + "x"; // result 는 9999자
동작:
총 처리 글자 수:
1 + 2 + 3 + ... + 10000
= 10000 * 10001 / 2
= 50,005,000 (약 5천만)
총 생성 객체 수:
10000 개 String 객체
9999 개가 Garbage
메모리 소비:
T(n) = 1 + 2 + 3 + ... + n
= n(n+1)/2
= O(n²)
실측 (1만 회):
String + 반복:
반복 1: [""] → [..."x"] (새 객체)
반복 2: ["x"] → [..."xx"] (새 객체, 1자 복사)
반복 3: ["xx"] → [..."xxx"] (새 객체, 2자 복사)
...
반복 N: ["xxx...x"] → [..."xxx...xx"] (새 객체, N-1 자 복사)
→ N 개 객체 + 누적 N²/2 글자 복사
StringBuilder:
초기: [버퍼 16자, 사용 0]
반복 1: [버퍼 16자, 사용 1] (자리 추가)
반복 2: [버퍼 16자, 사용 2]
...
반복 16: [버퍼 16자, 사용 16] (꽉 참)
반복 17: [버퍼 34자, 사용 17] (확장!)
...
반복 N: [충분한 버퍼, 사용 N]
→ 1 개 객체 + 가끔 확장 → 누적 ~2N 글자 (분할 상환 O(n))
String 의 불변성 은 안전이라는 이점을 주지만,
반복적 변경 에는 치명적 비효율.
StringBuilder 가 같은 작업을 1000배 빠르게.
→ 자바에서 StringBuilder 가 단순한 도구가 아니라 기본 도구.
Q2: StringBuilder 의 capacity 와 자동 확장의 비용은?
한 줄 답: capacity 부족 시 현재 * 2 + 2 로 확장 + 기존 데이터 복사. 분할 상환 O(n).
상세 설명:
StringBuilder sb = new StringBuilder();
System.out.println(sb.capacity()); // 16
System.out.println(sb.length()); // 0
sb.append("hello");
System.out.println(sb.capacity()); // 16 (확장 없음)
System.out.println(sb.length()); // 5
개념:
capacity = 내부 버퍼 크기 (value.length)length = 실제 사용 중인 문자 수 (count)length <= capacity 항상 성립그림:
[StringBuilder 내부]
├── byte[] value: [h][e][l][l][o][_][_][_][_][_][_][_][_][_][_][_]
│ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
│ ↑ ↑
│ length=5 capacity=16
public void ensureCapacityInternal(int minimumCapacity) {
if (minimumCapacity - value.length > 0) {
value = Arrays.copyOf(value, newCapacity(minimumCapacity));
}
}
private int newCapacity(int minCapacity) {
int newCapacity = (value.length << 1) + 2; // 현재 * 2 + 2
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
return newCapacity;
}
확장 규칙:
1. 새 capacity = 현재 * 2 + 2
2. 그래도 부족 → 필요한 크기로
예시:
초기: capacity = 16
첫 확장: 16 → 34 (16 * 2 + 2)
두 번째: 34 → 70
세 번째: 70 → 142
네 번째: 142 → 286
...
비용:
1. 새 배열 할당 — O(새 크기)
2. 기존 데이터 복사 — Arrays.copyOf — O(기존 크기)
3. 옛 배열은 GC 대상
시간:
N 개 문자 추가:
- 확장 횟수: log₂(N) (대략)
- 각 확장의 비용: O(현재 크기)
- 누적 복사: 16 + 34 + 70 + 142 + ... ≈ 2N
총 비용: N (추가) + 2N (복사) = O(n)
→ append 한 번 = O(1) 평균 (분할 상환).
나쁜 예 — 확장 빈번:
StringBuilder sb = new StringBuilder(); // 16
for (int i = 0; i < 1_000_000; i++) {
sb.append("x");
}
// 확장: 16 → 34 → 70 → 142 → ... → 1,048,594
// 약 17 번 확장
// 누적 복사: ~ 2 백만 글자
좋은 예 — 한 번에 충분히:
StringBuilder sb = new StringBuilder(1_000_000); // 100만
for (int i = 0; i < 1_000_000; i++) {
sb.append("x");
}
// 확장: 0 번
// 누적 복사: 0
// → 약 30% 빠름
| 상황 | 권장 |
|---|---|
| 짧은 결합 (~100 자) | 기본 (16) OK |
| 중간 (~1KB) | 명시 (1024) |
| 큰 (~MB) | 명시 (예상 크기) |
| 정확한 예측 어려움 | 기본 사용 (자동 확장 OK) |
capacity 와 length 는 다르다 — capacity 는 버퍼, length 는 실제.
자동 확장은 효율적 (분할 상환 O(1)) 이지만, 예상 크기를 알면 초기 capacity 지정 으로 더 나음.
1만 자 String 빌딩 시 — capacity 지정으로 약 30% 성능 개선.