문자열 비교와 String 메서드
문자열을 equals로 비교해야 하는 이유를 문자열 풀과 함께 이해하고, 원본을 바꾸지 않는 String 메서드로 문자열을 찾고 다듬고 나누고 자르는 방법을 배웁니다.
2장부터 문자열은 token.equals("quit")처럼 equals로 비교해 왔고, 기본형과 참조형 복사에서는 참조형에 쓴 ==가 내용이 아니라 '같은 객체인가'를 묻는다는 것을 확인했습니다. String도 참조형이므로 이 규칙의 예외가 아닙니다. 그런데 문자열은 ==로 비교해도 true가 나오는 경우가 꽤 많습니다. 그래서 잘못된 비교가 테스트에서는 통과했다가 실제 사용자 입력에서만 틀리는 일이 생깁니다.
이 글에서는 먼저 ==가 어떤 때는 맞고 어떤 때는 틀리는 이유를 문자열 풀(string pool)로 설명합니다. 이어서 String이 한번 만들어지면 바뀌지 않는 불변 객체라는 점을 확인하고, 그 성질 위에서 동작하는 주요 메서드로 게시글 제목을 다듬고 나누고 자르는 방법을 익힙니다. 인덱스로 문자열을 자를 때 지켜야 할 범위와 length()가 실제 글자 수와 다를 수 있는 경우도 함께 다룹니다.
==로 비교하면 같은 문자열도 다르다고 나온다
다음 프로그램은 설정에 적어 둔 트랙 이름과 사용자가 입력한 값을 비교해 트랙을 고릅니다. 실제로 입력을 받는 대신 new String("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두 변수의 내용은 똑같이 java인데 첫 줄은 unknown-track입니다. configured == userInput은 두 변수에 든 참조값이 같은지, 즉 두 변수가 같은 String 객체를 가리키는지를 묻기 때문입니다. configured는 리터럴 "java"를, userInput은 new로 새로 만든 객체를 가리키므로 결과는 false입니다. 반면 String은 Object의 equals를 문자 내용을 비교하도록 재정의해 두었습니다. 그래서 둘째 줄의 equals는 두 문자열의 문자를 하나씩 비교해 true를 돌려줍니다.
문자열의 내용을 비교할 때는 언제나 equals를 씁니다. 다만 비교할 값이 null일 수 있다면 호출하는 방향에 주의해야 합니다. userInput.equals("java")는 userInput이 null이면 메서드를 호출할 객체가 없어서 NullPointerException이 납니다. 상수 쪽에서 "java".equals(userInput)처럼 호출하면 null과의 비교는 예외 없이 false가 됩니다. 양쪽 모두 null일 수 있다면 java.util.Objects의 Objects.equals(configured, userInput)을 씁니다. 이 메서드는 둘 다 null이면 true, 한쪽만 null이면 false를 돌려주고, 그 밖의 경우에는 첫 번째 인수의 equals로 비교합니다.
리터럴은 문자열 풀의 같은 객체를 함께 쓴다
그렇다면 ==가 true가 되는 때는 언제일까요? 자바는 소스 코드에 적힌 문자열 리터럴을 문자열 풀이라는 곳에 내용별로 하나씩만 두고 함께 씁니다. 그래서 프로그램 여러 곳에 "object"라고 적어도 모두 풀에 있는 같은 String 객체를 가리킵니다. 반면 new String("object")는 내용이 같더라도 풀과 상관없이 언제나 새 객체를 만듭니다. intern() 메서드는 풀에 같은 내용의 문자열이 이미 있으면 그 객체를 돌려주고, 없으면 이 문자열을 풀에 넣은 뒤 돌려줍니다.
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출력을 한 줄씩 보겠습니다. first와 second는 같은 리터럴이므로 풀의 같은 객체를 가리키고, 그래서 literal-identity는 true입니다. created는 new로 만든 별도 객체라서 first == created는 false지만, 내용은 같으므로 created-equality는 true입니다. 마지막 줄의 created.intern()은 풀에 이미 있는 "object" 객체를 돌려주므로 first와 같은 참조가 되어 true입니다. 이때 created 변수가 바뀌지는 않습니다. intern()은 풀의 객체를 반환할 뿐이고, created는 여전히 new로 만든 객체를 가리킵니다.
StringPoolObservation의 네 비교는 같은 문자 내용과 같은 객체 참조가 서로 다른 질문임을 보여 준다.
문자열 내용은 equals로, 참조는 ==로 비교합니다
StringPoolObservation에서 first와 second는 리터럴 "object", created는 new String("object")로 만듭니다.
| 비교식 | 결과 | 확인한 관계 |
|---|---|---|
first == second | true | 두 리터럴은 같은 풀의 대표 참조 |
first == created | false | new로 만든 객체는 별도 참조 |
first.equals(created) | true | 객체가 달라도 문자 내용은 같음 |
first == created.intern() | true | intern()의 반환값이 풀의 대표 참조 |
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 변환은 주변 본문에서 별도로 설명합니다.
이 예제에서 보듯이 ==의 결과는 비교하는 코드가 아니라 문자열이 어떻게 만들어졌는지에 달려 있습니다. 리터럴끼리 비교하는 테스트에서는 true가 나오다가, 사용자 입력이나 파일에서 읽은 값과 비교하는 순간 false가 됩니다. 리터럴이 풀의 객체를 함께 쓰는 것은 Java 언어 명세가 정한 동작이지만, 비교하는 쪽에서는 상대 문자열이 리터럴에서 왔는지 실행 중에 만들어졌는지 알 수 없습니다. intern()으로 참조를 맞춘 뒤 ==를 쓰는 방법도 있지만, 일반 애플리케이션에서 그렇게 할 이유는 없습니다. 내용 비교는 equals 하나로 충분합니다.
더 깊이: 컴파일할 때 미리 합쳐지는 문자열
문자열을 +로 이어 붙인 결과도 경우에 따라 풀의 객체가 됩니다. 컴파일러는 값이 컴파일 시점에 정해지는 식, 즉 상수식(constant expression)을 미리 계산해 하나의 리터럴처럼 다루기 때문입니다. 다음 프로그램은 같은 "java"를 세 가지 방법으로 만들어 리터럴과 ==로 비교합니다.
public final class ConstantConcatIdentity {
public static void main(String[] args) {
String literal = "java";
String folded = "ja" + "va";
final String constantPrefix = "ja";
String foldedFinal = constantPrefix + "va";
String prefix = "ja";
String runtime = prefix + "va";
System.out.println("folded=" + (literal == folded));
System.out.println("final=" + (literal == foldedFinal));
System.out.println("runtime=" + (literal == runtime));
System.out.println("runtime-equals=" + literal.equals(runtime));
}
}folded=true
final=true
runtime=false
runtime-equals=true"ja" + "va"는 리터럴끼리의 결합이라 컴파일러가 미리 "java"로 합쳐 둡니다. 실행할 때는 처음부터 리터럴 "java"를 쓴 것과 같습니다. final로 선언하고 상수로 초기화한 constantPrefix도 상수로 취급되므로 constantPrefix + "va" 역시 컴파일할 때 합쳐집니다. 반면 final이 없는 prefix는 일반 변수라서, prefix + "va"는 실행할 때 새 String 객체를 만듭니다. 변수 선언에 final이 붙었느냐에 따라 ==의 결과가 바뀌는 셈입니다. 이런 규칙까지 따져 가며 ==를 쓰는 것은 얻을 것이 없고, equals는 마지막 줄처럼 어느 경우에나 같은 답을 냅니다. 리터럴과 상수식이 풀의 객체를 함께 쓴다는 규칙은 Java 언어 명세 3.10.5절에 있습니다.
String은 한번 만들어지면 바뀌지 않는다
불변 객체와 안전한 공유에서 만든 불변 객체처럼, String도 한번 만들어지면 담고 있는 문자를 바꿀 방법이 없습니다. 그래서 concat, replace, toUpperCase처럼 문자열을 바꾸는 것처럼 보이는 메서드는 모두 원본을 그대로 두고, 결과를 담은 새 문자열을 돌려줍니다.
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 EQUALITYtitle.concat(" equality")는 title이 가리키는 "object"를 건드리지 않고, 두 문자열을 이은 새 문자열 "object equality"를 만들어 돌려줍니다. 그 결과를 expanded에 받았기 때문에 다음 줄에서 쓸 수 있습니다. expanded.toUpperCase()도 마찬가지로 대문자로 바꾼 새 문자열을 만듭니다. 출력의 첫 줄이 여전히 title=object인 것은 이 과정에서 원본이 한 번도 바뀌지 않았기 때문입니다. 프로그램이 끝날 때 세 변수는 서로 다른 문자열 세 개를 하나씩 가리키고 있습니다.
String이 불변이라서 얻는 이점은 다음과 같습니다.
- 같은 문자열을 여러 객체가 함께 가리켜도 안전합니다. 한쪽에서 문자열을 '수정'하면 새 객체가 생길 뿐 다른 쪽이 보는 값은 그대로입니다. 기본형과 참조형 복사에서 회원 사본이 원본과 같은 이메일 문자열을 가리켜도 문제가 없었던 이유가 이것입니다.
- 문자열 풀이 가능한 것도 불변이기 때문입니다. 풀의
"object"를 누군가 바꿀 수 있다면, 같은 리터럴을 쓰는 프로그램의 모든 곳이 함께 바뀌어 버립니다. - 내용이 바뀌지 않으니
hashCode()값도 바뀌지 않습니다. OpenJDK의String은 처음 계산한 해시 값을 필드에 저장해 두고 다시 씁니다. 문자열은HashMap의 키로 자주 쓰이므로, 같은 문자열의 해시 값을 매번 다시 계산하지 않아도 된다는 이점이 있습니다. - 파일 경로나 클래스 이름처럼 검사를 거친 문자열을, 검사가 끝난 뒤 다른 코드가 몰래 바꿔치기할 수 없습니다.
대신 주의할 점도 하나 생깁니다. 메서드가 새 문자열을 돌려주므로, 그 반환값을 받지 않으면 아무 일도 일어나지 않은 것처럼 보입니다. title.toUpperCase();라고만 쓰고 title이 대문자가 되기를 기대하는 실수인데, 다음 글에서 자세히 다룹니다.
하는 일로 묶어 보는 String 메서드
String에는 메서드가 많지만 하는 일로 묶으면 기억하기 쉽습니다. 인덱스는 0부터 세고, indexOf 계열은 찾는 값이 없으면 -1을 돌려줍니다. 각 메서드의 정확한 동작은 String API 문서에서 볼 수 있습니다.
| 하는 일 | 메서드 | 예와 결과 |
|---|---|---|
| 길이와 문자 확인 | length, charAt, isEmpty, isBlank | "java".charAt(1) → 'a', " ".isEmpty() → false, " ".isBlank() → true |
| 내용 비교 | equals, equalsIgnoreCase, compareTo | "Java".equalsIgnoreCase("JAVA") → true, "apple".compareTo("banana") → 음수 |
| 찾기 | contains, indexOf, lastIndexOf, startsWith, endsWith | "board.notes.txt".indexOf('.') → 5, "board.notes.txt".lastIndexOf('.') → 11 |
| 잘라 내기와 바꾸기 | substring, replace, replaceFirst, replaceAll | "object".substring(1, 4) → "bje" |
| 다듬기 | strip, trim, toLowerCase, toUpperCase | " Java ".strip() → "Java" |
| 나누기와 합치기 | split, String.join | "a,b".split(",") → [a, b], String.join("-", "a", "b") → "a-b" |
isEmpty()는 길이가 0일 때만 true이고, isBlank()는 공백 문자만 있을 때도 true입니다. 그래서 스페이스 하나짜리 입력을 '비어 있다'고 처리하려면 isBlank()를 씁니다. compareTo는 두 문자열을 문자 코드 기준의 사전식 순서로 비교해, 앞이면 음수, 같으면 0, 뒤면 양수를 돌려줍니다. 문자열을 정렬할 때 이 순서가 쓰입니다.
이 가운데 이름 때문에 가장 많이 헷갈리는 것이 replace와 replaceAll입니다. 이름만 보면 replace는 처음 하나만, replaceAll은 전부 바꿀 것 같지만 실제로는 둘 다 일치하는 부분을 모두 바꿉니다. 차이는 첫 인수를 해석하는 방식에 있습니다. replace는 인수를 글자 그대로 찾고, replaceAll과 replaceFirst는 인수를 정규 표현식으로 해석합니다. 정규 표현식에서 .은 '아무 문자 하나'라는 뜻이라서 결과가 크게 달라집니다.
| 식 | 결과 |
|---|---|
"v1.2.3".replace(".", "-") | "v1-2-3" |
"v1.2.3".replaceAll(".", "-") | "------" |
"v1.2.3".replaceAll("\\.", "-") | "v1-2-3" |
replaceAll(".", "-")은 점만이 아니라 모든 문자를 하나씩 -로 바꿉니다. 점을 글자 그대로 찾게 하려면 셋째 줄처럼 역슬래시로 이스케이프해야 하는데, 자바 문자열 안에서는 역슬래시도 두 번 적어야 합니다. 정해진 글자를 다른 글자로 바꾸는 것이 목적이라면 처음부터 replace를 쓰는 편이 안전하고 의도도 분명합니다.
게시글 제목 입력을 다듬고 나누기
이제 여러 메서드를 이어서 실제 입력을 처리해 보겠습니다. 사용자가 게시글 제목 여러 개를 쉼표로 구분해 한 줄로 입력했는데, 앞뒤에 공백이 붙어 있고 대소문자도 제각각이라고 해 봅시다.
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=trueraw.strip()은 양끝 공백을 지운 새 문자열을 돌려주고, 그 결과에 곧바로 toLowerCase()를 호출해 소문자로 바꿉니다. 메서드가 문자열을 돌려주기 때문에 이렇게 점으로 이어 호출할 수 있는데, 이 방식은 9장 마지막 글에서 자세히 봅니다. split의 인수 "\\s*,\\s*"는 '공백 0개 이상, 쉼표, 공백 0개 이상'을 구분자로 쓰라는 정규 표현식입니다. \s는 공백 문자 하나를, *는 '바로 앞의 것이 0개 이상'을 뜻하므로 쉼표 앞뒤의 공백이 구분자와 함께 사라집니다. Scanner로 키보드 입력 받기에서 쓴 "\\s+"와 같은 문법입니다.
Arrays.toString은 배열 내용을 대괄호 안에 쉼표로 나열해 보여 주는 도우미이고, String.join(" -> ", titles)는 배열 항목 사이에만 구분자를 넣어 하나의 문자열로 합칩니다. 마지막 줄의 contains는 부분 문자열이 들어 있는지만 확인합니다.
split에는 알아 두어야 할 동작이 하나 있습니다. 인수가 하나인 split은 결과 배열의 끝에 생기는 빈 문자열을 버립니다. 입력 끝에 쉼표가 남은 경우로 비교해 보겠습니다.
| 식 | 결과 | 길이 |
|---|---|---|
"a,b,,c,".split(",") | [a, b, , c] | 4 |
"a,b,,c,".split(",", -1) | [a, b, , c, ] | 5 |
b와 c 사이의 빈 항목은 두 경우 모두 남지만, 마지막 쉼표 뒤의 빈 항목은 인수가 하나인 split에서 사라집니다. 두 번째 인수 limit에 음수를 주면 끝의 빈 문자열까지 모두 남습니다. 그래서 빈 항목을 입력 오류로 처리해야 한다면 split(",", -1)로 받아서 검사해야 오류를 놓치지 않습니다. 빈 항목을 허용할지, 대소문자를 보존할지 같은 결정은 메서드가 대신해 주지 않으므로 입력 규칙으로 먼저 정해 두어야 합니다.
더 깊이: strip과 trim이 지우는 공백
Java 11에서 추가된 strip()과 오래된 trim()은 둘 다 양끝 공백을 지우지만, 무엇을 공백으로 보는지가 다릅니다. trim()은 코드 값이 U+0020(스페이스) 이하인 문자를 모두 지웁니다. 탭과 줄바꿈 같은 제어 문자는 여기에 들어가지만, 코드 값이 그보다 큰 유니코드 공백은 지우지 못합니다. strip()은 Character.isWhitespace가 공백으로 인정하는 문자를 지우므로 EM SPACE(U+2003) 같은 문자도 지웁니다.
그렇다고 strip()이 모든 공백을 지우는 것은 아닙니다. 줄바꿈 없는 공백(NBSP, U+00A0)은 Character.isWhitespace가 공백으로 보지 않으므로 strip()과 trim() 어느 쪽으로도 지워지지 않습니다. 웹 페이지에서 복사한 텍스트에는 NBSP가 자주 섞여 있으니, 그런 입력을 다룬다면 Character.isWhitespace의 기준을 확인하고 필요한 문자를 직접 처리해야 합니다.
인덱스로 자를 때 지켜야 할 범위
charAt(i)와 substring(begin, end)는 인덱스로 문자열의 일부를 꺼냅니다. substring의 끝 인덱스는 결과에 포함되지 않으므로 "object".substring(1, 4)는 1번, 2번, 3번 문자인 "bje"이고, 결과의 길이는 언제나 end - begin입니다. charAt에는 0부터 length() - 1까지, substring에는 0부터 length()까지의 인덱스만 쓸 수 있고 begin이 end보다 커서도 안 됩니다. 이 범위를 벗어나면 StringIndexOutOfBoundsException이 발생하므로, 사용자 입력처럼 길이를 미리 알 수 없는 문자열은 자르기 전에 길이나 구분자의 위치를 확인해야 합니다.
다음 프로그램은 제목을 최대 길이까지만 잘라 내는 prefix와 파일 이름에서 확장자를 꺼내는 extension을 정의합니다.
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
출력의 넷째 줄은 비어 있습니다. extension("README")가 빈 문자열을 돌려주었기 때문입니다.
prefix는 Math.min(value.length(), maxLength)를 끝 인덱스로 씁니다. "immutability"는 12글자라서 5에서 잘려 immut이 되고, "api"는 3글자라서 끝 인덱스가 3이 되어 원래 문자열이 그대로 나옵니다. 끝 인덱스로 무조건 maxLength를 주었다면 "api".substring(0, 5)가 되어 예외가 났을 것입니다. extension은 lastIndexOf('.')로 마지막 점의 위치를 찾습니다. 점이 없어서 -1이 나오거나 점이 맨 끝에 있으면 확장자가 없다고 보고 빈 문자열을 돌려주고, 그렇지 않으면 점 다음부터 끝까지를 잘라 냅니다. 마지막 점을 찾기 때문에 board.notes.txt처럼 점이 여러 개인 이름에서도 txt를 얻습니다.
SafeTitlePrefix의 두 함수가 실제로 검사하는 조건과 원본 main의 결과를 구분한다.
접두어와 확장자는 각각의 경계 조건을 검사합니다
SafeTitlePrefix는 접두어와 확장자에 같은 검사를 적용하지 않습니다. 검사 조건은 함수 본문, 입출력은 원본 main 기준입니다.
| 함수 | 구현한 검사 | main 입력 → 결과 |
|---|---|---|
접두어 · prefix | 값이 그 외에는 길이와 |
|
확장자 · extension | 마지막 점이 없거나 끝에 있으면 그 외에는 점 다음부터 추출 |
|
- 접두어 ·
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 입력 검사가 없습니다.
이 둘을 모든 입력에 안전한 공통 검증 절차로 해석하지 않습니다. 숨김 파일과 실제 경로의 정책도 이 예제의 보장 범위 밖입니다.
두 함수가 모든 입력을 막아 주는 것은 아닙니다. prefix는 null이면 빈 문자열을 돌려주지만, maxLength가 음수이면 substring에서 예외가 납니다. extension은 null을 검사하지 않으므로 null을 넘기면 NullPointerException이 납니다. 그러니 이 함수를 부르는 쪽에서 maxLength가 0 이상이고 파일 이름이 null이 아니라는 조건을 맞춰 주어야 합니다.
파일 이름 규칙도 생각보다 다양합니다. 이 extension은 .gitignore처럼 점으로 시작하는 숨김 파일에 대해 gitignore를 돌려줍니다. 이런 이름을 확장자 없는 파일로 볼지는 프로그램이 정할 규칙이고, 9장 마지막 글에서는 이 경우를 다르게 처리하는 버전을 봅니다. 경로 구분자까지 섞인 실제 파일 경로를 다룬다면 문자열을 직접 자르기보다 Path API를 쓰는 편이 안전합니다.
더 깊이: length()는 글자 수가 아니다
String의 길이와 인덱스는 화면에 보이는 글자가 아니라 UTF-16 코드 단위(code unit), 즉 char 개수를 기준으로 합니다. 한글 음절을 비롯한 대부분의 문자는 char 하나로 표현되지만, 이모지처럼 코드 포인트(code point)가 U+FFFF보다 큰 문자는 서로게이트 쌍(surrogate pair)이라는 char 두 개로 저장됩니다. 다음 프로그램은 화면에서 두 글자로 보이는 "A😀"의 길이를 확인합니다.
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😀length()는 A 하나와 이모지의 char 두 개를 세어 3을 돌려주고, codePointCount는 코드 포인트 단위로 세어 2를 돌려줍니다. substring(0, 2)는 A와 이모지의 앞쪽 절반만 잘라 내므로, 결과의 마지막 char가 짝을 잃은 상위 서로게이트(high surrogate)라는 것을 셋째 줄이 보여 줍니다. 이런 문자열은 화면에 깨진 문자로 나옵니다. 코드 포인트 개수로 자르려면 offsetByCodePoints(0, 2)로 '처음부터 코드 포인트 2개를 지난 인덱스'를 구해 substring에 넘깁니다. 여기서는 3이 나오므로 substring(0, 3)이 되어 이모지가 온전히 남습니다.
코드 포인트로 세어도 사람이 보는 글자와 항상 같지는 않습니다. 여러 코드 포인트가 결합해 한 글자로 보이는 이모지나 악센트 조합은 이 방법으로도 중간에서 잘릴 수 있습니다. 문자와 인코딩은 문자와 바이트: 유니코드와 UTF-8에서 자세히 다룹니다.
요구에 맞는 메서드 고르기
문자열을 다루는 코드를 쓸 때는 먼저 요구를 한 문장으로 적어 보고, 그 문장에 딱 맞는 가장 좁은 메서드를 고르는 것이 좋습니다. '두 값이 같은가'는 equals, '이 접두어로 시작하는가'는 startsWith, '이 단어가 들어 있는가'는 contains, '어디에 있는가'는 indexOf, '이 글자를 저 글자로 바꾼다'는 replace입니다. 예를 들어 title.indexOf("java") == 0과 title.startsWith("java")는 같은 결과를 내지만, 뒤쪽이 무엇을 확인하려는지 훨씬 분명하게 드러냅니다.
메서드를 여러 번 이어 호출하면 그때마다 중간 문자열이 새로 만들어집니다. 그렇다고 처음부터 성능을 걱정해 코드를 복잡하게 만들 필요는 없습니다. 한 줄에서 몇 번 변환하는 정도는 대부분 문제가 되지 않습니다. 실제로 비용이 커지는 것은 반복문에서 문자열을 계속 이어 붙이는 경우이고, 이 문제는 다음 글에서 StringBuilder로 해결합니다.
대소문자를 바꿀 때는 로케일(locale)도 생각해야 합니다. 인수 없는 toLowerCase()와 toUpperCase()는 프로그램이 실행되는 환경의 기본 로케일 규칙을 따르는데, 대소문자 규칙은 언어마다 다릅니다. 대표적인 예가 터키어입니다. 터키어에서는 대문자 I의 소문자가 점 없는 ı이므로, 터키어 로케일에서 "TITLE".toLowerCase()는 "title"이 아니라 "tıtle"이 됩니다. 앞의 BoardTitleNormalizer처럼 비교하려고 정규화한 값이 실행 환경에 따라 달라지면, 같은 제목을 다른 제목으로 인식하게 됩니다. 그래서 명령어, 설정 키, 태그처럼 프로그램이 식별자로 쓰는 문자열은 toLowerCase(Locale.ROOT)처럼 로케일을 명시해 어디서 실행해도 같은 결과가 나오게 합니다. 사람 이름이나 문장처럼 언어마다 규칙이 다른 자연어 텍스트는 이와 다른 국제화 처리가 필요합니다.
정리
==는 두 문자열이 같은 객체인지를,equals는 내용이 같은지를 비교합니다. 문자열의 내용은 언제나equals로 비교하고,null일 수 있으면"java".equals(input)처럼 상수 쪽에서 호출하거나Objects.equals를 씁니다.- 리터럴과 상수식은 문자열 풀의 같은 객체를 함께 쓰지만,
new String이나 실행 중 결합으로 만든 문자열은 별도 객체입니다. 그래서==의 결과는 문자열이 만들어진 방법에 따라 달라집니다. String은 불변입니다. 문자열을 바꾸는 것처럼 보이는 메서드는 모두 새 문자열을 돌려주므로 반환값을 받아야 합니다.replace와replaceAll은 둘 다 일치하는 부분을 모두 바꾸지만,replaceAll은 인수를 정규 표현식으로 해석합니다. 인수가 하나인split은 끝의 빈 문자열을 버립니다.substring의 끝 인덱스는 결과에 포함되지 않고, 범위를 벗어난 인덱스는 예외를 일으킵니다. 길이와 인덱스는 UTF-16 코드 단위 기준입니다.- 식별자로 쓰는 문자열의 대소문자를 바꿀 때는
Locale.ROOT를 명시합니다.
연습 문제
- 이메일 문자열에
@가 정확히 하나 있고 그 앞뒤가 비어 있지 않으면 사용자명과 도메인을 출력하고, 그렇지 않으면invalid를 출력하세요.
해설 보기
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
invalidindexOf와 lastIndexOf가 같은 위치를 돌려준다면 @가 하나뿐이라는 뜻입니다. at > 0은 @가 없거나(-1) 맨 앞에 있는(0) 경우를 거르고, at < email.length() - 1은 @가 맨 끝에 있는 경우를 거릅니다. broken@@example.com은 처음 @가 6번, 마지막 @가 7번에 있어서 invalid가 됩니다. 세 조건을 모두 통과하면 substring(0, at)이 @ 앞부분을, substring(at + 1)이 @ 뒷부분을 잘라 냅니다.
이 검사는 흐름을 보여 주기 위한 최소 규칙입니다. 실제 서비스에서는 이메일 표준 전체를 정규식으로 직접 구현하기보다, 서비스가 받아들일 형식의 범위를 정하고 검증 라이브러리를 쓰는 편이 낫습니다.
- 다음 프로그램의 출력을 예상하고, 줄마다 이유를 설명하세요.
public final class PoolQuiz {
public static void main(String[] args) {
String a = "hello";
String b = "hel" + "lo";
String c = new String("hello");
String d = c.intern();
System.out.println(a == b);
System.out.println(a == c);
System.out.println(a == d);
System.out.println(a.equals(c));
}
}해설 보기
true
false
true
true"hel" + "lo"는 리터럴끼리의 결합이라 컴파일할 때 "hello"로 합쳐지므로, b는 a와 같은 풀의 객체를 가리킵니다. c는 new로 만든 별도 객체라서 a == c는 false입니다. c.intern()은 풀에 이미 있는 "hello"를 돌려주므로 d는 a와 같은 객체이고, 마지막 줄은 내용을 비교하므로 true입니다. 앞의 세 줄은 문자열을 만든 방법에 따라 답이 달라졌지만, equals는 그런 사정과 상관없이 내용만 봅니다.
"java,,spring,".split(",")과"java,,spring,".split(",", -1)이 돌려주는 배열의 길이를 각각 예상하세요.
해설 보기
첫 번째는 [java, , spring]으로 길이가 3이고, 두 번째는 [java, , spring, ]으로 길이가 4입니다. 쉼표 세 개로 나누면 조각은 java, 빈 문자열, spring, 빈 문자열의 네 개입니다. 인수가 하나인 split은 끝에 오는 빈 문자열만 버리므로 가운데의 빈 문자열은 남고 마지막 것만 사라집니다. limit에 음수를 준 두 번째 호출은 아무것도 버리지 않습니다.
다음 글
문자열 서식과 StringBuilder에서는 반환값을 버리는 실수를 바로잡고, 값을 문자열로 바꾸고 서식에 맞춰 출력하는 방법과 반복문에서 문자열을 효율적으로 이어 붙이는 StringBuilder를 다룹니다.