작업 세 개 중 첫 번째만 끝났는데 화면에서는 세 개가 모두 완료됐다고 나온다. JavaScript로 작업별 상태 배열을 만들 때 Array(3).fill({ status: "pending" })를 썼다면, 비동기 실행 순서보다 초기화 코드를 먼저 보자. fill은 객체 세 개를 만들지 않는다. 같은 객체 하나를 배열의 세 자리에 넣는다.
배열과 객체를 다뤄 본 개발자를 위한 글이다. 항목별로 상태를 바꿔야 한다면 Array.from({ length: 3 }, () => ({ status: "pending" }))처럼 콜백 안에서 새 객체를 만든다. 로그 배열도 작업마다 달라야 한다면 같은 콜백 안에서 새로 만든다. 첫 두 코드 예제는 이어서 실행하고, 나머지 짧은 예제는 각각 별도로 실행한다. 마지막에는 한 번에 실행할 전체 코드를 제공한다. 아래 예제는 AI 도구 여러 개의 진행 상태를 표시한다고 가정한 합성 실험이며, 실제 서비스 장애 기록은 아니다.
같은 내용 세 개가 아니라 같은 객체를 가리키는 자리 세 개
const states = Array(3).fill({ status: 'pending', logs: [] });
states[0].status = 'done';
console.log(states.map(x => x.status)); // ['done', 'done', 'done']
console.log(states[0] === states[1]); // true
중괄호로 만든 객체는 fill을 호출하기 전에 한 번 생성된다. fill에 전달되는 것은 그 객체다. 첫 항목의 status를 바꾸면 나머지 항목도 같은 객체를 읽기 때문에 done을 보여 준다. 이 예제에는 Promise도 동시 실행도 없다. 작업을 직렬로 실행하도록 바꾸어도 이 참조 관계는 달라지지 않는다.
TC39의 Array.prototype.fill 절은 대상 인덱스마다 같은 value를 설정하고 원래 객체를 반환한다. 항목별 복제나 생성 함수 호출은 하지 않는다. 따라서 fill의 반환값을 새 배열이라고 생각하는 것도 피해야 한다. 기존 배열에 호출하면 그 배열 자체가 바뀐다.
객체의 속성을 바꾸기와 배열의 한 자리를 교체하기
states[0] = { status: 'retry', logs: [] };
console.log(states.map(x => x.status)); // ['retry', 'done', 'done']
console.log(states[1] === states[2]); // true
이번에는 공유 객체를 고치지 않고 0번 자리에 새 객체를 넣었다. 그래서 0번만 retry가 된다. 하지만 1번과 2번은 여전히 같은 객체를 가리킨다. 일부 항목을 나중에 교체하는 코드가 섞이면 어떤 작업은 독립적으로 움직이고 어떤 작업은 함께 바뀌어 원인을 찾기 더 어려워진다. 초기화 직후부터 항목들이 분리되어 있는지 확인하는 편이 명확하다.
작업 상태는 생성 함수로 만들고, 중첩 배열도 안에서 만든다
function makeState(id) {
return { id, status: 'pending', logs: [] };
}
const states = Array.from({ length: 3 }, (_, index) => makeState(index));
states[0].status = 'done';
states[0].logs.push('finished');
console.log(states[1].status); // pending
console.log(states[1].logs); // []
여기서는 콜백이 호출될 때마다 바깥 객체와 logs 배열을 함께 만든다. id는 예제의 배열 인덱스다. 실제 작업 목록이 이미 있다면 jobs.map(job => makeState(job.id))처럼 기존 작업 ID를 전달하면 된다. 화면에서 재정렬되는 위치와 작업 식별자를 혼동하지 않는 데에도 도움이 된다.
Array.from 절에서 이 예제의 length 기반 경로는 각 인덱스마다 mapper를 호출하고 그 결과를 저장한다. 그렇다고 Array.from 자체가 객체를 복제하는 것은 아니다. 콜백 밖의 객체를 () => template로 반환하면 다시 같은 객체를 공유한다. 중요한 것은 함수 이름이 아니라 새 객체를 만드는 위치다.
펼침 문법으로 바꿨는데 로그만 계속 섞인다면
const template = { status: 'pending', logs: [] };
const states = Array.from({ length: 3 }, () => ({ ...template }));
console.log(states[0] === states[1]); // false
console.log(states[0].logs === states[1].logs); // true
바깥 객체는 새로 생겼지만 logs 값은 같은 배열을 가리킨다. status 변경은 분리돼도 logs.push는 다른 작업의 기록에 나타날 수 있다. 이 구조에서는 () => ({ status: "pending", logs: [] })로 필요한 초기값을 직접 만들면 충분하다. 중첩 필드가 추가될 때도 작업마다 바뀌는 필드는 생성 함수 안에서 만들고, 의도적으로 공유하는 읽기 전용 자료와 구분한다.
앞선 Object.freeze의 중첩 객체 설명은 변경을 막는 문제를 다룬다. 여기서는 각 작업의 상태가 정상적으로 바뀌어야 한다. 공유 객체를 얼려 모든 수정을 막기보다, 작업마다 수정할 객체를 따로 만드는 것이 이 문제에 맞는 선택이다.
Array(3).map으로 줄이면 콜백이 실행되지 않는다
Array(3)은 undefined 값 세 개를 넣은 배열이 아니라 길이만 3인 빈 슬롯 배열이다. 일반적인, 인덱스 속성을 따로 추가하지 않은 환경에서 여기에 map을 호출하면 콜백은 한 번도 실행되지 않는다. map 절은 인덱스 속성이 존재하는지 확인한 뒤 콜백을 호출한다. Array.from({ length: 3 }, makeState)로 바로 넘기면 makeState의 첫 인수에 index가 아니라 undefined가 들어가 id가 undefined가 된다. 따라서, 위처럼 (_, index) => makeState(index)로 ID 전달도 명시한다.
숫자·불리언 같은 원시값으로 채우는 용도까지 fill을 피할 필요는 없다. Array(3).fill(false)에서 0번 자리를 true로 교체해도 다른 자리는 false다. 동일한 객체의 내부를 수정하는 경우와 배열의 값 한 칸을 교체하는 경우를 나누어 판단하면 된다.
한 파일로 초기화 방식의 차이 확인하기
다음 코드를 fill-check.mjs로 저장하고 node fill-check.mjs로 실행한다. 외부 패키지나 네트워크 요청은 없다. 상태 변화뿐 아니라 바깥 객체와 logs 배열의 동일성도 검사한다.
import assert from 'node:assert/strict';
const shared = Array(3).fill({ status: 'pending', logs: [] });
shared[0].status = 'done';
shared[0].logs.push('first');
assert.equal(shared[0], shared[1]);
assert.deepEqual(shared.map(x => x.status), ['done', 'done', 'done']);
console.log('fill:', shared.map(x => x.status).join(','));
shared[0] = { status: 'retry', logs: [] };
assert.notEqual(shared[0], shared[1]);
assert.equal(shared[1], shared[2]);
console.log('replace:', shared.map(x => x.status).join(','));
const fresh = Array.from({ length: 3 }, (_, index) => ({
id: index, status: 'pending', logs: []
}));
fresh[0].status = 'done';
fresh[0].logs.push('first');
assert.equal(new Set(fresh).size, 3);
assert.equal(new Set(fresh.map(x => x.logs)).size, 3);
assert.deepEqual(fresh.map(x => x.status), ['done', 'pending', 'pending']);
assert.deepEqual(fresh[1].logs, []);
console.log('factory:', fresh.map(x => x.status).join(','));
const template = { status: 'pending', logs: [] };
const shallow = Array.from({ length: 3 }, () => ({ ...template }));
assert.notEqual(shallow[0], shallow[1]);
shallow[0].logs.push('shared log');
assert.equal(shallow[0].logs, shallow[1].logs);
assert.deepEqual(shallow[1].logs, ['shared log']);
console.log('spread: outer objects differ; logs shared');
let calls = 0;
const holes = Array(3).map(() => { calls++; return {}; });
assert.equal(calls, 0);
assert.equal(holes.length, 3);
assert.equal(Object.keys(holes).length, 0);
console.log('sparse map: calls=' + calls);
const flags = Array(3).fill(false);
flags[0] = true;
assert.deepEqual(flags, [true, false, false]);
console.log('primitive:', flags.join(','));
console.log('PASS:', process.version);
2026년 11월 8일 KST, Node.js v24.22.1에서 실행한 출력은 다음과 같다.
fill: done,done,done
replace: retry,done,done
factory: done,pending,pending
spread: outer objects differ; logs shared
sparse map: calls=0
primitive: true,false,false
PASS: v24.22.1
초기값이 같아도 이후의 변화는 따로 남겨야 한다
작업 상태를 만드는 함수의 테스트에는 “전부 pending인가”만 넣지 말자. 한 항목을 done으로 바꾸고 로그를 하나 추가한 뒤 다른 항목이 pending과 빈 로그를 유지하는지도 검사한다. 세 작업의 시작 값이 같다는 것과 세 작업이 같은 상태 객체를 사용한다는 것은 다른 조건이다.
공통 문서나 변하지 않는 설정을 의도적으로 공유할 수는 있다. 반면 진행 상태·재시도 횟수·작업별 로그처럼 각자 달라지는 값은 항목마다 만들어야 한다. 초기화 코드에 이 구분이 드러나면, 나중에 비동기 실행을 붙여도 어떤 작업의 변화인지 추적하기 쉽다.
확인 범위: 2026년 11월 8일 KST, Node.js v24.22.1의 일반 배열·일반 객체 합성 실험. 실제 AI 서비스나 다른 런타임의 동작·성능은 측정하지 않았다. 연결한 TC39 문서는 확인 당시 ECMAScript 2027로 표시된 갱신 중인 명세다. 명세 표기와 실행한 Node 버전은 별개다.