점수 배열 [10, 9, 1, 100, 25]를 sort()로 정렬했더니 [1, 10, 100, 25, 9]가 나옵니다. 오류도 경고도 없습니다. 이 글을 읽고 나면 기본 sort()가 무엇을 비교하는지, 비교 함수가 어떤 값을 돌려줘야 하는지, 원래 배열이 언제 바뀌는지, 파일 이름이나 다국어 문자열은 무엇으로 정렬해야 하는지를 직접 고를 수 있습니다. 예제는 모두 2026-10-02에 macOS 27.0.1의 Node.js v24.21.0(V8 13.6, ICU 78.3)에서 node -p로 실행한 결과입니다.
기본 sort는 숫자가 아니라 문자열을 비교한다
비교 함수 없이 sort()를 부르면 JavaScript는 각 원소를 문자열로 바꾼 다음, 그 문자열을 UTF-16 코드 단위 순서로 비교합니다. ECMAScript 명세의 CompareArrayElements 단계가 그렇게 정해져 있습니다. 비교 함수가 없으면 두 값에 ToString을 적용하고 문자열끼리 크기를 따집니다.
그래서 10은 "10", 9는 "9"가 됩니다. 문자열은 첫 글자부터 비교하므로 "1"로 시작하는 "1", "10", "100"이 앞에 모이고, "2"로 시작하는 "25", 마지막으로 "9"가 옵니다. 사전에서 단어를 찾는 순서와 같습니다. 같은 이유로 [-1, -2, 3].sort()는 [-1, -2, 3]을 돌려줍니다. "-1"과 "-2"는 둘째 글자 1과 2에서 갈리기 때문입니다.
숫자 크기대로 정렬하려면 비교 함수를 넘깁니다. sort((a, b) => a - b)를 쓰면 같은 배열이 [1, 9, 10, 25, 100]이 됩니다. 내림차순은 b - a입니다.
undefined와 빈 칸은 따로 다룹니다. 명세는 undefined를 비교 함수에 넘기지 않고 항상 뒤로 보내며, 빈 칸(존재하지 않는 원소)은 그보다 더 뒤에 둡니다. 실행해 보면 [3, undefined, 1, , 2].sort()는 [1, 2, 3, undefined, <1 empty item>]입니다.
![Node.js v24.21.0 실행 결과. [10, 9, 1, 100, 25].sort()는 [1, 10, 100, 25, 9]. a - b 비교 함수는 [1, 9, 10, 25, 100]. a > b 비교 함수는 [10, 9, 1, 100, 25] 그대로. [3, undefined, 1, 빈 칸, 2].sort()는 [1, 2, 3, undefined, 빈 칸 1개]. sort 뒤 원본 a는 [1, 9, 10]이고 b === a는 true. toSorted 뒤 원본은 [10, 9, 1], 새 배열은 [1, 9, 10].](/images/ko/js-array-sort-numbers-as-strings-run-01.png)
비교 함수는 음수, 0, 양수를 돌려줘야 한다
비교 함수 (a, b)는 a가 앞에 와야 하면 음수, 뒤에 와야 하면 양수, 순서가 상관없으면 0을 돌려줘야 합니다. 이 약속이 깨지면 명세는 결과 순서를 “구현에 따라 다르다(implementation-defined)”고 봅니다. 엔진마다, 배열 길이마다 결과가 달라질 수 있다는 뜻입니다.
자주 보는 실수가 sort((a, b) => a > b)입니다. 이 함수는 true 아니면 false를 돌려주고, 명세는 이 값을 숫자로 바꿉니다. true는 1, false는 0입니다. 음수가 한 번도 나오지 않으니 “a가 앞”이라는 신호를 보낼 방법이 없습니다. 이번 실행에서는 배열이 [10, 9, 1, 100, 25] 그대로 돌아왔습니다. 다른 엔진이나 다른 길이에서 우연히 정렬된 것처럼 보일 수 있다는 점이 오히려 위험합니다.
a - b도 만능은 아닙니다. BigInt 배열 [30n, 4n, 100n]에 (a, b) => a - b를 쓰면 뺄셈 결과가 BigInt가 되고, 명세가 그 값을 Number로 바꾸려다 TypeError: Cannot convert a BigInt value to a number로 멈춥니다. 이때는 (a, b) => (a < b ? -1 : a > b ? 1 : 0)처럼 부호만 돌려주는 형태가 안전합니다. 실행 결과는 [4n, 30n, 100n]이었습니다.
sort는 원래 배열을 바꾸고 같은 배열을 돌려준다
const sorted = list.sort(...)라고 쓰면 새 배열이 생기는 것처럼 보이지만, sort()는 제자리에서 정렬하고 같은 배열을 돌려줍니다. 실행해 보면 a.sort(...) 뒤의 a는 이미 [1, 9, 10]이고 b === a는 true입니다. React 상태나 함수 인자로 받은 배열을 이렇게 정렬하면 다른 곳에서 쓰던 순서까지 바뀝니다.
원본을 그대로 두려면 toSorted()를 씁니다. 같은 비교 규칙으로 정렬한 새 배열을 돌려주고, 원래 배열은 손대지 않습니다. 실행 결과 a는 [10, 9, 1] 그대로였고 새 배열만 [1, 9, 10]이었습니다. MDN은 toSorted()가 2023년 7월부터 주요 브라우저에서 쓸 수 있다고 표시합니다. 더 오래된 환경을 지원해야 하면 [...list].sort(...)로 먼저 복사합니다. 빈 칸은 두 메서드가 다르게 다룹니다. sort()는 빈 칸을 빈 칸으로 남기지만 toSorted()는 undefined로 채웁니다.
정렬의 안정성도 알아 두면 좋습니다. ECMAScript 2019부터 sort()는 안정 정렬이어야 합니다. 비교 결과가 0인 원소끼리는 원래 순서를 지킵니다. 나이 30, 25, 30, 25인 A, B, C, D를 나이순으로 정렬하면 B D A C가 됩니다. 같은 나이 안에서 B가 D보다, A가 C보다 앞이던 순서가 그대로입니다. 그래서 “이름순으로 정렬한 뒤 부서순으로 정렬”하면 부서 안에서 이름순이 유지됩니다.
문자열과 파일 이름은 Intl.Collator로
문자열은 기본 sort()로 정렬해도 될 것 같지만, 코드 단위 순서는 사람이 기대하는 순서와 다릅니다. ["banana", "Zoe", "apple", "Éclair"].sort()는 ["Zoe", "apple", "banana", "Éclair"]입니다. Z의 코드는 90, a는 97, É는 201이라 대문자가 소문자보다 앞에, 악센트가 붙은 글자는 맨 뒤로 갑니다. localeCompare(b, "en")로 비교하면 ["apple", "banana", "Éclair", "Zoe"]가 됩니다.
숫자가 섞인 파일 이름도 같은 문제를 겪습니다. img10.png, img2.png, img1.png를 기본 정렬하면 img10.png가 img2.png보다 앞에 옵니다. new Intl.Collator("en", { numeric: true })의 compare를 넘기면 문자열 속 숫자를 숫자로 보고 img1, img2, img10 순서가 됩니다. MDN은 큰 배열을 정렬할 때는 localeCompare를 매번 부르기보다 Intl.Collator 객체를 한 번 만들어 compare를 쓰라고 권합니다.

로캘 정렬 결과는 로캘과 ICU 데이터에 따라 달라질 수 있습니다. 위 결과는 ICU 78.3을 쓰는 Node.js v24.21.0의 결과이고, 다른 브라우저나 버전에서는 실행하지 않았습니다. localeCompare가 돌려주는 값도 -1, 1로 정해져 있지 않으니 부호만 보고 판단해야 합니다.
정렬 코드를 쓰기 전에 확인할 것
정렬이 이상하면 먼저 배열에 무엇이 들어 있는지 봅니다. 숫자인지, 숫자처럼 보이는 문자열인지, BigInt인지, undefined나 빈 칸이 섞였는지에 따라 고를 도구가 달라집니다. Number는 (a, b) => a - b, BigInt처럼 뺄셈 결과를 Number로 바꿀 수 없는 값은 부호만 돌려주는 비교 함수, 사람에게 보여 줄 문자열과 파일 이름은 Intl.Collator를 씁니다. 그리고 원본을 지켜야 하는 곳에서는 toSorted()나 복사본을 정렬합니다.
가장 짧게 기억할 한 줄은 이것입니다. 비교 함수 없는 sort()는 숫자 배열에 쓰지 않는다. 코드 리뷰에서 인자 없는 .sort()를 보면 배열에 무엇이 들어 있는지 한 번 물어보면 됩니다.
이 기록에 대화를 더해 주세요.
궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.
로그인 상태 확인 중…
댓글을 불러오는 중…