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

빌드노트

로그에 id=42가 있는데 String.matches는 왜 false일까

로그 안에 id=42가 있어도 String.matches는 전체 입력을 검사해 false일 수 있다. 같은 패턴으로 matches·lookingAt·find의 결과와 탐색 위치, 실패 후 group() 예외, region의 영향을 직접 비교한다.

Java에서 "INFO id=42 done".matches("id=([0-9]+)")는 false다. 문자열 안에 id=42가 있어도, String.matches는 문자열 전체가 패턴에 맞아야 성공하기 때문이다. 로그 안에서 식별자를 찾으려면 Matcher.find()를 쓴다. 입력 전체가 식별자 형식인지 검증하려는 코드라면 matches()가 맞다.

JDK 17 이상이 설치되어 있다면 터미널에서 java -version으로 버전을 확인하고 jshell --execution local을 실행한다. 아래 입력은 같은 JShell 세션에서 순서대로 실행한다. 첫 비교에서는 패턴을 바꾸지 않고 전체 검증을 부분 검색으로 바꿨을 때 결과가 달라지는지 본다.

import java.util.regex.Pattern;
import java.util.regex.Matcher;
String line = "INFO id=42 done; id=7";
Pattern p = Pattern.compile("id=([0-9]+)");
System.out.println(line.matches(p.pattern()));
System.out.println(p.matcher(line).find());
false
true

위 출력과 아래 비교는 Windows의 OpenJDK 17.0.2에서 같은 입력을 다시 실행해 확인했다. 패턴의 [0-9]+는 숫자 한 개 이상을 뜻하고, 괄호는 그 숫자 부분을 나중에 꺼내기 위해 묶었다. INFO 와 done; id=7까지 이 패턴에 맞는 것은 아니므로 전체 검증은 실패한다.

String.matches 문서 (새 탭에서 열림)는 이 호출이 Pattern.matches(regex, str)와 같은 결과를 낸다고 설명한다. Pattern 문서 (새 탭에서 열림)는 이를 패턴 생성과 Matcher.matches() 호출로 풀어 보여 준다. 메서드 이름의 matches를 ‘안에 들어 있는지 찾는다’로 읽으면 이 차이를 놓치기 쉽다.

앞에 붙은 문자와 뒤에 붙은 문자를 나눠 본다

lookingAt()은 시작 위치를 확인할 때 고르는 연산이다. 다음 비교는 같은 패턴에 식별자만 있는 입력, 뒤에 문자가 붙은 입력, 앞뒤에 문자가 붙은 입력, 식별자가 없는 입력을 넣는다. 각 연산에는 새 Matcher를 만들어 이전 탐색 상태가 섞이지 않게 한다.

for (String s : new String[]{"id=42", "id=42 done", line, "INFO done"}) {
    System.out.printf("%s -> matches=%s lookingAt=%s find=%s%n", s,
        p.matcher(s).matches(), p.matcher(s).lookingAt(), p.matcher(s).find());
}
id=42 -> matches=true lookingAt=true find=true
id=42 done -> matches=false lookingAt=true find=true
INFO id=42 done; id=7 -> matches=false lookingAt=false find=true
INFO done -> matches=false lookingAt=false find=false

id=42 done은 식별자로 시작하지만 뒤에 문자가 남는다. 그래서 lookingAt()은 성공하고 matches()는 실패했다. 앞에 INFO 까지 붙인 입력에서는 lookingAt()도 실패하고, 시작 위치를 옮겨 찾는 find()만 성공했다. 이 결과에 따라 필요한 질문을 고른다.

  • 입력 전체가 식별자 하나여야 한다면 matches().
  • 입력이 식별자로 시작하는지만 확인한다면 lookingAt().
  • 입력 어딘가에 식별자가 있는지 찾거나 그 값을 꺼낸다면 find().

Matcher의 세 연산 (새 탭에서 열림)은 엄밀히는 현재 영역(region)을 기준으로 동작한다. 새 Matcher의 기본 영역은 입력 전체다. 영역을 따로 지정하지 않은 위 비교에서는 문자열의 처음과 끝을 기준으로 판단하면 된다.

find는 다음 일치로 이동한다

로그에 있는 식별자를 모두 꺼낼 때는 같은 Matcher에서 find()를 반복한다. 다음 입력으로 일치한 문자열, 숫자 그룹, 시작과 끝 위치를 함께 확인한다. 마지막 try/catch는 탐색이 끝난 뒤 group()을 읽으면 어떻게 되는지 확인하는 실험용 코드다.

Matcher scan = p.matcher(line);
while (scan.find()) {
    System.out.printf("%s number=%s [%d,%d)%n",
        scan.group(), scan.group(1), scan.start(), scan.end());
}
try { System.out.println(scan.group()); }
catch (IllegalStateException e) { System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage()); }
id=42 number=42 [5,10)
id=7 number=7 [17,21)
IllegalStateException: No match found

이 패턴에서 두 번째 find()는 앞서 찾은 id=42 뒤부터 탐색해 id=7을 반환했다. 세 번째 호출은 false가 되어 반복문을 끝냈다. 모든 값을 찾으려면 Matcher를 반복문 밖에서 한 번 만든다. 이 입력에서 매번 p.matcher(line).find()로 시작하면 첫 번째 식별자를 계속 찾게 된다.

범위 [5,10)은 인덱스 5를 포함하고 10은 포함하지 않는다는 뜻이다. group()은 일치 전체인 id=42, group(1)은 괄호로 묶은 42다. 두 번째 결과에서는 각각 id=7과 7로 바뀌었다.

값은 find()가 성공한 분기 안에서 읽는다. 반복문 밖에서는 직전 탐색이 이미 실패했으므로, 조금 전에 찾은 값도 group()으로 다시 읽을 수 없었다. 별도 실행에서도 실패한 matches() 뒤와 매칭을 한 번도 시도하지 않은 상태의 group()은 같은 예외를 냈다. 이는 group 문서의 실패 조건 (새 탭에서 열림)과 일치한다. 첫 식별자만 필요하면 while 대신 if (scan.find())의 성공 분기에서 값을 읽으면 된다.

region을 지정했다면 ‘전체’의 범위도 바뀐다

이미 입력의 어느 구간이 필드인지 알고 있다면 region(start, end)로 그 구간을 검사할 수 있다. 다음 입력은 같은 line에서 id=42가 있는 구간만 전체 검증하고, 끝에 공백 한 칸을 더 포함했을 때도 비교한다.

Matcher field = p.matcher(line).region(5, 10);
if (field.matches()) {
    System.out.printf("%s [%d,%d)%n", field.group(), field.start(), field.end());
}
System.out.println(p.matcher(line).region(5, 11).matches());
System.out.println(p.matcher(line).region(5, line.length()).lookingAt());
id=42 [5,10)
false
true

문자열 자체는 그대로인데 region(5, 10)의 matches()가 성공했다. 이 호출에서 검사하는 전체는 id=42 다섯 글자다. 끝을 11로 늘리면 공백이 포함되어 실패했다. 영역 시작을 5로 옮긴 lookingAt()도 성공했다. 여기서 ‘시작’은 원본 문자열의 0이 아니다. 반환된 위치는 새 영역 안의 0부터 다시 세지 않고 원본 기준인 [5,10)이었다.

region 문서 (새 탭에서 열림)에 따르면 영역 지정은 Matcher를 초기화하고 탐색 범위를 설정한다. 기존 Matcher에서 결과가 예상과 다르면 regionStart()와 regionEnd()로 현재 범위도 확인한다. 식별자만 있는 "id=42"를 새 Matcher에 넣어 세 연산이 모두 성공하는지 비교하면, 패턴 문제와 입력 범위 문제를 좁힐 수 있다. 같은 line으로 다시 실행했는데 첫 find()부터 실패한다면 위에서 정의한 패턴·입력과 같은지, 이미 사용한 Matcher인지부터 대조한다.

실행을 마치면 JShell에서 /exit를 입력한다. 여기서 확인한 것은 ASCII 숫자를 쓰는 짧은 합성 문자열의 매칭 범위와 탐색 상태다. 이 패턴은 실제 로그의 식별자 경계 규칙까지 정의하지 않는다. Java 21 문서와 실행 버전인 Java 17 문서의 해당 계약을 대조했으며, Java 21 런타임, 빈 문자열에 일치하는 패턴, 앵커·전후방 탐색과 영역 경계의 조합, 긴 로그의 성능은 실행하지 않았다.

← 전체 글 보기