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

빌드노트

문자열 배열에 map(parseInt)를 썼더니 NaN이 섞인다

map이 넘기는 인덱스가 parseInt의 진법 인자로 들어가면 같은 문자열도 NaN이나 엉뚱한 숫자가 된다. 실제 Node 실행으로 한 인자 래퍼, 명시 진법 10, Number를 비교하고 접미 문자와 빈 문자열의 변환 차이에 맞춰 수정한다.

["10", "10", "10", "10"].map(parseInt)의 결과는 [10, NaN, 2, 3]이다. 각 문자열은 같은 숫자인데 결과는 위치마다 달라진다. map이 넘긴 배열 인덱스가 parseInt의 두 번째 인자인 진법(radix)으로 들어가기 때문이다. 10진수 정수로 읽으려면 래퍼에서 호출 인자를 고정한다. 자기 코드의 values.map(parseInt)를 values.map(text => parseInt(text, 10))로 바꾼다.

같은 문자열을 네 번 넣어 비교한다

다음 코드는 직접 전달, 한 인자 래퍼, 진법을 10으로 고정한 래퍼, Number의 차이를 확인한다. Node.js가 설치된 환경에서 내용을 parseint-map.mjs로 저장하고 node parseint-map.mjs를 실행한다. 별도 패키지는 필요 없다. 아래는 Windows의 Node.js 22.23.2에서 새 프로세스로 실행한 출력이다. node 명령을 찾지 못하면 먼저 Node.js 실행 환경을 준비한다.

const values = ["10", "10", "10", "10"];
console.log("direct", values.map(parseInt));
console.log("unary", values.map(text => parseInt(text)));
console.log("decimal", values.map(text => parseInt(text, 10)));
console.log("number", values.map(Number));
direct [ 10, NaN, 2, 3 ]
unary [ 10, 10, 10, 10 ]
decimal [ 10, 10, 10, 10 ]
number [ 10, 10, 10, 10 ]

MDN의 map 인자 설명 (새 탭에서 열림)에 따르면 콜백은 (현재 값, 인덱스, 원본 배열)을 받는다. parseInt(string, radix)는 두 번째 인자를 진법으로 사용하고 세 번째 인자는 사용하지 않는다. 따라서 direct의 네 호출은 다음처럼 해석된다.

  • 인덱스 0: parseInt("10", 0) → 진법 추론 규칙에 따라 이 입력은 10진수로 읽혀 10이 된다.
  • 인덱스 1: parseInt("10", 1) → 허용되지 않는 진법 1이므로 NaN이다.
  • 인덱스 2: parseInt("10", 2) → 2진수 10이므로 값은 2다.
  • 인덱스 3: parseInt("10", 3) → 3진수 10이므로 값은 3이다.

ECMAScript의 parseInt 명세 (새 탭에서 열림)는 진법 2~36을 허용하며, 0이나 생략한 진법에는 별도 추론 규칙을 적용한다. 이 결과에서 NaN만 제거하면 잘못 읽은 숫자 2와 3은 남는다. 배열 원소가 하나뿐일 때 [10]으로 잘 나오는 것도 해결의 증거가 되지 않는다. 그 호출의 인덱스는 0이기 때문이다.

자기 코드에서 결과가 다르면 우선 map에 넘긴 함수와 변환 직전 문자열을 확인한다. parseInt를 직접 넘긴 경우에는 값뿐 아니라 위치가 진법을 바꾼다. 같은 고정 입력의 출력까지 다르다면 기본 내장 parseInt 대신 같은 이름의 사용자 함수를 참조하는지도 확인한다.

text => parseInt(text)도 인덱스가 흘러들어가는 문제를 막지만 진법 추론은 남는다. 따로 ["0x10"]으로 실행했을 때 한 인자 래퍼는 [16], 진법 10을 명시한 래퍼는 [0]이었다. 뒤의 결과는 10진수로 읽을 수 있는 맨 앞의 0까지만 사용한 것이다. 따라서 10진수로 읽겠다는 의도는 두 번째 래퍼가 더 분명하며, 진법 명시만으로 문자열 전체를 검증하지는 않는다.

values.map(parseInt, 10)으로 수정하면 같은 문제가 남는다. map의 두 번째 인자는 콜백의 this 값인 thisArg이며 parseInt의 진법이 아니다. 이 호출도 실제 결과는 [10, NaN, 2, 3]이었다. 진법은 반드시 래퍼 안의 parseInt(text, 10)에 넘긴다.

Number로 바꾸면 허용하는 문자열도 달라진다

첫 비교에서 map(Number)도 모두 10을 반환했다. Number 함수 명세 (새 탭에서 열림)는 인덱스를 진법으로 사용하지 않는다. 다만 parseInt와 변환 규칙이 같지는 않다. parseInt는 앞에서부터 해당 진법의 숫자로 읽을 수 있는 부분을 사용하고, Number는 문자열 전체의 숫자 문법 (새 탭에서 열림)에 따라 변환한다.

다음 코드는 접미 문자·빈 문자열·공백·소수·지수 표기의 처리 차이를 확인한다. 별도 파일 conversion-map.mjs에 저장해 node conversion-map.mjs로 실행한다. 앞 예제의 변수 없이 단독 실행할 수 있다.

const samples = ["12px", "", "  ", "3.5", "1e2"];
console.log("decimal", samples.map(text => parseInt(text, 10)));
console.log("number", samples.map(Number));
decimal [ 12, NaN, NaN, 3, 1 ]
number [ NaN, 0, 0, 3.5, 100 ]

같은 환경에서 "12px"는 parseInt로 12, Number로 NaN이 됐다. parseInt가 px를 만나기 전의 정수 부분만 읽었기 때문이다. "3.5"와 "1e2"도 parseInt에서는 각각 3과 1이고, Number에서는 3.5와 100이었다. 소수나 지수 표기를 온전히 숫자로 바꾸려는 코드에서는 이 차이가 수정안 선택에 영향을 준다.

빈 문자열과 공백 두 칸은 parseInt에서 NaN, Number에서 0이었다. 문자열 전체를 변환한다는 말이 빈 항목을 거절한다는 뜻은 아니다. 빈 항목을 허용하지 않는다면 Number를 호출하기 전에 따로 검사해야 한다. 변환 뒤의 0만으로 원래 빈 값과 문자열 "0"을 구별할 수 없다.

앞부분의 정수를 읽는 것이 요구라면 진법을 고정한 래퍼를 쓴다. 소수·지수 표기를 포함한 문자열 전체의 수치 변환이 요구라면 map(Number)를 검토한다. 두 경우 모두 빈 값 허용 여부와 정수·범위 같은 요구는 별도로 정한다. 이 비교를 모든 입력의 유효성 검사로 사용할 수는 없다.

직접 실행한 범위는 Node.js 22.23.2의 기본 내장 함수와 위의 고정 문자열 배열이다. 콜백에 값·인덱스·원본 배열 세 인자가 실제 전달되는 것도 별도 출력으로 확인했다. 브라우저별 실행과 프레임워크의 실제 데이터 변환 경로는 확인하지 않았다.

← 전체 글 보기