BodyCut Training
감량 중에도 골격근량은 지키도록 체성분·러닝·근력운동을 한 화면에 모은 모바일 웹앱 - 해석 문장만 로컬 LLM이 쓰고, 화면에 뜨는 숫자는 전부 코드가 계산한다.
- TypeScript
- Hono / SQLite
- Local LLM (Ollama)
- Auth / Security
- QA
Setup
- Problem
감량 중인 사람이 실제로 막히는 지점은 "체중이 안 빠진다"가 아니었다. 체중은 줄이고 싶은데 근손실은 피하고 싶고, 체지방률 목표는 있는데 어느 정도 운동량이 적절한지 모르고, 러닝과 근력운동을 어떻게 병행할지 모른다. 헬스장 운동명과 머신 이름이 아직 익숙하지 않고, 집에 기구가 있어도 활용법을 모른다. 일정과 피로 때문에 계획은 자주 깨진다. 여기에 하나가 더 붙는다. 체성분 수치는 하루 사이에도 크게 흔들린다. 물을 얼마나 마셨는지, 언제 쟀는지, 식전인지 식후인지에 따라 체지방량이 껑충 뛴 것처럼 보인다. 그 숫자를 그대로 던지면 사용자는 자기 노력이 실패한 걸로 읽는다. 실제로 실패한 게 아니라 측정이 흔들린 것뿐인데도 그렇다. 그래서 포지셔닝을 "체중을 빼는 앱"이 아니라 "근육을 지키면서 체지방을 빼는 앱"으로 잡고, 스펙에 "측정 오차를 실패로 해석하지 않도록 설계한다"를 원칙으로 따로 박아뒀다. 이 한 줄이 뒤에 나오는 신뢰도 판정 규칙과 LLM 경계선의 출발점이 됐다.
- Context
혼자 만들었고, 2026년 8월 8일부터 10일까지 이틀 스프린트로 핵심 루프까지 올렸다. 제약이 세 개였다. 첫째, 체성분과 체중은 건강 데이터라 외부 LLM API로 보내고 싶지 않았다. 둘째, 손을 댈 수 있었던 시간이 그 이틀이 전부였다. 셋째, 혼자라서 리뷰어가 없다. 그래서 API는 집에 있는 Mac mini 한 대에 올리고 Tailscale Funnel로만 밖에 냈다. 프론트는 Cloudflare Pages 정적 SPA다. LLM도 그 기기에서 도는 Ollama qwen2.5:32b만 부른다. 데이터가 기기를 벗어나지 않는다는 게 제품 설명이자 배포 구조의 이유다. 대가는 분명하다. 기기가 꺼지면 API도 같이 내려간다. 링크를 눌렀을 때 프론트는 떠도 데이터가 안 붙을 수 있고, 이건 감출 항목이 아니라 적어둘 항목이라고 봤다. 리뷰어가 없으니 결정의 근거를 코드 주석에 남기는 걸 규칙으로 했다. 왜 이 상태코드인지, 왜 이 키로 레이트리밋을 거는지를 코드 옆에 적어두면 나중에 다시 여는 내가 리뷰어가 된다. 이 케이스 스터디에 적을 수 있는 이유들도 대부분 그때 남긴 주석에서 나왔다.
- Users
스펙에 적어둔 1차 페르소나는 감량 중이면서 근육은 최대한 지키고 싶은 사람이다. 러닝을 자주 하고 싶고, 헬스장은 주 2-4회 갈 수 있고, 집에 간단한 기구가 있고, 운동 초급에서 중급이라 머신 이름이 아직 낯설고, 스마트 체중계나 InBody 수치를 자주 들여다보고, 5km에서 10km 대회 참가 계획이 있는 사람. 솔직히 말하면 그 페르소나의 출발점은 나 자신이었다. 다만 나 한 명용으로 두면 개인 목표 수치가 코드에 박히기 때문에, 대회 거리를 5K/10K/하프/풀로 일반화하고 회원가입·약관 동의·관리자 회원관리까지 붙여 다중 사용자를 받을 수 있는 구조로 다시 설계했다. 구조상 받을 수 있다는 것과 실제로 쓰는 사람이 있다는 건 다른 얘기다. 실사용자 수는 측정 전이고, 분석 도구도 붙어 있지 않다.
- Hypothesis
흔들리는 체성분 수치를 신뢰도 규칙으로 먼저 걸러 확정한 다음, 확정된 숫자만 근거로 해석 문장을 붙여주면, 하루치 측정 오차가 실패로 읽히지 않고 감량 중 근육 보존이라는 진짜 목표를 계속 추적하게 만들 수 있다.
Build
- What I did
- Today 대시보드를 GET /api/today 단일 집계로 렌더링 - 오늘 체중과 전일 대비 델타, 체지방률의 7일 평균 대비 변화, 골격근량 해석, 목표 대비 진행률, 오늘 러닝과 근력운동, 다음 롱런, 대회 D-day, 컨디션 체크(GREAT/GOOD/TIRED/PAIN)를 한 화면에
- 체성분 입력·히스토리와 신뢰도 자동 판정(lib/
confidence.ts) - 측정조건 8종을 HIGH/MEDIUM/LOW 기본값에 매핑하고, 6시간 이내 직전 HIGH/MEDIUM 측정 대비 체지방량이 절대 2.0kg 또는 상대 12% 이상, 골격근량이 1.0kg 이상 튀면 LOW로 강등. 임계값 4개는 상수 객체로 분리해 조정 가능하게 - Progress 추세 그래프 - 체중·체지방률·체지방량·골격근량과 러닝 거리를 7/14/30/90일/전체 기간으로 보고, 14일 근육 보존 모니터가 GOOD/WARN/NEUTRAL을 판정
- 운동 라이브러리 25종(검색·카테고리·근육명)과 상세 페이지, 주간 트레이닝 플랜, 오늘의 운동 완료 체크, 장비 CRUD(조절식 무게 스테퍼 포함)
- 러닝 기록 CRUD와 progression 자동 추천 - 더 긴 롱런이나 대회를 기록하면 최장 거리가 올라가고, 그 기록을 지우면 다시 계산한다. 대회 CRUD와 D-day, 대회 준비도 화면
- 로컬 LLM 해석 2종 - GET /api/ai/body-insight(오늘 체성분 해석)와 GET /api/ai/muscle-monitor(14일 근육 보존 코멘트). 수치는 전부 코드가 계산해 프롬프트에 JSON으로 주입하고 모델은 문장만 쓴다. 도달 불가·타임아웃·파싱 실패면 규칙기반 폴백을 fallback:true로 반환
- 인증과 계정 - HS256 JWT와 scrypt를 node:crypto로 직접 구현, 이름·휴대폰·약관 3종 동의, 비밀번호 재확인 후 전체 데이터를 트랜잭션으로 지우는 회원 탈퇴,
ADMIN_EMAILS기반 관리자 회원관리 패널, 그리고 CSV 임포트 3종(체성분·러닝·근력)과 타입별 템플릿 다운로드
- Product decisions
- 계산은 코드로, 설명은 AI로. 추세·평균·신뢰도·근육 보존 상태를 결정적 규칙으로 먼저 계산해 확정값으로 화면에 띄우고, Ollama에는 그 숫자를 컨텍스트로 넘겨 해석 문장만 쓰게 했다. 이유는 하나다. 숫자를 AI가 지어내게 하지 않기 위해서. 코드에도 그 경계가 남아 있어서, 모델 응답을 받은 뒤 confidenceNote는 코드가 계산한 값으로 덮어쓴다.
- 외부 LLM API를 쓰지 않고 로컬 Ollama만 호출. 건강 데이터가 사용자 기기를 벗어나지 않는다는 걸 제품 설명으로 삼았으니, 편의를 위해 외부 API를 한 번 부르는 순간 그 설명이 거짓이 된다. Ollama 클라이언트 파일 상단에는 서버 사이드 전용이며 브라우저 번들에 절대 들어가면 안 된다는 경고를 달아 경계를 명시해뒀다.
- 레이트리밋 키를 IP가 아니라 계정(이메일)으로. 터널 뒤에서는 X-Forwarded-For를 클라이언트가 조작할 수 있어 IP 키는 헤더 회전으로 우회되고, 반대로 모두가 같은 IP로 보이면 한 명이 전체 사용자를 잠글 수 있다. 다만 가입은 아직 계정이 없으니 전역 캡(10분 30건)으로 막았고, 소규모 앱이라 정상 가입 버스트가 그 캡을 나눠 쓰는 트레이드오프를 감수한다고 적어뒀다.
JWT_SECRET에 하드코딩 폴백을 두지 않고, 값이 없으면 부팅 자체를 거부. 폴백이 있으면 그 값으로 토큰을 위조할 수 있다. 켜지긴 하는데 안전하지 않은 상태보다 아예 안 켜지는 쪽을 택했다.- 탈퇴 재확인 비밀번호가 틀렸을 때 401이 아니라 403. 이 앱의 클라이언트는 모든 401을 세션 만료로 보고 로그아웃시키기 때문에, 여기서 401을 주면 비밀번호를 한 번 잘못 친 사용자가 통째로 튕긴다. 서버 상태코드를 클라이언트 전역 처리와 같이 놓고 골랐다.
- 신뢰도 LOW 측정을 평균·최저최고 집계에서는 빼되, 그래프에서는 지우지 않고 흐리게 표시. 지워버리면 사용자는 자기가 잰 기록이 사라졌다고 느끼고, 그대로 섞으면 평균이 오염된다. 오차를 감추지도 않고 실패로 읽히게도 하지 않겠다는 원칙을 UI 규칙으로 내린 것이다.
- QA considerations
- 인증 게이트가 라우트 핸들러보다 먼저 도는가, 그리고 로그인 응답 시간이 계정 존재 여부를 흘리는가 - /api/* 전체에 JWT 미들웨어를 라우터 등록보다 먼저 걸고 signup/login만 경로 비교로 예외 처리했다. 이메일이 존재하지 않아도
DUMMY_HASH로 scrypt 검증을 한 번 반드시 수행해 응답 시간 차이를 없앴고, 해시 비교와 JWT 서명 비교는 timingSafeEqual을 쓴다. 다만 이 경로를 고정하는 테스트는 없다. 토큰 없이 친 /api/today와 /api/ai/health가 둘 다 401로 떨어지는 것을 라이브에서 손으로 확인한 게 전부다. - 레이트리밋이 공격자 대신 정상 사용자를 잠그는가 - 로그인은 실패만 카운트하고 성공하면 카운터를 리셋해서 오타를 몇 번 낸 사람이 잠기지 않게 했다. 이메일당 10회, 가입 전역 30회, 탈퇴 비번 오류 계정당 5회에 각각 10분 창이고, 초과하면 429와 Retry-After 헤더를 돌려준다. 반대편 구멍도 같이 적어둔다. 카운터가 프로세스 메모리 Map이라 API를 재시작하면 창이 통째로 초기화되고, 재시작을 반복하면 제한이 사실상 없어진다. 지금 안 터지는 건 설계가 막아서가 아니라 프로세스가 하나뿐이고 쓰는 사람이 없어서다.
- Ollama가 꺼져 있거나 네트워크가 끊겼을 때 화면이 비는가 - 1.5초 도달성 프로브로 빨리 실패시키고, 생성은 25초 타임아웃, 응답은 중괄호 추출까지 시도하는 파싱 가드를 거친 뒤 headline/body 존재와 tone enum 소속을 검증한다. 하나라도 어긋나면 규칙기반 문장을 fallback:true로 돌려주고, 캐시에 있던 값도 파싱이 깨지면 캐시 미스로 처리한다. 클라이언트에서도 네트워크 실패를 status 0의 ApiError로 정규화해 20개 페이지 전부가 에러 상태를 갖는다. 여기까지는 코드 경로 얘기고, 실제로 Ollama를 내리고 재현해 남긴 증적은 없다. navigator.onLine 기반 오프라인 안내도 없어서 사용자는 "안 된다"까지만 보고 "왜 안 되는지"는 못 본다.
- LLM이 숫자를 지어내는가 - 델타·평균·모니터 상태를 코드가 계산해 프롬프트에 JSON으로 주입하고, 프롬프트에 제공된 수치 밖의 새 숫자를 넣지 말라고 명시한다. 결정적 신호인 confidenceNote는 모델 응답을 받은 뒤 코드 확정값으로 덮어써서, 문장이 틀려도 숫자는 오염되지 않게 했다.
- 과거 측정을 지우거나 날짜를 소급했을 때 낡은 해석이 남는가 - AI 캐시 키를 측정 id가 아니라 신호값 조합(7일 대비 체중·체지방·골격근 델타 + 모니터 상태 + 신뢰도)으로 잡아, 평균이 바뀌면 문장도 다시 생성되게 했다. 키에 userId 프리픽스를 붙여 사용자 간 캐시 오염도 막았다.
- 임포트한 CSV가 쓰레기 값을 그대로 통과시키는가 - 클라이언트 파서가 따옴표 안의 콤마, 이스케이프된 따옴표, CRLF/LF, BOM을 처리하고, 서버가 행마다 체중·체지방률·골격근량을 양수로 재검증한다. measuredAt은 YYYY-MM-DD로 시작하지 않으면 버리는데, 그 컬럼이 정렬과 신뢰도 비교의 기준이라 문자열 하나가 깨지면 판정 전체가 흔들리기 때문이다. 실패한 행은 행 번호와 사유로 돌려준다. 이 항목을 따지다가 같은 데이터를 넣는 단건 POST /api/body에는 같은 서버 검증이 없다는 것도 드러났는데, 고치지 않고 회고에 그대로 남겼다.
- 탈퇴가 데이터를 남기는가, 관리자가 자기 자신을 지울 수 있는가 - 탈퇴와 관리자 삭제가 같은 cascade를 쓰고, 유저 데이터 8개 테이블과
user_profile, userId 프리픽스 AI 캐시를 BEGIN/COMMIT/ROLLBACK 한 트랜잭션으로 지운다. 관리자가 자기 계정을 지우는 건 400으로 막아 자기 락아웃을 방지했고, 관리자 권한은 서버가ADMIN_EMAILS로 계산하며 클라이언트가 보낸 role은 믿지 않는다. 문제는 이 트랜잭션이 정말 아무것도 남기지 않는지를 테스트로 고정해두지 않았다는 것이다. 손으로 한 번 훑어봤을 뿐 증적을 남기지 않았고, 되돌릴 수 없는 경로가 검증이 가장 얇은 자리로 남았다.
- 인증 게이트가 라우트 핸들러보다 먼저 도는가, 그리고 로그인 응답 시간이 계정 존재 여부를 흘리는가 - /api/* 전체에 JWT 미들웨어를 라우터 등록보다 먼저 걸고 signup/login만 경로 비교로 예외 처리했다. 이메일이 존재하지 않아도
Outcome
- Metrics
- 프론트는 https://bodycut-training.pages.dev 로 라이브. API는 개인 Mac mini 한 대에서 도는 단일 프로세스라 기기가 꺼지면 같이 내려간다. 링크가 항상 살아 있다고 보장하지 못한다.
- /api/* 엔드포인트 41개 + 인증 없는 /health 1개, 프론트 라우트 20개(로그인 후 18개 + /login, /signup), 운동 카탈로그 25종, CSV 임포트 3종과 템플릿 3종
- 소스 15,271줄(.ts/.tsx,
node_modules제외), 커밋 19개, 머지된 PR 14개. 2026-08-08부터 08-10까지 이틀 스프린트로 여기까지 왔고 그 뒤로 커밋이 없다. - 자동화 테스트 0개. 테스트 파일도, 테스트 러너 의존성도, test 스크립트도 없다.
- 20개 페이지 전부가 에러 상태를, 16개가 로딩 상태를 다루고 aria-busy 16곳·aria-live 12곳·aria-label 12곳·aria-pressed 5곳이 붙어 있다. 다만 자동화된 접근성 검사는 0건이라 이 속성들이 실제로 맞게 붙었는지는 확인하지 않았다.
- GitHub Actions 배포 워크플로 실행 10회, 성공 0회. Cloudflare 시크릿을 등록하지 않아서고, 실제 Pages 배포는 수동으로 하고 있다.
- 사용자 수·재방문·지속 사용 여부, 그리고 LLM 응답 시간은 전부 측정 전. 분석 도구를 붙이지 않았고, 문서에 적혀 있는 웜/콜드 생성 시간은 벤치마크 로그가 없어 근거로 쓰지 않는다.
- Result / Learning
이틀 만에 핵심 루프까지 라이브로 올렸다. 체성분을 넣으면 신뢰도가 자동으로 붙고, 흔들린 측정은 집계에서 빠지고, Today 화면에는 확정된 숫자와 그 숫자를 설명하는 한국어 문장이 함께 뜬다. 러닝과 근력운동 기록이 같은 화면에 들어와 있어 오늘 뭘 하면 되는지가 한 번에 보인다. 출시 전에 임시로 깔아뒀던 엣지 비밀번호 게이트는 실제 로그인을 붙이고 나서 지웠다. 프롬프트가 두 번 뜨는 이중 관문이 됐기 때문이다. 다만 스펙이 MVP 범위로 잡았던 목표 시뮬레이터는 아직 TODO에 남아 있어서, 핵심 루프가 도는 것과 스펙 기준으로 MVP가 끝난 것은 같지 않다. 가장 분명하게 배운 건 AI 기능의 경계선을 어디에 긋느냐가 곧 품질 결정이라는 점이다. "체지방률 해석을 AI가 해준다"는 한 문장 안에는 숫자를 계산하는 일과 문장을 쓰는 일이 섞여 있는데, 이 둘을 나누지 않으면 모델이 틀린 평균을 말해도 잡아낼 방법이 없다. 나눠 놓으니 반대로 검증할 자리가 생겼다. 숫자는 규칙을 읽고 확인하면 되고, 문장은 틀려도 숫자를 오염시키지 못한다. 그리고 로컬 LLM을 붙이면서 해피 패스보다 실패 경로를 먼저 짠 건 지금도 옳은 순서였다고 본다. 다만 어디까지가 확인된 얘기인지는 구분해서 적어야 한다. 코드 경로상으로는 Ollama가 떠 있든 아니든 화면이 무언가를 보여주게 돼 있지만, 그 경로를 실제로 끊어보고 고정한 테스트는 없다. 그리고 지금 화면에 뜨는 문장이 모델이 쓴 것인지 규칙기반 폴백인지는 밖에서 구분할 수 없다. AI 헬스체크도 인증 뒤에 있어서 로그인한 사람만 확인할 수 있다.
- Retrospective
- 자동화 테스트를 0개로 둔 채 올렸다. 이게 이 프로젝트의 가장 아픈 지점이다. 이틀 스프린트였다는 건 변명이 되지 않는다. 신뢰도 판정·JWT·레이트리밋은 이미 결정적 규칙이고 임계값이 상수로 분리돼 있어서, 경계값 테스트를 붙이는 비용이 제일 낮은 자리였다. 가장 싸게 고정할 수 있는 곳을 안 고정한 셈이다. 다시 손대면
confidence.ts의 2.0kg / 12% / 1.0kg / 6시간 경계부터 붙인다. - 검증이 경로마다 비대칭이다. CSV 임포트는 행마다 양수 검증을 하는데, 같은 데이터를 넣는 단건 POST /api/body는 서버 검증 없이 요청 JSON을 그대로 insert한다. SQLite가 타입에 느슨해서 클라이언트 검증만 우회하면 음수 체중 같은 값이 그대로 저장될 수 있다. 나중에 만든 경로만 방어하고 먼저 만든 경로는 그대로 둔, 전형적인 순서 편향이었다. 같은 이유로 CORS 오리진도 기본값이 열려 있어서,
JWT_SECRET처럼 부팅을 거부하게 만들지 않은 건 일관성이 없다. - 배포 자동화를 워크플로부터 만들어놓고 시크릿을 안 넣었다. 10번 돌아 10번 다 실패했고 main에는 계속 빨간 배지가 달려 있다. "설정 전까지는 빨리 실패할 뿐 다른 건 안 깨진다"고 주석에 미리 적어둔 건 맞지만, 늘 빨간 CI는 신호 자체를 죽인다. 켜둘 거면 끝까지 붙이고, 아니면 워크플로를 지우는 게 맞았다. 배포와 백업 쪽도 같이 비어 있다. SQLite 백업 절차가 배포 문서에 없고, 스키마 변경은 컬럼 유무를 확인하고 ALTER TABLE ADD COLUMN 하는 자체 방식이라 롤백이라는 개념 자체가 없다.
- 자동화 테스트를 0개로 둔 채 올렸다. 이게 이 프로젝트의 가장 아픈 지점이다. 이틀 스프린트였다는 건 변명이 되지 않는다. 신뢰도 판정·JWT·레이트리밋은 이미 결정적 규칙이고 임계값이 상수로 분리돼 있어서, 경계값 테스트를 붙이는 비용이 제일 낮은 자리였다. 가장 싸게 고정할 수 있는 곳을 안 고정한 셈이다. 다시 손대면
- Tech stack
- TypeScript 5.6
- React 18 + React Router 6
- Vite 5
- TailwindCSS 3
- Hono 4 + @hono/node-server
- Node 24 (node:sqlite 내장 드라이버)
- SQLite (WAL, foreign_keys ON)
- pnpm workspaces 모노레포 (web / api / shared)
- Ollama qwen2.5:32b (로컬 LLM)
- 자체 구현 HS256 JWT + scrypt (node:crypto)
- Cloudflare Pages
- Tailscale Funnel
- macOS launchd LaunchAgent
- GitHub Actions (설정 미완, 실행 전부 실패 상태)