거북이경보 (Turtle Alert)
작품 설명
장시간 모니터 앞에서 일하며 굽어지는 자세를 교정하기 위해 만든 macOS 메뉴바 상주 앱입니다. 설정한 주기마다 "어휴~ 거북이되겠다~" 잔소리 톤의 알림을 띄워 스트레칭을 유도합니다. 메뉴바에 남은 시간(MM:SS)을 거북이 이모지와 함께 표시하여, 창을 열지 않아도 다음 알림까지 얼마나 남았는지 확인할 수 있습니다.
기획 의도
개발자로 일하면서 어느 순간 제 자세가 눈에 띄게 안 좋아진 것을 스스로 느꼈고, 이를 고치기 위해 직접 사용할 도구를 만들어야겠다고 생각했습니다. 시중의 자세 교정 앱들은 너무 진지하거나 작업 흐름을 크게 방해한다고 느껴서, 제가 실제로 꾸준히 쓸 수 있을 만큼 가볍고 귀여운 톤으로 부담 없이 사용할 수 있고, 창 없이 메뉴바에서 모든 조작이 끝나는 '최소 침습' 자세 알림 앱을 목표로 만들었습니다.
핵심 기능
메뉴바 상주형 UI (창 없음)
∙
BrowserWindow를 의도적으로 제거하고 Tray + Menu 만으로 모든 상호작용을 구성
∙
app.dock.hide()로 Dock 아이콘까지 숨겨 시스템 리소스 점유를 최소화
∙
tray.setTitle('🐢 MM:SS')로 남은 시간을 상단바에 실시간 표시
알림 주기 커스터마이징
∙
15분 / 30분 / 45분 / 1시간 / 2시간 단위로 선택 가능
∙
electron-store로 사용자 설정과 일일 알림 카운트 영구 저장
랜덤 스트레칭 가이드
∙
목 / 어깨 / 허리 스트레칭 가이드를 배열에서 랜덤 선택해 알림에 동봉
∙
urgency: 'critical' 옵션으로 알림센터에 남겨 놓치지 않도록 설계
AI 자세 감시 모드 (웹캠 + TensorFlow.js)
∙
단순 타이머 알림을 넘어, 웹캠으로 실제 자세를 실시간 감지해 거북목/구부정한 자세일 때만 알림을 띄우는 '감시 모드' 제공
∙
imagesnap으로 웹캠 스냅샷을 캡처하고, TensorFlow.js의 MoveNet(SINGLEPOSE_LIGHTNING) 모델로 어깨·귀·코 키포인트를 추출
∙
키포인트의 픽셀 좌표를 어깨 너비로 나눠 카메라 거리에 무관한 정규화 지표(noseShoulderRatio, earShoulderRatio, shoulderTilt 등)로 변환 후 판정
∙
거북목 판정을 단일 신호 AND 가 아닌 강/약 단·이중 신호 트리로 구성 — 강 단독(코 또는 귀 한쪽이 매우 깊게 떨어짐) / 약 이중(두 신호 동시) 일 때 거북목 라벨링, 약 단독은 '구부정한 자세'로 분리해 실제 거북목 사용자가 누락되지 않도록 설계
∙
감시 주기를 20초 / 40초 / 1분 / 2분 / 5분 단위로 커스터마이징 가능, 연속 N회(기본 3회) 나쁜 자세가 감지될 때만 알림 발송해 한 번의 false positive 로 사용자를 깨우지 않도록 처리
∙
macOS 카메라 권한이 거부되어 있거나 모델 로드에 실패하면 자동으로 타이머 모드로 fallback 되도록 안전장치를 설계
∙
imagesnap 바이너리를 앱 번들에 포함시켜, 사용자가 별도 설치 없이 DMG 다운로드 한 번으로 모든 기능을 쓸 수 있도록 처리
사용자 캘리브레이션 + false positive 0 정책 (v0.7.6)
∙
초기 절대 임계값 기반 판정은 카메라 위치/각도/사용자 체형에 따라 같은 자세도 전혀 다른 비율값으로 잡혀 "올바른 자세인데 알림이 뜨는" 오탐이 불가피했음
∙
사용자 실측 디버그 로그(/tmp/turtle-posture-debug.log) 분석 결과 거북목 상태에서도 키포인트 신뢰도 부족으로 잘못 'good' 처리되거나, 코·귀 한쪽만 잡혀 단일 신호 AND 조건을 충족 못 하는 케이스가 다수 확인되어 정책을 "오탐 0 우선"으로 재정의
∙
트레이 메뉴에서 '📸 지금 자세를 기준으로 저장'을 누르면 그 시점의 정규화 지표를 baseline 으로 electron-store 에 저장, 이후 평가는 절대 임계값이 아닌 baseline 대비 변화량(delta)으로 진행 — 같은 환경 안에서 비교하기 때문에 SNR(신호 대 잡음비)이 높아짐
∙
v0.7.6 부터는 AI 자세 검사를 켜는 즉시 자동 캘리브레이션이 강제로 트리거되어 baseline 없이 알림을 발송하는 경로 자체를 차단 — '올바른 자세인데 알림이 뜨는' 케이스를 줄임
∙
baseline 후보 자세 검증(이미 구부정/기울어진 자세는 거부)으로 나쁜 자세가 기준이 되어 거북목이 영영 안 잡히는 사고를 방지
카메라 이동 자동 감지
∙
캘리브레이션 기반 시스템의 약점인 '카메라 위치가 바뀌면 baseline 이 무효해진다'는 문제를 자동 감지로 해결
∙
매 측정마다 baseline 의 어깨 픽셀 너비와 현재 어깨 너비를 비교해 ±30% 이상 차이가 나면 카메라가 옮겨진 것으로 판단
∙
감지되면 store 에서 postureBaseline 을 즉시 삭제 + AI 자세 검사 토글을 OFF + 감시 중단 + "기준 자세를 다시 저장해 주세요" 알림 1회 — 환경 변경 직후의 false positive 알림을 방지
∙
사용자는 AI 모드를 다시 켜면서 새 환경에서 자연스럽게 baseline 을 다시 저장하는 흐름
새 버전 자동 알림 (업데이트 체커)
∙
앱 시작 시 1회 GitHub Releases API를 조회해 현재 버전보다 최신이 있는지 확인 (메뉴바 앱은 한 번 띄워놓고 오래 쓰는 특성상, 주기 폴링의 복잡도보다 실익이 적다고 판단해 시작 시 1회 방식으로 단순화)
∙
새 버전 발견 시 트레이 메뉴 최상단에 "🆕 새 버전 vX.Y.Z 받기" 항목을 동적으로 삽입하고 알림을 1회 띄워 사용자 방해를 최소화
∙
electron-updater 대신 GitHub API 기반 반자동 방식을 택해 사용자가 직접 새 DMG 를 받는 흐름을 유지 — 자동 교체보다 명시적 확인을 선호하는 메뉴바 앱 특성에 맞춤
∙
createAppCore 팩토리에 checkForUpdateOnce를 DI로 받도록 구조화해 네트워크 의존 코드를 stub으로 격리, 테스트에서 콜백·트레이 메뉴 분기까지 전부 커버 (function 커버리지 80%+)
Apple Developer ID 코드 서명 + Notarization (v0.7.4)
∙
초기 버전은 ad-hoc 서명(identity:null)으로 배포해 macOS Gatekeeper가 "손상되어 열 수 없습니다" 경고를 띄우는 문제가 있었고, 사용자에게 xattr -dr com.apple.quarantine 명령을 안내해야 했음
∙
Apple Developer Program 가입 후 Developer ID Application 인증서를 발급받아 키체인 → .p12 export → base64 → GitHub Secrets(CSC_LINK, CSC_KEY_PASSWORD) 흐름으로 CI에 안전하게 주입
∙
package.json의 mac 설정에 hardenedRuntime, entitlements(JIT/미서명 라이브러리 로드/카메라), notarize:true 를 활성화해 electron-builder가 sign → Apple Notarization → staple 까지 자동 수행하도록 구성
∙
노터라이즈된 빌드 도입 이후 Homebrew cask 의 postflight xattr 단계와 다운로드 가이드의 우회 안내를 모두 제거 — 사용자는 이제 DMG 더블클릭만으로 경고 없이 앱을 실행
Homebrew Cask 자동 배포 채널 (v0.7.x)
∙
별도 tap 레포(project-hh-org/homebrew-turtle-alert)를 운영해 brew install --cask project-hh-org/turtle-alert/turtle-alert 한 줄 설치 / brew upgrade --cask turtle-alert 한 줄 업데이트 지원
∙
릴리즈 자산명을 버전 비포함(TurtleAlert-arm64.dmg, TurtleAlert-x64.dmg)으로 고정하고, releases/latest/download/ 영구 링크를 cask URL · 다운로드 페이지 · README 모두에서 단일 출처로 참조
∙
release.yml 에 cask 자동 갱신 단계를 추가 — 태그 push 한 번이면 빌드 → 노터라이즈 → GitHub Release 생성 → tap 레포의 version/sha256 in-place 치환 + 노터라이즈 후 불필요해진 postflight 블록 제거까지 무인 수행
∙
HOMEBREW_TAP_TOKEN 시크릿이 설정되지 않은 환경에서는 cask 갱신 단계가 안전하게 noop으로 통과하도록 설계해, 시크릿 누락이 릴리즈 자체를 막지 않도록 가드
프로젝트 랜딩 페이지 (GitHub Pages + 커스텀 도메인)
∙
macOS 사용자가 ⌘ 키 조합이나 터미널을 거치지 않고 깔끔한 한 페이지에서 칩셋별 DMG 를 내려받을 수 있도록 turtle-alert.project-hh.com 랜딩 페이지를 별도 운영
∙
Jekyll 워크플로우의 source 를 정적 자산 전용 디렉토리(site/)로 한정해 README 등 다른 마크다운이 사이트로 새어 나가지 않도록 분리
∙
다크 모드 톤의 단일 HTML/CSS 로 작성해 외부 의존성·빌드 도구 없이 GitHub Pages 가 직접 서빙 — 가벼움과 유지보수 비용 최소화를 우선
실제 알림 예시
설정한 주기가 되면 macOS 알림센터에 "거북이경보!" 메시지와 함께, 그때그때 랜덤으로 뽑힌 스트레칭 가이드가 함께 표시됩니다.

인상 깊었던 트러블슈팅
productName 한글 설정 시 macOS 26.2+ 에서 SIGTRAP 크래시
∙
한글 앱 이름으로 빌드 시 런치 직후 SIGTRAP으로 종료되는 현상을 확인
∙
electron-builder의 productName은 영문(TurtleAlert)으로 고정하고, 사용자에게 보이는 이름은 CFBundleDisplayName에 한글을 주입하는 방식으로 우회 해결
∙
원인 조사 과정을 CRASH_INVESTIGATION.md에 문서화하여 동일 이슈를 겪을 개발자에게 공유
릴리즈 자동화 파이프라인 구축
∙
pnpm release:tag 한 줄로 버전 bump → 태그 push 까지 수행하는 스크립트 작성
∙
GitHub Actions(release.yml)가 태그 push를 감지해 x64 / arm64 DMG를 병렬 빌드하고 GitHub Release에 자동 업로드
∙
자산명을 버전 없이(TurtleAlert-arm64.dmg) 고정해 releases/latest/download/ 영구 링크로 README / DOWNLOAD 문서에서 참조 가능하게 설계
프로덕션에서만 터지는 AI 모델 로드 실패 (v0.6.x)
∙
pnpm dev 에서는 AI 자세 검사가 정상 동작하는데 설치된 .app에서만 "TensorFlow.js 모델을 불러올 수 없습니다" 에러가 발생
∙
원인은 두 겹 — ①@tensorflow-models/pose-detection이 index.js에서 BlazePose 모듈을 정적 import 하면서 @mediapipe/pose를 암묵적으로 peer require, ②Node 환경에서 tfjs CPU 백엔드가 자동 등록되지 않아 createDetector가 "No backend found in registry" 실패
∙
빌드된 Electron 앱은 stderr 확보가 어렵고 os.tmpdir()이 /tmp가 아닌 /var/folders/... 라서 디버깅이 꼬였고, 결국 asar를 직접 추출해 require 체인을 Node로 재현하는 방식으로 진짜 스택을 확보
∙
해결: 두 패키지를 dependencies에 추가 + tf.setBackend('cpu') / tf.ready() 명시 호출. v0.6.0에서 한 겹만 고쳐 불완전했던 경험을 RCA 문서로 남겨, "require 성공 이후 실제 createDetector 까지 프로덕션 동등 환경에서 확인"이라는 재발 방지 체크리스트를 정립
∙
후속(v0.7.3)으로 MoveNet 디텍터 파일을 직접 import 하도록 리팩토링해 BlazePose / PoseNet 경로의 숨은 peer 의존성을 정적 require 단계에서 차단 — 번들 크기와 로드 비용을 함께 절감
조직 리네임 마이그레이션 (project-hh-com → project-hh-org)
∙
GitHub 조직 이름이 변경되면서 동일 레포가 두 URL에서 모두 열리는 듯 보였지만, Homebrew tap cask 의 URL · Pages CNAME · 다운로드 가이드 · 업데이트 체커 API 호출 경로가 옛 org에 박혀 있어 일관성이 깨진 상태였음
∙
코드/문서/스크립트 전반(README, DOWNLOAD, CLAUDE, release.yml, lib/update-check.js, tap cask)에 흩어진 GitHub URL 을 일괄 치환하고, 로컬 git remote 도 새 org 로 교체
∙
정본을 한쪽으로 통일한 뒤 cask 의 자산명 규칙(버전 비포함)과 v0.7.x 버전을 함께 맞춰 푸시 — 옛 cask 가 v0.3.1 + 옛 자산명에 멈춰 brew 채널 자체가 동작하지 않던 상태를 복구
GitHub Pages 랜딩 페이지 빌드 경로 충돌
∙
Jekyll 워크플로우가 리포 루트(./)를 source로 잡아 빌드하면서 docs/index.html 이 도메인 루트(/)에 매핑되지 않고 /docs/ 하위로 노출되어 turtle-alert.project-hh.com 이 빈 페이지로 보이던 문제
∙
정적 자산 전용 디렉토리 site/ 로 분리하고 워크플로우의 source 를 ./site 로 한정 — README 등 다른 마크다운이 함께 빌드되어 사이트로 새어 나가는 부수 효과도 동시에 차단
∙
이후 같은 패턴(GitHub Pages + 커스텀 도메인) 셋업 시 처음부터 정적 자산 전용 폴더로 시작한다는 원칙을 정립
거북목인 사용자가 안 잡히던 문제 — 디버그 로그 RCA
∙
사용자(본인)가 명백한 거북목 상태에서도 알림이 거의 뜨지 않는다는 피드백을 받아 lib.js 의 _debugLogPosture 가 OS 임시 디렉토리에 누적해 둔 실측 로그(/var/folders/.../T/turtle-posture-debug.log) 를 분석
∙
원인 1: 대부분의 check 가 isGood:true / issues:["키포인트 신뢰도 부족"] 으로 빠지고 있었음 — 거북목인 사용자가 화면 가까이서 어깨가 프레임 밖으로 잘려 신뢰도 < 0.3 으로 떨어진 케이스가 자동으로 'good' 처리되던 구조적 결함을 확인
∙
원인 2: 어쩌다 잡힌 측정에서도 코-어깨 비율이 0.55~0.70 사이에 머물러 '구부정한 자세' 라벨로만 분류, '거북목' 라벨이 단 한 번도 발생하지 않음 — 단일 신호 AND 조건과 절대 임계값의 보수성 한계가 동시 노출됨
∙
초기에는 단순히 임계값을 느슨하게 하는 방향을 검토했으나, 임계값 완화는 진짜 거북목 검출과 정상 자세 오탐 사이에서 반드시 한쪽이 희생되는 트레이드오프임을 인지. 사용자 의견을 다시 묻고 'false positive 0' 을 정책으로 명문화
∙
해결 1: '키포인트 신뢰도 부족' 케이스를 mode:'uncertain' 으로 명시 분류해 silent good 누적을 방지. 강 단독 신호(코 또는 귀 한쪽이 매우 깊게 떨어짐)도 거북목으로 인정하는 단·이중 신호 트리로 재구성
∙
해결 2: AI 검사를 켜는 즉시 사용자 baseline 캘리브레이션을 강제하고, 카메라가 옮겨졌다고 판단되면 baseline 을 자동 무효화해 환경 변경 직후의 오탐도 차단. 이 과정에서 '판정 임계값 조정' 같은 코드 변경보다 '사용자가 본인 기준을 직접 한 번 찍게 만드는 UX 변경'이 오탐을 줄이는 데 더 효과적임을 확인