카드를 옮길 때마다 아이폰에서 판이 번쩍였다
솔리테어를 아이폰에서 하다가 카드를 끌어 놓으면, 카드가 제자리로 날아가는 순간 판 전체가 번쩍였다. 판이 아주 잠깐 비었다가 다시 나타나는 모양이었다. 같은 날 스파이더에서는 더미를 눌러 카드를 나눌 때, 프리셀에서는 카드를 옮길 때 같은 일이 생겼다. 데스크톱 크롬에서는 한 번도 보이지 않았다.
이 카드 게임들은 캔버스가 아니라 HTML 요소로 그린다. 카드 한 장이 <div> 하나이고, 줄과 빈 칸도 요소다. 카드가 날아가는 연출은 FLIP 이라는 방법으로 만든다. 옮기기 전에 카드마다 화면 위치를 재 두고(First), 판을 새 상태로 다시 그린 다음 새 위치를 재고(Last), 그 차이만큼 카드를 예전 자리로 되돌려 놓았다가(Invert) 원래 자리로 움직인다(Play). 레이아웃은 한 번만 바뀌고 움직임은 transform 만으로 처리할 수 있어서 많이 쓰는 방법이다.
다른 게임에서 통한 방법을 그대로 옮겼다
그 직전에 패 짝맞추기에서 비슷한 증상을 고친 일이 있었다. 섞기 연출이 끝날 때 판이 깜빡였는데, 원인은 연출 동안 날아가는 패를 다른 패 위로 올리려고 바꾼 z-index 를 연출이 끝날 때 원래대로 되돌리는 데 있었다. 사파리는 화면을 몇 개의 그림 층으로 나눠 그리는데, 쌓이는 순서가 바뀌면 층을 다시 나누면서 판 전체를 새로 그리는 것으로 보았다. 그래서 z-index 를 되돌리지 않고, 움직이는 패에는 will-change: transform 을 줘서 늘 따로 그려 두게 했다. 패 짝맞추기는 이것으로 나았다.
카드 게임도 증상이 비슷해 보였다. 세 게임에 같은 고침을 넣었다. 그런데 이 원인은 짐작이었다. 아이폰에서만 생기는 현상이라 개발하는 맥에서는 재현할 수 없었고, 데스크톱에서 확인할 수 있는 것은 연출이 여전히 잘 돈다는 것뿐이었다.
실제 아이폰에서 다시 해 보니 세 게임 모두 그대로 번쩍였다.
판 전체를 새로 만들고 있었다
다시 코드를 보니 세 게임의 그리기 함수가 같은 모양이었다. 카드를 한 장 옮길 때마다 판 전체의 HTML 을 문자열로 만들어 innerHTML 에 넣었다. 솔리테어는 52장, 스파이더는 104장의 카드 요소를 매번 지우고 새로 만든 것이다. 그리고 FLIP 연출은 방금 만든 새 요소에 바로 걸렸다. 설명을 위해 줄인 모양은 이렇다.
// before: every move rebuilt the whole board
function draw() {
board.innerHTML = columns.map((col) =>
`<div class="col">${col.map((c) => `<div class="card" data-id="${c.id}">…</div>`).join('')}</div>`
).join('');
animateFrom(before); // FLIP on brand-new elements
}
여기에 첫 번째 고침이 더해졌다. 카드마다 will-change: transform 을 줬으니, 새로 만든 카드 수십 장이 한꺼번에 각자의 그림 층을 요구했다. 사파리가 이 층들을 만들고 그리는 동안 판이 비어 보였다. 데스크톱 크롬은 이 일을 한 프레임 안에 끝내서 보이지 않았던 것으로 보인다. 패 짝맞추기에서 통한 will-change 가 여기서는 문제를 키웠다.
고친 방법은 요소를 다시 만들지 않는 것이다. 판 틀(줄, 빈 칸, 더미)은 게임을 열 때 한 번만 만든다. 카드 요소는 카드 번호마다 하나를 만들어 Map 에 넣어 두고 계속 쓴다. 그릴 때는 카드 요소를 제 줄로 옮기고, 앞면인지 뒷면인지 같은 상태는 클래스로 바꾼다. 줄의 자식 목록이 실제로 달라졌을 때만 replaceChildren 으로 바꾼다. 같은 요소가 같은 자리에 있으면 아무것도 건드리지 않는다.
const cardEls = new Map(); // card id -> element, created once
function cardEl(card) {
let el = cardEls.get(card.id);
if (!el) {
el = document.createElement('div');
el.dataset.id = card.id;
cardEls.set(card.id, el);
}
el.className = 'card' + (card.up ? ' up' : '');
return el;
}
function put(column, kids) { // touch the DOM only when the order really changed
const same = kids.length === column.children.length &&
kids.every((k, i) => column.children[i] === k);
if (!same) column.replaceChildren(...kids);
}
replaceChildren 에 이미 화면에 있는 요소를 넘기면 새로 만들지 않고 그 요소를 옮긴다. 카드 한 장을 옮기면 바뀌는 줄은 두 개뿐이고, 나머지 줄과 카드는 그대로 남는다. 카드마다 줬던 will-change 는 뺐다.
요소를 계속 쓰니 새로 챙길 것도 생겼다. 예전에는 매번 새 요소라서 지난 연출이 남아 있을 수가 없었다. 이제는 같은 요소이니, 카드가 아직 날아가는 중에 다음 이동을 하면 도는 연출이 남아 있다. 그래서 다시 그리기 전에 카드가 지금 보이는 자리를 재고, 돌던 연출을 멈춘 뒤 그 자리에서 다시 날린다. 끌 때 붙였던 위치 값과 놓을 자리 점선 표시도 예전에는 요소와 함께 사라졌지만, 이제는 그리기 함수가 직접 지운다.
이렇게 바꾸고 실제 아이폰에서 세 게임을 다시 했다. 이번에는 카드를 옮겨도 판이 번쩍이지 않았다.
사흘 뒤 숫자 밀기에서 칸을 누를 때 판이 깜박인다는 기록이 들어왔다. 열어 보니 밀 때마다 판을 innerHTML 로 새로 만들고 있었다. 이번에는 바로 칸 요소를 그대로 두고 숫자와 그림만 바꾸는 쪽으로 고쳤다. 지금은 게임을 만들 때 지키는 규칙에 들어가 있다. 옮길 때마다 판을 통째로 새로 만들지 않고, 많은 요소에 will-change 를 늘 주지 않는다.
증상이 같아 보여도 원인이 같다는 보장은 없었다. 패 짝맞추기는 패를 다시 만들지 않는 게임이었고, 카드 게임은 매번 다시 만드는 게임이었다. 두 게임의 그리기 방식을 먼저 비교했다면 첫 번째 고침을 건너뛸 수 있었을 것이다.
참고한 문서
- Paul Lewis, FLIP Your Animations: https://aerotwist.com/blog/flip-your-animations/
- MDN, will-change: https://developer.mozilla.org/en-US/docs/Web/CSS/will-change
- MDN, Element.innerHTML: https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML
- MDN, Element.replaceChildren: https://developer.mozilla.org/en-US/docs/Web/API/Element/replaceChildren
- MDN, Element.getBoundingClientRect: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
이 글과 관련된 게임
다른 글
- 젤리 캐릭터는 뼈대 대신 모양 다섯 개로 움직인다 2026년 11월 1일
- 라이브러리 없이 3D 주사위를 굴려 원하는 면에서 멈추기 2026년 11월 1일
- 3D 모델의 텍스처가 하얗게 나왔다 2026년 10월 30일
- 2D 게임 틀에 three.js 를 끼워 넣기 2026년 10월 30일
- 모드 버튼을 눌렀더니 판이 출렁였다 2026년 10월 26일
