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

빌드노트

같은 문자열인데 정규식 test()가 true와 false를 오가는 이유

같은 JavaScript 정규식으로 같은 문자열을 검사해도 g 플래그의 lastIndex 상태 때문에 결과가 바뀔 수 있다. 호출 전후 위치를 직접 비교하고, 포함 판별에서 g를 제거할 때와 호출마다 0으로 초기화할 때를 구별한다.

JavaScript에서 한 번 만든 /item/g로 같은 "item"을 검사했는데 true 다음에 false가 나온다면, 정규식 객체의 lastIndex를 확인한다. g가 붙은 정규식은 다음 검색 위치를 객체에 남긴다. 문자열에 패턴이 포함되는지만 매번 독립적으로 판단하려면 g를 빼는 것이 간단하다. g를 유지해야 한다면 각 판별 직전에 lastIndex를 0으로 돌리는 경계를 정해야 한다.

반환값 옆에 검색 위치를 찍어 본다

Node.js가 설치돼 있다면 터미널에서 node를 실행하고 아래 코드를 붙여 넣는다. 같은 정규식 객체를 네 번 사용하도록 선언을 반복문 밖에 둔다. 각 줄은 호출 번호, 호출 전 lastIndex, 반환값, 호출 후 lastIndex 순서다.

const re = /item/g;
const input = "item";
for (let i = 1; i <= 4; i++) {
  const before = re.lastIndex;
  const result = re.test(input);
  console.log(i, before, result, re.lastIndex);
}

Windows x64의 Node.js 22.23.2에서 새 프로세스로 실행했을 때, 반복문의 출력은 다음과 같았다. Node 대화형 실행기가 별도로 표시하는 평가 결과는 제외했다.

1 0 true 4
2 4 false 0
3 0 true 4
4 4 false 0

첫 호출은 위치 0부터 item을 찾아 성공하고, 그 바로 다음 위치인 4를 남겼다. 두 번째 호출은 같은 문자열이라도 위치 4부터 검색한다. 이미 문자열 끝이고 더 찾을 item이 없으므로 실패한다. 이 실패로 lastIndex가 0이 되어 세 번째 호출은 다시 성공한다. 문자열이 바뀌거나 사라진 것이 아니라, 호출마다 출발점이 달랐다.

ECMAScript의 test() 절차 (새 탭에서 열림)는 정규식 실행 결과가 null이면 false, 그렇지 않으면 true를 반환한다. Boolean만 돌려준다고 객체 상태까지 보존하는 것은 아니다. 기본 실행 절차인 RegExpBuiltinExec (새 탭에서 열림)에서 g의 검색 시작 위치를 읽고, 성공하면 일치한 부분의 끝 위치를 기록하며, 끝까지 찾지 못하면 0으로 되돌린다.

포함 여부를 묻는 코드에서는 g를 뺀다

필요한 답이 “이 입력에 item이 하나라도 있는가”라면 첫 예제의 선언을 const re = /item/;로 바꾼다. 새 Node 세션에서 같은 반복문을 실행하니 네 번 모두 true였고, 호출 전후 lastIndex는 모두 0이었다. /item/만으로도 문자열 안에서 패턴을 찾으므로, 포함 여부를 확인하기 위해 g를 붙일 필요는 없다.

반대로 한 문자열의 매치를 차례로 찾아가는 코드에서는 검색 진행 상태가 필요하다. 그 용도의 정규식을 바꾸기 어렵고 별도의 포함 판별에서도 사용해야 한다면, 판별 함수 안에서 매번 출발점을 정할 수 있다. 아래 코드는 앞 예제와 별도의 새 Node 세션에서 실행한다.

const re = /item/g;
function hasItem(input) {
  re.lastIndex = 0;
  return re.test(input);
}
console.log(hasItem("item"), hasItem("item"));
console.log(hasItem("book"));

출력은 다음과 같았다. 일치하지 않는 book은 여전히 거절한다.

true true
false

같은 "item"을 네 번 검사하는 비교에서도, 호출 직전에 0을 대입하면 네 번 모두 true였다. 다만 성공한 호출 뒤의 lastIndex는 여전히 4다. 한 번 초기화해 두는 것만으로 이후 호출까지 독립적이 되는 것은 아니다. 또 초기화는 같은 객체로 진행 중이던 검색 위치도 덮어쓴다. 순차 검색과 포함 판별이 서로 다른 용도라면 판별용 /item/ 객체를 따로 두는 편이 상태 관리를 줄인다.

입력이 바뀌어도 객체의 위치는 남는다

같은 입력을 연달아 넣지 않는다고 문제가 없어지지는 않는다. 이번에는 새 Node 세션에서 정규식 하나에 서로 다른 네 문자열을 넣고, 입력과 호출 전후 위치를 함께 출력한다.

const re = /item/g;
for (const input of ["item-a", "item-b", "xx item", "item"]) {
  const before = re.lastIndex;
  const result = re.test(input);
  console.log(JSON.stringify(input), before, result, re.lastIndex);
}

네 입력 모두 item을 포함하지만 결과는 같지 않았다.

"item-a" 0 true 4
"item-b" 4 false 0
"xx item" 0 true 7
"item" 7 false 0

두 번째 입력은 앞에 있는 item을 지나친 위치 4부터 검색한다. 네 번째는 이전 입력에서 남은 위치 7이 새 문자열 길이 4보다 커서 실패한다. lastIndex는 입력별로 관리되는 값이 아니라 정규식 객체에 남는 값이기 때문이다. MDN의 lastIndex 설명 (새 탭에서 열림)도 다른 입력에 같은 객체를 재사용할 때의 부수 효과와 초기화를 다룬다. 같은 네 입력을 /item/으로 검사하거나 /item/g의 위치를 매번 0으로 초기화한 비교에서는 모두 true가 나왔다.

g를 쓰면 반드시 성공과 실패가 한 번씩 번갈아 나온다는 뜻도 아니다. 별도로 "item item"을 같은 /item/g로 네 번 검사하니 true, true, false, true였고, 호출 후 위치는 4, 9, 0, 4였다. 다음 매치가 있으면 검색이 이어진다. 따라서 반환값의 규칙성만 보지 말고, 문제가 생긴 호출의 입력과 전후 lastIndex를 같이 본다. 예상과 다른 결과가 계속되면 실제 재사용되는 객체의 플래그, 호출 직전 위치와 중간에 같은 객체를 사용하는 코드부터 확인한다.

직접 실행한 범위는 위 Node 버전의 기본 RegExp와 /item/, /item/g, 합성 문자열이다. 브라우저별 실행이나 사용자 정의 exec()를 사용하는 객체는 확인하지 않았다. 실제 폼이나 사용자 입력을 수집한 결과도 아니다.

← 전체 글 보기