본문으로 건너뛰기

프론트엔드

자바스크립트 배열의 내부 동작 원리

2026. 06. 17. 수요일 오후 10시 5분

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배 차이가 난다."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을 가리킨다."배열은 인덱스를 키로 가지는 객체임을 보여주는 다이어그램. 키 '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]); // true
  • for...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); // hello

length의 동작

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 function

NodeList로 받은 요소들에 .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.prototype이 아니라서, Array.from으로 변환하거나 Array.prototype.method.call로 빌려 써야 합니다.
  • 배열 판별은 Array.isArray로 합니다. typeof는 배열을 구분하지 못합니다.
  • delete arr[i]는 값만 지우고 length는 그대로 둬서 구멍(sparse array)을 만듭니다. 길이까지 정리하려면 splice를 씁니다.

마치며

배열을 인덱스가 키인 객체로 보면 length의 자동 증감, unshift가 느린 이유, delete의 동작 방식을 한 가지 모델로 설명할 수 있습니다. 실제 엔진은 연속된 메모리에 저장하지만, 앞에서 작업하는 쪽이 데이터가 많아질수록 느려진다는 점은 같습니다.

평소에는 push 하나로 충분합니다. 성능이 이상하게 느껴지는 상황에서는 이 글에서 설명한 내부 동작이 원인을 찾는 단서가 됩니다. 긴 글 읽어주셔서 감사합니다.