2026-08-14 PulseUp 작업일지입니다. 오늘은 12건의 작업을 진행했습니다.
심박 로그 실측 확인 + ANT+ 라이브러리 디버그 로그 폭주 제거
현상/기능
- 로그 (1단계 최적화 결과 검증)
- stdout 을 채우는 `0` 로그의 출처를 찾아 제거
작업
- 재생 중 실측값: ANT 수신 11.5~12건/s(기기 3대·대당 3.9Hz), POST 0.1~0.3회/s, POST 응답 12~34ms(최대 96ms), 429·실패 0건, 이벤트루프 지연 평균 4~8ms(최대 37ms). 서버 로그의 모든 배치에서 `deduplicatedCount`가 0이고 `savedCount == receivedCount`로, 5초 격자 멱등 설계가 의도대로 동작함을 확인했다. 앱 시작 시 로컬 큐에 남아 있던 1090건(263KB)이 운동 시작과 함께 200행·58행 배치로 자동 드레인되어 0으로 비워졌고, 이 구간만 큐 I/O가 111ms였고 이후 1~5ms로 떨어졌다. 미조치 관찰 2건: ① 운동 시작 전에는 `queueDrains`가 0이라 큐 잔량이 다음 운동까지 방치된다. ② 모든 배치에 `unmappedDeviceIds`(ant-10084·10256·10771)가 찍혀 심박이 전부 운동 소유자 계정으로 저장되고 있다(심박계-회원 배정 없음)
- `build-scripts/patch-ant-plus-log.js`(신규) · · `package.json`
- 파일 로그 1214줄 중 1167줄(96%)이 `[LOG] 0`이었고, 출력 간격이 3개 묶음·246ms로 ANT 브로드캐스트 주기와 일치했다. 원인은 `ant-plus-next/dist/index.{cjs,mjs}`에 남아 있는 디버그 호출 `console.log(this.deviceId)`로, 스캐너의 deviceId 가 0 이어서 수신 패킷마다 `0`을 찍었다(기기 3대 기준 약 12회/초의 동기 stdout 쓰기 + 파일 append). 업스트림 최신 0.4.0 에도 남아 있어 해당 호출만 제거하는 멱등 패치 스크립트를 신설하고 루트 `postinstall` 및 electron-app `build`·`dev`·`dev:local` 앞단에 연결했다. 패치 후 라이브러리 로드(HeartRateScanner/HeartRateSensor/GarminStick3) 정상 확인. **적용에는 Electron 재시작이 필요하다.**
- **분석(로그 실측)** · Electron `heart_rate_throughput` · API `heart_rate_batch_saved`
심박 저장 최적화 1단계 — 5초 격자 표본 + 주기 전송 (다지점 동시 저장 대비)
현상/기능
- 서버 운영 시 여러 지점에서 동시에 심박을 저장하면 분명 문제가 생긴다. 저장 방법을 최적화해야 한다. (저장 해상도 5초 / 클라이언트 감축 먼저 진행으로 합의)
- 격자 축소·멱등 저장·주기 전송 검증
작업
- `main/heart-rate-modules/`(신규)
- 기기별 5초 격자 1건으로 표본을 줄이는 코얼레서를 분리 신설했다( 600줄 제한). 표본 timestamp 를 격자 시작 시각으로 고정해 재전송·큐 드레인이 반복돼도 서버 유니크 키가 같아 새 행이 생기지 않는다(멱등). 전송은 "10건 모이면 즉시"를 버리고 3초 주기로 **끝난 격자만** 보내 한 격자가 정확히 한 번 전송되게 했다. 화면 표시는 원본 4Hz 그대로 유지한다. 세션 종료·앱 종료 경로(`flushBuffer`/`persistBufferForShutdown`)는 진행 중 격자까지 모두 내보낸다. 표본 상한 200건(서버 배치 스키마 최대치) 도달 시 즉시 전송한다
- 코얼레서 단위 테스트 5건 추가(격자 최신값 유지, timestamp 격자 정규화, 기기별 분리, 끝난 격자만 꺼내기, 시간순 정렬). 매니저 테스트는 새 동작에 맞춰 갱신했다(4Hz 20건 → 1건 전송, 끝난 격자만 전송, 운동 전환 시 즉시 전송). 격자 경계로 결과가 흔들리지 않게 가짜 타이머로 시각을 고정하고, `setImmediate` 는 fake 대상에서 제외해 큐 파일 I/O 완료를 기다리게 했다. Electron 전체 320건 통과
- **효과(계산)** 심박계 20대 지점 기준 초당 8회 POST → 5초에 1회, 전송 행 초당 80 → 4, 같은 1초 행 중복 업서트 4회 → 0. 10지점 합계로 초당 80 트랜잭션 → 초당 2, 저장 행 하루 576만 → 115만
- **분석 — 다지점 위험 요소(미조치, 2단계 예정)** ① `heart_rate_data` 인덱스가 9개(랜덤 UUID PK + 유니크 + 중복 7개)로 행당 B-tree 9개를 갱신한다. ② `ON DUPLICATE KEY UPDATE created_at = CURRENT_TIMESTAMP` 때문에 값이 같아도 실제 행 쓰기가 발생한다. ③ 단일 INSERT 를 명시적 트랜잭션으로 감싸 왕복·fsync 가 3배다. ④ 429 가드가 `userId:masterId:instanceId` 라 여러 지점 유입을 전혀 막지 못한다. ⑤ DB 풀 20개를 전체 API 가 공유해 심박이 점유하면 예약·관리 기능이 대기열에 밀린다. ⑥ 보존 정책(파티션/요약)이 없다
- **Test** · (신규)
심박 저장 부하 실측 — 영상이 멈추는 원인 계량
현상/기능
- 운동영상 재생과 심박 저장이 겹치면 영상이 멈출 정도로 느리다. 서버 성능이 낮으므로 저장 방법을 최적화해야 한다. 먼저 실측부터 한다
- 서버가 초당 몇 건의 배치를 받고 그중 몇 건을 429로 되돌리는지 확인
작업
- `main/heart-rate-modules/`(신규)
- 저장 경로에 계량기를 붙여 10초마다 `heart_rate_throughput` JSON 한 줄을 남긴다. 수신 건수/초·기기 수, POST 횟수/초와 왕복시간(평균·최대), 보낸 행 대비 실제 저장·중복제거 행, 실패 큐 append/drain 횟수와 소요시간, 큐 파일 크기·라인 수, 메인 프로세스 이벤트 루프 지연(평균·최대)을 기록한다. 계량 훅은 `collectFromANT`·`postBatch`·`appendQueue`·`flushQueue`에 카운터 증가로만 붙여 동작은 바꾸지 않았다. `LINKHIIT_HR_METER=0`으로 끌 수 있다
- 주기 로그(`heart_rate_batch_saved`)에 구간 통계(배치 횟수·초당 배치 수·429 횟수·수신 행 수·최대 처리시간)를 추가했다. 모든 배치가 429로 막히는 상황에서도 실측이 보이도록 `heart_rate_batch_throttled` 주기 로그를 함께 남긴다
- **API** · `routes/`
- **분석 (코드 기준 추정)** ① ANT+는 기기당 초당 약 4회 전송하는데 축소 단계가 없다. ② 전송이 `10건 모이면 즉시`라 심박계 20대면 초당 8회 POST가 되고, 서버는 같은 초를 하나만 남기므로 보낸 것의 3/4이 버려지며 같은 1초 행을 배치마다 덮어써 DB 쓰기도 배가된다. ③ 429/타임아웃이 나면 실패 큐 파일에 쌓이고, 이후 전송마다 큐 파일 전체를 읽어 파싱하고 전체를 다시 쓴다(느려지면 더 쌓이는 악순환 → 영상 멈춤의 직접 원인 후보). ④ 렌더러도 초당 80회 인라인 스타일을 고쳐 쓰고 카드에 `transition: all`이 걸려 있다
- **다음 단계(합의)** 실측 확인 후 기기별 초당 1건 코얼레싱 + 3초 주기 배치 전송으로 전환한다
사용자 추가 팝업 — 역할이 코드값으로 보이던 문제
현상/기능
- 사용자 추가 팝업에서 역할이 코드값으로 나온다
작업
- 닫힌 Select는 항목 라벨을 몰라 `user` 같은 value를 그대로 보여 주던 문제를, 역할 한글 `labels`를 넘겨 고쳤다
- **Web**
리모컨 심박 탭 — 심박계·회원 배정
현상/기능
- 리모컨에 심박계 연결 탭을 추가하고, 지점 심박계와 지점 회원을 선택해 저장/삭제하며 추가 버튼으로 다음 짝을 만든다. 연결을 언제 끊을지 아이디어도 필요하다
작업
- 「심박」 탭을 추가했다. 오늘 운동을 고른 뒤 회원·심박계를 한 줄씩 저장/해제하고, 「추가」로 빈 줄을 늘린다. 배정은 선택한 운동(master)에만 유효하다. 해제는 화면의 삭제, 또는 같은 기기/회원을 다른 짝으로 다시 저장할 때다. 운동이 끝나면 심박 저장은 멈추고 배정 행은 남는다
- **Web** · `pages/RemoteControl/` · `pages/RemoteControl/components/`(신규) · `pages/RemoteControl/components/`(신규)
리모컨 심박 탭 — 초기화 / 개별 삭제 후 재설정
현상/기능
- 연결 목록에 초기화 버튼을 두고 저장된 연결을 모두 disable 한다. 개별 삭제도 disable 후 해당 행을 지우고, 다시 쓰려면 추가해서 저장하게 한다
작업
- 「초기화」는 활성 배정을 모두 `is_active=FALSE`로 내린 뒤 목록을 비운다. 「삭제」는 그 행만 비활성하고 화면에서 제거한다. 빈 줄은 자동으로 만들지 않으며, 다시 연결하려면 「추가」 후 저장한다
- **Web** · `pages/RemoteControl/components/`
리모컨 심박 탭 — 저장 후 회원명이 코드로 보이던 문제
현상/기능
- 저장하니 사용자가 코드값으로 보인다
작업
- 닫힌 Select는 항목 라벨을 몰라 value(UUID)를 그대로 보여 주던 문제를, 회원명/심박계명 `labels`를 넘겨 고쳤다
- **Web** · `pages/RemoteControl/components/`
리모컨 심박 탭 — 저장된 회원/심박계는 추가 목록에서 제외
현상/기능
- 저장되고 활성화된 회원/심박계는 추가 시 선택 목록에 나오지 않게 한다
작업
- 이미 저장된 활성 배정의 회원·심박계는 새 줄 선택 목록에서 빼도록 했다. 해당 줄의 자기 값은 그대로 보이고, 삭제하면 다시 목록에 나온다
- **Web** · `pages/RemoteControl/components/`
리모컨 심박 탭 — 오늘 운동 선택 제거
현상/기능
- 심박계 연결 시 오늘 저장된 운동을 고르지 말고, 재생 중이거나 선택된 운동에 자동으로 붙이게 한다
작업
- 오늘 운동 드롭다운을 없앴다. Electron 세션의 `masterId`를 폴링해 그 운동에 배정한다. 운동이 없으면 기본 탭에서 불러오라는 안내만 보여준다
- **Web** · · · `services/`
리모컨 심박 탭 — 웹 실행 오류 수정
현상/기능
- 수정 이후 웹페이지가 실행되지 않는다. default export 없음
작업
- `adminService`를 default import 하던 부분을 named import로 바꿨다. 이 모듈은 App이 리모컨 페이지를 바로 불러와서, import 오류가 전체 화면을 막았다
- **Web** · `pages/RemoteControl/components/`
심박 조회 VIEW — 심박 + 사용자 + 운동 조인
현상/기능
- 저장된 심박정보와 사용자정보, 운동정보를 조인해서 보여주는 view 테이블
작업
- `v_heart_rate_workout_user` VIEW를 추가했다. `heart_rate_data`를 기준으로 측정 회원(`users`), 운동 마스터(`workout_history_master`), 운동 소유자, 지점, 활성 참가자 배정 여부를 조인한다. 비밀번호는 포함하지 않는다. DB 적용은 운영자가 이 SQL을 실행한다
- **DB** · (신규)
심박 슬롯 — 끊김 20초 / 카드 유지 / 재연결 활성화
현상/기능
- 끊김 판정 20초, 한번 들어온 슬롯은 꺼져도 없애지 않고 회색 처리, 다시 들어오면 활성화
작업
- ANT+ 무수신 판정 기본값을 30초에서 20초로 바꿨다. 운동 구간 전환 cleanup 신호가 회색 카드를 지우지 않도록 했고, 같은 슬롯으로 다시 수신되면 connected 스타일로 되돌린다