본문 바로가기
빌드노트 검색

빌드노트

Java replaceAll에서 달러 오류와 역슬래시 손실을 막으려면

Java replaceAll의 replacement는 달러를 그룹 참조로, 역슬래시를 이스케이프로 해석한다. 실제 예외와 경로 손실을 비교하고, 문자 치환에는 String.replace를, 정규식 검색에는 Matcher.quoteReplacement를 적용하는 기준을 설명한다.

Java에서 외부 문자열을 replaceAll의 두 번째 인자로 넘겼는데 No group 5가 뜨거나 경로의 역슬래시가 사라진다면, 바꿀 문자열인 replacement의 해석을 확인한다. replaceAll은 첫 번째 인자의 정규식뿐 아니라 두 번째 인자의 $와 \도 특별하게 해석한다. 찾을 대상과 바꿀 값이 모두 일반 문자열이면 String.replace를 쓰고, 정규식으로 찾아야 한다면 바꿀 값을 Matcher.quoteReplacement로 감싼다.

replace도 일치하는 문자열을 모두 바꾼다. 한 번만 바꾸는 메서드여서 replaceAll로 옮겨야 하는 것은 아니다. Java SE 17의 String 문서 (새 탭에서 열림)는 문자열을 받는 replace가 대상과 바꿀 값을 모두 문자 그대로 처리한다고 설명한다.

$5를 금액으로 넣었는데 그룹을 찾는다

JDK 17의 bin이 PATH에 잡힌 터미널에서 jshell -q를 실행하고 다음 코드를 붙여 넣는다. try는 실패한 호출 뒤에도 비교를 이어 가기 위한 코드다. 실제 수정은 뒤의 두 호출 중 목적에 맞는 하나를 고르는 것이다.

import java.util.regex.Matcher;
String text = "TOKEN|TOKEN";
String replacement = "$5";
try {
    System.out.println(text.replaceAll("TOKEN", replacement));
} catch (RuntimeException e) {
    System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
}
System.out.println(text.replaceAll("TOKEN", Matcher.quoteReplacement(replacement)));
System.out.println(text.replace("TOKEN", replacement));

Windows 11, OpenJDK 17.0.2에서 실행한 출력은 다음과 같았다. JShell 프롬프트와 변수 값 안내는 생략했다.

IndexOutOfBoundsException: No group 5
$5|$5
$5|$5

TOKEN이라는 정규식은 정상이다. 실패한 쪽은 바꿀 값 $5다. Matcher의 replacement 문법 (새 탭에서 열림)에서 $ 뒤의 숫자는 캡처 그룹 참조다. 여기에는 5번 그룹이 없으므로 예외가 났다. quoteReplacement를 거친 호출과 replace는 두 TOKEN을 모두 실제 문자 $5로 바꿨다.

달러가 있다고 항상 예외가 나는 것도 아니다. 같은 입력에서 바꿀 값을 $price로 주면 IllegalArgumentException: Illegal group reference가 났지만, $0으로 주면 TOKEN|TOKEN이 나왔다. $0이 전체 일치 문자열을 참조해 원래 TOKEN을 다시 넣었기 때문이다. 예외가 없더라도 달러가 보존됐는지 결과를 확인해야 한다.

역슬래시는 예외 없이 사라질 수도 있다

같은 JShell에서 다음 코드를 이어 실행한다. Java 소스의 \\는 실행 중 문자열에서 역슬래시 한 글자다. 첫 줄의 값에는 탭이나 줄바꿈이 들어 있지 않다.

String path = "C:\\temp\\new";
System.out.println(path);
System.out.println(text.replaceAll("TOKEN", path));
System.out.println(text.replaceAll("TOKEN", Matcher.quoteReplacement(path)));
System.out.println(text.replace("TOKEN", path));
C:\temp\new
C:tempnew|C:tempnew
C:\temp\new|C:\temp\new
C:\temp\new|C:\temp\new

입력 자체에는 역슬래시가 있었지만, 그대로 넘긴 replaceAll에서는 없어졌다. replacement의 역슬래시가 뒤 문자를 이스케이프하는 데 쓰였기 때문이다. 끝이 역슬래시 하나인 값 tail\도 따로 실행했으며, 이때는 IllegalArgumentException: character to be escaped is missing이 났다. 두 값을 quoteReplacement로 처리하거나 replace에 넘기면 역슬래시가 보존됐다.

Matcher.quoteReplacement 문서 (새 탭에서 열림)가 보장하는 것은 바꿀 값의 달러와 역슬래시를 문자 그대로 넣는 것이다. 파일이나 요청에서 이미 읽어 온 문자열을 Java 소스 리터럴처럼 다시 손으로 고치기보다, 읽어 온 원본 값에 Matcher.quoteReplacement를 한 번 적용해 replacement로 전달한다.

Pattern.quote는 찾을 대상에 적용한다

quoteReplacement는 첫 번째 인자의 정규식 의미까지 없애지 않는다. 점이 들어 있는 a.b를 찾을 때 두 인자를 구별해 보면 차이가 드러난다.

import java.util.regex.Pattern;
String target = "a.b";
String value = "$5\\tmp";
System.out.println("a.b aXb".replaceAll(target, Matcher.quoteReplacement(value)));
System.out.println("a.b aXb".replaceAll(Pattern.quote(target), Matcher.quoteReplacement(value)));
System.out.println("a.b aXb".replace(target, value));
$5\tmp $5\tmp
$5\tmp aXb
$5\tmp aXb

첫 호출에서는 정규식의 점이 X에도 일치해 두 곳이 바뀌었다. Pattern.quote (새 탭에서 열림)를 첫 번째 인자에 적용한 호출은 실제 점이 있는 a.b만 바꿨다. String.replace도 같은 결과였다. 반대로 첫 번째 인자만 Pattern.quote(target)으로 감싸고 두 번째 인자에 $5\tmp를 그대로 주면 여전히 No group 5가 났다. 두 quote 메서드는 맡은 인자가 다르다.

찾을 조건이 [0-9]+처럼 정규식이어야 한다면 그 패턴은 유지하고 바꿀 값만 처리한다. 같은 value로 "id=42;id=7".replaceAll("[0-9]+", Matcher.quoteReplacement(value))를 실행하자 id=$5\tmp;id=$5\tmp가 나왔다. 숫자 묶음을 찾으면서 외부 값의 달러와 역슬래시는 보존한 것이다.

의도한 그룹 참조까지 감싸지는 않는다. "id=42".replaceAll("id=([0-9]+)", "$1")의 결과는 42였고, 바꿀 값 $1을 quoteReplacement로 감싸면 결과는 문자 그대로 $1이었다. 외부 데이터를 넣는지, 개발자가 작성한 그룹 참조를 실행하는지에 따라 선택한다. 확인을 마치면 JShell에서 /exit로 종료한다.

자기 코드에서 결과가 다르면 먼저 실제 입력에 패턴이 일치하는 부분이 있는지 본다. 이번 실행에서 "nothing".replaceAll("TOKEN", "$5")는 예외 없이 nothing을 반환했다. 일치가 없는 입력만으로 replacement가 안전하다고 판단할 수 없다. 수정 확인에는 실제로 치환되는 입력과 달러·역슬래시가 포함된 값을 함께 쓴다.

위 결과는 OpenJDK 17.0.2에서 짧은 합성 문자열을 실행한 관찰이다. 다른 JDK 버전과 실제 외부 입력 경로는 실행하지 않았다. 정규식 성능이나 긴 입력의 처리 시간도 측정하지 않았다.

← 전체 글 보기