- Paste-to-embed: pasting an image into the markdown editor uploads it and
inserts  at the cursor. Unlike gallery attachments these aren't
tied to a journal_entry (the entry may not exist yet while composing), so
they're stored per-user under app/media/journal/{user_id}/pasted/ with no
DB row, served through an ownership-scoped route, and never cleaned up
automatically when an entry is deleted -- an accepted tradeoff at this
app's personal scale.
- The markdown sanitizer was stripping all <img> tags (not on the bleach
allowlist), which would have silently deleted every pasted image on save;
added img/src/alt/title while keeping event-handler attributes blocked.
- Cap embedded image width in both the editor pane and the rendered preview
so a large pasted photo can't overflow its card.
- Fix real data loss risk found while testing this: docker-compose.yml had
no volume for app/media, so every container recreate during a deploy wiped
uploaded photos, and deploy_sftp.py was syncing app/media/ (runtime user
data, not source) into the remote build context. Added the volume mount
and excluded media/ from the sync script. Recovered and relocated the
real attachments that had already landed in the wrong place on the NAS
during earlier deploys this session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
37 KiB
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
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,app/routers/journal.py—/api/*하위, JSON in/out API. 라우터 레벨dependencies=[Depends(require_login)]로 기본 보호되고, 유저 객체가 필요한 각 엔드포인트는current_user: User = Depends(require_login)을 시그니처에 추가로 선언한다(FastAPI가 같은 요청 안에서 dependency를 캐싱하므로 DB 조회가 중복되지 않는다). 미인증 시 401 JSON을 반환.journal.py는 현재 첨부파일 스트리밍(GET /api/journal/media/{attachment_id}) 하나뿐이다.app/routers/pages.py,app/routers/journal_pages.py— SSR 페이지(/login,/today,/habits,/history,/journal)와 htmx가 폼 제출로 호출하는 액션 엔드포인트(/habits/new,/habits/{id}/complete,/journal/new등). 미인증 시 401 대신/login으로 303 리다이렉트한다 —_current_user_or_redirect(request, db)가User(로그인됨) 또는RedirectResponse(미인증)를 반환하고, 각 핸들러는isinstance(current, RedirectResponse)로 분기한다. 폼 액션은 대부분 처리 후HX-Redirect헤더로 같은 탭을 새로고침하는 방식으로 단순화되어 있다(부분 DOM 스왑이 아님) — 단/today의 체크 토글과/journal의 day-detail 내부 액션(수정/삭제)처럼 이미 htmx partial 안에 있는 경우는 예외로, 그 partial을 다시 렌더링해서 돌려준다.journal_pages.py는 별도 파일이지만pages.py의_current_user_or_redirect와templates(Jinja2Templates 인스턴스)를 그대로 import해서 재사용한다 —pages.py가 계속 비대해지는 걸 막기 위해 기능별로 페이지 라우터 파일을 분리하기 시작한 첫 사례.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/emailunique,name/picture_urlnullable, 구글 로그인 시 조회/생성(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=일, Pythondate.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_idnullable 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 스크립트를 작성하는 것도 방법.
개발 단계
- ✅ 기반 셋업 + 습관 CRUD (
/habits화면, PIN 로그인 — 7단계에서 구글 OAuth로 대체됨) - ✅ 데일리 체크 &
/today화면 —/habits와 달리 여기는 실제 htmx 부분 갱신을 쓴다: 체크 버튼이#today-content를 통째로partials/today_content.html로 교체한다(개별 아이템만 스왑하지 않는 이유: 진행률 배지도 같이 갱신해야 해서). 이 partial은today.html의 최초 렌더와 토글 응답에서 동일하게 재사용된다 (app/routers/pages.py의_today_context). - ✅ 기록 확인 화면 (월별/주별) —
/history. 두 뷰 모두 순수 SSR(링크 기반 이전/다음 네비게이션)이고 htmx 상호작용은 없다. 월별 집계와 주별 매트릭스는 모두 현재 active 상태인 습관만 기준으로 계산한다(app/services/log_service.py의get_monthly_summary/get_weekly_matrix) — 완료 처리되었거나 삭제된 습관은 과거 날짜라도 집계에서 빠진다. 즉 "그 날 실제로 무엇이 예정되어 있었는가"를 재구성하지 않고 "지금 진행 중인 습관 기준으로 최근 기록이 어떤지"를 보여주는 단순화된 설계다. 히트맵 투명도는app/template_utils.py의heatmap_opacity()가 계산해--color-accent-rgbCSS 변수와 조합한다. - ✅ 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 필요)로 생성. Pillow는 8단계(저널링)의 첨부 이미지 썸네일 생성에도 쓰이기 시작해 지금은 런타임 의존성이다(pyproject.toml의dependencies에 있음, 예전엔dev전용이었음).- 캐싱 전략 (중요): 처음엔 모든 GET을 stale-while-revalidate로 캐싱했는데,
/habits·/today처럼 사용자가 직접 데이터를 바꾸고 곧바로 재방문하는 페이지에서 "방금 저장한 게 사라진 것처럼" 보이는 실사용 버그로 이어졌다(습관 생성/수정 후HX-Redirect로 재이동했을 때 캐시된 옛 페이지가 먼저 뜸).service-worker.js의 fetch 핸들러는 이제request.mode === "navigate"(페이지 탐색)와 그 외(정적 자산)를 분리한다 — 탐색은 네트워크 우선(오프라인일 때만 캐시 폴백), 정적 자산만 캐시 우선 stale-while-revalidate 유지. 서비스워커 캐시 로직을 바꿀 때마다CACHE_NAME버전을 올려야activate핸들러가 구버전 캐시를 정리한다(현재habit-tracker-v2).
- 캐싱 전략 (중요): 처음엔 모든 GET을 stale-while-revalidate로 캐싱했는데,
- ✅ 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를 모두 정리한 뒤 하나만 새로 띄울 것.
- 경합 방지:
- ✅ 다듬기 & 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)" 참고.
- HTTPS 문제: 서비스워커/Web Push는 보안 컨텍스트(HTTPS 또는
- ✅ 구글 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까지 도달하는지 확인해야 한다.
- 왜 유저 테이블을 nullable FK로 연결했나:
- ✅ 저널링 — 습관 체크에 곁들이는 회고와, 습관과 무관한 자유 일기를 함께 지원한다. 카테고리(사용자가 자유롭게 만드는 "일상"/"투자" 같은 저널 묶음,
JournalCategory), 태그, 사진/영상 첨부, 월별 캘린더, 고정 질문 템플릿, 기분 트래킹, "1년 전 오늘" 회상을 이번 출시에 포함했고 AI 기반 프롬프트/피드백만 후속 업데이트로 미뤘다. 별도 프로젝트로 분리하지 않고 기존 구글 OAuth/유저/DB/PWA 인프라를 재사용했다.- 라우터 파일이 하나 더 늘었다: 위 "요청 흐름" 절에서 설명한 API(
habits.py/logs.py/push.py) vs 페이지(pages.py) 2계열 구조를 그대로 따르되,pages.py를 더 비대하게 만들지 않으려고 저널 전용 파일을 새로 뺐다 —app/routers/journal.py(/api/journal/*, 현재는 첨부파일 스트리밍GET /api/journal/media/{attachment_id}하나뿐)와app/routers/journal_pages.py(/journal캘린더·day-detail·엔트리/카테고리 CRUD,pages.py의_current_user_or_redirect와templates를 그대로 import해서 재사용). 앞으로 다른 기능도 규모가 커지면pages.py에 계속 얹기보다 이 패턴(기능별 페이지 라우터 파일 분리)을 따를 것. - 데이터 모델:
app/models/journal.py에JournalCategory(user별 유니크 이름),JournalEntry(entry_date와 created_at 분리 — entry_date는 사용자가 지정 가능해 어제 일을 오늘 쓸 수 있고, 하루+카테고리당 여러 엔트리를 허용하므로 유니크 제약이 없다),JournalTag/JournalEntryTag(M:N),JournalEntryMood,JournalAttachment,JournalPrompt(카테고리 무관 전역 질문 뱅크, 마이그레이션에서 시드 데이터 삽입)가 있다. 카테고리 FK는 non-null이고, 대신journal_service.ensure_default_category가 유저의 첫 저널 진입 시 카테고리가 하나도 없으면 "일상"을 자동 생성해 "카테고리 없음" 케이스를 아예 없앤다. - 기분(mood)은 엔트리당 하나가 아니라 여러 개를 태그처럼 붙일 수 있다: 처음엔
JournalEntry.mood가 단일 nullable enum 컬럼이었는데(0010 마이그레이션), 감정을 동시에 여러 개 고를 수 있어야 한다는 요구로 0011 마이그레이션에서 그 컬럼을 지우고JournalEntryMood(entry_id, mood)연결 테이블로 옮겼다 —JournalTag와 달리 mood는 고정된 enum 값 집합이라 별도 이름 엔티티 없이 값 자체를 복합 PK로 쓴다(journal_tag처럼 이름 조회/생성 로직이 필요 없음).JournalMood는 만족도 스케일 5종(최고/좋음/보통/별로/힘듦)에 구체적 감정 9종(아픔/성취/분노/신남/평온/행복/걱정/피곤/슬픔)을 더해 총 14종이다. 폼에서는 Alpine 배열(moods: [])로 다중 토글 pill을 만들고 쉼표로 join한 hidden input 하나로 제출한다(태그 입력과 동일한 패턴,journal_pages._parse_moods가 서버에서 다시 분해). pill 목록/이모지는journal_pages.py의JOURNAL_MOOD_OPTIONS에 한 곳에 정의해templates.env.globals로 등록, 작성/수정 폼과 day-detail 표시 양쪽에서 재사용한다. - 첨부파일은
/static이 아니라 인증된 라우트로 서빙한다: 이 앱의 유일한StaticFiles마운트(/static)는 완전 공개라 사진/영상처럼 유저별로 비공개여야 하는 파일을 두면 안 된다.journal_service.save_attachment는settings.journal_media_root(기본app/media/journal/{user_id}/{entry_id}/,.gitignore에 등록됨) 아래 로컬 디스크에 저장하고,GET /api/journal/media/{attachment_id}가JournalAttachment→JournalEntry.user_id소유권을 확인한 뒤에만FileResponse로 스트리밍한다. 이미지 첨부는 Pillow로 가로 400px 썸네일(?thumbnail=true쿼리로 구분)을 만들어 목록/캘린더에서 원본 대신 가볍게 로드한다 — 영상은 썸네일을 만들지 않고 재생 링크만 보여준다(ffmpeg 등 별도 도구가 필요해 v1 범위 밖으로 미룸). - 미디어 라우트를
/api/밑에 둔 이유:service-worker.js의 fetch 핸들러는/api/로 시작하는 GET 요청을 무조건 네트워크로 그냥 통과시키고 캐싱하지 않는다(아래 4단계 캐싱 전략 참고). 저널 미디어를/api/journal/media/...에 둠으로써 이 기존 규칙에 공짜로 올라타 서비스워커를 전혀 건드리지 않고도 "비공개 사진/영상이 클라이언트 캐시에 무기한 남는" 문제를 피했다 — 만약/journal/media/...처럼/api/밖에 뒀다면 정적 자산과 똑같이 stale-while-revalidate로 캐싱돼버렸을 것. - 캘린더/day-detail은
/history의 기존 패턴을 그대로 재사용했다:calendar.Calendar(firstweekday=6).monthdatescalendar()로 월 그리드를 만들고, 날짜를 클릭하면 htmx로#day-detail에 partial을 swap하는 구조가 동일하다. 다만 의미가 달라heatmap_opacity(습관 완료율 기반 투명도)는 재사용하지 않고, 그날 등장한 카테고리 색상을 점(.journal-day-dot)으로 표시하는 방식을 새로 만들었다. - 엔트리 수정/삭제/첨부삭제는
HX-Redirect가 아니라#day-detailpartial을 다시 렌더링해서 돌려준다:/today의 체크 토글과 같은 이유 — 이미 htmx로#day-detail에 로드된 상태에서 벌어지는 액션이라 전체 페이지 리다이렉트 대신 그 자리에서 갱신하는 게 자연스럽다. 검증 실패 시에도 같은 partial을edit_error/editing_entry_id컨텍스트와 함께 다시 렌더링해 해당 엔트리의 수정 폼이 열린 채로 에러 메시지를 보여준다(habit_item.html의 Alpineediting토글과 같은 아이디어를 서버 렌더링 쪽에서 구현한 것). - 테스트는 SQLite(단위) + 실제 MariaDB(수동 curl/httpx 스모크)로 이중 검증했다: "1년 전 오늘" 회상과 랜덤 프롬프트 뽑기를 처음엔 각각
MONTH()/DAY(),RANDOM()같은 DB 함수로 짜려고 했는데, 이 프로젝트의 pytest는 SQLite 인메모리 DB를 쓰고 운영은 MariaDB라 방언이 다르면(RANDOM()vsRAND()등) 테스트만 통과하고 운영에서 깨질 위험이 있었다. 그래서 두 기능 다 파이썬 레벨 필터링/random.choice()로 바꿔 방언 종속성을 아예 없앴다 — 개인 규모 데이터라 전체 스캔 비용도 무시할 만하다. 한글 저장/조회가 실제 MariaDB(utf8mb4)에서도 깨지지 않는지는httpx로 직접 폼을 제출해 왕복 검증했다(터미널에 출력할 때는 콘솔 코드페이지 때문에 깨져 보일 수 있어도, 문자열 비교 자체는 정상이었다 — 실제 버그가 아니라 표시상의 문제였음을 확인).
- 라우터 파일이 하나 더 늘었다: 위 "요청 흐름" 절에서 설명한 API(
Docker 배포
Dockerfile + docker-compose.yml + scripts/docker-entrypoint.sh로 구성했다(README "Docker로 배포하기" 참고). 이 저장소가 만들어진 개발 환경 자체에는 Docker가 없지만, scripts/deploy_sftp.py로 실제 배포 서버(시놀로지 NAS, deploy.env 참고)에 소스를 올린 뒤 그 서버에서 SSH로 docker compose build && docker compose up -d를 실행해 검증하는 흐름은 실제로 여러 번 써봤다.
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의 유니크 제약 경합 방지 덕에 죽지는 않지만 애초에 여러 개 띄울 이유가 없다). - 저널 첨부파일(
app/media/)은 반드시 볼륨 마운트해야 한다:docker-compose.yml에volumes: ["./media:/app/app/media"]가 있는데, 이게 없으면docker compose up -d로 컨테이너를 재생성할 때마다(이미지 재빌드 후 흔히 하는 작업) 그 안에 쌓인 유저 업로드 사진이 컨테이너의 임시 쓰기 레이어와 함께 통째로 사라진다 — 실제로 이 마운트가 빠진 채로 배포를 여러 번 반복하다 발견한 문제였다. 또한scripts/deploy_sftp.py의SKIP_NAMES에"media"가 들어있는 것도 같은 이유다 — 이게 없으면 로컬에서 테스트하며 쌓인 진짜 유저 사진이 파일 동기화 스크립트를 통해 원격 빌드 컨텍스트(app/media/)로 그대로 올라가버린다(소스 코드가 아니라 런타임 데이터인데도). 새로 추가되는 유저 업로드 디렉터리가 있다면 똑같이 볼륨 마운트 +deploy_sftp.py제외 둘 다 챙길 것. - 타임존:
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"으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.