- rebrand from 습관 트래커 to 해빗랩 across templates, manifest, service worker - add HTTPS redirect middleware for reverse-proxied deployments - add public landing page (/) and privacy policy page with real data handling disclosures - add in-app account deletion (Apple review requirement) - add Android TWA Digital Asset Links support (/.well-known/assetlinks.json) - add SFTP deployment script for the Synology-hosted server Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
101 lines
29 KiB
Markdown
101 lines
29 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## Project
|
|
|
|
개인용 습관 관리 PWA (`habit-tracker`). 만들고 싶은 습관 / 끊고 싶은 습관을 요일 단위로 관리하고, 데일리 체크와 월별/주별 기록 확인, Web Push 알람을 제공한다. 아이폰(홈 화면에 추가)과 PC 브라우저 양쪽에서 같은 서버에 접속해 데이터를 공유하는 구조. 상세 설계는 최초 구현 시 작성된 계획 문서를 참고 (요구사항, 마일스톤, 디자인 토큰 등).
|
|
|
|
Python 3.13, conda 환경 이름은 `py_web` (`.iml`의 SDK 이름과 동일).
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
conda activate py_web
|
|
pip install -e . # 의존성 설치 (pyproject.toml)
|
|
|
|
alembic upgrade head # DB 마이그레이션 적용
|
|
alembic revision -m "설명" # 새 마이그레이션 추가 (모델 변경 시 수동 작성 권장 — MariaDB 접속 없이 autogenerate가 안 되는 경우가 있음)
|
|
|
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 # 개발 서버 실행 (반드시 저장소 루트에서 실행 — templates/static 상대경로 의존)
|
|
|
|
pytest # 전체 테스트
|
|
pytest tests/test_habits.py::test_name # 단일 테스트
|
|
```
|
|
|
|
`.env`가 없으면 `app/config.py`의 `Settings()`가 곧바로 실패한다 (`SECRET_KEY`, `DATABASE_URL` 필수). `.env.example`을 복사해서 채울 것. `DATABASE_URL`의 비밀번호에 `@`, `%` 같은 특수문자가 있으면 반드시 URL-encode해야 한다 (`urllib.parse.quote(pw, safe='')`) — 그렇지 않으면 SQLAlchemy가 자격증명/호스트 구분을 잘못 파싱한다.
|
|
|
|
구글 로그인을 쓰려면 `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`/`GOOGLE_REDIRECT_URI`를 채워야 한다(`.env.example` 주석 참고, Google Cloud Console에서 OAuth 클라이언트를 만들고 `GOOGLE_REDIRECT_URI`와 정확히 같은 값을 "승인된 리디렉션 URI"에 등록). VAPID 키는 `scripts/generate_vapid_keys.py`로 생성.
|
|
|
|
## Architecture
|
|
|
|
- **백엔드**: FastAPI + SQLAlchemy 2.0 (declarative) + Alembic + MariaDB(PyMySQL 드라이버). 인증은 구글 OAuth(`authlib`) 기반 멀티유저 구조다 — `User` 테이블(`google_sub`/`email` 등)에 로그인한 계정이 저장되고, 성공 시 `itsdangerous`로 서명한 세션 쿠키(`{"user_id": ...}`)를 발급한다(`app/security.py`, `app/routers/auth.py`). 아무 구글 계정이나 로그인하면 자동으로 새 계정이 생성된다(화이트리스트 없음). `Habit`/`PushSubscription`은 `user_id`(nullable FK)로 소유자가 갈린다 — 서비스 계층(`habit_service`, `log_service`, `push_service`) 함수는 거의 전부 `user_id`를 필수 인자로 받아 필터링한다(IDOR 방지). 예외는 스케줄러 전용 `habit_service.list_active_habits_with_reminders`뿐 — 전체 유저를 순회해야 하므로 유저 스코핑이 없고, 그래서 이름에 의도를 명시했다.
|
|
- **프론트엔드**: 서버사이드 렌더링(Jinja2) + htmx(폼 제출/부분 갱신) + Alpine.js(요일 토글, 알람 입력 등 클라이언트 UI 상태). React 등 SPA 프레임워크 없음. htmx/Alpine은 CDN이 아니라 `app/static/js/vendor/`에 로컬 vendoring된 파일을 사용 (PWA 오프라인 캐싱을 CDN 의존 없이 동작시키기 위함).
|
|
- **디자인 시스템**: `app/static/css/style.css`에 CSS 커스텀 프로퍼티로 정의된 Claude.ai 톤의 디자인 토큰(크림 배경 + 테라코타 포인트 컬러, 라이트/다크 모드는 `prefers-color-scheme` 기반). 새 화면을 추가할 때는 여기 정의된 토큰과 기존 컴포넌트 클래스(`.card`, `.btn-*`, `.weekday-pill`, `.habit-item` 등)를 재사용할 것.
|
|
- **네비게이션 (상단 탭 + 모바일 하단 탭바 이중 구조)**: `app/templates/base.html`의 `.top-nav`(오늘/습관 관리/기록 링크)는 넓은 화면 전용이고, 같은 3개 링크를 `.bottom-tab-bar`로 한 번 더 렌더링해서 `max-width: 480px`에서만 `.top-nav .nav-links`를 숨기고 `.bottom-tab-bar`를 고정 하단바로 노출한다(`app/static/css/style.css`). "활성 탭" 표시(`{% block nav_today %}` 등, 각 페이지 템플릿이 `active`로 오버라이드)를 두 네비게이션이 공유해야 해서, 하단 탭바 쪽은 블록을 다시 정의하지 않고 `{{ self.nav_today() }}`로 이미 정의된 블록의 렌더링 결과를 재사용한다 — 새 페이지를 추가할 때 `nav_today`/`nav_habits`/`nav_history` 블록 중 하나를 오버라이드하면 상단/하단 양쪽에 자동으로 반영된다. `.app-shell`에 `padding-bottom`(하단바 높이 + `env(safe-area-inset-bottom)`)을 줘서 콘텐츠가 하단바에 가려지지 않게 했다.
|
|
|
|
### 요청 흐름 (2가지 라우터 계열이 공존)
|
|
|
|
- `app/routers/habits.py`, `app/routers/logs.py`, `app/routers/push.py` — `/api/*` 하위, JSON in/out API. 라우터 레벨 `dependencies=[Depends(require_login)]`로 기본 보호되고, 유저 객체가 필요한 각 엔드포인트는 `current_user: User = Depends(require_login)`을 시그니처에 추가로 선언한다(FastAPI가 같은 요청 안에서 dependency를 캐싱하므로 DB 조회가 중복되지 않는다). 미인증 시 401 JSON을 반환.
|
|
- `app/routers/pages.py` — SSR 페이지(`/login`, `/today`, `/habits`, `/history`)와 htmx가 폼 제출로 호출하는 액션 엔드포인트(`/habits/new`, `/habits/{id}/complete` 등). 미인증 시 401 대신 `/login`으로 303 리다이렉트한다 — `_current_user_or_redirect(request, db)`가 `User`(로그인됨) 또는 `RedirectResponse`(미인증)를 반환하고, 각 핸들러는 `isinstance(current, RedirectResponse)`로 분기한다. 폼 액션은 대부분 처리 후 `HX-Redirect` 헤더로 같은 탭을 새로고침하는 방식으로 단순화되어 있다(부분 DOM 스왑이 아님).
|
|
- `app/routers/auth.py` — `/auth/*`, 페이지 네비게이션(리다이렉트/폼 POST)이라 `/api/*` 프리픽스를 쓰지 않는다. `GET /auth/google/login`이 구글 동의 화면으로 리다이렉트하고, `GET /auth/google/callback`이 `authlib`로 id_token을 검증해 `User`를 조회/생성한 뒤 세션 쿠키를 발급, `POST /auth/logout`이 쿠키를 지운다. OAuth 핸드셰이크 중 state/nonce를 담는 `SessionMiddleware`(`app/main.py`)는 로그인 유지용 쿠키(`habit_session`)와 별개의 임시 쿠키(`oauth_session`)를 쓴다.
|
|
|
|
두 계열이 같은 `app/services/*`, `app/schemas/*`를 공유한다 — 새 기능을 추가할 때 API와 페이지 라우터 양쪽에서 비즈니스 로직을 중복 구현하지 말고 `app/services/`에 두고 재사용할 것.
|
|
|
|
### 데이터 모델 (`app/models/`)
|
|
|
|
- `User`: `google_sub`/`email` unique, `name`/`picture_url` nullable, 구글 로그인 시 조회/생성(`app/routers/auth.py`의 `google_callback`).
|
|
- `Habit`: `user_id`(nullable FK→`user.id`, ondelete=CASCADE — nullable인 이유는 PIN 시절 데이터 이관 때문, 아래 "구글 OAuth 전환" 참고), `habit_type`(build/quit), `status`(active/completed), `weekdays_mask`는 비트마스크(bit0=월…bit6=일, Python `date.weekday()`와 동일한 인덱스, `Habit.is_scheduled_on(weekday)`로 조회), `difficulty`(easy/medium/hard/unlimited — 습관 등록 시 선택하는 목표 기간 난이도, `HABIT_DIFFICULTY_TARGET_DAYS`가 각각 21/66/254일/무제한으로 매핑한다. 이 숫자들은 임의로 정한 게 아니라 Lally et al.(2010, UCL)의 습관 자동화 소요 기간 실증 연구값을 그대로 가져온 것 — 근거는 `habit-formation-research.md` 참고. `Habit.target_days`/`Habit.goal_target_date`가 `created_at` 기준으로 목표 종료일을 계산하고, `template_utils.goal_progress()`가 이를 "D-N"/"N일차" 형태로 `/habits` 목록에 표시한다. 요일과 마찬가지로 과거에 난이도를 바꾼 이력은 추적하지 않는다), `condition_text`(달성 조건, nullable, 빈 문자열은 `HabitBase.blank_condition_to_none` 검증기가 자동으로 None으로 변환), `reminder_time`은 nullable.
|
|
- `HabitLog`: "행이 존재하면 그 날 체크 완료"라는 설계 — 별도 boolean 컬럼 없음. `(habit_id, log_date)` unique. 체크 해제는 행 삭제. 유저 스코핑은 `Habit`을 조인해서 한다(`log_service.list_logs`).
|
|
- `PushSubscription`(`user_id` nullable FK 포함), `HabitNotificationLog`: Web Push 구독 정보와 중복 알림 방지용 발송 기록 (5단계 마일스톤에서 실제로 사용 시작).
|
|
|
|
### 통계 (완료율 / 연속 달성일)
|
|
|
|
`log_service.get_habit_stats(db, habit)` — `/habits` 목록과 `/today` 체크리스트 양쪽에서 습관마다 배지로 표시된다(`/today`는 `TodayItem`에 `completion_rate`/`current_streak`/`scheduled_days`를 직접 포함시켜 `log_service._to_today_item`이 습관마다 `get_habit_stats`를 호출한다). 요일 스케줄은 **현재의** `weekdays_mask`를 습관 생성일부터 오늘까지 그대로 적용한 것으로 계산한다 — 과거에 요일을 바꾼 이력은 추적하지 않는다(월별/주별 집계와 같은 단순화 원칙). `current_streak`은 오늘부터 거슬러 올라가되, **오늘 아직 체크 안 한 것은 스트릭을 끊지 않는다**(하루가 아직 안 끝났으므로) — 오늘보다 이전 날짜의 미체크만 스트릭을 끊는다.
|
|
|
|
### 습관 순서 드래그 재정렬
|
|
|
|
`/habits` 목록은 `app/static/js/vendor/sortable.min.js`(SortableJS, 로컬 vendoring)로 드래그 재정렬을 지원한다. **`forceFallback: true`가 필수**다 — iOS Safari는 네이티브 HTML5 Drag and Drop의 터치 지원이 불안정해서, SortableJS 자체 포인터 이벤트 기반 폴백을 강제하지 않으면 아이폰에서 드래그가 아예 안 먹힌다(`app/static/js/habit-reorder.js`). 각 `habit_item.html`의 `.drag-handle`(⠿ 아이콘)만 드래그를 시작할 수 있게 `handle` 옵션으로 제한했다 — 그래야 수정/완료/삭제 버튼 클릭이 드래그와 충돌하지 않는다. 드롭이 끝나면(`onEnd`) `#habit-list`의 현재 DOM 순서 그대로 `POST /api/habits/reorder`로 보내 `Habit.sort_order`를 일괄 갱신한다(`habit_service.reorder_habits`). `sort_order`가 `NULL`인 습관(한 번도 재정렬 안 됨)은 항상 뒤로 밀려서(`list_habits`의 `ORDER BY sort_order IS NULL, sort_order, created_at`) 새로 추가한 습관이 자동으로 맨 뒤에 붙는다.
|
|
|
|
**테스트 관련 주의**: 이 harness의 브라우저 자동화는 실제 드래그 제스처(연속된 포인터 이동)를 합성 이벤트로 재현하지 못한다 — `left_click_drag`, 합성 `MouseEvent`, 합성 `PointerEvent` 세 가지 방식 모두 SortableJS의 폴백 드래그를 트리거하지 못했다. 이 기능을 만질 때는 재정렬 로직 자체(`POST /api/habits/reorder`, `habit_service.reorder_habits`)는 curl로 직접 검증하고, 실제 드래그 제스처 UX는 사람이 진짜 기기(마우스 또는 터치)로 확인해야 한다.
|
|
|
|
`/history`에도 완료율이 있다 — 월별 뷰 상단에 "이번 달 완료율" 배지, 주별 매트릭스에 습관별 "완료율" 열. 두 곳 모두 계산 시 **오늘 이후(미래) 날짜는 제외**하고(`summarize_completion_rate`의 `up_to` 파라미터, 주별은 `d <= today` 체크) **습관 생성일 이전 날짜도 제외**한다(`get_monthly_summary`/`get_weekly_matrix`에서 `h.created_at.date() <= d` 비교) — 이 두 필터가 없으면 "아직 시작 안 한 습관"과 "아직 안 지난 미래"가 전부 "예정됐지만 안 함"으로 잡혀 완료율이 실제보다 크게 낮게 나온다(실제로 이 버그로 6.2%가 나왔다가 고친 뒤 50%가 된 사례가 있었음 — 새로 비슷한 집계를 추가할 때 같은 함정을 주의).
|
|
|
|
### 마이그레이션
|
|
|
|
`migrations/env.py`는 `alembic.ini`의 configparser 보간을 우회하고 `settings.database_url`을 직접 엔진 생성에 사용한다 — 비밀번호에 `%`가 포함되면 `config.set_main_option`이 interpolation 에러를 내기 때문. 마이그레이션을 새로 작성할 때 이 패턴을 건드리지 말 것. 원격 MariaDB만 사용하므로 `alembic revision --autogenerate`는 항상 실제 DB 접속이 필요하다 — 접속이 안 되는 환경에서는 `migrations/versions/0001_initial.py`처럼 수동으로 `op.create_table` 스크립트를 작성하는 것도 방법.
|
|
|
|
## 개발 단계
|
|
|
|
1. ✅ 기반 셋업 + 습관 CRUD (`/habits` 화면, PIN 로그인 — 7단계에서 구글 OAuth로 대체됨)
|
|
2. ✅ 데일리 체크 & `/today` 화면 — `/habits`와 달리 여기는 실제 htmx 부분 갱신을 쓴다: 체크 버튼이 `#today-content`를 통째로 `partials/today_content.html`로 교체한다(개별 아이템만 스왑하지 않는 이유: 진행률 배지도 같이 갱신해야 해서). 이 partial은 `today.html`의 최초 렌더와 토글 응답에서 동일하게 재사용된다 (`app/routers/pages.py`의 `_today_context`).
|
|
3. ✅ 기록 확인 화면 (월별/주별) — `/history`. 두 뷰 모두 순수 SSR(링크 기반 이전/다음 네비게이션)이고 htmx 상호작용은 없다. 월별 집계와 주별 매트릭스는 모두 **현재 active 상태인 습관만** 기준으로 계산한다(`app/services/log_service.py`의 `get_monthly_summary`/`get_weekly_matrix`) — 완료 처리되었거나 삭제된 습관은 과거 날짜라도 집계에서 빠진다. 즉 "그 날 실제로 무엇이 예정되어 있었는가"를 재구성하지 않고 "지금 진행 중인 습관 기준으로 최근 기록이 어떤지"를 보여주는 단순화된 설계다. 히트맵 투명도는 `app/template_utils.py`의 `heatmap_opacity()`가 계산해 `--color-accent-rgb` CSS 변수와 조합한다.
|
|
4. ✅ PWA 기본 (manifest, 서비스워커, 아이콘) — `manifest.json`은 `/static/manifest.json`에 있고 `base.html`이 `/static/manifest.json`으로 직접 링크한다(루트 경로 라우트 아님, manifest는 자체 `scope` 필드로 범위를 지정하므로 파일 위치가 중요하지 않음). 반면 `service-worker.js`는 앱 전체를 커버해야 해서 `app/main.py`의 `GET /service-worker.js`가 `app/static/service-worker.js`를 루트 경로로 직접 서빙한다 — 이 둘의 서빙 방식이 다른 이유이니 헷갈리지 말 것. 아이콘은 `scripts/generate_icons.py`(Pillow 필요, 런타임 의존성 아님)로 생성.
|
|
- **캐싱 전략 (중요)**: 처음엔 모든 GET을 stale-while-revalidate로 캐싱했는데, `/habits`·`/today`처럼 사용자가 직접 데이터를 바꾸고 곧바로 재방문하는 페이지에서 "방금 저장한 게 사라진 것처럼" 보이는 실사용 버그로 이어졌다(습관 생성/수정 후 `HX-Redirect`로 재이동했을 때 캐시된 옛 페이지가 먼저 뜸). `service-worker.js`의 fetch 핸들러는 이제 `request.mode === "navigate"`(페이지 탐색)와 그 외(정적 자산)를 분리한다 — 탐색은 **네트워크 우선**(오프라인일 때만 캐시 폴백), 정적 자산만 캐시 우선 stale-while-revalidate 유지. 서비스워커 캐시 로직을 바꿀 때마다 `CACHE_NAME` 버전을 올려야 `activate` 핸들러가 구버전 캐시를 정리한다(현재 `habit-tracker-v2`).
|
|
5. ✅ Web Push 알림 (APScheduler 매분 tick + VAPID) — VAPID 키 원시 바이트는 `py_vapid`의 `b64urlencode`로 직접 인코딩해서 `.env`에 저장한다(`Vapid02`에 `public_key_str` 같은 헬퍼가 없음, `scripts/generate_vapid_keys.py` 참고). `scheduler_service._tick()`은 습관별 job을 등록/해제하는 대신 **매분 폴링** 방식으로 전체 active 습관을 훑어 지금 시각+요일이 맞고 오늘 아직 `habit_notification_log`에 없는 것만 발송한다 — 습관 CRUD와 스케줄러 job을 동기화할 필요가 없어서 이 방식을 택함. `push_service.send_to_user(db, user_id, ...)`(멀티유저 전환 전에는 `send_to_all`이었음)는 만료된 구독(404/410)만 자동 삭제하고 다른 오류는 무시하고 다음 구독자로 넘어간다(한 구독자 오류가 전체 발송을 막지 않도록). 프론트엔드 구독 흐름은 `app/static/js/push-register.js`(`window.habitPush`), `/today` 카드에 "알림 켜기" 버튼으로 노출. **주의**: Chrome 네이티브 알림 권한 프롬프트는 브라우저 크롬 UI 영역이라 자동화 도구로 클릭할 수 없다 — 구독 저장/발송/스케줄러 로직은 curl과 직접 함수 호출로 검증했지만, 실제 브라우저 권한 승인 → 진짜 푸시 수신까지의 마지막 단계는 사람이 직접 확인해야 한다.
|
|
- **경합 방지**: `_tick()`은 발송 전에 `habit_notification_log`에 먼저 커밋해 "선점"하고(`_claim_notification_slot`), `(habit_id, notify_date)` 유니크 제약을 경합 방지 락처럼 쓴다 — 선점에 실패(`IntegrityError`)하면 이미 다른 워커/틱이 처리한 것으로 보고 조용히 건너뛴다. 이 앱은 `uvicorn --reload`로 개발 중 실행하는 경우가 많은데, **포트가 겹친 채로 오래된 워커 프로세스가 안 죽고 새 프로세스와 동시에 떠 있으면 각자 스케줄러를 따로 띄워서 같은 알림을 동시에 두 번 기록하려다 죽는 사고**가 실제로 있었다(웹 요청도 임의로 둘 중 한 프로세스로 라우팅되어 "가끔 옛날 코드로 응답"하는 것처럼 보이는 증상과 세트로 나타남). 이상 동작이 보이면 먼저 `Get-NetTCPConnection -LocalPort 8000 -ErrorAction SilentlyContinue`(PowerShell, `netstat`보다 정확함)로 실제 리스너가 1개인지 확인하고, 여러 개면 관련 `python.exe`/`uvicorn.exe`를 모두 정리한 뒤 하나만 새로 띄울 것.
|
|
6. ✅ 다듬기 & Windows 상시 실행 등록 — 여러 개선을 진행했다:
|
|
- **HTTPS 문제**: 서비스워커/Web Push는 보안 컨텍스트(HTTPS 또는 `localhost`)에서만 동작하는데, 아이폰이 접속하는 `http://<LAN IP>:8000`은 iOS Safari에서 보안 컨텍스트로 인정되지 않아 푸시가 동작하지 않는다. Tailscale의 `tailscale serve --bg 8000`으로 해결 — 별도 인증서 관리 없이 tailnet 내에서 신뢰된 HTTPS(`https://<PC>.<tailnet>.ts.net`)를 제공한다(README "HTTPS로 접속하기" 참고).
|
|
- **폼 검증 UX**: `POST /habits/new`에서 `name: str = Form(...)`(필수)로 두면 빈 문자열 제출 시 FastAPI 자체 검증이 `HabitCreate`의 커스텀 검증보다 먼저 걸려 못생긴 JSON 422가 나온다 — `Form("")`(기본값 빈 문자열)로 바꿔 항상 우리 쪽 `HabitCreate` 검증까지 도달하게 해야 친절한 한글 에러 메시지(`app/routers/pages.py`의 `create_habit_page`)가 나간다. 에러는 `hx-target="#habit-form-error-{habit_type}"`로 폼 내부에 표시된다.
|
|
- **에러 페이지**: `app/main.py`에 `StarletteHTTPException`(404를 페이지 경로에서만 스타일링, `/api/`·`/static/`은 JSON 유지)과 전역 `Exception` 핸들러(트레이스백은 서버 로그에만, 사용자에게는 `500.html`) 등록.
|
|
- **반응형**: 이 환경의 브라우저 자동화 도구는 `resize_window`가 실제 뷰포트에 반영되지 않는 문제가 있어, 실제 창 크기를 바꾸는 대신 `<iframe>`을 임의 픽셀 크기로 만들어 그 안에서 페이지를 로드하는 방식으로 좁은 뷰포트를 재현해 검증했다(iframe은 자신만의 진짜 `window.innerWidth`를 가지므로 media query가 실제로 다르게 평가됨). 이 과정에서 긴 습관 이름 + 액션 버튼이 있는 `.habit-item`이 줄바꿈되지 않고 버튼 텍스트가 세로로 쪼개지는 문제를 발견 — `.btn`에 `white-space: nowrap`과 `flex-shrink: 0`이 빠져있었던 게 원인. `.habit-item`에 `flex-wrap: wrap`을 추가해 좁은 화면에서 액션 버튼이 이름 아래 줄로 자연스럽게 내려가도록 수정.
|
|
- **상시 실행**: `scripts/run_server.ps1` — conda activate 대신 대상 환경의 `python.exe`를 직접 호출(예약 작업은 인터랙티브 셸이 아니므로). **주의**: 이 스크립트에서 `$ErrorActionPreference = "Stop"`을 네이티브 프로세스의 `*>>` 스트림 리다이렉션과 같이 쓰면 안 된다 — PowerShell 5.1은 리다이렉션된 stderr의 각 줄(uvicorn의 정상 INFO 로그 포함)을 `NativeCommandError`로 감싸는데, `-Stop`이 걸려있으면 첫 로그 줄에서 즉시 스크립트가 종료돼 서버가 바로 죽는다. 반드시 `ErrorActionPreference`를 기본값(`Continue`)으로 둘 것. 작업 스케줄러 등록 명령은 README "상시 실행 (Windows)" 참고.
|
|
7. ✅ 구글 OAuth 로그인 + 진짜 멀티유저 전환 — PIN 로그인(`APP_PIN_HASH`, `scripts/hash_pin.py`)을 완전히 제거하고 구글 로그인만 남겼다. 화이트리스트 없이 아무 구글 계정이나 로그인하면 자동으로 `User` 행이 생성된다(1단계에서 언급한 "PIN 로그인"은 이제 존재하지 않음).
|
|
- **왜 유저 테이블을 nullable FK로 연결했나**: `docker-entrypoint.sh`가 컨테이너 기동마다 자동으로 `alembic upgrade head`를 돌리는데, "먼저 구글 로그인을 해야 User가 생긴다"는 순서와 "마이그레이션은 무인 자동 실행"이 충돌한다. `Habit.user_id`/`PushSubscription.user_id`를 NOT NULL로 강제하지 않고 nullable FK로 둬서 이 문제를 피했다 — 기존 PIN 시절 데이터는 마이그레이션 후 `user_id IS NULL`인 채로 남고, 배포자가 구글로 한 번 로그인한 뒤 `python scripts/claim_orphan_habits.py <이메일>`을 실행해 자신에게 연결한다(README "기존 데이터 이관" 참고). `PushSubscription`은 이관 스크립트가 안 건드린다 — 브라우저가 재구독하면 `push_service.save_subscription`이 기존 endpoint 행의 `user_id`를 자동으로 최신 로그인 유저로 갱신하기 때문에 자연스럽게 새 유저에게 붙는다.
|
|
- **IDOR 방지**: 전환 전에는 `habit_service.get_habit(db, habit_id)`가 PK만으로 조회해서 다른 유저의 habit_id를 넣어도 접근 가능한 구멍이었다. 지금은 `habit_service`/`log_service`/`push_service`의 거의 모든 함수가 `user_id`를 필수 인자로 받아 `WHERE user_id = ...`로 필터링한다. 새 서비스 함수를 추가할 때 이 패턴을 깨지 말 것 — 스코핑 안 된 조회 함수를 실수로 API에 노출하면 바로 크로스 유저 데이터 유출이 된다.
|
|
- **스케줄러도 크로스 유저 발송 버그가 될 뻔했다**: `push_service.send_to_all()`이 전체 구독자에게 보내던 구조를 그대로 뒀다면, 유저 A의 습관 알림 시각에 유저 B의 기기로도 알림이 갔을 것이다. `scheduler_service._tick()`은 유저 스코핑 없는 `habit_service.list_active_habits_with_reminders(db)`로 전체 유저의 알림 예약 습관을 훑되(스케줄러는 요청 컨텍스트가 없어 애초에 "현재 유저"가 없으므로 이 함수만 예외적으로 스코핑이 없음), 발송은 `habit.user_id` 기준 `push_service.send_to_user(db, habit.user_id, ...)`로 좁혔다. `habit.user_id is None`(아직 이관 안 된 습관)은 발송 대상에서 제외한다.
|
|
- **OAuth 핸드셰이크와 로그인 세션은 별개의 쿠키**: `authlib`가 리다이렉트 도중 state/nonce를 저장하려면 `request.session`이 있어야 해서 `app/main.py`에 `SessionMiddleware`(쿠키명 `oauth_session`, `max_age=600`)를 추가했다. 로그인 유지용 쿠키(`habit_session`, `itsdangerous` 서명, 30일)와는 완전히 다른 메커니즘이니 헷갈리지 말 것 — `create_session_token(user_id)`가 담는 페이로드도 `{"authenticated": True}`에서 `{"user_id": ...}`로 바뀌었다.
|
|
- **리디렉션 URI는 동적 추론이 아니라 `.env`에 명시**: 이 앱은 Tailscale로 리버스 프록시 없이 HTTPS를 받는 배포가 흔한데(위 6단계 HTTPS 문제 참고), `request.url_for()`로 콜백 URL을 추론하면 프록시 뒤에서 scheme이 `http`로 잘못 잡힐 위험이 있다. 그래서 `GOOGLE_REDIRECT_URI`를 `.env`에 명시적으로 두고 Google Cloud Console의 "승인된 리디렉션 URI"와 정확히 일치시키는 방식을 택했다 — 값이 하나라도 다르면 구글이 `redirect_uri_mismatch`로 콜백을 거부한다.
|
|
- **테스트 관련 주의**: 실제 구글 계정으로 로그인/동의 화면을 클릭하는 마지막 단계는 브라우저 자동화로 재현할 수 없다(진짜 구글 계정 자격증명이 필요한 영역) — `GET /auth/google/login`이 `accounts.google.com`으로 302 리다이렉트하는지, 로그인 후 발급된 세션 쿠키로 API가 정상 동작하는지는 curl로 검증할 수 있지만, 구글 동의 화면 자체는 사람이 직접 로그인해서 `/today`까지 도달하는지 확인해야 한다.
|
|
|
|
## Docker 배포
|
|
|
|
`Dockerfile` + `docker-compose.yml` + `scripts/docker-entrypoint.sh`로 구성했다(README "Docker로 배포하기" 참고). 이 저장소가 만들어진 개발 환경에는 Docker가 설치되어 있지 않아서 **이미지를 직접 빌드/실행해 검증한 적은 없다** — 실제 배포 서버(Docker 있는 곳)에서 처음 빌드할 때 이 문서에 적은 가정들이 맞는지 확인할 것.
|
|
|
|
- `pip install .`(non-editable)로 설치하지만 `app/main.py`의 `StaticFiles(directory="app/static")`/`Jinja2Templates(directory="app/templates")`는 **상대경로**라 컨테이너의 현재 작업 디렉터리(`WORKDIR /app`)에 실제 소스 트리가 `/app/app/...`로 그대로 COPY되어 있어야 동작한다 — 로컬 개발 시 "저장소 루트에서 uvicorn 실행" 관례와 동일한 이유. Dockerfile의 `COPY app ./app` 구조를 바꾸면 이 상대경로도 깨진다.
|
|
- `scripts/docker-entrypoint.sh`가 컨테이너 시작마다 `alembic upgrade head`를 먼저 실행한 뒤 `uvicorn`을 `exec`한다 — 이미 적용된 리비전은 건너뛰므로 재시작마다 실행돼도 안전(idempotent)하다.
|
|
- `.env`는 이미지에 COPY하지 않고(`.dockerignore`) `docker-compose.yml`의 `env_file`로 런타임에 주입한다 — 이미지 레이어에 비밀번호가 남지 않게 하기 위함.
|
|
- **컨테이너는 반드시 1개만 실행**해야 한다 — `scheduler_service`가 프로세스 안에서 APScheduler를 직접 돌리므로, replica를 늘리면 각자 스케줄러를 따로 띄워 같은 알림을 중복 처리하려 든다(`_claim_notification_slot`의 유니크 제약 경합 방지 덕에 죽지는 않지만 애초에 여러 개 띄울 이유가 없다).
|
|
- **타임존**: `date.today()`(`/today`, 완료율/스트릭 계산 등 날짜 관련 로직 전반)는 컨테이너의 시스템 로컬 타임존을 그대로 쓴다. `python:3.13-slim` 베이스 이미지는 기본 타임존이 UTC라서, `Dockerfile`에 `TZ=Asia/Seoul` + `tzdata` 설치 + `/etc/localtime` 심볼릭 링크를 명시하지 않으면 자정~오전 9시(KST) 사이에 서버가 "아직 어제"로 날짜를 계산한다 — 실제로 이 때문에 매일 아침 `/today`가 전날 체크 상태 그대로 보이고 날짜가 안 넘어가는 버그가 있었다. 코드 로직(`date.today()`) 자체는 문제가 아니라 컨테이너 타임존 설정 누락이 원인이었으니, 비슷한 날짜 관련 이상 증상이 배포 환경에서만 재현되면 먼저 컨테이너 타임존을 의심할 것.
|
|
- **HTTPS는 배포 대상에 따라 둘 중 하나**: (1) 집 PC를 직접 서버로 쓰는 경우 → Tailscale(`tailscale serve --bg 8000`), 컨테이너 8000번이 호스트 8000번에 그대로 매핑되므로(`ports: ["8000:8000"]`) 프로세스로 직접 띄우든 컨테이너로 띄우든 Tailscale 입장에서 차이 없음. (2) **이미 리버스 프록시(nginx 등)가 앞단에 있는 서버에 배포하는 경우 → Tailscale 불필요**, 프록시가 도메인의 TLS를 처리하고 컨테이너의 8000번으로 평문 HTTP 프록시하면 된다. 이 앱은 리버스 프록시가 보내주는 `X-Forwarded-Proto` 헤더를 보고 `http`면 301로 `https`로 리다이렉트한다(`app/main.py`의 `redirect_http_to_https` 미들웨어) — 프록시가 이 헤더를 안 보내주면(로컬 `uvicorn` 직접 실행 등) 그냥 통과하므로 로컬 개발엔 영향 없다. 이 미들웨어가 실제로 동작하려면 **프록시가 HTTP(80)와 HTTPS(443) 요청을 모두 앱까지 전달하면서 각각 `X-Forwarded-Proto: http`/`https`를 명시적으로 설정**해야 한다 — 시놀로지 NAS 역방향 프록시처럼 리다이렉트 기능 자체가 없는 프록시 뒤에 배포할 때 특히 이 헤더 설정을 빠뜨리기 쉽다(80번 포트에 대한 프록시 규칙 자체가 없으면 트래픽이 앱에 도달하지도 못하고 NAS 자체 관리 페이지 등 엉뚱한 곳으로 샐 수 있음 — 실제로 이 문제가 있었음). 프록시가 컨테이너와 같은 호스트에서 돈다면 `docker-compose.yml`의 포트 매핑을 `"127.0.0.1:8000:8000"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
|