포트폴리오 > 지출 관리 특화 가계부 > 진행현황.md

기획 문서 원문

진행현황.md

지출 관리 특화 가계부 프로젝트로 돌아가기

※ 이 설계 문서는 사후에 정리한 산출물이 아니라, Claude Code와 대화하며 프로젝트 설계를 진행한 원문 그대로입니다.

⚠️ [2026-07-10] 이 문서는 더 이상 갱신되지 않습니다. 1차 개발 완료 처리에 따라 이 파일은 docs/1차/진행현황.md로 정리·이관되었습니다(S-5-2·S-1-13·서브앱 전체 2차 이관 표시 포함, "1차 개발 완료" 선언 추가). 2차 개발 관련 내용은 docs/2차/인수인계.md 참고. 아래는 원본 그대로 보존된 내용입니다.

이 파일은 WBS.md의 TASK 목록을 체크리스트로 옮긴 것입니다. TASK 하나가 완료되면 PM 확인 후 이 파일의 해당 체크박스만 [ ][x]로 바꿉니다. WBS.md 자체(설계 문서)는 수정하지 않습니다 — 계획과 진행상황을 분리해서, 계획 문서가 진행 중에 계속 바뀌어 보이지 않게 합니다.

업데이트 규칙: Claude Code는 TASK 완료를 PM이 확인("완료" 또는 "다음" 등 승인 발언)한 직후에만 체크박스를 바꿉니다. 스스로 판단해서 미리 체크하지 않습니다.


2. Phase 0 — 사전 셋업 (시스템, 아키텍처 2장/8장)

  • [x] S-0-1 (시스템) GitHub 저장소 생성 + 브랜치 전략 설정(main/develop)
  • [x] S-0-2 (시스템) pnpm workspace + Turborepo 모노레포 스캐폴딩
  • [x] S-0-3 (시스템) apps/main/web Next.js(App Router) 프로젝트 생성
  • [x] S-0-4 (시스템) Tailwind + shadcn/ui 셋업
  • [x] S-0-5 (시스템) packages/ui, packages/types, packages/utils, packages/config 패키지 골격 생성
  • [x] S-0-6 (시스템) Supabase 프로젝트 생성 (Cloud)
  • [x] S-0-7 (시스템) Supabase CLI 로컬 설치 + supabase init + supabase/ 폴더 구성
  • [x] S-0-8 (시스템) Supabase ↔ GitHub 연동(Working directory 지정, Preview Branch 옵션 활성화)
  • [x] S-0-9 (시스템) packages/supabase-client 패키지 작성 (브라우저/서버 클라이언트 초기화)
  • [x] S-0-10 (시스템) GitHub Actions ci.yml (lint/typecheck/test/build) 작성
  • [x] S-0-11 (시스템) GitHub Actions deploy-migrations.yml 작성 (main push → supabase db push)
  • [x] S-0-12 (시스템) 프론트 배포 방식 결정 및 연동 (Vercel GitHub 연동 또는 8-3-1의 B~D 중 선택)
  • [x] S-0-13 (시스템) 시크릿 등록 (SUPABASE_ACCESS_TOKEN, SUPABASE_SERVICE_ROLE_KEY 등 GitHub Secrets)
  • [x] S-0-14 (시스템) ESLint + Prettier 공유 설정 (packages/config) 작성 및 각 앱에 연결
  • [x] S-0-15 (시스템) Husky + lint-staged 설정 (pre-commit: lint/format, pre-push: typecheck)
  • [x] S-0-16 (시스템) .gitignore + 환경변수 관리 체계 (.env.example, local/CI/production 분리 규칙 문서화)
  • [x] S-0-17 (시스템) README.md 작성 (프로젝트 개요, 로컬 실행법, 디렉토리 설명)
  • [x] S-0-18 (시스템) 루트 tsconfig.json + path alias 설정, 각 패키지 tsconfig 상속 구조
  • [x] S-0-19 (시스템) Node/pnpm 버전 고정 (.nvmrc, package.jsonpackageManager/engines)
  • [x] S-0-20 (시스템) turbo.json 파이프라인 정의 (build/lint/test/dev 태스크 의존관계+캐싱)
  • [x] S-0-21 (시스템) 테스트 프레임워크 설치/설정 — 단위테스트(Vitest) + E2E(Playwright) 골격, pnpm test/pnpm test:e2e 스크립트
  • [x] S-0-22 (시스템) commitlint 설정 (Husky commit-msg 훅과 연계, [TASK-ID] ... 형식 강제)
  • [O] S-0-23 (시스템) GitHub 브랜치 보호 규칙 (main: PR 필수 + CI status check 통과 필수, 직접 push 금지) ⚠️ 규칙 설정 완료, GitHub Free + private 제약으로 미적용 — 팀 확장 시 Pro 업그레이드 후 재적용
  • [x] S-0-24 (시스템) VS Code 워크스페이스 권장 설정 (.vscode/settings.json, extensions.json) — 선택
  • [x] S-0-25 (시스템) Docker 설치/실행 확인 (Supabase CLI 로컬 개발에 필수 — supabase start가 Docker 의존)

3-1. 시스템: 데이터베이스 구현 (아키텍처 4장 ERD)

  • [x] S-1-1 (시스템) CATEGORY 테이블 마이그레이션 + 시스템 기본 카테고리 시드 데이터(seed.sql, 데이터정책_및_시드정의서 1-1 참조)

    2026-07-01 재검증: npx supabase start로 전체 컨테이너 정상 기동 확인(이전 supabase_storage unhealthy 이슈 재현 안 됨 — 당시는 이미지 레지스트리 레이트리밋(429)으로 인한 일시적 지연이었던 것으로 추정). 마이그레이션 적용, 시드 11건(식료품/의류/주거/보험/구독료/관리비/회비/용돈/교통/의료·건강/기타) 전부 데이터정책_및_시드정의서 1-1과 일치, RLS 정책(select/insert/update, delete 정책 미생성)도 3장 표와 일치 확인.
    2026-07-02 PM 요청으로 F-1-4-2와 함께 스키마 변경(20260702013201_category_flat_per_user_defaults.sql): ① parent_id 제거(지출항목은 단일 계층), ② 시스템 기본 지출항목을 전역 공유 행(user_id IS NULL)에서 payment_method와 동일한 "회원가입 트리거로 사용자별 소유 행 자동 생성" 방식으로 전환 — 사용자별로 안전하게 활성/비활성 토글 가능해짐. seed.sql의 전역 카테고리 INSERT는 제거(트리거로 대체). 데이터정책_및_시드정의서 1-1/3장, 아키텍처 설계서 ERD도 함께 갱신 필요.

  • [x] S-1-2 (시스템) PAYMENT_METHOD 테이블 마이그레이션 + 현금(고정) 시드 (데이터정책_및_시드정의서 1-2 참조)
  • [x] S-1-3 (시스템) VENDOR 테이블 마이그레이션
  • [x] S-1-4 (시스템) ITEM 테이블 마이그레이션 (aliases, merged_into_item_id 포함)
  • [x] S-1-5 (시스템) UNIT 테이블 마이그레이션 + 시스템 기본 단위 시드 (데이터정책_및_시드정의서 1-3 참조)
  • [x] S-1-6 (시스템) TRANSACTION 테이블 마이그레이션
  • [x] S-1-7 (시스템) TRANSACTION_DETAIL 테이블 마이그레이션
  • [x] S-1-8 (시스템) BUDGET 테이블 마이그레이션
  • [x] S-1-9 (시스템) 전체 테이블 RLS 정책 작성 (데이터정책_및_시드정의서 3장 표 그대로 구현)

    별도 마이그레이션 없음 — S-1-1~S-1-8 각 마이그레이션에 RLS 포함 완료. 3장 전항목 대조 검증 이상 없음.
    ⚠️ 2026-07-01 F-1-4-1 구현 중 발견: RLS 정책은 맞게 작성됐으나 8개 테이블 전부 authenticated 롤에 테이블 단위 GRANT(SELECT/INSERT/UPDATE(/DELETE))가 누락되어 있었음(config.tomlauto_expose_new_tables 기본 비활성화로 자동 부여 안 됨). PostgreSQL이 GRANT를 RLS보다 먼저 확인하므로 지금까지 authenticated 사용자는 어떤 테이블도 실제로 접근 불가했음(42501 permission denied). 20260701100604_grant_authenticated_table_privileges.sql 마이그레이션으로 보완, 로컬 db reset 후 REST API 재현 테스트로 SELECT/INSERT 정상 동작 확인. 클라우드 프로젝트에도 supabase db push 필요.

  • [x] S-1-10 (시스템) Postgres 함수/트리거: TRANSACTION.amount 자동계산(상세 합계)
  • [x] S-1-11 (시스템) supabase gen types typescript 연동 + packages/types/database.generated.ts 생성 확인

3-2. 시스템: 인증 (아키텍처 3장)

  • [x] S-1-12 (시스템) Supabase Auth 이메일 로그인 활성화 + 설정
  • [ ] S-1-13 (시스템) 소셜 로그인(OAuth Provider) 설정 (선택)

    2026-07-08 진행 중: S-5-1(프로덕션 배포) 최종 점검 중 프로덕션 SMTP 미비 문제를 발견 — 도메인 미보유로 이메일 인증 발신이 당장 불가능해, 이를 우회할 대안으로 구글 소셜 로그인을 이번에 착수(PM 결정). 코드는 완료: app/actions/auth.tssignInWithGoogleAction(요청 헤더에서 origin을 동적으로 읽어 Vercel Preview 배포별 URL 차이에 자동 대응), 로그인/회원가입 화면에 구글 버튼(components/GoogleSignInButton.tsx) 추가, /auth/callback 라우트는 기존 이메일 확인 링크 처리 로직 그대로 재사용(PKCE 코드 교환 로직 동일). 구글 로그인 사용자는 user_metadata.display_name이 아니라 full_name/name으로 이름이 들어와 계정 설정 화면에 폴백 추가.
    PM이 완료한 외부 설정: Google Cloud Console에 OAuth 동의화면(외부/Testing 상태) + OAuth 2.0 클라이언트 ID 생성(리디렉션 URI: Supabase /auth/v1/callback), Supabase 대시보드에 클라이언트 ID/보안 비밀 등록 + URL Configuration(Site URL/Redirect URLs)까지 완료.
    남은 것: 아직 Vercel 프로덕션 배포 전이라 실제 구글 계정으로 끝까지 로그인되는지 미검증. e2e는 실 구글 계정이 필요해 자동화 대상에서 제외(운영자 CRUD와 동일 사유) — 대신 버튼 클릭 시 Supabase OAuth authorize로 정확히 넘어가는지(배선)만 검증(e2e/auth.spec.ts 신규 2건, docs/test-scenarios.md T-1-3-1 절 참고). 배포 후 PM 실사용 테스트로 완료 확정 예정 — 그때 체크박스 갱신.
    2026-07-08 최종 결정 정정: 한때 "이메일 가입 UI를 완전히 없애고 구글 로그인만 남기는" 방향으로 진행했으나, 그러면 이 프로젝트 e2e 스위트 13개 파일 전체(대시보드/예산/캘린더/필터 등 — 인증된 세션이 필요한 거의 모든 테스트)가 이메일 가입 폼으로 테스트 계정을 만드는 방식에 의존하고 있어 전부 깨지는 게 뒤늦게 드러나 되돌림. 대신 PM이 프로덕션 Supabase Auth의 "Confirm email"을 끄는 것으로 해결 — 로컬 개발과 동일하게 이메일 인증 없이 즉시 가입/로그인되므로 SMTP 없이도 기존 이메일 폼을 그대로 쓸 수 있음("Enable email provider"는 켜진 채 유지, "Confirm email"만 끔). 최종 상태: 이메일(인증 없이 즉시) + 구글 로그인 둘 다 제공, e2e 13개 파일 전부 재확인 48/48 통과. 부수로 signUpActionemailRedirectTo 누락 버그도 함께 수정(나중에 Confirm email을 다시 켜더라도 인증 링크가 /auth/callback을 정상적으로 거치도록).

  • [x] S-1-14 (시스템) 인증 미들웨어(Next.js middleware, 세션 검증) 구현

    2026-07-02: F-1-4-2 검증 중 pnpm dev 실행 시 미들웨어의 supabase.auth.getUser()에서 AuthRetryableFetchError: fetch failed 반복 발생. .env.development.local(로컬 Supabase, 127.0.0.1:54321) 설정 자체는 정상이었고, 원인은 로컬 Supabase 스택이 일부 컨테이너만 떠 있던 것 — supabase status 확인 결과 supabase_kong(API 게이트웨이, 포트 54321 담당) 등 6개 서비스가 Stopped 상태였음(DB/storage/auth 등 개별 컨테이너는 떠 있어 Docker Desktop 목록만 보고는 "실행 중"으로 오인하기 쉬움). pnpm supabase stoppnpm supabase start로 전체 스택을 재기동해 Kong까지 정상 기동시키자 오류 재현 안 됨(3회 요청 모두 정상). 앞으로 로컬 개발 시작 시 Docker Desktop만으로 컨테이너를 개별 기동하지 말고 반드시 pnpm supabase start(전체 스택 일괄 기동)로 실행할 것.

3-3. 앱 기능: 인증 화면 (F-3)

  • [x] F-1-3-1 (앱기능) 회원가입 화면/폼
  • [x] F-1-3-2 (앱기능) 로그인 화면/폼
  • [x] F-1-3-3 (앱기능) 로그아웃/세션 만료 처리
  • [x] F-1-3-4 (앱기능) 계정 설정 화면
  • [x] T-1-3-1 (테스트) 인증 단위/통합 테스트 (가입→로그인→로그아웃)

3-4. 앱 기능: 마스터 데이터 관리 (F-1-1-1~3, 1-1-13 일부)

  • [x] F-1-4-1 (앱기능) 지출분류 관리 화면 (현금 표시 + 카드 등록/수정/삭제)

    2026-07-01 PM 완료 확인. 계좌(통장) 등록도 범위에 포함해 구현(PM 확인). 구현 중 발견한 인프라 버그(GRANT 누락, database.generated.ts 중복 스텁, 로그인 레이아웃/입력값 리셋)는 별도 커밋([S-1-9], [S-1-11], [F-1-3-1], [F-1-3-2])으로 분리 처리.
    2026-07-03 정정(F-1-5-13/14 세션): 카드 등록 폼의 "카드 별칭"+"카드사" 두 입력을 "카드사명" 한 칸으로 통합 — card_issuer 컬럼은 삭제하지 않고 유지하되 더는 입력받지 않아 항상 null(cardSchema에서 필드 자체 제거, settings/payment-methods/actions.ts). 원래 설계(가계부_아키텍처_설계.md 1-1①)는 "카드사" 필드로 발급사 단위 집계(F-3-1-1/F-5-4)를 계획했었는데, 표시명이 곧 카드사명 역할을 겸하도록 단순화하면서 집계 단위도 "발급사"가 아니라 "등록된 카드(payment_method_id)"로 변경 — 관련 문서(가계부_아키텍처_설계.md, 기능명세서_IA.md, WBS.md, 이 문서의 F-3-1-1 항목) 함께 갱신함.
    2026-07-03 UI 정리(같은 세션): 카드 등록 줄에서 "등록" 버튼이 아래로 떨어지던 걸 CardFieldstrailingButton 슬롯을 둬서 카드사명 입력·종류 select와 한 줄에 배치(입력창 min-w-0로 줄어들 수 있게). 카드 종류 select의 "종류" placeholder 옵션 제거하고 기본값을 "신용"(CREDIT)으로 변경. 계좌/카드/지출항목/지출처 등록 버튼 문구를 전부 "OOO 추가"→"등록"(진행 중 "등록 중…")으로 통일, 토글 버튼은 "다시 활성화"→"활성화"로 통일(활성화/비활성화 두 상태로만 운영) — F-1-4-2/F-1-4-3에도 동일 적용.
    2026-07-04 UI 정리: 화면 상단 "← 계정 설정으로" 링크 제거(F-1-4-3 항목 메모 참고, 지출항목/품목 관리 화면도 동일 적용).

  • [x] F-1-4-2 (앱기능) 지출항목 관리 화면 (CRUD, 단일 항목)

    2026-07-02 PM 완료 확인. PM 요청으로 부모-자식 계층 제거(단일 항목), 시스템 기본 지출항목을 payment_method와 동일한 회원가입 트리거 방식으로 전환(20260702013201 마이그레이션), 아이콘은 프리셋 팝오버 선택 UI로 구현. "항목분류"→"지출항목" 명칭 변경도 코드/문서 전체 반영(WBS.md 포함, PM 명시 요청에 따른 예외).
    2026-07-03 UI 정리: 등록 버튼 "지출항목 추가"→"등록", 토글 버튼 "다시 활성화"→"활성화"로 통일(F-1-4-1 항목 메모 참고).
    2026-07-04 후속 개선(F-1-4-3과 같은 세션): 지출처 관리와 동일하게 페이지네이션 적용 — 등록순 정렬을 오름차순에서 최신 등록순(내림차순)으로 변경, "등록" 폼을 목록 하단에서 상단으로 이동(새로 등록한 항목이 항상 1페이지 맨 위에 보이도록), 공용 PaginationNav 컴포넌트 사용(F-1-4-3 메모 참고). 상단 "← 계정 설정으로" 링크 제거.
    2026-07-07 PM 요청: 아이콘 프리셋 이모지 개수가 너무 적다는 피드백으로 대폭 확대(기존 16개 → 140개, lib/category-schemas.tsCATEGORY_ICON_GROUPS). fluentemoji.com류 이미지 에셋 사용은 검토 후 기각(새 asset 파이프라인/라이선스 검토 필요) — 기존처럼 유니코드 이모지 문자만 사용, DB icon 컬럼 의미 변경 없음. 가계부 특화 6개 탭(식비/생활/쇼핑·미용/교통/여가/금융·가족)으로 분류해 팝오버에 탭 UI 추가(CategorySection.tsxIconPicker) — 탭 전환 시 그 카테고리 이모지만 그리드로 표시, 팝오버를 열 때 현재 선택된 아이콘이 속한 탭으로 자동 이동.

  • [x] F-1-4-3 (앱기능) 지출처 관리 화면 (CRUD)

    2026-07-02 PM 완료 확인. F-1-4-1/F-1-4-2와 동일 패턴(soft delete 토글 포함)으로 구현, 기본 카테고리 추천(default_category_id) 드롭다운 포함.
    2026-07-03 정정: 한 지출처(예: GS슈퍼)에서 여러 카테고리(식료품+의류 등) 지출이 섞일 수 있어, 지출처를 특정 카테고리에 고정 매칭하는 게 설계상 부적절하다고 PM 판단 — 기본 카테고리 추천 select를 화면에서 제거, VendorFields는 이름 입력만 남김. default_category_id 컬럼은 삭제하지 않고 유지하되 항상 null(폼/스키마(vendor-schemas.ts)/액션(settings/vendors/actions.ts, expenses/actions.tsresolveVendorId)에서 모두 제거). 가계부_아키텍처_설계.md/기능명세서_IA.md도 함께 갱신함. ITEM.default_category_id(품목 단위 추천)는 대상이 아님 — 품목은 카테고리가 고정적이라(양파=식료품) 문제 없음. 관련 아이디어(상세항목별 카테고리 지정)는 docs/백로그.md(B-5)로 기록.
    2026-07-03 UI 정리: 등록 버튼 "지출처 추가"→"등록", 토글 버튼 "다시 활성화"→"활성화"로 통일(F-1-4-1 항목 메모 참고).
    2026-07-04 후속 개선: 화면 상단 "← 계정 설정으로" 링크 제거(카테고리/품목/결제수단 관리 화면도 PM 요청으로 동일 적용). 지출처가 계속 늘어나면 화면이 무한정 길어지는 문제 해결 — 20건/페이지 페이지네이션 추가, 정렬을 등록순 오름차순에서 최신 등록순(내림차순)으로 변경하고 "등록" 폼을 목록 하단에서 상단으로 이동(새로 등록한 지출처가 항상 1페이지 맨 위에 보이도록, PM 확인). 이전/다음 링크 + "N / M" 표시 UI는 apps/main/web/app/(app)/_components/PaginationNav.tsx 공용 컴포넌트로 추출해 지출항목 관리(F-1-4-2)·품목 관리(F-1-6-1)·지출 내역 목록(F-1-5-12, /expenses)까지 모두 이 컴포넌트를 재사용하도록 통일(기존 /budgets·/expenses/calendar의 "이전 달/다음 달" 월 단위 내비게이션은 총 페이지 수 개념이 없는 별개 패턴이라 통합 대상에서 제외). 로컬 Supabase 대상 임시 Playwright 스펙으로 21개 등록 후 페이지네이션/최신순 정렬/이전·다음 이동 검증하고 삭제.

  • [x] T-1-4-1 (테스트) 마스터 데이터 CRUD 단위 테스트

    2026-07-01: WBS 원 범위(F-1-4-1~3 전체)는 F-1-4-2/F-1-4-3 미구현으로 지금 다 커버 불가 — PM 확인 하에 F-1-4-1(지출분류 관리) 스키마 단위테스트만 우선 작성(__tests__/payment-method-schemas.test.ts, 10건 통과). F-1-4-2/3 구현 시 이 TASK를 재오픈해 이어서 작성할 것. E2E는 샌드박스 제약으로 미작성 — 로컬/CI에서 별도 필요.
    2026-07-02 재오픈: F-1-4-2(지출항목)/F-1-4-3(지출처) 구현에 맞춰 __tests__/category-schemas.test.ts(5건), __tests__/vendor-schemas.test.ts(4건) 추가 — WBS 원 범위(F-1-4-1~3) 스키마 단위테스트 전체 19건 통과.
    2026-07-02 PM 완료 확인. E2E 작성과 Google Sheets 동기화(sync-test-sheet.mjs 실행)는 PM 결정으로 docs/백로그.md(B-2, B-3)로 이관 — 이 TASK는 스키마 단위테스트 범위로 완료 처리.

3-5. 앱 기능: 지출 입력 (F-1-1-4~10) — 가장 복잡한 영역, 세분화 필수

  • [x] F-1-5-1 (앱기능) 지출 입력 폼 기본 골격 (지출분류/지출항목/지출처 선택 UI)

    2026-07-02 PM 완료 확인. /expenses/new에 선택 UI 골격만 우선 구현(저장 버튼 비활성) — 이후 PM 피드백으로 F-1-5-2/F-1-5-3과 한 세션에 이어서 병합 처리(URL도 /expenses/create로 변경). 자세한 내용은 F-1-5-3 항목 참고.

  • [x] F-1-5-2 (앱기능) 지출처 자동완성(검색) 컴포넌트

    2026-07-02 PM 완료 확인. 지출처(Vendor)는 카드 명세서 가맹점명과 같은 성격이라 ITEM처럼 동의어 사전/병합 기반 정규화가 필요 없다는 PM 판단에 따라, ITEM 패턴(자동완성에 없으면 확인 팝업 후 생성)을 그대로 가져오지 않고 확인 절차 없이 자동완성 선택 또는 자유 입력 즉시 저장 시점에 생성하는 방식으로 설계 변경. ExpenseEntryForm.tsxVendorCombobox — 입력값으로 기존 지출처를 부분일치 필터링해 드롭다운 노출, 선택 시 정확한 기존 이름으로 채움. 동시 중복 생성은 서버 액션의 find-or-create(정확 일치 이름 재사용)로 방지. 지출처 병합 화면은 정규화 대상이 아니라는 결론으로 백로그에도 올리지 않음.
    2026-07-04 버그 수정: 비활성화(is_active=false)한 지출처를 지출 입력에서 정확히 같은 이름으로 다시 쓰면, resolveVendorIdis_active 여부를 확인하지 않고 그대로 재사용만 해 자동완성 목록엔 계속 노출되지 않는(재활성화 안 되는) 상태로 남는 문제 발견 — 기존 지출처를 찾을 때 재사용 시점에 is_active: true로 함께 갱신하도록 수정(expenses/actions.tsresolveVendorId).

  • [x] F-1-5-3 (앱기능) 금액 직접입력 모드(상세 미사용) 저장 로직

    2026-07-02 PM 완료 확인. apps/main/web/app/(app)/expenses/create/actions.tsaddExpenseAction — zod 검증(lib/expense-schemas.ts) → 지출처 find-or-create → transaction insert(input_type: MANUAL, has_detail 기본값 false). 트랜잭션 저장 시 새로 생성된 지출처의 default_category_id도 함께 채워 넣음(선택한 지출항목 재사용, 부가 이득).
    검증(Playwright, PM이 설치한 로컬 Chromium) 중 React 19 버그 발견 및 수정: 서버 액션 제출 후 폼이 네이티브 reset되는데, 값이 안 바뀐 검증 실패 케이스(예: 금액만 오류)에서 <select>(지출분류/지출항목)가 React 상태와 무관하게 placeholder로 되돌아가는 현상 확인 — <input>은 영향 없음. resetKey로 매 제출 결과마다 해당 <select>를 리마운트시켜 controlled value를 재적용하도록 수정, 회귀 테스트로 확인.
    화면이 실제로 완성(저장 동작)되어 nav-items.ts/기능명세서_IA.md 1-1장에 "지출 입력"(/expenses/create) 노출 추가.

  • [x] F-1-5-4 (앱기능) 상세항목 추가 UI(동적 행 추가/삭제)

    2026-07-02 PM 완료 확인. ExpenseEntryForm.tsxDetailItemRows 추가 — "+상세항목" 클릭 시 직접입력 금액 필드를 감추고 빈 행(품목명/수량단위 원본텍스트/금액)을 노출, "+ 항목 추가"/행별 "삭제"로 동적 추가·삭제. 별도의 "직접입력으로 전환" 버튼은 두지 않고, 행을 하나씩 삭제해 0개가 되면 자동으로 직접입력 모드로 복귀(상태 기반 조건부 렌더링이라 별도 로직 불필요). transaction_detail.item_id가 NOT NULL FK라 지금 단계에선 실제 저장이 불가능하므로 상세행이 1개 이상이면 저장 버튼을 의도적으로 비활성화(안내 문구 노출) — Item 마스터 연동(F-1-5-5/5-6), 수량/단위 파싱(F-1-5-8), 실제 저장(F-1-5-11)은 이후 TASK로 분리.
    2026-07-02 PM 피드백으로 F-1-5-10("상세 금액 합계 → Transaction.amount 자동표시(읽기전용) UI 연동")의 클라이언트 표시 부분을 이 TASK로 앞당겨 함께 구현 — 상세행 금액을 입력/삭제할 때마다 detailRows.reduce로 합계를 즉시 계산해 읽기전용 "금액(상세항목 합계, 자동계산)" 필드에 천단위 구분(toLocaleString)으로 표시. DB 저장과 무관한 순수 클라이언트 계산이라 스키마/백엔드 영향 없음 — 실제 저장 연동은 여전히 F-1-5-11 범위.

  • [x] F-1-5-5 (앱기능) 상세항목 자동완성(Item 검색) 컴포넌트

    2026-07-02 PM 완료 확인. ExpenseEntryForm.tsxItemCombobox 추가 — 상세행 품목명 입력을 사용자의 ITEM 마스터(name + aliases) 자동완성으로 대체(VendorCombobox와 동일 구조 재사용). page.tsx에서 item 목록을 merged_into_item_id IS NULL 조건으로 조회해 전달. 선택 시 DetailRow.itemId에 기존 Item id 연결, 자유 입력 중에는 itemId를 비워 "아직 연결 안 됨" 상태 유지. 신규 Item 생성 확정(F-1-5-6), 유사항목 제안/동의어 사전(F-1-5-7), 실제 저장(F-1-5-11)은 계속 범위 밖 — 상세행이 있으면 저장 버튼은 여전히 비활성.

  • [x] F-1-5-6 (앱기능) 신규 Item 생성 플로우(자동완성 없을 때)

    2026-07-02 PM 완료 확인. WBS상 F-1-5-11(실제 저장)이 F-1-5-6에 의존하지 않는 점에 근거해 PM과 범위를 "UI 피드백만"으로 확정 — 자동완성에 일치하는 Item이 없는 텍스트를 입력하면(itemId 미설정) 해당 행에 'OOO' 새 품목으로 등록됩니다 안내 문구만 노출. DB에는 아무것도 쓰지 않음(실제 ITEM 생성/저장은 F-1-5-11에서 처리) — 상세행이 있으면 저장 버튼은 계속 비활성 유지.

  • [x] F-1-5-7 (앱기능) 유사 항목 제안 팝업 (편집거리 기반, 동의어 사전 연동)

    2026-07-02 PM 완료 확인 — 편집거리(Levenshtein) 기반 제안만 구현, 동의어 사전 연동은 Phase 3로 이관. 착수 전 확인 결과 SYNONYM_DICTIONARY 테이블이 아직 없음(데이터정책_및_시드정의서.md 1-4장 "Phase 3에서 시드", S-3-1 계열) — WBS에는 F-1-5-7이 F-1-5-5에만 의존하는 걸로 되어 있어 이 선행조건이 누락돼 있었음, PM 확인 하에 편집거리 부분만 먼저 진행.
    ExpenseEntryForm.tsxlevenshteinDistance/findSimilarItem 추가 — 자동완성에 정확히/부분일치하는 후보가 없을 때만(예: "대파" 입력, "파"는 있지만 부분일치 안 됨) 편집거리가 가까운 기존 Item을 찾아 "혹시 'OOO'와 같은 품목인가요?" 제안, 클릭 시 해당 item에 연결. 받침 유무에 따른 조사(와/과) 처리 버그를 검증 중 발견해 waOrGwa 헬퍼로 수정. 무관한 텍스트는 기존 F-1-5-6 "새 품목으로 등록됩니다" 안내로 폴백. 동의어 사전 연동은 Phase 3에서 SYNONYM_DICTIONARY 생성 후 별도 TASK로 재오픈 필요.

  • [x] F-1-5-8 (앱기능) 수량/단위 입력 필드 + 파싱(숫자+단위 분리) 로직

    2026-07-02 PM 완료 확인. ExpenseEntryForm.tsxparseQuantityText 추가 — 아키텍처 1-1⑥ 규칙 그대로 구현: 선행 숫자 + 나머지가 한글/영문만이면 구조화 성공(예: "1개"→수량1/단위'개', "5"→수량5/단위없음), 나머지에 숫자/기호가 섞이거나("1+1") 선행 숫자가 아예 없으면("한줌") 구조화 실패로 판정해 원본 텍스트만 보존 안내. 각 상세행 수량/단위 입력 아래에 파싱 결과 미리보기 텍스트로만 노출 — 단위 마스터(UNIT) 매칭/신규 단위 등록 제안(F-1-5-9), 실제 저장(F-1-5-11)은 범위 밖, DB 쓰기 없음.

  • [x] F-1-5-9 (앱기능) 단위 자동완성/신규 단위 등록 제안

    2026-07-02 PM 완료 확인. page.tsx에서 unit 목록(시스템 기본 user_id IS NULL + 내 커스텀)을 조회해 전달, ExpenseEntryForm.tsx에서 parseQuantityText 결과의 단위 텍스트를 UNIT 마스터와 대소문자 무시 정확 매칭 — 매칭되면 조용히 통과, 없으면 "'OOO'는 새 단위로 등록됩니다" 안내(F-1-5-6/자동완성과 동일하게 UI 피드백만, DB 쓰기 없음). 검증 중 "'스푼'는"처럼 받침 있는 단어에 하드코딩된 "는" 조사를 쓴 버그를 발견 — F-1-5-7의 와/과 버그와 같은 유형이라 hasBatchim 공통 헬퍼로 리팩터링해 waOrGwa/eunOrNeun 둘 다 재사용하도록 정리.

  • [x] F-1-5-10 (앱기능) 상세 금액 합계 → Transaction.amount 자동표시(읽기전용) UI 연동

    2026-07-02 PM 완료 확인(F-1-5-4 세션에서 선반영, 별도 노트는 F-1-5-4 항목 참고) — 클라이언트에서 detailRows.reduce로 합계를 즉시 계산해 읽기전용 필드에 표시. DB 저장 연동은 F-1-5-11에서 처리.

  • [x] F-1-5-11 (앱기능) 지출 저장(API 연동, 검증)

    2026-07-02 PM 완료 확인. 상세입력 모드도 실제 저장되도록 actions.tsaddExpenseAction을 확장 — 숨김 input hasDetail로 모드 분기, resolveVendorId/resolveItemId/resolveUnitId 헬퍼로 find-or-create(Item은 이름 완전일치, Unit은 대소문자 무시 일치), 같은 제출 안 동일 신규 이름 중복 생성을 막는 요청 스코프 캐시(Map) 적용. transaction insert(amount=서버에서 재검증한 합계, has_detail=true) 후 transaction_detail bulk insert(단일 INSERT라 그 자체로 원자적) — bulk insert 실패 시 방금 만든 transaction을 보상 삭제. parseQuantityTextlib/quantity-parse.ts로 옮겨 클라이언트/서버 양쪽에서 재사용. 폼 각 상세행에 detailItemText/detailQuantityText/detailAmount/detailItemId(hidden) name을 부여해 FormData로 배열 전달, 저장 버튼도 상세모드에서 더 이상 비활성화하지 않음.
    검증 중 이전 F-1-5-3의 "select 리셋" 수정이 불완전했음을 발견해 재수정. 값과 화면 표시가 어긋나는(desync) 더 심각한 버그였음 — 두 번째 이후 옵션(예: "의류")을 고르고 검증 실패를 유발하면 실제 제출값은 "의류"로 남는데 화면엔 "🛒 식료품"(첫 옵션)이 선택된 것처럼 보였음(이전 검증은 매번 우연히 첫 옵션으로만 테스트해서 못 잡았던 케이스). key 리마운트 방식(resetKey) 대신 PaymentMethodSelect/CategorySelectref+useEffect를 추가해 매 액션 결과(state 참조 변화)마다 DOM .value를 명시적으로 재동기화하도록 교체 — 실패 시 실제 선택값이 화면에도 정확히 유지되고, 성공 시엔 진짜로 플레이스홀더로 초기화됨을 재검증.
    알려진 한계: transaction_detail bulk insert 실패 시 보상 삭제 방식은 supabase-js가 멀티 스테이트먼트 트랜잭션을 지원하지 않아 완벽한 원자성은 아님(RPC 함수로 가면 해결 가능, 이번 범위 밖).
    2026-07-09 버그 수정: (1) 지출분류/지출항목 <select>의 placeholder 옵션이 disabled라 아무 것도 선택하지 않으면 FormData.get()""가 아닌 null을 반환 — z.string().min(1, ...)이 타입 체크 단계에서 먼저 걸려 커스텀 한글 메시지 대신 Zod 기본 영문 메시지("Invalid input: expected string, received null")가 노출되던 문제를 expense-schemas.tsrequiredSelectField(null/undefined → 빈 문자열 정규화 preprocess) 헬퍼를 추가해 해결. (2) 상세입력 모드(hasDetail=true)의 actions.ts 검증 실패 처리가 parsed.error.issues[0]만 최상단 배너로 보여주고 있어, 필드 하나만 비워도 에러가 해당 필드 아래가 아니라 폼 맨 위에 뜨던 문제를 직접입력 모드와 동일하게 flatten().fieldErrors 기반 필드별 표시로 통일(addExpenseAction/updateExpenseAction 양쪽 모두). ExpenseEntryForm.tsx에 상세항목(details) 배열 레벨 에러를 위한 FieldError 슬롯도 추가. 기존 테스트가 빈 문자열("") 케이스만 커버해 이 null 케이스를 못 잡았던 것이 원인이라 expense-schemas.test.ts에 회귀 테스트 추가.
    2026-07-09 후속 개선(같은 버그의 UX 후속): 상세항목을 많이 입력해 화면이 길어진 상태에서 검증에 실패하면, 위쪽 필드(지출분류/지출항목 등)의 에러 문구가 스크롤 밖이라 눈에 안 띄는 문제 — ExpenseEntryForm.tsxformRef + useEffect([state])를 추가해 검증 실패 시 폼 순서상 가장 위에 있는 에러 필드로 자동 포커스 이동(브라우저 기본 동작으로 스크롤도 함께 됨). amount는 실제 제출값을 담는 input이 name="amount" hidden이라 포커스가 안 돼 화면에 보이는 짝(id="amount-visible")을 대신 타겟팅하도록 AmountInputid prop 추가(상세항목 행의 detailAmount는 같은 name이 여러 개 반복돼 id를 주지 않음 — 중복 id 방지). details(배열 레벨 에러)는 상세항목 섹션 첫 번째 행 품목명 입력으로 이동. Playwright로 모바일 폭에서 상세항목 8개 입력 후 저장 버튼까지 스크롤한 상태로 지출항목 미선택 제출 → 포커스가 지출항목 select로 이동하고 뷰포트 안에 보이는 것까지 확인.

  • [x] F-1-5-12 (앱기능) 지출 내역 리스트 화면 (페이지네이션)

    2026-07-02 PM 완료 확인. /expenses 신규 — FK 임베드 select로 지출분류/지출항목/지출처/상세건수 한 번에 조회, 페이지당 20건 이전/다음 페이지네이션.
    ⚠️ 검증 중 발견한 캐시 이슈(백로그 B-4로 등록): 다른 라우트(/expenses/create)에서 저장한 직후 /expenses를 처음 방문하면 방금 저장한 항목이 1회에 한해 누락되고 재방문 시엔 항상 정상 — revalidatePath("/expenses") 누락이 1차 원인이라 두 저장 경로 모두에 추가했고, export const dynamic = "force-dynamic"(page.tsx) + Supabase 서버 클라이언트에 cache: "no-store" fetch 옵션(packages/supabase-client/src/server.ts, 전역 적용)까지 더했지만 1회성 지연 자체는 완전히 없어지지 않음 — Next dev(Turbopack) 캐시 무효화 전파 지연으로 추정, pnpm build && pnpm start 프로덕션 빌드에서 재현 여부 확인 필요(B-4 참고). 앞으로 다른 라우트의 mutation을 화면에 즉시 반영해야 하는 TASK(F-1-5-13 필터, F-1-5-14 수정/삭제 등)에서 테스트가 "방금 저장/수정한 게 목록에 안 보인다"처럼 실패하면, 버그를 새로 찾았다고 착각하지 말고 먼저 이 항목/B-4를 확인하고 재방문(재조회) 한 번으로 정상화되는지부터 볼 것.
    2026-07-04 리팩터(F-1-4-3과 같은 세션): 인라인으로 작성돼 있던 이전/다음 페이지네이션 블록을 설정 화면들과 공유하는 PaginationNav 공용 컴포넌트(apps/main/web/app/(app)/_components/PaginationNav.tsx) 호출로 교체 — 동작/필터 유지 로직 변화 없음, 코드 통일 목적. Playwright로 필터(지출항목) 적용 상태에서도 다음 페이지 이동 시 categoryId 쿼리가 유지되는지 재검증.

  • [x] F-1-5-13 (앱기능) 지출 내역 필터(기간/분류/카테고리/지출처)

    2026-07-03 PM 완료 확인. /expensesExpenseFilters.tsx(GET 폼, JS 없이 동작) 추가 — 시작일/종료일, 지출분류(현금/카드 optgroup), 지출항목, 지출처 select. page.tsx에서 searchParams로 값을 읽어 transaction 쿼리에 gte/lte(occurred_at), eq(category_id/payment_method_id/vendor_id) 조건 적용. 필터 적용 상태는 페이지네이션 링크에도 그대로 유지, 결과 0건이면 "조건에 맞는 지출 내역이 없습니다" + 필터 초기화 링크로 분기.
    2026-07-03 UI 정리(같은 세션, 지출 입력/지출 내역 화면 공통 반영):

    1. 지출분류 표기를 "아이콘 표시명/구분"(예: 💵 우리은행/현금, 💳 신한카드/신용)으로 통일 — lib/payment-method-format.ts(formatPaymentMethodLabel/paymentMethodLabelText) 신규, 지출 입력 폼(ExpenseEntryForm)·이 화면의 필터(ExpenseFilters)·지출 내역 목록(page.tsx) 3곳 공통 적용. 현금/카드 대분류(optgroup) 제거, 등록순 평면 목록으로 변경. 목록 화면은 <select>가 아니라 실제 JSX라 컬러 아이콘을 쓸 수 있어 PaymentMethodTag.tsx(lucide Banknote/CreditCard)로 별도 렌더링 — 현금/체크/신용을 --paylens-cash/--paylens-check/--paylens-credit 전용 색상 토큰으로 구분(DESIGN_GUIDE.md/CLAUDE.md 컬러 표 갱신, 처음엔 --paylens-action/--paylens-data-soft 재사용했으나 현금과 체크 색이 비슷해 구분 안 된다는 피드백으로 전용 토큰 3개 신설).
    2. 지출처 필터를 <select>에서 검색 자동완성 콤보박스(VendorFilterCombobox)로 교체 — 지출처가 계속 늘어나 select 옵션이 무한정 길어지는 문제 방지. 검색 결과는 개수 제한 없이 모두 표시하고 드롭다운 자체에 max-h-64 스크롤 적용.
      2026-07-07 PM 요청: 은행 계좌로 등록한 지출분류가 지갑(현금)과 똑같이 "현금"으로만 표시돼 헷갈린다는 피드백 — DB에는 둘 다 type=CASH로 저장되지만(계좌는 subtype=AUTO_TRANSFER), 표시 라벨에서 계좌는 "계좌", 지갑(현금, subtype=CASH_SPEND)은 기존대로 "현금"으로 구분(lib/payment-method-format.tspaymentMethodLabelText). 원래 데이터정책 문서엔 "이 내부 코드는 화면에 노출하지 않는다"고 돼 있었는데 PM 결정으로 방침 변경 — docs/데이터정책_및_시드정의서.md 1-2장도 함께 갱신. subtypePaymentMethodTag/formatPaymentMethodLabel 호출부(지출 입력 select, 지출 내역 목록/캘린더, 대시보드 지출분류별 차트) 전체에 select 절 추가.
  • [x] F-1-5-14 (앱기능) 지출 수정/삭제 화면

    2026-07-03 PM 완료 확인. ExpenseEntryForm.tsx/actions.tsexpenses/create/에서 expenses/ 공용 위치로 이동해 생성/수정 화면이 폼과 지출처·품목·단위 find-or-create 헬퍼를 공유하도록 재구성. transactionId/initialValues/initialDetailRows가 주어지면 수정 모드로 동작(updateExpenseAction 사용, 상세항목은 부분 diff 없이 전체 삭제 후 재삽입, 성공 시 목록으로 redirect). /expenses/[id]/edit 신규 라우트 — 기존 값 프리필(수량/단위는 quantity_raw_text 또는 quantity_value+단위명으로 복원), 하단 삭제 버튼(DeleteExpenseButton.tsx, confirm() 확인 후 실제 행 삭제 — TRANSACTION은 soft delete 정책 없음, transaction_detail은 FK CASCADE). 목록 화면 각 카드가 수정 화면으로 이동하는 링크로 동작.
    2026-07-03 UI 정리: 금액 입력에 천단위 콤마 표시(AmountInput, ExpenseEntryForm.tsx, 직접입력 금액/상세항목 금액 모두 적용) — 화면엔 "15,000"처럼 콤마로 보여주고(<input type="text">+toLocaleString), 실제 제출값은 같은 이름의 숨김 input에 숫자만 담아 전달 — zod 검증/DB 저장 로직은 변경 없음.
    2026-07-04 변경(F-1-10-2 캘린더 수정 팝업 작업의 일부): updateExpenseAction이 캘린더 팝업에서도 재사용되면서, 위 노트의 "성공 시 목록으로 redirect"가 더 이상 서버 액션 안에서 일어나지 않음 — addExpenseAction처럼 상태만 반환하도록 변경하고, 이 화면(전체화면 수정)은 신규 EditExpenseFormClient.tsx(클라이언트 래퍼)가 onSuccess에서 router.push('/expenses')로 이동을 대신 처리. 사용자에게 보이는 동작(저장 후 목록 이동)은 동일, 구현 경로만 변경 — Playwright로 재검증 완료.
    ⚠️ 2026-07-07 버그 수정: 목록(/expenses, F-1-5-12)이 TanStack Query로 /api/expenses를 클라이언트에서 가져오는 구조로 바뀌면서, 삭제 후 deleteExpenseActionrevalidatePath(서버 RSC 캐시만 무효화)로는 목록의 React Query 캐시(staleTime 30초)가 갱신되지 않아 삭제한 항목이 새로고침 전까지 그대로 보이는 버그 발견 — DeleteExpenseButton.tsx에서 삭제 성공 시 queryClient.invalidateQueries({queryKey: ["expenses-offset"]})/["expenses-cursor"]를 직접 호출하도록 수정. Playwright로 수정 전 재현 → 수정 후 해결까지 확인.
    2026-07-07 PM 요청: 지출 입력/수정 폼에 "비고"(메모) 필드 추가 — DB transaction.memo 컬럼은 이미 있었으나(아키텍처 설계 당시 생성) 앱에 연결 안 돼 있던 것을 연결. 숫자/특수문자 포함 자유 텍스트, 최대 1000자(expense-schemas.ts, maxLength + 실시간 글자수 카운터 n/1,000자). 신규입력/전체화면 수정/캘린더 팝업 모두 ExpenseEntryForm 공유라 한 번에 적용.

  • [x] T-1-5-1 (테스트) 지출 입력 단위테스트(금액 자동계산 검증 포함)

    2026-07-03 PM 완료 확인. ExpenseEntryForm.tsx의 상세항목 합계 자동계산(F-1-5-10) 로직을 lib/expense-calculations.tssumDetailAmounts로 추출해 재사용+테스트. expense-schemas.test.ts(13, 직접/상세입력 스키마 검증), quantity-parse.test.ts(8, 수량/단위 파싱), expense-calculations.test.ts(4, 금액 자동계산 합계) 총 25개 신규 — docs/test-scenarios.md 기록, scripts/test-data/T-1-5-1.mjs 생성 완료. E2E는 T-1-5-2로 분리.

  • [x] T-1-5-2 (테스트) 지출 입력 E2E 테스트(직접입력/상세입력 두 경로)

    2026-07-03 PM 완료 확인. 이 샌드박스 환경엔 헤드리스 브라우저 구동용 시스템 라이브러리가 없어 Playwright 자동화는 T-1-3-1/T-1-4-1과 동일하게 불가 — 대신 PM이 로컬 dev 서버(pnpm dev + supabase start)에서 직접입력/상세입력 두 경로를 포함해 이번 세션에서 변경된 지출 입력/수정/삭제/필터 전체 흐름을 수동 검증 완료. 자동화된 Playwright 회귀테스트는 docs/백로그.md(B-6)로 분리.

3-6. 앱 기능: 품목/단위 관리 화면 (F-1-1-8)

  • [x] F-1-6-1 (앱기능) 품목(Item) 목록 화면

    2026-07-03 PM 완료 확인. /settings/items 신규 — 이름순 조회, 병합된 품목은 제외(F-1-6-3에서 다룸). IA F-1-1-8 범위상 등록 폼 없이 조회만(품목은 지출 입력 자동완성을 통해서만 생성). 각 행에 별칭 태그, 기본 카테고리 추천 배지 표시. ExpenseEntryForm.tsxitemAliasesToArraylib/item-aliases.ts로 추출해 공용화. nav-items.ts/settings/page.tsx/기능명세서_IA.md 1-1장 함께 갱신.
    2026-07-04 후속 개선: 품목이 지출 입력을 통해 계속 늘어나는 특성상 페이지네이션 적용(이름순 정렬은 유지, 등록 폼이 원래 없어 위치 변경은 대상 아님, 공용 PaginationNav 컴포넌트 사용 — F-1-4-3 메모 참고). 주의가 필요했던 부분: 병합(F-1-6-3) 대상 드롭다운이 페이징된 목록만 참조하면 1페이지 품목을 2페이지 이후 품목으로 합칠 수 없는 실질적 기능 축소가 생겨, ItemSection에 페이징 없는 전체 품목 목록(id/name만 경량 조회, allItems prop)을 별도로 전달해 병합 대상 선택은 항상 전체 품목 기준으로 동작하도록 분리(items/page.tsx). 상단 "← 계정 설정으로" 링크도 제거. 로컬 Supabase 대상 임시 Playwright 스펙으로 지출 1건에 상세항목 22개를 담아 품목 22개를 생성한 뒤, 1페이지 품목을 2페이지 품목으로 실제 병합까지 검증하고 삭제.

  • [x] F-1-6-2 (앱기능) 품목 별칭 추가/수정 UI

    2026-07-03 PM 완료 확인. /settings/items를 클라이언트 컴포넌트(ItemSection.tsx)로 전환 — 별칭 태그마다 × 삭제 버튼(removeAliasAction, 확인 없이 즉시 호출), "+ 별칭 추가" 인라인 폼(addAliasAction, 대소문자 무시 중복 체크 후 item.aliases jsonb 배열에 추가). lib/item-schemas.ts(aliasSchema) 신규 + 단위테스트 4건. item 테이블은 이미 UPDATE GRANT/RLS 있어 마이그레이션 불필요.

  • [x] F-1-6-3 (앱기능) 품목 수동 병합(merge) UI + 과거 집계 재계산 트리거

    2026-07-03 PM 완료 확인. /settings/items 각 행에 병합 컨트롤(대상 품목 select + confirm() 확인) 추가. mergeItemAction: ① transaction_detail.item_id를 source→target으로 일괄 재배정(MV가 아직 없는 Phase 1에선 이게 "과거 집계 재계산" — 이후 item_id 기준 집계가 바로 target 기준으로 정확해짐) → ② source 이름/별칭을 mergeAliases로 target에 흡수(검색 연속성 유지) → ③ 마지막에 source에 merged_into_item_id 표시(중간 실패 시에도 source가 활성 상태로 남아 재시도 가능하도록 순서 배치). lib/item-aliases.tsmergeAliases 추가 + 단위테스트 7건. 알려진 한계: supabase-js 멀티 스테이트먼트 트랜잭션 미지원으로 완벽한 원자성은 아님(기존 지출 저장 로직과 동일 제약).
    2026-07-04 PM 문의 대응(사용자 가이드용 동작 정리): "병합했는데 지출 보기엔 여전히 옛 이름으로 보인다"는 질문 — transaction_detail.item_raw_text(그 당시 실제 입력한 텍스트)는 병합이 건드리지 않고 그대로 보존(의도된 동작, 영수증 히스토리 개념). 병합의 진짜 효과는 ①과거 지출의 item_id는 즉시 재배정됨 ②새 지출은 자동완성에서 제안을 실제로 클릭해서 선택해야만 병합된 품목으로 기록됨(타이핑만 하고 선택 안 하면 resolveItemId가 별칭이 아니라 품목 이름 정확 일치만 검사해 옛 품목을 그대로 재사용) ③lib/item-merge.tsresolveCanonicalItemId/groupByCanonicalItem은 향후 품목별 통계(Top10, F-3-1-3)에서 병합된 품목들의 지출을 합산하기 위한 로직인데, 현재 시점엔 단위테스트에서만 쓰이고 실제 화면(대시보드/목록) 어디에도 연결되어 있지 않음 — F-3-1-3 착수 시 이 로직을 붙일 것. 즉 병합의 본질은 "지금 당장의 화면 표시 변경"이 아니라 "나중에 품목별 통계를 정확하게 합산하기 위한 사전 정지작업"임. /settings/items 안내 문구를 이 취지에 맞게 갱신(자동완성 제안 선택 필요 조건 명시).
    2026-07-04 PM 추가 확인 + 동작 수정: "병합 의도 자체가 앞으로도 같은 이름으로 입력하면 계속 하나로 잡히길 원하는 것"이라는 지적에 따라, 위 ②(제안을 직접 클릭해야만 병합 반영)를 수정 — expenses/actions.tsresolveItemIditemId 미지정 시 이름 완전일치만 보던 것을, 사용자의 전체 품목(이름+별칭+merged_into_item_id)을 한 번에 가져와 이름/별칭 일치 검색 + resolveCanonicalItemId로 병합 체인 끝까지 resolve하도록 변경. 자동완성 제안을 선택했든(itemId 제공) 그냥 타이핑만 했든(이름/별칭 매칭) 항상 최종 대표 품목 id로 귀결됨. 회원가입→"양파"/"깐 양파" 각각 신규 생성→병합→병합 후 "깐 양파" 재입력(제안 미선택) 전체 흐름을 Playwright로 재현하고, transaction_detail/item 조인 쿼리로 새 항목이 옛 품목이 아니라 "양파"에 직접 연결되는 것을 DB에서 직접 확인. 자동화 회귀 테스트는 아직 없음(서버 액션 DB 로직이라 기존 테스트 패턴과 안 맞음) — docs/test-scenarios.md T-1-6-1 "사용자 가이드 참고" 절에 상세 기록, /settings/items 안내 문구도 "제안 선택 불필요"로 갱신.

  • [x] T-1-6-1 (테스트) 병합 로직 단위테스트(merged_into_item_id 그룹화 검증)

    2026-07-03 PM 완료 확인. lib/item-merge.ts 신규 — resolveCanonicalItemId(병합 체인을 따라가 최종 대표 품목 id 조회, 순환 참조 방어), groupByCanonicalItem(상세항목을 대표 품목 기준 합계로 그룹화). F-1-6-3의 mergeItemAction이 병합 시점에 transaction_detail.item_id를 즉시 재배정하지만 멀티 스테이트먼트 트랜잭션 미지원으로 완벽한 원자성은 아니므로, 이후 통계/집계 쪽에서 과거 데이터가 섞여도 정확히 묶이도록 하는 방어 로직. __tests__/item-merge.test.ts 단위테스트 9건, scripts/test-data/T-1-6-1.mjs 생성.

3-7. 앱 기능: 예산 (F-4)

  • [x] F-1-7-1 (앱기능) 예산 설정 화면(전체 예산 → 카테고리별 월 한도 배분)

    2026-07-03 PM 완료 확인. /budgets 신규 화면(설정 하위가 아닌 최상위 메뉴 — IA 1장 "4. 예산 관리"가 독립 메뉴로 계획돼 있어 nav-items.ts/기능명세서_IA.md 1-1표에 최상위 항목으로 추가). 구현 도중 PM 요청으로 최초 범위(카테고리별 한도만)에서 두 차례 확장/재설계됨:

    1. 전체 예산 개념 추가: BUDGET_TOTAL 테이블 신규(20260703085846_create_budget_total_table.sql, budget과 별개 엔티티, (user_id, period) 유니크) — 전체 예산을 먼저 등록해야 카테고리별 예산을 등록할 수 있고, 카테고리별 합계가 전체 예산을 초과하면 저장 자체를 차단(기존 "지출이 예산 초과해도 입력은 막지 않는다"는 정책과는 별개 검증 — 데이터정책시드정의서에 구분 명시). 아키텍처 설계서 4장 ERD/기능명세서_IA.md F-4-1/WBS.md 함께 갱신.
    2. UI를 단일 폼으로 재구성: 항목별 미설정/설정 토글, 개별 등록 버튼, 전체 예산 삭제 버튼을 모두 없애고 전체 예산+카테고리별 예산 입력을 한 화면(하나의 <form>)에서 관리 — 맨 아래 "등록" 버튼 하나로 일괄 저장(saveBudgetsAction, 금액 0은 "예산 없음"으로 해석해 해당 행 삭제), "리셋" 버튼은 카테고리별 입력값만 0으로 되돌림(등록을 눌러야 실제 반영). 리셋 확인/등록 완료 안내는 네이티브 alert()/confirm() 대신 @base-ui/react/alert-dialog·dialog 기반 커스텀 레이어 팝업(ConfirmDialog.tsx, SuccessDialog.tsx, 예산 화면 로컬 범위)으로 구현.
      월 이동(이전달/다음달) 시 입력값이 잔류하지 않도록 <BudgetSection key={period}> 적용. 신규 파일: lib/budget-schemas.ts, app/(app)/budgets/{page.tsx, BudgetSection.tsx, actions.ts, ConfirmDialog.tsx, SuccessDialog.tsx}. T-코드 없는 TASK라 별도 스키마 단위테스트는 추가하지 않음(기존 F-1-6-x 관례와 동일).
      ConfirmDialog.tsx/SuccessDialog.tsx는 2026-07-05 다른 화면(지출 삭제 확인 등)에서도 재사용하게 되면서 _components/(공용 컴포넌트 폴더)로 이동함(budgets 로컬 범위 아님으로 정정).
      2026-07-07 PM 요청 3건, 같은 세션:
    3. 카테고리 수가 많아지면 목록이 세로로 너무 길어지는 문제 — 태블릿/데스크탑(md 이상)에서 카테고리 목록을 grid-cols-2로 전환. 캘린더(F-1-10-1)처럼 max-w-[1920px]까지 넓히는 것도 검토했으나, 캘린더의 날짜 셀과 달리 예산 행은 폭이 늘어도 채울 내용이 늘지 않아 카드 안에 빈 공간만 커지는 문제를 스크린샷으로 확인 — 대신 모바일은 기존 폭(max-w-xl) 유지, md 이상만 2열에 맞는 max-w-4xl로 적당히 확장.
    4. "리셋"이 실제로는 화면 입력값만 0으로 되돌리고 "등록"을 다시 눌러야 저장돼, 리셋 직후 새로고침하면 예전 값이 그대로 돌아오는 버그 — 이미 ConfirmDialog로 재확인을 거치므로 확인 즉시 서버에도 반영하도록 변경(resetBudgetsAction 신규, saveBudgetsAction의 "전체 예산 0" 분기와 로직 공유). 위 문단의 "리셋 버튼은 카테고리별 입력값만 0으로 되돌림" 설명은 이제 사실이 아님 — 정정.
    5. "지난달 예산 불러오기" 버튼 추가 — 전체 예산 박스 위에 배치, 지난달에 예산이 없으면 비활성화. 현재 화면에 이미 값이 입력돼 있으면(0이 아니면) ConfirmDialog로 덮어쓰기 확인 후 진행, 화면에만 채우고 서버 저장은 안 함(등록을 눌러야 반영 — 리셋과 달리 검토/조정 여지를 남기는 게 목적).
  • [x] F-1-7-2 (앱기능) 예산 소진율 게이지 컴포넌트

    2026-07-03 PM 완료 확인. BudgetGauge.tsx — 예산 미설정(≤0)이면 렌더링 안 함, 100% 이하는 --paylens-data-soft(DESIGN_GUIDE "잔여 예산 바" 용도), 초과 시 --paylens-accent("지출 경고" 용도)로 전환. /budgets 화면에 인라인 배치(전체 예산 박스 아래 + 카테고리별 행 아래) — 별도 대시보드 화면이 아직 없어(3-8 미착수) 예산 관리 화면 자체에 표시. page.tsx에서 해당 월 transaction을 카테고리별로 직접 집계(Phase 1 임시 단순 쿼리 — S-1-15의 대시보드 전용 집계/뷰와는 별개 범위이니 S-1-15 착수 시 중복 구현 주의). 게이지는 입력 중인 미저장 값이 아니라 저장된 budget/budget_total 기준으로 계산. 기능명세서_IA.md F-4-2 설명 갱신.

3-8. 앱 기능: 대시보드 1차 (F-5-1~3, MV 없이 단순 쿼리)

  • [x] S-1-15 (시스템) 대시보드용 단순 집계 쿼리/뷰 작성 (1단계는 MV 없이 직접 쿼리)

    2026-07-03: TS 쿼리 모듈 방식으로 구현(apps/main/web/lib/dashboard-queries.ts) — DB View/MV 대신 F-1-7-2(예산 페이지)와 동일하게 TRANSACTION 직접 fetch 후 JS 집계. RLS 리스크 없음, 마이그레이션/타입재생성 불필요, Vitest 단위테스트 용이성을 이유로 PM과 함께 View 대신 이 방식으로 결정. getDashboardSummary/getMonthlyTrend/getCategoryBreakdown 3개 함수 제공, F-1-8-1~3에서 재사용.
    참고: F-1-7-2에서 /budgets 화면 전용으로 이미 "해당 월 카테고리별 지출 합계" 임시 쿼리를 app/(app)/budgets/page.tsx에 직접 작성해둠(대시보드 공용 집계는 아님, 화면 범위 한정). 이 TASK 착수 시 재사용 가능한 형태로 일반화할지 검토.

  • [x] F-1-8-1 (앱기능) 대시보드 개요 화면(요약 카드)

    2026-07-03: 홈(/) 플레이스홀더를 요약 카드 3개(이번달 총지출/전월 대비/예산 소진율 게이지)로 교체(app/(app)/page.tsx). getDashboardSummary 재사용, BudgetGauge(F-1-7-2) 재사용. Playwright로 빈 상태/증가 케이스 시각 검증 완료. docs/기능명세서_IA.md 1-1장 표 갱신.
    2026-07-04 PM 요청으로 헤더 개편: "홈" 타이틀(사이드바에 이미 강조 표시되어 중복) 제거, 대신 "{period} 지출 요약"을 카드로 승격해 왼쪽에 크게 표시 + 오른쪽에 "📅 캘린더 바로가기"/"+ 지출 입력" 바로가기 버튼 2개 배치(버튼 하나만 있으면 붕 떠 보인다는 지적에 따라 짝을 맞춤). 컨테이너 폭도 캘린더(F-1-10-1)와 동일 근거(차트가 w-full이라 넓을수록 유리)로 max-w-5xlmax-w-[1920px]로 확장 — 카테고리별 도넛(F-1-8-3)은 원 자체가 sm:w-56 고정폭이라 커지지 않고 옆 범례만 여유가 생기는 것까지 확인.
    2026-07-07 PM 요청: 지금까지는 항상 이번 달 데이터만 보여주던 걸, 예산/캘린더 화면과 동일한 ?period=YYYY-MM 패턴으로 "← 이전 달 / 다음 달 →" 이동 추가 — getDashboardSummary/getMonthlyTrend/getCategoryBreakdown/getPaymentMethodBreakdown이 이미 전부 period 인자를 받는 구조라 화면단 변경만으로 구현. 더 이상 항상 이번 달이 아니므로 "이번달 총지출"/"이번달 카테고리별 지출"/"이번달 지출분류별 지출" 라벨에서 "이번달" 고정 문구 제거(지난달을 보면서 "이번달"이라 뜨는 혼란 방지). "📅 캘린더 바로가기" 링크도 현재 보고 있는 period를 그대로 넘기도록 수정(/expenses/calendar?period=...) — 원래는 항상 이번 달 캘린더로만 이동해 3월을 보다가 캘린더로 가면 7월이 뜨는 버그가 있었음.

  • [x] F-1-8-2 (앱기능) 월별 추이 차트

    2026-07-03: Recharts 최초 도입(pnpm add recharts). MonthlyTrendChart.tsx — 최근 6개월 단일 시리즈 Area 차트, getMonthlyTrend 재사용, 네이비(--paylens-main) 단색 + 크로스헤어 툴팁. dataviz 스킬 기준(추이=line/area, 단일 시리즈=1색, 대비 15:1 검증) 적용. Playwright로 빈 상태/데이터 있는 상태/툴팁 hover 시각 검증 완료.

  • [x] F-1-8-3 (앱기능) 항목별(카테고리) 도넛/트리맵 차트

    2026-07-03: CategoryBreakdownChart.tsx — 이번달 카테고리별 지출 도넛(최대 6구간, 초과분은 "기타" 병합). dataviz 스킬로 6색 카테고리 팔레트 신규 검증(CVD/명도/채도/대비 전항목 PASS), 기존 예약색(경고/액션/결제수단 태그)과 겹치지 않도록 설계. Playwright로 8개 카테고리(기타 병합 케이스) 입력 후 시각 검증 완료.

  • [x] T-1-8-1 (테스트) 대시보드 화면 E2E (데이터 입력 후 반영 확인)

    2026-07-04 PM 완료 확인. e2e/dashboard.spec.ts 4건 — 신규가입 직후 빈 상태, 지출 1건 입력 후 총지출/카테고리별/지출분류별 반영, 2건 입력 후 누적 합산. 진행 중 e2e/auth.spec.ts의 "가입→로그인→로그아웃" 테스트가 로컬 enable_confirmations=false(가입 시점에 이미 세션 발급) 때문에 실패하는 걸 발견해 같은 세션에서 수정(가입→로그아웃→로그인→로그아웃 흐름으로 변경) — docs/test-scenarios.md T-1-3-1/T-1-8-1 섹션 갱신. 전체 E2E 11/11 통과.

3-9. 반응형 대응 (cross-cutting, 아키텍처 6장)

  • [x] F-1-9-1 (앱기능) 전체 화면 반응형 점검/보정 (모바일 1열, 데스크탑 다열)

    2026-07-04 PM 완료 확인. Small(<640, 모바일)/Middle(768~1023 md, 태블릿)/Large(≥1024 lg, 데스크탑) 3단계로 로그인/회원가입 포함 전 화면 스크린샷 점검(Tailwind 기본 브레이크포인트 사용, 프로젝트 커스텀 브레이크포인트 없음). flex items-center gap-2 행에서 고정폭 요소(라벨/아이콘피커) + flex-1 입력 조합에 min-w-0이 빠져 모바일 폭에서 입력창이 옆 버튼/텍스트를 밀어내며 가로로 넘치는 버그를 5곳에서 발견해 수정: budgets/BudgetSection.tsx, settings/payment-methods/{AccountSection,CashSection}.tsx, settings/categories/CategorySection.tsx, expenses/ExpenseEntryForm.tsx(상세항목 금액). CardSection.tsx/VendorSection.tsx는 이전 세션에 이미 적용돼 있어 문제없었음. Middle(태블릿, 사이드바가 md:flex로 나타나는 전환 지점) 재검증에서 추가 버그는 없었음 — 홈 대시보드 "월별추이+카테고리별" 2열 그리드(lg:grid-cols-2)는 PM 결정으로 태블릿에서도 1열 유지(차트가 좁아져 y축 라벨이 답답해지는 것 방지). 전체 E2E 11/11 통과.

  • [x] F-1-9-2 (앱기능) PWA 매니페스트 적용

    2026-07-04 PM 완료 확인. app/manifest.ts(Next.js 파일 컨벤션, /manifest.webmanifest 자동 생성) — name/short_name/description, display: standalone, theme_color: #0B2545. 아이콘(public/icons/icon-192.png, icon-512.png)은 로고 SVG가 아직 없어 PM 확인 하에 임시 플레이스홀더(네이비 배경 + "pL", 헤더 로고와 동일 톤) 생성 — 실제 로고 SVG 완성 시 교체 필요(매니페스트 파일에 주석 남김). app/layout.tsxviewport.themeColor/appleWebApp 메타 추가. 진행 중 middleware.ts의 matcher가 manifest.webmanifest를 제외 목록에서 빠뜨려 비로그인 상태에서 매니페스트 요청이 /login으로 리다이렉트되는 버그(PWA 설치 자체가 막힘) 발견해 즉시 수정 — favicon.ico와 동일하게 제외 목록에 추가. 비로그인 상태 200 응답, <head>에 manifest link/theme-color meta 정상 주입 확인. 전체 E2E 11/11 통과.

  • [x] F-1-9-3 (앱기능) 네비게이션/공통 레이아웃(AppShell) 반영
    ┌──────────────────────────────────────────┐
    │ payLens 🔔 사용자 │
    ├────────────┬─────────────────────────────┤
    │ left menu │ contents │
    └────────────┴─────────────────────────────┘

3-10. 앱 기능: 지출 내역 캘린더 뷰 (2026-07-04 백로그 B-7 채택)

  • [x] F-1-10-1 (앱기능) 지출 내역 캘린더 조회 화면 (월별 그리드, 일자별 지출 카드 나열)

    2026-07-04 PM 완료 확인. /expenses/calendar 신규 — 일요일 시작 월별 그리드(최대 6주×7일), 셀마다 그 날짜의 지출 카드 나열(태블릿 이상: 지출처명+결제수단 색점+금액, 모바일 <640px: 색점만 — 셀 폭에 텍스트가 잘려 의미 없어지는 걸 발견해 축약), 오늘 날짜 강조, ?period=YYYY-MM 쿼리 기반 이전/다음 달 이동(예산 화면과 동일 패턴 재사용). /expenses 목록 화면과 "목록|캘린더" 토글로 상호 연결(사이드바에 별도 메뉴 추가 안 함 — IA 1장 "전체 메뉴 트리"가 원래 지출분류별 통계처럼 이런 화면들을 대시보드 하위 항목으로 계획했던 것과 결이 맞음). 컨테이너 폭은 다른 폼 화면(max-w-xl)과 달리 격자형 데이터라 넓을수록 유리 — PM 요청으로 max-w-[1920px]까지 확장(FHD까지는 100%로 차오르고 QHD/4K 초대형 모니터에서만 상한 역할, 태블릿/모바일은 영향 없음).
    2026-07-09 PM 발견/버그 수정(운영 배포 후 실사용 중 발견 — hotfix): 그리드는 항상 6주(42칸) 고정이라 이번 달 앞뒤로 옆 달 날짜도 흐리게 함께 표시(예: 1일이 금요일인 2026-05는 4월 26~30일이 첫 주에 함께 나옴)하는데, page.tsx의 조회 범위는 정확히 "이번 달"(gte(이번달 1일)~lt(다음달 1일))만 필터링해 그 흐린 칸에 실제 등록된 지출이 있어도 표시되지 않는 문제(운영에서 3월 말/4월 데이터가 안 보인다는 PM 리포트로 발견). ExpenseCalendarGrid.tsxbuildMonthGrid와 동일한 그리드 시작일 계산으로 조회 범위를 42일 전체로 확장해 해결. 4월 30일 지출 등록 후 5월 그리드의 흐린 "30" 칸에 정상 표시되는 것을 Playwright로 확인. 실사용 데이터 노출 버그라 Phase 5 완료를 기다리지 않고 hotfix/* 브랜치로 main에 즉시 반영(PM 결정).

  • [x] F-1-10-2 (앱기능) 날짜별 빠른 지출 입력 레이어 팝업 ("+" 트리거, 저장 즉시 캘린더 반영)

    2026-07-04 PM 완료 확인. 날짜 셀 "+"(모바일은 항상 옅게 보이고 데스크탑은 hover 시 표시 — 터치기기엔 hover가 없다는 점 고려)로 그 날짜가 채워진 빠른 입력 레이어 팝업(@base-ui/react/dialog, 기존 ConfirmDialog와 같은 라이브러리) — 새 폼을 만들지 않고 ExpenseEntryForminitialValues로 그대로 재사용. 저장 성공 시 폼의 onSuccess 콜백으로 팝업이 스스로 닫히고, 서버 액션의 revalidatePath(CALENDAR_PATH)로 같은 라우트가 새로고침 없이 자동 갱신.
    PM 추가 요청으로 같은 세션에서 확장: ①캘린더의 기존 지출 카드 클릭도 /expenses/[id]/edit 페이지 이동 대신 동일한 방식의 수정 레이어 팝업(EditExpenseDialog.tsx, 수정+삭제 모두 포함)으로 열고 닫히도록 변경. ②두 팝업 모두 우측 상단에 Dialog.Close+lucide X 아이콘 닫기 버튼 추가 — 팝업 전용 컴포넌트에만 있어 전체화면 입력/수정 페이지엔 영향 없음.
    공유 서버 액션 리팩터: updateExpenseAction을 캘린더 팝업도 쓰게 되면서, 성공 시 서버에서 강제 redirect하던 것을 addExpenseAction과 동일하게 상태만 반환하도록 변경 — 기존 /expenses/[id]/edit 전체화면 페이지는 신규 EditExpenseFormClient.tsx(클라이언트 래퍼)의 onSuccess에서 router.push로 이동을 대신 처리(사용자에게 보이는 동작은 동일, 구현 경로만 변경 — F-1-5-14 항목에도 동일 메모).
    버그 발견/수정: 두 화면이 공유하는 초기값 변환 함수(buildFormValuesFromTransaction)가 "use client" 파일(ExpenseEntryForm.tsx) 안에 있어, 서버 컴포넌트인 /expenses/[id]/edit/page.tsx에서 직접 호출 시 Next.js RSC 경계 위반으로 화면 전체가 깨지는(__next_error__) 버그를 검증 중 발견 → 순수 로직을 lib/expense-form-values.ts(서버/클라이언트 공용)로 분리해 해결, 전체화면 수정 페이지 정상 동작 재확인.
    2026-07-07 PM 요청/버그 수정, 같은 세션:

    1. 삭제 버튼 크기 — 이 팝업(EditExpenseDialog.tsx)만 DeleteExpenseButton을 별도 줄에 작게 렌더링하던 걸, 전체화면 수정 페이지(EditExpenseFormClient.tsx)가 이미 쓰던 ExpenseEntryFormsecondaryActions 슬롯으로 옮겨 "수정"/"삭제"가 같은 행에 동일 크기로 나란히 배치되도록 통일.
    2. ⚠️ 삭제 확인 팝업에서 "삭제"를 눌러도 실제 삭제가 안 되는 버그ConfirmDialog(공용, _components/)가 z-index를 지정하지 않아, 이 캘린더 수정 팝업(z-50) 위에 중첩되어 열릴 때 확인 팝업이 뒤에 깔린 채 렌더링되고 클릭도 뒤쪽 수정 폼 요소가 가로챔(Playwright로 elementFromPoint 확인해 재현) — ConfirmDialogz-[60]/z-[61] 명시해 항상 다른 다이얼로그 위에 뜨도록 수정. 전체화면 수정 페이지는 겹치는 다이얼로그가 없어 이 버그가 드러나지 않았던 것.
  • [x] T-1-10-1 (테스트) 캘린더 조회 + 빠른입력 E2E (월 이동, 일자별 표시, 팝업 저장 후 반영 확인)

    2026-07-04 PM 완료 확인. e2e/calendar.spec.ts 8건 — 진입/토글 3(목록↔캘린더, 월 이동), 빠른입력 팝업 2, 수정/삭제 팝업 3. 전체 E2E 스위트(auth 6 + calendar 8 + dashboard 4 + home 1) 19/19 통과.

5. Phase 3 — 통계 분리/캐시/대시보드 고도화 (3단계)

  • [x] S-3-1 (시스템) tx_stats Materialized View 작성

    2026-07-07 구현: 20260707045330_create_tx_stats_view.sqluser_id×period(YYYY-MM)×category_id×vendor_id×payment_method_id 기준 사전 집계(SUM(amount), COUNT(*)), 리프레시용 유니크 인덱스 포함. ⚠️ 설계 이슈 발견: PostgreSQL Materialized View는 RLS를 지원하지 않아(ALTER MATERIALIZED VIEW ... ENABLE ROW LEVEL SECURITY 자체가 불가), 데이터정책 문서 3장의 "테이블과 동일하게 GRANT" 전제가 MV에는 그대로 적용 안 됨 — 그대로 GRANT SELECT를 주면 인증된 사용자가 다른 사용자의 통계를 조회할 수 있는 구멍이 생김. MV 자체는 어떤 롤에도 GRANT하지 않고, auth.uid()로 내부 필터링하는 SECURITY DEFINER 함수 get_tx_stats(p_period text DEFAULT NULL)authenticated에 EXECUTE 부여(S-1-10recalculate_transaction_amount와 동일 패턴). 두 사용자로 실제 검증: 소유자는 본인 집계 정상 조회, 다른 사용자는 빈 배열, MV 직접 SELECT는 42501 권한거부로 차단 확인. supabase gen types typescript --localdatabase.generated.ts 갱신 완료.

  • [x] S-3-2 (시스템) item_stats Materialized View 작성

    2026-07-07 구현: 20260707050401_create_item_stats_view.sqltransaction_detailuser_id/occurred_at이 없어 transaction과 조인해 (user_id, item_id, period) 기준 SUM(amount)/COUNT(*)/AVG(amount) 집계. 병합(F-1-6-3) 처리는 MV 안에서 체인을 재해석하지 않음 — mergeItemAction이 병합 시점에 transaction_detail.item_id를 이미 대표 품목으로 재배정해두고, 누락분 방어는 lib/item-merge.tsresolveCanonicalItemId(다단계 체인 대응)가 F-3-1-3에서 조회 결과에 적용하는 걸로 역할 분리(중복 구현 방지). S-3-1과 동일한 보안 설계(MV는 GRANT 없음, get_item_stats(p_period) SECURITY DEFINER 함수만 authenticated EXECUTE) — 두 사용자로 소유자 조회/타 사용자 빈 배열/직접 SELECT 차단 재검증 완료.

  • [x] S-3-3 (시스템) item_unit_stats Materialized View 작성 (quantity_value/unit_id 필터)

    2026-07-07 구현: 20260707081302_create_item_unit_stats_view.sql(user_id, item_id, unit_id, period) 기준 SUM(quantity_value)/SUM(amount)/AVG(amount/quantity_value). quantity_value/unit_id 중 하나라도 NULL이면("1+1" 등 구조화 실패) WHERE절에서 제외 — 금액 자체는 item_stats(S-3-2)에는 계속 포함되고 이 "수량 기반" 통계에서만 빠지는 설계서 5장 의도된 동작. 검증: 구조화된 행(2개·6,000원)과 미구조화 "1+1" 행(2,000원)을 함께 넣고 조회 시 미구조화 행이 정확히 제외되어 결과가 total_quantity=2, total_amount=6000, avg_unit_price=3000으로만 나오는 것 확인. S-3-1/S-3-2와 동일한 보안 설계(SECURITY DEFINER get_item_unit_stats) — 소유자 조회/타 사용자 빈 배열/직접 SELECT 차단 재검증 완료.

  • [x] S-3-4 (시스템) MV 리프레시 트리거/스케줄(Edge Function 또는 트리거)

    2026-07-07 구현: 20260707081835_create_stats_refresh_triggers.sql — Edge Function 스케줄 대신 트리거로 구현(설계서 5장 "실시간 집계로 충분" 명시). transaction 테이블 INSERT/UPDATE/DELETE 시 tx_stats 리프레시, transaction_detail INSERT/UPDATE/DELETE 및 transaction.occurred_at 변경 시 item_stats/item_unit_stats 리프레시(둘 다 FOR EACH STATEMENT로 한 문장당 한 번만). REFRESH MATERIALIZED VIEW CONCURRENTLY는 사용자 단위가 아니라 MV 전체를 재계산 — 현재 규모에선 허용 가능(설계서 명시), 사용자 증가 시 배치/큐 방식 전환 필요.
    검증(수동 REFRESH 전혀 없이): ①직접입력 지출 등록 → tx_stats 즉시 반영 ②상세항목 지출 등록 → item_stats/item_unit_stats 즉시 반영, 기존 recalculate_transaction_amount 트리거의 연쇄 UPDATE로 tx_stats도 함께 갱신되는 것까지 확인 ③날짜만 수정 → item_stats의 period 버킷이 즉시 이동 ④지출 삭제(FK CASCADE) → 관련 통계가 즉시 사라짐. 5개 시나리오 모두 통과.

  • [x] S-3-5 (시스템) Upstash Redis 연동 (캐시 레이어)

    2026-07-07 구현: @upstash/redis 패키지 추가, lib/redis-client.ts(Redis.fromEnv() 싱글턴, 서버 전용) 신규. .env.exampleUPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN 플레이스홀더 추가, 실제 값은 .env.local.
    ⚠️ 작업 중 보안 이슈 발견/수정: 실제 Upstash 값이 최초에 커밋 대상 파일인 .env.example에 입력돼 있던 것을 발견 — 아직 커밋 전이라 히스토리엔 안 남음, 즉시 .env.local로 옮기고 .env.example은 플레이스홀더로 정정.
    ⚠️ 1차 토큰 권한 이슈: 처음 발급받은 토큰이 read-only라 SETNOPERM으로 거부됨(GET은 정상) — PM이 읽기/쓰기 가능한 토큰으로 재발급 후 재검증, SET/GET/TTL/DEL 라운드트립 전부 통과 확인.

  • [x] S-3-6 (시스템) Top10/통계 API 캐시 적용 (item_top10:{userId}:{period} 등 키 설계)

    2026-07-07 구현: lib/stats-cache.ts — WBS에 명시된 키 그대로 item_top10:{userId}:{period}, TTL 5분 cache-aside 패턴(getItemTop10: 캐시 있으면 즉시 반환, 없으면 get_item_stats RPC로 Top10 계산 후 캐싱). F-3-1-3(상세항목 Top10 화면) 아직 미착수라 화면에서 호출하는 곳은 없음 — 그 화면이 그대로 가져다 쓸 캐시 레이어만 먼저 마련.
    캐시 무효화: MV는 트리거(S-3-4)로 실시간 갱신되지만 Redis 캐시는 TTL까지 낡은 값을 들고 있을 수 있어, expenses/actions.tsaddExpenseAction/updateExpenseAction/deleteExpenseActioninvalidateItemTop10Cache 호출 추가 — 등록/삭제는 해당 기간, 수정은 날짜가 다른 달로 바뀔 수 있어 이전/이후 기간 둘 다 무효화.
    검증(실제 앱 플로우, 임시 API 라우트로 서버 컨텍스트에서 getItemTop10 직접 호출 후 삭제): ①첫 조회 캐시 미스 → RPC로 계산 후 Redis 저장 확인 ②재조회 캐시 히트 확인 ③같은 품목으로 지출 추가 등록 → 등록 즉시 캐시 무효화되어 재조회 시 합계가 최신값(5000→8000)으로 정확히 반영되는 것까지 확인.
    PM 문의 대응: "쓰기 시점에 Redis를 직접 UPDATE해서 동기화하면 어떤지" 검토 — 현재 무효화 방식도 쓰기 즉시 삭제되므로 낡은 값이 보이는 구간 자체가 없어(항상 최신) 동기화 관점에서 개선 여지가 없고, 즉시 UPDATE 방식은 오히려 가장 빈번한 동작(지출 저장)마다 RPC 재계산을 기다리게 만들어 아직 없는 화면(Top10)의 조회 속도를 위해 저장 속도를 희생하는 트레이드오프임을 설명 — PM 확인 하에 현재 무효화 방식 유지로 결정.

  • [x] F-3-1-1 (앱기능) 지출분류별(①) 통계 화면 (현금/카드, 카드(카드사명)별)

    2026-07-03 정정: 카드 등록 시 별도 "카드사" 필드를 폐기하고 "카드사명"(표시명)으로 통합(F-1-4-1 항목 메모 참고) — 이 TASK 착수 시 카드사(발급사) 단위가 아니라 등록된 카드(payment_method_id) 단위로 집계 쿼리를 설계할 것.
    2026-07-03 PM 완료 확인. 원래 계획(WBS)은 별도 "통계 화면" + S-3-1(MV) 의존이었으나, PM과 논의 후 홈(/) 대시보드에 위젯으로 통합하는 것으로 범위 변경(IA 1장 "전체 메뉴 트리"가 애초에 지출분류별 통계를 대시보드 하위 항목으로 계획했던 것과도 부합) + F-1-8-1~3과 동일하게 MV 없이 직접 쿼리로 구현(S-3-1 미착수 상태이므로). getPaymentMethodBreakdown(lib/dashboard-queries.ts) 신규, PaymentMethodBreakdownChart.tsx — 누적막대바 1개(개별 결제수단 세그먼트, 색은 기존 --paylens-cash/check/credit 재사용) + 상단 브래킷 라벨(현금/카드 대분류 총액·비율)로 대분류·개별 수치를 한 그래프에 표시(2조각 파이는 dataviz 스킬 안티패턴이라 배제). Playwright로 현금 단독/현금+카드 혼합 케이스 시각 검증 완료. 기능명세서_IA.md 1-1장 표 갱신(별도 라우트 없음, 홈 F-코드에 F-3-1-1 추가).

  • [x] F-3-1-2 (앱기능) 지출처 Top N 화면

    2026-07-07 구현: F-3-1-1과 동일하게 별도 라우트 대신 홈(/) 대시보드 위젯으로 통합("지출처 Top 10", 기능명세서_IA.md 1-1장 표 갱신). WBS 선행작업은 S-3-2(item_stats)로 적혀 있으나 실제로는 vendor_id를 그룹 기준으로 가진 S-3-1tx_stats가 맞는 데이터 소스 — 문서상 선행작업 표기 오류로 보임(WBS.md 정정 필요, 이번엔 코드만 수정). getVendorTopN(lib/dashboard-queries.ts) 신규 — get_tx_stats RPC 결과를 vendor_id 기준으로 재합산(카테고리/지출분류 조합별로 나뉜 행을 합침) 후 이름 붙여 상위 10개만 반환, raw transaction 재스캔 없음.
    차트: dataviz 스킬 절차대로(마그니튜드 비교→bar, sequential 단일 색) VendorTopNChart.tsx 신규 — 가로 막대(지출처명이 길어 세로 대신 가로 채택), --paylens-main 단일 색(월별추이 F-1-8-2와 동일 톤), 막대 끝 금액 직접 라벨. Recharts 애니메이션(기본 ~1.5초) 때문에 페이지 로드 직후 스크린샷에는 라벨이 안 보였던 걸 애니메이션 버그로 착각할 뻔했으나, 대기 후 재확인해 정상 렌더링 확인.
    Playwright로 지출처 4곳(88,000/50,000/23,000/12,000원) 등록 후 내림차순 정렬·라벨·빈 상태 화면 모두 시각 검증 완료.

  • [x] F-3-1-3 (앱기능) 상세항목 Top10 화면 + 드릴다운(지출처별)

    2026-07-07 구현: F-3-1-1/2와 동일하게 홈(/) 대시보드 위젯("상세항목 Top 10")으로 통합, 기능명세서_IA.md 1-1장 표 갱신. getItemTop10Display(lib/dashboard-queries.ts) — S-3-6의 getItemTop10 Redis 캐시를 그대로 쓰고 품목명만 붙임. 병합(F-1-6-3) 처리: 캐시된 item_idmergeItemAction이 병합 시점에 이미 대표 품목으로 재배정해둔 값이 보통이지만, 재배정이 누락된 과거 데이터를 대비해 resolveCanonicalItemId(lib/item-merge.ts)로 한 번 더 정규화 후 합산 — S-3-2 메모에서 예고했던 "F-3-1-3 착수 시 이 로직을 붙일 것" 반영.
    드릴다운: app/(app)/actions.ts 신규("use server") — getItemVendorBreakdown(itemId, period)가 그 품목(병합된 다른 품목 포함)을 transaction_detail!inner(transaction) 조인으로 지출처별 합산해 반환. Top10 캐시(TTL 5분)와 달리 온디맨드성 조회라 캐시 미적용(클릭할 때만 조회, 재조회 확률 낮고 캐시 키 조합만 늘어남).
    UI: 다른 위젯과 달리 "품목 클릭 → 그 자리에 지출처별 내역 펼치기" 아코디언이 필요해 Recharts 대신 순수 HTML/CSS 막대로 직접 구현(ItemTop10Chart.tsx) — 막대 하나 아래에 패널을 붙이는 건 Recharts 마크 모델과 안 맞음. 마크 스펙(단일색 --paylens-main, 막대 끝 직접 라벨)은 dataviz 스킬 기준 그대로 따름.
    Playwright로 같은 품목("양파")을 두 지출처(이마트 5,000원/쿠팡 3,000원)에서 구매 후 Top10에 8,000원 합계로 정확히 집계, 클릭 시 지출처별로 정확히 분리돼 펼쳐지고 다른 품목은 접힌 채 유지되는(아코디언, 한 번에 하나만 펼침) 것까지 시각 검증 완료.
    2026-07-09 후속(PM 요청): "당월만 보임"이 불편하다는 피드백으로 이 위젯에 "당월/최근 3개월/최근 6개월" 합산 보기 추가. 다른 위젯이 전부 공유하는 페이지 전역 period(이전달/다음달 이동)와는 별개로 이 위젯만의 itemRange(1/3/6) 쿼리 파라미터를 새로 둠 — 전역 이동을 건드리지 않기 위함. 로직: get_item_stats(p_period) RPC가 p_period 생략 시 유저 전체 기간을 반환하는 것에 착안(F-3-1-4가 이미 쓰는 "전체 기간을 한 번에 받아 JS에서 버킷팅" 패턴 재사용) — stats-cache.tsgetItemTop10monthsBack 인자를 추가해, 1개월(기본)이면 기존처럼 정확한 월만 RPC로 받고 3/6개월이면 RPC를 전체 기간으로 한 번 호출한 뒤 필요한 개월만 필터링해 item_id별로 합산(가중평균으로 avgAmount 재계산). 캐시(S-3-6) 키: monthsBack=1은 WBS 원 형식(item_top10:{userId}:{period}) 그대로 유지(하위호환), 3/6개월만 :{monthsBack} 접미사 추가 — 무효화(invalidateItemTop10Cache)도 바뀐 달이 걸쳐 있을 수 있는 모든 3/6개월 윈도우(endPeriod 변형 최대 10개)까지 함께 지우도록 확장, 예산/캘린더와 동일한 "즉시 반영" 원칙 유지. 드릴다운: Top10이 여러 달 합계로 바뀌면 클릭 시 지출처별 내역도 같은 기간이어야 막대 합계와 일치하므로, getItemVendorBreakdown(app/(app)/actions.ts)에도 monthsBack을 추가해 조회 범위를 넓힘. UI: ItemTop10Chart.tsx에 세그먼트 탭 3개(당월/최근 3개월/최근 6개월, <Link> 기반 itemRange 쿼리 갱신)와 3/6개월일 때만 보이는 기간 라벨(예: "2026-02 ~ 2026-07") 추가. __tests__/stats-cache.test.ts에 다개월 합산/범위별 캐시 무효화 회귀 테스트 추가(6건), Playwright로 당월/3개월/6개월 전환 시 합계·드릴다운·기간 라벨 모두 정확히 바뀌는 것까지 시각 검증 완료.
    2026-07-09 버그 수정: 탭 <Link>scroll={false} 없이 기본값(스크롤 유지 안 함)으로 나가, 화면 아래 이 위젯을 보다가 탭을 눌러도 Next.js가 새 내비게이션마다 스크롤을 맨 위로 올려버려 "그래프만 바뀌는 게 아니라 화면이 새로고침된 것처럼 보인다"는 리포트로 발견 — 세 탭 Linkscroll={false} 추가. Playwright로 스크롤 640px 위치에서 탭 클릭 후에도 스크롤 위치가 그대로 유지되는 것 확인.

  • [x] F-3-1-4 (앱기능) 단가 분석 화면(품목×단위 추이 차트)

    2026-07-07 구현: F-3-1-1~3과 동일하게 홈(/) 대시보드 위젯("단가 분석")으로 통합, 기능명세서_IA.md 1-1장 표 갱신. getUnitPriceOptions/getUnitPriceTrend(lib/dashboard-queries.ts) — item_unit_stats(S-3-3)는 애초에 구조화된 수량(quantity_value+unit_id)만 집계하므로, select 선택지 자체가 자동으로 "1+1" 같은 비구조화 항목을 제외한다. 다른 위젯과 달리 여러 달 데이터가 필요해 get_item_unit_stats(전체 기간)을 한 번만 불러와 JS에서 최근 6개월로 버킷팅(추가 range 쿼리 없음).
    UI: 어떤 품목×단위를 볼지 고르는 <select>를 URL 쿼리(?unitItem=itemId:unitId)로 관리 — 예산/캘린더의 ?period= 패턴과 동일하게 새로고침/공유해도 선택이 유지됨. 데이터 없는 달은 0이 아니라 null로 채워 그래프에서 끊기게 함(월별추이 F-1-8-2의 "0원으로 채움"과 의도적으로 다름 — 평균 단가는 0이 유효한 값이 아니라 "그 달엔 안 샀다"와 "공짜로 샀다"를 구분해야 함).
    Playwright로 같은 품목(양파·kg)을 지난달 2,500원→이번달 3,000원으로 등록해 상승 추이가 정확히 그려지는 것, 비구조화 항목("잡화", "1+1")이 Top10(F-3-1-3)에는 포함되지만 이 select에는 제외되는 것, 빈 상태 문구까지 시각 검증 완료.
    2026-07-09 후속(PM 요청): 품목×단위 조합이 계속 늘어나면서 <select>가 스크롤만 길어지는 문제 — ExpenseEntryForm.tsxVendorCombobox/ItemCombobox와 동일한 입력창+필터 드롭다운 패턴으로 교체(검색 대상은 품목명만, PM 확인 — 단위명은 검색 범위 제외). 입력창 옆 "검색" 버튼은 드롭다운을 직접 열지 않고 타이핑만 한 뒤에도, 현재 필터링된 목록의 1순위(기존 정렬 그대로 구매량 많은 순)를 바로 선택. 선택된 조합 라벨("품목 (단위)")을 입력창 기본값으로 보여주다 보니 그 값 그대로 필터링하면 단위 표기 때문에 자기 자신도 안 걸리는 문제가 있어, 사용자가 실제로 타이핑을 시작했는지(isDirty)를 따로 추적해 타이핑 전엔 전체 목록을 보여주도록 처리. selectedKey 변경 시 입력창 재동기화는 useEffect 대신 렌더 중 이전 값 비교 패턴 사용(CLAUDE.md 컨벤션 — ExpenseEntryFormprevState와 동일한 이유로 캐스케이드 렌더링 경고 회피). Playwright로 "당근" 검색 시 양파/감자가 제외되고 당근/당근케이크만 남는 것, 드롭다운 클릭과 검색 버튼 클릭 둘 다 정확한 조합으로 전환되는 것까지 시각 검증 완료.
    2026-07-09 재설계(PM 요청): "검색해도 변화가 없다"는 리포트로 확인해보니, 처음 진입 시 구매량 1위 조합을 자동으로 골라 바로 그래프를 그려주던 기존 동작(이미 그래프가 있어 검색 후에도 "바뀐 게 안 보인다"고 느끼기 쉬움) 자체를 없애기로 결정 — page.tsxselectedUnitOption이 더 이상 options[0]으로 폴백하지 않고, ?unitItem=이 실제로 있을 때만 데이터를 가져옴(getUnitPriceTrend 호출도 그때만). 입력창은 처음엔 완전히 빈 채로 "항목을 검색하세요" placeholder만 표시, 그래프 영역도 선택 전에는 "검색해서 확인할 품목을 선택하세요" 안내문구로 대체.
    2026-07-09 CI 회귀 수정: PM이 GitHub Actions 실패 로그(docs/log/, 커밋 대상 아님)를 공유해 확인 — 위 재설계로 <select>가 사라지고 초기 자동 선택도 없어졌는데, e2e/stats-accuracy.spec.ts의 F-3-1-4 테스트가 옛 UI(unitSection.locator("select"), 등록 직후 자동 표시 기대) 그대로였어서 CI에서 실패하고 있었음. 검색 입력창에 타이핑 후 "검색" 버튼을 눌러 선택하는 새 흐름으로 테스트를 갱신 — 로컬에서 전체 E2E 스위트(48/48) 재확인.
    2026-07-09 버그 수정으로 "검색" 버튼 제거: PM 리포트 — 드롭다운에서 직접 클릭해 선택하면 정상 동작하는데, "검색" 버튼을 누르면 "이상하게 초기화된다"는 증상. 원인은 그 버튼의 "지금 필터링된 목록의 1순위(구매량 많은 순)를 선택" 로직 — 검색어와 여러 개가 매칭될 때 사용자가 의도한 항목이 아니라 다른 항목이 선택되면서 입력창 라벨이 갑자기 바뀌어 "리셋된 것"처럼 보였던 것. 드롭다운 클릭 선택은 항상 정확한 항목을 고르므로 문제가 없었음 — 모호한 자동 선택 로직과 버튼을 통째로 제거하고 ExpenseEntryFormVendorCombobox/ItemCombobox와 동일하게 드롭다운 클릭 선택만 남김(Enter 키 단축 동작도 함께 제거). e2e/stats-accuracy.spec.ts도 드롭다운 클릭 방식으로 다시 갱신, 로컬 E2E 스위트(48/48) 재확인.

  • [x] F-3-1-5 (앱기능) 동의어 사전 관리 화면(운영자)

    2026-07-07 구현: 기능명세서_IA.md F-1-1-13, 신규 라우트 /admin/synonyms(홈 위젯이 아니라 별도 화면이라 IA 1-1장 표에 새 행 추가). synonym_dictionary 테이블(마이그레이션 신규) — (group_key, term) UNIQUE, RLS는 authenticated SELECT만 정책 있음(운영자만 쓰기, INSERT/UPDATE/DELETE 정책 없음).
    운영자 판별: 별도 role 체계가 없어 서버 전용 env var ADMIN_EMAILS(콤마 구분, NEXT_PUBLIC_ 아님) 화이트리스트 방식 채택(PM 확인) — lib/admin.tsisAdminEmail(). 사이드바 노출 여부(AppShell/nav-items.ts)와 라우트 자체(page.tsx의 redirect), 서버 액션(actions.tsrequireAdmin()) 세 군데 모두 독립적으로 재검증 — 사이드바 숨김은 UI일 뿐, 실제 방어선은 서버 액션.
    버그 발견/수정 (GRANT 누락): 운영자 쓰기는 createSupabaseAdminClient()(Service Role)로 하는데, (1) 이 유틸이 원래 @supabase/ssrcreateServerClient(쿠키 기반)를 쓰고 있어서 로그인된 사용자가 호출하면 Service Role Key가 아니라 그 사용자의 세션 토큰이 Authorization으로 나가 RLS가 그대로 적용되는 문제 발견 → packages/supabase-client/src/server.ts를 쿠키/세션을 전혀 안 쓰는 순수 @supabase/supabase-jscreateClient로 교체(다른 호출부 없음, 영향 범위 이 함수 하나). (2) 그 다음엔 service_role에도 이 테이블 GRANT가 없어(auto_expose_new_tables 비활성화 — CLAUDE.md에 문서화된 기존 이슈와 동일 클래스, 이번엔 authenticated가 아니라 service_role 쪽) 42501 permission denied로 막힘 → 신규 마이그레이션(20260707103102_grant_service_role_synonym_dictionary_privileges.sql)으로 GRANT SELECT, INSERT, DELETE ON public.synonym_dictionary TO service_role 추가(DELETE도 PostgREST가 내부적으로 RETURNING을 써서 SELECT 권한이 같이 필요).
    Playwright로 (a) 운영자 계정 추가/삭제 CRUD 정상 동작, (b) 일반 사용자는 사이드바에 메뉴 자체가 안 보임, (c) 일반 사용자가 URL을 직접 알아내 접근해도 /로 리다이렉트되는 것까지 검증 완료. packages/supabase-client의 admin 클라이언트 교체는 이 화면 외 다른 호출부가 없어 영향 범위 확인됨.

  • [x] T-3-1 (테스트) 통계 정확도 검증(수동 계산값과 MV 결과 비교)

    2026-07-07 구현: e2e/stats-accuracy.spec.ts 신규 — F-3-1-1(지출분류별)~F-3-1-4(단가 분석)를 실 UI로 알려진 금액/수량을 등록한 뒤 테스트 코드에서 직접 계산한 기댓값과 화면 표시값을 비교(MV/캐시 쿼리를 직접 호출하지 않고 항상 대시보드 화면 경유). 상세는 docs/test-scenarios.md T-3-1 절, 테스트 데이터는 scripts/test-data/T-3-1.mjs 참고.
    부수 발견/수정 1: 이 과정에서 e2e/dashboard.spec.ts가 F-3-1-2/F-3-1-3 위젯 추가 이후 갱신되지 않아 깨져 있던 것(빈 상태 문구 개수 검증)을 발견해 같이 수정.
    부수 발견/수정 2: 전체 e2e 스위트 실행 중 e2e/calendar.spec.ts 4건도 실패 발견 — 처음엔 "F-1-4-3 관련 별도 미커밋 작업과 충돌"로 잘못 판단해 PM께 확인을 요청했으나, 실제로는 그 파일들이 이미 이번 세션 중 커밋된 상태였고(오래된 git status 스냅샷을 근거로 잘못 판단), 진짜 원인은 다른 완료된 TASK에서 버튼 문구/삭제 흐름이 바뀐 뒤 테스트가 못 따라간 것("저장하기"→"저장", "수정하기"→"수정", 네이티브 confirm()→커스텀 ConfirmDialog 팝업, /expenses/expenses?page=1 페이지네이션 정규화). 재확인 후 calendar.spec.ts 셀렉터를 실제 UI에 맞게 수정해 8/8 통과 확인.
    최종 확인: 전체 e2e 스위트(auth 6 + calendar 8 + dashboard 4 + home 1 + stats-accuracy 4) 23/23 통과, typecheck/lint 통과, 로컬 DB 재초기화 완료.

  • [x] T-3-2 (테스트) 캐시 갱신/무효화 테스트

    2026-07-08 구현: 단위 __tests__/stats-cache.test.ts(Redis/Supabase 모킹, lib/stats-cache.ts 캐시 히트/미스/무효화 로직 검증) + E2E e2e/cache-invalidation.spec.ts(실 Upstash Redis 연결) 신규. T-3-1은 "등록 후 홈 첫 진입"만 검증해 사실상 캐시 미스 경로만 탔던 것을 발견 — 이번엔 먼저 홈에 진입해 캐시를 채운 뒤 데이터를 등록/삭제하고 재조회해, 무효화 안 됐다면 낡은 값이 보였을 시나리오로 검증. 상세는 docs/test-scenarios.md T-3-2 절, 테스트 데이터는 scripts/test-data/T-3-2.mjs 참고.
    최종 확인: 단위 84/84 통과, 전체 e2e 스위트(auth 6 + calendar 8 + dashboard 4 + home 1 + stats-accuracy 4 + cache-invalidation 2) 25/25 통과, typecheck/lint 통과. supabase db reset 미실행(신규 이메일 가입으로 격리).
    부수 작업: PM의 Google Sheets 동기화 최초 설정 과정에서 docs/GOOGLE_SHEETS_SETUP.md가 실제 스크립트 동작과 어긋나 있던 것 발견(서비스 계정이 Drive 파일을 직접 생성 못 해 수동 시트 생성 필요, GOOGLE_DRIVE_FOLDER_ID는 미사용 죽은 변수, 실제로는 GOOGLE_SPREADSHEET_ID 필요) — 문서를 실제 동작에 맞게 정정.

6. Phase 4 — 통합 테스트 / QA / 출시 - 서브앱 전

  • [x] T-5-1 (테스트) 전체 기능 회귀테스트(F-코드 전체 체크리스트), 단 다음 단계인 서브앱 관련 항목 제외

    2026-07-08 구현: 기존에 자동화 테스트가 없던 6개 영역에 신규 회귀 테스트 작성 — 마스터데이터 CRUD(e2e/master-data.spec.ts), 품목 별칭/병합(e2e/item-merge.spec.ts), 예산 설정/게이지/리셋(e2e/budgets.spec.ts), 동의어 사전 비인가 접근 차단(e2e/admin-synonyms.spec.ts + __tests__/admin.test.ts), 지출 내역 필터(e2e/expense-filters.spec.ts), 반응형 스모크(e2e/responsive.spec.ts). 나머지 F-코드(인증/지출입력/대시보드/통계/캐시/캘린더)는 기존 스위트 재실행으로 확인. 상세는 docs/test-scenarios.md T-5-1 절, 테스트 데이터는 scripts/test-data/T-5-1.mjs 참고.
    의도적 미자동화: 동의어 사전 관리(F-3-1-5) CRUD 성공 경로는 운영자 판별이 PM 실제 이메일(ADMIN_EMAILS) 단일 화이트리스트라 반복 자동화 테스트가 그 계정에 쓰기 동작을 재현하는 게 안전하지 않아 보류 — 화이트리스트 로직(단위)과 비인가 접근 차단(e2e)만 자동화, CRUD는 기존 수동 검증 기록에 의존.
    부수 발견: 동의어 사전(synonym_dictionary)이 F-1-5-7(유사 항목 제안)에 실제로 연동되어 있지 않음(관리 화면 안내문은 연동됐다고 서술하지만 ExpenseEntryForm.tsxfindSimilarItem은 사용자 본인 품목명 편집거리 매칭만 함) — PM 확인 후 docs/백로그.md B-8로 기록.
    최종 확인: 단위 90/90 통과, 전체 e2e 스위트 39/39 통과(auth 6 + admin-synonyms 2 + calendar 8 + budgets 2 + cache-invalidation 2 + dashboard 4 + expense-filters 2 + home 1 + item-merge 2 + master-data 4 + responsive 2 + stats-accuracy 4), typecheck/lint 통과.

  • [x] T-5-2 (테스트) RLS 정책 보안 점검(타 사용자 데이터 접근 차단 검증)

    2026-07-08 구현: e2e/rls-security.spec.ts 신규 — UI를 거치지 않고 PostgREST(Supabase REST API)를 사용자 A/B 세션으로 직접 호출해 DB 레벨 RLS 자체를 검증(화면 권한 노출 여부와는 별개). 9개 테이블(category/payment_method/vendor/item/unit/transaction/transaction_detail/budget/budget_total)에서 타 사용자의 SELECT/UPDATE(/DELETE 정책 있는 4개는 DELETE도)가 전부 차단되고 원본 데이터가 그대로인지 확인, 동의어 사전(전역 공유)은 조회는 허용·쓰기는 차단되는지 확인. 상세는 docs/test-scenarios.md T-5-2 절, 테스트 데이터는 scripts/test-data/T-5-2.mjs 참고.
    안전장치: .env.local에 실제 클라우드 Supabase 프로젝트 자격증명이 있는 것을 확인 — 이 테스트는 Next.js를 거치지 않고 Supabase를 직접 호출하므로 env 로딩 순서에 기대지 않고 로컬 인스턴스 주소(127.0.0.1:54321)를 하드코딩해 클라우드 프로젝트 오염 가능성을 원천 차단.
    테스트 자체 검증(mutation testing): B 대신 A(소유자) 토큰으로 조회하도록 일시적으로 바꿔 재실행 → 예상대로 즉시 실패(권한 노출 감지)하는 것을 확인 후 원복 — 항상 통과하는 가짜 초록불이 아님을 확인.
    최종 확인: 2/2 통과(브라우저 미사용, 실행 1초 내외), 전체 e2e 회귀 스위트 41/41 통과, typecheck/lint 통과.

  • [x] T-5-3 (테스트) 반응형 크로스 디바이스 점검

    2026-07-08 구현: e2e/cross-device.spec.ts 신규 — T-5-1 스모크(모바일 드로어/데스크탑 사이드바)에 이어 ①한 번도 검증된 적 없던 태블릿 티어(768~1023px)의 지출 목록 페이지 크기(15건, 데스크탑 20건과 비교) 분기 동작 ②모바일/태블릿/데스크탑 3개 뷰포트에서 앱의 사실상 전체 13개 라우트(로그인 전 2개 + 로그인 후 11개, 동적 라우트 /expenses/[id]/edit 포함)에 가로 스크롤이 생기지 않는지 확인. 상세는 docs/test-scenarios.md T-5-3 절, 테스트 데이터는 scripts/test-data/T-5-3.mjs 참고.
    버그 발견/수정: 가로 스크롤 감지 로직 검증 중 app/globals.csshtml/body에 걸려있던 max-width: 100vw + overflow-x: hidden이 의도된 디자인 방어가 아니었음을 PM 확인 — 실제 레이아웃이 넘쳐도 스크롤바 없이 내용이 조용히 잘려나가는 형태라 더 위험한 패턴. clamp가 남아있는 상태에서 전체 13개 라우트를 body.scrollWidth 기준(clamp 영향 안 받음)으로 사전 스캔 → 오버플로 없음 확인 → CSS에서 제거 → 동일 스캔 재실행 → 제거 후에도 오버플로 없음 확인(불필요한 코드였음을 실측으로 증명), 별도 수정 불필요.
    최종 확인: 4/4 통과(CSS 제거 전/후 모두), 전체 e2e 회귀 스위트 45/45 통과, typecheck/lint/format 통과.

  • [x] T-5-4 (테스트) 성능 점검(대시보드 응답속도, MV/캐시 효과 확인)

    2026-07-08 구현: 다른 T-5-x와 달리 속도 측정이 목적이라 ①로컬 DB에 SQL로 대량 데이터(트랜잭션 1,500건+상세항목 1,400건)를 시딩해 MV/캐시 효과를 EXPLAIN ANALYZE/Redis 직접 호출로 일회성 정밀 측정(측정 후 데이터 삭제) ②회귀 스위트에는 응답 예산(5초) 초과 여부만 상시 감시하는 e2e/performance.spec.ts 커밋. 상세 수치는 docs/test-scenarios.md T-5-4 절, 테스트 데이터는 scripts/test-data/T-5-4.mjs 참고.
    측정 결과 — MV: 원본 JOIN+GROUP BY 대비 MV 조회가 상세항목 Top10 약 6배(1.011ms→0.162ms), 지출처 Top N 약 2배(0.664ms→0.309ms) 빠름, buffer hit도 7~10배 적음 — 데이터가 쌓일수록 격차 확대 예상.
    측정 결과 — 캐시(예상 밖 발견): 로컬 Postgres RPC(2~10ms)가 Upstash Redis GET(200~300ms)보다 오히려 20~100배 빠름 — 로컬 Postgres는 같은 머신이라 무지연인데 Upstash는 클라우드라 매번 실제 네트워크 왕복이 들기 때문. 캐시 설계 결함이 아니라 로컬 환경 특성(프로덕션에선 DB도 네트워크 건너편이라 격차 사라짐, 캐시의 진짜 가치는 지연시간보다 트래픽 증가 시 DB 부하/비용 절감)으로 판단 — 캐시 유지, 별도 수정 없음.
    최종 확인: performance.spec.ts 1/1 통과, 전체 e2e 회귀 스위트 46/46 통과, typecheck/lint 통과. 벤치마크 시딩 데이터는 측정 후 전량 삭제 확인.

  • [x] S-5-1 (시스템) 운영 환경 마이그레이션 최종 점검 + 프로덕션 배포, , 단 다음 단계인 서브앱 관련 항목 제외

    2026-07-09 PM 완료 확인. develop → 프로덕션 배포 완료(최근 [S-5-1] 커밋들 — CI Node.js 22 상향, pnpm 버전 이중 지정 충돌 수정, 사이드바/페이지 내 링크 prefetch 비활성화 등 반영). 운영 규칙: 배포 후 실사용 중 발견되는 버그 수정(예: F-1-5-11의 2026-07-09 검증 에러 한글화 수정)은 이 TASK가 아니라 해당 기능의 원 TASK 항목에 날짜별로 개별 기록 — S-5-1 자체는 배포 파이프라인/인프라 점검 완료로 범위 한정.

  • [ ] S-5-2 (시스템) 모니터링/에러로깅 설정(선택)

    2026-07-09: 아직 착수 전 — 배포된 사이트의 최종 검증(실사용 점검)이 끝난 뒤 착수 여부/범위를 다시 정할 예정. 검증 기간 중 계속 발견될 수 있는 버그 수정은 위 S-5-1과 동일한 규칙으로 해당 기능의 원 TASK 항목에 개별 기록하고, 이 TASK 자체는 모니터링/에러로깅 도구 도입 여부만 추적(체크박스는 그 결정이 나기 전까지 [ ] 유지).

7. Phase 5 — 서브앱 확장

4-1. 시스템: Webhook 수신 인프라

  • [ ] S-2-1 (시스템) packages/event-contracts 패키지 + zod 스키마 작성
  • [ ] S-2-2 (시스템) supabase/functions/subapp-webhook-receiver Edge Function 골격
  • [ ] S-2-3 (시스템) Webhook 서명 검증 로직(서비스 토큰/API 키)
  • [ ] S-2-4 (시스템) 멱등성 체크(external_id+source_app 유니크) 로직
  • [ ] S-2-5 (시스템) Webhook → TRANSACTION+TRANSACTION_DETAIL insert 로직 (input_type=SUBAPP)
  • [ ] S-2-6 (시스템) GitHub Actions deploy-functions.yml (Edge Functions 배포)
  • [ ] T-2-1 (테스트) Webhook 수신 통합테스트(정상/중복/위조 서명 케이스)

4-2. 시스템: 계정 연결/항목 매핑 데이터 구조

  • [ ] S-2-7 (시스템) SUB_APP 테이블 마이그레이션
  • [ ] S-2-8 (시스템) ACCOUNT_LINK 테이블 마이그레이션 + RLS
  • [ ] S-2-9 (시스템) SUB_APP_ITEM_MAP 테이블 마이그레이션

4-3. 앱 기능: 서브앱 연동 화면 (F-1-2)

  • [ ] F-2-1-1 (앱기능) 서브앱 목록/소개 화면
  • [ ] F-2-1-2 (앱기능) 계정 연결(Account Link) 플로우 화면
  • [ ] F-2-1-3 (앱기능) 항목 매핑 설정 화면
  • [ ] F-2-1-4 (앱기능) 연동 내역 확인 화면(원본보기, raw_payload)
  • [ ] F-2-1-5 (앱기능) 연동 해제 기능
  • [ ] T-2-2 (테스트) 계정연결~매핑~Webhook수신 E2E 테스트

4-4. 관리비 서브앱 (별도 작업물, 최소 구현)

  • [ ] S-2-10 (시스템) apps/sub-apps/utility-bill 프로젝트 골격
  • [ ] F-2-2-1 (앱기능) 관리비 명세 입력 화면(수동 입력 MVP, OCR은 후순위)
  • [ ] S-2-11 (시스템) 관리비 → 메인앱 Webhook 발행 로직(publisher)
  • [ ] T-2-3 (테스트) 관리비 입력→Webhook 발행→메인 반영 통합테스트

4-5 서브앱 확장 + 계정연결 정식화 (4단계)

  • [ ] S-4-1 (시스템) apps/sub-apps/subscription 골격 + Webhook 발행 연동
  • [ ] S-4-2 (시스템) apps/sub-apps/card-statement 골격 + Webhook 발행 연동
  • [ ] F-4-1-1 (앱기능) 다중 서브앱 대응 UI 보정 (목록/매핑 화면 확장성 점검)
  • [ ] S-4-3 (시스템) Event Bus 전환 필요성 재검토 (서브앱 3개 기준 Webhook 부하 점검, 필요시 Upstash Kafka 등 도입)
  • [ ] T-4-1 (테스트) 서브앱 3종 동시 연동 회귀테스트

지출 관리 특화 가계부 프로젝트로 돌아가기