5분 AI.
TrendShift 딥다이브 · 2026-08-24 · Daily #6

openGym 딥다이브
— 데이터베이스도 프레임워크도 없이, 파일 몇 개로 굴러가는 자가호스팅 헬스 기록 앱

openGymdocker compose up 한 줄로 내 서버에 올리는 헬스·체중 기록 앱이다. 계정도 구독료도 광고도 없고, 로그인은 비밀번호 대신 패스키(지문·얼굴)로 한다. 그런데 코드를 열어 보면 진짜 볼거리는 기능 목록이 아니다 — 서버가 프레임워크 없는 Node 파일 하나(554줄)이고, 데이터베이스 대신 JSON 파일 몇 개를 쓰며, "다음 세트에 몇 kg를 들지"를 정하는 규칙 전체가 테스트 붙은 순수 함수로 따로 떨어져 있다.
(v1.2.4 · AGPL-3.0 · React 19 + Vite 8 + Zustand 5 / Node 순정 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개는 원본의 인기와 아무 관계가 없다.

목차
  1. 프로젝트 한 줄 요약
  2. 왜 지금 주목받는가
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 분석
  5. 디렉토리 구조 해부
  6. 학습 포인트
  7. 하드웨어 · 시스템 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 학습 로드맵
  10. 핵심 키워드 사전
  11. 참고 링크

1프로젝트 한 줄 요약

"헬스 기록 앱을, 남의 서버가 아니라 내 서버에 — 그것도 DB 없이"

openGym이 하는 일은 단순하다. 요일별 운동 계획을 짜고, 운동할 때 세트마다 무게와 횟수를 찍고, 체중을 기록하고, 그래프로 본다. Hevy·Strong·FitNotes 같은 앱이 이미 하는 일이다.

다른 점은 어디서 도는가다. 이 앱은 도커 컨테이너 세 개로 내 서버(집에 있는 라즈베리파이든 클라우드 VM이든)에서 돌고, 기록은 그 서버의 ./data 폴더 안 JSON 파일로 남는다. 회사가 망해도, 요금제가 바뀌어도, 내 폴더는 내 것이다.

그리고 여기서 기술적으로 흥미로운 선택들이 시작된다. 보통 이런 앱을 만들면 Express로 API를 짜고 PostgreSQL을 붙이지만, openGym은 둘 다 쓰지 않는다. 서버는 Node에 기본으로 들어 있는 http 모듈만 쓴 파일 하나고, 데이터베이스 자리는 JSON 파일이 대신한다. 서버가 실행 시점에 필요로 하는 외부 패키지는 단 두 개다 — 패스키 검증용과 푸시 알림용.

용어
자가호스팅 (self-hosting)
남이 운영하는 서비스에 가입하는 대신, 같은 기능의 소프트웨어를 내가 관리하는 컴퓨터에 직접 설치해 쓰는 것. 가입·구독이 없고 데이터가 내 손에 남는 대신, 서버를 켜 두고 백업하고 업데이트하는 일이 내 몫이 된다.
한 문장 비유

"헬스장 회원권 대신, 집 창고에 바벨을 들여놓는 쪽"

회원권(SaaS)은 편하다. 시설도 관리도 남이 한다. 대신 관두면 아무것도 안 남고, 가격은 저쪽이 정한다.

창고에 바벨을 들이면(자가호스팅) 청소도 정리도 내가 해야 한다. 대신 없어지지 않는다. openGym이 판 것은 이 거래다 — 그리고 "청소 부담"을 줄이려고 부품 수를 극단적으로 깎았다. DB 서버가 없으니 백업할 것도 ./data 폴더 하나뿐이다.

2왜 지금 주목받는가

자가호스팅 목록에서 오래 비어 있던 칸을, 부품을 극단적으로 줄여서 채웠다

① 자가호스팅 진영에 헬스 앱 칸이 비어 있었다

메모(Joplin), 사진(Immich), 미디어(Jellyfin), 가계부(Firefly III)처럼 상용 SaaS를 대체하는 자가호스팅 앱은 분야마다 대표 주자가 있다. 그런데 운동 기록은 오래 비어 있었다. 이 분야 앱은 대부분 폰 전용 상용 앱이고, 기록이 그 회사 서버에 쌓인다.

비교 대상성격openGym과의 차이
Hevy · Strong상용 SaaS · 폰 앱기록이 회사 서버에. 고급 기능은 구독제
FitNotes무료 · 안드로이드 로컬 저장기기 간 동기화 없음, 웹 UI 없음
wger자가호스팅 · Django기능은 더 넓지만 PostgreSQL 등 부품이 많고 무겁다
openGym자가호스팅 · PWA + APKDB 서버 없음 · 로그인은 패스키 · 컨테이너 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은 비밀번호 항목 자체를 만들지 않았다. 로그인은 패스키 — 폰의 지문·얼굴 인식으로 서명하고, 서버는 공개키만 들고 있다가 서명을 검증한다.

용어
패스키 / WebAuthn
비밀번호 대신 기기 안에 저장된 개인키로 서명해 로그인하는 웹 표준. 개인키는 폰의 보안 칩(또는 비밀번호 관리자)을 절대 벗어나지 않고, 서버에는 공개키만 저장된다. 서버가 털려도 훔쳐 갈 비밀번호가 없다. 대신 "어느 사이트용 열쇠인가"를 호스트명(RP_ID)으로 엄격하게 묶기 때문에, 주소 구성이 조금만 어긋나도 로그인이 안 된다(뒤에서 다룬다).

④ "왜 이 무게인가"를 앱이 설명한다

대부분의 기록 앱은 지난 기록을 보여 주는 데서 끝난다. openGym은 다음 세션을 열면 무게가 이미 채워져 있고, 그 옆에 이유가 적혀 있다. "지난번에 전 세트를 다 채웠으니 2.5kg 올립니다", "세 번 정체됐으니 10% 내립니다" 같은 문장이다.

이게 가능한 건 진행 규칙(선형 증량, Greyskull LP, 더블 프로그레션, 시간 증가)이 UI에 흩어져 있지 않고 frontend/src/lib/progression.js의 순수 함수 한 곳에 모여 있기 때문이다. 함수가 돌려주는 값에 why 필드가 아예 들어 있다. 4절에서 코드를 직접 본다.

⑤ 그리고 원본이 사라졌다

냉정하게 보면, 이번 트렌딩에는 저장소가 없어졌다는 사실 자체도 섞여 있다. 목록에 오른 주소는 사라진 저장소를 보존하는 계정의 사본이고, 이런 항목은 "이거 왜 없어졌지?"라는 호기심으로 클릭 수를 모은다. 프로젝트의 기술적 가치와는 별개의 동력이다 — 위 ①~④가 진짜 볼거리고, ⑤는 지금 이 주에만 유효한 이유다.

3기술 스택 전체 지도

백엔드 · 프론트엔드 · 모바일 · 인프라를 한 장에

백엔드 — api/server.js 554줄이 전부다

항목선택메모
런타임Node 22 (alpine)"type": "module" — ESM
HTTP순정 node:httpExpress·Fastify 없음
인증@simplewebauthn/server 13.x패스키 등록·검증만 담당
세션자체 HMAC-SHA256 서명 쿠키JWT 라이브러리조차 안 씀
저장소JSON 파일 (DATA_DIR)DB 서버 없음. 원자적 쓰기 직접 구현
푸시web-push (VAPID)키는 첫 실행에 자동 생성
스케줄러setInterval / setTimeoutcron·큐 없음

프론트엔드 — React 19 SPA

항목선택메모
UIReact 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개
PWApublic/sw.js 41줄Workbox 없이 직접 작성

모바일 — 같은 코드로 서버 없는 앱을 만든다

Capacitor 7로 같은 프론트엔드를 안드로이드 앱(APK)으로 감싼다. 그런데 단순 포장이 아니다. VITE_MOBILE=1로 빌드하면 동작 방식이 세 군데 갈린다.

자가호스팅(웹)모바일 앱
저장서버에 동기화@capacitor/filesystem으로 폰 안에만
알림서버 Web Push@capacitor/local-notifications
운동 이미지서버가 로컬 파일 서빙(약 140MB)jsDelivr CDN에서 로드(앱 용량 절감)

모바일에서 굳이 파일시스템에 한 번 더 저장하는 이유는 iOS WebView가 저장 공간이 부족할 때 localStorage를 지울 수 있기 때문이다. "브라우저 저장소는 언제든 날아갈 수 있다"는 전제를 그대로 코드로 옮긴 셈이다.

인프라 — 컨테이너 3개

서비스이미지역할
mediaalpine/git운동 이미지·GIF 약 140MB를 한 번만 받아 놓고 종료(restart: no)
apinode:22-alpine3000 포트. ./data 볼륨 마운트
webnginx:alpine프론트 정적 파일 서빙 + /api 프록시. 기본 8080

배포 옵션으로 render.yaml도 들어 있지만 정의된 서비스는 웹 하나뿐이다. API까지 한 번에 올라가지는 않으니, Render로 올릴 생각이면 API는 따로 배포해야 할 것으로 보인다(저장소에 설명이 없어 추정).

데이터셋 — 운동 1,324개는 어디서 오나

운동 정보는 외부 저장소 hasaneyldrm/exercises-dataset에서 온다. 다만 글과 그림을 갈라 놓았다.

한 항목의 실제 모양은 이렇다. 키 이름을 두 글자로 줄인 게 눈에 띈다 — 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"}

4아키텍처 심화 분석

요청이 어디로 흐르고, 왜 그렇게 흐르는가
브라우저 / 폰 | https://gym.example.com | ┌─────────────────────▼──────────────────────┐ │ web (nginx) ← 여기가 "단 하나의 주소" │ │ / → 프론트 정적 파일 │ │ /api/ → api:3000 으로 프록시 │ │ /img /gif → 운동 이미지 (30일 캐시) │ └─────────────────────┬──────────────────────┘ │ (컨테이너 내부망) ┌─────────────────────▼──────────────────────┐ │ api (node:http, 프레임워크 없음) │ │ routes["POST /api/login/verify"] ... │ │ ├ WebAuthn 검증 (@simplewebauthn) │ │ ├ 서명 쿠키 (HMAC-SHA256) │ │ ├ Web Push (web-push / VAPID) │ │ └ 인메모리: 챌린지(5분) · 접속표시(70초) │ └─────────────────────┬──────────────────────┘ │ 원자적 쓰기(tmp → rename) ┌─────────────────────▼──────────────────────┐ │ ./data (호스트 폴더 = 백업 대상 전부) │ │ secret 서명 키 │ │ db.json 계정 · 공개키 · 구독 │ │ state-<uid>.json 개인 기록 (1인 1파일) │ │ vapid.json 푸시 키 쌍 │ └────────────────────────────────────────────┘

설계 패턴 ① — 주소를 하나로 묶은 건 취향이 아니라 패스키 때문

프론트와 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이 어긋나면 브라우저가 그냥 거부한다. 그래서 .envRP_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은 쪼개지지 않는 한 번의 동작이라, 결과는 "옛 파일" 아니면 "새 파일"뿐이고 그 중간은 없다.

대신 이 구조는 동시 접속이 많아지면 무너진다. 사용자 파일 하나를 통째로 덮어쓰기 때문에, 같은 계정을 두 기기에서 동시에 쓰면 나중 쓰기가 앞 쓰기를 지운다. 가족 몇 명이 쓰는 규모라서 성립하는 선택이지, 일반적인 정답은 아니다.

설계 패턴 ③ — 로컬 우선, 서버는 나중에 따라온다

운동 중에 지하 헬스장에서 신호가 끊기는 건 흔한 일이다. 그래서 상태 관리는 서버가 아니라 브라우저 저장소가 주인이다.

세트 기록 입력 → Zustand 상태 갱신 (화면 즉시 반영) → localStorage 'gym_state_v1' 저장 + 'gym_dirty' 플래그 → 1.5초 조용해지면 PUT /api/data 로 동기화 실패해도 dirty 플래그가 남아 다음에 다시 시도

여기서 한 줄이 특히 흥미롭다. 서버로 보내기 직전에 진행 중인 운동 세션을 일부러 지운다.

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개 언어 어디서든 같은 이유를 자기 언어로 말할 수 있다. 로직과 문구를 섞지 않은 것이다.

용어
Greyskull LP · 더블 프로그레션 · 디로드
Greyskull LP는 마지막 세트를 "가능한 만큼(AMRAP)" 하는 초보자용 증량 프로그램이다. openGym은 여기에 규칙 하나를 코드로 박아 뒀다 — 마지막 세트가 목표의 2배를 넘기면 증량 폭을 2배로. 더블 프로그레션은 무게를 올리기 전에 횟수부터 정해진 범위 위쪽까지 채우는 방식이고, 디로드는 몇 번 실패하면 무게를 일부러 낮춰(여기선 10%) 다시 올라오게 하는 것이다.

설계 패턴 ⑥ — 모르는 건 대답하지 않는다 (1RM 추정)

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를 저장이 아니라 조회에서 변환한다

세트가 얼마나 힘들었는지 적는 척도가 두 가지다. 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로 적은 기록이 한 파일에 섞여도 아무 문제가 없는 이유다.

설계 패턴 ⑨ — 알림 검사 주기가 60초가 아니라 10초인 이유

"오늘 운동 예정인데 아직 기록이 없으면 알려 준다"는 기능을 setInterval로 돌리는데, 주기가 10초다. 주석에 이유가 적혀 있다 — 1분 주기는 정각에 맞춰 도는 게 아니라서, 목표한 분을 최대 59초까지 놓칠 수 있다. 10초로 줄이면 오차가 9초 안으로 들어온다. 각 사용자의 "지금"은 Intl.DateTimeFormat으로 그 사람의 시간대 기준으로 계산한다.

휴식 타이머 알림은 다르다. 클라이언트가 setTimeout으로 예약을 걸어 두었다가, 탭이 백그라운드로 가서 스스로 타이머를 못 돌릴 때만 서버 푸시가 대신 울린다.

설계 패턴 ⑩ — 화면 꺼짐 방지는 "의도"를 따로 기억해야 한다

세트 사이에 폰이 꺼져 잠금 화면으로 돌아가면 기록하기가 번거롭다. Wake Lock API로 화면을 켜 두는데, 문제는 브라우저가 탭 전환·화면 잠금 때마다 이 잠금을 자동으로 풀어 버린다는 점이다. 한 번 요청하고 끝내면 안 된다.

// App.jsx — 라우트가 아니라 "운동이 진행 중인가"에 묶는다
useWakeLock(!!S.active && S.keepAwake !== false)

그래서 lib/wakelock.jswanted라는 플래그로 사용자의 의도를 따로 들고 있다가, 화면이 다시 보일 때마다 잠금을 재요청한다. 그리고 이 조건이 화면(라우트)이 아니라 "운동 중인가"에 걸려 있어서, 기록 도중 통계 탭을 잠깐 봐도 화면이 꺼지지 않는다. iOS 저전력 모드처럼 거부당하는 상황에서는 조용히 넘어간다 — 사용자가 할 수 있는 게 없기 때문이다.

5디렉토리 구조 해부

어디부터 열어야 하는가
openGym/ ├── api/ ← 서버. 열어 볼 파일은 사실상 하나 │ ├── server.js 554줄. 라우팅·패스키·쿠키·푸시·관리자 전부 │ ├── package.json 런타임 의존성 2개 │ └── Dockerfile ├── frontend/ ← 앱 본체 (약 14,000줄) │ ├── src/ │ │ ├── App.jsx 라우트 정의 + Wake Lock 연결 │ │ ├── views/ 화면 10개 (Home/Plan/Workout/Stats/…/Admin) │ │ ├── components/ BodyMap · Heatmap · LineChart · RestTimer … │ │ ├── store/ │ │ │ ├── useStore.js 193줄. 데이터 + 로컬우선 동기화 │ │ │ └── useUI.js 131줄. 모달·시트 등 화면 상태 │ │ ├── lib/ ★ 순수 함수 — 여기가 이 저장소의 핵심 │ │ │ ├── progression.js 증량 규칙 4종 + 맨몸 처리 │ │ │ ├── onerm.js 1RM 추정 (공식 3개 + 상한 12) │ │ │ ├── effort.js RIR/RPE 정규화 │ │ │ ├── history.js 기록 해석 · 모드 판정 │ │ │ ├── i18n.js 56줄짜리 자체 번역 엔진 │ │ │ ├── wakelock.js 화면 꺼짐 방지 재획득 │ │ │ ├── mobile.js Capacitor 분기 │ │ │ ├── exercises.js 이미지 경로 계산 │ │ │ └── exercises-data.js 888KB — 운동 1,324개 │ │ ├── instr/ 영어 외 운동 설명 (언어별 분리) │ │ ├── locales/ UI 번역 12개 언어 │ │ └── *.test.js Vitest 7개 파일 │ ├── android/ Capacitor 안드로이드 프로젝트 │ ├── ios/ (빈 디렉토리 — Xcode 프로젝트 미포함) │ ├── public/sw.js 41줄 서비스 워커 │ ├── scripts/check-locales.mjs 번역 키 누락 검사 │ └── capacitor.config.json ├── media/ 운동 사진·GIF 약 137MB (저장소에 커밋돼 있음) ├── web/nginx.conf 프록시 설정 (루트 nginx.conf와 내용 동일) ├── scripts/ fetch-media.sh · build-instructions.mjs ├── website/ 소개 페이지 정적 파일 ├── docs/ SELF_HOSTING.md · MOBILE.md ├── docker-compose.yml · Dockerfile · render.yaml └── CHANGELOG.md · LICENSE(AGPL-3.0) · SECURITY.md · NOTICE.md

규모 감각

구분수치
코드(JS/JSX)약 14,500줄 — 프론트 13,968 + 서버 554
CSS약 1,088줄
테스트7개 파일 약 1,546줄 (코드 대비 약 11%)
파일 수약 226개 (미디어 제외)
미디어약 137MB (사진 1,324 + GIF 1,324)
구조에서 읽히는 것

lib/에 테스트 파일이 바로 옆에 붙어 있다는 게 이 저장소의 성격을 말해 준다. 화면 코드에는 테스트가 없다. 테스트가 붙은 건 "무게를 얼마 올릴지", "1RM이 얼마인지" 같은 틀리면 사용자가 다치는 계산뿐이다. 테스트를 어디에 쓸지 고민될 때 참고할 만한 배분이다.

읽기 순서 추천

  1. api/server.js — 554줄이라 한 번에 읽힌다. 웹 서버가 실제로 무엇을 하는지가 압축돼 있다.
  2. frontend/src/lib/progression.js + progression.test.js — 도메인 규칙을 순수 함수로 짜는 법.
  3. frontend/src/store/useStore.js — 로컬 우선 동기화 193줄.
  4. docker-compose.yml + nginx.conf — 세 컨테이너가 어떻게 한 주소로 합쳐지는지.
  5. frontend/src/lib/i18n.js — 라이브러리 없이 번역을 붙이는 최소 구현.

6학습 포인트

이 저장소에서 실제로 훔쳐 갈 수 있는 것들

① 프레임워크 없는 Node 서버가 실제로 어떻게 생겼나

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)가 없고, 요청 한도 제한도 없다. 저장소는 후자를 리버스 프록시의 몫으로 넘긴다고 명시한다.

② 패스키 로그인의 전체 흐름

가입 로그인 POST /api/register/options POST /api/login/options 서버: 랜덤 챌린지 생성 서버: 챌린지 생성 메모리 Map에 5분간 보관 allowCredentials: [] (cid = randomBytes(16)) → "이 기기에 저장된 열쇠 아무거나" ↓ ↓ 브라우저: 지문/얼굴 → 개인키로 서명 브라우저: 지문/얼굴 → 서명 ↓ ↓ POST /api/register/verify POST /api/login/verify verifyRegistrationResponse() verifyAuthenticationResponse() → 공개키를 db.json에 저장 → 서명 검증 + counter 갱신 → 서명 쿠키 발급 → 서명 쿠키 발급

등록 옵션에 residentKey: 'required'가 걸려 있다. 아이디를 먼저 입력받지 않고 "이 기기에 저장된 openGym 열쇠"를 바로 고르게 하는 설정이다. 자가호스팅 앱에서 가입 화면을 최소화하는 실용적인 선택이다.

③ 오프라인을 견디는 상태 관리

Zustand 자체는 작은 라이브러리지만, 여기서 배울 건 라이브러리 사용법이 아니라 동기화 규율이다.

④ 라이브러리 없이 번역 붙이기

lib/i18n.js는 56줄이다. 핵심은 두 가지 — Vite의 import.meta.glob으로 언어 파일을 자동으로 모으고, React의 useSyncExternalStore로 언어 변경을 화면에 전파한다. 영어는 번들에 들어가고 나머지 11개는 고를 때 받아온다. 그리고 번역 키가 빠지는 사고를 scripts/check-locales.mjs가 막는다 — 기준을 특정 파일이 아니라 모든 언어 파일 키의 합집합으로 잡아서, 새 키를 한 언어에만 넣어도 나머지가 걸린다.

⑤ 서비스 워커를 41줄로 쓰는 법

운동 사진·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를 진짜로 파싱해야 하는 이유

다른 앱에서 기록을 가져오는 기능에 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.jslib/history.js에 같은 일을 하는 작은 함수(effectiveRoutineId)가 두 번 쓰여 있다. 주석에 이유가 있다 — "두 런타임 사이에 공유 인프라를 만들 만큼 큰 함수가 아니다." 공유하려면 별도 패키지든 빌드 설정이든 구조가 하나 늘어난다. DRY가 항상 이기는 규칙은 아니라는 것을, 근거와 함께 남겨 둔 사례다.

7하드웨어 · 시스템 요구사항

라즈베리파이로 충분한가? — 대체로 그렇다

서버 쪽

항목필요한 것메모
필수 소프트웨어Docker + ComposeNode를 따로 깔 필요 없음(빌드도 컨테이너 안에서)
아키텍처amd64 · arm64사전 빌드 이미지가 둘 다 제공(라즈베리파이 OK)
메모리512MB 정도면 충분할 것으로 보임DB 서버가 없어 상주 메모리가 작다(공식 수치 없음 — 추정)
디스크약 200MB + 기록미디어 137MB가 대부분. 기록은 텍스트라 사실상 무시
첫 실행약 140MB 내려받기운동 사진·GIF. 두 번째부터는 건너뜀
포트기본 8080 (WEB_PORT)API 3000은 컨테이너 내부

가장 중요한 요구사항은 성능이 아니라 HTTPS다

사전 준비
폰에서 쓰려면 도메인 + 인증서가 사실상 필수

패스키는 안전한 주소(HTTPS)에서만 동작하고, localhost만 예외다. 집 안 IP(http://192.168.x.x:8080)로 폰에서 열면 로그인 자체가 불가능하다. 도메인 하나와 Caddy·Nginx Proxy Manager·Cloudflare Tunnel 같은 인증서 자동화 수단을 앞에 세우고, .envRP_ID·ORIGIN을 그 주소로 맞춰야 한다.

이 앱의 진입 장벽은 앱이 아니라 그 앞단에 있다. 도커는 한 줄인데, HTTPS를 붙이는 게 처음이라면 그쪽에서 시간이 더 걸린다.

환경 변수 전체

변수기본값하는 일
RP_IDlocalhost패스키를 묶을 호스트명. 주소와 정확히 일치해야
ORIGINhttp://localhost:8080전체 주소. https면 쿠키에 Secure가 붙는다
RP_NAMEopenGym패스키 등록 창에 뜨는 이름
WEB_PORT8080호스트에 열 포트
DATA_DIR/dataJSON 저장 위치(호스트 ./data에 마운트)
PORT3000API 내부 포트
ADMIN_UIDS(없음)관리자 UID 목록, 콤마 구분. 비우면 관리자 없음
INVITE_ONLY꺼짐초대 코드가 있어야 가입 가능
SESSION_DAYS90로그인 유지 기간
VAPID_SUBJECTORIGIN 또는 mailto:푸시 규격상 필요
사본에서 확인된 문제
.env.example 파일이 없다

README와 문서는 cp .env.example .env로 시작하라고 하는데, 사본에는 그 파일이 없다(.env* 검색 결과 0건). 사본을 뜨는 과정에서 빠졌거나 원래 .gitignore에 걸려 있었을 수 있다(추정). 그러니 위 표를 보고 .env를 직접 쓰는 게 빠르다. 최소한 RP_ID·ORIGIN 두 줄만 맞으면 뜬다.

클라이언트 쪽

백업 — 이게 전부다

./data/
  secret            서명 키   ← 지우면 전원 로그아웃(재로그인은 가능)
  db.json           계정 · 패스키 공개키 · 푸시 구독 · 초대코드
  state-<uid>.json  사람마다 하나. 계획 · 운동기록 · 체중 · 설정
  vapid.json        푸시 키 쌍

→ 이 폴더만 복사하면 백업 끝. DB 덤프 명령 같은 건 없다.

패스키의 개인키는 서버에 없다. 폰의 보안 칩(또는 비밀번호 관리자) 안에 있고, db.json에는 공개키만 있다. 서버 파일이 통째로 유출돼도 로그인할 수는 없다. 물론 운동 기록은 평문이니 그건 별개로 보호해야 한다.

8직접 해볼 수 있는 실습 과제

쉬운 것부터 — 각 과제가 어떤 개념을 건드리는지도 함께
과제 1난이도 ★☆☆☆☆

노트북에 띄우고 패스키로 로그인해 보기

docker compose up -dhttp://localhost:8080. 프로필을 만들면 브라우저가 지문·PIN·비밀번호 관리자 중 하나를 물어본다. 비밀번호를 한 번도 입력하지 않고 계정이 만들어지는 경험을 먼저 해 보는 게 목적이다.

확인해 볼 것: 개발자 도구 → Application → Cookies에서 세션 쿠키의 모양이 uid:만료:버전.서명인지. 그리고 ./data/db.json을 열어 비밀번호 필드가 없다는 것을 눈으로 확인.

과제 2난이도 ★★☆☆☆

증량 규칙 테스트를 읽고, 규칙 하나를 새로 추가하기

cd frontend && npm install && npm test로 테스트를 돌린다. progression.test.js 452줄을 읽으면 각 정책이 어떤 상황에서 무엇을 돌려주는지가 표처럼 정리돼 있다.

그다음 POLICIES'wave'(3주 주기로 무게를 올렸다 되돌리는 파동식) 같은 정책을 하나 추가해 본다. 테스트를 먼저 쓰고 구현하는 순서로 하면 이 저장소가 왜 이런 구조인지가 몸으로 이해된다. why 배열도 잊지 말 것 — 이유를 못 대는 정책은 이 앱의 규칙에 어긋난다.

과제 3난이도 ★★★☆☆

1RM 공식을 하나 더 넣고, 상한이 왜 12인지 직접 확인하기

onerm.js에 Wathan 공식(w * 100 / (48.8 + 53.8 * Math.exp(-0.075 * r)))을 추가한다. 그리고 1~20회 구간에서 네 공식의 값이 얼마나 벌어지는지 표로 뽑아 본다.

목표는 공식 추가가 아니라 REP_CAP = 12가 임의의 숫자가 아니라는 걸 데이터로 보는 것이다. 새 공식을 넣었을 때 상한을 조정해야 하는지도 판단해 보자. 도메인 상수에 근거를 붙이는 훈련이다.

과제 4난이도 ★★★★☆

JSON 파일 저장소를 SQLite로 바꿔 보기

Node 22에 내장된 node:sqlitedb.jsonstate-<uid>.json을 대체한다. 의존성을 늘리지 않고 할 수 있다.

여기서 진짜로 배우는 건 무엇을 잃고 무엇을 얻는가다. 얻는 것 — 부분 갱신이 가능해져 두 기기 동시 사용이 덜 위험해진다. 잃는 것 — "폴더를 복사하면 백업 끝"이라는 단순함, 그리고 파일을 cat으로 열어 보는 투명함. PUT /api/data가 상태를 통째로 덮어쓰는 구조라, API 모양까지 손봐야 한다는 것도 곧 알게 된다.

과제 5난이도 ★★★★★

동시 편집 충돌을 실제로 만들어 보고, 막아 보기

같은 계정으로 브라우저 두 개를 열고, 양쪽에서 서로 다른 운동을 기록한 뒤 나중에 동기화되는 쪽이 앞의 기록을 덮어쓰는지 확인한다. 재현이 첫 단계다.

그다음 대응을 설계한다 — 상태에 버전 번호를 두고 PUT /api/data가 버전 불일치를 409로 거절하게 할 수도 있고(낙관적 잠금), 운동 기록을 배열 병합이 가능한 형태로 바꿀 수도 있다. 어느 쪽이든 "어떤 충돌은 자동으로 못 푼다"는 결론에 도달하게 되는데, 그 지점에서 원저자가 왜 state.active를 아예 동기화에서 뺐는지가 이해된다.

9관련 기술 심화 학습 로드맵

4주 — 매주 이 저장소의 한 부분을 축으로

1주차 · 서버의 밑바닥

2주차 · 패스키(WebAuthn)

3주차 · 오프라인 우선 프론트엔드

4주차 · 배포와 도메인 모델링

순서를 이렇게 잡은 이유

이 저장소는 얇아서 밑바닥이 보이는 게 장점이다. 그러니 프레임워크에 가려지지 않는 부분(서버·인증)부터 보고, 그다음에 프론트, 마지막에 배포로 가는 게 이득이 크다. 반대로 화면 코드부터 읽으면 여느 React 앱과 다를 게 없어 보인다.

10핵심 키워드 사전

이 문서에 나온 말들을 한 줄씩

인증 · 보안

용어
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 · Lombardi1RM 추정 공식 세 가지. 반복 횟수가 커질수록 서로 벌어진다
AMRAP"가능한 만큼 반복". Greyskull LP의 마지막 세트가 이것
선형 증량 (linear progression)성공할 때마다 정해진 만큼 무게를 올리는 가장 단순한 방식
Greyskull LP마지막 세트를 AMRAP으로 하고, 크게 넘기면 증량 폭을 2배로 하는 초보자 프로그램
더블 프로그레션횟수를 범위 위쪽까지 채운 다음에야 무게를 올리는 방식
디로드 (deload)정체가 반복되면 무게를 일부러 낮춰(여기선 10%) 다시 올라오게 하는 것
정체 (stall)목표 횟수를 채우지 못한 상태. 정책마다 몇 번 쌓이면 디로드로 간다
RIRReps In Reserve — 그 세트에서 몇 개 더 할 수 있었나
RPE같은 체감을 10점 만점으로 표현한 것. RPE = 10 − RIR
슈퍼세트두 운동을 쉬지 않고 붙여서 하고, 그 쌍이 끝난 뒤에만 쉬는 방식
편측 운동 (per side)런지처럼 좌우를 따로 하는 동작. 목표 횟수가 2씩 움직인다

11참고 링크

단, 원본 저장소 링크는 지금 죽어 있다
링크 상태
공식 링크 대부분이 원본을 가리켜 404다

github.com/DuarteSantos8/openGym과 그 아래 Discussions·Issues·Releases, 그리고 README의 별 배지·다운로드 링크(opengym.duarte-santos.ch)는 원본이 사라진 지금 살아 있다고 보장할 수 없다. 코드를 보려면 아래 사본을 쓰는 수밖에 없다.

저장소

저장소 안에서 읽을 순서

  1. api/server.js — 554줄. 서버 전체
  2. frontend/src/lib/progression.js + progression.test.js — 도메인 규칙과 그 근거
  3. frontend/src/store/useStore.js — 로컬 우선 동기화
  4. docker-compose.yml · nginx.conf — 세 컨테이너를 한 주소로
  5. frontend/src/lib/i18n.js · wakelock.js — 라이브러리 없이 푸는 작은 문제 둘
  6. CHANGELOG.md — 원본 이력이 남은 유일한 파일
  7. docs/SELF_HOSTING.md — HTTPS·RP_ID 설정의 실제 함정

기술 문서