🎯1주차 Unit 6.2 — StringBuilder vs StringBuffer

Psj·2026년 5월 8일

F-lab

목록 보기
42/230

🎯 Unit 6.2 — StringBuilder vs StringBuffer

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, 보고서).
동시성 측면에서 둘의 정확한 차이.


🌍 1. 세상 속 비유

String = 인쇄된 종이 / StringBuilder = 화이트보드

시나리오 1 — 인쇄된 종이 (String)

  • 한번 인쇄하면 고칠 수 없음
  • 수정하려면 → 새 종이 인쇄
  • 1000번 수정 → 1000장의 종이 (대부분 버려짐)
  • 자원 낭비 ⚠️
String result = "";
for (int i = 0; i < 1000; i++) {
    result += "item" + i;  // 매번 새 종이!
}
// → 1000장의 String 객체 → GC 부담

시나리오 2 — 화이트보드 (StringBuilder)

  • 지우고 다시 쓸 수 있음
  • 같은 보드에서 계속 수정
  • 1000번 수정 → 1개의 보드
  • 마지막에 결과를 종이로 인쇄 (toString())
  • 효율적
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append("item").append(i);  // 같은 보드에 추가
}
String result = sb.toString();  // 마지막에 종이로

StringBuffer = 잠긴 화이트보드 (멀티 스레드 안전)

시나리오 3 — 여러 사람이 같이 쓰는 화이트보드

  • 한 사람이 쓰는 동안 다른 사람 대기 (lock)
  • 차례로 한 명씩 → 순서 보장
  • 그러나 느림 (대기 시간)
  • → 멀티 스레드 환경 적합
StringBuffer sb = new StringBuffer();  // 동기화됨
// 여러 스레드가 안전하게 사용 가능

핵심 한 문장

"String 은 불변, StringBuilder/Buffer 는 가변. 단일 스레드 = StringBuilder, 멀티 스레드 = StringBuffer (그러나 거의 안 씀)."

비유 정리:

비유자바 클래스특성
인쇄된 종이String불변, 안전
개인 화이트보드StringBuilder가변, 빠름
공용 화이트보드StringBuffer가변, 동기화, 느림

🔥 2. 탄생 배경

"String 으로 다 되는데 왜 또 만들었나?"

자바 초기 (1.0):

  • String 만 있음
  • 문자열 조작은 모두 String 으로

문제 발견:

// 자바 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

시간 복잡도:

  • 매번 String 전체 복사 (길이 n)
  • n 번 반복 = O(n²)
  • 100,000 자 → 100억 회 연산 → 수 분 소요

자바 1.0 의 답 — StringBuffer

자바 1.0 부터 StringBuffer 등장:

  • 가변 char[] 버퍼
  • 메서드 호출 시 같은 버퍼에 추가
  • O(n) 시간 복잡도

그러나 한계:

  • 모든 메서드가 synchronized
  • 단일 스레드에서도 lock 비용
  • 90% 의 사용 시나리오 (단일 스레드) 에서 불필요한 비용

Java 5 의 답 — StringBuilder

Java 5 (2004):

  • StringBuffer 의 동기화 없는 버전
  • 단일 스레드에서 더 빠름
  • 인터페이스는 동일
StringBuilder sb = new StringBuilder();  // 단일 스레드용
sb.append("hello");

현재 표준:

  • StringBuilder 가 기본 선택
  • StringBuffer 는 멀티 스레드에서 공유할 때만

컴파일러의 자동 변환

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+ 의 invokedynamic

Java 9 부터 String + 의 컴파일 결과가 더 효율적:

  • invokedynamic 으로 런타임 최적화
  • StringConcatFactory 사용
  • 단순 합치기는 거의 StringBuilder 수준

그러나 — 반복문은 여전히 직접 StringBuilder 권장.


핵심 통찰

"가변 버퍼는 String 의 불변성을 보완하는 도구다."

String 의 불변성은 안전, StringBuilder/Buffer 는 효율. 둘이 공존하는 이유.
자바는 읽기/공유는 String, 만들기는 StringBuilder 의 패턴 권장.
단일 스레드 = StringBuilder, 멀티 스레드 (거의 없는 케이스) = StringBuffer.


💣 3. 없으면 생기는 문제

시나리오 1: ILIC 의 보고서 생성 — 운영 사고

// ❌ 비효율 코드 — 운영에서 자주 보임
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;
}

현실 영향:

  • 1만 건 화물 보고서 → 1만 번 String 새로 생성
  • 1MB 의 최종 문자열을 1만 번 복사 → 수 GB 메모리 사용
  • GC 폭증 → API 응답 지연
  • → 매월 보고서 생성 시 서버 멈춤

해결:

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();
}

→ 메모리 ↓, 속도 ↑.


시나리오 2: 동적 SQL 생성

// ❌ 비효율
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;
}

문제:

  • 단순한 조회에서는 차이 미미
  • 그러나 운영 환경의 백만 번 호출 시 누적 비용

시나리오 3: 멀티 스레드 동시 접근

// ❌ 위험: 멀티 스레드에서 StringBuilder
public class LogCollector {
    private static StringBuilder log = new StringBuilder();
    
    public static void append(String msg) {
        log.append(msg);  // ❌ 동기화 X → 데이터 손실
    }
}

문제:

  • 여러 스레드가 동시 append → race condition
  • 일부 메시지 손실 또는 깨진 데이터
  • IndexOutOfBoundsException 가능

해결책 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<>();

시나리오 4: 면접 단골 질문

"String, StringBuilder, StringBuffer 의 차이는?"

모르면:

  • "음... StringBuilder 는 빠르고 StringBuffer 는 느려요?"
  • → 시니어 자격 의심

알면:

  • 불변 vs 가변
  • 동기화 여부 (StringBuffer = synchronized)
  • 시간 복잡도 (String + = O(n²) vs StringBuilder = O(n))
  • 사용 시점 결정 가능

시나리오 5: 작은 차이의 누적 영향

// 일견 문제 없어 보이는 코드
public String formatPhoneNumber(String raw) {
    String result = "";
    for (char c : raw.toCharArray()) {
        if (Character.isDigit(c)) {
            result += c;  // 짧은 문자열이지만...
        }
    }
    return formatHyphens(result);
}

누적 영향:

  • 메서드 자체는 빠름 (~수 μs)
  • 그러나 매 API 호출마다 호출 → 일 100만 번
  • → 누적 GC 비용 + 성능 저하

해결:

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());
}

시나리오 6: API 응답 빌딩 — JSON 수동 생성

// 심각하게 비효율
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;
}

더 좋은 방법:

  • JSON 라이브러리 (Jackson, Gson) 사용
  • 만약 직접 만들어야 한다면 StringBuilder
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 conditionStringBuffer 또는 ThreadLocal
면접탈락시니어 답변
작은 메서드 누적잠재 성능 문제처음부터 최적

자바 시니어의 기본 도구.


✅ 4. 해결책 — 두 클래스의 정확한 이해

StringBuilder ⭐ (단일 스레드 표준)

핵심 특징

public final class StringBuilder 
    extends AbstractStringBuilder
    implements Serializable, Comparable<StringBuilder>, CharSequence {
    
    // 부모 클래스 AbstractStringBuilder 의 필드
    // byte[] value;       (Java 9+) 또는 char[] (Java 8 이하)
    // int count;          현재 길이
    // capacity = value.length
}

핵심:

  • 가변 (mutable)
  • 동기화 X (단일 스레드)
  • 빠름
  • Java 5+ 도입

주요 메서드

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 으로

Method Chaining (메서드 체이닝)

String result = new StringBuilder()
    .append("Hello")
    .append(", ")
    .append(name)
    .append("!")
    .toString();

이게 가능한 이유:

  • 모든 modify 메서드가 this 반환
  • → 깔끔한 코드 + 객체 1개로 작업
public StringBuilder append(String str) {
    super.append(str);
    return this;  // ← 자기 자신 반환
}

StringBuffer (멀티 스레드 — 거의 안 씀)

핵심 특징

public final class StringBuffer extends AbstractStringBuilder ... {
    
    @Override
    public synchronized StringBuffer append(String str) {  // ← synchronized!
        toStringCache = null;
        super.append(str);
        return this;
    }
    
    // 거의 모든 public 메서드가 synchronized
}

핵심:

  • StringBuilder 와 인터페이스 동일
  • 모든 메서드 synchronizedThread-Safe
  • 단일 스레드에서도 lock 비용 → 느림
  • Java 1.0+

성능 비교 (1만 번 append)

클래스시간사용 시점
String +~5초절대 X
StringBuilder~5ms단일 스레드 (99%)
StringBuffer~10ms멀티 스레드 (드묾)

셋 비교 표 ⭐⭐ (면접 답변 핵심)

StringStringBuilderStringBuffer
불변/가변불변가변가변
동기화(해당 없음)X
속도매우 느림 (변경 시)빠름보통
Thread-Safe✓ (불변)
시간 복잡도O(n²)O(n)O(n)
언제읽기/공유단일 스레드 빌딩멀티 스레드 공유
등장Java 1.0Java 5Java 1.0

사용 결정 가이드

문자열을 어떻게 사용?
├── 읽기/공유만 → String
├── 만들기 (반복 등)
│   ├── 단일 스레드 → StringBuilder ⭐
│   └── 멀티 스레드 공유 (드묾)
│       ├── StringBuffer
│       └── 또는 ThreadLocal<StringBuilder>
└── 비교/조회 → String + equals

현실 — 99% 의 경우:

  • 읽기: String
  • 빌딩: StringBuilder
  • StringBuffer 는 거의 사용 안 함

Java 의 컴파일러 최적화

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 사용.


🏗️ 5. 내부 동작 원리

내부 구조 (AbstractStringBuilder)

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 보다 큼)

초기 capacity

new StringBuilder()              // capacity = 16 (기본)
new StringBuilder(100)           // capacity = 100
new StringBuilder("hello")       // capacity = 5 + 16 = 21
new StringBuilder(initialString) // capacity = initialString.length() + 16

왜 16?:

  • 자바 표준 기본값
  • 작은 String 의 경우 확장 없이 처리 가능
  • 너무 크면 메모리 낭비, 너무 작으면 확장 빈번

자동 확장 메커니즘

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)
  • 옛날 배열 GC 대상

시간 복잡도:

  • 단일 append = O(1) 평균 (분할 상환)
  • 확장 발생 시 = O(n)
  • n 번 append 전체 = O(n) (분할 상환 분석)

초기 capacity 설정의 효과

// ❌ 확장 여러 번
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 의 동기화

// 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 메커니즘:

  • 객체 자체에 모니터 (intrinsic lock)
  • 한 스레드가 메서드 호출 시 → 다른 스레드 대기
  • 메서드 종료 시 → 다음 스레드 진입

성능 영향:

  • 단일 스레드에서도 lock 획득/해제 비용
  • JIT 최적화로 일부 완화 (Lock Elision)

toString() 의 동작

public String toString() {
    return new String(value, 0, count);
}

핵심:

  • 내부 배열을 복사 해서 새 String 생성
  • StringBuilder 의 버퍼와 String 은 별개
  • StringBuilder 수정 시 String 영향 X
StringBuilder sb = new StringBuilder("hello");
String s = sb.toString();
sb.append(" world");
System.out.println(s);   // "hello" (변하지 않음)
System.out.println(sb);  // "hello world"

Java 9+ Compact Strings

abstract class AbstractStringBuilder {
    byte[] value;
    byte coder;  // LATIN1 (1 byte/char) or UTF16 (2 bytes/char)
}

효과:

  • ASCII 만 → LATIN1 → 메모리 50% 절약
  • 한글 등 → UTF16 → 기존과 동일

ILIC 같은 한글/영문 혼용 시스템:

  • 효과 작을 수 있음 (대부분 UTF16)
  • 그러나 영문 코드/숫자가 많은 부분에서는 효과

+ 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 권장.


💻 6. 실전 코드 예시

예시 1: ILIC 의 효율적인 보고서 생성

@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();
    }
}

예시 2: 동적 SQL 빌더

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 활용 권장. 위는 학습용 예시.


예시 3: CSV 파싱 / 생성

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;
    }
}

예시 4: 멀티 스레드 — StringBuffer 시연

// ❌ 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();
    }
}

예시 5: 성능 비교 벤치마크

@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 사용)
    }
}

예시 6: ILIC — 알림 메시지 빌더

@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();
    }
}

예시 7: String.format vs StringBuilder

// 두 가지 방법 비교
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
  • 반복문에서 빌딩: StringBuilder 필수
  • 로깅: 최신 라이브러리는 자체 최적화

예시 8: Stream API 와 함께

// ✓ 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=== 끝 ==="           // 끝
        ));
}

⚠️ 7. 주의사항 & 흔한 실수

실수 1: 반복문에서 String +

// ❌ 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();

실수 2: 단일 스레드에서 StringBuffer

// ❌ 불필요한 동기화 비용
StringBuffer sb = new StringBuffer();
// 단일 스레드에서만 사용

// ✓
StringBuilder sb = new StringBuilder();

실수 3: 멀티 스레드에서 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);
}

실수 4: capacity 무시 (잠재적 비효율)

// ❌ 확장 여러 번
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);
}

실수 5: toString() 여러 번 호출

StringBuilder sb = new StringBuilder();
// ... 빌딩 ...

String s1 = sb.toString();  // 새 String 생성
String s2 = sb.toString();  // 또 새 String 생성!

// ✓ 한 번만
String result = sb.toString();
String s1 = result;
String s2 = result;

실수 6: 짧은 문자열에서도 StringBuilder

// ❌ 과한 사용
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.


실수 7: equals 로 StringBuilder 비교

StringBuilder sb1 = new StringBuilder("hello");
StringBuilder sb2 = new StringBuilder("hello");

sb1.equals(sb2);  // ❌ false! (Object.equals — 참조 비교)

이유:

  • StringBuilder 는 equals() 오버라이드 X
  • 비교하려면 toString().equals() 또는 compareTo()
// ✓
sb1.toString().equals(sb2.toString());  // true

실수 8: 멀티스레드에서 toString 도 위험

// ❌ StringBuffer 라도 다음은 안전 X
StringBuffer log = new StringBuffer();

// Thread 1
log.append("event1");
String snapshot = log.toString();  // 다른 스레드가 수정 중일 수 있음

// 즉, 개별 메서드는 동기화되지만 여러 메서드 간은 X

해결: 외부 동기화 또는 다른 패턴.


🔗 8. 연관 개념 맵

Phase 6 (데이터 다루기) 에서의 위치

[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]

Phase 4-5 와의 연결

학습연결
Unit 4.1 (Heap)StringBuilder 의 char[] 도 Heap
Unit 5.1 (GC)String + 의 Garbage 폭발 = GC 부담
Unit 6.1 (String)가변 vs 불변의 대조

Stream API 와의 관계

// 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]"

동시성 패턴 비교

방식설명적합
StringBuffersynchronized 메서드옛 코드
ThreadLocal스레드별 독립일반적
ConcurrentLinkedQueue + 나중 합치기비동기 수집 후 일괄고성능
Reactive (Flux 등)스트림 기반최신

다른 언어와 비교

언어가변 String
JavaStringBuilder, StringBuffer
C#StringBuilder (Java 와 비슷)
Pythonlist + ''.join() (관용 패턴)
JavaScriptArray + .join() 또는 += (V8 이 최적화)
C++std::string (가변, +=)
RustString (가변), &str (불변)

면접 단골 질문 매핑

질문이 Unit 에서의 답
String, StringBuilder, StringBuffer 차이?불변 / 가변 / 동기화
시간 복잡도?String + = O(n²), StringBuilder = O(n)
언제 어떤 거 쓰나?단일=Builder, 멀티=Buffer (드묾)
capacity 와 length 차이?버퍼 크기 vs 실제 길이
자동 확장은?* 2 + 2

📝 9. 핵심 요약 — 3줄 정리

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.


🎓 학습 자기 점검

기본 이해

  • String, StringBuilder, StringBuffer 셋의 차이를 표로 그릴 수 있다
  • StringBuilder 가 빠른 이유를 메모리 관점에서 설명할 수 있다
  • StringBuffer 의 synchronized 의 비용을 안다
  • capacity 와 length 의 차이를 안다

실전 적용

  • 반복문에서 StringBuilder 자동 사용
  • 멀티 스레드 환경에서 적절한 선택 가능
  • 예상 크기에 맞는 초기 capacity 지정
  • Stream + Collectors.joining 활용

면접 대비 (5분 답변)

  • "String, StringBuilder, StringBuffer 차이?" 답변 가능
  • "왜 String + 가 비효율인가?" 메모리 그림 답변
  • "언제 어떤 거 쓰나?" 정확한 답변
  • capacity 자동 확장 메커니즘 설명

자기 점검 질문 답변

Q1: String result = result + "x"; 를 1만 번 반복하면 왜 비효율적인지 메모리 관점에서 설명하라.

한 줄 답: 매번 새 String 객체 생성 + 기존 객체 복사 → O(n²) + GC 부담.

상세 설명:

Step 1: 첫 반복 (i=0)

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

Step 2: 100번째 반복 (i=99)

result = result + "x";  // result 는 99자 String

동작:
1. result (99자) + "x" → 새 String 100자
2. 99자 String 은 Garbage
3. 매번 n 글자 복사 발생

시간:

  • 99자 복사 + 1자 추가 = 100 개 글자 처리

Step 3: 10000번째 반복

result = result + "x";  // result 는 9999자

동작:

  • 9999자 복사 + 1자 추가 = 10000 개 글자 처리
  • 그 전 String 은 Garbage

누적 분석

총 처리 글자 수:

1 + 2 + 3 + ... + 10000
= 10000 * 10001 / 2
= 50,005,000 (약 5천만)

총 생성 객체 수:

10000 개 String 객체
9999 개가 Garbage

메모리 소비:

  • 평균 String 크기 = 5000 자
  • 1만 개 객체 = 50,000,000 자
  • 1자 = 2 byte (UTF16) = 100 MB 메모리 사용
  • 그 중 99% 가 Garbage → GC 폭증

시간 복잡도

T(n) = 1 + 2 + 3 + ... + n
     = n(n+1)/2
     = O(n²)

실측 (1만 회):

  • String + : ~5초
  • StringBuilder : ~5ms
  • 1000배 느림

시각화

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).

상세 설명:

capacity vs length

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 대상

시간:

  • 단일 확장 = O(현재 크기)
  • 그러나 빈도가 적음 → 분할 상환 O(1) per append

분할 상환 분석 (Amortized Analysis)

N 개 문자 추가:
- 확장 횟수: log₂(N) (대략)
- 각 확장의 비용: O(현재 크기)
- 누적 복사: 16 + 34 + 70 + 142 + ... ≈ 2N

총 비용: N (추가) + 2N (복사) = O(n)

append 한 번 = O(1) 평균 (분할 상환).


초기 capacity 의 효과

나쁜 예 — 확장 빈번:

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% 빠름

capacity 지정의 가이드

상황권장
짧은 결합 (~100 자)기본 (16) OK
중간 (~1KB)명시 (1024)
큰 (~MB)명시 (예상 크기)
정확한 예측 어려움기본 사용 (자동 확장 OK)

결론

capacity 와 length 는 다르다 — capacity 는 버퍼, length 는 실제.
자동 확장은 효율적 (분할 상환 O(1)) 이지만, 예상 크기를 알면 초기 capacity 지정 으로 더 나음.
1만 자 String 빌딩 시 — capacity 지정으로 약 30% 성능 개선.


다음 Unit으로

  • ArrayList vs LinkedList 학습 준비 완료
  • List 자료구조의 선택 기준이 궁금하다
  • 메모리 모델 (Phase 4) + GC (Phase 5) 의 적용 사례 만날 준비
profile
Software Developer

0개의 댓글