본문으로 건너뛰기
개발 머꼬
개발 노트JavaScript
hohyeon.dev86

좌석 하나를 골랐는데 같은 번호 좌석이 전부 선택된 이유

  • #Common Pitfall
  • #Engineering Note
  • #JavaScript

문제 발생

공연 예매 화면의 좌석 선택 기능이었습니다. 행과 열 수만큼 false로 채운 2차원 배열을 만들고, 좌석을 누르면 그 칸을 true로 바꿔 색을 칠했습니다.

const rows = 3, cols = 4;
const seats = new Array(rows).fill(new Array(cols).fill(false));

seats[0][2] = true;   // A열 3번 좌석 선택

A열 3번을 눌렀는데 화면에서는 A, B, C열의 3번 좌석이 한꺼번에 칠해졌습니다. 배열을 찍어 보니 정말로 세 줄 모두 바뀌어 있었습니다.

[[false,false,true,false],
 [false,false,true,false],
 [false,false,true,false]]

급하게 "줄마다 새 배열을 만들면 되겠지" 하고 fill 대신 map으로 바꿨더니, 이번엔 화면이 아예 안 뜨고 콘솔에 에러가 찍혔습니다.

const seats = new Array(rows).map(() => new Array(cols).fill(false));
seats[0][2] = true;
// TypeError: Cannot set properties of undefined (setting '2')

원인 분석

fill은 같은 값을 모든 칸에 넣습니다. 객체라면 같은 객체를요. MDN의 Array.prototype.fill() 문서는 value 인자를 설명하면서, 배열의 모든 원소가 정확히 이 값이 되므로 값이 객체라면 배열의 각 칸이 그 객체를 참조하게 된다고 적어 둡니다. new Array(cols).fill(false)는 인자로 넘어가기 전에 한 번만 평가되고, 그렇게 만들어진 배열 하나가 세 칸에 똑같이 들어갑니다. 줄이 세 개처럼 보이지만 실제로는 같은 배열 하나를 세 번 가리키고 있는 겁니다. seats[0] === seats[2]를 찍어 보면 true가 나옵니다. 그러니 seats[0][2]를 바꾸는 건 세 줄 모두의 3번 칸을 바꾸는 것과 같습니다.

배열만의 문제도 아닙니다. 카운터 객체를 new Array(3).fill({ count: 0 })으로 만들고 첫 번째 것만 올려도, 세 칸 모두 {"count":1}이 됩니다.

new Array(n)은 빈 칸 n개짜리 배열이고, map은 빈 칸을 건너뜁니다. MDN의 Array() 생성자 문서에 따르면 정수 하나만 넘기면 length가 그 수인 배열을 돌려주는데, 이건 실제 undefined 값이 든 칸이 아니라 빈 칸(empty slot) n개라는 뜻이라고 주석을 달아 둡니다. 그리고 Array 문서의 "배열 메서드와 빈 칸" 항목은 forEach 같은 반복 메서드가 빈 칸을 아예 방문하지 않는다고 설명합니다. map도 빈 칸을 특별하게 다루는 메서드 목록에 들어 있습니다. 콜백이 한 번도 불리지 않으니 결과도 빈 칸 3개짜리 배열이고, seats[0]은 undefined입니다. Node 22에서 찍어 보면 [ <3 empty items> ]로 나옵니다.

해결 방안

  1. Array.from({ length })에 만드는 함수를 넘깁니다. Array.from의 두 번째 인자인 mapFn은 문서대로 배열에 들어갈 모든 값에 대해 호출되고, 그 반환값이 대신 들어갑니다. 함수가 칸마다 새로 불리니 줄마다 다른 배열이 만들어집니다. { length: rows }처럼 length만 있는 객체도 유사 배열로 받아 줍니다.
const seats = Array.from({ length: rows }, () => new Array(cols).fill(false));
seats[0][2] = true;
// [[false,false,true,false],[false,false,false,false],[false,false,false,false]]

안쪽의 new Array(cols).fill(false)는 그대로 둬도 됩니다. false는 원시값이라 같은 값이 여러 칸에 들어가도 서로 영향이 없습니다. 문제는 fill에 객체나 배열을 넘길 때만 생깁니다.

  1. map을 쓰고 싶다면 빈 칸을 먼저 채웁니다. new Array(rows).fill().map(...)이나 [...Array(rows)].map(...)은 둘 다 됩니다. 앞의 것은 fill()이 빈 칸을 undefined로 채우고, 뒤의 것은 전개 구문이 빈 칸을 undefined로 꺼내기 때문입니다. 실제로 두 방식 모두 줄마다 다른 배열이 만들어졌습니다. 다만 "왜 fill()을 한 번 거쳐야 하는지"를 코드만 보고 알기 어려워서, 저는 의도가 바로 읽히는 1번을 씁니다.

  2. 빈 칸을 다루는 메서드가 제각각이라는 걸 기억합니다. MDN 설명대로 forEach, map, filter 같은 오래된 메서드는 빈 칸을 건너뛰고, fill, find, includes, keys 같은 새 메서드는 빈 칸을 undefined처럼 다룹니다. 직접 해 보니 new Array(3)에 forEach를 돌리면 콜백이 0번 불렸고, for...of로 돌리면 칸 수만큼 undefined가 나왔습니다. JSON.stringify는 빈 칸을 null로 바꿔 [null,null,null]을 만듭니다. 같은 배열이 어디로 넘어가느냐에 따라 "비어 있음"의 뜻이 달라지니, new Array(n)을 만들었다면 바로 채워서 빈 칸이 남지 않게 하는 편이 안전합니다.

  3. 이미 꼬인 배열은 structuredClone으로도 안 풀립니다. 같은 배열을 공유하는 seats를 structuredClone(seats)로 복사한 뒤 복사본의 한 칸을 바꿔 봤는데, 복사본 안에서도 세 줄이 여전히 같은 배열이었습니다. 복사본에서 t[0] === t[2]가 true로 나왔습니다. 원본에서 같은 배열을 가리키던 관계가 복사본에서도 그대로 유지된 겁니다. 공유된 참조는 복사로 고칠 게 아니라, 만드는 시점에 Array.from으로 따로따로 만들어야 합니다.

  4. 2차원 배열을 만드는 코드는 한 곳에 두고 테스트합니다. 이번 버그는 화면에서 좌석을 하나 눌러 보기 전까지 아무도 몰랐습니다. 처음 만든 직후의 배열은 전부 false라서 공유되든 아니든 똑같이 보이기 때문입니다. 격자를 만드는 함수를 하나로 모으고, "한 칸을 바꾸면 그 칸만 바뀐다"는 테스트를 붙여 둡니다.

const grid = (r, c, v) => Array.from({ length: r }, () => Array.from({ length: c }, () => v));

const g = grid(3, 4, false);
g[0][2] = true;
console.assert(g[1][2] === false, "다른 줄이 같이 바뀌면 안 된다");

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.