Theme:

자바에서 가장 많이 쓰는 타입이 뭘까? int? boolean? 아마 대부분의 프로그램에서 **String**이 압도적일 것이다. 그런데 이 문자열이라는 녀석, 생각보다 복잡하다. "왜 String은 한번 만들면 바꿀 수 없지?" "리터럴이랑 new String()이 뭐가 다른 거지?" — 이런 의문을 한 번이라도 가져봤다면 이 글이 도움이 될 것이다.

TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.

String은 왜 불변(Immutable)인가

불변이라는 게 뭐냐면

String 객체는 한번 생성되면 내부 값을 절대 바꿀 수 없다. "바꾸는 것처럼 보이는" 모든 연산은 사실 새로운 String 객체를 만드는 것이다.

JAVA
String name = "hello";
name = name + " world"; // "hello"가 바뀌는 게 아니라 "hello world"라는 새 객체가 생성됨

내부적으로 String 클래스는 final로 선언되어 있고, 문자 데이터를 담는 byte[] 배열도 private final이다.

JAVA
// String 클래스의 내부 (간략화)
public final class String {
    private final byte[] value; // 한번 할당되면 변경 불가
    private int hash;           // 해시코드 캐시
}

왜 이렇게 설계했을까?

공부하다 보니 "그냥 바꿀 수 있게 하면 편할 텐데?"라고 생각했는데, 불변으로 만든 이유가 꽤 많았다.

1. String Pool에서 공유하기 위해

Java는 같은 문자열 리터럴을 메모리에 하나만 두고 여러 변수가 공유한다. 만약 하나의 변수가 값을 바꿔버리면 같은 문자열을 참조하는 다른 변수에도 영향이 간다. 불변이니까 안전하게 공유할 수 있다.

2. 해시코드 캐싱

StringhashCode()는 최초 호출 시 계산한 값을 hash 필드에 저장해둔다. 값이 절대 안 바뀌니까 한 번만 계산하면 된다. HashMap의 키로 String을 쓸 때 성능이 좋은 이유가 이것이다.

3. 보안

DB 연결 문자열, 파일 경로, 네트워크 URL 같은 민감한 정보가 String으로 전달된다. 불변이 아니면 전달 후에 값이 바뀔 수 있어서 보안 취약점이 된다.

4. 스레드 안전성

불변 객체는 여러 스레드가 동시에 읽어도 문제가 없다. 동기화 처리가 필요 없으니 멀티스레드 환경에서 자유롭게 쓸 수 있다.

기억 포인트: "String이 왜 불변인가요?"는 면접 단골 질문이다. Pool 공유, 해시 캐싱, 보안, 스레드 안전 — 이 네 가지를 떠올리자.

String Pool

리터럴 vs new String()

이 차이를 제대로 이해하는 게 문자열의 핵심이다.

JAVA
// 리터럴 방식 — String Pool에 저장
String a = "hello";
String b = "hello";

// new 방식 — 힙(Heap)에 새 객체 생성
String c = new String("hello");
String d = new String("hello");

System.out.println(a == b); // true  — 같은 Pool 객체 참조
System.out.println(a == c); // false — Pool과 힙은 다른 영역
System.out.println(c == d); // false — 각각 다른 힙 객체

그림으로 보면 이렇다.

PLAINTEXT
┌─────────────────────────────────────┐
│              Heap 영역               │
│                                     │
│   ┌───────────────────────┐         │
│   │    String Pool        │         │
│   │  ┌─────────────┐     │         │
│   │  │   "hello"   │ ◄── a, b      │
│   │  └─────────────┘     │         │
│   └───────────────────────┘         │
│                                     │
│   ┌─────────────┐                   │
│   │ String 객체  │ ◄── c            │
│   │ ("hello")   │                   │
│   └─────────────┘                   │
│   ┌─────────────┐                   │
│   │ String 객체  │ ◄── d            │
│   │ ("hello")   │                   │
│   └─────────────┘                   │
└─────────────────────────────────────┘
  • 리터럴("hello")은 컴파일 시점에 String Pool에 등록되고, 같은 값이면 같은 객체를 재사용한다.
  • **new String()**은 무조건 힙에 새 객체를 만든다. Pool과 별개다.

intern() 메서드

intern()을 호출하면 해당 문자열이 Pool에 있는지 확인하고, 있으면 Pool의 참조를 반환한다.

JAVA
String x = new String("world"); // 힙에 생성
String y = x.intern();          // Pool에 "world"가 있으면 그 참조 반환
String z = "world";             // Pool의 "world" 참조

System.out.println(x == z); // false — x는 힙 객체
System.out.println(y == z); // true  — y는 Pool 참조

▸ 실무에서 intern()을 직접 쓸 일은 많지 않다. 하지만 "String Pool이 어떻게 동작하는지" 설명할 때 꼭 나오는 메서드이니 원리를 알아두자.

String vs StringBuilder vs StringBuffer

왜 StringBuilder가 필요할까?

String이 불변이라는 건 문자열을 이어 붙일 때마다 새 객체가 생긴다는 뜻이다.

JAVA
// 비효율적인 문자열 연결
String result = "";
for (int i = 0; i < 10000; i++) {
    result += i; // 매 반복마다 새 String 객체 생성!
}

이 코드는 반복할 때마다 새로운 String 객체를 만들고, 이전 객체는 가비지 컬렉션 대상이 된다. 10,000번이면 10,000개의 임시 객체가 생기는 셈이다.

StringBuilder — 가변(Mutable) 문자열

StringBuilder는 내부 버퍼를 가지고 있어서, 문자열을 추가해도 같은 객체 안에서 수정된다.

JAVA
// 효율적인 문자열 연결
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append(i); // 같은 객체 내부에서 추가
}
String result = sb.toString(); // 최종 결과만 String으로 변환

StringBuilder vs StringBuffer

공부하다 보니 이 둘의 차이가 면접에서 정말 자주 나오더라고요. 핵심은 딱 하나다.

구분StringBuilderStringBuffer
가변(Mutable)OO
동기화(synchronized)XO
스레드 안전XO
성능빠름느림
도입 버전Java 5Java 1.0
JAVA
// StringBuilder — 동기화 없음, 단일 스레드에서 사용
StringBuilder sb = new StringBuilder("hello");
sb.append(" world");

// StringBuffer — 동기화 있음, 멀티스레드에서 안전
StringBuffer sbf = new StringBuffer("hello");
sbf.append(" world"); // synchronized 메서드

주요 메서드

StringBuilderStringBuffer는 같은 메서드를 제공한다.

JAVA
StringBuilder sb = new StringBuilder("Hello");

sb.append(" World");       // 뒤에 추가 → "Hello World"
sb.insert(5, ",");         // 특정 위치에 삽입 → "Hello, World"
sb.delete(5, 6);           // 범위 삭제 → "Hello World"
sb.replace(6, 11, "Java"); // 범위 치환 → "Hello Java"
sb.reverse();              // 뒤집기 → "avaJ olleH"

int len = sb.length();     // 길이
char ch = sb.charAt(0);    // 특정 위치 문자

언제 뭘 써야 하나?

  • 문자열 변경이 거의 없다String
  • 반복문에서 문자열을 이어 붙인다StringBuilder
  • 멀티스레드 환경에서 공유되는 문자열을 수정한다StringBuffer

▸ 99%의 경우 StringBuilder면 충분하다. StringBuffer를 써야 하는 상황이라면 아마 다른 동기화 전략(예: ConcurrentHashMap)을 고민하는 게 나을 수도 있다.

문자열 비교

== vs equals()

이건 자바 문자열에서 가장 많이 실수하는 부분이다.

JAVA
String a = "hello";
String b = "hello";
String c = new String("hello");

// == 는 참조(주소)를 비교한다
System.out.println(a == b); // true  — 같은 Pool 객체
System.out.println(a == c); // false — Pool vs 힙, 다른 객체

// equals()는 내용(값)을 비교한다
System.out.println(a.equals(b)); // true
System.out.println(a.equals(c)); // true — 값이 같으니까

규칙은 단순하다: 문자열 비교는 항상 equals()를 쓴다. ==를 쓰면 Pool 여부에 따라 결과가 달라지므로 버그의 원인이 된다.

equalsIgnoreCase()

대소문자를 무시하고 비교할 때 쓴다.

JAVA
String email1 = "User@Email.com";
String email2 = "user@email.com";

System.out.println(email1.equals(email2));           // false
System.out.println(email1.equalsIgnoreCase(email2)); // true

compareTo()

문자열을 사전순(lexicographic order)으로 비교한다. 정렬할 때 유용하다.

JAVA
String a = "apple";
String b = "banana";
String c = "apple";

System.out.println(a.compareTo(b)); // 음수 — a가 b보다 앞
System.out.println(b.compareTo(a)); // 양수 — b가 a보다 뒤
System.out.println(a.compareTo(c)); // 0    — 같은 문자열

반환값 규칙:

  • 음수: 호출 객체가 사전순으로 앞
  • 0: 같음
  • 양수: 호출 객체가 사전순으로 뒤

NullPointerException 방어

equals() 호출 시 null이 올 수 있다면, 리터럴을 앞에 두는 게 안전하다.

JAVA
String input = null;

// input.equals("hello"); // NullPointerException 발생!
"hello".equals(input);    // false — 안전하게 비교

또는 Java 7+에서는 Objects.equals()를 사용한다.

JAVA
import java.util.Objects;

Objects.equals(input, "hello"); // false — null 안전

기억 포인트: ==는 "같은 객체냐", equals()는 "같은 값이냐"를 묻는 것이다. 문자열 비교는 반드시 equals().

유용한 String 메서드

자주 쓰는 메서드를 한 번에 정리한다.

기본 조작

JAVA
String s = "  Hello, Java World!  ";

// 공백 제거
s.trim();          // "Hello, Java World!" — 앞뒤 공백 제거
s.strip();         // "Hello, Java World!" — Java 11+, 유니코드 공백도 처리

// 대소문자 변환
s.trim().toUpperCase(); // "HELLO, JAVA WORLD!"
s.trim().toLowerCase(); // "hello, java world!"

// 길이와 빈 문자열 체크
s.length();        // 22 (공백 포함)
"".isEmpty();      // true  — 길이가 0
"  ".isBlank();    // true  — Java 11+, 공백만 있으면 true

substring — 부분 문자열 추출

JAVA
String str = "Hello, World!";

str.substring(7);       // "World!" — 7번 인덱스부터 끝까지
str.substring(0, 5);    // "Hello" — 0번부터 5번 전까지 (5 미포함)

공부하다 보니 substring()끝 인덱스가 미포함이라는 점에서 자주 헷갈렸다. [시작, 끝) 형태라고 기억하면 된다.

split — 문자열 분리

JAVA
String csv = "apple,banana,cherry";
String[] fruits = csv.split(","); // ["apple", "banana", "cherry"]

// 정규식 특수문자는 이스케이프 필요
String path = "C:\\Users\\dev\\file.txt";
String[] parts = path.split("\\\\"); // ["C:", "Users", "dev", "file.txt"]

// 분리 개수 제한
String data = "a:b:c:d:e";
String[] limited = data.split(":", 3); // ["a", "b", "c:d:e"] — 최대 3개로 분리

join — 문자열 합치기

JAVA
// 구분자로 합치기
String joined = String.join(", ", "apple", "banana", "cherry");
// "apple, banana, cherry"

// 배열도 가능
String[] arr = {"one", "two", "three"};
String result = String.join(" - ", arr);
// "one - two - three"

format과 formatted

JAVA
// String.format() — C의 printf 스타일
String msg = String.format("%s님, %d개의 메시지가 있습니다.", "홍길동", 5);
// "홍길동님, 5개의 메시지가 있습니다."

// formatted() — Java 15+, 인스턴스 메서드 버전
String msg2 = "%s님, %d개의 메시지가 있습니다.".formatted("홍길동", 5);
// 같은 결과

자주 쓰는 포맷 지정자:

지정자의미예시
%s문자열"hello"
%d정수42
%f실수3.14
%.2f소수점 2자리3.14
%n줄바꿈(OS에 맞는 줄바꿈)

그 외 유용한 메서드

JAVA
String s = "Hello, World!";

// 포함 여부
s.contains("World");   // true
s.startsWith("Hello"); // true
s.endsWith("!");        // true

// 검색
s.indexOf("World");     // 7  — 처음 등장하는 위치
s.lastIndexOf("l");     // 10 — 마지막으로 등장하는 위치
s.indexOf("xyz");       // -1 — 못 찾으면 -1

// 치환
s.replace("World", "Java"); // "Hello, Java!"
s.replaceAll("\\w+", "*");  // "*, *!" — 정규식 사용

// 문자 → 문자열 변환
String.valueOf(42);     // "42"
String.valueOf(true);   // "true"
String.valueOf('A');    // "A"

replace()는 단순 문자열 치환, replaceAll()은 정규식 기반 치환이다. 이 차이를 모르면 정규식 특수문자가 들어왔을 때 버그가 난다.

정규표현식 기초

Pattern과 Matcher

문자열에서 패턴을 찾고 싶을 때 정규표현식(Regex)을 사용한다. Java에서는 PatternMatcher 클래스를 쓴다.

JAVA
import java.util.regex.Pattern;
import java.util.regex.Matcher;

// 이메일 형식 검증 (간단 버전)
String email = "user@example.com";
String regex = "^[\\w.-]+@[\\w.-]+\\.\\w{2,}$";

// 방법 1: String의 matches()
boolean isValid = email.matches(regex); // true

// 방법 2: Pattern + Matcher (반복 사용 시 성능 유리)
Pattern pattern = Pattern.compile(regex); // 패턴 컴파일 (한 번만)
Matcher matcher = pattern.matcher(email);
boolean isValid2 = matcher.matches();     // true

자주 쓰는 정규식 패턴

패턴의미예시
.아무 문자 1개a.cabc, a1c
\d숫자 ([0-9])\d{3}123
\w단어 문자 ([a-zA-Z0-9_])\w+hello_123
\s공백 문자\s+ → 공백/탭/줄바꿈
*0회 이상 반복ab*cac, abbc
+1회 이상 반복ab+cabc, abbc
?0회 또는 1회ab?cac, abc
{n,m}n~m회 반복\d{2,4}12, 1234
^, $시작, 끝^Hello$
[]문자 클래스[aeiou] → 모음

그룹 추출

JAVA
// 날짜에서 년, 월, 일 추출하기
String date = "2026-03-19";
Pattern p = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher m = p.matcher(date);

if (m.matches()) {
    String year = m.group(1);  // "2026"
    String month = m.group(2); // "03"
    String day = m.group(3);   // "19"
    System.out.println(year + "년 " + month + "월 " + day + "일");
}

replaceAll()과 정규식

JAVA
// HTML 태그 제거
String html = "<p>Hello <b>World</b></p>";
String text = html.replaceAll("<[^>]+>", ""); // "Hello World"

// 연속 공백을 하나로
String messy = "hello    world   java";
String clean = messy.replaceAll("\\s+", " "); // "hello world java"

// 전화번호 형식 변환
String phone = "01012345678";
String formatted = phone.replaceAll("(\\d{3})(\\d{4})(\\d{4})", "$1-$2-$3");
// "010-1234-5678"

Pattern.compile()은 비용이 있는 연산이다. 같은 패턴을 반복 사용한다면 컴파일 결과를 상수로 저장해두는 게 좋다.

JAVA
// 권장: 패턴을 상수로 선언
private static final Pattern EMAIL_PATTERN =
    Pattern.compile("^[\\w.-]+@[\\w.-]+\\.\\w{2,}$");

public boolean isValidEmail(String email) {
    return EMAIL_PATTERN.matcher(email).matches();
}

텍스트 블록 (Java 13+)

여러 줄 문자열의 고통

Java 13 이전에는 여러 줄 문자열을 쓰려면 이런 식이었다.

JAVA
// 기존 방식 — 읽기도 쓰기도 고통스럽다
String json = "{\n" +
    "  \"name\": \"홍길동\",\n" +
    "  \"age\": 25,\n" +
    "  \"email\": \"hong@example.com\"\n" +
    "}";

텍스트 블록으로 깔끔하게

텍스트 블록(""")을 사용하면 여러 줄 문자열을 그대로 쓸 수 있다.

JAVA
// 텍스트 블록 — Java 13+ (정식: Java 15)
String json = """
        {
          "name": "홍길동",
          "age": 25,
          "email": "hong@example.com"
        }
        """;

핵심 규칙

JAVA
// 1. 여는 """ 뒤에 바로 내용을 쓸 수 없다 (줄바꿈 필수)
String s = """
        내용은 여기서부터
        """;

// 2. 닫는 """의 위치가 들여쓰기 기준점이 된다
String a = """
        Hello
        World
        """;
// 결과: "Hello\nWorld\n" — 닫는 """가 같은 들여쓰기

String b = """
Hello
World
""";
// 결과: "Hello\nWorld\n" — 들여쓰기 없음

// 3. formatted()와 조합 가능 (Java 15+)
String html = """
        <html>
          <body>
            <p>안녕하세요, %s님!</p>
          </body>
        </html>
        """.formatted("홍길동");

▸ 텍스트 블록은 JSON, HTML, SQL 같은 여러 줄 문자열을 다룰 때 가독성이 비약적으로 좋아진다. Java 15 이상을 쓰고 있다면 적극 활용하자.

실전 예제

지금까지 배운 내용을 종합해서 실전 문제를 풀어보자.

예제 1: CSV 파싱

JAVA
// CSV 한 줄을 파싱해서 객체 리스트로 변환
String csvData = """
        이름,나이,이메일
        홍길동,25,hong@example.com
        김영희,30,kim@example.com
        박철수,28,park@example.com
        """;

String[] lines = csvData.strip().split("\n"); // 줄 단위로 분리
String[] headers = lines[0].split(",");       // 헤더 추출

for (int i = 1; i < lines.length; i++) {
    String[] fields = lines[i].split(",");
    // 각 필드를 이름, 나이, 이메일로 매핑
    String name = fields[0];
    int age = Integer.parseInt(fields[1]);
    String email = fields[2];
    System.out.println(name + " (" + age + "세) - " + email);
}

예제 2: 로그 분석

JAVA
// 로그에서 ERROR 레벨만 추출하고, 타임스탬프와 메시지를 분리
String log = """
        2026-03-19 10:00:01 [INFO] 서버 시작됨
        2026-03-19 10:00:05 [ERROR] DB 연결 실패: timeout
        2026-03-19 10:00:10 [INFO] 재시도 중...
        2026-03-19 10:00:15 [ERROR] DB 연결 실패: connection refused
        """;

Pattern logPattern = Pattern.compile(
    "(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}) \\[ERROR\\] (.+)"
);

// 각 줄에서 ERROR 패턴을 찾아 추출
for (String line : log.strip().split("\n")) {
    Matcher m = logPattern.matcher(line);
    if (m.matches()) {
        String timestamp = m.group(1); // 타임스탬프
        String message = m.group(2);   // 에러 메시지
        System.out.println("[" + timestamp + "] " + message);
    }
}
// 출력:
// [2026-03-19 10:00:05] DB 연결 실패: timeout
// [2026-03-19 10:00:15] DB 연결 실패: connection refused

예제 3: 문자열을 활용한 간단한 템플릿 엔진

JAVA
// 간단한 템플릿 치환 엔진
String template = "안녕하세요, {{name}}님! {{count}}개의 새 알림이 있습니다.";

// 치환할 데이터
Map<String, String> data = Map.of(
    "name", "홍길동",
    "count", "3"
);

// {{key}}를 찾아서 값으로 치환
String result = template;
for (Map.Entry<String, String> entry : data.entrySet()) {
    result = result.replace("{{" + entry.getKey() + "}}", entry.getValue());
}
System.out.println(result);
// "안녕하세요, 홍길동님! 3개의 새 알림이 있습니다."

정리 테이블

String vs StringBuilder vs StringBuffer

구분StringStringBuilderStringBuffer
가변 여부불변(Immutable)가변(Mutable)가변(Mutable)
스레드 안전O (불변이므로)XO (synchronized)
성능변경 시 느림빠름StringBuilder보다 느림
사용 시점변경 적을 때반복 연결 시멀티스레드 공유 시

문자열 비교 방법

방법비교 대상용도
==참조(주소)같은 객체인지 확인 (거의 안 씀)
equals()내용(값)문자열 비교의 표준
equalsIgnoreCase()내용 (대소문자 무시)이메일, 사용자 입력 비교
compareTo()사전순정렬, 대소 비교

주요 String 메서드 요약

메서드설명반환
length()문자열 길이int
charAt(i)i번째 문자char
substring(s, e)부분 문자열 [s, e)String
split(regex)분리String[]
join(delim, ...)합치기String
trim() / strip()공백 제거String
contains(s)포함 여부boolean
indexOf(s)위치 검색int
replace(a, b)단순 치환String
replaceAll(regex, b)정규식 치환String
format(fmt, ...)포맷팅String
matches(regex)정규식 매칭boolean
toUpperCase() / toLowerCase()대소문자 변환String

다음 글에서는 Enum과 어노테이션을 다룬다. 상수를 더 안전하게 다루는 방법과, 어노테이션이 실제로 뭘 하는 건지 궁금하다면 이어서 보자.

댓글 로딩 중...