project

거북이경보 (Turtle Alert)

거북이경보 (Turtle Alert) 대표 이미지
SKILLS
Electron
Node.js
JavaScript
TensorFlow.js
Apple Code Signing
GitHub Pages
TOOLS
Claude Code
Visual Studio Code
electron-builder
GitHub Actions
Homebrew Cask
진행기간
2026.04.16 - 2026.05.17
개발인원
1명 (개발자 1명)
역할
기획
개발
배포·운영
기여도
5/5
카테고리
macOS App

작품 설명

장시간 모니터 앞에서 일하며 굽어지는 자세를 교정하기 위해 만든 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 변경'이 오탐을 줄이는 데 더 효과적임을 확인

배운 점

처음에는 순전히 제 자세 문제를 해결하려고 혼자 쓰려고 만든 앱이었는데, 만들고 나니 주변 동료들로부터 "나도 공유해달라"는 요청을 예상보다 많이 받았습니다. 이 과정을 통해 저만 느끼던 불편함이 아니라, 오래 앉아서 일하는 많은 사람들이 공통적으로 공감하고 있던 문제였다는 것을 체감했습니다. 작은 개인 도구라도 누군가의 불편을 정확히 짚어내면 자연스럽게 확장될 수 있다는 것을 배웠고, 이후로 제품을 기획할 때 '내가 실제로 겪는 문제'에서 출발하는 방식에 더 큰 확신을 갖게 되었습니다. 또한 이 앱은 Claude Code를 활용해 페어 프로그래밍 방식으로 개발했는데, Electron을 처음 써보는 상황이었음에도 새로운 스택에 도전하는 심리적·시간적 허들이 크게 낮아졌습니다. 익숙하지 않은 API(Tray, Notification, electron-builder 등)를 빠르게 탐색하고, 제가 내린 설계 결정의 근거를 대화로 다듬어가며 구현할 수 있었고, 이 경험을 통해 AI 페어 프로그래밍이 '학습 비용이 높아서 미뤄왔던 아이디어'를 실제 제품으로 옮기는 데 큰 도움이 된다는 것을 체감했습니다. 기술적으로는 Electron 하면 흔히 떠오르는 '무거운 크롬 창을 띄우는 앱'이라는 선입견을 깨고 창 없이 OS 네이티브 트레이만으로 완결된 UX를 설계해 본 경험, 그리고 혼자 쓰는 작은 도구임에도 CI/CD·버전 관리·크로스 아키텍처(x64/arm64) 배포 파이프라인을 처음부터 끝까지 직접 구축하며 사용자 설치 경험까지 고려한 엔드-투-엔드 제품 사고를 연습할 수 있었던 것이 가장 큰 수확이었습니다. v0.7.x 라인으로 넘어오면서 가장 크게 체감한 것은 '코드를 만드는 비용'과 '코드를 사용자 손에 끊김 없이 닿게 하는 비용'이 별개라는 사실이었습니다. Apple Developer ID 인증·노터라이즈 셋업, Homebrew tap 운영, 다운로드 랜딩 페이지 분리, 조직 리네임 마이그레이션처럼 기능과 무관한 운영 작업이 사용자 체감 품질을 결정하는 경우가 많았고, 이 과정을 직접 겪으면서 '배포 파이프라인은 한 번 만들고 끝나는 게 아니라 제품의 일부로 지속적으로 가꿔야 하는 영역'이라는 시각을 갖게 되었습니다. v0.7.6 에서 '거북목인데 알림이 안 뜬다' 는 본인 실측 피드백을 받아 판정 로직을 처음부터 다시 점검한 경험은 특히 인상 깊었습니다. 디버그 로그를 직접 분석해 단순 임계값 조정이 아닌 'false positive 0' 정책을 명문화하고, 절대 임계값 → 사용자 캘리브레이션 baseline 으로 평가 기준을 옮긴 뒤 카메라 이동 자동 감지까지 더해 환경 변화에 강건하게 만든 과정에서 '임계값 미세 조정' 보다 '사용자가 본인 기준점을 직접 찍게 만드는 UX' 가 정확도 개선에 더 효과적이라는 것을 배웠습니다. 동시에 신호 추정 시스템을 운영할 때는 사용자 동의 하에 로컬 디버그 로그를 누적해 두는 작은 투자가 디버깅 비용을 크게 줄인다는 점도 함께 체감했습니다.