본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
9장 : Object와 불변성

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 연산과 새 값 반환

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은 유니코드 공백을 기준으로 양끝을 정리하고 trim은 오래된 ASCII 중심 규칙을 사용합니다.

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

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


문자열 경계와 빈 문자열

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

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

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의 끝 인덱스는 포함되지 않습니다.

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

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


문자열 API 선택 기준

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

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

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

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

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


연습 문제

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

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

해설 보기
src/EmailSplitExercise.java
public final class EmailSplitExercise {
    public static void main(String[] args) {
        printParts("board@example.com");
        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

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

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