자바스크립트 배열의 내부 동작 원리
왜 unshift는 push보다 느릴까?
같은 일을 하는 코드인데 한쪽이 1,000배 느리다면, 그 이유를 알아두는 건 성능이 이상한 코드를 만났을 때 어디부터 의심할지 아는 것과 같습니다. 배열 맨 앞에 값을 넣는 unshift와 맨 뒤에 넣는 push가 딱 그런 경우입니다. 하는 일은 똑같이 "원소 하나 추가"인데, 저는 어느 날 코드 리뷰에서 "큐를 배열로 만들면서 shift를 쓰면 느려질 수 있다"는 코멘트를 받고 처음으로 이 둘의 비용이 다르다는 걸 의식하게 됐습니다.
말로만 듣고 넘어가긴 찝찝해서 직접 측정해봤습니다. 빈 배열에 10만 개를 넣되, 한 번은 push로 뒤에서, 한 번은 unshift로 앞에서 넣어봤습니다.
1const N = 100000;
2
3const a = [];
4console.time('push');
5for (let i = 0; i < N; i++) a.push(i); // 뒤에 추가
6console.timeEnd('push');
7
8const b = [];
9console.time('unshift');
10for (let i = 0; i < N; i++) b.unshift(i); // 앞에 추가
11console.timeEnd('unshift');
12제 환경(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을 배열에 써보면, 인덱스가 문자열 키로 나옵니다.
1const arr = ['a', 'b', 'c'];
2
3const keys = [];
4for (const key in arr) keys.push(key);
5console.log(keys); // [ '0', '1', '2' ]
6
7console.log(arr['1'] === arr[1]); // true
8for...in이 돌려준 키는0이 아니라 문자열'0'입니다.- 그래서
arr['1']처럼 문자열로 접근해도arr[1]과 같은 값이 나옵니다. 내부적으로 키가 문자열이기 때문입니다.
다만 for...in은 여기서 키가 문자열임을 보여주는 용도일 뿐입니다. 실제로는 인덱스 외에 직접 추가한 속성과 프로토타입의 enumerable 속성까지 함께 돌고 순서도 보장되지 않으니, 배열을 순회할 때는 for...of나 인덱스 루프를 쓰는 게 맞습니다.
객체이다 보니, 인덱스가 아닌 평범한 속성도 붙일 수 있습니다. 다만 이런 속성은 length에 포함되지 않습니다.
1const arr = ['a', 'b', 'c'];
2arr.note = 'hello'; // 권장하진 않지만 가능은 하다
3
4console.log(arr.length); // 3 ← note는 세지 않는다
5console.log(arr.note); // hello
6length는 인덱스를 따라 움직인다
length는 우리가 직접 관리하는 값이 아니라, 원소 추가/삭제에 따라 자동으로 따라오는 속성입니다. 비어 있지 않은 배열에서는 항상 정확히 "가장 큰 인덱스 + 1"을 유지합니다. 이보다 큰 값을 갖는 경우는 length에 직접 더 큰 값을 대입했을 때뿐입니다. (잠시 뒤에 보겠지만 length에 직접 작은 값을 넣어 잘라낼 수도 있습니다.)
1const arr = [];
2console.log(arr.length); // 0
3
4arr.push('x');
5console.log(arr[0], arr.length); // x 1
6
7arr[5] = 'y'; // 인덱스 5에 직접 할당
8console.log(arr.length); // 6 ← 중간(1~4)은 비어 있는 채로 점프
9push('x')는 현재length(=0)를 키로 값을 넣고length를 1로 올립니다.arr[5]에 직접 값을 넣으면, 중간이 비어 있더라도length는 곧장 6으로 뜁니다.
반대로 length에 직접 작은 값을 대입하면, 뒤쪽 원소가 잘려 나갑니다. 이건 배열을 비우는 트릭으로도 종종 쓰입니다.
1const arr = ['a', 'b', 'c', 'd'];
2arr.length = 1;
3console.log(arr); // [ 'a' ]
4이제 끝과 앞의 차이를 메서드 동작으로 따져봅시다. 잠깐 표기 하나만 짚고 가면, 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 vs unshift 789ms)이 이 표와 정확히 맞아떨어집니다. 추가로 제거 쪽도 재봤더니 pop은 3.4ms, shift는 791ms로, 역시 앞에서 빼는 쪽이 약 230배 느렸습니다. 그래서 배열로 큐를 구현하면서 shift를 자주 호출하면, 데이터가 많아질수록 점점 느려집니다. 이럴 땐 인덱스 포인터를 따로 두거나 다른 자료구조를 쓰는 편이 낫습니다. 예를 들어 처리한 위치를 가리키는 포인터 변수를 하나 두고 shift 대신 그 인덱스만 한 칸씩 올리면, 앞 원소를 옮기지 않고도 큐를 비울 수 있습니다.
한 가지 덧붙이면, 위 수치는 제 환경에서 한 번 잰 값입니다. 벤치마크는 기기와 실행 시점(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을 공유하고, 거기에 정의된 메서드를 빌려 씁니다.
1const arr = [3, 1, 2];
2console.log(Object.getPrototypeOf(arr) === Array.prototype); // true
3바꿔 말하면, 프로토타입 체인에 Array.prototype이 없으면 이 메서드들을 쓸 수 없다는 뜻이기도 합니다. 그리고 이 사실은, 배열처럼 보이는데 배열 메서드가 안 먹는 황당한 상황의 원인이 됩니다.
유사 배열(array-like)의 함정
자바스크립트 API 중에는 배열처럼 생겼지만 진짜 배열은 아닌 값을 돌려주는 것들이 있습니다. document.querySelectorAll이 돌려주는 NodeList, 함수 안의 arguments 객체가 대표적입니다. 이들을 **유사 배열(array-like)**이라고 부릅니다.
유사 배열은 숫자 인덱스와 length는 가지고 있지만, prototype이 Array.prototype이 아닙니다. 그래서 map이나 sort 같은 메서드를 호출하면 에러가 납니다.
1const arrayLike = { 0: 'first', 1: 'second', 2: 'third', length: 3 };
2
3console.log(typeof arrayLike.map); // undefined
4// arrayLike.map(...) → TypeError: arrayLike.map is not a function
5NodeList로 받은 요소들에 .map을 쓰려다 map is not a function을 만나본 적이 있다면, 바로 이 이유 때문입니다. (참고로 NodeList는 예외적으로 자체 forEach는 가지고 있지만, map·filter·reduce 같은 변환 메서드는 없습니다.) 해결책은 두 가지입니다.
첫째, Array.from으로 진짜 배열로 변환하는 방법입니다. 인덱스와 length를 보고 새 배열을 만들어 줍니다.
1const real = Array.from(arrayLike);
2
3console.log(real.map((s) => s.toUpperCase()));
4// [ 'FIRST', 'SECOND', 'THIRD' ]
5둘째, 변환 없이 Array.prototype의 메서드를 call로 빌려 쓰는 방법입니다. call의 첫 번째 인자는 메서드 안에서 this가 될 값입니다. 즉 Array.prototype.forEach를 가져와 this 자리에 arrayLike를 꽂아 실행하는 것이죠. 메서드 입장에선 this가 배열이든 유사 배열이든, 숫자 인덱스와 length만 있으면 동작하기 때문에 가능합니다.
1const result = [];
2Array.prototype.forEach.call(arrayLike, (s) => result.push(s));
3
4console.log(result); // [ 'first', 'second', 'third' ]
5요즘은 Array.from이나 스프레드([...arrayLike])가 깔끔해서 변환 후 다루는 쪽을 더 자주 씁니다. 다만 call로 빌려 쓰는 방식은, 위에서 본 "배열 메서드는 결국 인덱스와 length만 보고 동작한다"는 사실을 그대로 보여줘서 알아둘 가치가 있습니다.
배열인지 확실히 판별하기
유사 배열이 섞여 들어올 수 있는 상황이라면, 받은 값이 진짜 배열인지 확인하고 싶어집니다. 그런데 typeof는 도움이 안 됩니다. 배열도 객체라서 둘 다 'object'가 나오거든요.
1const real = [1, 2, 3];
2const arrayLike = { 0: 'a', length: 1 };
3
4console.log(typeof real, typeof arrayLike); // object object
5이럴 때 쓰는 게 Array.isArray입니다. 이름 그대로 진짜 배열인지를 정확히 알려줍니다.
1console.log(Array.isArray(real)); // true
2console.log(Array.isArray(arrayLike)); // false
3구형 환경 호환이 필요하던 시절에는 Object.prototype.toString.call을 쓰기도 했습니다. 이 방식은 내부 타입 태그를 문자열로 돌려줍니다.
1const tag = (x) => Object.prototype.toString.call(x);
2
3console.log(tag(real)); // [object Array]
4console.log(tag(arrayLike)); // [object Object]
5지금은 Array.isArray가 표준으로 잘 동작하니 이걸 쓰면 됩니다. toString.call 방식은 "왜 이렇게도 판별할 수 있었나"를 이해하는 정도로만 알아두면 충분합니다.
delete는 length를 줄이지 않는다
마지막으로 한 가지 함정을 짚고 가겠습니다. 객체의 속성을 지울 때 쓰는 delete를 배열 원소에 쓰면, 직관과 다르게 동작합니다.
1const arr = [1, 2, 3];
2delete arr[1];
3
4console.log(arr); // [ 1, <1 empty item>, 3 ]
5console.log(arr.length); // 3 ← 줄어들지 않는다!
6출력에 보이는 empty item은 값이 undefined라는 뜻이 아니라, 키 자체가 없는 빈 칸을 가리킵니다. delete arr[1]은 키 '1'에 매핑된 값만 지웁니다. 배열도 객체라는 사실을 떠올리면 당연한 동작입니다. 키를 지웠을 뿐, 나머지 인덱스를 다시 매기지도 length를 줄이지도 않습니다. 그래서 가운데가 텅 빈 **sparse array(구멍 난 배열)**가 만들어집니다.
이 구멍은 값이 undefined인 것과도 다릅니다. 아예 키 자체가 없는 상태이기 때문입니다. in 연산자로 확인하면 차이가 보입니다.
1console.log(1 in arr); // false ← 키가 아예 없음
2console.log(2 in arr); // true
3
4// 일부 메서드는 구멍을 아예 건너뛴다
5const visited = [];
6arr.forEach((v, i) => visited.push(i));
7console.log(visited); // [ 0, 2 ] ← 인덱스 1은 방문하지 않음
8원소를 실제로 빼내면서 길이까지 줄이고 싶다면 delete가 아니라 splice를 써야 합니다. arr.splice(1, 1)은 인덱스 1의 원소를 제거하고 뒤 원소를 당겨 length까지 정리해 줍니다.
정리
- 자바스크립트 배열은 "인덱스를 키로 가지는 객체"이고,
length는 비어 있지 않은 배열에서 항상 "가장 큰 인덱스 + 1"을 자동으로 따라가는 속성입니다(직접 더 큰 값을 대입한 경우는 예외). push/pop은 끝만 건드려 O(1)이지만,unshift/shift는 모든 원소를 한 칸씩 옮겨야 해서 O(n)입니다. 실제로 10만 번 추가 시 제 환경에선 수백~천 배대(약 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 하나로 충분합니다. 하지만 성능이 이상하게 느껴지는 순간이 오면, 오늘 본 내부 동작이 가장 빠른 단서가 되어줄 겁니다. 긴 글 읽어주셔서 감사합니다. ☺️