[Java] String 은 왜 불변(Immutable)일까? ( feat. StringBuilder를 써야 하는 이유)

2025. 11. 17. 15:32·Develop/Back-End
반응형

 

코테 풀다가 한 번쯤 턱 맞는 포인트

프로그래머스 같은 데서 이런 문제 한 번쯤 본 적 있을 거다.

문자열 my_string 과 정수 k가 주어질 때,
my_string 을 k번 반복한 문자열을 return 하세요.

대부분 처음에는 이렇게 짠다.

public String solution(String my_string, int k) {
    String answer = "";
    for (int i = 0; i < k; i++) {
        answer += my_string;
    }
    return answer;
}

테스트 몇 개 돌려보면 잘 동작하고, 길이도 작으면 제출해도 그냥 통과한다.
근데 문자열 길이랑 k가 좀만 커지면 이런 코드 패턴이 진짜 성능 구멍이 된다.

이유는 하나다.

Java 의 String 은 “불변(immutable)” 이기 때문.

이 글에서는

String 이 불변이라는 게 정확히 무슨 뜻인지

왜 그렇게 설계됐는지

코테/실무에서 어디서 사고가 나는지

그래서 실제로 코드를 어떻게 짜는 게 좋은지

정리해보려고 한다.


1.String 이 불변이라는 말의 의미

 

아주 단순한 코드 하나 보자.

String s = "hello";  
s.toUpperCase();  
System.out.println(s); // hello  

처음 보면 여기서 한 번씩 당황한다.
“대문자로 바꾸라고 했는데 왜 그대로지?”

이유는 간단하다.

toUpperCase() 는 기존 String 을 수정하는 메서드가 아니다.

대문자로 변환된 새 String 객체 를 만들어서 리턴할 뿐이다.

원래 "hello" 라는 문자열 객체는 그대로 남아 있다.

정상적인 사용은 이렇게 된다.

String s = "hello";  
s = s.toUpperCase();  
System.out.println(s); // HELLO  

정리하면:

한 번 만들어진 String 객체의 내용은 절대 안 바뀐다.
바뀌는 건 그 객체를 가리키는 변수 뿐이다.

이게 String 이 불변(immutable) 이라는 말의 의미다.


2. 왜 굳이 String 을 불변으로 만들었을까?

“그냥 가변으로 만들어서 바꾸기 편하게 해주지…” 싶은데,
Java 쪽에서 일부러 불변으로 설계한 이유가 몇 가지 있다.

2-1. 보안(Security)

String 은 이런 데 자주 쓰인다.

DB 커넥션 URL, 사용자 ID, Password

파일 경로

Class 이름, 리플렉션 관련 문자열

네트워크 주소, 각종 설정값

만약 String 이 가변이면,
어딘가에서 참조를 들고 있다가 중간에 값을 바꿔버리는 것도 가능해진다.

String password = "secret";  
// 다른 코드가 password 를 바꿔버릴 수 있다면?  

불변이면 이런 류의 공격 자체가 구조적으로 막힌다.
한 번 만들어진 문자열의 내용은 누구도 바꿀 수 없기 때문.

 

2-2. 스레드 안전(Thread-Safety)

멀티스레드 환경에서 여러 스레드가 같은 문자열을 공유하는 경우가 많다.

같은 URL

같은 SQL 쿼리

같은 메시지 템플릿 등

만약 가변 타입이면, 동기화 안 하면 레이스 컨디션, 데이터 깨짐이 발생할 수 있다.
하지만 String 은 불변이라서,

여러 스레드가 동시에 읽기만 해도 안전 하고

따로 synchronized 를 걸 필요도 없다.

그래서 JVM 내부, 라이브러리 내부에서도 String 을 마음 편하게 공유해서 쓴다.

2-3. String Pool & 캐싱

Java 에는 String Pool(상수 풀) 이라는 개념이 있다.

String a = "hello";  
String b = "hello";  

System.out.println(a == b); // true (같은 객체)  

리터럴 "hello" 는 JVM 상수 풀에 한 번만 올라가고,
같은 리터럴을 쓰는 변수는 그 객체를 재사용한다.

이게 가능한 이유도 String 이 불변이기 때문이다.
누군가 "hello" 라는 문자열의 내용을 중간에 바꿔버릴 수 있다면,
상수 풀이라는 개념이 성립할 수 없다.

추가로 String 은 hashCode() 결과도 한 번 계산해두고 재사용한다.
그래서 HashMap 의 key, HashSet 의 원소로 자주 쓰이는 것도 이 덕분이다.


3.불변이라서 생기는 성능 문제 – 코테 예시로 보기

다시 처음 코테 문제로 돌아가 보자.

my_string 을 k번 반복해서 이어붙인 문자열을 반환

처음 짰던 코드:

public String solution(String my_string, int k) {  
    String answer = "";  
    for (int i = 0; i < k; i++) {  
        answer += my_string;  
    }  
    return answer;  
}

겉으로 보면

첫 번째 반복: "" + "abc" → "abc"

두 번째 반복: "abc" + "abc" → "abcabc"

세 번째 반복: "abcabc" + "abc" → "abcabcabc"

이렇게 보이는데, 내부에서는 이런 일이 일어난다.

answer = answer + my_string;

이 한 줄이 컴파일되면 실제로는 대략:

answer = new StringBuilder(answer)  
             .append(my_string)  
             .toString();

이런 식으로 바뀐다.

즉, 매 반복마다:

기존 answer 내용을 전부 복사해서

my_string 을 뒤에 붙인 새 문자열을 만들고

그걸 다시 answer 에 대입

길이가 커질수록 매번 더 긴 문자열을 계속 복사하는 구조라서
결국 전체 시간 복잡도는 O(n²) 쪽으로 간다.

문자열이 10개, 100개 이럴 때는 티가 안 나는데,
길이 10만, 20만 이런 쪽으로 가면 이 차이가 바로 시간 초과로 튀어나온다.


4.StringBuilder 를 써야 하는 이유

그래서 문자열 누적할 때는 보통 이렇게 바꾼다.

public String solution(String my_string, int k) {  
    StringBuilder sb = new StringBuilder(my_string.length() * k);  
    for (int i = 0; i < k; i++) {  
        sb.append(my_string);  
    }  
    return sb.toString();  
}

StringBuilder 는 내부에 char[] 버퍼를 하나 들고 있고,
append() 할 때마다 그 버퍼에 글자를 채워 넣는 식으로 동작한다.

버퍼가 부족해지면 가끔씩만 크게 확장

전체적으로는 O(n) 에 가까운 시간 복잡도

여기서

new StringBuilder(my_string.length() * k);

이렇게 capacity 를 미리 잡아두는 이유는,

최종 문자열 길이를 대략 알고 있으니까

중간에 버퍼 확장(reallocate) 이 거의 안 일어나게 하려는 것

코테에서 “문자열 이어붙이기” 계열 문제는
이 패턴 하나만 붙잡고 있어도 웬만한 건 다 처리된다.


5.“컴파일러가 + 를 StringBuilder 로 바꿔준다면서?”

자바 컴파일러가 문자열 더하기를 최적화해 준다는 얘기도 많이 들을 거다.

String s = "a" + "b" + "c";  

이 코드는 컴파일할 때 대략 이렇게 바뀐다.

String s = new StringBuilder()  
        .append("a")  
        .append("b")  
        .append("c")  
        .toString();  

그래서 한 줄 안에서 상수 + 상수 + 상수 이런 건 크게 걱정 안 해도 된다.

문제는 반복문 안에서 누적할 때 이다.

String s = "";  
for (int i = 0; i < n; i++) {  
    s = s + "a";  
}  

이건 반복마다 다음처럼 바뀐다.

s = new StringBuilder(s)  
        .append("a")  
        .toString();  

매번 새로운 StringBuilder 를 만들고,
이전 문자열 전체를 복사하고, 새 String 을 만들고… 이게 반복된다.

그래서 실질적인 가이드는 이렇게 보면 된다.

한 줄 안에서 "a" + "b" + "c"
→ 컴파일러가 알아서 최적화해주니 괜찮다.

반복문 안에서 문자열 누적 (+=, s = s + ...)
→ 성능 구멍이 될 수 있으니 무조건 StringBuilder 를 쓰는 게 안전하다.


6.Java 11 이상이라면 repeat() 도 있다

문제 조건이 “문자열을 k번 반복해서 이어붙여라” 딱 이 정도면,
Java 11 이상에서는 아예 메서드가 준비돼 있다.

public String solution(String my_string, int k) {  
    return my_string.repeat(k);  
}

내부 구현도 결국 StringBuilder 비슷한 방식으로 효율적으로 동작하기 때문에,
환경이 허용하면 이게 제일 깔끔하다.

정리

Java 11+ : my_string.repeat(k);

Java 8 기준 : StringBuilder + append 반복


7.불변이라서 생기는 또 다른 함정들

불변이라는 특성 때문에 자주 헷갈리는 부분 몇 개만 정리해 둔다.

7-1. 문자열 메서드들은 전부 “새 String 반환”

아래 코드를 보자.

String s = " hello ";  

s.trim();  
s.toUpperCase();  
s.replace("H", "J");  

System.out.println(s); // " hello "  

값이 하나도 안 바뀐다.

이유는:

trim(), toUpperCase(), replace() 전부  

기존 문자열을 고치는 게 아니라, 새 문자열을 만들어서 리턴하는 메서드이기 때문

정상적인 사용은 이렇게 해야 한다.

String s = " hello ";  

s = s.trim();             // "hello"  
s = s.toUpperCase();      // "HELLO"  
s = s.replace("H", "J");  // "JELLO"  

체이닝해도 마찬가지다.

String s = " hello ";  
s = s.trim()  
     .toUpperCase()  
     .replace("H", "J");  

7-2. substring 도 동일

String s = "abcdef";  
String sub = s.substring(0, 3); // "abc"  

System.out.println(s);   // "abcdef"  
System.out.println(sub); // "abc"  

substring() 이 원본을 자르는 게 아니라,
잘라진 부분만 따로 담은 새 String 을 만들어준다고 생각하면 된다.


8.코테/실무에서 String 불변 때문에 생기는 패턴 정리

마지막으로, 실전에서 그냥 규칙처럼 가져가도 되는 패턴들만 정리해본다.

반복문에서 문자열 누적 → 무조건 StringBuilder

StringBuilder sb = new StringBuilder();  
for (...) {  
    sb.append(...);  
}  
String result = sb.toString();  

출력 많이 하는 백준/프로그래머스 스타일 문제

StringBuilder sb = new StringBuilder();  
for (int i = 0; i < n; i++) {  
    // 로직  
    sb.append(result).append('\\n');  
}  

System.out.print(sb);

문자열 반복 (my_string, k 문제 같은 것)

Java 11 이상: my_string.repeat(k)

Java 8 기준: StringBuilder 로 직접 반복

문자열 조작 메서드들은 전부 “새 객체 반환”이라는 걸 항상 기억하기

trim, toUpperCase, toLowerCase, replace, substring …

호출만 하고 변수에 다시 대입 안 하면, 원본은 그대로다.


9.마무리

정리하면, 머릿속에는 이 정도만 남겨두면 된다.

String 은 한 번 만들어지면 안 바뀐다.
바뀌는 건 그걸 가리키는 변수뿐이다.

여기에:

반복문에서 문자열 누적 시 StringBuilder 써라

많이 반복되는 문자열은 capacity 미리 잡아라

문자열 메서드는 항상 “새 String” 을 리턴한다

이 정도만 붙여두면,
코테에서도 시간초과 덜 나고 실무 코드에서도 의미 없이 느린 문자열 처리 코드를 피할 수 있다.

나중에 기회 되면 여기서 파생되는 주제들
(예: String Pool, intern(), == vs equals() 비교 등)
따로 빼서 면접 대비용으로 정리해도 괜찮을 것 같다.

::contentReference\[oaicite:0\]{index=0}
반응형
저작자표시 비영리 (새창열림)

'Develop > Back-End' 카테고리의 다른 글

[Java] 리플렉션(Reflection) 이란 무엇인가요?  (7) 2024.10.07
[SpringBoot] 스프링을 이용하여 가벼운(간단한) 스케줄링 작업 처리하는 방법 😁  (4) 2024.08.13
[Java] 모던 자바(Modern JAVA) 란 무엇인가!!!!😒 (feat. 새롭게 추가된 기능들)  (105) 2024.03.24
스프링 배치(Spring Batch) 시작하기 !😭  (131) 2024.02.03
스프링 부트 카카오 로그인 API 기능 추가하기 😳  (114) 2023.10.22
'Develop/Back-End' 카테고리의 다른 글
  • [Java] 리플렉션(Reflection) 이란 무엇인가요?
  • [SpringBoot] 스프링을 이용하여 가벼운(간단한) 스케줄링 작업 처리하는 방법 😁
  • [Java] 모던 자바(Modern JAVA) 란 무엇인가!!!!😒 (feat. 새롭게 추가된 기능들)
  • 스프링 배치(Spring Batch) 시작하기 !😭
    반응형
  • 개발자는어디까지공부해야할까?
  • 전체
    오늘
    어제
    • 분류 전체보기 (51)
      • 인디해커 (1)
      • Develop (42)
        • Front-End (7)
        • Back-End (17)
        • Spring (1)
        • Tool (1)
        • DATABASE (1)
        • DevOps (7)
        • CS (3)
        • Trouble Shooting (5)
      • 다이소 (1)
        • 코딩테스트문제풀이 (1)
      • 변소 (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 깃허브(Github)
    • 개발 Feed
  • 공지사항

  • 인기 글

  • 태그

    SpringBoot
    JavaScript
    리액트
    리그오브레전드
    spring boot
    백엔드
    타임리프 사용방법
    셀레니움
    op.gg
    백엔드 개발자 면접 단골 질문 뿌시기
    스프링부트
    fow.kr
    lol
    react-router-dom
    @Scheduled
    개발자 면접
    jdk
    자바
    Recoil
    thymeleaf
    React
    Java
    backend
    spring
    롤
    Oracle
    node
    개발자
    mybatis
    github
  • 최근 댓글

  • 최근 글

  • 01-24 13:31
  • hELLO· Designed By정상우.v4.10.3
[Java] String 은 왜 불변(Immutable)일까? ( feat. StringBuilder를 써야 하는 이유)
상단으로

티스토리툴바