운동이 작심삼일로
끝나지 않게,
게임으로 만들었다

헬게이터(HELLGATER)는 운동을 RPG처럼 즐기는 피트니스 앱입니다. 기록할수록 캐릭터가 성장하고, 스킬이 해금되고, 25주 커리큘럼 맵을 정복합니다. "오늘도 운동 가기 싫다"를 "오늘 퀘스트 깨러 가야지"로 바꾸는 것 — 이 프로젝트의 전부입니다.

12개월
1인 풀스택 개발
25주
운동 커리큘럼
164개
3D 클레이 스킬 아이콘
7부위
독립 레벨 시스템
스킬트리 화면
홈 대시보드

왜 사람들은 운동을 그만둘까?

헬스장 신규 등록자의 대다수가 3개월 안에 떠납니다. 의지의 문제가 아니라 설계의 문제라고 봤습니다. 운동은 성장이 눈에 안 보이고, 보상이 늦고, 혼자 하면 지루하니까요. 게임이 수십 년간 검증해온 동기 설계를 운동에 이식했습니다.

🎯

보이지 않는 성장 → 보이는 레벨

몸의 변화는 몇 달이 걸리지만, 캐릭터의 경험치 바는 오늘 바로 차오릅니다. 어깨·가슴·등·팔·복근·힙·다리 7개 부위가 각자 레벨업하며 "내가 어디까지 왔는지"를 즉시 보여줍니다.

🗺️

막막한 시작 → 정해진 모험 루트

"뭐부터 해야 하지?"라는 질문을 없앴습니다. 25주 커리큘럼을 RPG 맵으로 만들어 1-1 스테이지부터 차례로 클리어하면, 자연스럽게 올바른 순서로 배우게 됩니다.

🏆

늦은 보상 → 즉각적인 보상 루프

기록 1번 = EXP·포인트·퀘스트 진행·아이템 조각 드롭이 한 번에 따라옵니다. 일일 퀘스트, 연속 출석, 업적, 랭킹까지 — 플레이어 유형(성취·탐험·경쟁·사교)별 동기를 모두 설계했습니다.

기록 → 성장 → 모험, 하나의 게임 루프

앱의 모든 화면이 하나의 게임 루프로 연결됩니다. 운동을 기록하면 모든 시스템이 동시에 반응합니다.

RECORD고릴라를 터치해서 기록한다

운동 기록의 첫 화면은 폼이 아니라 캐릭터입니다. 마스코트 '킹릴라/퀸릴라'의 몸에서 오늘 운동할 부위를 직접 터치하면, 해당 부위 운동 목록이 펼쳐집니다.

  • 부위 터치 선택 — 앞판/뒤판 전환으로 전신 부위 지원
  • 스마트 추천 무게 — 성별·체중 기반으로 운동별 시작 무게 자동 제안
  • 지난 기록 비교 — 직전 세션 대비 중량/횟수/세트 변화를 실시간 표시
  • 어제 운동 불러오기 · 루틴 진행 — 반복 기록을 3초로 단축
운동 기록 화면
부위 선택 화면

GROW운동이 스킬로 해금된다

푸시업을 마스터하면 딥스가, 딥스를 넘어서면 벤치프레스가 열립니다. 실제 운동 발달 단계를 그대로 스킬트리로 옮겼습니다.

  • 브론즈 → 챌린저 — 7단계 티어로 배치된 부위별 스킬트리
  • 164개 3D 클레이 아이콘 — 모든 스킬에 수제 점토 질감의 전용 아이콘
  • 영상 인증 시스템 — 플란체·머슬업 같은 고난도 스킬은 영상 심사로만 해금 (허위 기록 차단)
  • 안전 설계 — 초보 단계는 무릎 푸시업·월 푸시업 등 홈트 경로 제공
스킬트리 화면

EXPLORE5개 대륙을 정복하는 25주

운동 지식 커리큘럼을 속성별 5개 대륙으로 만들었습니다. 땅(근육)·불(체력)·바람(심폐)·물(지방)·마음(근성) — 각 대륙은 목표가 다른 트레이닝 철학을 담고 있습니다.

  • 이론 → 실습 → 퀴즈 → 보스전 — 스테이지 타입별 학습 흐름
  • 실제 운동으로 클리어 — 보스 스테이지는 기록된 운동량으로만 격파 가능
  • 속성별 고유 지형 — 화산·성좌 등 대륙마다 다른 시각 연출
무속성 맵
스테이지 상세
무속성 대륙
🌱무속성 · 기초
땅 대륙
⛰️땅 · 근육
불 대륙
🔥불 · 체력
바람 대륙
🌪️바람 · 심폐
물 대륙
💧물 · 지방

COMPETE매일의 퀘스트, 모두의 랭킹

혼자 하는 운동을 함께 하는 경쟁으로 만들었습니다. 일일·주간·월간 퀘스트가 매일의 할 일을 만들어주고, 리더보드가 어제의 나와 다른 유저 모두와 겨루게 합니다.

  • 일일/주간/월간 퀘스트 — 출석·세트 수·칼로리·PR 갱신 등 다층 목표
  • 업적 시스템 — 연속 출석, 누적 운동 횟수, 레벨 달성 뱃지
  • 리더보드 — 레벨·주간 XP·월간 XP 카테고리별 랭킹
  • 운동 통계 — 일자별 볼륨 추이, 종목별 1RM 변화 그래프
퀘스트와 업적
리더보드

게임이지만, 운동 과학은 진짜다

재미를 위해 정확성을 포기하면 유저가 다칩니다. 헬게이터의 모든 수치는 검증된 운동 과학 공식과 실제 트레이닝 표준 위에서 동작합니다.

📐

1RM 자동 분석 — 표준 Epley 공식

기록한 무게와 횟수로 최대 근력(1RM)을 자동 추정합니다. 고반복 구간의 과대 추정을 막기 위해 12회 초과 기록은 보정합니다.

1RM = 무게 × (1 + 횟수 / 30)

예) 50kg × 5회 → 1RM ≈ 58.3kg

⚖️

상대적 강도 기반 경험치

무거운 걸 드는 사람이 아니라 자기 한계에 가깝게 노력한 사람이 더 많은 EXP를 받습니다. 본인의 공인 1RM 대비 강도가 보상의 기준입니다.

EXP = 기본EXP × (이번 무게 ÷ 내 1RM)

60kg이 한계인 사람의 55kg가, 150kg 드는 사람의 60kg보다 더 가치있게 평가됩니다.

🥇

체중 대비 등급 체계

운동별·체중별 수행 능력 기준표로 7단계 등급을 판정합니다. 벤치·스쿼트·데드·오버헤드프레스는 각각 전용 기준표를 사용해 부위 간 형평성을 지켰습니다.

BRONZESILVERGOLDPLATINUMDIAMONDMASTERCHALLENGER
🛡️

초보자 안전 설계

잘못된 운동 정보는 부상으로 이어집니다. 안전을 시스템 차원에서 강제했습니다.

  • ◆ 성별·체중 기반 추천 시작 무게 (여성 레터럴 레이즈 2~3kg부터)
  • ◆ 1RM 초과 기록은 차단 — 영상 레벨테스트로만 갱신
  • ◆ 체감 강도(RPE) 미입력 시 증량 대신 폼 숙달 우선 권고
  • ◆ 고난도 스킬은 영상 심사 통과 전 해금 불가

다크 판타지 × 귀여운 점토 질감

"헬(Hell)의 관문"이라는 어두운 세계관과, 매일 만나도 부담 없는 친근함 — 상반된 두 감성을 하나의 디자인 언어로 묶었습니다.

🎨

절제된 다크 테마

색을 아끼는 것이 원칙입니다. 깊은 다크 배경 위에 민트 단일 포인트만 사용하고, 골드는 공식 뱃지에만 허용합니다. 카테고리별 무지개색을 금지해 시선이 콘텐츠에만 머뭅니다.

Mint#78E6C8
Coral#FF5B5B
Black#2D2D2D
🦍

캐릭터가 인터페이스다

마스코트 바알시불(분홍 악마)과 킹릴라/퀸릴라(고릴라)가 단순 장식이 아니라 UI 그 자체입니다. 부위 선택은 고릴라 몸을 터치하고, 맵 여정은 바알시불과 함께 걷습니다.

🧱

수제 클레이 3D 아이콘 164종

모든 스킬 아이콘을 지문 자국이 남은 스톱모션 점토 질감으로 통일 제작했습니다. 게임적 깊이와 수공예적 따뜻함을 동시에 전달하는 비주얼 시그니처입니다.

📱

모바일 퍼스트, 어디서나 3초 기록

헬스장 한가운데서 한 손으로 쓰는 앱입니다. 리스트형 카드, 가로 스크롤 필터 칩, 하단 시트 입력 — 모든 인터랙션을 엄지 동선 기준으로 설계하고, PC에서는 max-width로 밀도를 유지합니다.

성취의 순간을 화려하게

레벨업·스킬 해금·PR 갱신 순간에만 파티클과 모달 연출을 집중 투입합니다. 평상시 기록은 가벼운 토스트로 — 중요한 순간과 일상의 리듬을 구분하는 피드백 위계를 지킵니다.

// FOR DEVELOPERS

여기서부터는 기술 이야기입니다

1인 풀스택으로 기획부터 배포·운영까지. 아래는 이 서비스를 실제로 굴러가게 만드는 구조와, 그 과정에서 부딪힌 문제들의 기록입니다.

타입 안전한 풀스택 모노레포

client / server / shared 워크스페이스 구조로, API 타입과 게임 상수를 프론트·백엔드가 단일 소스로 공유합니다.

FRONTEND

  • React 18SPA + 함수형 컴포넌트
  • TypeScript 5strict mode
  • Vite 5빌드 · HMR
  • Redux Toolkit인증 상태 + persist
  • React Query 5서버 상태 · 캐싱
  • Tailwind CSS다크 테마 토큰
  • Framer Motion인터랙션 애니메이션
  • Recharts운동 통계 시각화

BACKEND

  • Node.js 20런타임
  • Express 4REST API
  • Prisma 5타입 안전 ORM
  • PostgreSQL 15Supabase 호스팅
  • JWTAccess 15m / Refresh 7d
  • Zod런타임 스키마 검증
  • Winston구조화 로깅
  • Jest유닛 · 회귀 테스트

INFRA / OPS

  • Docker Compose개발환경 3컨테이너
  • Fly.io도쿄 리전 배포
  • GitHub Actionsrelease push → 자동 배포
  • SupabaseDB + Storage
  • Prisma Migrate운영 안전 마이그레이션
  • CapacitorAndroid 하이브리드 앱
  • PlaywrightUI 검증 자동화
  • Keep-Alive cron무료 플랜 정지 방지

게임 엔진을 품은 레이어드 아키텍처

운동 기록 1건이 들어오면 RM 분석 → EXP 계산 → 레벨업 → 스킬 해금 → 맵 진행 → 퀘스트 → 업적 → 아이템 드롭까지, 게임 엔진 서비스들이 트랜잭션과 이벤트 버스로 연쇄 처리됩니다.

Client — React + ViteRedux Toolkit(인증) · React Query(서버 상태) · Tailwind · Framer Motion
▼ REST API (JWT)
Express — Routes → Controllers → ServicesZod 검증 · 인증 미들웨어 · 이벤트 버스
gameEngine/
EXP·레벨·전투력
rmAnalysis/
1RM·등급 판정
mapProgression
스테이지 클리어
skillUnlock
quest · achievement
▼ Prisma ORM ($transaction 원자성 보장)
PostgreSQL — Supabase Tokyo (직접 연결 :5432)
User · Character
UserBodyPart ×7
WorkoutRecord · 1RM
Skill · MapStage · Quest
37+
페이지
70+
컴포넌트
15+
도메인 컨트롤러
109
서버 유닛 테스트

실서비스에서 부딪힌 문제와 해결

튜토리얼에는 없는 문제들입니다. 원인을 계층별로 격리하고, 근본 원인을 수정하고, 회귀 테스트로 방어한 기록입니다.

01

경험치 경제의 악용 가능성 — 게임 밸런스를 코드로 지키기

PROBLEMEXP가 무게×횟수×세트에 선형 비례하고 상한이 없어, 비정상 입력 1건으로 수백만 EXP 파밍(즉시 만렙)이 가능했다. 고반복 기록은 Epley 공식 특성상 강도 배율이 항상 최대치에 붙었다.
SOLUTION단건 EXP 캡 + 일일 EXP 캡(초과 시 숨기지 않고 capReached 플래그로 노출), Epley 입력 reps 12회 클램프, PR 퀘스트에 최소 +2.5% 갱신 조건. 악용 시나리오를 유닛 테스트로 고정.
// 일일 한도는 트랜잭션 내부에서 집계 — 동시 요청 우회 차단
const todayExp = await tx.workoutRecord.aggregate({
  where: { userId, createdAt: { gte: dayStart } }, _sum: { expGained: true },
});
const granted = Math.min(calculatedExp, Math.max(0, DAILY_EXP_CAP - todayExp));
02

상대적 강도 EXP — "공정함"을 공식으로 만들기

PROBLEM절대 무게 기반 보상은 초보자에게 불공평하고, 가벼운 무게 고반복 도배가 최적 전략이 되는 왜곡을 낳는다.
SOLUTION유저별 공인 1RM 대비 강도 배율(0.3~1.05)로 보상을 정규화. 공인 1RM 초과 기록은 차단하고 영상 레벨테스트로만 갱신 — 보상 체계와 데이터 신뢰성을 같이 지켰다.
const set1RM = weight * (1 + Math.min(reps, 12) / 30); // Epley + 고반복 클램프
const intensity = Math.min(1.05, Math.max(0.3, set1RM / official1RM));
const totalExp = (baseExp + gradeBonus + volumeBonus + categoryBonus) * intensity;
03

서비스워커가 구버전 앱을 영구 서빙 — 배포가 도달하지 않는 사고

PROBLEM오프라인용 SW가 index.html을 Cache-First로 캐싱해, 배포 후에도 기존 유저 브라우저에 구버전 앱이 영구 표시. 하드 리프레시로도 해소 불가 — "고쳐도 반영이 안 되는" 유령 버그의 원인.
SOLUTION브라우저의 sw.js 자동 업데이트 감지를 역이용한 self-destroying SW 배포: 활성화 즉시 전체 캐시 삭제 → 자기 등록 해제 → 열린 탭 자동 새로고침. 유저 개입 없이 전원 자동 복구.
self.addEventListener('activate', (event) => event.waitUntil((async () => {
  await Promise.all((await caches.keys()).map(k => caches.delete(k)));
  await self.registration.unregister();
  (await self.clients.matchAll()).forEach(c => c.navigate(c.url)); // 자동 새로고침
})()));
04

"화면 위가 잘려요" — 3개의 원인이 겹친 UI 버그 추적기

PROBLEM동일 증상에 원인이 3개 중첩: ① 라우트 이동 시 이전 페이지 스크롤 위치 유지 ② 조상의 overflow:hidden이 sticky 헤더 무력화 ③ 결과 모달이 화면보다 길 때 items-center 중앙정렬로 상단이 스크롤 불가 영역으로 밀려남.
SOLUTIONPlaywright로 유저의 실제 조작 시퀀스를 재현·캡처해 원인을 하나씩 격리. 전역 ScrollToTop, overflow-x-clip 전환(가로 넘침 차단 + sticky 보존), 모달은 items-center 대신 자식 margin:auto 패턴으로 교체.
/* 모달 상단 잘림의 표준 해법: 공간이 남으면 중앙, 넘치면 위부터 스크롤 */
/* container */ .overlay { display: flex; justify-content: center; overflow-y: auto; }
/* child     */ .modal   { margin-block: auto; height: fit-content; }
05

데이터 정합성 — 시드와 서비스 로직의 계약을 테스트로 강제

PROBLEM운동 코드 매핑 테이블의 키가 실제 DB 시드 코드와 어긋나며 추천 무게가 전부 기본값으로 추락(초보 여성에게 과중량 추천 위험). 맵 클리어 조건도 시드와 판정 로직의 타입 문자열이 달라 진행 불가 상태였다.
SOLUTIONshared 워크스페이스에 단일 타입 정의(ClearCondition)를 두고 시드·서비스가 공유. "시드의 모든 코드 ↔ 매핑 ↔ 표준 데이터" 3단 정합성을 검증하는 회귀 테스트를 추가해 같은 사고의 재발을 빌드 단계에서 차단.
it('seed의 모든 운동 code가 추천무게 매핑에 존재해야 한다', () => {
  const missing = seedCodes.filter(code => !(code in EXERCISE_CODE_TO_NAME));
  expect(missing).toHaveLength(0); // 누락 시 CI에서 즉시 실패
});
06

인증 상태 유실 — Redux Persist와 내비게이션의 레이스

PROBLEM로그인 직후 페이지 이동 시 간헐적으로 accessToken이 NULL — Redux 상태가 localStorage에 기록되기 전에 새 페이지가 로드되는 레이스 컨디션.
SOLUTIONpersistor.flush()로 저장 완료를 보장한 뒤에만 이동. 회원가입의 User + RefreshToken 생성도 prisma.$transaction으로 묶어 부분 저장(고아 레코드) 자체를 제거.
dispatch(setCredentials({ user, accessToken, refreshToken }));
await persistor.flush();  // localStorage 쓰기 완료 보장
window.location.href = '/home';
07

무료 인프라에서 운영하기 — Supabase × Fly.io 생존기

PROBLEMSupabase 무료 플랜은 7일 미사용 시 DB 자동 정지, Transaction Pooler는 Prisma와 circuit breaker 충돌, Fly.io auto-stop은 콜드 스타트 타임아웃 유발.
SOLUTIONGitHub Actions cron으로 매시간 헬스체크(DB 쿼리 포함), pooler 대신 직접 연결(:5432) 전환, 운영 배포는 prisma migrate deploy만 허용 + 실패 시 서버 기동 중단(exit 1)으로 데이터 정합성 보호.

push 한 번이 배포가 되기까지

release 브랜치 push → GitHub Actions → Fly.io 도쿄 리전 자동 배포. 운영 컨테이너 기동 시 마이그레이션이 자동 적용되며, 실패하면 서버가 뜨지 않습니다 — 깨진 스키마로 트래픽을 받는 일이 없습니다.

git pushrelease branch
GitHub Actionsbuild · test
Fly.io DeployAPI + Web (Tokyo)
migrate deploy실패 시 기동 중단
Live 🚀rolling · 무중단
3~5분
push → 라이브
200+
누적 커밋
2 Apps
API · Web 분리 배포
24/7
Keep-Alive 모니터링