JavaScript sort()가 숫자를 이상하게 정렬하는 이유: 기본 문자열 비교와 비교 함수 규칙
[10, 9, 1, 100].sort()는 [1, 10, 100, 9]를 돌려줍니다. 기본 정렬의 문자열 비교, 비교 함수의 반환값과 일관성 조건, 원본 변경, undefined와 빈 칸, toSorted()를 ECMA-262 명세로 정리했습니다.
[10, 9, 1, 100].sort()의 결과는 [1, 10, 100, 9]입니다. 비교 함수를 넘기지 않으면 Array.prototype.sort()가 요소를 문자열로 바꿔 비교하기 때문입니다. 이 글은 2026년 10월 11일에 확인한 ECMA-262 편집자 초안(ECMAScript 2027)을 기준으로 합니다.
기본 정렬은 문자열 비교
비교 함수가 없으면 명세의 CompareArrayElements는 두 값을 ToString으로 바꾼 뒤 IsLessThan으로 비교합니다. 문자열 비교는 UTF-16 코드 유닛 값을 앞에서부터 하나씩 견주고, 한쪽이 다른 쪽의 접두사이면 짧은 쪽을 앞에 둡니다. "1"은 "10"의 접두사이고, "100"과 "9"는 첫 글자에서 1(U+0031)이 9(U+0039)보다 작으므로 위 결과가 나옵니다.
[10, 9, 1, 100].sort(); // [1, 10, 100, 9]
[-1, -2, -10].sort(); // [-1, -10, -2]
["b", "a", "B", "A"].sort(); // ["A", "B", "a", "b"]
["e", "é", "f"].sort(); // ["e", "f", "é"]
대문자(U+0041부터)가 소문자(U+0061부터)보다 앞에 오고, é(U+00E9)는 f(U+0066) 뒤로 갑니다. 사전 순서가 아니라 코드 유닛 순서입니다. 코드 유닛은 글자 수가 도구마다 다른 이유에서 다뤘습니다.
비교 함수의 반환값
숫자는 비교 함수를 넘겨 정렬합니다.
[10, 9, 1, 100].sort((a, b) => a - b); // [1, 9, 10, 100]
[10, 9, 1, 100].sort((a, b) => b - a); // [100, 10, 9, 1]
명세는 comparator(x, y)가 x가 y보다 작으면 음수, 크면 양수, 그 밖에는 0을 돌려줘야 한다고 정합니다. 반환값은 ToNumber로 바뀐 뒤 부호만 쓰이므로 -1과 1 대신 -37이나 5를 돌려줘도 됩니다. NaN이 나오면 0으로 취급합니다.
문자열 키는 빼기로 비교할 수 없으니 부호를 직접 돌려줍니다.
users.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
BigInt 배열에 a - b 비교 함수를 쓰면 TypeError가 납니다. BigInt끼리 빼는 것 자체는 문제없지만, 그러면 비교 함수가 BigInt를 돌려주고, CompareArrayElements가 그 반환값에 ToNumber를 적용합니다. ToNumber는 BigInt를 받으면 TypeError를 던집니다.
[3n, 1n, 2n].sort((a, b) => a - b); // TypeError
[3n, 1n, 2n].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); // [1n, 2n, 3n]
비교 함수 자리에 함수가 아닌 값을 넘겨도 TypeError입니다. [1, 2].sort(null)이 그 예이고, undefined만 비교 함수가 없는 것으로 처리됩니다.
일관된 비교 함수
명세는 비교 함수가 ’일관된 비교자(consistent comparator)’이기를 요구합니다. 같은 두 값에는 늘 같은 숫자(NaN 제외)를 돌려주고, 정렬 중인 객체나 그 프로토타입 체인에 있는 어떤 객체도 수정하지 않아야 합니다. 또 같다는 판정은 반사성, 대칭성, 추이성을, 작다·크다는 판정은 추이성을 만족해야 합니다. 이 조건이 깨지면 정렬 순서는 구현 정의(implementation-defined)입니다. 엔진이나 입력에 따라 결과가 달라도 명세 위반이 아니라는 뜻입니다.
자주 보이는 실수가 불리언을 돌려주는 비교 함수입니다.
arr.sort((a, b) => a > b); // 잘못된 비교 함수
true는 1, false는 0이 됩니다. a가 b보다 작을 때도 0, 곧 ’같다’가 나오므로 (1, 2)는 같다, (2, 1)은 크다로 판정되어 대칭성이 깨집니다. 결과는 구현 정의이고, 어떤 입력에서 우연히 맞게 나와도 보장되지 않습니다.
원본이 바뀐다
sort()는 배열을 제자리에서 정렬하고 같은 객체를 돌려줍니다.
const a = [3, 1, 2];
const b = a.sort((x, y) => x - y);
b === a; // true
a; // [1, 2, 3]
원본을 그대로 두려면 toSorted()를 씁니다. 비교 함수 규칙과 비교 방식은 sort()와 같고, 결과는 새 배열로 돌려줍니다.
const a = [3, 1, 2];
const b = a.toSorted((x, y) => x - y);
a; // [3, 1, 2]
b; // [1, 2, 3]
undefined와 빈 칸
undefined는 비교 함수를 거치지 않고 항상 뒤로 갑니다. CompareArrayElements가 비교 함수를 부르기 전에 undefined를 먼저 처리하므로, 비교 함수가 undefined를 인수로 받는 일은 없습니다.
빈 칸(hole)은 length보다 작지만 속성이 없는 인덱스입니다. sort()는 빈 칸을 정렬 대상에서 빼고, 정렬한 값을 앞에서부터 채운 다음 남은 인덱스를 지워 빈 칸 수를 유지합니다. 그래서 빈 칸은 undefined보다도 뒤에 남습니다.
const a = [3, undefined, , 1];
a.sort((x, y) => x - y); // [1, 3, undefined, 빈 칸]
3 in a; // false
toSorted()는 빈 칸을 undefined로 읽으므로 결과에 빈 칸이 없습니다.
[3, undefined, , 1].toSorted((x, y) => x - y); // [1, 3, undefined, undefined]
위 예시는 프로토타입에서 상속한 인덱스 속성이 없는 일반 배열을 전제로 합니다. sort()는 각 인덱스를 HasProperty로 확인하는데, HasProperty는 프로토타입 체인까지 찾습니다. 그래서 예를 들어 Array.prototype[2]가 있다면 인덱스 2는 빈 칸으로 건너뛰지 않고 그 값을 읽어 정렬합니다.
참고
- ECMA-262 편집자 초안, 23.1.3.30 Array.prototype.sort, 23.1.3.30.1 SortIndexedProperties, 23.1.3.30.2 CompareArrayElements, 23.1.3.34 Array.prototype.toSorted
- ECMA-262 편집자 초안, 7.1.4 ToNumber, 7.2.12 IsLessThan
- ECMA-262 편집자 초안, 10.4.2 Array Exotic Objects
절 번호는 확인 시점의 편집자 초안 기준입니다. 링크와 내용은 2026년 10월 11일에 확인했습니다.