[JavaScript] 자바스크립트 Array.sort()는 제자리 정렬이며 문자열 기준입니다.
🖐️ 서론
자바스크립트, 타입스크립트로 개발자들은 Array.prototype의 내장 메서드를 자주 활용합니다. map, filter, reduce, flat 등은 예상했던 동작 그대로 기능합니다. 하지만 sort 메서드는 제 예상과 달리 조금 특이한 동작을 했습니다.
const list = [5,23,3,11,2] const sortedList = list.sort(); console.log(sortedList); // [11, 2, 23, 3, 5] console.log(list); //[11, 2, 23, 3, 5]
- 오름차순으로 정렬되지 않은 것처럼 보입니다. (이는 sort의 기본 동작이 문자열 기준이기 때문에 그렇습니다.)
- 원본 배열에 영향을 미쳤습니다.
익숙한 메서드일수록 정확한 스펙을 알고 사용해야 합니다. 이번 글에서는 array.prototype.sort를 탐구해 보았습니다.
🔧 자바스크립트 정렬의 특성
1. 제자리 정렬
제자리 정렬이란, 원래의 데이터가 저장된 공간 내에서 정렬을 수행하는 방식을 의미합니다. 자바스크립트에서는 원본 배열 자체가 직접 변경된다고 이해할 수 있습니다.
- 그러면 왜 sort 메서드는 제자리 정렬로 구현되었을까요?
map, filter, reduce는 ES5, flat은 ES6에서 도입된 반면, sort 메서드는 그보다 훨씬 이전, 자바스크립트 초창기부터 존재했습니다. 즉, 당시의 상황을 생각해 보면 메모리 효율적인 기능이 더 중요했다고 추측해 볼 수 있습니다. 함수형 프로그래밍에서는 불변성을 중시 여기지만 sort 메서드는 이러한 패러다임을 위해 도입된 메서드가 아니었던 것 같습니다. 실제로 초창기 프로그래밍 언어인 C언어 등을 참고해 보니 javascript와 마찬가지로 제자리 정렬로 구현되어 있습니다.
2. 문자열 정렬
- sort 메서드는 매개변수로 콜백 함수를 받을 수 있습니다. (optional) 자바스크립트의 슈퍼셋인 타입스크립트에서 그 시그니처를 쉽게 확인할 수 있었습니다.
// lib.es5.d sort(compareFn?: (a: T, b: T) => number): this;
- compareFn이 없을 때의 기본 동작이 문자열 변환 후 정렬입니다.
if compareFn is not supplied, all non-undefined array elements are sorted by converting them to strings and comparing strings in UTF-16 code units order.
3. 안정 정렬
안정 정렬이란, 정렬 이후에도 값이 같은 요소들의 원래 순서가 보장되는 것을 의미합니다.
- es2019 이후부터, ECMAScript 명세에 포함되었습니다.
Other updates included requiring that Array.prototype.sort be a stable sort,
4. compareFn이 동작하는 기준
- compareFn(a,b)가 양수라면 -> sort a after b, e.g., [b, a] === b가 앞에 나와야 합니다.
- compareFn(a,b)가 음수라면 -> sort a before b, e.g., [a, b] === a가 앞에 나와야 합니다.
- compareFn(a,b)가 0이라면 -> keep original order of a and b === 요소들의 기존 순서가 그대로 유지됩니다.
const compareNum = (a: number, b: number): number => { console.log(`a: ${a}, b:${b}`); return a - b; } const sampleArray = [1,2]; sampleArray.sort(compareNum) // 콘솔 출력: a: 2, b: 1 → 반환값: 양수 → b가 앞에, a가 뒤로 정렬됨 → [1, 2]
첫 번째 비교에서는 배열의 1번 인덱스 값(2)이 a로, 0번 인덱스 값(1)이 b로 들어왔습니다. compareFn은 a - b(= 2 - 1)를 반환하여 양수가 되었고, 따라서 b(=1)가 앞에 오게 되어 정렬 결과는 [1, 2]가 됩니다.
compareFn에 전달되는 a와 b는 배열의 실제 요소 순서와 반드시 일치하지 않아도 됩니다. 결국 정렬 순서는 compareFn의 반환값으로 결정됩니다. compareFn(1, 2)로 비교되었다면 이 결과가 음수이기에 a(=1)이 앞에 와서 똑같이 [1,2]가 반환되었을 것입니다.
즉, 배열의 어느 요소가 compareFn에 들어가면 순서와 관계없이 compareFn(a, b)의 결과만을 가지고 순서가 결정됩니다.
5. Tim sort
정렬 알고리즘은 V8 기준으로 팀 소트(Tim sort) 알고리즘으로 구현되어 있습니다. Tim sort는 삽입 정렬과 병합 정렬을 적절히 섞은 최적화된 정렬 알고리즘입니다. 실제 데이터의 특성에 따라 빠른 성능을 보여줄 수 있어서 널리 쓰이는 알고리즘입니다.
숫자를 정렬하기 위해서는
const compareNum = (a: number, b: number): number => { console.log(`a: ${a}, b:${b}`); return a - b; } const list = [5,23,3,11,2] const sortedList = [...list].sort(compareNum); // 제자리 정렬로 인한 원본 배열 변경 방지 console.log(sortedList); // [2, 3, 5, 11, 23] console.log(list); //[5, 23, 3, 11, 2]
유의사항
제자리 정렬이라는 특성은 메모리 효율적입니다. 현대 프로그래밍에서는 이 특성을 조심할 필요가 있습니다.
const latestInfluencerReport = influencerReportData.Content.Reports.sort((a, b) => { return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); })[0];
Report 인터페이스에는 월간 지표들이 표시되어 있는데, 누적값을 표기할 때 가장 최신 날짜의 누적 데이터를 사용해야 합니다. 이를 위해 정렬 후 첫 요소를 추출하는 방식으로 latestInfluencer를 계산하면 될 것 같습니다. 그러나 이 코드는 잠재적인 버그를 유발할 수 있습니다.
influencerReportData.Content.Reports 배열은 월간 지표 외에 다른 곳에서도 사용될 수 있습니다. 제자리 정렬이라는 특성 때문에 latestInfluencerReport만을 위해 순서를 바꿔버리면 결국 원본 배열이 오염됩니다.
const latestInfluencerReport = [...influencerReportData.Content.Reports].sort((a, b) => { return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); })[0];
제자리 정렬이라는 특성을 기억하고 있다면 간단하게 해소할 수 있습니다.
🧭 끝으로
익숙하다고 생각했던 sort 메서드가 이렇게 많은 특성을 가지고 있다는 걸 정리하면서 새삼 느꼈습니다. 특히 제자리 정렬이라는 특성 때문에 실무에서 예상치 못한 버그를 만들어낸 경험이 있어서 더욱 와닿았습니다.
어떤 기능이든 "당연하다"고 여기지 않고, 동작 방식을 명확히 이해하려는 태도가 중요하다고 생각합니다. 매일 사용하는 메서드일수록 한 번쯤은 공식 문서를 들여다보고, 내부 동작을 이해해 보려는 노력이 필요하다고 생각합니다. 그래야 예상치 못한 상황에서도 원인을 빠르게 파악하고 대응할 수 있으니까요.
앞으로도 "어? 이상하네?"라는 순간들을 그냥 넘기지 말고 깊이 파보는 습관을 가져야겠습니다.
댓글 목록