String은 불변(immutable) 객체다. 한번 만들어진 문자열의 내용은 절대 바뀌지 않는다. toUpperCase(), replace(), substring()은 모두 새 String을 반환하고 원본은 그대로다.
String s = "hello";
s.toUpperCase(); // 반환값을 버림 — s는 여전히 "hello"
String t = s.toUpperCase(); // t = "HELLO", s = "hello"내부적으로 String은 private final byte[] value(JDK 9+, 이전에는 char[])를 갖고, 이 배열을 외부에 노출하거나 수정하는 메서드가 없으며, 클래스 자체가 final이라 상속으로 우회할 수도 없다.
왜 불변으로 설계했는가?
| 이유 | 설명 |
|---|---|
| String Pool 공유 | 같은 리터럴을 여러 변수가 공유해도 안전. 누군가 바꾸면 다른 변수까지 바뀌는 문제가 원천 차단 |
| 해시코드 캐싱 | hashCode() 를 한 번 계산해 hash 필드에 저장. 불변이라 가능 |
| 보안 | 파일 경로, URL, DB 접속 정보가 String. 검증 후 다른 스레드가 내용을 바꿔치기하는 공격 불가 |
| 스레드 안전 | 불변 객체는 동기화 없이 여러 스레드가 공유 가능 |
| 클래스 로딩 | 클래스 이름이 String. 바뀌면 엉뚱한 클래스가 로드될 수 있음 |
JDK 9의 Compact Strings: 문자열이 Latin-1 범위(ISO-8859-1)에만 있으면 문자당 1바이트, 아니면 UTF-16으로 2바이트를 쓴다(coder 필드로 구분). 영문 위주 문자열의 메모리를 절반으로 줄인 최적화다. 한글은 2바이트 인코딩을 쓴다.
String Pool(문자열 상수 풀)은 힙 안에 있는 특별한 영역으로, 문자열 리터럴을 한 번만 저장하고 재사용하는 캐시다. (JDK 7부터 힙으로 이동, 그 전에는 PermGen에 있었다.)
String a = "order"; // 풀에 "order" 생성 (또는 기존 것 재사용)
String b = "order"; // 풀의 같은 객체를 참조
String c = new String("order"); // 힙에 새 객체 생성 (풀과 별개)
String d = "or" + "der"; // 컴파일 타임 상수 접기 → "order" 리터럴과 동일 취급
String e = a.substring(0, 5); // 런타임 생성 → 새 객체
a == b // true (같은 풀 객체)
a == c // false (다른 객체)
a == d // true (컴파일러가 접었음)
a == e // false
a.equals(c) // true — 내용 비교| 연산 | 비교 대상 | 사용 시점 |
|---|---|---|
== |
참조(주소)가 같은가 | 원시 타입, 또는 "같은 객체인가"를 물을 때 |
equals() |
내용이 같은가 | 문자열 비교는 항상 이것 |
equalsIgnoreCase() |
대소문자 무시 내용 비교 | 사용자 입력 비교 |
compareTo() |
사전순 비교 (음수/0/양수) | 정렬 |
==가 "가끔" true를 반환하기 때문에 더 위험하다. 리터럴끼리 비교하는 테스트에서는 통과하고, DB나 네트워크에서 읽어 온 문자열(런타임 생성)과 비교하는 운영 환경에서는 실패한다.
intern()은 "이 문자열과 같은 내용이 풀에 있으면 그 참조를, 없으면 풀에 넣고 그 참조를 반환"한다. c.intern() == a는 true. 동일 문자열이 대량으로 반복될 때(예: 로그의 상태 코드) 메모리 절약용으로 쓰지만, 풀 조회 비용이 있으므로 남용은 금물이다.
String result = "";
for (int i = 0; i < 100_000; i++) {
result += i; // 매 반복마다 새 String 생성
}String은 불변이므로 result += i는 (1) 기존 result의 내용을 복사하고 (2) i를 붙인 새 String을 만들어 (3) result에 다시 대입한다. 반복 n번이면 복사 총량이 1+2+3+...+n ≈ n²/2 바이트. 10만 번이면 50억 바이트 복사다. O(n²)의 전형이다.
StringBuilder는 내부에 가변 배열을 두고 끝에 문자를 이어 붙인다. 배열이 꽉 차면 (기존 용량 × 2 + 2)로 확장하며 복사하는데, 이 확장은 드물게 일어나서 상각 비용이 O(1)이다. 전체 O(n).
JDK 9+ invokedynamic 최적화: 단일 문장 안의 a + b + c는 컴파일러가 StringConcatFactory를 이용한 invokedynamic 호출로 바꾼다(JEP 280). 이는 결과 길이를 미리 계산해 정확한 크기의 배열을 한 번만 할당하므로 매우 빠르다. JDK 8까지는 new StringBuilder().append(a).append(b)...로 변환됐다.
그러나 이 최적화는 한 문장 안에서만 적용된다. 루프를 돌면서 result += i를 하면 매 반복이 별도 문장이므로 매번 새 String이 만들어진다. 정리:
| 상황 | 권장 | 이유 |
|---|---|---|
한 문장에서 몇 개 연결 ("a" + b + "c") |
+ 그대로 |
JDK 9+에서 최적화됨. 가독성 좋음 |
| 루프에서 누적 연결 | StringBuilder |
+는 O(n²) |
| 컬렉션을 구분자로 연결 | String.join() / Collectors.joining() |
내부적으로 StringBuilder |
| 형식 지정 | String.format() / formatted() |
느리지만 가독성 우선인 곳 (로그 메시지 등) |
둘은 API가 동일하며 유일한 차이는 동기화다.
| StringBuilder | StringBuffer | |
|---|---|---|
| 스레드 안전 | 아니오 | 예 (모든 메서드 synchronized) |
| 속도 | 빠름 | 느림 (락 획득/해제 비용) |
| 등장 | JDK 5 | JDK 1.0 |
| 사용 | 기본 선택 | 여러 스레드가 같은 버퍼에 append할 때만 |
실무에서 여러 스레드가 하나의 StringBuffer를 공유하는 상황은 거의 없다. 대부분의 문자열 조립은 메서드 지역 변수로 이뤄지고, 지역 변수는 스레드 간 공유되지 않는다. 그래서 StringBuffer는 사실상 레거시다. "스레드 안전하니까 안전하게 StringBuffer"라는 판단은 불필요한 락 비용만 내는 것이다.
내부 확장: 기본 초기 용량 16. append 시 용량 부족하면 newCapacity = (oldCapacity << 1) + 2로 확장 후 Arrays.copyOf. 최종 크기를 알 수 있으면 new StringBuilder(expectedSize)로 초기 용량을 지정해 재할당을 없앨 수 있다.
| 메서드 | 예시 | 결과 | 주의 |
|---|---|---|---|
split(regex) |
"a,b,,c".split(",") |
[a, b, , c] |
인자가 정규식. "." 은 "\\." 로. 끝의 빈 문자열은 제거 |
join(delim, ...) |
String.join("-", "a", "b") |
"a-b" |
Iterable도 받음 |
format / formatted |
"%s=%,d".formatted("x", 1234) |
"x=1,234" |
%d 정수, %s 문자열, %.2f 소수, %,d 천 단위 |
strip() |
" a ".strip() |
"a" |
JDK 11+. trim()은 ASCII 공백만, strip()은 유니코드 공백까지 |
isBlank() |
" ".isBlank() |
true |
JDK 11+. isEmpty()는 길이 0만 |
repeat(n) |
"ab".repeat(3) |
"ababab" |
JDK 11+ |
lines() |
"a\nb".lines() |
Stream["a","b"] |
JDK 11+ |
chars() |
"ab".chars() |
IntStream[97,98] |
문자 단위 처리 |
contains / startsWith / endsWith |
boolean | 정규식 아님 | |
replace(a, b) |
"aaa".replace("a","b") |
"bbb" |
모두 치환. 정규식 아님 |
replaceAll(regex, b) |
"a1b2".replaceAll("\\d","") |
"ab" |
정규식 |
indexOf / lastIndexOf |
"abc".indexOf("c") |
2 |
없으면 -1 |
substring(b, e) |
"hello".substring(1, 3) |
"el" |
e는 exclusive |
charAt(i) |
"abc".charAt(1) |
'b' |
범위 밖은 예외 |
toCharArray() |
char[] |
문자 조작 후 new String(arr) |
텍스트 블록(JDK 15+): """로 감싼 여러 줄 문자열. SQL, JSON, HTML을 코드에 넣을 때 이스케이프 지옥에서 벗어난다. 들여쓰기는 닫는 """의 위치 또는 가장 적게 들여쓴 줄을 기준으로 공통 부분이 제거된다.
String json = """
{
"orderId": "%s",
"amount": %d
}
""".formatted("O-1", 30000);원시 타입(int, long, double...)은 객체가 아니라 컬렉션에 넣을 수 없고, null이 될 수 없고, 제네릭 타입 인자로 쓸 수 없다. Wrapper 클래스(Integer, Long, Double, Boolean, Character...)는 원시 값을 객체로 감싼다.
JDK 5부터 오토박싱(autoboxing, 원시 → Wrapper 자동 변환)과 언박싱(unboxing, 그 반대)이 지원되어 코드에서는 구분이 잘 안 보인다. 하지만 내부에서는 변환이 일어나며, 그 과정에 함정이 있다.
Integer boxed = 42; // 컴파일러: Integer.valueOf(42)
int primitive = boxed; // 컴파일러: boxed.intValue()함정 1: Integer 캐시와 ==
Integer.valueOf(n)은 -128 ~ 127 범위의 값을 미리 만들어 캐시해 두고 그 객체를 재사용한다. 범위 밖은 매번 new Integer. 따라서:
Integer a = 127, b = 127; a == b // true (캐시된 같은 객체)
Integer c = 128, d = 128; c == d // false (서로 다른 객체)이 캐시는 JVM 옵션(-XX:AutoBoxCacheMax)으로 상한을 늘릴 수 있어서 환경마다 결과가 달라질 수 있다. 결론: Wrapper 비교는 항상 equals() 또는 언박싱 후 비교. Long, Short, Byte, Character(0~127)도 같은 캐시 정책이고 Double, Float는 캐시가 없다.
함정 2: null 언박싱 NPE
Map<String, Integer> stock = new HashMap<>();
int qty = stock.get("SKU-1"); // get이 null 반환 → null.intValue() → NullPointerExceptionDB 컬럼이 nullable이면 엔티티 필드를 Integer로 두게 되는데, 이걸 int 변수에 대입하거나 산술 연산에 쓰면 NPE. stock.getOrDefault("SKU-1", 0) 또는 명시적 null 체크.
함정 3: 루프 안 박싱 비용
Long sum = 0L;
for (long i = 0; i < 1_000_000; i++) sum += i; // 매 반복 언박싱 + 덧셈 + 박싱(new Long)100만 개의 Long 객체가 생성된다. long sum으로 바꾸면 객체 생성이 0이 된다. JIT의 탈출 분석(escape analysis)이 일부를 제거해 주기도 하지만, 실측에서 보통 수 배 이상 느리고 GC 부담이 늘어난다.
parseInt vs valueOf
| 메서드 | 반환 | 용도 |
|---|---|---|
Integer.parseInt(String) |
int (원시) |
문자열을 숫자로 바꿔 계산에 바로 쓸 때 |
Integer.valueOf(String) |
Integer (객체, 캐시 활용) |
컬렉션에 넣거나 Wrapper가 필요할 때 |
Integer.valueOf(int) |
Integer |
박싱 (컴파일러가 오토박싱에 사용) |
new Integer(int) |
Integer |
JDK 9 deprecated, JDK 16+ 경고 강화. 캐시를 우회해 항상 새 객체. 쓰지 말 것 |
둘 다 숫자가 아닌 입력에 NumberFormatException(unchecked)을 던진다.
Math: 모두 static. abs, max/min, pow, sqrt, round(반올림, long 반환), floor/ceil(double 반환), random()(0.0 ≤ x < 1.0, 실무에서는 ThreadLocalRandom 또는 Random 권장), floorMod(음수에서도 양수 나머지).
오버플로 검출이 필요하면 Math.addExact, multiplyExact(초과 시 ArithmeticException).
System: currentTimeMillis()(벽시계, epoch ms — 시각 표시용), nanoTime()(단조 증가, 경과 시간 측정용 — 벤치마크는 반드시 이것), getenv()(환경 변수), getProperty()(JVM 시스템 프로퍼티, -D 옵션), exit(), arraycopy()(네이티브 배열 복사), lineSeparator().
Object: equals/hashCode/toString(01 레슨), getClass()(런타임 클래스), clone()(얕은 복사, Cloneable 필요 — 실무에서는 복사 생성자 권장), wait/notify(동시성 레슨), finalize()(JDK 9 deprecated, JDK 18 제거 예정 — Cleaner 또는 AutoCloseable 사용).
Objects 유틸리티(java.util): Objects.equals(a, b)(null 안전 비교), Objects.hash(...)(hashCode 조합), Objects.requireNonNull(x, "msg")(생성자 인자 검증), Objects.toString(x, "default"), Objects.requireNonNullElse(x, default).