자바스크립트 객체 키 순서: Object.keys에서 숫자 키가 먼저 나오는 이유

Object.keys에서 숫자처럼 생긴 키가 넣은 순서와 상관없이 앞에 오는 이유를 ECMA-262의 OrdinaryOwnPropertyKeys로 설명합니다. 배열 인덱스의 범위, 심볼, 삭제 후 다시 넣은 키, JSON.stringify의 순서도 정리했습니다.

개발JavaScript

객체에 키를 "300", "20", "a7", "1" 순서로 넣고 Object.keys로 꺼내면 넣은 순서가 아니라 ["1", "20", "300", "a7"]이 나옵니다. 배열 인덱스에 해당하는 키는 작은 수부터 앞으로 모이고, 나머지 문자열 키는 그 뒤에 넣은 순서대로 붙습니다.

const stock = {};
stock["300"] = "C";
stock["20"] = "B";
stock["a7"] = "D";
stock["1"] = "A";
console.log(Object.keys(stock)); // ["1", "20", "300", "a7"]

엔진마다 다른 동작이 아니라 JavaScript 표준인 ECMA-262가 정한 순서입니다. 이 글은 2026년 10월 20일에 확인한 ECMA-262 편집자 초안(ECMAScript 2027)을 기준으로 합니다. 코드 주석의 결과는 아래에서 설명하는 명세 알고리즘을 따라가 얻은 값이고, 이 글의 예제를 그대로 Node.js 24.13.0에서 실행해 같은 결과가 나오는 것을 확인했습니다.

명세가 정한 세 묶음

JavaScript에서 프로퍼티 키는 문자열 아니면 심볼입니다. 보통 객체가 자기 키 목록을 돌려줄 때 쓰는 단계는 OrdinaryOwnPropertyKeys이고, 키를 세 묶음으로 나눠 이어 붙입니다.

  1. 배열 인덱스인 키를 숫자 크기순으로
  2. 배열 인덱스가 아닌 문자열 키를 프로퍼티가 만들어진 순서로
  3. 심볼 키를 프로퍼티가 만들어진 순서로

첫 번째 묶음만 정렬되고 나머지 둘은 생성 순서를 따릅니다. 그래서 “삽입 순서가 유지된다”는 말은 숫자처럼 생긴 키가 섞이면 맞지 않습니다.

“숫자 키”의 정확한 범위

첫 번째 묶음에 들어가는 키를 명세는 배열 인덱스(array index)라고 부릅니다. 정수 인덱스(integer index) 가운데 그 수치가 +0부터 2³² − 2 사이에 있는 문자열입니다. 명세의 주석은 2³² − 1을 뺀 모든 32비트 부호 없는 정수에 대응하는 배열 인덱스가 있다고 적고, "-0"은 정수 인덱스도 배열 인덱스도 아니라고 따로 밝혀 둡니다.

그래서 범위 바로 바깥의 키는 숫자처럼 보여도 일반 문자열 키로 취급되어 생성 순서 자리에 남습니다.

const o = {};
o["4294967295"] = "2^32 - 1";
o["-0"] = "minus zero";
o["4294967294"] = "2^32 - 2";
o["7"] = "seven";
console.log(Object.keys(o)); // ["7", "4294967294", "4294967295", "-0"]

"7"과 "4294967294"(2³² − 2)는 배열 인덱스라서 크기순으로 앞에 옵니다. "4294967295"(2³² − 1)와 "-0"은 배열 인덱스가 아니므로 만들어진 순서대로 뒤에 붙습니다. 숫자 -0이 비교 연산에서 어떻게 다뤄지는지는 NaN === NaN은 왜 false일까에 따로 정리했습니다. 여기서의 "-0"은 그 숫자가 아니라 두 글자짜리 문자열 키입니다.

같은 순서를 공유하는 API

키 목록을 돌려주는 내장 함수들은 대부분 객체의 내부 메서드 [[OwnPropertyKeys]]를 불러 그 순서를 출발점으로 씁니다. 보통 객체에서 이 메서드는 OrdinaryOwnPropertyKeys의 결과를 그대로 돌려줍니다.

const tag = Symbol("tag");
const obj = {};
obj.b = 1;
obj[tag] = 2;
obj["2"] = 3;
obj.a = 4;
obj["1"] = 5;
Object.defineProperty(obj, "hidden", { value: 6, enumerable: false });

console.log(Object.keys(obj));                // ["1", "2", "b", "a"]
console.log(Object.getOwnPropertyNames(obj)); // ["1", "2", "b", "a", "hidden"]
console.log(Reflect.ownKeys(obj));            // ["1", "2", "b", "a", "hidden", Symbol(tag)]
console.log(JSON.stringify(obj));             // {"1":5,"2":3,"b":1,"a":4}
  • Reflect.ownKeys는 [[OwnPropertyKeys]]의 결과를 배열로 바꿔 돌려줍니다. 열거 불가능한 키와 심볼까지 모두 들어 있습니다.
  • Object.getOwnPropertyNames와 Object.getOwnPropertySymbols는 같은 목록을 앞에서부터 훑으며 문자열 키만, 또는 심볼 키만 골라냅니다. 순서는 바뀌지 않습니다.
  • Object.keys와 JSON.stringify는 EnumerableOwnProperties라는 단계를 거쳐 열거 가능한 문자열 키만 씁니다. 그래서 hidden과 심볼 키가 빠집니다.
  • Object.assign은 원본 객체의 [[OwnPropertyKeys]] 순서대로 열거 가능한 프로퍼티를 하나씩 대상 객체에 씁니다.

마지막 항목 때문에 객체를 복사해서 순서를 바꾸려는 시도는 통하지 않습니다. 새 객체에서도 배열 인덱스 키는 다시 크기순으로 앞에 모입니다.

값을 바꾸면 자리 유지, 지웠다 넣으면 맨 뒤

두 번째와 세 번째 묶음의 기준은 “프로퍼티가 만들어진 순서”입니다. 이미 있는 프로퍼티에 값을 다시 넣는 것은 새로 만드는 것이 아니므로 자리가 그대로입니다. delete로 지운 뒤 다시 넣으면 그때 새로 만들어진 것이므로 맨 뒤로 갑니다. 이 동작은 명세 문장에서 끌어낸 결과입니다.

const cfg = {};
cfg.host = "a";
cfg.port = 1;
cfg.user = "u";
cfg.host = "b";
console.log(Object.keys(cfg)); // ["host", "port", "user"]
delete cfg.host;
cfg.host = "c";
console.log(Object.keys(cfg)); // ["port", "user", "host"]

출력 순서를 직접 정하고 싶을 때

로그나 API 응답의 키 순서를 고정해야 한다면 JSON.stringify의 두 번째 인자에 키 이름 배열을 넘길 수 있습니다. 명세는 이 배열을 PropertyList로 만들고, 객체를 직렬화할 때 EnumerableOwnProperties 대신 이 목록을 그 순서대로 씁니다. 같은 이름이 두 번 들어 있으면 처음 것만 남습니다.

const row = {};
row.name = "kim";
row["2026"] = 10;
row.id = 7;
row.meta = { z: 1, id: 2 };
console.log(JSON.stringify(row));
// {"2026":10,"name":"kim","id":7,"meta":{"z":1,"id":2}}
console.log(JSON.stringify(row, ["id", "name", "2026", "meta"]));
// {"id":7,"name":"kim","2026":10,"meta":{"id":2}}

주의할 점이 하나 있습니다. PropertyList는 최상위 객체에만 적용되지 않고 직렬화되는 모든 객체에 같은 목록이 쓰입니다. 위 예에서 meta의 z가 사라진 것이 그 때문입니다. 중첩된 객체의 키도 목록에 넣어야 남습니다. JSON 쪽에서 값이 조용히 바뀌거나 빠지는 다른 경우는 JSON 오류 정리에 모아 두었습니다.

순서 자체가 데이터의 의미라면 객체 대신 배열에 담는 편이 명확합니다. 객체 키의 순서는 위 규칙을 아는 사람에게만 예측 가능하기 때문입니다.

이 글에서 다루지 않은 것

위 규칙은 보통 객체(ordinary object)의 규칙입니다. 명세는 몇몇 특수 객체에 [[OwnPropertyKeys]]를 따로 정의합니다. String 객체는 문자열의 각 위치("0", "1", …)를 먼저 내놓고, TypedArray, 모듈 네임스페이스 객체, Proxy도 각자의 정의가 있습니다. Proxy에 ownKeys 트랩이 있으면 몇 가지 검사를 통과한 트랩의 반환 목록이 그 순서 그대로 결과가 되므로, 위 순서가 보장되지 않습니다. for...in 반복의 순서는 이 글에서 확인한 범위에 들어 있지 않아 다루지 않았습니다.

참고 자료

모두 2026년 10월 20일에 확인했습니다. 절 번호는 확인 시점의 ECMA-262 편집자 초안(ECMAScript 2027) 기준입니다.

블로그로