지난 편에서는 탈것 강화 시스템을 완성했습니다. enhance.ts의 지수 비용 곡선, enhanceBike 액션의 인자 없는 설계, 그리고 EnhanceButton의 수입 미리보기까지, 이제 “강화 vs 교체” 딜레마가 생겼고 게임다운 결정이 생겼습니다.

그런데 한 가지 문제가 있었습니다. 기능은 완성됐지만, 화면이 답답했습니다. EnhanceButtonBikeShop이 수직으로 나란히 쌓여 있었고, “지금 얼마나 모였나”를 직관적으로 알 수 있는 피드백이 없었습니다. 방치형 게임의 핵심 재미인 “숫자가 쌓이는 느낌”이 시각적으로 전달되지 않았습니다.

5단계에서 구현한 것들은 다음과 같습니다

  1. ProgressBar.tsx, 다음 탈것까지 진행률을 실시간으로 보여주는 바
  2. UpgradePanel.tsx, 강화와 탈것 패널을 탭으로 묶은 통합 패널
  3. 강화 성공 펄스 애니메이션 (EnhanceButton.tsx)
  4. 탈것 구매 바운스 애니메이션 (App.tsx)
  5. CSS 커스텀 키프레임 (index.css)
  6. App.tsx 레이아웃 전면 개편

방치형 게임 UI의 핵심 원칙: 정보 밀도 vs 단순함

방치형 게임 UI를 설계할 때 가장 먼저 부딪히는 딜레마는 “얼마나 많은 정보를 보여주느냐”입니다.

정보가 너무 많으면: 화면이 복잡해서 어디를 봐야 할지 모릅니다. 특히 모바일에서 작은 숫자가 빼곡하면 피로감이 생깁니다. 쿠키 클릭커 계열 게임이 초반에 많은 플레이어를 잃는 이유 중 하나가 이것입니다. 처음 켰을 때 화면에 숫자와 버튼이 너무 많아서 무엇을 해야 할지 모르게 됩니다.

정보가 너무 적으면: 진행 상황을 알 수 없어서 게임을 계속할 동기가 없어집니다. “내가 지금 무언가를 향해 나아가고 있다”는 느낌이 없으면 방치형 게임은 그냥 방치됩니다.

이 딜레마를 해결하는 방법은 정보의 계층화입니다. 가장 중요한 정보는 가장 크고 눈에 띄게, 덜 중요한 정보는 작게 혹은 상호작용을 통해서만 보이도록 배치합니다.

배달왕 키우기의 정보 계층은 다음과 같이 설계했습니다

     
계층 정보 크기/위치
1 현재 탈것 (이모지) 화면 중앙 대형
2 현재 돈 상단 고정, 큰 텍스트
3 다음 탈것까지 진행률 프로그레스 바: 시각적 즉시 파악
4 강화 or 탈것 구매 탭 패널: 필요할 때만 전환

이 계층 구조에서 “다음 탈것까지 진행률”을 숫자가 아닌 로 표현하기로 한 것이 5단계의 핵심 결정이었습니다.


프로그레스 바의 심리적 효과

게임 디자인 심리학에서 프로그레스 바는 단순한 UI 요소가 아닙니다. 이것은 목표까지의 거리를 시각화합니다. ”현재 돈: 7,234원 / 필요 돈: 15,000원”이라는 텍스트와 “바가 48% 채워져 있다”는 시각 정보는 동일한 내용이지만, 뇌가 처리하는 방식이 다릅니다. 숫자는 계산이 필요하고, 바는 즉시 인지됩니다. 특히 방치형 게임에서 “거의 다 왔다” 효과가 중요합니다. 프로그레스 바가 80-90%를 넘어서면 플레이어가 자리를 떠나기 어려워집니다. “조금만 더 기다리면 된다”는 생각이 게임 내 체류 시간을 늘립니다. 이것은 진행감(progress)이 플레이어 유지율(retention)에 직접 영향을 준다는 방치형 게임의 기본 원리입니다. 반대로 “이제 막 시작한” 느낌(바가 5% 이하)은 포기 욕구를 자극합니다. 이 때문에 탈것 가격 곡선을 설계할 때 초반 탈것들 간의 가격 차이를 작게 설정한 것과도 연결됩니다. 플레이어가 첫 탈것 교체를 빠르게 경험해야 합니다.

구현: 실시간 채움과 부드러운 트랜지션

// ProgressBar.tsx
const progress = Math.min(money / nextBike.price, 1);
const pct = Math.floor(progress * 100);

return (
  <div
    className="h-2 rounded-full bg-yellow-500 transition-all duration-300"
    style={{ width: `${pct}%` }}
  />
);

transition-all duration-300이 핵심입니다. money는 게임 틱마다(기본 60fps) 바뀌지만, 바가 매 프레임 즉시 점프하면 오히려 어색합니다. 300ms 트랜지션을 걸면 바가 부드럽게 채워지는 것처럼 보이고, 이것이 “숫자가 쌓이는 느낌”을 강화합니다. Math.floor(progress * 100)으로 정수 퍼센트를 사용하는 것도 의도적인 선택입니다. CSS width: 47.832%처럼 소수점까지 정밀하게 표현하면 렌더링 부하가 미묘하게 늘어납니다. 정수 퍼센트는 1% 단위의 변화만 반영하므로, 60fps 틱 중 실제 DOM 업데이트가 훨씬 적어집니다. 방치형 게임처럼 장시간 실행되는 앱에서는 이런 소소한 최적화가 쌓입니다.

MAX 상태 처리

if (!nextBike) {
  return (
    <div className="px-4 py-2 bg-gray-900 border-t border-gray-700">
      <div className="flex items-center justify-between mb-1">
        <span className="text-xs text-yellow-400 font-bold">🏆 MAX — 프레스티지 가능</span>
      </div>
      <div className="w-full h-2 rounded-full bg-gray-700">
        <div className="h-2 rounded-full bg-yellow-500 w-full" />
      </div>
    </div>
  );
}

마지막 탈것을 달성하면 “다음 탈것”이 없습니다. 이 상태에서 프로그레스 바는 100% 채워진 채 고정되고, “프레스티지 가능” 메시지가 표시됩니다. 이것은 두 가지 역할을 합니다. 하나는 “끝”이 아니라 “다음 단계”가 있다는 암시입니다. 프레스티지 시스템은 아직 구현되지 않았지만, 플레이어에게 “MAX가 되면 무언가 특별한 일이 생긴다”는 기대감을 심어줍니다. 다른 하나는 UI 일관성입니다. 프로그레스 바 영역이 조건에 따라 사라지고 나타나면 레이아웃이 흔들립니다. MAX 상태에서도 같은 자리에 같은 크기의 UI가 유지되어야 화면 구조가 안정적입니다.


탭 vs 아코디언 vs 한 화면: 모바일 정보 구조 선택

4단계까지 UI는 EnhanceButtonBikeShop이 세로로 쌓여 있었습니다. 이것을 어떻게 개선할지 세 가지 방안을 검토했습니다.

방안 1: 한 화면에 모두 표시 (현재 방식 유지)

장점: 스크롤 없이 모든 정보를 볼 수 있습니다. 상태 파악이 빠릅니다.

단점: 모바일 화면에서 공간이 부족합니다. 탈것 목록이 늘어나면 BikeShop이 길어져서 EnhanceButton이 화면 밖으로 밀립니다. 스크롤이 필요해지는 순간 UX가 무너집니다.

방안 2: 아코디언 (펼치기/접기)

// 아코디언 방식 (채택하지 않음)
const [showShop, setShowShop] = useState(false);
<button onClick={() => setShowShop(!showShop)}>탈것 목록 {showShop ? '▲' : '▼'}</button>
{showShop && <BikeShop />}

장점: 필요할 때만 콘텐츠를 펼치므로 공간 효율이 좋습니다. 현재 상태(펼침/접힘)가 명확합니다.

단점: 기본 상태가 “닫힘”이라면 플레이어가 탈것 목록을 발견하지 못할 수 있습니다. “기본 상태가 열림”이라면 한 화면에 모두 표시하는 것과 차이가 없습니다. 결국 어느 상태가 기본인지 결정해야 하는 문제가 남습니다.

또한 아코디언은 “현재 열려 있는 내용과 닫힌 내용이 함께 존재하는” 구조에 어울립니다. 강화와 탈것 목록은 상호 배타적인 선택지이지, 동시에 볼 필요가 있는 정보가 아닙니다.

방안 3: 탭 (채택)

// UpgradePanel.tsx
const [activeTab, setActiveTab] = useState<'enhance' | 'bikes'>('enhance');

장점: 두 섹션이 동등한 위계로 존재합니다. 어느 탭이 활성인지 항상 명확합니다. 공간을 고정적으로 사용하므로 레이아웃이 흔들리지 않습니다.

단점: 탭 간 전환 시 컨텍스트가 끊깁니다. “강화” 탭에 있다가 “탈것” 탭으로 가면 강화 정보가 사라집니다.

탭을 선택한 결정적인 이유는 사용 패턴입니다. 강화와 탈것 구매는 동시에 일어나지 않습니다. 플레이어는 “지금 강화할까, 아니면 다음 탈것을 사야 할까”를 결정한 후 해당 탭에서 행동합니다. 두 섹션을 동시에 볼 필요가 없으므로 탭이 아코디언보다 자연스럽습니다. 기본 탭을 'enhance'로 설정한 것도 의도적입니다. 게임을 열었을 때 “강화 탭”이 기본으로 보이면, 돈이 모여 있을 때 즉각적인 행동(강화)을 유도합니다. 탈것 목록은 의식적인 선택이 필요한 행동이므로 탭 전환이라는 마찰이 오히려 적절합니다.

탭 활성 상태 스타일링

className={`flex-1 py-2 text-sm font-bold transition-colors ${
  activeTab === 'enhance'
    ? 'text-yellow-400 border-b-2 border-yellow-400'
    : 'text-gray-500 hover:text-gray-300'
}`}

활성 탭: 노란색 텍스트 + 하단 2px 노란색 보더. 비활성 탭: 회색 텍스트 + hover 시 밝아짐. border-b-2 border-yellow-400 조합이 현재 위치를 명확히 표시합니다. 배경색으로 구분하는 것보다 하단 보더가 더 미니멀하고, 전체 게임 색상(노란색 = 행동 가능)과도 일관됩니다.


CSS 애니메이션 접근법 비교

5단계에서 두 종류의 애니메이션을 추가했습니다.

  1. 강화 성공 시 레벨 텍스트 펄스
  2. 탈것 교체 시 이모지 바운스-인

이 두 애니메이션을 구현하는 방법은 크게 세 가지를 고려했습니다.

방안 1: 애니메이션 라이브러리 (Framer Motion, React Spring)

Framer Motion은 React 생태계에서 가장 완성도 높은 애니메이션 라이브러리입니다.

// Framer Motion 방식 (채택하지 않음)
import { motion, AnimatePresence } from 'framer-motion';

<AnimatePresence>
  <motion.p
    key={currentBikeId}
    initial={{ scale: 0.5, opacity: 0.5 }}
    animate={{ scale: 1, opacity: 1 }}
    transition={{ type: 'spring', stiffness: 300 }}
  >
    {emoji}
  </motion.p>
</AnimatePresence>

장점: 선언적이고 읽기 쉽습니다. spring 물리 애니메이션처럼 CSS로 표현하기 어려운 것도 가능합니다. AnimatePresence로 마운트/언마운트 애니메이션을 깔끔하게 처리할 수 있습니다.

단점: 번들 크기가 큽니다. Framer Motion은 gzip 기준 약 30-50KB를 추가합니다. 방치형 게임처럼 모바일 우선 앱에서 초기 로딩 속도는 중요합니다. 또한 단순한 두 개의 애니메이션을 위해 라이브러리 전체를 포함하는 것은 과잉입니다.

React Spring은 Framer Motion보다 번들이 작지만 API가 더 복잡하고 학습 곡선이 있습니다.

방안 2: CSS Transition만 사용

// CSS transition 방식 (부분 채택)
<div className="transition-all duration-300" style={{ width: `${pct}%` }} />

프로그레스 바에는 CSS transition이 완벽하게 작동합니다. 값이 연속적으로 변하는 경우에 transition은 이상적입니다. 그러나 “트리거 → 짧은 효과 → 원래 상태”로 돌아오는 일회성 피드백 애니메이션에는 CSS transition이 어색합니다. 강화 성공 시 레벨 텍스트를 잠깐 커졌다가 돌아오게 하려면, 상태를 켜고 끄는 별도 로직이 필요합니다.

방안 3: CSS 키프레임 + useState (채택)

/* index.css */
@keyframes pulse-once {
  0% { transform: scale(1); }
  50% { transform: scale(1.3); color: #fbbf24; }
  100% { transform: scale(1); }
}

.animate-pulse-once {
  animation: pulse-once 0.3s ease-out;
}

@keyframes bounce-in {
  0% { transform: scale(0.5); opacity: 0.5; }
  50% { transform: scale(1.2); }
  100% { transform: scale(1); opacity: 1; }
}

.animate-bounce-in {
  animation: bounce-in 0.4s ease-out;
}

CSS 키프레임은 별도 라이브러리 없이 원하는 애니메이션을 정확히 표현할 수 있습니다. 번들에 추가되는 것은 몇 줄의 CSS뿐입니다. Tailwind CSS v4 환경에서는 커스텀 키프레임을 index.css에 직접 작성했습니다. Tailwind v3까지는 tailwind.config.jsextend.keyframes에 정의했지만, v4에서는 CSS 파일 내에서 직접 정의하는 것이 더 자연스럽습니다.


key prop 트릭: React에서 CSS 애니메이션 재트리거

CSS 애니메이션의 한 가지 문제가 있습니다. 한 번 실행된 애니메이션은 같은 DOM 요소에서 다시 트리거되지 않습니다. animate-bounce-in 클래스가 이미 적용된 요소에서는, 탈것이 바뀌어도 애니메이션이 실행되지 않습니다. 이 문제를 해결하는 방법은 여러 가지입니다.

방법 1: 클래스 제거 후 재추가

// 클래스 제거 후 재추가 (채택하지 않음)
element.classList.remove('animate-bounce-in');
void element.offsetWidth; // 리플로우 강제
element.classList.add('animate-bounce-in');

DOM을 직접 조작합니다. React의 선언적 패턴과 어긋납니다. void element.offsetWidth는 브라우저 리플로우를 강제로 일으키는 해킹이라 가독성이 나쁩니다.

방법 2: animation 속성 재설정 (CSS-in-JS)

// 인라인 스타일 방식 (채택하지 않음)
const [animKey, setAnimKey] = useState(0);
style={{ animation: `bounce-in 0.4s ease-out ${animKey}` }}

animKey를 변경해서 animation 값 자체를 바꿔 애니메이션을 재시작합니다. 동작하지만 인라인 스타일과 클래스를 혼용하는 어색함이 있습니다.

방법 3: key prop 변경 (채택)

// App.tsx
const [emojiKey, setEmojiKey] = useState(0);

useEffect(() => {
  setEmojiKey(k => k + 1);
}, [currentBikeId]);

// JSX
<p key={emojiKey} className="text-6xl animate-bounce-in">{emoji}</p>

React에서 key prop이 바뀌면 React는 해당 요소를 새로운 DOM 요소로 간주하고 재생성합니다. 새로 생성된 요소에는 animate-bounce-in 클래스가 처음 적용되는 것이므로, 애니메이션이 자동으로 처음부터 실행됩니다. 이 방법은 React의 핵심 개념인 “key가 바뀌면 element는 새것”을 그대로 활용합니다. DOM 조작도 없고, 해킹도 없습니다. 클래스 이름만으로 애니메이션을 선언적으로 관리할 수 있습니다. 단점은 DOM 요소가 실제로 파괴되고 재생성된다는 것입니다. 성능 측면에서 텍스트 요소 하나를 재생성하는 비용은 무시할 수 있는 수준이지만, 복잡한 컴포넌트에 이 패턴을 적용하면 주의가 필요합니다.


useState + setTimeout: 펄스 애니메이션 패턴

강화 성공 피드백은 다른 방식으로 구현했습니다.

// EnhanceButton.tsx
const [pulsing, setPulsing] = useState(false);

function handleEnhance() {
  const success = enhanceBike();
  if (success) {
    setPulsing(true);
    setTimeout(() => setPulsing(false), 300);
  }
}

// JSX
<span className={`text-yellow-400 font-bold ${pulsing ? 'animate-pulse-once' : ''}`}>
  Lv.{bikeLevel}
</span>

pulsing 상태가 true가 되면 animate-pulse-once 클래스가 추가되어 애니메이션이 실행됩니다. 300ms 후 클래스가 제거됩니다. 이 패턴은 단순하지만 잘 작동합니다. key prop 방식을 쓰지 않은 이유는 레벨 텍스트(Lv.{bikeLevel})가 바뀌기 때문입니다. bikeLevel이 바뀌면 텍스트 자체가 달라지므로, key를 외부에서 관리할 필요가 없습니다. 그러나 bikeLevel이 바뀐다고 해서 자동으로 CSS 애니메이션이 재트리거되지는 않습니다. 텍스트 내용이 바뀌어도 DOM 요소가 재생성되지는 않기 때문입니다. key={bikeLevel}을 주면 레벨이 바뀔 때마다 DOM을 재생성해서 애니메이션을 트리거할 수도 있습니다. 하지만 이 방법으로는 “강화 실패 시에는 애니메이션 없음”을 구현할 수가 없습니다. bikeLevel이 바뀌는 것 자체가 성공이므로 실패 케이스를 구분할 수 없습니다. useState + setTimeout 패턴의 장단점을 정리하면

장점

  • 성공/실패를 구분하여 조건부로 애니메이션을 실행할 수 있습니다.
  • 어떤 조건에서 애니메이션이 실행되는지 코드에서 명확히 보입니다.
  • 애니메이션 지속 시간을 setTimeout 값과 CSS animation-duration으로 이중으로 제어합니다.

단점

  • setTimeout은 컴포넌트가 언마운트된 이후에도 실행될 수 있습니다. 정확히는, 300ms 후에 setPulsing(false)를 호출하는데 그 시점에 컴포넌트가 없으면 React가 경고를 냅니다. (현재 코드에서는 EnhanceButton이 언마운트되는 상황이 거의 없으므로 실질적 문제는 아닙니다.)
  • setTimeout 지속 시간(300)과 CSS animation-duration(0.3s)을 따로 관리해야 합니다. 둘 중 하나를 바꾸면 다른 하나도 맞춰야 한다는 것을 기억해야 합니다.

더 견고한 방법은 useEffect + cleanup 패턴입니다.

// 더 안전한 패턴 (현재 프로젝트에서 미채택)
useEffect(() => {
  if (!pulsing) return;
  const id = setTimeout(() => setPulsing(false), 300);
  return () => clearTimeout(id);
}, [pulsing]);

cleanup 함수로 타이머를 정리하면 메모리 누수와 언마운트 이후 상태 업데이트 경고를 방지할 수 있습니다. MVP 단계에서는 단순함을 위해 현재 방식을 유지했습니다.


App.tsx 레이아웃 개편

5단계 이전의 레이아웃 구조

[이모지 + 탈것 이름]        ← 화면 중앙
[돈 / 수입 표시]           ← 하단 고정
[EnhanceButton]           ← 수직 스택
[BikeShop]                ← 수직 스택 (스크롤)

5단계 이후의 레이아웃

[이모지 + 탈것 이름 + Lv.N]  ← 화면 중앙 (레벨 추가)
[돈 / 수입 표시]             ← 상태 바
[ProgressBar]               ← 진행률 바 (신규)
[UpgradePanel (탭)]         ← 통합 패널 (신규)
// App.tsx
return (
  <div className="flex flex-col h-dvh">
    {/* 메인 영역 — 탈것 이모지 + 이름 + 레벨 */}
    <div className="flex-1 flex flex-col items-center justify-center bg-gray-800 gap-2">
      <p key={emojiKey} className="text-6xl animate-bounce-in">{emoji}</p>
      <p className="text-gray-400 text-sm">{bike.name}</p>
      <p className="text-xs text-yellow-500 font-bold">Lv.{bikeLevel}</p>
    </div>

    {/* 상태 바 — 돈 / 수입 */}
    <div className="p-4 text-center bg-gray-800 border-t border-gray-700">
      <p className="text-2xl font-bold">💰 {formatMoney(money)}원</p>
      <p className="text-sm text-gray-400">⚡ {formatMoney(ips)}원 / sec</p>
    </div>

    {/* 진행률 바 */}
    <ProgressBar />

    {/* 탭 패널 — 강화 | 탈것 */}
    <UpgradePanel />
  </div>
);

레이아웃의 핵심은 flex-1입니다. 이모지 영역이 남은 공간을 모두 차지하면서, 하단 패널들(상태 바, 프로그레스 바, 업그레이드 패널)은 콘텐츠 크기만큼만 차지합니다. 이 구조 덕분에 화면 크기와 무관하게 항상 탈것 이모지가 화면 중앙에 위치하고, 하단 UI가 고정 배치됩니다.

h-dvh(100 dynamic viewport height)를 쓴 이유는 모바일 브라우저의 주소창 때문입니다. 일반 h-screen(100vh)은 모바일에서 주소창이 보일 때 화면이 잘리는 문제가 있습니다. dvh는 주소창 높이를 동적으로 반영하므로 모바일에서도 정확한 전체 화면을 사용합니다. iOS Safari와 Android Chrome 모두 지원됩니다.

레벨 표시(Lv.{bikeLevel})를 이모지 아래에 추가한 것도 의미 있는 변경입니다. 탈것의 현재 강화 레벨이 항상 보이면, “지금 이 탈것을 더 강화할까”라는 생각이 더 자주 떠오릅니다. 프로그레스 바와 함께 “진행 중인 것들”을 화면에 상시 노출하여 플레이어의 관심을 유지합니다.


Claude와의 협업: UI 개선의 방향성 논의

5단계에서 Claude와의 협업은 주로 “무엇을 만들어야 하는가”보다 “어떻게 만들어야 하는가”에 집중됐습니다. 기능의 목표(프로그레스 바, 탭 패널, 애니메이션)는 이미 명확했지만, 구현 세부사항에서 여러 선택지가 있었습니다.

프로그레스 바 표시 형식 논의

처음에는 “50% (7,500원 / 15,000원)” 형식을 고려했습니다. 퍼센트와 절대값을 함께 보여주는 방식입니다. Claude의 의견: “방치형 게임에서 절대값이 중요한 건 ‘얼마나 더 필요한가’입니다. 현재금액 / 필요금액 형식이 앞으로 얼마나 더 모아야 하는지를 더 직관적으로 전달합니다.” {formatMoney(money)} / {formatMoney(nextBike.price)}원 형식으로 확정했습니다. 두 숫자가 나란히 있으면 남은 거리를 바로 계산할 수 있습니다.

탭 기본 탭 선택

Claude: “‘탈것’ 탭을 기본으로 하면 플레이어가 다음 목표를 먼저 확인하게 됩니다. ‘강화’ 탭을 기본으로 하면 즉각적인 행동(강화)을 유도합니다. 어떤 게임 경험을 우선하느냐의 선택입니다.”

저는 ‘강화’ 탭을 기본으로 선택했습니다. 방치형 게임에서 “게임을 열었을 때 즉시 무언가를 할 수 있다”는 느낌이 중요하기 때문입니다. 돈이 쌓여 있을 때 강화 버튼이 바로 보이면 클릭 욕구가 자극됩니다.

key prop 방식 제안

처음에는 classList.remove/add 방식으로 구현했습니다. 작동은 했지만 ref를 사용하고 DOM을 직접 건드리는 코드가 마음에 걸렸습니다.

Claude가 key prop 방식을 제안했습니다. useEffectcurrentBikeId 변화를 감지해 별도의 key 상태를 증가시키면, React가 DOM 재생성을 처리하고 CSS 애니메이션이 자동으로 재트리거됩니다.

const [emojiKey, setEmojiKey] = useState(0);
useEffect(() => { setEmojiKey(k => k + 1); }, [currentBikeId]);
<p key={emojiKey} className="animate-bounce-in">{emoji}</p>

코드가 훨씬 선언적이고 React스럽습니다. 즉시 채택했습니다.

Claude가 제안했지만 다르게 결정한 것

ProgressBar에 예상 도달 시간 표시: Claude는 현재 수입 기반으로 “약 X분 후 구매 가능”을 계산해서 보여주는 것을 제안했습니다.

// Claude의 제안 (미채택)
const remainingMoney = nextBike.price - money;
const etaSeconds = remainingMoney / ips;
const etaText = etaSeconds < 60 ? `${Math.ceil(etaSeconds)}초 후` : `${Math.ceil(etaSeconds / 60)}분 후`;

기능적으로는 유용합니다. 하지만 두 가지 이유로 채택하지 않았습니다.

첫째, 강화를 계속하면 수입이 늘어나므로 ETA가 계속 바뀝니다. 숫자가 너무 자주 바뀌면 오히려 혼란스럽습니다. “2분 후”가 강화하면 “1분 30초 후”로 바뀌는데, 이 변화가 게임 플레이에 어떤 의미를 주는지 불명확합니다.

둘째, ProgressBar 영역이 작습니다. 현재 텍스트(▶ 다음: 스쿠터, 7,500 / 15,000원)에 ETA까지 추가하면 공간이 부족합니다. MVP 단계에서는 필수 정보에 집중하기로 했습니다.

UpgradePanel 높이 고정: Claude는 탭 전환 시 레이아웃이 흔들리지 않도록 UpgradePanel에 고정 높이를 주는 것을 제안했습니다. 실제로 EnhanceButtonBikeShop의 높이가 달라서 탭 전환 시 레이아웃이 미묘하게 흔들립니다. 고정 높이로 해결할 수 있지만, 모바일 화면 크기가 다양하므로 픽셀 단위 고정 높이는 작은 화면에서 잘리거나 큰 화면에서 빈 공간이 생길 수 있습니다. 대신 minHeight: 0으로 플렉스 컨테이너가 최소한으로 수축하도록 설정했습니다. 완벽한 해결은 아니지만 강한 제약 없이 자연스럽게 동작합니다.


마주쳤던 고민과 이슈

1. 프로그레스 바의 퍼센트 vs 픽셀 폭

처음에 프로그레스 바를 width: ${pct}px로 구현했습니다. 부모 컨테이너의 폭을 측정해서 픽셀로 계산하려고 했습니다. useRef로 부모 div를 참조하고, getBoundingClientRect()로 폭을 계산하는 방식입니다. 구현하다 보니 불필요하게 복잡했습니다. CSS에서 width가 퍼센트 단위를 지원하고, 부모 컨테이너의 폭을 기준으로 계산합니다. width: ${pct}%가 원하는 동작을 완벽하게 합니다. useRefgetBoundingClientRect 없이 CSS 퍼센트 단위 하나로 해결됐습니다. 가장 단순한 해결책이 가장 올바른 경우가 많습니다.

2. transition-all vs transition-[width]

transition-all duration-300은 모든 CSS 속성에 트랜지션을 적용합니다. 성능을 고려하면 transition-[width] duration-300이 더 정확합니다. width만 변하므로 width 트랜지션만 필요합니다. Tailwind v4에서 transition-[width]는 임의값(arbitrary value) 문법으로 지원됩니다. 그러나 transition-all과 실제 성능 차이는 미미합니다. 프로그레스 바는 하나의 div고, 변하는 속성도 width 하나뿐입니다. 레이아웃 엔진이 불필요한 속성을 트랜지션할 일이 없습니다. 가독성이 좋은 transition-all을 유지했습니다.

3. animate-pulse vs animate-pulse-once

Tailwind에는 기본으로 animate-pulse 유틸리티가 있습니다. 그런데 이것은 무한 반복 애니메이션입니다. “계속 깜빡이는” 효과이므로 “강화 성공 피드백”과 맞지 않습니다. 커스텀 animate-pulse-once를 만든 이유가 바로 이것입니다. 한 번 실행되고 멈추는 애니메이션이 필요했습니다.

@keyframes pulse-once {
  0% { transform: scale(1); }
  50% { transform: scale(1.3); color: #fbbf24; }
  100% { transform: scale(1); }
}

.animate-pulse-once {
  animation: pulse-once 0.3s ease-out;
}

animation-fill-mode를 명시하지 않았는데, 기본값은 none입니다. 즉 애니메이션이 끝나면 요소가 원래 스타일로 돌아갑니다. forwards를 지정하면 마지막 keyframe 상태가 유지되는데, pulse-once는 마지막이 원래 상태(scale: 1)이므로 차이가 없습니다.

4. 탭 전환 시 강화 상태 보존

UpgradePanel에서 탭을 전환할 때 EnhanceButton이 언마운트됩니다. 만약 강화 pulsing 상태가 true인 상태에서 탭을 전환하면 어떻게 될까요? setTimeout이 300ms 후에 setPulsing(false)를 호출하려 하지만, 컴포넌트가 이미 언마운트된 상태입니다. React 18부터는 언마운트된 컴포넌트의 상태를 업데이트해도 경고를 내지 않도록 변경됐습니다. 이 경고 자체가 실제 메모리 누수를 일으키지 않았기 때문입니다. 그래서 현재 코드에서는 실질적인 문제가 없습니다. 그러나 이것은 “운이 좋은” 상황입니다. useEffect + cleanup 패턴으로 수정하는 것이 더 올바른 방향입니다.


전체 UI 변화 비교

4단계까지의 화면

┌─────────────────────┐
│     🛵 스쿠터         │
│   💰 15,234원        │
│   ⚡ 55원/sec        │
├─────────────────────┤
│ [강화하기 - 550원]    │  ← EnhanceButton
│  Lv.1 → Lv.2        │
│  55원/초 → 60원/초   │
├─────────────────────┤
│ 탈것 목록            │  ← BikeShop (스크롤)
│ [자전거 ✓] [킥보드 ✓] │
│ [전동킥보드 ✓]       │
│ [스쿠터 ✓현재]       │
│ [오토바이 🔒]        │
└─────────────────────┘

5단계 이후의 화면

┌─────────────────────┐
│                     │
│       🛵            │  ← animate-bounce-in
│      스쿠터          │
│      Lv.3           │  ← 레벨 항상 표시
│                     │
├─────────────────────┤
│   💰 15,234원        │
│   ⚡ 75원/sec        │
├─────────────────────┤
│ ▶ 다음: 오토바이      │  ← ProgressBar
│ ████████░░░ 62%     │  ← transition-all duration-300
│ 15,234 / 25,000원   │
├─────────────────────┤
│  [  강화  |  탈것  ] │  ← 탭 (activeTab: 'enhance')
├─────────────────────┤
│ ⬆️ 강화 Lv.3 → Lv.4 │  ← EnhanceButton (탭 내용)
│ 75원/초 → 80원/초    │
│ [강화하기 - 900원]    │
└─────────────────────┘

정보량은 비슷하지만 구조가 훨씬 명확해졌습니다. 진행 상황(ProgressBar), 현재 상태(레벨), 즉각적 행동(강화 탭)이 시각적 계층에 따라 배치됩니다.


다음 편 예고

5단계로 UI의 정보 구조가 갖춰졌습니다. 프로그레스 바가 진행감을 주고, 탭이 공간을 효율적으로 사용하며, 애니메이션이 행동에 피드백을 줍니다. 하지만 화면은 아직 “정적”입니다. 탈것 이모지가 화면 중앙에 가만히 서 있습니다. 방치형 게임에서 “게임이 돌아가고 있다”는 느낌을 주는 것이 중요한데, 아무런 시각적 움직임이 없으면 게임이 멈춰 있는 것처럼 느껴집니다. 6단계에서는 라이더 애니메이션을 구현합니다. 탈것이 화면에서 실제로 움직이도록 CSS 애니메이션을 추가하고, 게임이 “살아있다”는 느낌을 강화할 예정입니다. 단순한 CSS 애니메이션부터 시작해서, Canvas나 SVG 기반 접근법까지 검토해보겠습니다.


마치며

5단계는 새로운 게임 시스템을 추가한 것이 아니라 기존 시스템을 더 잘 보여주는 단계였습니다.

  • ProgressBar: 프로그레스 바 하나가 “다음 목표까지의 거리”를 즉각적으로 전달합니다. 텍스트 숫자보다 직관적입니다.
  • 탭 패널: 한정된 모바일 화면에서 두 섹션을 탭으로 묶는 것이 스크롤보다 자연스럽습니다.
  • key prop 트릭: React에서 CSS 애니메이션을 재트리거하는 가장 선언적인 방법입니다.
  • CSS 키프레임: 라이브러리 없이 두 개의 커스텀 애니메이션을 몇 줄의 CSS로 해결했습니다.
  • transition-all duration-300: 프로그레스 바의 부드러운 채움이 “숫자가 쌓이는 느낌”을 시각적으로 전달합니다.

Claude와의 협업에서 이번에 가장 도움이 됐던 것은 UI 결정의 근거를 명확히 하는 과정이었습니다. “탭 vs 아코디언”, “기본 탭 선택”, “key prop vs classList 조작”처럼 기능적으로는 여러 방법이 가능한 상황에서 각 선택지의 트레이드오프를 빠르게 정리하고 결정하는 데 도움이 됐습니다. 게임 개발에서 UI는 “보이는 것”이지만, 그 뒤에는 수많은 “보이지 않는 결정”이 있습니다. 어떤 정보를 얼마나 크게 보여줄지, 어떤 구조로 배치할지, 어떤 피드백을 줄지, 이 결정들이 쌓여서 “좋은 게임 느낌”을 만듭니다. AI와 대화하면서 그 결정들을 빠르게 검토하고 확정하는 것, 그게 이번 단계에서 가장 유효한 협업 방식이었습니다.