2026-08-13 PulseUp 작업일지입니다. 오늘은 5건의 작업을 진행했습니다.
심박 프로세스 정합화 1단계 — DB 스키마 + 백엔드 API
현상/기능
- 지점 심박계 등록/수정/삭제, 운동 전 사용자-심박계 배정, 미배정 심박계 임의 userId, 지점·사용자·운동일시 저장(master-detail) 요구사항에 맞게 수정하고 P2까지의 개선사항 반영
- 위와 동일 (요구사항 1)
- 위와 동일 (요구사항 3·4 + P0~P2)
- 위와 동일 (P1)
작업
- `workout_history_master.branch_id`(bigint) 추가 + 기존 데이터 backfill + 신규 INSERT 자동 채움 트리거 `trg_workout_history_master_branch_bi`를 만들었다(master INSERT 프로시저가 4곳이라 시그니처 변경 대신 트리거 1곳으로 처리). `heart_rate_data.slot_number` 추가로 Electron이 보내던 슬롯 번호 유실을 막았다. 지점 심박계 관리 프로시저는 `sp_UpdateBluetoothDevice`(신규), `sp_GetBluetoothDevices`(device_type 필터 추가), `sp_DeleteBluetoothDevices`·`sp_UpdateBluetoothDeviceConnection`(지점 스코프 파라미터 추가)로 재정의했다
- 기기 수정 `PUT /api/bluetooth/devices/:id`를 추가하고, 등록/수정/삭제에 `requireBranchAdminOrAbove`를 적용했다. 모든 라우트가 지점 스코프를 확정해 프로시저로 넘기므로 branch_admin은 자기 지점 기기만 조작할 수 있고, super_admin만 지점을 지정하거나 전체를 조회할 수 있다. zod 스키마로 요청 검증을 추가하고 프로시저의 `SQLSTATE 45000`을 400으로 변환했다
- 미매핑 심박계 데이터를 폐기하던 로직을 운동 소유자(`fallbackUserId`)로 저장하도록 바꾸고 `unmappedDeviceIds`를 응답에 담았다. `slot_number`를 UPSERT에 포함하고, 유니크 키 순 정렬 + 데드락/락타임아웃 재시도(3회)를 넣었다. 참가자 매핑 조회는 10초 TTL 캐시로 바꿔 배치마다 SELECT 하던 부하를 줄이고, 배정 변경 시 캐시를 무효화한다. 배치 동시성 가드 키를 `userId:masterId:instanceId` + TTL 20초로 바꿔 여러 PC가 같은 수업을 올릴 때 서로 429를 유발하던 문제를 없앴다. body의 `userId`가 토큰 사용자와 다르면 409(비재시도)로 거절해 로컬 큐의 타 계정 데이터 혼입을 막고, 정상 저장 로그는 10초 간격 샘플링으로 줄였다
- 커넥션 풀을 `connectionLimit` 20(기본), `queueLimit` 100(기본)으로 올리고 환경변수(`DB_POOL_CONNECTION_LIMIT`, `DB_POOL_QUEUE_LIMIT`)로 조정 가능하게 했다. 무제한 대기 큐 때문에 풀 고갈 시 요청이 무한정 매달리던 문제를 상한으로 막았다
- **DB** · (신규)
- **API** · `routes/` · `services/` · `schemas/`(신규)
- **API** · `services/` · `routes/` · `schemas/`
- **API** · `lib/`
심박 프로세스 정합화 2단계 — 웹 화면
현상/기능
- 지점에서 보유 심박계를 등록/수정/삭제할 수 있어야 한다 (요구사항 1)
- 위와 동일
- 운동시작 전에 사용자를 등록하고 등록된 사용자가 지점 보유 심박계를 연결한다 (요구사항 2)
작업
- `/admin/heart-rate-devices` 지점 심박계 관리 화면을 새로 만들었다. 목록 카드(미사용 포함 토글·새로고침·추가·삭제)와 등록/수정 폼 카드로 구성하고, 폼 모델·ANT+ ID 형식 검증은 로 분리했다. `ant-10771` 형식이 아니면 저장 전에 경고를 띄운다. 웹 API 클라이언트는 신규 수정 API와 `device_type` 필터를 지원하도록 `apiClient` 기반으로 재작성했고, 라우트는 `requiredRole="branch_admin"`으로 등록했다
- 메뉴가 DB 기반(`menus`)이므로 `/admin/heart-rate-devices` 메뉴를 지점관리자·최고관리자 각각에 재실행 안전하게 시드했다
- `workout_history_master_id`를 직접 타이핑하던 입력을 없애고, 수업 날짜에 저장된 운동 목록에서 선택하게 바꿨다(운동이 1개면 자동 선택). 심박계도 자유 입력에서 지점 보유 기기 선택으로 바꿔 기기명이 자동 채워지고, 이미 다른 회원에게 배정된 기기는 선택 목록에서 비활성화된다. 기기 목록·운동 목록 조회는 `useHeartRateAssignmentOptions` 훅으로 분리했고, `AttendanceDialog`가 슬롯 시작일에서 수업 날짜를 넘긴다
- **Web** · `pages/Admin/HeartRateDevices/*`(신규) · `services/`
- **DB** · `database/migrations/20260813_heart_rate_device_menu_seed.sql`(신규)
- **Web** · `pages/Booking/components/` · (신규)
심박 프로세스 정합화 3단계 — Electron 심박 수집 안정화
현상/기능
- 운동시작하면 심박정보를 저장한다 (요구사항 4) + 위의 P0~P1 수정
- 위와 동일 (P0)
- 위와 동일 (P1)
- 위와 동일
작업
- `main/heart-rate-modules/` · (신규)
- 운동이 끝나면 `activePlaySession`이 `null`이 되지 않고 `status`만 `ready`로 바뀌는데, 수집 게이트가 세션 존재만 확인해서 종료 후에도 계속 버퍼링·업로드하던 문제를 고쳤다. 이제 `status`가 `playing`/`paused`일 때만 저장 대상으로 삼고, `masterId`가 없으면 저장하지 않는다. 배치는 수집 시점의 세션(userId/masterId)을 스냅샷으로 들고 다니므로 종료 직후 flush 되더라도 원래 운동에 저장되고, 운동이 바뀌면 이전 배치를 먼저 보내 한 요청에 두 운동이 섞이지 않는다. 실패 큐 파일 처리는 `HeartRateQueueFile`로 분리하고(600줄 규칙) 전체 파일을 읽고 다시 쓰던 O(N²) 방식을 앞쪽(FIFO) prefix 소비 방식으로 바꿨다. 재시도해도 통과할 수 없는 4xx(예: 409 사용자 불일치)는 `.rejected` 파일로 격리해 큐 head가 영구히 막히는 문제를 없앴다. 업로드가 밀리면(`MAX_PENDING_UPLOADS` 초과) 네트워크를 기다리지 않고 즉시 큐에 적재해 프로미스 체인이 무한히 늘어나지 않게 했다. 진단용 `getQueueStatus`의 `fs.statSync` 동기 I/O를 캐시 조회로 바꿨다
- `main/` · `main/`
- `app.requestSingleInstanceLock`을 `initializeApp` 맨 앞으로 옮겼다. 기존에는 `app.whenReady` 등록 뒤에 검사해서, 두 번째 인스턴스가 HTTP 서버 포트(3002)와 ANT+ 동글을 이미 실행 중인 인스턴스와 다툴 수 있었다. 종료 시 미전송 심박수가 사라지는 문제는 `persistBufferForShutdown`(동기 `appendFileSync`)을 `will-quit`과 완전종료 경로 양쪽에서 호출해 로컬 큐에 남기고 다음 실행에서 전송하도록 했다(종료 창 1.5초 안에 네트워크 전송을 기대하지 않는다)
- `main/presentation/`
- `heart-rate-updated`·`ant-connection-status`를 `MODE_AGNOSTIC_CHANNELS`로 지정해 SINGLE/MULTI 양쪽 창에 모두 전달한다. 기존에는 현재 실행 모드의 창에만 보내서, MULTI로 운동 중일 때 SINGLE 전용 심박 창이 갱신되지 않았다(창 대상 집합이 서로 겹치지 않아 중복 전송은 없다)
- 큐 파일의 세션 경계 분리·중복 제거·격리·보존, 종료 시 동기 적재, `status: ready` 게이트, 운동 전환 시 배치 분리, 심박 채널의 전체 창 브로드캐스트를 테스트로 고정했다
- **Test** · (신규)
심박 프로세스 정합화 4단계 — 나머지 P2
현상/기능
- P2 — zone 기준 통일
- P2 — ANT+ 복구
- P2 — heartbeat 타임아웃
- P2 — 죽은 채널·미사용 코드 정리 (사용자 확인 후 삭제)
작업
- `common/`(신규) · `main/heart-rate-modules/` · `renderer/heart-rate-modules/`
- 저장 기준(100/140/170)과 타이머 화면 표시 기준(100/130/160)이 달라서 같은 심박수가 화면과 DB에서 다른 zone으로 보이던 문제를 고쳤다. main·renderer 공용 모듈 `common/` 하나만 기준을 갖고, 저장용 `calculateHeartRateZoneForApi`와 표시용 라벨이 모두 이 모듈을 쓴다
- `main/` · `main/heart-rate-modules/` · `heart-rate-modules/`
- 기존에는 최초 `initialize`가 성공했을 때만 `setupANTCallbacks`를 등록해서, 동글을 나중에 꽂아 매니저 내부 재시도가 성공해도 심박수 콜백이 미등록 상태로 남을 수 있었다. 콜백을 초기화 결과와 무관하게 먼저 등록하도록 바꿨다. 진단 화면의 `staleTimeoutMs`/`gracePeriodMs` fallback 값이 실제 기본값(30초/5분)과 달라 60초로 표시되던 문제는 `resolveAntStaleTimeoutMs`/`resolveAntGracePeriodMs`를 노출해 한 곳에서 결정하도록 했다
- `main/`
- 25초마다 ping은 보내지만 pong 수신을 추적하지 않아, TCP가 죽었는데 close/error가 오지 않는 half-open 연결에서 영구 오프라인이 될 수 있었다. `lastPongAt`을 기록하고 70초간 pong이 없으면 소켓을 `terminate`해 기존 재연결 백오프를 타도록 했다
- 호출자가 없는 IPC 채널 `collect-heart-rate`·`web-send-heart-rate`와 대응 메서드(`collectFromIPC`, `broadcastFromWeb`), 더 이상 쓰이지 않는 `getActiveSession` 의존성을 제거했다. 웹 앱의 심박수는 IPC가 아니라 Electron HTTP 서버 `POST /heart-rate`로 들어온다. 웹의 미사용 나이 기반 `calculateHeartRateZone`도 삭제해 zone 기준이 여러 개로 갈라질 여지를 없앴다. 운동 패널 색상 밴드(108/126/153)와 데모 시드 zone은 화면/데이터 변경을 피하기 위해 그대로 유지한다
- **Electron/Web** · · `heart-rate-modules/` · · `web-app/src/services/`
workout_exercises_logic.md — 프로그램 기준으로 문서 정합
현상/기능
- 현재 프로그래밍된 내용으로 문서를 수정
작업
- 저장·재생 코드에 맞춰 대전제(MULTI 스네이크 vs 1Monitor 선형), 구간 전환 Ready 5초, EMOM Loop 물보충, MULTI AMRAP 6슬롯 동시 time-box, 1Monitor Main/EMOM 휴식·화면을 고쳤다. 프로그램은 변경하지 않았다
- **문서** · `workout_exercises_logic.md`