프로젝트/내기 모음

내기 모음 웹사이트 개발기 - Vue3 + Tailwind로 4가지 게임 만들기

ksc-dev 2026. 4. 29. 21:00

프로젝트 소개

친구들이랑 모임에서 내기할 때 쓸 게임 모음 웹사이트를 만들었다. Vite + Vue3 + Tailwind CSS v4 스택으로 제작했고, 주사위 게임 / 제비뽑기 / 스톱워치 / 사다리 타기 총 4가지 게임을 구현했다.

 

배포: https://lucky-games-web.vercel.app

 

lucky-games-web

 

lucky-games-web.vercel.app

 

기술 스택

  • 프론트엔드: Vue3 + Vite
  • 스타일링: Tailwind CSS v4
  • 배포: Vercel + GitHub Actions
  • 분석: Google Analytics GA4

 

프로젝트 구조

lucky-games-web/
├── src/
│   ├── router/index.js
│   ├── views/
│   │   ├── IntroView.vue   # 인트로 (게임 선택)
│   │   ├── DiceView.vue    # 주사위 게임
│   │   ├── DrawView.vue    # 제비뽑기
│   │   ├── TimerView.vue   # 스톱워치
│   │   └── LadderView.vue  # 사다리 타기
│   ├── components/
│   │   ├── AppHeader.vue
│   │   └── PlayerListInput.vue
│   ├── utils/
│   │   └── index.js        # Fisher-Yates 셔플 유틸
│   ├── App.vue
│   └── main.js

 

구현한 내기 모음

🎲 주사위 게임

참가자 이름 입력 → 각자 주사위 굴리기 → 최고점 승자 판정 흐름이다.

핵심 패턴: phase 상태 관리

게임을 input → roll → result 3단계로 나눠서 관리했다. Vue의 ref()로 현재 단계를 추적하고 v-if로 UI를 전환하는 방식이다.

 
javascript
const phase = ref('input') // 'input' | 'roll' | 'result'

 

<winner computed>

javascript
const winner = computed(() => {
  if (phase.value !== 'result') return null
  const maxScore = Math.max(...players.value.map(p => p.result))
  return players.value.find(p => p.result === maxScore)
})

Math.max는 배열을 못 받아서 스프레드 연산자(...)로 펼쳐서 넘기는 게 핵심이다.


🎫 제비뽑기

참가자 + 항목 입력 → Fisher-Yates 셔플 → 카드 한 명씩 뒤집기 흐름이다.

 

<Fisher-Yates 셔플>

javascript
// src/utils/index.js
export function shuffle(arr) {
  const shuffled = [...arr]
  for (let i = shuffled.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
  }
  return shuffled
}

⚠️ ASI 함정: const j 다음 줄이 [로 시작하면 JavaScript가 배열 접근으로 잘못 파싱한다. ;[shuffled[i], shuffled[j]] 처럼 앞에 세미콜론을 붙여야 한다.

3D 카드 뒤집기 CSS

 
 
css
.card {
  perspective: 600px;
  transform-style: preserve-3d;
  transition: transform 0.5s;
}
.card.flipped {
  transform: rotateY(180deg);
}
.card-back {
  backface-visibility: hidden;
  transform: rotateY(180deg);
}

카드 앞/뒷면을 겹쳐놓고 backface-visibility: hidden으로 한 면씩만 보이게 하는 게 핵심이다. 클릭 시 부모에 rotateY(180deg)를 추가하면 자연스럽게 뒤집힌다.


⏱️ 스톱워치

requestAnimationFrame 기반 정확한 타이머에 랩 기록 기능을 추가했다.

왜 setInterval 대신 requestAnimationFrame인가?

setInterval은 탭이 비활성화되거나 CPU 부하 시 drift(오차)가 생긴다. requestAnimationFrame은 브라우저 화면 갱신 주기에 맞춰 실행되고, 시간 계산은 Date.now() 기준이라 rAF가 늦게 실행돼도 오차가 없다.

 
javascript
function tick() {
  elapsed.value = baseElapsed + (Date.now() - startedAt)
  rafId = requestAnimationFrame(tick)
}

 

<일시정지/계속 구조>

javascript
function pause() {
  baseElapsed = elapsed.value  // 현재 시간 저장
  cancelAnimationFrame(rafId)
}

function start() {
  startedAt = Date.now()  // 새로 시작 시각 찍기
  tick()
}

 

<formatTime 구현>

javascript
function formatTime(ms) {
  const minute = Math.floor(ms / 60000)
  const second = Math.floor(ms / 1000) % 60
  const cs = Math.floor(ms / 10) % 100

  return `${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}.${String(cs).padStart(2, '0')}`
}

⚠️ 흔한 실수: minute.toString.padStart(2, '0')처럼 toString을 함수 참조로 쓰면 안 된다. toString()으로 호출해야 문자열이 반환된다. 또한 % 나머지 연산 없이 단순 나누기만 하면 단위가 겹친다. Math.floor(ms/1000) % 60처럼 각 단위를 독립적으로 분리해야 한다.


🪜 사다리 타기

SVG로 세로선/가로선을 직접 그리고, 경로 추적 알고리즘으로 결과를 계산했다.

 

<사다리 생성 로직>

 
javascript
// rungs[row][col] = col과 col+1 사이에 가로선이 있냐
const rungs = ref([])

function generateRungs(count, rows) {
  return Array.from({ length: rows }, () => {
    const row = new Array(count - 1).fill(false)
    for (let col = 0; col < count - 1; col++) {
      if (Math.random() > 0.6 && !row[col - 1]) {
        row[col] = true
      }
    }
    return row
  })
}

!row[col - 1] 조건이 핵심이다. 바로 왼쪽에 이미 가로선이 있으면 추가하지 않는다. 이 조건이 없으면 같은 높이에 연속 가로선이 생겨서 경로가 꼬인다.

 

<tracePath 경로 추적>

javascript
function tracePath(startCol) {
  let col = startCol
  const points = [{ x: colX(col), y: TOP_Y }]

  for (let row = 0; row < ROWS; row++) {
    points.push({ x: colX(col), y: rowY(row) })

    if (rungs.value[row][col]) {
      col += 1
      points.push({ x: colX(col), y: rowY(row) })
    } else if (col > 0 && rungs.value[row][col - 1]) {
      col -= 1
      points.push({ x: colX(col), y: rowY(row) })
    }
    // 가로선 없으면 그냥 다음 row로
  }

  points.push({ x: colX(col), y: RESULT_Y })
  return { points, endCol: col }
}

⚠️ 함정: else { return }처럼 가로선 없을 때 함수 전체를 종료하면 안 된다. 그냥 다음 row로 넘어가야 한다.

⚠️ const 재할당: points = [...points, { x, y }]는 const라 불가능하다. points.push({ x, y })로 같은 배열 내부를 수정해야 한다.

 

트러블슈팅

Tailwind v4 + 커스텀 CSS 충돌

문제: mb-16 같은 Tailwind 유틸리티 클래스가 적용이 안 됐다.

원인: style.css에 * { margin: 0; padding: 0; } 리셋 스타일이 Tailwind보다 우선순위가 높아서 덮어쓰고 있었다.

해결: Tailwind v4는 자체 리셋을 포함하고 있어서 커스텀 리셋을 제거했다.

Fisher-Yates ASI 문제

문제: Cannot access 'j' before initialization 에러 발생

원인: JavaScript ASI(자동 세미콜론 삽입)로 인해 const j 다음 [가 배열 접근으로 파싱됨

해결: [shuffled[i], shuffled[j]] 앞에 ; 추가

flex-1 오버플로우

문제: 사다리 입력폼에서 모바일 화면을 벗어남

원인: flex-1 자식들이 최소 너비를 초과

해결: min-w-0 추가

 
html
<input class="flex-1 min-w-0 bg-gray-800 ..." />

Vercel SPA 새로고침 404

문제: /dice 같은 경로에서 새로고침하면 404 에러

해결: vercel.json에 rewrites 추가

 
json
{
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}

 

배포

  • GitHub main / dev 브랜치 구조로 관리
  • Vercel 자동 배포 연동
  • Google Analytics GA4 추가

회고

이번 프로젝트에서 가장 많이 배운 것들이다.

Vue 패턴: phase 패턴으로 단계 관리, computed로 실시간 계산, v-model + emit 조합으로 컴포넌트 통신

JavaScript 함정: ASI 세미콜론 문제, const 배열 재할당 vs push, toString vs toString() 호출

CSS: 3D 카드 플립, Flexbox min-w-0 오버플로우 처리, Tailwind v4 충돌

알고리즘: Fisher-Yates 셔플, SVG 경로 추적, requestAnimationFrame 타이머

버그를 만나고 → 이유를 이해하고 → 직접 고치는 사이클을 계속 반복한 게 실력이 느는 방식이었다.