DEV BOX

'26/11/06 UPDATE

JAVASCRIPT / REGEXP / FIELD NOTES

같은 문자열인데 정규식 test()가 한 번은 true, 한 번은 false라면 어디를 볼까

g 플래그가 붙은 정규식 객체는 지난번 검색이 끝난 위치를 lastIndex에 기억합니다. Node.js v24.21.0에서 같은 문자열의 test()가 번갈아 true와 false를 내고 filter()가 행을 빠뜨리는 것을 실행해 보고, 메서드마다 lastIndex를 어떻게 다루는지와 목적별 고치는 방법을 정리합니다.

입력값 검사 함수에 같은 값을 넣었는데 한 번은 통과하고 한 번은 실패하거나, filter()로 거른 배열에서 조건에 맞는 행이 절반쯤 사라질 때 먼저 볼 곳은 정규식의 g 플래그입니다. g가 붙은 정규식 객체는 지난번 검색이 끝난 위치를 기억합니다. 이 글을 읽고 나면 어떤 메서드가 그 위치를 쓰는지 구분하고, 코드에서 g를 뺄지 그대로 둘지 정할 수 있습니다.

예제는 2026-11-01에 macOS 27.0.1에서 Node.js v24.21.0(V8 13.6.233.17)으로 실행했습니다. 동작은 ECMA-262 초안(2026년 10월 31일판)과 MDN으로 확인했고, 브라우저에서는 실행하지 않았습니다. 예제의 값은 모두 가상입니다.

정규식 객체는 마지막 위치를 기억한다

모듈 맨 위에 상수로 둔 숫자 검사 정규식에, 필요 없는 g가 붙어 있는 경우입니다.

const digits = /^\d+$/g;

for (let call = 1; call <= 4; call++) {
  const before = digits.lastIndex;
  const result = digits.test("12345");
  console.log(`call ${call}: lastIndex ${before} -> ${digits.lastIndex}, test = ${result}`);
}

같은 문자열 "12345"인데 결과는 true, false, true, false였습니다. 범인은 lastIndex 속성입니다. 첫 호출은 0에서 시작해 끝까지 맞았고 lastIndex가 5가 되었습니다. 둘째 호출은 5에서 시작하므로 ^가 맞을 수 없어 실패했고, 실패하면서 lastIndex가 0으로 돌아갔습니다. 그래서 셋째 호출은 다시 맞습니다.

명세의 RegExpBuiltinExec도 같습니다. lastIndex를 읽되 g와 y가 모두 없으면 0으로 바꿔서 쓰고, 두 플래그 중 하나가 있을 때만 성공하면 끝 위치를, 실패하면 0을 다시 씁니다. test()는 이 과정을 그대로 거친 뒤 맞았는지만 돌려줍니다.

위치는 다른 문자열로도 이어진다

문제는 같은 문자열을 두 번 검사할 때만 생기지 않습니다. MDN은 test()가 true를 내는 동안 lastIndex가 다른 문자열을 검사해도 되돌아가지 않는다고 짚습니다. 모든 행에 숫자가 있는 배열을 공유 정규식으로 걸러 보았습니다.

const rows = ["a1", "b2", "c3", "d4"];

const shared = /\d/g;
console.log("shared /g  ", rows.filter((s) => shared.test(s)));

console.log("literal /g ", rows.filter((s) => /\d/g.test(s)));

const noFlag = /\d/;
console.log("shared, no g", rows.filter((s) => noFlag.test(s)));

공유 정규식은 a1과 c3만 남겼습니다. a1에서 맞은 뒤 lastIndex가 2가 되었고, 길이 2인 b2는 위치 2부터 찾을 글자가 없어 실패했습니다. 오류는 나지 않고 행만 줄어듭니다. 더 긴 문자열 뒤에 짧은 문자열을 검사해도 같습니다. /cat/g가 "my cat"에서 맞아 lastIndex가 6이 되자 "cat"은 false였습니다. 명세대로 lastIndex가 문자열 길이보다 크면 실패하고 0으로 돌아갑니다.

콜백 안에 직접 쓴 /\d/g는 네 행을 모두 남겼습니다. 명세상 정규식 리터럴은 평가될 때마다 새 객체를 만들므로, 콜백이 불릴 때마다 lastIndex가 0인 객체가 쓰였기 때문입니다. 그래서 잘 돌던 코드가 같은 식을 상수로 빼는 순간 틀리기 시작할 수 있습니다.

Node.js v24.21.0 실행 결과. validate.mjs: call 1 lastIndex 0에서 5, test true. call 2 lastIndex 5에서 0, test false. call 3 true, call 4 false. filter.mjs: shared /g는 a1, c3만, literal /g와 shared no g는 a1, b2, c3, d4 모두. true lastIndex 6 다음 false lastIndex 0. lastIndex = 0과 new RegExp도 네 행 모두, original lastIndex 0.
g 플래그가 붙은 공유 정규식이 test()에서 위치를 이어 가면서 결과가 갈리는 실제 출력입니다.

메서드마다 lastIndex를 다르게 다룬다

문자열 "a-a-a"와 /a/g를 두고, 호출 전 lastIndex를 2로 맞춘 뒤 메서드별로 결과와 호출 뒤 값을 찍었습니다.

  • test()와 exec()는 2부터 찾고 값을 3으로 옮겼습니다. 이 둘이 위치를 이어 가는 메서드입니다.
  • match()와 replace()는 g가 있으면 처음에 0으로 덮어써서 세 개를 모두 찾거나 바꾸고 0으로 끝났습니다.
  • matchAll()은 정규식을 복사해 쓰므로 원본은 2 그대로였습니다. 다만 그 복사본이 lastIndex도 물려받아 결과는 2와 4 두 개였습니다. 항상 처음부터 찾는 것이 아닙니다.
  • search()는 0에서 찾고 끝난 뒤 값을 2로 되돌렸습니다. split()은 새 정규식을 만들어 쓰므로 원본에 영향이 없었습니다.
  • g도 y도 없으면 test()는 lastIndex를 무시하고 값도 바꾸지 않았습니다. y는 g처럼 위치를 옮기며, 명세상 그 위치에서 바로 맞아야 합니다.

matchAll()은 g가 없으면 TypeError를 냅니다. 그래서 matchAll()에 넘기는 정규식에서는 g를 지우면 안 됩니다.

Node.js v24.21.0 methods.mjs 출력. re.test true, lastIndex after 3. re.exec index 2, after 3. text.match [a,a,a], after 0. text.matchAll [2,4], after 2. text.replace x-x-x, after 0. text.search 0, after 2. text.split 빈 문자열, -, -, 빈 문자열, after 2. no flag re.test true, after 2. y flag re.test true, after 3. no flag text.matchAll은 TypeError: String.prototype.matchAll called with a non-global RegExp argument.
호출 전 lastIndex를 2로 맞췄을 때 메서드마다 결과와 호출 뒤 값이 달라지는 실제 출력입니다.

exec() 반복문은 이 위치를 쓰라고 있는 기능이다

lastIndex가 나쁜 것은 아닙니다. while ((m = id.exec(text)) !== null) 반복문은 이 값 덕분에 다음 일치로 넘어갑니다. /id=(\d+)/g로 "id=7, id=42"를 돌리면 7과 42를 차례로 찾았습니다.

함정은 끝까지 돌지 않을 때입니다. 같은 상수로 첫 값만 돌려주는 함수를 만들어 같은 문자열을 세 번 넘기자 7, 42, null이 나왔습니다. 빈 문자열에 맞는 패턴도 조심해야 합니다. /\d*/g로 "abc"를 exec()하면 길이 0으로 맞아 lastIndex가 0에 머물렀고, 다섯 번에서 멈추게 해 둔 안전장치가 없었다면 끝나지 않았을 반복입니다. MDN도 이 경우를 무한 반복 함정으로 꼽고 대개 matchAll()로 바꿀 수 있다고 안내합니다. 같은 패턴을 matchAll()에 넣으니 위치 0, 1, 2, 3을 내고 끝났습니다.

Node.js v24.21.0 loops.mjs 출력. found 7 next search from 4. found 42 next search from 11. firstId: 7 42 null. guard stopped the loop after 5 turns, lastIndex 0. matchAll: [ 0, 1, 2, 3 ].
exec() 반복문이 lastIndex로 다음 일치를 찾는 모습과, 중간에 멈추거나 빈 일치에 걸리는 경우의 실제 출력입니다.

고치는 방법은 목적에 따라 고른다

맞는지만 알고 싶다면 g를 지웁니다. 위 noFlag처럼 공유해도 모든 행이 남았습니다. 모든 일치를 찾는다면 exec() 반복문 대신 match()나 matchAll()을 씁니다. g가 있는 match()는 전체 일치만 돌려줘서 앞의 /id=(\d+)/g로는 id=7과 id=42가 나왔습니다. 캡처 그룹이 필요하면 matchAll()을 씁니다. m[1]로 7과 42를 얻었습니다. g가 붙은 정규식을 어딘가에서 받아 와 지울 수 없다면, 호출할 때마다 그 직전에 re.lastIndex = 0을 넣거나, 호출할 때마다 new RegExp(re)로 새로 복사해 쓰면 됩니다. 복사본도 g를 물려받으므로 한 번만 복사해 공유하면 문제가 그대로입니다. 호출마다 그렇게 한 두 방법은 모두 네 행을 남겼고 복사한 쪽의 원본 lastIndex는 0 그대로였습니다.

개발 노트

g 정규식을 쓰기 전에 목적부터 고른다

lastIndex를 이어 가는 메서드는 test()와 exec()다

  • 맞는지만 알고 싶다

    const noFlag = /\d/;

    • g를 지운다
    • 공유 상수여도 네 행 모두 남았다
  • 모든 일치를 찾는다

    [...text.matchAll(re)]

    • match(), matchAll()을 쓴다
    • matchAll()은 g가 없으면 TypeError
    • 원본의 lastIndex가 0이 아니면 matchAll()은 거기서 시작한다
  • g를 지울 수 없다

    re.lastIndex = 0;

    • 매 호출 직전에 lastIndex를 0으로 둔다
    • 또는 호출마다 new RegExp(re)로 복사한다
    • 둘 다 네 행 모두 남았다

2026-11-01 Node.js v24.21.0에서 실행한 결과와 ECMA-262 초안(2026년 10월 31일판), MDN을 따른 정리다. 브라우저에서는 실행하지 않았다.

목적에 따라 g 플래그를 지우거나, 메서드를 바꾸거나, lastIndex를 직접 맞추는 선택을 나눈 그림입니다.

코드에서 g나 y가 붙은 정규식을 변수나 상수에 담아 둔 곳을 찾고, 그 변수가 test()나 exec()에 쓰이는지 보십시오. test()로 맞는지만 보고 한 문자열 안의 다음 일치를 이어서 찾는 코드가 아니라면 g는 대개 지워도 되는 글자입니다.

끝 · END OF NOTE
RSS 구독목록으로
COMMENTS BOX

이 기록에 대화를 더해 주세요.

궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.

최신순

로그인 상태 확인 중…

댓글을 불러오는 중…