입력값 검사 함수에 같은 값을 넣었는데 한 번은 통과하고 한 번은 실패하거나, 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인 객체가 쓰였기 때문입니다. 그래서 잘 돌던 코드가 같은 식을 상수로 빼는 순간 틀리기 시작할 수 있습니다.

메서드마다 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.](/images/ko/regex-global-test-lastindex-run-02.png)
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 ].](/images/ko/regex-global-test-lastindex-run-03.png)
고치는 방법은 목적에 따라 고른다
맞는지만 알고 싶다면 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나 y가 붙은 정규식을 변수나 상수에 담아 둔 곳을 찾고, 그 변수가 test()나 exec()에 쓰이는지 보십시오. test()로 맞는지만 보고 한 문자열 안의 다음 일치를 이어서 찾는 코드가 아니라면 g는 대개 지워도 되는 글자입니다.
이 기록에 대화를 더해 주세요.
궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.
로그인 상태 확인 중…
댓글을 불러오는 중…