H
ryuhaon.dev
/Blog/단순할 줄 알았던 사다리 — 사다리 타기 개발기
개발게임Next.jsSVG회고

단순할 줄 알았던 사다리 — 사다리 타기 개발기

1~100명 자유 입력, 사다리가 사라지고 도트가 순간이동하고 가로선이 한 행 위에서 일어나던 버그 향연.

미스틱 로또는 의식이고, 대포 추첨은 액션이라면, 사다리 타기는 결정 도구다.

야식 메뉴 정할 때, 청소 당번 정할 때, 벌칙자 정할 때. 너무 진지하지 않고 그냥 가볍게 누가 뭘 뽑을지 운에 맡기는 거. 그래서 톤도 청록·민트로 잡았다. 신비롭지도 않고 폭발적이지도 않은, 친구들 옆에서 같이 화면 보는 그런 느낌.

만들기는 가장 쉬울 줄 알았다. 알고리즘은 단순하고, 그림도 직선뿐이니까. 그래서 가장 많이 헤맸다.


알고리즘은 정말 단순했다

사다리의 핵심은 두 가지.

  1. 가로선 생성 — 1:1 매칭 보장
  2. 출발 → 도착 경로 따라가기

가로선은 그냥 무작위로 그리면 출발 두 개가 같은 도착에 모일 수 있다. 그러면 사다리가 아니다.

해결은 의외로 한 줄.

// 같은 행에서 가로선이 인접하지 않도록
if (c - lastPlaced < 2) continue;
if (Math.random() < prob) {
  row.push(c);
  lastPlaced = c;
}

한 행에서 가로선이 인접하지 않으면(즉 (0,1) 다음에 (1,2) 같은 게 같은 행에 없으면), 자동으로 1:1 매칭이 보장된다. 그래서 별도의 검증이나 재시도 로직 없이 그냥 셔플해도 안전.

타기 로직도 한 줄짜리.

for (let r = 0; r < shape.rows; r++) {
  const row = shape.rungs[r];
  if (row.includes(col - 1)) col = col - 1;
  else if (row.includes(col)) col = col + 1;
}

여기까지 30분. 게임의 본질은 이미 끝났다. 남은 건 다 시각 작업이다.

근데 그 시각 작업이 무한지옥이었다.


1단계 — 100명까지 가능해야 했다

처음엔 2~10명이면 충분할 줄 알았다. 사용자가 한마디 했다.

"10명 이상일 수도 있잖아. 100명까지 늘려야 할 텐데."

100명. 좋다. 다만 100명 이름과 결과를 다 직접 입력하라고 하면 아무도 안 쓴다. 그래서 두 가지 토글.

  • 이름 자동 (P1~P100) — 디폴트 ON. OFF면 직접 입력 grid 노출
  • 결과 자동 (당첨 N명 + 꽝) — 디폴트 ON. 슬라이더로 당첨자 수만 정하면 끝

둘 다 자동이면 100명도 클릭 한 번에 시작 가능. 야무진 사다리 게임을 위한 최소 조건이었다.

근데 100명이면 사다리 SVG가 가로로 엄청 넓다. 일반 모바일 화면에 안 들어간다. 가로 스크롤 컨테이너로 감싸고, SVG width는 cols × 36px 정도로 동적 계산. 사다리 영역의 실제 가용 크기를 ResizeObserver로 측정해서 height도 맞춤.

이때부터 디버깅 지옥이 시작됐다.


사다리가 화면에 안 보였다

"근데 사다리 선이 아직도 아예 안 보이는데?"

세로선과 가로선이 통째로 안 보였다. 다양한 원인 가능성:

  • SVG 안의 <linearGradient> 페인트 서버 참조 실패
  • <filter url(#...)> 글로우 필터가 깨짐
  • 가로선의 stroke-dasharray=120 + dashoffset=120 + animation forwards 패턴이 CSS animation 적용에 실패하면 영원히 invisible

이게 가장 무서운 패턴이었다. dashoffset 0이 되어야 보이는데 keyframe이 한 번이라도 안 작동하면 영원히 안 보임. fallback이 없다.

해결: 가로선 등장을 opacity 0→1 fade-in으로 교체. 같은 animation이 실패해도 opacity는 기본값(1)이라 결국엔 보인다.

// 이전 (실패 시 영구 invisible)
strokeDasharray: 120,
strokeDashoffset: 120,
animation: `ladder-draw-rung 320ms forwards`,

// 이후 (실패해도 보임)
style={{
  animation: `ladder-rung-fadein 320ms ease-out both`,
}}

gradient도 단색으로 교체. 페인트 서버를 통째로 떼버려서 fallback에 대한 의존을 0으로.

교훈: 시각 효과의 fallback은 "안 보이는 것"이 아니라 "기본 모양으로라도 보이는 것"이어야 한다.


도트가 순간이동했다

"도트가 막 순간이동하는 것 같아."

CSS transition이 멋지게 적용되어 있는데 왜 부드럽게 안 가지?

// 의심
<g style={{ transition: `transform ${stepMs}ms` }}>
  <circle cx={x} cy={y} ... />
</g>

<g>transition: transform을 줬는데, 정작 변경하는 건 circle의 cx/cy attribute. SVG attribute는 CSS transition으로 보간되지 않는다. Chrome도 Firefox도 transition이 SVG geometric attribute에 안 적용된다.

해결: cx/cy를 0으로 고정하고, <g>transform: translate(${x}px, ${y}px)로 위치 변경. transform은 CSS transition으로 잘 보간됨.

<g style={{
  transform: `translate(${x}px, ${y}px)`,
  transition: `transform ${stepMs}ms cubic-bezier(0.4, 0, 0.2, 1)`,
}}>
  <circle cx={0} cy={0} ... />
</g>

이걸로 도트가 부드럽게 이동.


4번에 도착했는데 1번에 불이 들어왔다

"4번 위치에 도착해도 1번 자리에 불이 들어와."

도착 매칭 버그. 코드 보니까 arrived set이 도착 인덱스(endCol) 로 저장되는데, 하단 라벨 매칭은 시작 인덱스(startCol) 로 검사. 의미가 안 맞았다.

// 라벨 i의 owner = 그 자리에 도착하는 참가자의 startCol
const owner = mapping.indexOf(i);
const showOwner = arrived.has(owner);  // ← owner는 startCol, arrived는 endCol set

arrived set을 startCol 기반으로 통일하니까 라벨 매칭이 일관되게 됐다. 단순한 의미론 일치인데 헷갈리기 쉬운 버그.


도트가 대각선으로 갔다

"실제 사다리 위에서 지나가는 게 아니라 대각선으로 이동하는 느낌이야."

tracePath가 한 step에 (row, col)을 동시에 변경했다. transform translate transition은 두 좌표를 직선 보간 → 대각선.

진짜 사다리라면 옆 → 아래 → 옆 → 아래의 cardinal(수직/수평) 이동만 해야 한다.

해결: path를 세로 sub-step + 가로 sub-step으로 분리.

if (cur.row !== prev.row) {
  // 세로 먼저: 가로선까지 내려옴
  out.push({ col: prev.col, row: cur.row, isHorizontal: false });
}
if (cur.col !== prev.col) {
  // 가로 나중: 가로선 위에서 옆으로
  out.push({ col: cur.col, row: cur.row, isHorizontal: true });
}

한 sub-step은 row 또는 col 중 하나만 변하니까 transition이 수직 또는 수평 직선.

근데 처음엔 sub-step 순서를 가로 먼저, 세로 나중으로 했었다. 그러면 도트가 한 행 위에서 옆으로 가는 것처럼 보였다 — 사다리 가로선은 cur.row 위치에 그려져 있는데 도트가 prev.row에서 옆으로 가니까 가로선 없는 공중에서 옆으로 가는 모션이 됐다.

세로 먼저(가로선 위치까지 내려옴) → 가로 나중(가로선 위에서 옆으로) 으로 바꿔야 자연스럽다. 사다리 구조와 path 시각을 정확히 맞추는 게 핵심.


라벨이 사다리 옆으로 어긋났다

"공중에서 지나가는 것 같아."

도트가 사다리 세로선 위에 있는 건 맞는데, 상단 라벨이 세로선보다 옆으로 어긋나 있어서 시각적으로 도트가 사다리 옆을 지나가는 것처럼 보였다.

원인: 라벨이 flex justify-around + padding: SIDE_PAD + width: cellW. flex 알고리즘이 라벨을 cellW 영역 가운데 배치하니까 라벨 중심이 세로선 x보다 cellW/2 어긋남.

해결: 라벨을 absolute + left: SIDE_PAD + i*cellW + transform: translateX(-50%). 라벨 중심을 세로선 x에 정확히 align.

SVG와 HTML 라벨이 같은 좌표계를 공유해야 하는데, flex 자동 배치는 그걸 보장 안 해준다.


한 박자 빠른 가로 이동

"가로 이동이 한 박자 빠르게 보여서 사다리 위에서 가는 게 아니라 더 빨리 움직여."

이건 진짜 미묘한 함정. setTimeout과 CSS transition duration이 시간상으론 일치했는데, 다음 step의 duration으로 transition을 설정해서 한 박자 빨라 보였다.

// 잘못
const next = steps[stepIndex + 1];
const upcomingMs = next ? (next.isHorizontal ? horizMs : vertMs) : vertMs;
// 새 transform 적용 시 transition duration이 *다음* step 기반

// 수정
const cur = steps[stepIndex];
const enterMs = stepIndex === 0 ? 0 : (cur.isHorizontal ? horizMs : vertMs);
// 새 transform 적용 시 transition duration = *현재* cur로 들어오는 시간

거기에 cubic-bezier(0.4, 0, 0.2, 1) 곡선은 끝부분에서 감속이라 시각적으로 이미 도착해 보이는데 transition은 끝까지 진행. setTimeout이 정확히 duration에 맞춰 발화하면 "도착 즉시 다음 step" 느낌. 그래서 40ms 버퍼도 추가.


그래서 결국 뭘 배웠나

사다리는 가장 단순한 게임이라고 생각했다. 알고리즘 30분, 시각 1시간이면 충분할 줄. 결과는 30분 + 며칠이었다.

배운 것들:

  • SVG attribute는 CSS transition 못 한다. transform 써야 함
  • 시각 효과 fallback은 "안 보이는 것"이 아니라 "기본 모양으로라도 보이는 것" 이어야 함
  • flex 자동 배치는 SVG와 HTML 좌표 align을 보장하지 않는다. 픽셀 align이 중요하면 absolute로
  • transition duration은 "현재 도달 중인" 것의 시간이지 "방금 도착한" 게 아님. 시점이 헷갈리기 쉬움
  • 사다리 구조와 path 좌표가 정확히 맞아야 한다 — 가로선 y와 가로 sub-step의 row가 같지 않으면 공중에서 옆으로 감

가장 큰 교훈: "단순해 보이는 것"이 실제로 단순한 게 아니다. 알고리즘은 단순했지만 시각의 디테일이 게임의 본질이었다. 사람들이 사다리 타기에서 보고 싶은 건 "결과"가 아니라 "도트가 사다리 위로 내려가는 그 순간"이니까.

지금 사다리 만들러 가기 →

🛒 이 글과 관련된 추천 상품

쿠팡 파트너스

ⓘ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.