push와 unshift의 속도 차이
배열 맨 앞에 값을 넣는 unshift와 맨 뒤에 넣는 push는 둘 다 원소를 하나 추가하지만, 걸리는 시간이 크게 다릅니다. 배열로 만든 큐에서 shift가 느려지는 것도 같은 원인입니다.
직접 측정해 봤습니다. 빈 배열에 10만 개를 넣되, 한 번은 push로 뒤에서, 한 번은 unshift로 앞에서 넣었습니다.
const N = 100000;
const a = [];
console.time('push');
for (let i = 0; i < N; i++) a.push(i); // 뒤에 추가
console.timeEnd('push');
const b = [];
console.time('unshift');
for (let i = 0; i < N; i++) b.unshift(i); // 앞에 추가
console.timeEnd('unshift');측정 환경(Node.js v22, Apple Silicon)에서 push는 0.6ms, unshift는 789ms가 나왔습니다.
"push와 unshift의 10만 번 추가 소요 시간 막대그래프. push는 0.6ms, unshift는 789ms로 약 1,300배 차이가 난다."
같은 10만 번인데 약 1,300배 차이입니다. 이 차이가 나는 이유는 자바스크립트 배열의 내부 구조에 있습니다.
객체로서의 배열
자바스크립트 배열은 인덱스만 알면 원소를 곧바로(원소가 많아도 일정한 시간에) 읽을 수 있는 자료구조입니다. arr[0], arr[1]처럼 위치만 알면 바로 꺼낼 수 있습니다. 여기까지는 다른 언어의 배열과 비슷합니다.
다른 점은 두 가지입니다. 하나는 크기가 고정이 아니라 동적이라는 것, 다른 하나는 배열이 객체라는 것입니다.
좀 더 정확히 말하면, 배열의 원소는 "인덱스를 키(key)로 가지는 값"입니다. ['a', 'b', 'c']는 내부적으로 '0' → 'a', '1' → 'b', '2' → 'c' 형태의 키-값 쌍을 가진 객체에 가깝습니다. 그리고 원소 개수를 담는 length라는 속성이 따로 붙어 있습니다.
"배열은 인덱스를 키로 가지는 객체임을 보여주는 다이어그램. 키 '0','1','2'가 각각 값 'a','b','c'에 매핑되고, length 속성이 3을 가리킨다."
객체의 키를 순회하는 for...in을 배열에 써 보면 확인할 수 있습니다. 인덱스가 문자열 키로 나옵니다.
const arr = ['a', 'b', 'c'];
const keys = [];
for (const key in arr) keys.push(key);
console.log(keys); // [ '0', '1', '2' ]
console.log(arr['1'] === arr[1]); // truefor...in이 돌려준 키는0이 아니라 문자열'0'입니다.- 그래서
arr['1']처럼 문자열로 접근해도arr[1]과 같은 값이 나옵니다. 내부적으로 키가 문자열이기 때문입니다.
다만 for...in은 여기서 키가 문자열임을 보여주는 용도일 뿐입니다. 실제로는 인덱스 외에 직접 추가한 속성과 프로토타입의 enumerable 속성까지 함께 돕니다. 한 객체 안에서는 정수 인덱스가 오름차순으로 먼저 열거되지만, 상속 속성까지 섞이면 순서에 의존하기 어려우니, 배열을 순회할 때는 for...of나 인덱스 루프를 쓰는 게 맞습니다.
객체이다 보니, 인덱스가 아닌 평범한 속성도 붙일 수 있습니다. 다만 이런 속성은 length에 포함되지 않습니다.
const arr = ['a', 'b', 'c'];
arr.note = 'hello'; // 권장하진 않지만 가능은 하다
console.log(arr.length); // 3 ← note는 세지 않는다
console.log(arr.note); // hellolength의 동작
length는 우리가 직접 관리하는 값이 아니라, 원소 추가/삭제에 따라 자동으로 따라오는 속성입니다. 비어 있지 않은 배열에서는 항상 정확히 "가장 큰 인덱스 + 1"을 유지합니다. 이보다 큰 값을 갖는 경우는 length에 직접 더 큰 값을 대입했을 때뿐입니다. (잠시 뒤에 보겠지만 length에 직접 작은 값을 넣어 잘라낼 수도 있습니다.)
const arr = [];
console.log(arr.length); // 0
arr.push('x');
console.log(arr[0], arr.length); // x 1
arr[5] = 'y'; // 인덱스 5에 직접 할당
console.log(arr.length); // 6 ← 중간(1~4)은 비어 있는 채로 점프push('x')는 현재length(=0)를 키로 값을 넣고length를 1로 올립니다.arr[5]에 직접 값을 넣으면, 중간이 비어 있더라도length는 곧장 6으로 뜁니다.
반대로 length에 직접 작은 값을 대입하면, 뒤쪽 원소가 잘려 나갑니다. 이것은 배열을 비우는 트릭으로도 종종 쓰입니다.
const arr = ['a', 'b', 'c', 'd'];
arr.length = 1;
console.log(arr); // [ 'a' ]이제 끝과 앞의 차이를 메서드 동작으로 따져봅시다. 먼저 표기를 설명하면, O(1)은 데이터가 아무리 많아도 걸리는 시간이 일정하다는 뜻이고, O(n)은 원소 개수 n에 비례해 시간이 늘어난다는 뜻입니다.
push는 "현재 length 위치에 값을 쓰고 length를 1 늘리는" 동작이고, pop은 "length - 1 위치의 값을 꺼내고 length를 줄이는" 동작입니다. 둘 다 끝만 건드립니다. 그래서 배열이 아무리 길어도 비용이 일정한 **O(1)**입니다.
반면 unshift/shift는 맨 앞에서 작업합니다. 앞에 자리를 만들거나 앞을 빼면, 인덱스가 키이기 때문에 나머지 원소를 전부 한 칸씩 다시 매겨야 합니다. 1번 원소가 0번이 되고, 2번이 1번이 되고... 원소가 n개면 n번의 재배치가 필요하니 **O(n)**이 됩니다.
| 메서드 | 작업 위치 | 시간 복잡도 | 비고 |
|---|---|---|---|
push | 끝에 추가 | O(1) | length 위치에 쓰고 length++ |
pop | 끝에서 제거 | O(1) | length-1 꺼내고 length-- |
unshift | 앞에 추가 | O(n) | 모든 원소를 한 칸씩 뒤로 |
shift | 앞에서 제거 | O(n) | 모든 원소를 한 칸씩 앞으로 |
도입부의 측정값(push 0.6ms, unshift 789ms)이 이 표와 맞습니다. 추가로 제거 쪽도 재봤더니 pop은 3.4ms, shift는 791ms로, 역시 앞에서 빼는 쪽이 약 230배 느렸습니다. 그래서 배열로 큐를 구현하면서 shift를 자주 호출하면, 데이터가 많아질수록 점점 느려집니다. 이럴 땐 인덱스 포인터를 따로 두거나 다른 자료구조를 쓰는 편이 낫습니다. 예를 들어 처리한 위치를 가리키는 포인터 변수를 하나 두고 shift 대신 그 인덱스만 한 칸씩 올리면, 앞 원소를 옮기지 않고도 큐를 비울 수 있습니다.
위 수치는 도입부에서 밝힌 환경(Node.js v22, Apple Silicon)에서 한 번 측정한 값입니다. 벤치마크는 기기와 실행 시점(GC(메모리 정리)·JIT(실행 중 코드 최적화)의 워밍업)에 따라 달라져서, 직접 돌려보면 절대값은 물론 배율도 조금씩 다르게 나옵니다. push와 pop처럼 둘 다 O(1)인 연산끼리는 절대값 순서가 노이즈로 뒤집히기도 합니다. 그래도 "앞에서 작업하는 쪽이 데이터가 많아질수록 수백 배 이상 느려진다"는 경향은 변하지 않습니다.
지금까지 설명한 "인덱스를 문자열 키로 가지는 객체"는 ECMAScript 명세가 정의하는 추상 모델입니다. V8 같은 실제 엔진은 성능을 위해, 구멍이 없는 배열은 문자열 키 해시맵이 아니라 연속된 메모리 블록으로 저장합니다. 그래서 unshift/shift가 O(n)인 이유는 "키를 다시 매겨서"라기보다, 이 연속된 메모리를 한 칸씩 복사하기 때문입니다. 두 설명은 모순이 아니라 추상화 수준이 다를 뿐입니다. 명세 모델은 "왜 그렇게 동작하는가"를 설명하고, 실제 메모리 레이아웃은 "왜 그렇게까지 느린가"를 설명합니다.
배열 메서드와 prototype
배열의 push 같은 메서드는 prototype에서 옵니다. prototype은 그 객체가 공통으로 빌려 쓰는 메서드 모음이고, 자기한테 없는 메서드는 prototype을 타고 올라가며 찾습니다(이 연결 고리를 프로토타입 체인이라고 합니다).
push, pop, shift, unshift, 그리고 map, filter, reduce 같은 메서드를 우리가 그냥 쓸 수 있는 이유는, 배열의 prototype이 Array.prototype이기 때문입니다. 배열 리터럴로 만든 모든 배열은 이 prototype을 공유하고, 거기에 정의된 메서드를 빌려 씁니다.
const arr = [3, 1, 2];
console.log(Object.getPrototypeOf(arr) === Array.prototype); // true바꿔 말하면, 프로토타입 체인에 Array.prototype이 없으면 이 메서드들을 쓸 수 없다는 뜻이기도 합니다. 이 사실이, 배열처럼 보이는데 배열 메서드가 동작하지 않는 상황의 원인이 됩니다.
유사 배열(array-like)
자바스크립트 API 중에는 배열처럼 생겼지만 배열은 아닌 값을 돌려주는 것들이 있습니다. document.querySelectorAll이 돌려주는 NodeList, 함수 안의 arguments 객체가 대표적입니다. 이들을 **유사 배열(array-like)**이라고 부릅니다.
유사 배열은 숫자 인덱스와 length는 가지고 있지만, prototype이 Array.prototype이 아닙니다. 그래서 map이나 sort 같은 메서드를 호출하면 에러가 납니다.
const arrayLike = { 0: 'first', 1: 'second', 2: 'third', length: 3 };
console.log(typeof arrayLike.map); // undefined
// arrayLike.map(...) → TypeError: arrayLike.map is not a functionNodeList로 받은 요소들에 .map을 쓰려다 map is not a function을 만나본 적이 있다면, 바로 이 이유 때문입니다. (참고로 NodeList는 예외적으로 자체 forEach는 가지고 있지만, map·filter·reduce 같은 변환 메서드는 없습니다.) 해결책은 두 가지입니다.
첫째, Array.from으로 배열로 변환하는 방법입니다. 인덱스와 length를 보고 새 배열을 만듭니다.
const real = Array.from(arrayLike);
console.log(real.map((s) => s.toUpperCase()));
// [ 'FIRST', 'SECOND', 'THIRD' ]둘째, 변환 없이 Array.prototype의 메서드를 call로 빌려 쓰는 방법입니다. call의 첫 번째 인자는 메서드 안에서 this가 될 값입니다. 즉 Array.prototype.forEach를 가져와 this 자리에 arrayLike를 넣어 실행하는 방식입니다. 메서드 입장에선 this가 배열이든 유사 배열이든, 숫자 인덱스와 length만 있으면 동작하기 때문에 가능합니다.
const result = [];
Array.prototype.forEach.call(arrayLike, (s) => result.push(s));
console.log(result); // [ 'first', 'second', 'third' ]요즘은 Array.from이나 스프레드([...arrayLike])를 써서 변환 후 다루는 쪽을 더 자주 씁니다. call로 빌려 쓰는 방식은, 배열 메서드가 인덱스와 length만 보고 동작한다는 사실을 보여줍니다.
배열 판별하기
유사 배열이 섞여 들어올 수 있는 상황이라면, 받은 값이 배열인지 확인해야 합니다. typeof는 도움이 되지 않습니다. 배열도 객체라서 둘 다 'object'가 나옵니다.
const real = [1, 2, 3];
const arrayLike = { 0: 'a', length: 1 };
console.log(typeof real, typeof arrayLike); // object object이럴 때 쓰는 게 Array.isArray입니다. 값이 배열인지를 정확히 판별합니다.
console.log(Array.isArray(real)); // true
console.log(Array.isArray(arrayLike)); // false구형 환경 호환이 필요하던 시절에는 Object.prototype.toString.call을 쓰기도 했습니다. 이 방식은 내부 타입 태그를 문자열로 돌려줍니다.
const tag = (x) => Object.prototype.toString.call(x);
console.log(tag(real)); // [object Array]
console.log(tag(arrayLike)); // [object Object]지금은 Array.isArray가 표준으로 잘 동작하니 이걸 쓰면 됩니다. toString.call 방식은 "왜 이렇게도 판별할 수 있었나"를 이해하는 정도로만 알아두면 충분합니다.
delete와 length
객체 속성을 지울 때 쓰는 delete를 배열 원소에 쓰면, 직관과 다르게 동작합니다.
const arr = [1, 2, 3];
delete arr[1];
console.log(arr); // [ 1, <1 empty item>, 3 ]
console.log(arr.length); // 3 ← 줄어들지 않는다!출력에 보이는 empty item은 값이 undefined라는 뜻이 아니라, 키 자체가 없는 빈 칸을 가리킵니다. delete arr[1]은 키 '1'에 매핑된 값만 지웁니다. 배열도 객체라는 사실을 떠올리면 당연한 동작입니다. 키를 지웠을 뿐, 나머지 인덱스를 다시 매기지도 length를 줄이지도 않습니다. 그래서 가운데가 비어 있는 **sparse array(구멍 난 배열)**가 만들어집니다.
이 구멍은 값이 undefined인 것과도 다릅니다. 아예 키 자체가 없는 상태이기 때문입니다. in 연산자로 확인하면 차이가 보입니다.
console.log(1 in arr); // false ← 키가 아예 없음
console.log(2 in arr); // true
// 일부 메서드는 구멍을 아예 건너뛴다
const visited = [];
arr.forEach((v, i) => visited.push(i));
console.log(visited); // [ 0, 2 ] ← 인덱스 1은 방문하지 않음원소를 실제로 빼내면서 길이까지 줄이고 싶다면 delete가 아니라 splice를 써야 합니다. arr.splice(1, 1)은 인덱스 1의 원소를 제거하고 뒤 원소를 당겨 length까지 정리합니다.
정리
- 자바스크립트 배열은 "인덱스를 키로 가지는 객체"이고,
length는 비어 있지 않은 배열에서 항상 "가장 큰 인덱스 + 1"을 자동으로 따라가는 속성입니다(직접 더 큰 값을 대입한 경우는 예외). push/pop은 끝만 건드려 O(1)이지만,unshift/shift는 모든 원소를 한 칸씩 옮겨야 해서 O(n)입니다. 10만 번 추가할 때 이 글의 측정 환경(Node.js v22, Apple Silicon)에서는 약 1,300배 차이가 났습니다.- 배열 메서드를 쓸 수 있는 건 prototype이
Array.prototype이기 때문입니다.NodeList·arguments같은 유사 배열은 이 prototype이 아니라서,Array.from으로 변환하거나Array.prototype.method.call로 빌려 써야 합니다. - 배열 판별은
Array.isArray로 합니다.typeof는 배열을 구분하지 못합니다. delete arr[i]는 값만 지우고length는 그대로 둬서 구멍(sparse array)을 만듭니다. 길이까지 정리하려면splice를 씁니다.
마치며
배열을 인덱스가 키인 객체로 보면 length의 자동 증감, unshift가 느린 이유, delete의 동작 방식을 한 가지 모델로 설명할 수 있습니다. 실제 엔진은 연속된 메모리에 저장하지만, 앞에서 작업할수록 느려지는 경향은 같습니다.
평소에는 push 하나로 충분합니다. 성능이 이상하게 느껴지는 상황에서는 이 글에서 설명한 내부 동작이 원인을 찾는 단서가 됩니다. 긴 글 읽어주셔서 감사합니다.