공공부하자개발 · 영어 학습 노트
자바
중급객체지향과 코어 라이브러리0/9 완료
  • 01상속, 다형성, 오버라이딩
  • 02추상 클래스 vs 인터페이스
  • 03예외 처리
  • 04java.lang 심화
  • 05컬렉션 프레임워크 딥다이브
  • 06메서드 활용 패턴 (중급)
  • 07Object 메서드와 비교
  • 08java.time 실무 날짜 계산
  • 09HTTP 와 JSON 기초
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 중급 › 04 / 9

java.lang 심화

String, StringBuilder, Wrapper
섹션 7진행 0 / 9
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 String의 불변성과 그 이유

String은 불변(immutable) 객체다. 한번 만들어진 문자열의 내용은 절대 바뀌지 않는다. toUpperCase(), replace(), substring()은 모두 새 String을 반환하고 원본은 그대로다.

java
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바이트 인코딩을 쓴다.

2.2 String Pool과 == vs equals

String Pool(문자열 상수 풀)은 힙 안에 있는 특별한 영역으로, 문자열 리터럴을 한 번만 저장하고 재사용하는 캐시다. (JDK 7부터 힙으로 이동, 그 전에는 PermGen에 있었다.)

java
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. 동일 문자열이 대량으로 반복될 때(예: 로그의 상태 코드) 메모리 절약용으로 쓰지만, 풀 조회 비용이 있으므로 남용은 금물이다.

2.3 문자열 연결의 성능: 왜 루프 안 +는 느린가

java
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() 느리지만 가독성 우선인 곳 (로그 메시지 등)

2.4 StringBuilder vs StringBuffer

둘은 API가 동일하며 유일한 차이는 동기화다.

StringBuilder StringBuffer
스레드 안전 아니오 예 (모든 메서드 synchronized)
속도 빠름 느림 (락 획득/해제 비용)
등장 JDK 5 JDK 1.0
사용 기본 선택 여러 스레드가 같은 버퍼에 append할 때만

실무에서 여러 스레드가 하나의 StringBuffer를 공유하는 상황은 거의 없다. 대부분의 문자열 조립은 메서드 지역 변수로 이뤄지고, 지역 변수는 스레드 간 공유되지 않는다. 그래서 StringBuffer는 사실상 레거시다. "스레드 안전하니까 안전하게 StringBuffer"라는 판단은 불필요한 락 비용만 내는 것이다.

내부 확장: 기본 초기 용량 16. append 시 용량 부족하면 newCapacity = (oldCapacity << 1) + 2로 확장 후 Arrays.copyOf. 최종 크기를 알 수 있으면 new StringBuilder(expectedSize)로 초기 용량을 지정해 재할당을 없앨 수 있다.

2.5 주요 String 메서드

메서드 예시 결과 주의
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을 코드에 넣을 때 이스케이프 지옥에서 벗어난다. 들여쓰기는 닫는 """의 위치 또는 가장 적게 들여쓴 줄을 기준으로 공통 부분이 제거된다.

java
String json = """
    {
      "orderId": "%s",
      "amount": %d
    }
    """.formatted("O-1", 30000);

2.6 Wrapper 클래스와 오토박싱

원시 타입(int, long, double...)은 객체가 아니라 컬렉션에 넣을 수 없고, null이 될 수 없고, 제네릭 타입 인자로 쓸 수 없다. Wrapper 클래스(Integer, Long, Double, Boolean, Character...)는 원시 값을 객체로 감싼다.

JDK 5부터 오토박싱(autoboxing, 원시 → Wrapper 자동 변환)과 언박싱(unboxing, 그 반대)이 지원되어 코드에서는 구분이 잘 안 보인다. 하지만 내부에서는 변환이 일어나며, 그 과정에 함정이 있다.

java
Integer boxed = 42;            // 컴파일러: Integer.valueOf(42)
int primitive = boxed;         // 컴파일러: boxed.intValue()

함정 1: Integer 캐시와 ==

Integer.valueOf(n)은 -128 ~ 127 범위의 값을 미리 만들어 캐시해 두고 그 객체를 재사용한다. 범위 밖은 매번 new Integer. 따라서:

java
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

java
Map<String, Integer> stock = new HashMap<>();
int qty = stock.get("SKU-1");   // get이 null 반환 → null.intValue() → NullPointerException

DB 컬럼이 nullable이면 엔티티 필드를 Integer로 두게 되는데, 이걸 int 변수에 대입하거나 산술 연산에 쓰면 NPE. stock.getOrDefault("SKU-1", 0) 또는 명시적 null 체크.

함정 3: 루프 안 박싱 비용

java
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)을 던진다.

2.7 Math / System / Object 요점

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

핵심 원리
  • 2.1 String의 불변성과 그 이유
  • 2.2 String Pool과 == vs equals
  • 2.3 문자열 연결의 성능: 왜 루프 안 +는 느린가
  • 2.4 StringBuilder vs StringBuffer
  • 2.5 주요 String 메서드
  • 2.6 Wrapper 클래스와 오토박싱
  • 2.7 Math / System / Object 요점
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제