본문으로 건너뛰기
개발 머꼬
개발 노트JavaScript
hohyeon.dev155

숫자 문자열 배열을 map(parseInt)로 변환했더니 중간에 NaN이 섞인 이유

  • #Common Pitfall
  • #Engineering Note
  • #JavaScript

문제 발생

입력 폼에서 넘어온 수량 문자열 배열을 숫자로 바꾸는 코드였습니다. 짧고 명확해 보였습니다.

const quantities = ["1", "2", "3", "4", "5", "10", "11"];
const parsed = quantities.map(parseInt);

[1, 2, 3, 4, 5, 10, 11]을 기대했는데 [1, NaN, NaN, NaN, NaN, 5, 7]이 나왔습니다. 더 무서운 건 끝의 두 개였습니다. "10"은 10이 아니라 5, "11"은 11이 아니라 7이 됐습니다. 앞쪽은 NaN이라 검증에서 걸러지기라도 했는데, 뒤쪽은 멀쩡한 숫자처럼 생겨서 그대로 재고 계산까지 흘러 들어갔습니다. 에러도 경고도 없었습니다.

원인 분석

map은 콜백에 인자를 세 개 넘깁니다. MDN의 Array.prototype.map() 문서에 따르면 콜백은 현재 요소, 인덱스, 그리고 원본 배열을 받습니다. quantities.map(parseInt)라고 쓰면 parseInt가 매번 (요소, 인덱스, 배열)로 불립니다.

parseInt의 두 번째 인자는 진법(radix)입니다. parseInt 문서에 따르면 두 번째 인자는 2부터 36 사이의 정수로 문자열을 어느 진법으로 읽을지 정합니다. map이 넘긴 세 번째 인자는 무시되지만, 두 번째 인자인 인덱스는 그대로 진법으로 쓰입니다. 그래서 실제로는 이렇게 실행됐습니다.

parseInt("1", 0);   // 1   — 진법 0은 "알아서 추론"이라 10진수
parseInt("2", 1);   // NaN — 진법 1은 2~36 범위 밖
parseInt("3", 2);   // NaN — 2진수에 '3'은 없는 숫자
parseInt("4", 3);   // NaN — 3진수에 '4'는 없는 숫자
parseInt("5", 4);   // NaN
parseInt("10", 5);  // 5   — 5진수 "10" = 1×5 + 0
parseInt("11", 6);  // 7   — 6진수 "11" = 1×6 + 1

문서는 진법이 236 범위를 벗어나면 NaN을 돌려준다고 적어 둡니다. 인덱스 1은 범위 밖이라 NaN, 인덱스 24는 그 진법에 없는 숫자라 NaN입니다. 인덱스 5, 6에서야 "10"과 "11"이 그 진법에서 유효한 숫자라 값이 나오는데, 10진수가 아니니 엉뚱한 수가 됩니다. 자릿수가 많은 문자열일수록 NaN 대신 조용히 틀린 값이 나올 확률이 높아집니다.

진법을 생략하면 10진수가 보장되지도 않습니다. 같은 문서는 진법을 주지 않거나 0이면 문자열을 보고 진법을 추론한다고 설명하면서, 10진수로 항상 기본 설정되는 게 아니니 주의하라고 적어 둡니다. 문자열이 0x나 0X로 시작하면 16진수로 읽습니다. 그래서 진법 없는 parseInt는 map에 넘기지 않더라도 위험합니다.

parseInt("0x1F");      // 31  — 16진수로 해석
parseInt("0x1F", 10);  // 0   — 10진수로 읽어 '0'까지만

해결 방안

  1. 진법을 지정하는 화살표 함수로 감쌉니다. 인자를 하나만 받게 만들면 인덱스가 끼어들 자리가 없습니다. 진법 10도 같이 박아 둡니다.
quantities.map((s) => parseInt(s, 10));  // [1, 2, 3, 4, 5, 10, 11]
  1. 정수 변환만 필요하면 Number를 씁니다. Number는 인자를 하나만 받아서 map에 그대로 넘겨도 안전합니다. MDN 문서도 같은 해법을 안내합니다. 다만 parseInt와 파싱 규칙이 다릅니다.
quantities.map(Number);  // [1, 2, 3, 4, 5, 10, 11]

Number와 parseInt는 숫자가 아닌 글자를 만났을 때 갈립니다. parseInt("12개", 10)은 유효한 데까지 읽어서 12를 주지만, Number("12개")는 전체가 숫자가 아니라서 NaN입니다. 반대로 빈 문자열은 parseInt("", 10)이 NaN인데 Number("")는 0입니다. "숫자 뒤에 단위가 붙어 올 수 있으면 parseInt, 깔끔한 숫자만 받고 섞인 입력은 걸러야 하면 Number"로 고르면 됩니다.

  1. 콜백에 함수 이름을 바로 넘기는 습관을 점검합니다. 이번 버그의 뿌리는 parseInt가 아니라 "여러 인자를 받는 함수를 map에 그대로 넘긴 것"입니다. map, forEach, filter, reduce 모두 인덱스를 넘기니 같은 함정이 반복됩니다. 인자를 하나만 받는 함수(Number, String, Boolean)는 안전하고, 여분의 인자를 무시하는 함수도 괜찮습니다. 예를 들어 parseFloat는 인자를 하나만 쓰므로 ["1.5", "2.5"].map(parseFloat)는 멀쩡히 [1.5, 2.5]가 됩니다. 애매하면 화살표 함수로 감싸는 쪽이 안전합니다.

  2. 변환 결과에 NaN이 없는지 확인합니다. 사용자 입력을 숫자로 바꾸는 자리라면 변환 후 검증을 붙입니다. NaN은 자기 자신과도 같지 않아서 ===로는 못 걸러지니 Number.isNaN을 씁니다.

const parsed = quantities.map((s) => parseInt(s, 10));
if (parsed.some(Number.isNaN)) {
  throw new Error("숫자로 바꿀 수 없는 값이 있습니다.");
}
  1. 진법은 항상 적습니다. map과 무관하게, parseInt를 쓸 때는 두 번째 인자를 생략하지 않는 걸 규칙으로 둡니다. ESLint의 radix 규칙이 진법 없는 parseInt를 잡아 줍니다. 10진수가 당연해 보여도 0x로 시작하는 입력 하나에 무너집니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.