점수순으로 내려준 랭킹이 화면에서는 사용자 ID순으로 바뀐 이유
문제 발생
주간 랭킹 API였습니다. 서버(Python)에서 점수 높은 순으로 정렬한 뒤, 사용자 ID를 키로 하는 객체에 차례로 넣어 JSON으로 내려 줬습니다.
ranking = {}
for user_id, score in rows: # 이미 점수 내림차순
ranking[str(user_id)] = {"score": score}
return json.dumps(ranking)응답 본문을 보면 순서는 정확했습니다.
{"1042": {"score": 980}, "87": {"score": 955}, "530": {"score": 940}, "guest_7": {"score": 900}}그런데 화면에서는 1등이 955점, 2등이 940점, 3등이 980점으로 나왔습니다. 프런트엔드는 받은 순서대로 그렸을 뿐이었습니다.
const data = await res.json();
Object.values(data).map((v) => v.score); // [955, 940, 980, 900]순서를 다시 보니 87, 530, 1042, guest_7이었습니다. 점수순이 아니라 사용자 ID 오름차순이었고, 게스트 계정만 맨 뒤에 그대로 붙어 있었습니다. Python에서 같은 응답을 json.loads()로 읽으면 ['1042', '87', '530', 'guest_7']로 원래 순서가 그대로 나와서, 서버 쪽 테스트는 문제를 못 잡았습니다.
원인 분석
JavaScript 객체는 정수처럼 생긴 키를 항상 앞에, 오름차순으로 세웁니다. MDN의 for...in 문서에 따르면 지금의 ECMAScript 명세에서 순회 순서는 잘 정의돼 있고 구현마다 같습니다. 배열 인덱스가 될 수 있는 음이 아닌 정수 키가 값의 오름차순으로 먼저 오고, 나머지 문자열 키는 속성이 만들어진 순서대로 그 뒤에 온다고요. Object.keys, Object.values, Object.entries도 같은 순서를 따릅니다. "1042", "87", "530"은 문자열이지만 정수 키로 취급되니 87, 530, 1042로 정렬됐고, "guest_7"만 넣은 순서대로 남았습니다.
어떤 키가 "정수 키"인지도 생각보다 좁습니다. 직접 여러 키를 섞어 넣어 보니 이렇게 나왔습니다.
// 넣은 순서: "b", "4294967295", "4294967294", "007", "-1", "1.5", "10", "2", "a"
Object.keys(o);
// ['2', '10', '4294967294', 'b', '4294967295', '007', '-1', '1.5', 'a']"2", "10", "4294967294"만 앞으로 갔습니다. "007"처럼 0으로 시작하거나, "-1", "1.5"처럼 음수나 소수인 키는 일반 문자열로 취급돼 넣은 순서대로 남았습니다. "4294967295"도 배열 인덱스 범위를 넘어서 일반 문자열이 됐습니다. 그래서 ID 체계가 u1042 같은 형식이면 멀쩡하다가, 숫자 ID로 바뀌는 순간 터집니다.
애초에 JSON은 객체의 순서를 약속하지 않습니다. JSON 표준인 RFC 8259는 객체를 이름/값 쌍의 순서 없는(unordered) 모음이라고 정의합니다. 그리고 JSON 파싱 라이브러리마다 객체 멤버의 순서를 호출한 쪽에 보여 주는지가 다르다는 점이 관찰됐다며, 멤버 순서에 의존하지 않는 구현이라야 서로 호환된다고 적어 둡니다. Python은 순서를 보존해서 보여 주고 JavaScript는 규칙대로 다시 세우니, 둘 다 표준을 어긴 게 아닙니다. 순서를 객체에 실은 쪽이 문제였습니다.
해결 방안
- 순서가 의미 있는 데이터는 배열로 보냅니다. 배열은 순서가 곧 데이터입니다. ID는 키가 아니라 원소 안의 필드로 넣습니다.
[{"id": 1042, "score": 980}, {"id": 87, "score": 955}, {"id": 530, "score": 940}]이렇게 바꾸고 JSON.parse로 읽으니 [1042, 87, 530] 순서가 그대로 유지됐습니다. 랭킹, 정렬된 검색 결과, 사용자가 순서를 바꾼 목록처럼 "순서"가 데이터의 일부라면 처음부터 배열이 맞습니다.
-
Map으로 옮겨도 이미 늦습니다. 처음엔 "Map은 넣은 순서를 지킨다던데" 하고new Map(Object.entries(data))로 바꿔 봤습니다. MDN 설명대로Map은 항목을 넣은 순서대로 순회하는 게 맞습니다. 하지만Object.entries(data)가 이미 87, 530, 1042 순서로 꺼내 주기 때문에, 그 순서 그대로Map에 들어갈 뿐이었습니다. 직접 해 보니 키가['87', '530', '1042', 'guest_7']로 나왔습니다.JSON.parse가 객체를 만드는 순간 원래 순서는 사라지니, 순서를 지키려면 응답 형식 자체를 1번처럼 바꿔야 합니다. -
API를 당장 못 바꾼다면 클라이언트에서 기준을 명시해 정렬합니다. 객체의 순서를 믿지 말고, 화면에 필요한 순서를 직접 만듭니다.
const ranked = Object.entries(data)
.sort(([, a], [, b]) => b.score - a.score)
.map(([id, v]) => ({ id, ...v }));점수가 같은 경우를 위해 두 번째 기준(예: 먼저 달성한 시각)까지 정해 두면 새로고침할 때마다 순서가 흔들리는 일도 막을 수 있습니다.
-
중간에서 JSON을 다시 만드는 서버도 확인합니다. Node로 만든 BFF나 프록시가 응답을
JSON.parse했다가JSON.stringify로 다시 내보내면, 그 순간 순서가 바뀐 채로 직렬화됩니다. 직접 해 보니 다시 만든 문자열이{"87":...,"530":...,"1042":...,"guest_7":...}이었습니다. 원본 서버의 응답은 맞는데 클라이언트가 받은 응답은 다르다면, 중간 계층이 객체를 한 번 거쳐 가는지 봅니다. -
키 순서에 기대는 코드를 리뷰에서 걸러 냅니다. MDN의
Map문서도 일반 객체의 키는 이제 순서가 있지만 그 규칙이 복잡하니 속성 순서에 의존하지 않는 게 좋다고 권합니다. "객체를Object.values로 꺼내서 그대로 그린다"는 코드가 보이면, 그 순서가 어디서 왔는지 한 번 물어봅니다. 서버 언어가 Python이든 Java의LinkedHashMap이든, 순서를 지켜 직렬화해도 받는 쪽이 지켜 준다는 보장은 없습니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.