본문 바로가기

String 비교와 불변 연산

문자열 풀과 new String의 차이를 재현하고 equals 기반 비교·불변 반환값·주요 탐색과 변환 메서드를 게시글 입력 처리에 적용합니다.

String은 문자 여러 개를 다루는 표준 불변 클래스입니다.

배열처럼 문자 저장 구조를 직접 관리하지 않아도 비교, 탐색, 치환, 분리 기능을 제공합니다.

편리함 때문에 기본형처럼 보이지만 실제로는 객체이므로 참조 동일성과 값 동등성을 구분해야 합니다.


문자열 비교와 참조 동일성

문자열 리터럴은 풀에서 같은 인스턴스를 재사용할 수 있지만 실행 중 만들어진 문자열이나 new String은 다른 객체일 수 있습니다.

==에 의존하면 값은 같아도 결과가 달라집니다.

lab/StringIdentityComparisonBug.java
public final class StringIdentityComparisonBug {
    public static void main(String[] args) {
        String configured = "java";
        String userInput = new String("java");

        if (configured == userInput) {
            System.out.println("track-selected");
        } else {
            System.out.println("unknown-track");
        }

        System.out.println("same-value=" + configured.equals(userInput));
    }
}
잘못된 분기
unknown-track
same-value=true

문자열 내용 비교에는 equals를 사용합니다.

null 가능 입력이라면 상수 쪽에서 "java".equals(userInput)을 호출하거나 Objects.equals(configured, userInput)을 사용합니다.

풀 최적화는 구현 세부 이점일 뿐 업무 로직의 비교 규칙이 될 수 없습니다.


문자열 생성 방식의 차이

리터럴을 평가하면 JVM은 문자열 풀에서 같은 값을 찾아 참조를 재사용합니다.

new String("...")은 명시적으로 새 인스턴스를 만듭니다.

intern()으로 풀의 대표 참조를 얻을 수 있지만 일반 애플리케이션에서 동등성 문제를 해결하려고 사용할 필요는 없습니다.

src/StringPoolObservation.java
public final class StringPoolObservation {
    public static void main(String[] args) {
        String first = "object";
        String second = "object";
        String created = new String("object");

        System.out.println("literal-identity=" + (first == second));
        System.out.println("created-identity=" + (first == created));
        System.out.println("created-equality=" + first.equals(created));
        System.out.println("interned=" + (first == created.intern()));
    }
}
literal-identity=true
created-identity=false
created-equality=true
interned=true

컴파일러가 상수 문자열 결합을 미리 계산할 수도 있으므로 == 실험 결과는 표현 방식에 따라 달라질 수 있습니다.

항상 equals를 사용하면 생성 경로와 최적화 여부에서 독립된 값 비교가 됩니다.

같은 문자 값과 같은 String 참조를 구분한다

StringPoolObservation의 네 비교는 같은 문자 내용과 같은 객체 참조가 서로 다른 질문임을 보여 준다.

문자열 내용은 equals로, 참조는 ==로 비교합니다

StringPoolObservation에서 first와 second는 리터럴 "object", created는 new String("object")로 만듭니다.

문자열 내용은 equals로, 참조는 ==로 비교합니다 first와second리터럴은풀대표String object를참조하고created는new로만든별도String object를참조한다. intern점선은대표참조반환이지만created변수를재대입하지않는다. 내용equals는같다. 문자열 풀의 대표 first 리터럴 second 리터럴 같은 String object created 참조 new String(...) 다른 String object intern: 풀 참조 반환 세 변수의 저장된 참조와 intern 반환을 구분 · 내용은 모두 object
문자열 내용은 equals로, 참조는 ==로 비교합니다 first와second리터럴은풀대표String object를참조하고created는new로만든별도String object를참조한다. intern점선은대표참조반환이지만created변수를재대입하지않는다. 내용equals는같다. 문자열 풀의 대표 first 리터럴 second 리터럴 같은 String object created 참조 new String(...) 다른 String object new: 별도 객체 점선: intern의 반환 참조 created는 재대입되지 않음
원본 프로그램의 비교식과 출력
비교식결과확인한 관계
first == secondtrue두 리터럴은 같은 풀의 대표 참조
first == createdfalsenew로 만든 객체는 별도 참조
first.equals(created)true객체가 달라도 문자 내용은 같음
first == created.intern()trueintern()의 반환값이 풀의 대표 참조
first == second
결과: true
확인한 관계: 두 리터럴은 같은 풀의 대표 참조
first == created
결과: false
확인한 관계: new로 만든 객체는 별도 참조
first.equals(created)
결과: true
확인한 관계: 객체가 달라도 문자 내용은 같음
first == created.intern()
결과: true
확인한 관계: intern()의 반환값이 풀의 대표 참조

앞의 실패 예제도 configured == userInput은 거짓이지만 configured.equals(userInput)은 참입니다. 내용 비교의 기준은 equals입니다.

intern()은 created 변수에 재대입하지 않습니다. null 가능 입력의 비교 정책과 원본을 보존하는 String 변환은 주변 본문에서 별도로 설명합니다.


String 연산과 새 값 반환

String 내부 문자는 생성 이후 바뀌지 않습니다.

concat, replace, toUpperCase 같은 메서드는 결과 문자열을 반환합니다.

반환을 받지 않으면 원본은 유지됩니다.

src/ImmutableStringOperations.java
public final class ImmutableStringOperations {
    public static void main(String[] args) {
        String title = "object";
        String expanded = title.concat(" equality");
        String normalized = expanded.toUpperCase();

        System.out.println("title=" + title);
        System.out.println("expanded=" + expanded);
        System.out.println("normalized=" + normalized);
    }
}
title=object
expanded=object equality
normalized=OBJECT EQUALITY

불변이므로 같은 문자열을 여러 설정과 기록이 공유해도 한쪽의 연산이 다른 참조의 값을 바꾸지 않습니다.

문자열 풀에서 인스턴스를 재사용하고 해시값을 안정적으로 캐시할 수 있는 기반도 됩니다.

보안 관련 경로나 클래스 이름처럼 공유되는 값이 몰래 변경되는 위험이 없습니다.


문자열 탐색과 정규화

문자열 메서드는 목적별로 나누어 익히는 편이 낫습니다.

  • 정보: length, isEmpty, isBlank, charAt
  • 비교: equals, equalsIgnoreCase, compareTo
  • 탐색: contains, indexOf, lastIndexOf, startsWith, endsWith
  • 부분과 치환: substring, replace, replaceFirst, replaceAll
  • 정리: strip, trim, toLowerCase, toUpperCase
  • 결합과 분해: split, String.join

정규 표현식을 받는 replaceAll과 단순 문자열을 받는 replace를 혼동하면 점이나 역슬래시가 예상과 다르게 해석될 수 있습니다.

단순 치환에는 replace를 우선합니다.

src/BoardTitleNormalizer.java
import java.util.Arrays;

public final class BoardTitleNormalizer {
    public static void main(String[] args) {
        String raw = "  Object, Equality, String  ";
        String normalized = raw.strip().toLowerCase();
        String[] titles = normalized.split("\\s*,\\s*");

        System.out.println("normalized=" + normalized);
        System.out.println("titles=" + Arrays.toString(titles));
        System.out.println("joined=" + String.join(" -> ", titles));
        System.out.println("has-string=" + normalized.contains("string"));
    }
}
normalized=object, equality, string
titles=[object, equality, string]
joined=object -> equality -> string
has-string=true

strip은 Character.isWhitespace가 인정하는 양끝 공백을 제거하고, trim은 U+0020 이하인 양끝 문자를 제거합니다. 모든 유니코드 공백이 strip의 대상은 아닙니다. EM SPACE(U+2003)는 제거되지만 줄바꿈 없는 공백 NBSP(U+00A0)는 그대로 남습니다. Character.isWhitespace의 기준으로 입력 정책을 확인합니다.

입력 형식에서 쉼표 주변 공백을 허용했으므로 split에 정규 표현식을 사용했습니다.

빈 항목을 허용할지, 대소문자를 보존할지는 도메인 규칙으로 결정합니다.

예를 들어 "java,"는 항목 뒤에 쉼표가 있지만 split(",") 결과의 길이는 1입니다. 기본 split은 뒤의 빈 문자열을 버립니다. 빈 항목도 검증하려면 split(",", -1)로 길이 2인 결과를 받은 다음 두 번째 빈 값을 처리합니다. 두 메서드가 같은 입력을 보고도 서로 다른 배열을 만든다는 점을 먼저 확인하세요.


문자열 경계와 빈 문자열

charAt와 substring은 유효 범위를 벗어나면 예외를 던집니다.

사용자 입력을 자르기 전에 길이나 구분자 위치를 확인해야 합니다.

예시

String의 length와 substring 인덱스는 UTF-16 코드 단위 기준입니다. 아래는 화면에서 A와 이모지로 보이는 값이 길이 3이 되는 이유를 확인하는 독립 파일입니다. substring(0, 2)는 이모지의 서로게이트 쌍을 중간에서 나눕니다.

src/StringCodeUnitBoundary.java
public class StringCodeUnitBoundary {
    public static void main(String[] args) {
        String text = "A😀";
        String cut = text.substring(0, 2);
        System.out.println("units=" + text.length());
        System.out.println("points=" + text.codePointCount(0, text.length()));
        System.out.println("split-surrogate=" + Character.isHighSurrogate(cut.charAt(1)));
        int end = text.offsetByCodePoints(0, 2);
        System.out.println("whole=" + text.substring(0, end));
    }
}
units=3
points=2
split-surrogate=true
whole=A😀

코드 포인트 수로 자를 때는 그 수를 offsetByCodePoints로 인덱스로 바꿉니다. 여러 코드 포인트가 결합된 글자 전체의 경계까지 해결하는 예제는 아닙니다. String의 UTF-16·인덱스·split 계약과 실제 요구 단위를 구별합니다.

src/SafeTitlePrefix.java
public final class SafeTitlePrefix {
    public static void main(String[] args) {
        System.out.println(prefix("immutability", 5));
        System.out.println(prefix("api", 5));
        System.out.println(extension("notes.txt"));
        System.out.println(extension("README"));
    }

    private static String prefix(String value, int maxLength) {
        if (value == null) return "";
        return value.substring(0, Math.min(value.length(), maxLength));
    }

    private static String extension(String fileName) {
        int dot = fileName.lastIndexOf('.');
        if (dot < 0 || dot == fileName.length() - 1) return "";
        return fileName.substring(dot + 1);
    }
}
immut
api
txt

substring의 끝 인덱스는 포함되지 않습니다.

위 prefix는 null이면 빈 문자열을 반환하지만, null이 아닌 값에는 maxLength가 0 이상이라는 호출 조건이 필요합니다. 음수는 substring 예외가 되며 extension은 null을 검사하지 않습니다. 모든 입력을 이미 검증한 함수로 읽지 않고 이 조건을 호출부에서 맞춥니다.

마지막 점을 찾으면 여러 점이 있는 파일명도 마지막 확장자를 얻을 수 있습니다.

숨김 파일, 끝에 점이 있는 이름, 경로 구분자까지 고려해야 하는 실제 파일 처리에서는 Path API와 명확한 규칙을 사용합니다.

prefix와 extension은 서로 다른 경계를 검사한다

SafeTitlePrefix의 두 함수가 실제로 검사하는 조건과 원본 main의 결과를 구분한다.

접두어와 확장자는 각각의 경계 조건을 검사합니다

SafeTitlePrefix는 접두어와 확장자에 같은 검사를 적용하지 않습니다. 검사 조건은 함수 본문, 입출력은 원본 main 기준입니다.

접두어와 확장자는 각각의 경계 조건을 검사합니다 prefix는null이면빈문자열,아니면0부터min(length,maxLength)까지반환한다. nonnull에서는maxLength>=0조건이필요하다. 별도extension은마지막점없음또는끝이면빈문자열,아니면점다음문자열이다. fileName의null은검사하지않는다. prefix · 독립 함수 입력 각 함수의 조건 value == null? 예: 반환 빈 문자열 "" 아니오: 반환 0..min(length,max) extension · 독립 함수 입력 각 함수의 조건 점 없음/마지막 점? 예: 반환 빈 문자열 "" 아니오: 반환 점 다음 문자열 prefix: null 아닌 값에는 maxLength≥0 · extension: fileName null 검사 없음
접두어와 확장자는 각각의 경계 조건을 검사합니다 prefix는null이면빈문자열,아니면0부터min(length,maxLength)까지반환한다. nonnull에서는maxLength>=0조건이필요하다. 별도extension은마지막점없음또는끝이면빈문자열,아니면점다음문자열이다. fileName의null은검사하지않는다. prefix · 독립 함수 입력 value null 또는 String value == null? 빈 문자열 반환 "" 부분 문자열 반환 0..min(length,max) 원 main: immut / api 예 아니오 extension · 독립 함수 파일명 입력 null 아닌 값 점 없음 또는 마지막이 점? 빈 문자열 반환 원 main: README 점 다음 문자열 원 main: txt 예 아니오 prefix: nonnull이면 max≥0 서로 다른 입력 조건
두 함수의 검사 범위와 실제 호출 결과
함수구현한 검사main 입력 → 결과
접두어 · prefix

값이 null이면 "" 반환

그 외에는 길이와 maxLength 중 작은 값을 끝 인덱스로 사용

prefix("immutability", 5) → "immut"

prefix("api", 5) → "api"

확장자 · extension

마지막 점이 없거나 끝에 있으면 "" 반환

그 외에는 점 다음부터 추출

extension("notes.txt") → "txt"

extension("README") → "" (빈 출력 줄)

접두어 · prefix
구현한 검사:

값이 null이면 "" 반환

그 외에는 길이와 maxLength 중 작은 값을 끝 인덱스로 사용

main 입력 → 결과:

prefix("immutability", 5) → "immut"

prefix("api", 5) → "api"

확장자 · extension
구현한 검사:

마지막 점이 없거나 끝에 있으면 "" 반환

그 외에는 점 다음부터 추출

main 입력 → 결과:

extension("notes.txt") → "txt"

extension("README") → "" (빈 출력 줄)

substring의 끝 인덱스는 포함하지 않습니다. prefix는 음수 maxLength를 거부하는 검사가 없고, extension에는 null 입력 검사가 없습니다.

이 둘을 모든 입력에 안전한 공통 검증 절차로 해석하지 않습니다. 숨김 파일과 실제 경로의 정책도 이 예제의 보장 범위 밖입니다.


문자열 API 선택 기준

요구를 먼저 문장으로 적고 가장 좁은 메서드를 선택합니다.

전체 값 동등성은 equals, 접두·접미 확인은 startsWith와 endsWith, 첫 위치가 필요할 때는 indexOf, 단순 치환은 replace가 의도를 잘 드러냅니다.

한 번의 처리에 여러 중간 문자열을 만드는 것이 성능 병목인지 측정하기 전에는 읽기 쉬운 불변 연산을 우선합니다.

지역 설정에 따라 대소문자 규칙이 달라지는 식별자 정규화는 toLowerCase(Locale.ROOT)처럼 로케일을 명시합니다.

사람 이름과 자연어는 기계 식별자와 다른 국제화 규칙이 필요합니다.


연습 문제

이메일 문자열에서 @가 정확히 하나 있고 앞뒤가 비어 있지 않을 때 사용자명과 도메인을 출력하세요.

잘못된 입력은 invalid로 처리합니다.

해설 보기
src/EmailSplitExercise.java
public final class EmailSplitExercise {
    public static void main(String[] args) {
        printParts("[email protected]");
        printParts("broken@@example.com");
    }

    private static void printParts(String email) {
        int at = email.indexOf('@');
        boolean valid = at > 0
                && at == email.lastIndexOf('@')
                && at < email.length() - 1;
        if (!valid) {
            System.out.println("invalid");
            return;
        }
        System.out.println("user=" + email.substring(0, at));
        System.out.println("domain=" + email.substring(at + 1));
    }
}
user=board
domain=example.com
invalid

위 검사는 흐름을 보여 주기 위한 최소 규칙입니다.

실제 이메일 표준 전체를 직접 정규식으로 구현하기보다 사용하는 시스템의 요구 범위를 정의하고 검증 라이브러리를 선택합니다.