docker compose up 한 줄로 내 서버에 올리는 헬스·체중 기록 앱이다. 계정도 구독료도 광고도 없고, 로그인은 비밀번호 대신 패스키(지문·얼굴)로 한다. 그런데 코드를 열어 보면 진짜 볼거리는 기능 목록이 아니다 — 서버가 프레임워크 없는 Node 파일 하나(554줄)이고, 데이터베이스 대신 JSON 파일 몇 개를 쓰며, "다음 세트에 몇 kg를 들지"를 정하는 규칙 전체가 테스트 붙은 순수 함수로 따로 떨어져 있다.
http · 코드 약 14,500줄 · 운동 1,324개 · UI 12개 언어 · TrendShift Daily #6)
TrendShift 목록에 오른 주소는 arvids-unavailable/openGym이다. 이름 그대로 "사라진 저장소를 받아 두는" 계정이고, 설명란에 원본 주소 DuarteSantos8/openGym이 적혀 있다. 확인해 보면 원본은 404(삭제되었거나 비공개 전환)이고, README 안의 링크·별 배지·토론 링크도 전부 원본을 가리켜 죽어 있다.
그래서 이 문서의 코드 인용은 전부 이 사본을 클론해 읽은 것이다. 사본은 이력을 커밋 하나로 눌러 놓아서 원본의 커밋 수·최종 수정일은 알 수 없다. 다만 CHANGELOG.md가 그대로 남아 있어 v1.2.4 / 2026-08-01까지의 이력은 확인된다. 별 수를 인용하지 않는 이유도 이것이다 — 사본 페이지의 별 10개는 원본의 인기와 아무 관계가 없다.
openGym이 하는 일은 단순하다. 요일별 운동 계획을 짜고, 운동할 때 세트마다 무게와 횟수를 찍고, 체중을 기록하고, 그래프로 본다. Hevy·Strong·FitNotes 같은 앱이 이미 하는 일이다.
다른 점은 어디서 도는가다. 이 앱은 도커 컨테이너 세 개로 내 서버(집에 있는 라즈베리파이든 클라우드 VM이든)에서 돌고, 기록은 그 서버의 ./data 폴더 안 JSON 파일로 남는다. 회사가 망해도, 요금제가 바뀌어도, 내 폴더는 내 것이다.
그리고 여기서 기술적으로 흥미로운 선택들이 시작된다. 보통 이런 앱을 만들면 Express로 API를 짜고 PostgreSQL을 붙이지만, openGym은 둘 다 쓰지 않는다. 서버는 Node에 기본으로 들어 있는 http 모듈만 쓴 파일 하나고, 데이터베이스 자리는 JSON 파일이 대신한다. 서버가 실행 시점에 필요로 하는 외부 패키지는 단 두 개다 — 패스키 검증용과 푸시 알림용.
회원권(SaaS)은 편하다. 시설도 관리도 남이 한다. 대신 관두면 아무것도 안 남고, 가격은 저쪽이 정한다.
창고에 바벨을 들이면(자가호스팅) 청소도 정리도 내가 해야 한다. 대신 없어지지 않는다. openGym이 판 것은 이 거래다 — 그리고 "청소 부담"을 줄이려고 부품 수를 극단적으로 깎았다. DB 서버가 없으니 백업할 것도 ./data 폴더 하나뿐이다.
메모(Joplin), 사진(Immich), 미디어(Jellyfin), 가계부(Firefly III)처럼 상용 SaaS를 대체하는 자가호스팅 앱은 분야마다 대표 주자가 있다. 그런데 운동 기록은 오래 비어 있었다. 이 분야 앱은 대부분 폰 전용 상용 앱이고, 기록이 그 회사 서버에 쌓인다.
| 비교 대상 | 성격 | openGym과의 차이 |
|---|---|---|
| Hevy · Strong | 상용 SaaS · 폰 앱 | 기록이 회사 서버에. 고급 기능은 구독제 |
| FitNotes | 무료 · 안드로이드 로컬 저장 | 기기 간 동기화 없음, 웹 UI 없음 |
| wger | 자가호스팅 · Django | 기능은 더 넓지만 PostgreSQL 등 부품이 많고 무겁다 |
| openGym | 자가호스팅 · PWA + APK | DB 서버 없음 · 로그인은 패스키 · 컨테이너 3개 |
결정적인 건 이사 오는 길을 열어 뒀다는 점이다. FitNotes(안드로이드·iOS 두 포맷)·Strong·Hevy의 내보내기 파일과 애플 건강의 체중 데이터를 그대로 가져온다. 기존 앱 사용자가 기록을 버리지 않고 넘어올 수 있다.
요즘 웹 앱은 npm install 한 번에 수백 개 패키지가 딸려 온다. openGym의 실행 시점 의존성은 아래가 전부다.
api/package.json (서버)
"@simplewebauthn/server": "^13.1.1" # 패스키 검증
"web-push": "^3.6.7" # 푸시 알림
→ 끝. Express도 DB 드라이버도 없다.
frontend/package.json (웹)
react / react-dom 19.2.7
react-router-dom 7.18.2
zustand 5.0.14
@capacitor/* (모바일 앱 빌드용 4개)
→ 상태관리·라우팅·리액트. 차트도 아이콘도 자체 구현.
차트 라이브러리(Recharts 등)도, 아이콘 팩도, i18n 라이브러리도 없다. 꺾은선 그래프(LineChart.jsx), 잔디밭 히트맵(Heatmap.jsx), 근육 지도(BodyMap.jsx), 아이콘 세트(Icon.jsx), 번역 엔진(lib/i18n.js 56줄)이 전부 저장소 안에 직접 쓰여 있다.
부품이 적으면 고장 날 곳도, 보안 패치를 따라가야 할 곳도 적다. 자가호스팅 앱에서 이건 취향이 아니라 실용적 이점이다 — 서버를 관리하는 사람이 취미로 하는 개인이기 때문이다. 대신 값을 치른다. 차트를 직접 그렸으니 버그도 직접 잡아야 하고, 기능을 넓힐 때마다 남이 만든 걸 가져다 쓰는 것보다 오래 걸린다.
개인이 띄운 서버에서 가장 흔한 사고는 비밀번호다. 재사용된 비밀번호, 유출된 해시, 무차별 대입. openGym은 비밀번호 항목 자체를 만들지 않았다. 로그인은 패스키 — 폰의 지문·얼굴 인식으로 서명하고, 서버는 공개키만 들고 있다가 서명을 검증한다.
RP_ID)으로 엄격하게 묶기 때문에, 주소 구성이 조금만 어긋나도 로그인이 안 된다(뒤에서 다룬다).대부분의 기록 앱은 지난 기록을 보여 주는 데서 끝난다. openGym은 다음 세션을 열면 무게가 이미 채워져 있고, 그 옆에 이유가 적혀 있다. "지난번에 전 세트를 다 채웠으니 2.5kg 올립니다", "세 번 정체됐으니 10% 내립니다" 같은 문장이다.
이게 가능한 건 진행 규칙(선형 증량, Greyskull LP, 더블 프로그레션, 시간 증가)이 UI에 흩어져 있지 않고 frontend/src/lib/progression.js의 순수 함수 한 곳에 모여 있기 때문이다. 함수가 돌려주는 값에 why 필드가 아예 들어 있다. 4절에서 코드를 직접 본다.
냉정하게 보면, 이번 트렌딩에는 저장소가 없어졌다는 사실 자체도 섞여 있다. 목록에 오른 주소는 사라진 저장소를 보존하는 계정의 사본이고, 이런 항목은 "이거 왜 없어졌지?"라는 호기심으로 클릭 수를 모은다. 프로젝트의 기술적 가치와는 별개의 동력이다 — 위 ①~④가 진짜 볼거리고, ⑤는 지금 이 주에만 유효한 이유다.
api/server.js 554줄이 전부다| 항목 | 선택 | 메모 |
|---|---|---|
| 런타임 | Node 22 (alpine) | "type": "module" — ESM |
| HTTP | 순정 node:http | Express·Fastify 없음 |
| 인증 | @simplewebauthn/server 13.x | 패스키 등록·검증만 담당 |
| 세션 | 자체 HMAC-SHA256 서명 쿠키 | JWT 라이브러리조차 안 씀 |
| 저장소 | JSON 파일 (DATA_DIR) | DB 서버 없음. 원자적 쓰기 직접 구현 |
| 푸시 | web-push (VAPID) | 키는 첫 실행에 자동 생성 |
| 스케줄러 | setInterval / setTimeout | cron·큐 없음 |
| 항목 | 선택 | 메모 |
|---|---|---|
| UI | React 19.2 | 클래스 컴포넌트 없음 |
| 라우팅 | React Router 7 (HashRouter) | 서버 설정 없이 정적 서빙·WebView에서 동작 |
| 상태 | Zustand 5 | 스토어 2개(useStore 데이터 / useUI 화면) |
| 빌드 | Vite 8 | 도커 빌드 스테이지 안에서 실행 |
| 테스트 | Vitest 4 | 테스트 파일 7개, 약 1,546줄 |
| 스타일 | 순수 CSS 약 1,088줄 | Tailwind·CSS-in-JS 없음 |
| 차트·아이콘·i18n | 전부 자체 구현 | 외부 패키지 0개 |
| PWA | public/sw.js 41줄 | Workbox 없이 직접 작성 |
Capacitor 7로 같은 프론트엔드를 안드로이드 앱(APK)으로 감싼다. 그런데 단순 포장이 아니다. VITE_MOBILE=1로 빌드하면 동작 방식이 세 군데 갈린다.
| 자가호스팅(웹) | 모바일 앱 | |
|---|---|---|
| 저장 | 서버에 동기화 | @capacitor/filesystem으로 폰 안에만 |
| 알림 | 서버 Web Push | @capacitor/local-notifications |
| 운동 이미지 | 서버가 로컬 파일 서빙(약 140MB) | jsDelivr CDN에서 로드(앱 용량 절감) |
모바일에서 굳이 파일시스템에 한 번 더 저장하는 이유는 iOS WebView가 저장 공간이 부족할 때 localStorage를 지울 수 있기 때문이다. "브라우저 저장소는 언제든 날아갈 수 있다"는 전제를 그대로 코드로 옮긴 셈이다.
| 서비스 | 이미지 | 역할 |
|---|---|---|
media | alpine/git | 운동 이미지·GIF 약 140MB를 한 번만 받아 놓고 종료(restart: no) |
api | node:22-alpine | 3000 포트. ./data 볼륨 마운트 |
web | nginx:alpine | 프론트 정적 파일 서빙 + /api 프록시. 기본 8080 |
배포 옵션으로 render.yaml도 들어 있지만 정의된 서비스는 웹 하나뿐이다. API까지 한 번에 올라가지는 않으니, Render로 올릴 생각이면 API는 따로 배포해야 할 것으로 보인다(저장소에 설명이 없어 추정).
운동 정보는 외부 저장소 hasaneyldrm/exercises-dataset에서 온다. 다만 글과 그림을 갈라 놓았다.
frontend/src/lib/exercises-data.js에 888KB짜리 배열로 박혀 있다. 빌드에 포함되니 네트워크 요청이 없다.media 서비스가 없을 때만 받아 온다(멱등).frontend/src/instr/*.js로 언어별 분리. 필요할 때만 불러온다.한 항목의 실제 모양은 이렇다. 키 이름을 두 글자로 줄인 게 눈에 띈다 — 888KB 파일에서는 키 이름 길이도 용량이다.
{"id":"0001","n":"3/4 sit-up","bp":"waist","eq":"body weight",
"tg":"abs","mg":"hip flexors","sm":["hip flexors","lower back"],
"st":["Lie flat...","..."],
"img":"0001-2gPfomN.jpg","gif":"0001-2gPfomN.gif"}
프론트와 API를 다른 주소(예: app.example.com / api.example.com)로 나누는 건 흔한 구성이다. openGym은 nginx 하나가 둘 다 서빙한다. nginx.conf 주석에 이유가 적혀 있다 — WebAuthn 때문이다.
location /api/ { proxy_pass http://api:3000; ... } # /api → 백엔드
location / { try_files $uri $uri/ /index.html; } # 나머지 → SPA
패스키는 "어느 사이트의 열쇠인가"를 RP_ID(호스트명)로 못 박는다. 프론트가 만든 인증 요청과 API가 검증하는 origin이 어긋나면 브라우저가 그냥 거부한다. 그래서 .env의 RP_ID·ORIGIN 두 값이 실제 주소와 정확히 맞아야 하고, 문서에도 "로그인 문제는 대부분 이 둘의 불일치"라고 적혀 있다.
http://localhost에선 되는데 폰에서 안 된다패스키는 HTTPS를 요구한다(localhost만 예외). 집 네트워크에서 http://192.168.0.10:8080으로 열면 로그인 단계에서 막힌다. 폰에서 쓰려면 도메인과 인증서가 필요하고, 그에 맞춰 RP_ID·ORIGIN을 바꿔야 한다. 문서 docs/SELF_HOSTING.md가 "두 줄만 고치면 된다"고 말하는 게 이 부분이다.
DB를 안 쓰면 가장 무서운 건 쓰는 도중에 프로세스가 죽는 것이다. 파일이 반쯤 쓰인 채 남으면 JSON이 깨지고, 그게 곧 계정 전체 소실이다. openGym은 이걸 atomicWrite로 막는다 — 임시 파일에 다 쓴 뒤 rename으로 갈아 끼운다. 같은 파일시스템 안에서 rename은 쪼개지지 않는 한 번의 동작이라, 결과는 "옛 파일" 아니면 "새 파일"뿐이고 그 중간은 없다.
대신 이 구조는 동시 접속이 많아지면 무너진다. 사용자 파일 하나를 통째로 덮어쓰기 때문에, 같은 계정을 두 기기에서 동시에 쓰면 나중 쓰기가 앞 쓰기를 지운다. 가족 몇 명이 쓰는 규모라서 성립하는 선택이지, 일반적인 정답은 아니다.
운동 중에 지하 헬스장에서 신호가 끊기는 건 흔한 일이다. 그래서 상태 관리는 서버가 아니라 브라우저 저장소가 주인이다.
여기서 한 줄이 특히 흥미롭다. 서버로 보내기 직전에 진행 중인 운동 세션을 일부러 지운다.
delete body.state.active // 진행 중인 운동은 서버에 저장하지 않는다
"지금 하고 있는 세트"까지 동기화하면, 폰과 태블릿에서 동시에 운동을 시작했을 때 어느 쪽이 진짜인지 다투게 된다. 끝난 기록은 합칠 수 있어도 진행 중인 상태는 합칠 수 없다. 그래서 아예 기기 밖으로 내보내지 않는다. 동기화 충돌을 푸는 대신, 충돌할 수 있는 데이터를 없앤 것이다.
JWT 라이브러리도 안 쓰고 서명 토큰을 직접 만든다. 페이로드는 사용자ID:만료시각:세션버전 세 조각이 전부다.
function sign(payload) {
const mac = crypto.createHmac('sha256', SECRET).update(payload).digest('base64url');
return payload + '.' + mac;
}
세 번째 조각인 세션 버전(sv)이 이 설계의 핵심이다. 서버는 발급한 쿠키를 저장하지 않는다(그래야 DB가 필요 없다). 그러면 "모든 기기에서 로그아웃"을 어떻게 할까? 사용자 레코드의 버전 숫자를 1 올린다. 이미 나가 있는 쿠키들은 전부 옛 버전을 달고 있으니 한꺼번에 무효가 된다. 서명 키 SECRET은 첫 실행 때 ./data/secret에 랜덤으로 생성되고 권한 0600으로 잠긴다.
이 저장소에서 가장 배울 게 많은 부분이다. "다음에 몇 kg를 들 것인가"는 화면 코드에 한 줄도 없다. lib/progression.js의 함수가 무게 · 종류 · 이유를 한 덩어리로 돌려준다.
if (last.ok) {
const dbl = policy === 'greyskull' && last.goal > 0 && last.amrap >= last.goal * 2
const step = dbl ? inc * 2 : inc
return {
policy, kind: 'up', weight: snap(w + step, inc),
why: dbl
? ['Last set hit {0} reps — twice the target, so take a double jump of {1} {2}.',
last.amrap, step, unit]
: ['Every rep last time — {0} {1} more.', step, unit]
}
}
if (stalls >= deloadAt) {
const dw = deloadTo(w, inc) // DELOAD_FACTOR = 0.9 → 10% 감량
return { policy, kind: 'deload', weight: dw, why: [...] }
}
why가 문장이 아니라 배열인 게 포인트다. 첫 칸이 번역 키가 되는 틀이고 나머지가 채워 넣을 값이라, 12개 언어 어디서든 같은 이유를 자기 언어로 말할 수 있다. 로직과 문구를 섞지 않은 것이다.
1RM(딱 한 번 들 수 있는 최대 무게)은 실제로 재기 위험해서 공식으로 추정한다. openGym은 공식 세 개를 넣어 뒀는데, 더 눈에 띄는 건 거절 조건이다.
export const REP_CAP = 12
export const FORMULAS = {
epley: (w, r) => w * (1 + r / 30), // Epley 1985
brzycki: (w, r) => w * 36 / (37 - r), // Brzycki 1993
lombardi:(w, r) => w * Math.pow(r, 0.1) // Lombardi 1989
}
export function estimate1RM(w, r, formula = 'epley') {
...
if (reps > REP_CAP) return null // 12회 초과는 추정하지 않는다
...
}
12회를 넘기면 공식들이 서로 크게 어긋나 숫자가 의미를 잃는다. 그래서 그럴듯한 값을 만들어 내는 대신 null을 돌려준다. 재미있는 건 테스트가 이 판단을 수치로 검증한다는 점이다 — onerm.test.js는 1회에서 세 공식이 정확히 일치하고, 2~8회에선 차이가 6 이내이며, 12회에서 그 차이가 최대가 됨을 단언한다. 테스트가 "왜 상한이 12인가"를 문서 대신 설명하고 있다.
계산할 수 있다고 해서 답해야 하는 건 아니다. 신뢰 구간을 벗어난 입력에 그럴듯한 숫자를 돌려주면, 쓰는 사람은 그게 추측인지 모른다. 도메인 규칙을 다루는 코드라면 거절 조건을 함수 안에 명시적으로 두고, 그 근거를 테스트로 남기는 이 패턴이 그대로 쓸 만하다.
푸시업에 "2.5kg 증량"은 말이 안 된다. 그래서 무게가 0이면 횟수를 올리고, 정해진 상한에 닿으면 세트를 하나 늘린다. 그런데 세트도 무한정 늘리지 않는다.
if (sets <= MAX_BW_SETS) return { kind:'up', weight:0, reps:bottom, sets, why:[...] }
return { kind:'hold', weight:0, reps:goal,
why:['{0} sets of {1} — time to add weight or move to a harder variation.', sets-1, goal] }
MAX_BW_SETS = 6. 여섯 세트를 넘어가면 앱이 증량을 멈추고 "이제는 무게를 달거나 더 어려운 동작으로 가라"고 말한다. 세트만 늘리는 걸 발전으로 착각하지 말라는 판단을, 숫자 하나로 코드에 넣은 것이다.
세트가 얼마나 힘들었는지 적는 척도가 두 가지다. RIR(몇 개 더 할 수 있었나)과 RPE(10점 만점 체감). 둘은 RPE = 10 − RIR로 서로 바꿀 수 있다.
export const rirOf = s =>
!s ? null : s.rir != null ? s.rir : s.rpe != null ? 10 - s.rpe : null
export const toScale = (kind, rir) =>
rir == null ? null : Math.round((kind === 'rpe' ? 10 - rir : rir) * 10) / 10
기록은 찍힌 그대로 두고(RPE로 적었으면 RPE로 남는다), 집계할 때만 RIR로 정규화한다. 설정을 바꿔도 과거 기록을 소급해 고치지 않는다. 다른 앱에서 RPE로 가져온 기록과 여기서 RIR로 적은 기록이 한 파일에 섞여도 아무 문제가 없는 이유다.
"오늘 운동 예정인데 아직 기록이 없으면 알려 준다"는 기능을 setInterval로 돌리는데, 주기가 10초다. 주석에 이유가 적혀 있다 — 1분 주기는 정각에 맞춰 도는 게 아니라서, 목표한 분을 최대 59초까지 놓칠 수 있다. 10초로 줄이면 오차가 9초 안으로 들어온다. 각 사용자의 "지금"은 Intl.DateTimeFormat으로 그 사람의 시간대 기준으로 계산한다.
휴식 타이머 알림은 다르다. 클라이언트가 setTimeout으로 예약을 걸어 두었다가, 탭이 백그라운드로 가서 스스로 타이머를 못 돌릴 때만 서버 푸시가 대신 울린다.
세트 사이에 폰이 꺼져 잠금 화면으로 돌아가면 기록하기가 번거롭다. Wake Lock API로 화면을 켜 두는데, 문제는 브라우저가 탭 전환·화면 잠금 때마다 이 잠금을 자동으로 풀어 버린다는 점이다. 한 번 요청하고 끝내면 안 된다.
// App.jsx — 라우트가 아니라 "운동이 진행 중인가"에 묶는다
useWakeLock(!!S.active && S.keepAwake !== false)
그래서 lib/wakelock.js는 wanted라는 플래그로 사용자의 의도를 따로 들고 있다가, 화면이 다시 보일 때마다 잠금을 재요청한다. 그리고 이 조건이 화면(라우트)이 아니라 "운동 중인가"에 걸려 있어서, 기록 도중 통계 탭을 잠깐 봐도 화면이 꺼지지 않는다. iOS 저전력 모드처럼 거부당하는 상황에서는 조용히 넘어간다 — 사용자가 할 수 있는 게 없기 때문이다.
| 구분 | 수치 |
|---|---|
| 코드(JS/JSX) | 약 14,500줄 — 프론트 13,968 + 서버 554 |
| CSS | 약 1,088줄 |
| 테스트 | 7개 파일 약 1,546줄 (코드 대비 약 11%) |
| 파일 수 | 약 226개 (미디어 제외) |
| 미디어 | 약 137MB (사진 1,324 + GIF 1,324) |
lib/에 테스트 파일이 바로 옆에 붙어 있다는 게 이 저장소의 성격을 말해 준다. 화면 코드에는 테스트가 없다. 테스트가 붙은 건 "무게를 얼마 올릴지", "1RM이 얼마인지" 같은 틀리면 사용자가 다치는 계산뿐이다. 테스트를 어디에 쓸지 고민될 때 참고할 만한 배분이다.
api/server.js — 554줄이라 한 번에 읽힌다. 웹 서버가 실제로 무엇을 하는지가 압축돼 있다.frontend/src/lib/progression.js + progression.test.js — 도메인 규칙을 순수 함수로 짜는 법.frontend/src/store/useStore.js — 로컬 우선 동기화 193줄.docker-compose.yml + nginx.conf — 세 컨테이너가 어떻게 한 주소로 합쳐지는지.frontend/src/lib/i18n.js — 라이브러리 없이 번역을 붙이는 최소 구현.Express를 쓰면 app.get('/x', ...)가 무슨 일을 하는지 안 보인다. openGym의 라우터는 객체 하나와 조회 한 번이다.
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
const key = req.method + ' ' + url.pathname;
const handler = routes[key];
if (!handler) return json(res, 404, { error: 'not found' });
try { await handler(req, res); }
catch (e) { if (!res.headersSent) json(res, 500, { error: 'server error' }); }
}).listen(PORT, ...)
이 20줄을 읽고 나면 미들웨어·라우터·에러 핸들러가 프레임워크에서 무엇을 대신해 주고 있었는지가 선명해진다. 물론 값도 안다 — 경로 파라미터(/users/:id)가 없고, 요청 한도 제한도 없다. 저장소는 후자를 리버스 프록시의 몫으로 넘긴다고 명시한다.
등록 옵션에 residentKey: 'required'가 걸려 있다. 아이디를 먼저 입력받지 않고 "이 기기에 저장된 openGym 열쇠"를 바로 고르게 하는 설정이다. 자가호스팅 앱에서 가입 화면을 최소화하는 실용적인 선택이다.
Zustand 자체는 작은 라이브러리지만, 여기서 배울 건 라이브러리 사용법이 아니라 동기화 규율이다.
localStorage에 즉시, 서버에는 1.5초 디바운스로.gym_dirty 플래그로 "아직 못 보낸 변경"을 표시 → 오프라인에서도 잃지 않는다.boot()이 세 모드(일반 / 모바일 / 데모)를 한 곳에서 분기한다.lib/i18n.js는 56줄이다. 핵심은 두 가지 — Vite의 import.meta.glob으로 언어 파일을 자동으로 모으고, React의 useSyncExternalStore로 언어 변경을 화면에 전파한다. 영어는 번들에 들어가고 나머지 11개는 고를 때 받아온다. 그리고 번역 키가 빠지는 사고를 scripts/check-locales.mjs가 막는다 — 기준을 특정 파일이 아니라 모든 언어 파일 키의 합집합으로 잡아서, 새 키를 한 언어에만 넣어도 나머지가 걸린다.
운동 사진·GIF → cache-first (한 번 받으면 끝, 안 바뀜)
그 외 요청 → network-first, 실패하면 캐시
HTML 없음 → index.html 로 폴백 (SPA)
push / notificationclick 도 여기서 처리
Workbox 없이 이 정도면 충분하다는 걸 보여 준다. 캐시 전략을 자원 성격에 따라 둘로만 나눈 판단이 핵심이다.
FROM --platform=$BUILDPLATFORM node:22-alpine AS build
--platform=$BUILDPLATFORM이 빌드 스테이지에만 붙어 있다. 이유가 주석에 적혀 있는데, 라즈베리파이(arm64)용 이미지를 x86 PC에서 만들 때 QEMU 에뮬레이션 아래서 esbuild·rollup의 네이티브 바이너리가 깨지는 문제를 피하기 위해서다. 빌드 결과물은 그냥 정적 파일이라 아키텍처와 무관하니, 빌드는 내 PC 아키텍처로 하고 실행 이미지만 대상 아키텍처로 두면 된다. 멀티 아키텍처 이미지를 만들어 본 사람만 아는 종류의 함정이다.
다른 앱에서 기록을 가져오는 기능에 CSV 파서가 직접 구현돼 있다. 콤마로 split하지 않는 이유는 명확하다 — "Bench Press, Close Grip" 같은 운동 이름이 실제 내보내기 파일에 들어 있고, 그러면 열이 통째로 밀린다. 게다가 에러가 나지 않고 조용히 어긋나기 때문에 사용자는 기록이 망가진 걸 나중에야 안다. 따옴표·줄바꿈·BOM·CRLF를 모두 처리하고, FitNotes(안드로이드/iOS 두 헤더)·Strong·Hevy의 열 이름을 별칭 표로 매핑한다.
남의 데이터를 받아들이는 코드에서 split(',') 같은 지름길은 대개 나중에 값을 치른다. 터지면 그나마 낫고, 안 터지면 더 나쁘다.
const ADMIN_UIDS = (process.env.ADMIN_UIDS || '').split(',').map(s => s.trim()).filter(Boolean);
const isAdmin = user => !!user && (user.admin === true || ADMIN_UIDS.includes(user.id));
권한 판정이 서버에서 강제되고, 프론트의 <Route path="/admin"> 가드는 화면을 숨기는 역할만 한다. 이 구분을 헷갈리면 그대로 보안 구멍이 된다.
초대 코드는 randomBytes(8) = 64비트다. 주석에 왜 이 길이인지가 적혀 있다 — 이 앱에는 요청 한도 제한이 없고 가입 옵션 요청이 "코드가 맞는지"를 알려 주는 통로가 되므로, 코드 자체가 무차별 대입으로 못 맞출 만큼 길어야 한다는 것이다. 없는 방어를 다른 방어로 메운 사례다.
server.js와 lib/history.js에 같은 일을 하는 작은 함수(effectiveRoutineId)가 두 번 쓰여 있다. 주석에 이유가 있다 — "두 런타임 사이에 공유 인프라를 만들 만큼 큰 함수가 아니다." 공유하려면 별도 패키지든 빌드 설정이든 구조가 하나 늘어난다. DRY가 항상 이기는 규칙은 아니라는 것을, 근거와 함께 남겨 둔 사례다.
| 항목 | 필요한 것 | 메모 |
|---|---|---|
| 필수 소프트웨어 | Docker + Compose | Node를 따로 깔 필요 없음(빌드도 컨테이너 안에서) |
| 아키텍처 | amd64 · arm64 | 사전 빌드 이미지가 둘 다 제공(라즈베리파이 OK) |
| 메모리 | 512MB 정도면 충분할 것으로 보임 | DB 서버가 없어 상주 메모리가 작다(공식 수치 없음 — 추정) |
| 디스크 | 약 200MB + 기록 | 미디어 137MB가 대부분. 기록은 텍스트라 사실상 무시 |
| 첫 실행 | 약 140MB 내려받기 | 운동 사진·GIF. 두 번째부터는 건너뜀 |
| 포트 | 기본 8080 (WEB_PORT) | API 3000은 컨테이너 내부 |
패스키는 안전한 주소(HTTPS)에서만 동작하고, localhost만 예외다. 집 안 IP(http://192.168.x.x:8080)로 폰에서 열면 로그인 자체가 불가능하다. 도메인 하나와 Caddy·Nginx Proxy Manager·Cloudflare Tunnel 같은 인증서 자동화 수단을 앞에 세우고, .env의 RP_ID·ORIGIN을 그 주소로 맞춰야 한다.
즉 이 앱의 진입 장벽은 앱이 아니라 그 앞단에 있다. 도커는 한 줄인데, HTTPS를 붙이는 게 처음이라면 그쪽에서 시간이 더 걸린다.
| 변수 | 기본값 | 하는 일 |
|---|---|---|
RP_ID | localhost | 패스키를 묶을 호스트명. 주소와 정확히 일치해야 |
ORIGIN | http://localhost:8080 | 전체 주소. https면 쿠키에 Secure가 붙는다 |
RP_NAME | openGym | 패스키 등록 창에 뜨는 이름 |
WEB_PORT | 8080 | 호스트에 열 포트 |
DATA_DIR | /data | JSON 저장 위치(호스트 ./data에 마운트) |
PORT | 3000 | API 내부 포트 |
ADMIN_UIDS | (없음) | 관리자 UID 목록, 콤마 구분. 비우면 관리자 없음 |
INVITE_ONLY | 꺼짐 | 초대 코드가 있어야 가입 가능 |
SESSION_DAYS | 90 | 로그인 유지 기간 |
VAPID_SUBJECT | ORIGIN 또는 mailto: | 푸시 규격상 필요 |
.env.example 파일이 없다README와 문서는 cp .env.example .env로 시작하라고 하는데, 사본에는 그 파일이 없다(.env* 검색 결과 0건). 사본을 뜨는 과정에서 빠졌거나 원래 .gitignore에 걸려 있었을 수 있다(추정). 그러니 위 표를 보고 .env를 직접 쓰는 게 빠르다. 최소한 RP_ID·ORIGIN 두 줄만 맞으면 뜬다.
frontend/ios/는 비어 있어서 후자는 손이 더 갈 것으로 보인다../data/
secret 서명 키 ← 지우면 전원 로그아웃(재로그인은 가능)
db.json 계정 · 패스키 공개키 · 푸시 구독 · 초대코드
state-<uid>.json 사람마다 하나. 계획 · 운동기록 · 체중 · 설정
vapid.json 푸시 키 쌍
→ 이 폴더만 복사하면 백업 끝. DB 덤프 명령 같은 건 없다.
패스키의 개인키는 서버에 없다. 폰의 보안 칩(또는 비밀번호 관리자) 안에 있고, db.json에는 공개키만 있다. 서버 파일이 통째로 유출돼도 로그인할 수는 없다. 물론 운동 기록은 평문이니 그건 별개로 보호해야 한다.
docker compose up -d → http://localhost:8080. 프로필을 만들면 브라우저가 지문·PIN·비밀번호 관리자 중 하나를 물어본다. 비밀번호를 한 번도 입력하지 않고 계정이 만들어지는 경험을 먼저 해 보는 게 목적이다.
확인해 볼 것: 개발자 도구 → Application → Cookies에서 세션 쿠키의 모양이 uid:만료:버전.서명인지. 그리고 ./data/db.json을 열어 비밀번호 필드가 없다는 것을 눈으로 확인.
cd frontend && npm install && npm test로 테스트를 돌린다. progression.test.js 452줄을 읽으면 각 정책이 어떤 상황에서 무엇을 돌려주는지가 표처럼 정리돼 있다.
그다음 POLICIES에 'wave'(3주 주기로 무게를 올렸다 되돌리는 파동식) 같은 정책을 하나 추가해 본다. 테스트를 먼저 쓰고 구현하는 순서로 하면 이 저장소가 왜 이런 구조인지가 몸으로 이해된다. why 배열도 잊지 말 것 — 이유를 못 대는 정책은 이 앱의 규칙에 어긋난다.
onerm.js에 Wathan 공식(w * 100 / (48.8 + 53.8 * Math.exp(-0.075 * r)))을 추가한다. 그리고 1~20회 구간에서 네 공식의 값이 얼마나 벌어지는지 표로 뽑아 본다.
목표는 공식 추가가 아니라 REP_CAP = 12가 임의의 숫자가 아니라는 걸 데이터로 보는 것이다. 새 공식을 넣었을 때 상한을 조정해야 하는지도 판단해 보자. 도메인 상수에 근거를 붙이는 훈련이다.
Node 22에 내장된 node:sqlite로 db.json과 state-<uid>.json을 대체한다. 의존성을 늘리지 않고 할 수 있다.
여기서 진짜로 배우는 건 무엇을 잃고 무엇을 얻는가다. 얻는 것 — 부분 갱신이 가능해져 두 기기 동시 사용이 덜 위험해진다. 잃는 것 — "폴더를 복사하면 백업 끝"이라는 단순함, 그리고 파일을 cat으로 열어 보는 투명함. PUT /api/data가 상태를 통째로 덮어쓰는 구조라, API 모양까지 손봐야 한다는 것도 곧 알게 된다.
같은 계정으로 브라우저 두 개를 열고, 양쪽에서 서로 다른 운동을 기록한 뒤 나중에 동기화되는 쪽이 앞의 기록을 덮어쓰는지 확인한다. 재현이 첫 단계다.
그다음 대응을 설계한다 — 상태에 버전 번호를 두고 PUT /api/data가 버전 불일치를 409로 거절하게 할 수도 있고(낙관적 잠금), 운동 기록을 배열 병합이 가능한 형태로 바꿀 수도 있다. 어느 쪽이든 "어떤 충돌은 자동으로 못 푼다"는 결론에 도달하게 되는데, 그 지점에서 원저자가 왜 state.active를 아예 동기화에서 뺐는지가 이해된다.
http 모듈: 요청·응답 스트림, 헤더, 스트리밍 본문 읽기SameSite·HttpOnly·Secure 세 플래그가 각각 막는 공격server.js를 읽고 엔드포인트 목록과 인증 필요 여부를 표로 정리RP_ID·origin·챌린지가 각각 무엇을 막는가(피싱 방어의 원리)residentKey)과 아이디 없는 로그인localStorage 지속화, 디바운스 동기화, 더티 플래그manifest.json, 홈 화면 추가BUILDPLATFORM과 멀티 아키텍처Intl.DateTimeFormat)이 저장소는 얇아서 밑바닥이 보이는 게 장점이다. 그러니 프레임워크에 가려지지 않는 부분(서버·인증)부터 보고, 그다음에 프론트, 마지막에 배포로 가는 게 이득이 크다. 반대로 화면 코드부터 읽으면 여느 React 앱과 다를 게 없어 보인다.
| 용어 | 뜻 |
|---|---|
| WebAuthn | 비밀번호 없이 기기의 개인키 서명으로 로그인하는 웹 표준 |
| 패스키 (passkey) | WebAuthn 자격증명을 사용자 눈높이로 부르는 이름. 지문·얼굴로 여는 열쇠 |
| RP_ID | 패스키가 묶이는 호스트명. 이게 어긋나면 브라우저가 로그인을 거부한다 |
| discoverable credential | 아이디를 먼저 안 물어도 기기가 알아서 해당 사이트 열쇠를 찾아 주는 방식 |
| 챌린지 (challenge) | 서버가 매번 새로 만드는 임의값. 예전 서명을 재사용하는 공격을 막는다 |
| HMAC | 비밀 키로 만든 데이터 지문. 위조를 막아 주지만 내용을 숨기지는 않는다 |
| 세션 버전 (sv) | 사용자 레코드의 숫자. 1 올리면 이미 발급된 쿠키가 전부 무효 |
| VAPID | 웹 푸시에서 "이 서버가 보낸 알림이 맞다"를 증명하는 키 쌍 규격 |
| 용어 | 뜻 |
|---|---|
| 자가호스팅 | 서비스에 가입하는 대신 같은 소프트웨어를 내 서버에 직접 설치해 쓰는 것 |
| 원자적 쓰기 | 임시 파일에 다 쓴 뒤 이름을 바꿔치기. 중간에 죽어도 파일이 반쯤 남지 않는다 |
| 멱등 (idempotent) | 여러 번 실행해도 결과가 같은 것. 미디어 내려받기가 이미 있으면 건너뛰는 게 예 |
| 리버스 프록시 | 앞에 서서 요청을 받아 뒤쪽 서버로 넘기는 중계자. 여기선 nginx |
| 단일 origin | 프론트와 API를 같은 주소로 서빙하는 구성. 패스키가 요구한다 |
| SPA 폴백 | 어떤 경로로 들어와도 index.html을 주고 라우팅은 브라우저가 하게 하는 설정 |
| 멀티스테이지 빌드 | 빌드용 이미지와 실행용 이미지를 나눠 최종 이미지를 가볍게 만드는 도커 기법 |
| BUILDPLATFORM | "빌드하는 기계의 아키텍처". 크로스 빌드 시 네이티브 도구가 깨지는 걸 피하는 데 쓴다 |
| 사이드로딩 | 스토어를 거치지 않고 APK 파일로 앱을 직접 설치하는 것 |
| 용어 | 뜻 |
|---|---|
| PWA | 홈 화면에 설치되고 오프라인에서도 열리는 웹 앱 |
| 서비스 워커 | 페이지와 별개로 도는 스크립트. 캐시·푸시 알림을 맡는다 |
| cache-first | 캐시에 있으면 그걸 쓰고 없을 때만 네트워크. 안 바뀌는 이미지에 적합 |
| Wake Lock | 화면이 자동으로 꺼지지 않게 잡아 두는 API. 탭을 벗어나면 자동 해제된다 |
| 디바운스 | 연달아 들어오는 요청을 마지막 것 하나로 묶는 기법. 여기선 1.5초 |
| 낙관적 잠금 | 버전 번호를 비교해 "그 사이 남이 고쳤으면" 거절하는 충돌 방지법 |
| HashRouter | 주소의 # 뒤를 경로로 쓰는 라우터. 서버 설정 없이 정적 배포에서 동작 |
| 코드 스플리팅 | 번들을 조각내 필요할 때만 받는 것. 여기선 언어팩·데모 시드에 적용 |
| 용어 | 뜻 |
|---|---|
| 1RM | 딱 한 번 들 수 있는 최대 무게. 위험해서 보통 공식으로 추정한다 |
| Epley · Brzycki · Lombardi | 1RM 추정 공식 세 가지. 반복 횟수가 커질수록 서로 벌어진다 |
| AMRAP | "가능한 만큼 반복". Greyskull LP의 마지막 세트가 이것 |
| 선형 증량 (linear progression) | 성공할 때마다 정해진 만큼 무게를 올리는 가장 단순한 방식 |
| Greyskull LP | 마지막 세트를 AMRAP으로 하고, 크게 넘기면 증량 폭을 2배로 하는 초보자 프로그램 |
| 더블 프로그레션 | 횟수를 범위 위쪽까지 채운 다음에야 무게를 올리는 방식 |
| 디로드 (deload) | 정체가 반복되면 무게를 일부러 낮춰(여기선 10%) 다시 올라오게 하는 것 |
| 정체 (stall) | 목표 횟수를 채우지 못한 상태. 정책마다 몇 번 쌓이면 디로드로 간다 |
| RIR | Reps In Reserve — 그 세트에서 몇 개 더 할 수 있었나 |
| RPE | 같은 체감을 10점 만점으로 표현한 것. RPE = 10 − RIR |
| 슈퍼세트 | 두 운동을 쉬지 않고 붙여서 하고, 그 쌍이 끝난 뒤에만 쉬는 방식 |
| 편측 운동 (per side) | 런지처럼 좌우를 따로 하는 동작. 목표 횟수가 2씩 움직인다 |
github.com/DuarteSantos8/openGym과 그 아래 Discussions·Issues·Releases, 그리고 README의 별 배지·다운로드 링크(opengym.duarte-santos.ch)는 원본이 사라진 지금 살아 있다고 보장할 수 없다. 코드를 보려면 아래 사본을 쓰는 수밖에 없다.
api/server.js — 554줄. 서버 전체frontend/src/lib/progression.js + progression.test.js — 도메인 규칙과 그 근거frontend/src/store/useStore.js — 로컬 우선 동기화docker-compose.yml · nginx.conf — 세 컨테이너를 한 주소로frontend/src/lib/i18n.js · wakelock.js — 라이브러리 없이 푸는 작은 문제 둘CHANGELOG.md — 원본 이력이 남은 유일한 파일docs/SELF_HOSTING.md — HTTPS·RP_ID 설정의 실제 함정http 문서 — 프레임워크가 감춰 주던 것BUILDPLATFORM 함정의 배경