JAVASCRIPT / FIELD NOTES

숫자 배열을 sort()했는데 왜 10이 9보다 앞에 올까

비교 함수 없이 sort()를 부르면 숫자도 문자열로 바뀌어 사전 순서로 정렬됩니다. Node.js v24.21.0 실행 결과로 비교 함수가 돌려줄 값, 원본을 바꾸는 sort()와 toSorted()의 차이, 파일 이름과 다국어 문자열을 Intl.Collator로 정렬하는 법을 정리합니다.

점수 배열 [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에서 갈리기 때문입니다.

세 단계. 1 [10, 9, 1, 100, 25].sort()를 비교 함수 없이 호출한다. 2 각 값이 문자열 "10" "9" "1" "100" "25"로 바뀌고 UTF-16 코드 단위로 비교된다. 3 "1"로 시작하는 값이 먼저 와서 결과는 [1, 10, 100, 25, 9]다. 숫자 크기순은 a - b 비교 함수로 [1, 9, 10, 25, 100].
비교 함수가 없으면 숫자도 문자열로 바뀌어 사전 순서로 정렬됩니다.

숫자 크기대로 정렬하려면 비교 함수를 넘깁니다. 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].
숫자 배열을 여러 방식으로 정렬한 실제 출력입니다. 비교 함수가 없거나 불리언을 돌려주면 숫자 크기순이 되지 않았습니다.

비교 함수는 음수, 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]이었습니다.

네 칸. Number에는 a - b가 음수, 0, 양수를 돌려줘 오름차순이 된다. a > b는 true 1, false 0만 돌려줘 음수가 없고 실행 결과 [10, 9, 1, 100, 25] 그대로였다. BigInt에 a - b를 쓰면 TypeError. a < b ? -1 : a > b ? 1 : 0처럼 부호만 돌려주면 [4n, 30n, 100n].
a > b처럼 불리언을 돌려주면 음수가 나오지 않아 정렬이 되지 않았고, BigInt에서 a - b는 TypeError로 멈췄습니다.

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를 쓰라고 권합니다.

Node.js v24.21.0 실행 결과. 기본 정렬은 Zoe, apple, banana, Éclair. localeCompare 영어 로캘은 apple, banana, Éclair, Zoe. 파일 이름 기본 정렬은 img1, img10, img2. Intl.Collator numeric은 img1, img2, img10. 나이순 toSorted 결과는 B D A C.
문자열과 파일 이름을 정렬한 실제 출력입니다. 기본 정렬과 로캘 정렬, 숫자 인식 정렬의 차이와 안정 정렬을 확인했습니다.

로캘 정렬 결과는 로캘과 ICU 데이터에 따라 달라질 수 있습니다. 위 결과는 ICU 78.3을 쓰는 Node.js v24.21.0의 결과이고, 다른 브라우저나 버전에서는 실행하지 않았습니다. localeCompare가 돌려주는 값도 -1, 1로 정해져 있지 않으니 부호만 보고 판단해야 합니다.

네 칸. sort()는 원본을 바꾸고 같은 배열을 돌려줘 b === a가 true. toSorted()는 원본 [10, 9, 1]을 두고 새 배열을 돌려주며 빈 칸은 undefined가 된다. localeCompare(b, "en")은 apple banana Éclair Zoe 순서. Intl.Collator에 numeric: true를 주면 img1, img2, img10 순서이고 큰 배열에서는 객체를 한 번만 만든다.
원본을 지켜야 하면 toSorted(), 사람에게 보여 줄 문자열과 파일 이름은 localeCompare나 Intl.Collator를 씁니다.

정렬 코드를 쓰기 전에 확인할 것

정렬이 이상하면 먼저 배열에 무엇이 들어 있는지 봅니다. 숫자인지, 숫자처럼 보이는 문자열인지, BigInt인지, undefined나 빈 칸이 섞였는지에 따라 고를 도구가 달라집니다. Number는 (a, b) => a - b, BigInt처럼 뺄셈 결과를 Number로 바꿀 수 없는 값은 부호만 돌려주는 비교 함수, 사람에게 보여 줄 문자열과 파일 이름은 Intl.Collator를 씁니다. 그리고 원본을 지켜야 하는 곳에서는 toSorted()나 복사본을 정렬합니다.

가장 짧게 기억할 한 줄은 이것입니다. 비교 함수 없는 sort()는 숫자 배열에 쓰지 않는다. 코드 리뷰에서 인자 없는 .sort()를 보면 배열에 무엇이 들어 있는지 한 번 물어보면 됩니다.

END OF NOTE목록으로
COMMENTS BOX

이 기록에 대화를 더해 주세요.

궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.

최신순

로그인 상태 확인 중…

댓글을 불러오는 중…