TreeMap
A free, open-source disk-space visualizer. Squarified treemap, duplicate hunting, disk-full forecasting, and cleanup that only ever moves files to the Trash.
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
대학생 팀 프로젝트(팀플)를 위한 협업 앱. 초대 링크 + 이름으로 들어와서 역할을 조율하고, 회의 시간을 잡고, 자료를 모으고, 기여 기록을 남깁니다. 계정 가입은 없습니다 — 이메일을 쓰는 것은 이미 팀에 속한 사람이 몇 팀 중 어디로 들어갈지 고르는 보조 길일 뿐입니다(1-2절). 디자인 핸드오프(33화면 프로토타입)를 실제 코드베이스로 옮기는 중입니다. .env 를 만들고 Supabase 대시보드 > Connect 의 값으로 채웁니다 (.env.example 참고 — 커밋되지 않습니다). .env 는 운영(배포본)과 같은 Supabase 를 가리킵니다. 개발 중에 누르는 버튼이 실제 팀의 데이터를 바꾸지 않도록, 로컬 Postgres 를 따로 두고 .env.development.local 로 덮어씁니다(커밋되지 않습니다).檢視完整 README ↗
대학생 팀 프로젝트(팀플)를 위한 협업 앱. 초대 링크 + 이름으로 들어와서 역할을 조율하고, 회의 시간을 잡고, 자료를 모으고, 기여 기록을 남깁니다.
계정 가입은 없습니다 — 이메일을 쓰는 것은 이미 팀에 속한 사람이 몇 팀 중 어디로 들어갈지 고르는 보조 길일 뿐입니다(1-2절).
디자인 핸드오프(33화면 프로토타입)를 실제 코드베이스로 옮기는 중입니다.
| 프레임워크 | Next.js 16 (App Router) · React 19 · TypeScript |
| 스타일 | Tailwind CSS v4 + CSS 변수 디자인 토큰 |
| 아이콘 | lucide-react (직접 등록한 것만 번들에 들어감) |
| 서체 | Pretendard Variable (저장소에 포함, next/font/local) |
| 데이터 | PostgreSQL (Supabase) + Prisma |
| AI | OpenRouter (기본 openrouter/free) |
| 서버 | Next.js Server Actions |
npm install
.env 를 만들고 Supabase 대시보드 > Connect 의 값으로 채웁니다
(.env.example 참고 — 커밋되지 않습니다).
.env 는 운영(배포본)과 같은 Supabase 를 가리킵니다. 개발 중에 누르는 버튼이
실제 팀의 데이터를 바꾸지 않도록, 로컬 Postgres 를 따로 두고 .env.development.local 로
덮어씁니다(커밋되지 않습니다).
createdb chalddeok_dev # Homebrew Postgres 기준
Windows 는 PostgreSQL 설치 시 bin 을 PATH 에 추가하면 createdb 를 같은 이름으로
씁니다. 경로 예시 C:\Program Files\PostgreSQL\17\bin. psql 도 같이 들어갑니다.
기본 DB 사용자가 달라질 수 있어 DATABASE_URL 의 <사용자> 를 맞추고,
scripts/db-test-base.mts 는 DIRECT_URL 을 그대로 씁니다.
npm run verify 가 돌아가는지 확인하려면 로컬 Postgres 가 실제로 살아 있어야
합니다 — db:check·test 계열이 이 DB 를 봅니다.
# .env.development.local
DATABASE_URL="postgresql://<사용자>@localhost:5432/chalddeok_dev"
DIRECT_URL="postgresql://<사용자>@localhost:5432/chalddeok_dev"
SUPABASE_URL="" # 파일 저장소를 끈다 — 운영 버킷에 쌓이지 않게
npx prisma migrate deploy # 표 만들기
npm run db:seed # 데모 팀 한 개 넣기
npm run dev
이 파일은 next dev 뿐 아니라 Prisma CLI 와 시드도 먼저 읽습니다
(scripts/load-env.mjs). 그래서 파일이 있는 동안에는
prisma migrate 가 운영 DB 로 가지 않습니다. 운영에 직접 마이그레이션해야 할 때만
잠시 치우세요(평소에는 배포가 prisma migrate deploy 를 돌립니다).
시드는 로컬이 아닌 DB 를 가리키면 멈춥니다(같은 초대 코드의 팀을 지우고 다시 넣기
때문입니다). 정말 원격에 넣으려면 ALLOW_REMOTE_SEED=1 을 붙입니다.
로컬 시드에는 데모 팀원 네 명이 같은 재입장 코드를 받습니다 — /join/rejoin 에서
초대 코드 CD-3K7F2Q, 이름(김민준·이서연·박지호·최유나), 재입장 코드 DEMO-DEMO-DEMO.
원격에 넣을 때는 코드를 넣지 않습니다(누구나 아는 코드는 뒷문이 됩니다).
DATABASE_URL 은 런타임용 트랜잭션 풀러(6543), DIRECT_URL 은 마이그레이션·시드용
직결(5432) 입니다. 풀러로는 prepared statement 와 DDL 잠금을 못 버텨 마이그레이션이 깨집니다.
런타임 커넥션 풀은 src/server/db.ts 에서 직접 만듭니다 — 쉬고 있던
연결이 끊겼을 때 프로세스가 죽지 않게 받아 주고, 순간적인 연결 실패(P1001·P2024)는
짧게 두 번 다시 시도합니다. 자세한 이유는 그 파일 주석에 있습니다.
src/styles/tokens/)src/components/ui/features/team/team-mbti.tsx). MBTI는 표시와
대화를 돕는 재료일 뿐이며, 역할 배정의 근거로 쓰지 않습니다 — 배정은 희망 역할과 Veto 만
봅니다(data/accepted-roles.ts). 캐릭터도 마찬가지입니다(05 화면에 같은 취지가 적혀 있습니다)openrouter/free 로 다듬어져 보이며, 원문은 그대로 두고 그 사람에게만 순화문을 보여
주며, 원문으로 언제든 돌아갑니다app/error.tsx, 탭 안은 app/(tabs)/error.tsx 라 탭바가 남습니다)핸드오프의 33개 화면을 모두 옮겼습니다.
파일 업로드는
SUPABASE_SECRET_KEY가 있을 때만 동작합니다. 없으면 화면이 "파일 저장소가 아직 연결되지 않았습니다"라고 그대로 알립니다.
AI 도구는
OPENROUTER_KEY가 있을 때만 모델을 부릅니다. 키가 없으면 앱은 그대로 돌아가고 도구는 미리 적어 둔 샘플을 돌려주며, 화면이 "아직 AI가 연결되지 않았습니다"라고 그대로 알립니다 — 샘플을 AI 결과인 척 보여 주지 않습니다.
src/
app/ 라우트
join/ 01 초대 링크 입장 · 00 팀 만들기 · 이메일 로그인
onboarding/ 02~06
(tabs)/ 하단 5탭(홈·채팅·일정·드라이브·팀)이 붙는 화면들
components/ui/ 공통 컴포넌트 (핸드오프 ui.jsx 대응)
features/ 화면 코드 — chat · contrib · drive · home · onboarding ·
roles · schedule · social · tasks · team · tools
server/ 서버에서만 도는 코드 — DB 연결·세션·쓰기 액션과
AI·인증·푸시·레이트리밌·파일 저장소
data/ 읽기 전용 조회(api.ts) · 제품 설정값(catalog.ts)
lib/ 도메인 타입·MBTI·유틸
styles/tokens/ 디자인 토큰 (색·타이포)
prisma/ schema.prisma · seed.ts · migrations/
docs/handoff/ 디자인 핸드오프 원본 — 여기 있으면 화면이 어디까지 왔는지
알 수 있습니다(참고 자료, 빌드에 포함되지 않음)
이 블록은 파일을 다 적은 게 아닙니다. 어느 폴더에 무엇이 들어 있는지 알려 주기 위한
간단한 안내입니다. 화면 번호(07·11·13 등)와의 대응은 docs/handoff/HANDOFF.md
에 있고, 새 폴더를 만들면 굳이 여기를 고칠 필요가 없습니다.
| 폭 | 레이아웃 |
|---|---|
| ~599px | 뷰포트를 꽉 채우고 하단 5탭으로 이동 |
| 600~1023px | 본문이 860px 까지만 넓어지고 가운데 정렬 · 하단 탭바 유지 |
| 1024px~ | 탭바가 왼쪽 세로 막대로 바뀌고 본문에 폭을 내줌 |
폭이 생기면 화면도 그에 맞게 바뀝니다 — 홈은 두 기둥, 드라이브 제출함은 두 줄, 기여도 리포트는 A4 비율, 08 시간표 격자는 칸이 44px 로 커져 최소 탭 영역을 지킵니다.
온보딩은 탭이 없는 집중 흐름이라 넓은 화면에서도 420px 카드로 모읍니다 — 입력칸 하나가 화면 폭만큼 늘어나면 읽기 어렵습니다.
채팅(33번)은 넓은 화면에서 실제로 3분할로 동작합니다.
| 폭 | 채팅 |
|---|---|
| ~1023px | 화면을 한 번에 하나씩 (32 → 19 / 31) |
| 1024px~ | 목록 + 대화 |
| 1280px~ | 목록 + 대화 + 자료·정보 |
1024px 에서 세 기둥을 다 펴지 않는 이유는 왼쪽 앱 내비게이션이 232px 을 이미 쓰기 때문입니다 — 거기서 세 기둥을 펴면 대화 칸이 말풍선 한 줄도 못 담습니다.
기획안에 규칙이 없어 임시로 정한 지점은 화면 안에 [검토] 점선 박스로 표시돼 있고,
평소에는 보이지 않습니다. 확인하려면 URL 에 ?review=1 을 붙이거나 콘솔에서 __CD_REVIEW__().
확정이 필요한 지점의 목록은 여기에 적지 않습니다 — 화면이 곧 목록이고, 위의 방법으로
한 번에 다 볼 수 있습니다. 문서에 개수를 적어 두면 화면이 늘었는데 숫자를 고치지 않는 일이
반복됩니다. 원래 기획안과 주고받은 기록은 docs/handoff/HANDOFF.md
에 남아 있습니다(역사 문서이므로 지금의 목록과 다를 수 있습니다).
npm run verify
db:check 는 지금 가리키는 DB 와 스키마를 비교합니다. 로컬에서 돌리면 로컬 DB(에셋)를
봅니다 — "마이그레이션을 깜빡했나"를 잡는 검사입니다. "운영 DB 가 뒤처졌나"를 잡는
것은 Vercel 빌드 쪽 db:check 입니다(거기서는 DIRECT_URL 이 운영 DB 를 가리킵니다).
두 개를 같은 이름으로 부르는 이유가 있어 헷갈리기 쉬우니 기억해 두세요.
cp scripts/pre-push.sh .git/hooks/pre-push && chmod +x .git/hooks/pre-push
Windows 는 위 두 줄을 Git Bash 에서 실행합니다(PowerShell·cmd 에는 cp·chmod 가
없습니다). 훅 자체가 sh 스크립트라 실행도 Git Bash 가 맡습니다 — 푸시할 때 뜨는 창이
Git Bash 면 그대로 돕니다.
verify 를 돌립니다 — Vercel 이 하는 배포 게이트를 더 엄격하게 로컬에서 합니다. 순서가
꼭 같을 필요는 없습니다. Vercel 에만 있는 것도, 로컬에만 있는 것도 있습니다.
로컬 (npm run verify) | CI (.github/workflows/ci.yml) | Vercel (vercel.json) | |
|---|---|---|---|
prisma migrate deploy | — | 빈 DB 에 전부 적용 | 운영 빌드만. 프리뷰(브랜치 푸시)는 하지 않는다 |
db:check | 한다 | 한다 | 운영 빌드만 |
storage:check:gate · 빌드 | 한다 | 빌드만 | 한다 |
db:migrations | 한다 | 한다 | — |
npm test · 서버 액션 하네스 | 한다 | 한다 | — |
test:drive (진짜 저장소) | 키가 있을 때만 | 안 한다 | — |
tsc · lint | 한다 | 한다 | — |
로컬에만 있는 것은 각자의 이유가 있습니다. db:migrations 은 "파일이 커밋됐는가"인데
Vercel clone 은 커밋된 것만 받아서 항상 통과합니다. 반대로 Vercel(운영 빌드)만 하는
migrate deploy 는 로컬에서 대신할 수 없고, 빌드는 죽었는데 마이그레이션은 이미 운영 DB 에
적용된 상태로 남을 수 있습니다(그래서 실패한 배포 뒤에는 롤백이 아니라 코드를 굴립니다).
하네스 전체는 npm run test:harness 한 줄입니다 (scripts/ci-harness.mjs). 푸시 전 훅·verify:code·CI 가
같은 목록을 씁니다 — 목록은 손으로 적지 않고 package.json 의 test:*(= scripts/harness/*.mts 를 부르는
것)에서 도출합니다. 새 하네스는 스크립트를 달기만 하면 세 곳에 모두 들어갑니다. 빠질 수 있는 길은 둘뿐이고
둘 다 소리가 납니다: ci-harness.mjs 의 EXCLUDED(이유를 적어야 한다), 그리고 npm run test:harness:check 가
잡는 "진입점은 있는데 스크립트가 없다"(만들어 놓고 안 도는 하네스).
&& 로 이으면 앞이 깨질 때 뒤가 아예 안 돕니다).test:drive 는 키가 있을 때만 돕니다 (npm run test:drive -- --if-configured). 예전에는 키가 없는 환경에서
항상 실패해서 모두가 --no-verify 를 썼고, 그러면 이 훅 전체가 의미를 잃었습니다. 플래그 없는 npm run test:drive 는 여전히 엄격합니다(키가 없으면 실패). 키가 있는데 운영 버킷을 가리키면 두 방식 모두 거절됩니다.main 에 어느 트리에서도
통과할 수 없는 검사 네 개가 들어와도 아무도 몰랐습니다. 전체가 1분 안팎이라 조를 나누지 않았습니다.audit:actions)의 "100% 검증" 은 액션 이름이 테스트 소스에 나오는지만 셉니다 — 통과 여부는 하네스
실행(위)이 봅니다.2026-09-28 에 화면만 먼저 반영하고 src/data/api.ts 반영을 빼서 타입 에러 57건인 커밋이
push 돼 Vercel 빌드가 두 번 실패했습니다. 그 사실을 알게 된 건 배포 목록을 봤을 때뿐이었고,
로컬에서 먼저 멈추게 하는 것이 그 일을 막습니다.
.git/hooks/ 는 커밋되지 않으니 규칙은 scripts/pre-push.sh 에
있습니다. 위 줄은 그 파일을 부르는 것만 합니다. 빼고 싶으면 --no-verify 로 부르되, 그
뒤 Vercel 이 빨개질 수 있다는 것을 알고 하세요.
npm run test:roles
여섯 액션이 전부 미검증이었다(npm run audit:actions 기준 "없음"). 그런데 규칙이 npm run decisions 가 세는 미결과 같은 자리에 있는 종류다 — 팀이 동의해야 하는데 몇 명을 모으는지 세는
곳이다.
검사 42건 — 제안하면 팀에 알림이 간다(없으면 마감만 지나가고 아무도 모른다) · 같은 역할에 두
번 제안할 수 없다 · 동의는 중복돼도 한 줄이고 반대는 제안을 지운다(그리고 팀에 알림) ·
동의가 살아 있으면 뽑지 않는다 · 도구는 팀이 동의한 것을 쓴다 — 눌러서 임의의 도구로 바꾸면
팀이 동의한 대상이 아니게 된다 · 결과는 당사자가 받을 때까지 확정되지 않는다 · 확정된 뒤에는 다시
뽑을 수 없다 · 무효 추첨(당첨자가 나감)만 자리를 비운다 · 당사자가 거절하면 결과가 지워지고 다시 비운다 · 남이 대신 거절할 수 없다 · 이미 확정한 것은 거절로 뒤집지 않는다(경쟁 상태가 되지 않는다) · 혼자 인 경우에만 그 역할을 맡고(claimSoleRole) 도구는 "담당" · 겹치거나 아무도 안 골랐으면 못 맡는다.
세 가지를 재서 적었다
awaitingConsent 는 마감 시각만 본다 — 전원이 동의해도 마감 전이면
뽑을 수 없다. 그리고 동의가 강제되지 않는다 — 마감 뒤에는 동의 없이도 뽑힌다."drawn" 이 아니라 "ok" 다. 그리고 뽑으려면 하고 싶은 역할이 있어야 한다 —
후보가 없으면 empty 로 끝나고 뽑힌 것이 아니다.myPendingDraw 이 확정분을 먼저
거절하고("gone"), 그 아래 deleteMany 의 accepted: false 는 도달하지 않는다. 하네스로는
뒤의 줄을 끊어도 깨지지 않는다 — 앞에서 이미 막기 때문이다. 앞단을 고쳐야 드러난다.
두 겹인 것은 의도고, 남는 이유를 코드에 적어 두었다.as(팀.멤버) 와 as(팀.세션토큰) 을 헷갈리면 서버가 엉뚱한 말을 한다. 멤버 객체를 넘기면
Prisma 가 token 이 객체라며 던지고 그 아래의 진짜 이유를 가린다. 하네스가 토큰이 문자열인지
먼저 보고 막는다 — 그랬어야 "로그인이 필요합니다" 라는 엉뚱한 말 대신 맞는 말을 하게 된다.검사가 규칙을 실제로 본다 — 동의 확인과 도구 규칙을 무시하면 두 건이 깨진다.
npm run test:team
logOut · leaveTeam · handOverAndLeave · disbandTeam 네 액션이 미검증이었다. 여기는
앱에서 되돌릴 수 없는 자리다 — 나가거나 없애면 다시 없다.
검사 51건. 네 개의 방어선이 실제로 잡힌다(각각을 무시하면 깨진다):
handOverAndLeave 도 팀장만 오른다.disbandTeam 은 되돌릴 수 없다 — 이 한 줄이
팀 전체보다 강하다.퇴장으로 남의 것이 지워지지 않는지도 봤다 — npm run decisions 의 "2GB 근거 파일은 업로드
첨부만" 이 그 무게에 걸린 자리다. 사람이 나가도 제출함·올린 파일·파일 내용·출처·회의록이 남고,
나간 사람도 다시 들어올 수 있다. 행을 넣어 둔 뒤 나간 뒤에 센다 — 나간 뒤에 세면 "지워졌다" 와
"애초에 없었다" 를 구분하지 못하고 둘 다 0 이다.
네 가지를 처음에 내가 틀렸다가 고친 것이다
Submission 이라는 모델은 없다. MeetingSlot 은 date/startMin 이 아니라
day/time/available/total 이고, 납품은 SubmissionBox → SubmittedFile → FileVersion
세 겹이다. 모두 실제로 넣어 둔 뒤 퇴장시킨다."팀장 혼자" 라는 말에 조심했다 — 사람이 한 명이어서가 아니라 넘길 수 있는 사람이 없다는
뜻이다. 세션 없는 팀원을 넣었다가 leftAt 을 세운다. 한 명짜리 팀을 만들면 "혼자가 아니라 팀원이
없다" 는 다른 버그를 재게 된다.
npm run test:report-share
createReportShareToken·revokeReportShareToken 두 액션이 미검증이었다. 그런데 이건 외부에
공개되는 링크의 문이다 — 교수가 열 수 있는 페이지가 토큰 하나로 열리고, 취소가 약하면 이미
끝난 것도 계속 열린다.
검사 8건. 폐기와 만료는 실제로 잡힌다(revokedAt·expiresAt 조건을 무시하면 두 건이 깨진다).
teamId 범위가 있어야 남의 링크 폐기를 못 한다.아직 없는 것: /report/<token> 페이지가 아직 없다. 토큰만 보호되고 노출 경로가 없다 — 그래서
이 하네스는 토큰의 수명만 보장하고, 누가 열 수 있는지는 아직 그림이다.
npm run test:email-auth
여섯 액션이 미검증이었다. 그런데 이 앱에 가입·로그인이 없는 것 같다는 판단이 한 번도 뒤집힌 자리라 여기가 진짜 인증 경계다.
검사 38건. 두 개의 옛 구멍이 실제로 막혀 있다(각각 무시하면 깨진다).
memberId 하나만 보고 세션을
만들어서 누구라도 팀원 한 명의 id 만 알면 그 사람 세션이 붙었다. 지금은 인증을 통과한
이메일이 쿠키(cd_email)에 남고 명단 안에서만 고른다. 무시하면 인증 없이도 ok 가 된다.나머지 규칙 — 틀린 번호는 횟수를 세고 남은 횟수를 알려 주며 다섯 번이면 토큰째 잠긴다(남겨 두면
"다시 시도"가 무한히 된다) · 맞는 번호와 매직 링크는 한 번 쓰면 소모 · 재발송은 1분 막음 ·
팀이 없으면 no-teams · 여러 팀이면 고르게 함 · 팀 고르기는 한 번 쓰면 쿠키가 지워진다 ·
나간 사람은 후보에서 빠짐.
하네스가 실제 메일을 나가지 않게 한다 — 진입점에서 GMAIL_USER·RESEND_API_KEY 를 비운다.
.env.local 에 나중에 운영 메일 변수가 생기면(복사해 붙이는 일이 많다) 그대로 나가고, 그 주소는
사람이 쓰는 곳일 수 있다.
다섯 가지를 처음에 내가 틀렸다가 고친 것이다
no-teams 다 — 앞의 검사가 전부 "로그인 실패" 로 끝나고 그건 하네스 순서 잘못이다.clearAll() 을 호출해 버렸다. 인증이 끝나면 cd_email 이 남고 팀 고르기는 그 쿠키를 본다 —
항아리를 비우면 양수 검사가 저절로 거짓이 되고 거절 검사들만 통과한 것처럼 보인다.MAX_OTP_ATTEMPTS 가 5). 한 칸 어긋나면 네 건이 깨진다.expired 로 끝나고 통과한 것처럼 보인다 — 그래서
6자리인지 먼저 확인한다..status 를 바로 못 읽는다. cast 로 읽어야 "성공했는데 사유가 없다" 와
"거절했는데 사유가 없다" 를 구분할 수 있다.해결된 위험 두 가지 (2026-10-10 보완 완료)
updateMemberEmail 이 인증 없이 emailVerifiedAt 을 찍지 않는다. 인증 절차(OTP/매직링크)를
통과하지 않은 단순 입력 변경은 emailVerifiedAt: null 로 유지되며, 이미 증명된 쿠키(cd_email)가
있거나 기존에 인증 완료된 동일 주소일 때만 인증 상태를 보존합니다.reason: "email-in-use" 로 즉시 거절하여 팀원 간 오인 및 식별 혼선을 차단합니다.npm run test:notes
세 액션이 미검증이었다. 회의록은 기록이 다르면 회의가 없는 것이 된다 — "무슨 일 하기로 했는가" 를 잃는 자리다.
검사 33건. 팀 필터는 실제로 잡힌다 — 세 곳의 teamId 조건을 무시하면 남의 팀 회의록이
그대로 나온다.
getMeetingNoteByProposalAction,
getMeetingNoteAction) 다.taskCount 는 음수가 되지 않는다.null 이다."한 줄뿐" 을 지킬 것은 코드만이 아니다. upsert 키를 사람마다로 나누어도 깨지지 않았다 —
MeetingNote.meetingId 가 DB 에서 @unique 라서 못 만든다. 즉 한 줄 보장은 DB 가 하고,
하네스는 그 사실을 확인한다. upsert 를 지우면 두 줄이 될까? 는 별도 질문이다(DB 가 막는다).
두 가지를 처음에 내가 틀렸다가 고친 것이다
MeetingProposal 에 label 이 없다. 설명·대상은 MeetingSlot 쪽에 있고 제안은 slotId
로 물고 든다. 그리고 respondBy 가 필수다 — 회의 제안은 언제까지 답을 받는지가 붙는다.npm run test:attendance
출석 체크는 기여 기록을 만든다 — 잘못 쌓이면 없던 참석이 기록에 남거나 한 번의 참석이 두 번
세어진다. 두 액션(saveMeetingAttendance, getMeetingAttendance)이 미검증이었다.
검사 51건 — 팀장만 · 확정된 회의에만 · 자기 팀 회의에만 찍힌다 · 출석과 기여 기록이 같은 수만큼 생기고 대기 상태의 자동 기록이다 · 같은 명단을 다시(그리고 동시에) 저장해도 늘지 않는다 · 남의 팀·나간 사람·없는 아이디는 버려진다 · 체크를 풀면 아무도 건드리지 않은 기록만 회수된다 · 조회는 팀 안으로만 열리고 편집은 팀장·확정 회의에서만 가능하다.
이 검사가 실제 버그를 잡았다(2026-10-10). 팀원이 확인했거나 이견이 달린 기록은 체크를 풀어도
남는데, 그 기록은 출석 행의 아이디에 묶여 있었다. 출석 행은 지워졌다 새 아이디로 만들어지므로
다시 체크하면 이미 있는 기록을 알아보지 못해 같은 참석이 두 건의 기여로 세어졌다. 원천을
출석 행에서 회의로 옮겼다((회의, 사람) 은 출석 행의 유일 키와 같은 쌍이다).
동시에 두 번 저장하면 한쪽이 DB 오류 원문을 던졌다(2026-10-11) — 유일 제약이 중복은 막지만 사용자는
Unique constraint failed 를 본다. 검사 "던졌다면 DB 오류 원문이 아니다" 가 약 절반씩만 실패해서 한동안
운 좋게 통과하다 드러났다. 회의 행을 FOR UPDATE 로 잠가 한 명씩 지나가게 했고(드라이브와 같은 관용구) 8회
연속 통과한다. 동시성 검사는 한 번의 통과를 믿지 않는다. 이미 쌓인 기록은
20261009150000_attendance_contrib_origin_meeting 이 옮긴다 — 출석 행이 남은 기록만이고, 이미 지워진
출석에 묶인 기록은 어느 회의인지 알 수 없어 지우지 않고 둔다.
npm run test:schedule
회의 시간은 이 앱이 제일 자주 쓰는 길인데 입구의 네 액션(saveMyBusyBlocks, askForTimetable,
proposeMeeting, requestRemoteInput)을 아무도 부른 적이 없었다. meetings 하네스는 팀 겹쳐보기에서
칸을 골라 제안하는 proposeMeetingAt 만 본다 — 후보 목록에서 고르는 proposeMeeting 은 다른 함수다.
검사 93건 — 저장은 내 줄만 바꾸고, 어떤 거절이든 이전 시간표가 그대로 남는다 · 기본 사유에 붙여 보낸 이름은 버려지고 직접 입력은 다듬어진다 · 지난 주 줄은 조용히 버려진다 · 저장하면 후보가 다시 만들어지고 제안이 올라와 있으면 건드리지 않는다 · 부탁은 자기·남의 팀·나간 사람에게 못 가고 하루 한 번이다 · 제안은 남의 팀 후보를 거절하고 제안자를 찬성으로 센다 · 상세 값(장소 100자·안건 200자·길이)은 서버가 정리한다 · 지나간 시각은 제안할 수 없다 · 의견 요청은 그 시간에 막힌 사람에게만, 나·나간 사람은 뺀다.
이 검사가 결함 둘을 잡았다(2026-10-10).
Poke)처럼 ScheduleAsk 의 유일 키가 마지막 문이
되었고, 화면의 "오늘 보냄" 도 같은 표를 읽는다. 알림이 가지 못하면 그날의 한 번을 되돌린다.⚠️ 동시성 검사는 한 번으로 믿지 않는다. 틈이 있어도 타이밍이 맞으면 통과한다 — 여러 쌍을 겹쳐 눌러야 틈이 드러난다.
npm run test:badges
test:inbox(아래 절)가 "누구의 것인가"를 본다면 이쪽은 숫자가 화면과 같은 답을 내는가와 읽음 처리의 경계를 본다.
(처음에는 이 하네스도 inbox 라는 이름이었는데 main 에 같은 이름이 먼저 들어와 badges 로 옮겼다.)
pollNavBadges·pollNotifications·markNotificationsRead·markDriveSeen 은 모든 탭이 몇십 초마다
부르는 길인데 아무도 부른 적이 없었다. 틀리면 한 화면의 문제가 아니라 사용자가 앱을 열 때마다 보는
숫자가 거짓이 된다.
검사 66건 — 알림은 내 것만, 최신순이고 · 목록 창은 50건인데 안 읽은 수는 창 밖까지 센다(55건이면 55, 탭 배지와 같은 숫자) · 읽음은 내 알림에만 찍히고 남의 id 를 섞어 보내도 남의 것은 그대로다 · 이미 읽은 시각은 바뀌지 않는다. 배지는 화면과 같은 답을 낸다 — 이미 응답했거나 마감이 지난 회의, 이미 동의한 추첨, 이미 확인한 기록, 내가 쓴 메시지·내가 올린 버전은 "내 할 일"이 아니고, 다른 팀의 일은 세지 않는다 · 승인 대기는 팀장만 센다 · 역할 겹침과 동의는 서로 빼서 세므로 한 역할이 두 번 들어가지 않는다 · 드라이브는 들어오기 전 버전은 세지 않고, 열었다고 적으면 0으로 돌아가며 읽음 표시는 사람당 한 줄이다.
이 검사가 결함 하나를 잡았다(2026-10-10). markNotificationsRead([]) 가 빈 목록을 "전부"로 읽어
안 읽은 알림을 통째로 읽음으로 바꿨다. 호출부는 지금 안전하다(한 건을 넘기거나 아예 안 넘긴다). 그러나
화면이 거른 결과가 우연히 비는 순간 알림이 모두 사라지는 함정이라 계약을 바꿨다: "전부" 는 목록을 아예
주지 않는 것뿐이고 빈 목록은 아무것도 읽음으로 바꾸지 않는다.
검사 쪽 실수가 세 번 있었다 — 코드는 맞았다. (1) 팀장이 팀원에게 쓴 DM 도 팀원에게는 받은 DM 이다(내가 쓴 것은 쓴 사람에게만 빠진다). (2) 버전 시각을 미래로 두면 "열었다" 고 적은 시각보다 뒤라 영원히 새 버전이다. (3) 방금 만든 팀원은 가입 시각이 지금이라 조금 전 버전이 "들어오기 전"으로 정확히 제외된다 — 가입 시각을 하루 전으로 돌려 놓고 본다. 하네스가 실패하면 코드부터 의심하지 말고 기대가 맞는지도 본다.
npm run test:drive-save
드라이브의 올리기(prepareUpload·finishUpload)는 test:drive 가 지킨다. 그런데 같은 약속을 다른
문으로 지키는 길이 셋 더 있었고 아무도 부른 적이 없었다 — announceUploads(올렸다고 팀에 알림),
getMyTeamSubmissionBoxesForSelect(저장할 제출함 목록), saveResearchToDrive(리서치 결과를 제출함에 PDF 로 저장).
검사 60건 — 제출함 목록은 내 팀 것만 · 알림은 내가 30분 안에 이 제출함에 올린 버전이 있는 파일만 알린다 (남의 파일·오래된 버전·다른 제출함·남의 팀은 조용히 지나간다) · 여러 개면 "외 N개" · 마감 뒤에 올렸으면 "마감 후 제출", 복원 버전은 아니다 · 저장은 파일 이름을 정리하고 · 같은 제목은 새 버전이며 · 동시에 저장해도 버전 이름이 겹치지 않고 · 기여 기록과 팀 알림이 따라간다.
⚠️ 이 검사는 저장소(Supabase) 키 없이 돈다. 키가 없으면 저장소를 건드리지 않는 쪽 길이 검사되고, 저장소
실패는 일부러 닿지 않는 주소를 심어 만든다. 진짜 객체를 올리는 길은 npm run test:drive 만 본다(개발
버킷 키가 필요하다).
이 검사가 결함 셋을 잡았다(2026-10-10).
saveResearchToDrive 는 업로드가 실패하면 로그만 찍고 버전 행·기여
기록·팀 알림을 그대로 만들었다. 객체 없는 버전은 미리보기가 404 가 되고 팀은 파일이 있다고 믿는다. 이제
던져서 트랜잭션이 되돌려진다 — 파일·버전·기여 기록이 남지 않는다.new Map(entries) 는 중복 키에서 마지막 값을
쓰는데 목록이 최신순이라, 주석은 "가장 최근 것"이라 했지만 v1 이 남았다. "마감 후 제출" 판정도 오래된
버전으로 갔다.검사 쪽 실수가 하나 있었다. 한글·공백이 섞인 제출함 이름의 순서는 DB 정렬 규칙(collation)이 정하는 것이지 앱의 규칙이 아니다 — 영문 이름으로 순서를 본다.
알려진 문제(수정하지 않음). saveResearchToDrive 가 만드는 PDF 는 한글이 깨지고(Helvetica 에 UTF-8
바이트를 그대로 넣는다) 200자에서 잘린다(URL 이 https://e 에서 끊긴다). 리서치 결과는 대부분 한국어라
저장된 "참고자료"가 사실상 읽히지 않는다. PDF 생성 방식(한글 글꼴을 넣는 라이브러리) 설계가 필요해서 이
검사는 내용을 단언하지 않는다.
npm run test:ai-actions
ai.* 일곱 개와 social.spinMenu 가 audit:actions 의 마지막 "없음"이었다. test:ai 는 사용 기록(limit.ts)을,
smoke-ai 는 모델 층의 순수 규칙을 본다 — 그 사이, 액션이 문을 올바르게 여닫는가는 아무도 보지 않았다.
검사 76건 — 예시 문장은 모델을 부르지 않는다 · 오늘 쓴 횟수는 읽기만 하고 읽기 도움을 따로 센다 · 키가 없으면 예시를 돌려주고 부르지 않은 호출은 기록하지 않는다(리서치 예시는 모두 출처가 있다, 모르는 발표 모드는 학술체로 떨어진다) · 키가 있으면 기록하고 모델이 실패하면 방금 깎은 그 행 하나만 되돌린다(그 사이에 다른 사람이 쓴 몫은 남는다) · 내 내역은 내 기록만(다른 사람의 이름·도구 기록이 없다) · 팀 현황은 도구별 집계뿐(이름·시각 칸이 없다) · 두 파일 모두 BOM 이 붙고 쉼표·따옴표가 든 이름도 한 칸이다 · 팀 요약은 집계뿐이고 7일 밖·남의 팀 기록은 세지 않는다 · 누가 하지는 목록에 없는 도구를 거절하고 동시에 돌려도 결과·도구·정한 사람이 한 번의 돌림에서 나온 한 벌이다.
이번에는 코드 결함이 나오지 않았다. 검사가 실패한 두 건은 모두 내 기대가 틀린 것이었다(발표 모드 이름이
casual 이 아니라 academic/conversational/concise 이고 모르는 값은 학술체로 떨어진다 · 하네스의 redirect
대역은 이동 대신 __harness_redirect__:/join 을 던진다). 결함이 없다는 말이 검사가 무는지는 말해 주지 않아서
일부러 망가뜨려 보았다 — 환불 끄기 · 도구 이름 검증 끄기 · 팀 요약의 팀 필터 빼기 세 곳이 9건의 실패로
잡혔다. 앞으로도 "통과" 만으로 믿지 않으려면 같은 방식으로 확인한다.
하지 않는 것. 모델 호출 주소(openrouter.ai)가 코드에 박혀 있어 가짜 서버를 둘 수 없고, 가짜 키를 외부로
보내지 않는다. 키가 있는 경로는 runTool 에 호출 함수를 직접 넘겨 본다. 모델이 실제로 무엇을 말하는가는
npm run tool:bench·cushion:bench 의 몫이다.
내려받기 범위는 정책이 확정되며 검사를 다시 썼다. 처음에는 exportAiUsage 가 팀 전체인지 내 것인지 정해지지
않아 팀 경계와 형식만 고정했는데, AI 허브에서 "내 내역은 내 기록만 · 팀 현황은 이름·시각 없는 집계"로 확정되어
(exportTeamAiUsage 신설) 다른 사람이 파일에 새는 것은 형식이 아니라 규칙 위반이 되었다. 합쳐 보니 이 하네스가
옛 가정으로 3건 깨졌고, 확정된 범위를 직접 고정하도록 바꿨다.
npm run test:inbox
네 액션이 미검증이었다. 공통점이 하나다 — 읽거나 쓰는 것이 "내 것" 인지 그게 규칙의 전부다.
검사 32건. 두 개의 소유권 경계가 실제로 잡힌다(각각 무시하면 깨진다).
남의 알림은 읽음으로 못 바꾼다. 조건에 memberId 를 함께 넣는 이유다 — 떼면 남의
알림까지 읽음이 찍힌다.
도구 이름은 목록에 있는 것만. 빼면 화면에 없는 이름("제빵기 로 정했습니다")이 남고
되돌릴 수 없다.
id를 주면 그 것만, 주지 않으면 내 읽지 않은 것 전부 · 이미 읽은 것은 다시 찍히지
않는다 · 목록은 newest-first · 남의 알림은 배지에 섞이지 않는다 · 배지는 내가 안 한
일이 올라오고 읽으면 내려간다 · 세부는 parts 로 나뉜다 · 밥 모는 팀에 하나 ·
다시 정하면 덮어써지고 알림에 "대신" 이 남아 "몇 명이 같은 걸로 돌린 거지?" 에 대답된다.
한 가지를 처음에 내가 틀렸다가 고친 것이다 — 맨 위를 절대 비교했다. 앞선 시나리오가 남긴 알림이 섞이고(더 최신이다) 매번 깨졌다. 이건 나열 테스트가 아니라 두 줄의 상대 순서를 보는 자리다.
배지 구조를 지어냈다가 바로잡았다 — joinRequests 같은 필드는 없다. 배지는
team·cal·chat·drive·notifications 합계와 parts 세부로 나뉜다.
npm run test:rejoin
재입장은 오늘 우리가 직접 고친 자리가 세 곳이다 — 동명이인 · 기기 끄기 · 재입장 코드. 그런데
rejoin.ts 의 여섯 액션은 아무도 부르지 않았다.
가장 위험한 곳은 코드 찍기와 잠금이고, 주석에 이런 사고가 적혀 있다 — "팀 코드와 이름 아무 쌍이나 알고 있는 사람이 그 쌍을 10분씩 잠글 수 있었다". 고쳐졌다는 것을 아무도 확인하지 않았다.
검사 22건 — 없는 사람(팀 코드+이름)은 실패로 세지 않는다(그 쌍을 아는 사람이 아무 코드나 찍어도 누구도 잠기지 않는다) · 코드를 아직 받지 못한 사람도 세지 않는다 · 틀린 코드는 세고 6회째에 잠긴다 · 맞는 코드도 잠기면 못 들어간다 · 잠금은 (팀 코드, 이름) 조합 단위다 · 맞으면 카운터가 비고 · 나간 사람이 돌아오면 명단에 돌아온다 · 코드를 새로 만들면 이전 코드는 즉시 못 쓴다 · 기기를 지우는 것은 내 것만.
잠기는 시점은 재서 적었다 — 한계가 10분에 5회이고 판정이 > max 이므로 6회째로 짐작했는데,
재보니 6회째였다(주석으로 짐작하지 않는다 — 내가 짐작했다가 틀릴 뻔했다).
카운터 키는 sha256 해시다 — 팀 코드로 contains 로 찾으려고 하면 영원히 0 이 나온다(내가
그랬다). 코드의 키 계산을 그대로 불러와야 무엇을 세는지를 볼 수 있다.
⚠️ 남는 위험 하나를 고치지 않고 고정한다 — 잠금의 키가 (팀 코드, 이름) 이므로 그 조합을 아는
사람은 그 팀원을 10분 잠글 수 있다. "없는 사람은 세지 않는다" 는 고정이 그 조합을 이미 아는
사람에게는 도움이 되지 않는다. 팀 코드와 이름은 화면에 보이기 때문이다. 고치는 대신 여기서
고정한다 — 다음 사람이 이 결정을 낼 때 근거가 눈앞에 있어야 한다.
npm run test:meetings
회의는 이 앱에서 제일 자주 쓰는데 검증이 가장 얇았다. 드라이브·업무·AI·가입·기여·cron 에는 하네스를 세웠고, 회의는 DB 수준 테스트 1개뿐이었다 — 그것도 예약 작업에서 한 번 불러 보는 것이고, 사람이 누르는 여섯 개 액션은 아무도 부르지 않았다.
검사 29건 — 제안하면 제안자가 자동으로 찬성하고 팀에 알림이 간다 · 진행 중인 결정 위에서 다시 제안할 수 없다 · 두 명이 같은 순간에 제안하면 하나만 · 찬성은 중복돼도 한 줄이고 반대는 제안을 철회한다 · 마감 뒤의 반대는 받지 않는다 · 이월은 팀 전체가 알게 한다 · 화면이 숨긴 이월도 서버가 막는다 · 시간표 밖의 시간은 거절된다.
⚠️ 그리고 이 검사는 시각에 의존하면 안 된다 — 처음에는 mondayOf(todayInSeoul()) + day 1 +
hour 3 을 썼는데, 오늘이 화요일이고 그 시간이 이미 지났으면 액션이 "이미 지나간 시간입니다" 로
거절한다. 아침에는 통과하고 오후에 깨지는 검사였다(2026-09-28 에 실제로 그랬다). 그래서 후보
기간의 마지막 날, 시간표의 마지막 칸을 쓴다 — 그 칸은 지금이 몇 시든 지나가지 않는다.
막는 메시지는 둘이고, 어느 것이 나오느냐는 타이밍이다 — 앞 단의 검사가 먼저 온 경우("이미 올라온 회의 제안이 있습니다")와 유일 인덱스가 마지막으로 잡는 경우("누군가 먼저 회의 시간을 제안했습니다"). 둘 다 사람이 읽을 수 있는 말이라는 것만 고정한다. 어느 방어선이 실제로 일했는지는 타이밍이고, 그것을 검사에서 다루면 이 코드가 무엇을 보장하는지가 아니게 된다.
검사가 방어선을 실제로 본다 — 진행 중인 결정 검사를 우회하면 제안이 뒤엎어지고 두 건이 깨진다.
npm run audit:actions
구조 검사는 형식만 본다. 그 형식이 틀렸으면 초록불인데 실제로는 안전하지 않다 — 오늘 기여 의견 잠금이 그랬다(순서는 맞았고, 검사가 재현을 못했다).
더 조용한 문제가 있다. 액션이 있는지 조차 모르는 것. 순수 규칙은 잘 테스트했는데 그것을 부르는 액션 본문은 소스 텍스트로만 본다 — 순수 함수가 초록불이어도 액션이 그것을 제대로 쓰고 있다는 보장이 없다. 오늘 업무 권한 네 곳이 정확히 그 상태였다.
이 도구는 그 상태를 목록으로 만든다. 114개 중 실행됨은 20개이고, 나머지 94개는 형식만(50) 또는 아무도 안 봄(44) 이다. 다음에 무엇을 실제로 돌릴지는 눈으로 훑어 고르지 않고 이 목록으로 정한다.
npm run test:tasks
오늘 업무 권한 네 곳을 닫았다 — 담당자 지정·제목·기한(넣은 사람+팀장) · 상태 변경(넣은 사람+팀장)
· 마감 수정(담당자+팀장) · 복원(올린 사람+팀장). 전부 lib/task-permission.ts 의 순수 함수로
옮겼고, 순수 함수는 실제로 돌려서 확인했다.
그런데 액션 본문은 소스 텍스트를 읽어서만 확인하고 있었다.
const actions = readCode("../src/server/actions/tasks.ts");
check("순수 판정을 부른다", fn.includes("taskEditBlock("), true);
순수 함수가 초록불이어도 액션이 그것을 제대로 쓰고 있다는 보장이 없다. 그것은 오늘 다섯 번 배운 정확한 문장이다 — 드라이브 재전송(잠금 순서가 틀렸고 실제로 버그가 났다) · AI 한도(내가 근거를 잘못 읽었다) · 팀장 선출(검사가 잠금을 보고 있지 않았다) · 첨부 저장(순차로 눌러 재확인을 지워도 통과했다) · 기여 의견(처음엔 경합을 보고 있지 않았다).
순수 규칙과 그것을 부르는 자리는 다른 일이다. 검사 32건은 후자를 본다. 그리고 이 검사가 하나를 잡았다 — 같은 막힘에 화면과 액션이 다른 말을 했다("넣기 전에 있던 업무라 팀장만" vs "만든 사람이 아니어서"). 같은 막힘에 두 개의 다른 이유는 배워지지 않는다.
npm run test:cron
하루에 한 번 도는 주소가 하나 있고, 그 주소가 사용자 화면의 네 문장을 참으로 만듭니다.
| 화면이 말하는 것 | 실제로 해야 참이 되는 것 |
|---|---|
| "그 기록도 90일 뒤 자동 삭제됩니다" | sweepAiUsage() |
| "계측도 같은 기간으로 지운다" | sweepAiCalls() |
| 회의가 마감되면 자동으로 확정됩니다 | confirmDueMeetings() |
| 재입장 시도가 쌓이지 않습니다 | sweepAttempts() |
네 가지 모두 검사가 없었다. 그리고 구조가 있었다 — confirmDueMeetings() 가 맨 앞에 있고,
그것이 던지면 뒤의 것들이 하나도 실행되지 않았다. 그 결과로 "90일 뒤 삭제됩니다" 라는 문장이
영영 거짓이 되고, 아무도 몰랐다.
지금은 하나가 던져도 나머지가 돈다. 회의 확정이든 기록 청소든 서로 무관한 일이므로
Promise.allSettled 로 따로 돌리고, 무엇이 실패했는지 응답으로 말한다(failures). 조용히
0 을 말하지 않는다 — 0 과 실패는 전혀 다른 말이다. 이 흉내를 내어 확인했다(회의 확정을 흉내
내고 청소 하나를 흉내 내면 failures: ["confirmed","attempts"] 가 나온다).
이 검사는 그 주소를 직접 부른다. cron 은 요청 밖에서 부르면 revalidatePath 가 던지는 구조라
직접 부르는 것 자체가 불가능했는데, 하네스의 next/cache 스텁이 그 자리를 통과시킨다 —
경계 안에서 도는 것과 같은 코드가 돌아가고, 무엇을 알렸는지도 기록으로 남는다.
검사가 스스로를 구별하지 못하는 문제를 이 자리에서 또 잡았다. 처음엔 경계를
AI_POLICY.retentionDays 에서 읽었다. 그랬더니 보관 기간을 36500일로 바꿔도 통과했다 —
기대값이 같은 상수에서 나오니 검사도 따라갔다. 화면이 "90일 뒤" 라고 말하는 동안 100년을 말하게
되는 검사는 아무것도 확인하지 않는다. 그래서 화면이 말하는 숫자(90) 를 코드에 적고, 정책과
화면이 같은지 따로 확인한다.
npm run test:contrib
ContribDispute 에 유니크 제약이 없다. 즉 두 사람이 동시에 "다르다"를 달아도 DB 는 막지
못하고, ContribRecord 행의 FOR UPDATE 가 유일한 방어선이다. 그 잠금이 실제로 그 일을
하는지는 아무도 확인하지 않았다 — 소스 텍스트를 읽어서 잠금 순서만 세고 있었다.
검사는 실제로 재현한다. 잠금을 지우고 돌리면 의견이 2개 붙는다(14건 중 3건이 깨진다) — 주석이 서술한 버그 그대로. 오늘 하루에 같은 방식으로 확인한 세 번째 잠금이며, 이번 것은 처음부터 실패했습니다(나머지 둘은 검사가 경합을 보고 있지 않아서 통과하고 있었고 그것을 고치는 데 걸렸다).
assert 가 하나라도 깨지면 그 잠금을 지웠다는 뜻이다.
npm run test:join
팀장 선출은 되돌릴 수 없는 상태를 만든다 — 팀장이 둘. onboarding.ts 의 주석은 그 버그가
실제로 겪혔다고 적고, 고치는 방법도 팀 행 FOR UPDATE 로 서술한다. 검사는 없었다.
검사 35건 — 창작자가 첫 팀장 · 빈 팀에 여럿이 동시에 붙어도 한 명 · 팀장이 있으면 요청만 ·
같은 이름은 name-taken · 승인해도 아직 멤버가 아니다(신청인 브라우저의 폴링에서 만들어진다) ·
두 번 approving 해도 안 되돌림 · 승인하는 순간 동명이인이 있으면 승인이 실패하고 요청이 그대로
남는다 · 남의 팀 요청은 그 팀장이 처리할 수 없다 · 팀장 위임은 새 팀장 한 명.
이 검사가 바로잡은 것 — 경합을 재현하지 못하고 있었다. 팀 잠금을 실제로 빼고 돌려도
통과했다. 그래서 잠금이 없는 쪽을 고쳐 보니 25회 중 25회 팀장이 둘 나왔다(같은 두 문장을
잠금 없이 겹쳐 돌림). 즉 경합은 실재하고, joinTeam 을 통해서는 재현되지 않을 뿐이었다.
재현되지 않는 이유는 가입 abuse 한계다. 팀을 행 키로 쓰고 hitWindow 의
INSERT … ON CONFLICT DO UPDATE 가 행 단위로 직렬화하므로, 요청들이 팀 잠금에 닿기 전에
차례로 처리된다. 안전이 abuse 한계의 부작용에 의존하고 있다 — 한계의 키나 숫자를 손대는 사람이
팀장 선출의 안전까지 흔들게 된다. 그래서 잠금을 지우지 않고, 사실을 코드에 적었다.
npm run test:drive
드라이브의 약속은 계약이다 — 2GB · 같은 이름이면 새 버전 · 복원은 덮어쓰기가 아니라 추가 · 거절된 올리기는 아무것도 남기지 않는다. 그런데 이 계약은 손으로 두 번 확인하고 두 번 버렸다(임시 스크립트). 손으로 확인한 것은 남지 않는다.
이 검사는 실제 저장소(개발 버킷)와 실제 서버 액션을 통과한다. 가짜는 요청 컨텍스트
하나뿐이고(쿠키 항아리·revalidatePath), 앱 코드는 한 줄도 건드리지 않았다 — 그래서
액션이 스스로 막는지를 본다(화면이 버튼을 숨겼다고 안전하지 않으므로).
2GB 를 실제로 올릴 수는 없다. 그래서 큰 버전을 DB 에 심는다 — 팀 사용량은 저장소가 아니라
FileVersion.bytes 의 합이라 심은 만큼이 실제로 한도 직전·직후가 된다. 올리는 대상은 진짜
저장소 객체다(몇 KB).
⚠️ 로컬 DB 와 개발 버킷에서만 돕다. 팀을 만들고 저장소에 올렸다가 지우기 때문에,
운영 DB 나 운영 버킷(submissions) 을 가리키는 채로 한 번 돌면 팀이 남는다. 진입점이 두
가지를 확인하고 멈춘다(scripts/harness/drive.mts).
이 검사가 실제로 버그를 하나 잡았다(2026-09-28). 팀이 꽉 찬 뒤에 화면이 같은 응답을 다시
보내면 over-quota 로 돌아갔고, 거절된 올리기의 정리(storage().remove)가 이미 기록된
객체를 지웠다. 업로드는 성공했는데 파일이 사라지고 버전 행만 남았다. 원인은 잠금 안에서
중복 응답 검사가 용량 검사 뒤에 있었던 순서였고, 순서를 바로잡았다(server/actions/drive.ts).
네 액션이 형식만 남아 있었다(2026-10-07). 소스는 읽지만 한 번도 안 돌린 것들이다 —
announceUploads · markDriveSeen · getMyTeamSubmissionBoxesForSelect · saveResearchToDrive.
다섯 개의 경계가 실제로 잡힌다(각각 무시하면 깨진다).
/)를 지운다 · 제목은 30자에서
자른다.%PDF- 가 올라갔는지 본다 — DB 행만 있으면 "저장했다" 는 말이 거짓이 된다."읽음 표시가 남는다" 가 검사가 아니었다. 행이 있는지만 보면 내가 미리 심어 둔 행 때문에
upsert 를 없애도 통과한다 — 실제로 그렇게 통과했다. 봐야 할 것은 시각이 방금으로
옮겨졌는가 다.
민감도 확인에서 한 번 헛짚었다. replace(…, 1) 이 drive.ts 의 다른 함수에 있는 같은
줄을 먼저 지워서 검사가 안 깨졌고, 그걸 "규칙이 안 잡힌다" 로 오해할 뻔했다. 함수 본문만
떼어 고치자 네 건이 깨졌다.
하네스 자체가 서 있는지 따로 본다 — 세션 없는 호출이 실제로 막히는가:
npm run test:drive:probe
npm run test:drive-save-pdf
드라이브 검사(test:drive)의 "저장소에 진짜 %PDF- 가 올라갔다" 는 머리 다섯 글자만 본다. 그래서 한글이
전부 깨진 PDF(º·· Œ‚ ºfl‚„º)도 초록불이었다. 이 검사는 만든 파일을 poppler
(pdftotext·pdfinfo·pdffonts)로 열어 제목·출처·연도·주소·요약이 그대로 읽히는지, 한 줄이
쪽 폭을 넘지 않는지, 글꼴이 파일 안에 들어 있는지를 본다. 본문 생성 함수
(server/drive/research-pdf.ts)를 직접 부르는 겹과, 서버 액션을 통과해 저장소에 올라간 바이트를
꺼내 읽는 겹이 있다.
drive-save-pdf.mts). 앱의 저장소 어댑터는 그대로 돌고
SUPABASE_URL 만 로컬로 덮어쓰므로 개발 버킷이 없어도 돌고, 원격에는 닿지 않는다.brew install poppler). poppler 가 없으면 건너뛰지 않고 실패한다.server/drive/fonts/)은 Noto Sans KR(OFL)을 한글·한자·기호만 남겨 정적으로 만든 5.3MB 파일이다.
어떻게 만들었는지는 같은 폴더의 README.md.npm run storage:check
스키마가 아니라 버킷을 봅니다 — 환경변수·버킷 존재·비공개·50MB 제한, 그리고 서명 주소 를 실제로 발급받는지까지. 버킷이 보여도 키가 틀리면 마지막에서 걸립니다.
이 검사가 필요한 이유가 있습니다. isStorageConfigured() 는 환경변수의 존재만 봅니다 —
2026-09-28 에 실제로 환경변수는 다 채워져 있는데 버킷을 한 번도 만들지 않은 Supabase
프로젝트였습니다. 그 상태에서 앱은 "연결됐다"고 말하면서 올리기만 조용히 실패했고, 그때
스키마 검사는 아무것도 잡지 못했습니다(저장소를 만질 일이 없으므로 당연합니다).
배포돼 있습니다 — https://chalddeok-omega.vercel.app
저장소가 Vercel 프로젝트(chalddeok)에 연결돼 있어 main 에 푸시하면 자동으로 배포됩니다.
배포는 항상 푸시한 커밋에서 일어납니다 — Vercel 이 github.com/rkdansdlf/ChalDDuck 를
그 커밋으로 클론해서 빌드합니다.
npm run deploy:why # 가장 최근 배포
npm run deploy:why -- <배포 아이디> # 그 배포
● Error 만 보고 고치지 마세요. 빌드가 시작했느냐가 둘을 가릅니다.
| 보이는 것 | 뜻 | 할 일 |
|---|---|---|
| 소요 시간이 없다 | Vercel 이 빌드를 시작하지 못했다. 코드는 실행되지 않았다 | 몇 분 뒤 같은 커밋으로 다시 시도. hobby 는 프로젝트당 동시 빌드가 제한돼 있어 겹친 빌드가 원인인 경우가 많다 |
| 소요 시간이 있다 (30~60초) | 빌드가 돌았고 코드가 실패했다 | npm run verify 를 로컬에서 돌린다 |
2026-09-28 에 두 번째 경우가 여러 번 연속(6회) 왔는데 전부 첫 번째였고, 몇 분 뒤 같은
커밋이 그대로 성공했다. Vercel 이 Resource provisioning failed 로 빌드 머신 배정을 거부한
것이고, 그 오류는 빌드 로그가 한 줄도 없어서 CLI 로는 원인을 볼 수 없다 — 그래서 이
도구가 있다.
npx vercel --prod 로 직접 올릴 수도 있지만 그러면 푸시하지 않은 커밋까지 배포됩니다.
배포본이 main 보다 앞서고, 다음 푸시가 또 다른 배포를 만들어 배포 이력이 두 갈래로벌어집니다. 되도록 푸시로만 올리세요.
vercel.json 의 buildCommand 는 node scripts/vercel-build.mjs 이고, 그 스크립트가 빌드 순서를 정합니다.
운영 은 예전과 같습니다 — prisma migrate deploy → db:check → storage:check:gate → npm run build.
푸시 = 마이그레이션 적용 + 가드 + 빌드 이므로 db:check 를 빠뜨릴 길이 없습니다.
⚠️ 프리뷰(브랜치 푸시)는 마이그레이션도 db:check 도 하지 않고 빌드만 합니다. 예전에는 프리뷰 빌드도
migrate deploy 를 돌렸고, Preview 의 DIRECT_URL 이 운영 DB 를 가리켜서 PR 브랜치를 푸시하는 순간 그
브랜치의 마이그레이션이 머지 전에 운영에 적용됐다(2026-10-10, PR #2 의 마이그레이션 두 개가 같은 시각에
_prisma_migrations 에 남았다). Prisma CLI 는 DATABASE_URL 이 아니라 DIRECT_URL 을 읽는다는 점이 함정이었다.
막는 방향 — 쓰기는 production 일 때만, 모르면 읽어서 확인한다. VERCEL_ENV 가 정확히 production 일 때만
prisma migrate deploy 를 한다. 비어 있거나 모르는 값이거나 대소문자가 다르면(Production) 적용하지 않는다 —
"프리뷰가 아니면 운영" 으로 쓰면 환경 이름이 어긋난 빌드가 조용히 운영 DB 를 바꾼다. 다만 그렇게만 쓰면 변수가
운영 배포에서 비었을 때 마이그레이션 없이 조용히 성공한다(앞의 P2022 사고와 같은 모양). 그래서 모르는
환경에서는 읽기 전용인 db:check 는 돌린다 — 운영이었는데 마이그레이션이 필요했다면 빌드가 로그에 ⚠️ 를 남기며
눈에 보이게 실패하고, 필요 없었다면 그대로 통과한다.
VERCEL_ENV | 마이그레이션(쓰기) | db:check(읽기) |
|---|---|---|
production | 한다 | 한다 |
preview · development | 안 한다 | 안 한다 |
| 없음 · 모르는 값 | 안 한다 | 한다 |
결정은 scripts/deploy-plan.mjs 한 곳에 있고 scripts/smoke-deploy.mts 가 결정표(어떤 값이 와도 쓰기는
production·프리뷰 옵트인뿐이고, 쓰면 반드시 읽어서 확인한다)와 vercel.json 이 맨 migrate deploy 로 되돌아가지
못한다는 것을 고정한다.
프리뷰에서 db:check 도 건너뛰는 이유. 마이그레이션 뒤에야 의미가 있다. 건너뛴 채 돌리면 새 마이그레이션이 든
PR 의 프리뷰가 "차이" 로 막힌다. 같은 검사는 CI 가 한다(빈 DB 에 전부 적용한 뒤 동기화 확인).
프리뷰가 분리된 DB 를 쓰게 되면 Preview 환경에 MIGRATE_ON_PREVIEW=1 을 넣으면 프리뷰도 마이그레이션한다.
운영 DB 를 가리킨 채 이 값을 넣으면 위 사고가 그대로 난다. 분리하는 법은
프리뷰 전용 Supabase.
Preview 의 DIRECT_URL 은 비우지 말고 자리표시 값으로 바꾸는 편이 좋다. npm ci 의 postinstall
(prisma generate)이 이 변수가 있기만 하면 되고 연결하지는 않는다. 없으면 설치에서 죽고, 운영 DB 를 가리키고
있으면 이 보호가 한 겹뿐이다.
⚠️ 마지막은 next build 가 아니라 npm run build 여야 합니다. next build 를 직접 부르면
package.json 의 build 스크립트를 통째로 건너뛰고, 그 안에 있는 플래그가 배포에 닿지
않습니다. 실제로 그랬습니다 — Next 16 은 next build 의 기본값이 Turbopack 인데, 이 앱은
--webpack 으로 돌아가도록 해 둔 상태였고(next/dist/docs/01-app/02-guides/upgrading/version-16.md),
배포는 그 플래그를 못 받아 Turbopack 으로 돌다가 globals.css 후처리에 죽었습니다. 로컬
npm run build 는 통과하는데 배포만 빨간 — 이 문서를 고쳤어야 그 차이를 읽을 수 있었다는
뜻입니다.
새로 세팅한다면 저장소를 Vercel 프로젝트에 연결하고 환경변수 여덟 개를 넣으면 됩니다.
| 변수 | 없으면 |
|---|---|
DATABASE_URL | 앱이 뜨지 않습니다 (런타임 · 트랜잭션 풀러 6543) |
DIRECT_URL | 빌드가 실패합니다 — 마이그레이션이 이걸 씁니다 (직결 5432) |
CRON_SECRET | 회의 마감 예약 작업이 항상 401 입니다 |
SUPABASE_URL + SUPABASE_SECRET_KEY | 파일 업로드만 막히고 앱은 돕니다 |
OPENROUTER_KEY | AI 도구가 샘플만 돌려주고 앱은 돕니다 |
NEXT_PUBLIC_VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY | 푸시가 전혀 나가지 않습니다 — 알림함의 "알림 받기"가 켜지지 않는다고 그대로 말합니다 |
GMAIL_USER + GMAIL_APP_PASSWORD | 이메일 로그인이 되지 않습니다 — 발송 실패라고 그대로 말합니다 (무료·도메인 불필요) |
RESEND_API_KEY + RESEND_FROM | 위와 같습니다. 단 RESEND_FROM 의 도메인은 Resend 에서 인증돼 있어야 합니다 |
APP_URL | 비워도 배포 주소로 자동 채워집니다. 이마저도 없으면 도착한 메일의 매직 링크가 http://localhost:3000 을 가리켜 링크를 눌러도 로그인이 되지 않습니다 |
둘 중 하나만 있으면 됩니다. 둘 다 있으면 Gmail 을 씁니다. 도메인이 생기면 GMAIL_* 를
빼고 RESEND_* 만 남기면 됩니다 — 코드 수정이 아니라 환경변수만 바꾸면 되는 구조로 두었습니다.
위 표와 달리 이 셋은 비워도 앱이 돕습니다. 값을 안 읽으면 코드 안의 기본값을 씁니다 (그래서 배포할 때 넣을 필요가 없습니다).
| 변수 | 비우면 | 기본값 |
|---|---|---|
VAPID_SUBJECT | 푸시가 "알 수 없는 발신자"로 보일 수 있습니다 | mailto:[email protected] (server/notify/push.ts) |
SUBMISSIONS_BUCKET | 버킷 이름이 submissions 가 됩니다 | submissions (server/storage/client.ts) |
SUPABASE_SERVICE_ROLE_KEY | SUPABASE_SECRET_KEY 를 대신 봅니다 | — (별칭) |
VAPID_SUBJECT 는 발신자로 보이는 연락처입니다. 웹푸시는 이 값을 못 읽으면 일부 기기에서
"알 수 없는 발신자"로 보입니다 — 앱 안에서는 아무 일도 안 보이는 차이라, 넣지 않으면 이유를
찾을 자리가 없습니다.
RESEND_API_KEY 는 Vercel 의 Supabase 연동으로 채워지지 않습니다. 연동은 SUPABASE_*
만 채워 넣습니다. 그리고 Resend 는 도메인을 소유해야만 보낼 수 있는데, *.vercel.app 은
Vercel 이 소유한 공유 도메인이라 DNS 를 건드릴 수 없어 인증 자체가 불가능합니다. 그래서
도메인이 없는 동안 Gmail 이 유일한 무료 길입니다.
⚠️ GMAIL_APP_PASSWORD 는 그 주소로 메일을 보낼 권한 그 자체입니다. Vercel 환경변수는
프로젝트에 접근하는 사람이 볼 수 있으니 공용 발신자로 쓸 계정 하나만 정하세요. Gmail 자동발송
약관상 하루 수십 통 수준은 괜찮지만, 어설프게 쓰면 계정이 잠깁니다.
RESEND_API_KEY 와 APP_URL 은 이메일 인증(입장 화면 01의 보조 경로)에만
쓰입니다. 없는 동안에도 나머지 앱은 그대로 돕습니다 — 다만 운영에서 인증번호를 화면에 되돌려
주는 도움은 꺼집니다. 그 값이 새면 메일 발송이 실패해도 인증번호가 도착해 로그인 경로가
우회되기 때문입니다. 로컬 개발(next dev)에서만 6자리가 서버 로그와 화면에 보입니다.
푸시 키 쌍은 npx web-push generate-vapid-keys 로 한 번만 만듭니다. 배포와 로컬에
같은 값을 넣어야 합니다 — 서버와 브라우저의 키가 다르면 구독은 만들어지지만 아무것도
오지 않습니다(화면은 "켜짐"이라고 말합니다). 값을 바꾸면 예전에 구독한 기기들이 전부
구독을 다시 해야 합니다.
⚠️ .env 는 배포본과 같은 Supabase 프로젝트입니다. .env.development.local 없이
npm run dev 를 띄우면 로컬에서 누른 것이 그대로 실제 팀의 데이터가 됩니다 — 위의
"로컬 DB 로 개발하기"를 먼저 해 두세요.
빌드 명령은 vercel.json 에 있습니다 —
prisma migrate deploy && npm run db:check && npm run storage:check:gate && npm run build.
배포할 때마다 마이그레이션이 먼저 돕고, 그다음 DB 가 스키마와 같은지 확인합니다.
Preview 환경의 DATABASE_URL·DIRECT_URL·SUPABASE_URL·SUPABASE_SECRET_KEY 가 운영 프로젝트와
같은 값이면 — 2026-10-10 에 실제로 그랬습니다 — 프리뷰 주소에서 누른 가입·채팅·기록이 실제 팀의 데이터가 되고,
프리뷰에서 올린 파일이 운영 버킷에 쌓입니다. 빌드가 마이그레이션을 건너뛰게 한 것(deploy-plan)은 그중 한 겹일
뿐이라, 프리뷰가 도는 동안의 읽기·쓰기는 막지 못합니다. Vercel 의 Hidden 타입 변수는 만든 뒤에 값을 볼 수 없어서,
지금 어느 쪽을 가리키는지는 대시보드로도 확인되지 않습니다 — 분리한 값으로 새로 넣는 것이 확인하는 것보다 빠릅니다.
Supabase 프로젝트를 하나 더 만들어 아래 네 값만 Preview 환경에서 바꿉니다. Production·Development 는 건드리지 않습니다.
| 변수 | Supabase 에서 가져오는 곳 |
|---|---|
DATABASE_URL | Connect > Transaction pooler (6543) |
DIRECT_URL | Connect > Session pooler (5432) |
SUPABASE_URL | Settings > API > Project URL |
SUPABASE_SECRET_KEY | Settings > API Keys > Secret key (sb_secret_...) |
나머지는 그대로 둬도 됩니다. 다만 두 가지는 알아 두세요.
GMAIL_* 는 프리뷰에서도 실제로 메일을 보냅니다.APP_URL 이 없으면 NEXT_PUBLIC_APP_URL 을 먼저 봅니다(appBaseUrl()). Preview 에 이 값이 운영 주소로 들어
있으면 프리뷰에서 받은 매직 링크가 운영으로 갑니다.순서가 중요합니다. 변수부터 바꾸면 빈 DB 를 보고 프리뷰가 깨집니다. .env 는 운영을 가리키므로 새 프로젝트의 주소를
환경에 직접 넣어 실행하세요.
# 1. 새 프로젝트에 마이그레이션 (DIRECT_URL 을 쓴다)
DIRECT_URL="<새 Session pooler 주소>" npx prisma migrate deploy
# 2. 파일 버킷 — 로컬이 아닌 DB 라 ALLOW_REMOTE_SEED=1 이 있어야 돈다
DIRECT_URL="<새 Session pooler 주소>" ALLOW_REMOTE_SEED=1 npm run db:storage
# 3. Vercel Preview 변수 교체 (변수마다 rm → add, 값은 sensitive 로)
npx vercel env rm DIRECT_URL preview
npx vercel env add DIRECT_URL preview
3 은 위 표의 네 변수에 모두 하고, 마지막에 Preview 에 MIGRATE_ON_PREVIEW=1 을 넣습니다. 그 뒤 아무 PR 의 프리뷰를
다시 배포해 빌드 로그에서 migrate 가 새 DB 에 적용되는지와 storage:check:gate 가 통과하는지 봅니다.
⚠️ 분리한 뒤에도 MIGRATE_ON_PREVIEW=1 은 새 프로젝트를 가리킬 때만 넣습니다. 운영 주소가 한 변수라도 남아 있으면
(특히 DIRECT_URL) 이 값이 위 사고를 그대로 되살립니다.
⚠️ 변수를 빼서 분리하지 마세요. 특히 DIRECT_URL 은 npm ci 의 postinstall(prisma generate)이 있기만 하면
되고 연결하지는 않아서, 없으면 설치에서 죽습니다. 빼는 것이 아니라 다른 프로젝트의 값으로 바꾸는 것이 분리입니다.
프리뷰 DB 는 비어서 시작합니다. 화면을 채우려면 새 DB 에만 ALLOW_REMOTE_SEED=1 npm run db:seed 를 돌립니다.
db:check — 왜 있는가prisma migrate deploy 는 "할 일이 없다"를 실패로 보지 않습니다. 그래서
prisma/schema.prisma 만 고치고 마이그레이션 파일을 빠뜨려도 배포가 초록불입니다.
그렇게 올라간 Prisma 클라이언트는 첫 질의에서 죽고, 죽은 건 서버 컴포넌트라
브라우저에는 Minified React error #441 로만 찍힙니다. 실제로 그렇게 배포가 성공하고
/chat 이 깨진 적이 있습니다 — 서버 컴포넌트가 예외를 내면 RSC 페이로드의 에러 행으로
바뀌고, 그 행을 클라이언트가 되살린 것이 441 입니다. 진짜 원인은 Vercel 함수
로그에만 있습니다.
그래서 마이그레이션을 덮은 직후에 DB 와 스키마를 직접 비교합니다
(prisma migrate diff, scripts/check-schema-in-sync.mjs).
차이가 있으면 빌드가 실패합니다. 로컬에서 미리 확인하려면 npm run db:check.
규칙 하나만 지키면 됩니다: prisma/schema.prisma 를 건드린 커밋에는 반드시
마이그레이션을 함께 넣는다. 그래야 migrate deploy 가 할 일을 찾습니다.
db:migrations — 마이그레이션이 커밋되었는지db:check 는 DB 와 스키마만 비교합니다. "파일이 생겼는데 커밋 안 됐다"는 그 둘 어디에도
없습니다. 그래서 이런 일이 실제로 났습니다.
prisma migrate dev 를 돌렸습니다. 파일이 생겼고 로컬 DB 에도,
실수로 운영 DB 에도 적용됐습니다. 그런데 커밋은 하지 않았습니다.migrate deploy 는 커밋된 것만 보고 "할 것이 없다"고 정상 종료합니다.ReadCushion 의 "on" 컬럼을 지우는 마이그레이션이 저장소에 없어서 실행되지 않아,
운영 DB 에 그 컬럼이 남았습니다.db:check 가 그 차이를 잡아 빌드가 실패했습니다 — 즉 증상은 잡혔고 원인은 남았습니다.
스키마가 더 안 바뀌었다면 조용히 지나갔을 차이입니다.그래서 푸시 전에 이 검사를 둡니다(npm run db:migrations, npm run verify 의 두 번째 단계):
npm run db:migrations
prisma/migrations 의 파일이 커밋된 것(HEAD) 과 같은지 봅니다.
git ls-files 가 아니라 git ls-tree HEAD 인 이유는 git add 만 하고 커밋을 안 한
상태를 잡아야 하기 때문입니다 — 스테이징한 파일은 인덱스에는 있어도 푸시엔 가지 않습니다.
커밋돼 있는데 디스크에서 사라진 마이그레이션과, migration.sql 이 없는 폴더도 함께 봅니다.
로컬에서만 도는 것이 맞습니다. Vercel 은 저장소를 커밋 그대로 클론해서 디스크와 HEAD 가
항상 같고, 잡을 수 있는 시점도 이미 지나간 뒤입니다. 이 사고를 만들 수 있는 곳은 푸시하기
전의 로컬뿐입니다.
버킷은 한 번만 만들면 됩니다(npm run db:storage). 버킷은 Supabase 에만 있습니다 —
로컬 Postgres 에는 storage 스키마가 없어 이 명령이 목적을 이루지 못합니다. 그래서
로컬 DB 를 가리키면 아무것도 건드리지 않고 그 사실을 알려주고 끝냅니다.
예전에는 이 명령이 .env(운영)만 읽어, next dev 는 로컬 DB 를 보는데 운영 DB 의 버킷을
바꾸러 갔습니다. 시드가 가진 가드를 붙여 로컬이 아닌 곳을 가리키면 명시적 허락
(ALLOW_REMOTE_SEED=1) 없이는 실패하게 했습니다. 버킷을 만들 대상은 직접 지목하세요 —
운영에 만들려면 그쪽 주소를 담은 환경에서 실행하고 ALLOW_REMOTE_SEED=1 을 붙입니다.
⚠️ Vercel Hobby 는 예약 작업이 하루 한 번까지입니다. vercel.json 의 schedule 을
그보다 잦게 적으면 배포가 실패합니다. 지금은 하루 한 번(00:00 KST)이고, 화면이
effectiveStage() 로 직접 계산하므로 이 작업이 늦어도 사용자는 맞는 상태를 봅니다.
핸드오프의 화면은 모두 옮겼습니다. 제품이 되려면 다음이 남아 있습니다.
읽기도 쓰기도 전부 데이터베이스를 거칩니다. 브라우저 안에만 남아 있는 상태는 없습니다.
| 옮긴 것 | 위치 |
|---|---|
| 팀 만들기 · 입장 · 세션 | server/actions/onboarding.ts |
| 역할 추첨·수락·거절 | server/actions/roles.ts |
| 회의 제안·응답·확정·이월 | server/actions/meetings.ts |
| 파일 복원(새 버전 추가) | server/actions/drive.ts |
| 기여 기록 추가 · 정정 응답 | server/actions/contrib.ts |
| 할 일 추가·상태 변경 · 콕 찌르기 | server/actions/tasks.ts |
| 내 시간표 저장 | server/actions/schedule.ts |
| 메시지 보내기 · 읽음 표시 | server/actions/chat.ts |
| AI 도구 호출 자리 | server/actions/ai.ts (쿠션·문장 변환은 /api/ai/stream) |
온보딩 중에만 쓰는 features/onboarding/onboarding-state.ts 는 남겨 뒀습니다 —
아직 팀에 저장되기 전의 선택이라 서버로 보낼 것이 없습니다.
마감은 시각이 지나면 저절로 오는 일이라 아무도 앱을 열지 않아도 지나갑니다. 두 겹으로 둡니다.
effectiveStage() 가 "마감이 지났고 반대가 없으면 확정"을
그 자리에서 판단하므로, 예약 작업이 늦게 돌아도 지나간 마감이 "대기 중"으로 보이지 않습니다./api/cron/meetings 를 주기적으로 부르면 마감이 지난 제안을
confirmed 로 바꿉니다(vercel.json 에 하루 한 번 등록 — Vercel Hobby 는 그보다
잦으면 배포가 실패합니다. Pro 이상이면 schedule 만 바꾸면 됩니다). 다른 데 올린다면 어떤
스케줄러로든 이 주소만 부르면 됩니다. CRON_SECRET 이 없으면 항상 401 입니다.
이 작업이 지난 재입장 시도 기록과 보관 기간이 지난 AI 사용 기록도 함께 치웁니다 —
둘 다 하루 한 번이면 충분하고, 늦어도 틀리지는 않습니다.마감이 지난 뒤의 반대는 서버가 거절합니다 — 받아 주면 이미 확정된 회의가 뒤집힙니다. 24시간을 기다리지 않고 마감 뒤 화면을 보려면 09 화면의 "마감 지금으로 당기기 (데모)" 버튼을 쓰세요(개발 환경에서만 보입니다). 확정하지 않고 시계만 당기므로 그 버튼으로 본 화면이 실제 마감 뒤의 화면과 같습니다.
계정 가입은 없습니다. 팀에 들어오는 모든 길의 마지막 문은 팀장이 엽니다. 다만 그 문은 예전처럼 팀 전체가 나눠 가진 코드 한 개가 아니라 초대 한 장입니다.
코드로 바꾼 이유는 그 코드가 되돌릴 수 없었기 때문입니다 — 팀장도 모르게 새 사람이 그
코드로 들어와 팀의 기여 기록과 대화를 볼 수 있었습니다. 초대 한 장을 만들면 그 공유 하나만
골라 되돌릴 수 있고, 쓸 횟수와 기한도 붙입니다. 서버에는 원문 링크가 아니라 해시만 남습니다
(TeamInvite) — 다시 보여 줄 수 없으니 화면은 만들 때 딱 한 번 보여 주고, 다 쓴 뒤엔
"새 초대"를 쓰게 합니다. 예전 재입장 코드와 같은 타협입니다.
코드는 아직도 받습니다. 예전에 만들어진 팀이 TeamInvite 행이 없기 때문입니다
(resolve-target.ts 의 3번 길). 다만 ?t= 초대 링크가 있으면 그 길로 떨어지지 않습니다 —
초대는 명시적인 권한 증명이고, 그 증명이 실패했을 때 조용히 코드 길로 물러서면 증명의 실패가
숨겨집니다.
| 상황 | 흐름 |
|---|---|
| 처음 들어오기 | 이름·MBTI·희망 역할을 고르고 요청 → 팀장이 승인하면 자동으로 들어감 |
| 팀을 만든 첫 사람 | 승인해 줄 팀장이 없으므로 바로 들어가고, 그 사람이 팀장이 됨 |
| 재입장 (같은 이름, 새 기기) | 아래 두 길 |
| 몇 팀에나 속해 있을 때 | 이메일 인증번호로 이 기기가 그 이메일의 소유자임을 증명하고, 그다음 고를 수 있는 팀을 고른다 |
이미 있는 이름으로 새 기기에서 들어오려면 둘 중 하나를 거칩니다.
| 길 | 언제 |
|---|---|
| 재입장 코드 | 첫 입장 때 한 번 보여 준 코드를 적는다. 서버에는 해시만 남는다 |
| 팀장 승인 | 코드가 없을 때. 팀장 화면에 요청이 뜨고 승인하면 저절로 들어간다 |
이메일은 신원을 만들지 않습니다. 인증번호를 통과하면 그 서버가 아는 사실은 "이 기기가
그 이메일의 소유자" 까지입니다. 권한은 그다음 Member 명단에서 나옵니다 — 이 이메일과
일치하는 팀원이 있어야 그 사람이 될 수 있고, 없으면 아무 팀에도 들어가지 못합니다.
증명이 담긴 쿠키는 httpOnly 이고 인증번호 수명(10분)만큼만 살아 있습니다.
발송은 한 경로입니다 — 환경변수에 따라 Gmail 또는 Resend 중 하나가 골라집니다. 발송이 실패하면 성공이라 하지 않습니다 — 형식적으로는 "보냈다"가 되지만 메일은 안 나간 상태라, 사용자는 받은 것처럼 기다리다 10분 뒤에야 만료됩니다. 운영에서는 발송이 실패했을 때 인증번호를 화면에 되돌려 주지도 않습니다(그 값이 새면 발송 경로가 우회됩니다). 로컬 개발에서만 서버 로그와 화면에 6자리가 함께 보입니다.
예전에는 Supabase Auth 와 Resend 두 경로를 병행했는데, 그 조합이 이 로그인을 고장 냈다.
같은 주소로 6자리가 두 개 나갔는데(Supabase 가 만든 것은 여기서 발급한 해시 토큰과
다른 코드였다) 어느 쪽인지 화면에 표시되지 않았고, 성공 판정은 "둘 다 실패했나"로 계산했는데
Supabase 는 메일을 하나도 안 보내도 성공을 보고한다(API 가 200 이었다는 뜻일 뿐이다).
그래서 RESEND_API_KEY 가 비어 있어 실제 메일이 0통이어도 "전송했습니다"가 되었다. 이 앱은
애초에 Supabase Auth 를 쓰지 않으므로(가입·로그인이 없는 제품이고 Supabase 는 파일 저장소
전용 — 2-1절) 발송자와 검증자를 각각 하나씩만 남겼다.
팀장은 팀을 만든 사람입니다(Member.isLeader). 만든 브라우저에 표시를 남겼다가
그 브라우저가 온보딩을 마칠 때 세웁니다.
같이 막은 것:
upsert 를 없앴습니다. 예전에는 남의 이름을 적으면 기록을 이어받았고,
그 과정에서 상대의 희망 역할·Veto 까지 덮어썼습니다 — 역할 추첨의 입력이 바뀌는 일입니다./join?code= 를 훑어
실제 팀을 찾아낼 수 있었습니다. 6자리면 32⁶ ≈ 10억입니다./team/access 에서 내 기기를 보고 내보낼 수 있고, 재입장 코드를 새로 받을 수 있습니다.
팀장은 그냥 나갈 수 없습니다. 팀장이 사라지면 새 기기 재입장을 승인해 줄 사람이 없어 팀원들이 기기를 바꾸는 순간 잠깁니다. 그래서 나갈 때 둘 중 하나를 골라야 합니다.
| 길 | 결과 |
|---|---|
| 팀장 넘기고 나가기 | 팀원 한 명을 골라 넘기고 나간다. 프로젝트는 그대로 |
| 프로젝트 없애기 | 팀이 통째로 사라진다. 되돌릴 수 없어 팀 이름을 직접 적어야 한다 |
넘기기만 하고 팀에 남을 수도 있습니다.
나가도 Member 행은 지우지 않고 leftAt 만 찍습니다. 기여 기록·메시지·파일 이력이
성적 근거라, 사람이 나갔다고 팀 기록에 구멍이 나면 안 됩니다. 명단과 집계에서만 빠지고,
같은 이름으로 다시 들어오면(재입장 문을 거쳐) 명단에 되돌아옵니다.
추방은 두지 않습니다. 팀에 있는 사람은 어차피 초대를 받아(링크든 코드든) 들어온 사람이고, 잘못 들어온 경우는 팀장이 재입장을 승인하지 않는 것으로 이미 막힙니다.
재입장 코드를 틀린 횟수는 RejoinAttempt 표에 셉니다(10분 창 안에 5회). 예전에는
프로세스 메모리의 Map 이었는데, 서버리스는 요청마다 다른 인스턴스가 받을 수 있어
인스턴스 수만큼 시도가 늘어났습니다 — 5회 제한이 사실상 없는 것과 같았습니다.
세는 키도 초대 코드 + 이름을 정규화해 해시한 값입니다(예전에는 사용자가 적은 문자열
그대로라 대소문자만 바꿔도 다른 칸으로 셌습니다).
도구 5종이 server/ai/tools.ts 에서 OpenRouter 를 통해 지정된
모델을 부릅니다. 프롬프트가 곧 제품 약속이라 그 파일에 약속을 함께 적어 뒀습니다 — 쿠션 번역기가 마감을
늦춰 적거나 AI 서기가 정해지지 않은 담당자를 채우면 그건 버그입니다.
리서처만 웹 검색을 붙였습니다. "출처가 없는 결과는 보여주지 않는다"가 약속인데 검색 없이 물으면 있을 법한 논문 제목과 학회지 이름을 지어냅니다. 그래서 검색 결과에서 실제로 인용된 주소를 가진 카드만 남깁니다 — 프롬프트가 아니라 코드가 거릅니다.
모델은 도구마다 다르게 고릅니다 — OPENROUTER_MODEL_<도구>(예: OPENROUTER_MODEL_CLERK).
없으면 OPENROUTER_MODEL, 그것도 없으면 openrouter/free(요청마다 다른 무료 모델)입니다.
고르는 자리는 server/ai/model.ts 의 modelFor 한 곳이고,
모델 이름을 코드에 적지 않습니다 — 무료 카탈로그는 OpenRouter 가 돌려주고 며칠 지나면
바뀝니다(실측: 무료 모델 16개 전부 세대가 달랐고, 예전에 여기 적혀 있던 유료 슬러그는 목록에서
사라져 있었습니다).
왜 도구마다 달라야 하는가 — 추측이 아니라 실측입니다. npm run tool:bench /
npm run cushion:bench 로 같은 코퍼스를 나란히 돌린 결과(2026-09-30):
| 도구 | inclusionai/ling-3.0-flash-sante:free | openrouter/free |
|---|---|---|
| AI 서기 | 5/6 · 2.6초 | 3/6 · 22초 |
| 발표 지원 | 2/3 · 7초 | 2/3 · 10초 |
| AI 리서처 | 3/3 · 15초(출처 5개가 실제로 붙음) | 0/3 전부 타임아웃 |
| 상황별 문장 | 3/3 · 2.5초 | 1/3 (없는 사실 "완료" 를 지어냄) |
| 쿠션 번역기 | 2/3 · 2.9초 | 2/3 (없는 약속 "천천히" 를 지어냄) |
| 읽기 순화 | ❌ 거절 16/26 | ✅ 거절 1/26 |
읽기 순화만 예외입니다. 구조화 출력을 잘하는 모델이 심한 욕설을 순화하지 못하고 거절합니다
— 순화가 가장 필요한 말에서 가장 자주 막히는 셈이라, 순화만 무료 라우터를 씁니다.
하나의 모델로 전부 잘 하는 것은 없습니다. 그래서 modelFor 가 있습니다.
⚠️ 이 슬러그들은 썩습니다. 사라지면 404·429 가 납니다. 지금 쓸 수 있는 무료 모델과 함수 호출 지원 여부는
npm run tool:bench -- --list-models로 확인하고, 위 표의 숫자를 다시 재려면npm run tool:bench -- --tool clerk --models <슬러그> --strict를 돌리세요.--list-models의 표는 OpenRouter 의 선언이고 사실이 아닙니다 — 실측에서 그 16개 중tool_choice를 실제로 지킨 것은 3개뿐이었습니다.
실패하면 한 번만 다시 부릅니다 — 다른 모델로(OPENROUTER_FALLBACK_MODEL, 없으면 무료
라우터). 일시적인 실패(타임아웃·연결 끊김·5xx·빈 응답)에만 합니다. 429 는 다시 시도하지
않습니다 — 몇 밀리초 뒤에 눌러도 똑같이 막히므로, 사용자에게 기다리라고 말하는 편이
정직합니다. 재시도는 모델 층에서 한 번에 끝납니다(runTool 이 아니라) — 그래야 한도도 한 번이고
"답을 받았거나 못 받았다" 는 둘뿐이라 위의 환불 규칙이 그대로 맞습니다.
AbortSignal 로 끊습니다(OPENROUTER_TIMEOUT_MS, 기본 60초).
SDK 의 timeout 만으로는 899초가 끊기지 않았습니다. 시간 초과는 일시적인 실패로 봅니다 —
그 모델이 느렸다는 뜻이지 요청이 잘못됐다는 뜻이 아니므로, 다른 모델을 한 번 더 시도합니다.tools.ts 가 모델 출력을 그대로 믿지 않습니다. 그 규칙은
lib/ai-draft-shape.ts 로 분리했습니다 — "담당자 칸이 빈
문자열로 왔다" 같은 걸러 내기는 계약이고, 계약은 스모크가 모델을 부르지 않고 확인해야
합니다. 거기 두면 확인하려면 AI 를 불러야 하고, 그러면 안 지켜졌을 때 아무도 모릅니다.User Safety: safe 를 돌려줬고 그 라벨이 그대로 "다듬은 말" 로 보였습니다 —
사용자는 번역 대신 분류 결과를 받았고, 기록에는 성공으로 남았습니다. 이제 라벨이면
실패로 돌려 폴백을 돌립니다(UnusableOutputError). isRefusal 을 그대로 쓰지 않습니다 —
그것은 Sorry, 로 시작하는 정상적인 사과 번역도 거절로 보기 때문입니다
(lib/read-cushion.ts 의 isSafetyLabel).AiCall 에는 실제로 응답한 모델이 적힙니다. openrouter/free 같은 라우터는 요청한
슬러그와 다른 모델이 답하므로, 응답이 알려 준 모델을 기록해야 "이 기능이 나쁘다"와
"라우터가 나쁜 모델을 골랐다"를 가를 수 있습니다.품질이 필요하면 OPENROUTER_MODEL_CLERK="anthropic/claude-sonnet-5" 처럼 유료 슬러그로 바꾸세요.
유료를 강제하지는 않습니다 — 지출은 사람이 정하는 값이고, 무엇을 썼는지는 계측이 남깁니다.
하루 한도는 없습니다 (2026-09-28). 있었습니다 — 팀 200회 · 한 사람 60회, 읽기 순화는 따로 400 · 120. 그리고 14일간 한 번도 걸리지 않았습니다. 운영 기록의 최고 하루가 도구 18회 (한도의 9%), 순화 1회였고, 거절된 날도 없었습니다. 무료 라우터가 속도 한도로 막은 적도 없었습니다(남은 실패는 안전 필터 거절 2건과 60초 타임아웃 1건 — 한도와 무관).
근거 없는 숫자를 한도라고 부르지 않기로 했습니다. 14일간 아무 일도 하지 않으면서 사용자에게 "하루 200회" 로 보이던 것은 한도가 아니라 거짓말이었습니다. 숫자 네 개를 지웠습니다.
남긴 것은 두 가지입니다 — 보관 기간 90일과 읽기 순화 폭주 차단(하루 5,000, 사람 눈에 보이지 않음). 도구는 사람이 누릅니다. 읽기 순화만 메시지마다 자동으로 도므로 여기만 무제한이 가능한 형태의 사고이고, 관측치의 5,000배인 숫자는 한도가 아니라 회로 차단기입니다.
AiUsage) — 입력한 글도 결과도 저장하지 않습니다.
그 기록도 AI_POLICY.retentionDays(90일)가 지나면 예약 작업이 지웁니다.
이 기록은 지우지 않았습니다 — 오늘 "몇 번이나 쓰는가" 를 5분 만에 답한 것이 이 표였고,
기록이 없으면 다음번에 같은 질문에 답할 수 없습니다.throw 가 아니라 돌려준 답)은 해당하지 않으므로, 안전 필터 거절이 공짜로 반복되는
길은 열리지 않습니다.AiUsage 의 행 id 를 되돌려 받아서). 같은 날 같은 사람이 쓴 다른
도구의 기록을 함께 지우는 사고를 막기 위함입니다.AiResult). 운영 빌드의 Next 는 서버에서
던진 오류 문구를 지우고 digest 만 보내서, 던지면 "한도를 다 썼습니다"가 사용자에게
닿지 않습니다.?review=1 에서 허브의 [검토] 박스).두 개의 표가 있습니다 — 다릅니다.
| 표 | 무슨 표인지 | 실패하면 |
|---|---|---|
AiUsage | 몇 번 썼나(기록, 한도 아님) | 행이 사라집니다(환불) |
AiCall | 어떻게 됐나(도구·모델·지연·결과) | 남습니다 |
한 표에 둘을 넣을 수 없어서 갈랐습니다. 환불 때문에 실패의 흔적이 지워지면 "이 도구가 왜
안 되나" 를 나중에 알 수 없기 때문입니다. AiCall 에도 글은 남기지 않습니다 — 도구 ·
실제로 응답한 모델 · 지연 · ok/refused/failed · 폴백 여부만 남깁니다
(server/ai/call-stats.ts).
refused(거절)는 failed(실패)와 다른 값입니다. 거절은 답이 온 것입니다(모델이
"못 하겠다"고 말했습니다). 한 값으로 합치면 "모델을 바꿔야 한다" 와 "다시 시도하면 된다" 가
같은 0 으로 보입니다. 거부는 다시 눌러도 같습니다(실측 16/26).calls 는 2 가 되고 한도는 1 입니다. 비용과 지연은 2회분이라서 그래야 하고,
retried 로 "폴백이 몇 번 먹혔나" 를 봅니다. 모델 층만 이 사실을 아는데, 호출 층이 판단하면
재시도 경로 안에서 일어난 일이 통째로 사라집니다.쿠션 번역기 · 문장 변환은 조각으로 옵니다 — "다듬는 중…" 만 4초간 도는 대신 글자가 조금씩
나옵니다. 서버 액션이 아니라 Route Handler(app/api/ai/stream)를
씁니다 — 서버 액션으로 ReadableStream 을 돌려주는 방법은 Next 문서에 없습니다
(node_modules/next/dist/docs/ 확인). Route Handler 의 스트리밍은 문서에 있고 버전 사이에
사라지지 않습니다.
runTool 을 지납니다. 한도·환불·출처 판정을 경로마다 따로 만들면 그 길에서만
조용히 사라집니다(사용자는 "스트리밍이 안 돌아오네" 하고 그냥 기다립니다). 스트림의
controller 를 runTool 밖에서 잡아 두고 call 안에서 조각을 밀어 넣는 이유는 그대가
유일하게 "답을 받았거나 못 받았다" 를 볼 수 있는 자리이기 때문입니다. 스트리밍은
표현의 문제지 계약의 문제가 아닙니다.ai 인지 sample 인지를
아는 곳은 runTool 하나뿐입니다. 여기서 isAiConfigured() 를 다시 부르면 그 판단이 두 곳에
있고, 그랬을 때 바로 "예시가 AI 초안 배지 아래에 놓였다" 는 사고가 났습니다. 배지가 한 박자
늦게 오는 것이, 글은 AI 냄새가 나는데 배지는 예시라고 나오는 것보다 낫습니다.lib/ai-stream-lines.ts에
두고 스모크가 확인합니다. 한국어는 한 글자가 3바이트라, 바이트 경계에서 자르면 화면에 � 가
납니다 — 사용자는 "모델이 이상한 글자를 냈다" 하고 오해합니다. 이 검사는 실제로 그 버그를
잡아냈습니다(디코더를 매번 새로 만드는 구현이 통과하지 않는지까지 함께 봅니다).사용 내역은 CSV 로 내려받습니다 — exportAiUsage() 가 팀 전체 기록을 만들고, 화면이
Blob 으로 받아 파일로 저장합니다. 한도와 같은 기준(팀 단위, 한국 날짜)으로 세므로
내려받은 숫자가 화면의 숫자와 어긋나지 않습니다.
홈 맨 위에 "오늘의 브리핑" 카드가 있고, AI 도구 칩은 규칙으로 고른 추천으로 바뀝니다.
둘 다 features/home/briefing.ts 에 있고, 모델을 부르지 않습니다.
브리핑이 세는 것은 다섯 개뿐이고, 나머지는 데이터가 없어서 세지 않습니다.
| 세는 것 | 안 세는 것 | 이유 |
|---|---|---|
| ✅ 내 확인 대기 N건 | ❌ 회의가 끝났는데 메모 없음 | 회의록을 저장하는 곳이 없다 — 스키마에 모델이 없다 |
| ✅ 오늘 회의 | ❌ 발표 임박 | 발표일 필드가 없다. Team.dday 는 "중간발표 D-12" 같은 문자열이다 |
| ✅ 마감을 아직 안 정한 일 N건 | ||
| ✅ 마감이 임박한 일 N건 | ||
| ✅ 남에게 알려 줄 일 N건 |
지워버린 게 아니라 세지 않는다는 사실을 코드에 적어 뒀습니다. 다음 사람이 "브리핑에 발표 임박도 넣자" 고 하면 그 이유가 바로 나옵니다.
⚠️ "마감이 임박한 일" 은 Task.dueAt(2-0 절)이 생겨서 새로 늘어난 줄입니다. 전에는
Task.due 가 자유 텍스트("9/19", "다음 주")라 비교할 시각이 없어서 "아직 안 정했다" 로
대체했습니다. 둘은 다른 두 가지입니다 — 하나는 사람이 비워 둔 것이고 다른 하나는 정했다가
다가온 것입니다. 한 줄로 합치면 그 줄은 아무것도 말하지 않게 되므로 둘 다 셉니다.
⚠️ 세는 것이 드뭅니다. 기존 행은 백필하지 않았고 앞으로 사람이 연도가 있는 값을 적어야 채워집니다. 그래도 괜찮습니다 — 있는 숫자만 말하는 것이 이 카드의 전부이기 때문입니다.
⚠️ 표시 문자열을 여기서 다시 해석하지 않습니다. due 를 브리핑에서 파싱하면 lib/due.ts 와
두 개의 기준이 생기고 어느 쪽이 맞는지 알 수 없게 됩니다. 해석은 한 곳에서만 합니다.
마감 미정과 마감 임박은 따로 셉니다. dueOf 가 빈 값을 "미정" 으로 저장하므로 그
문자열은 사람이 비워 뒀다 는 뜻입니다. "다음 주" 같은 자유 텍스트는 여전히 파싱하지
않습니다 — 틀릴 확률이 높고, 틀린 숫자를 보여 주는 브리핑은 없는 것보다 나쁩니다.
정규값(dueAt)이 있을 때만 "임박" 을 세고, 없으면 세지 않습니다. 세는 것과 추측하는
것을 구분합니다.
"마감이 없다" 고 말하지 않고 "아직 안 정했다" 고 말합니다. 팀이 못 정한 것과 정하지 않기로 한 것은 다릅니다.
MeetingProposal.date 가 null 이면 오늘 회의가 아닙니다. 모르는 것과 아닌 것을
같은 값으로 보면 "오늘 회의 없음" 이라는 거짓말이 됩니다.
"오늘" 은 서버가 정합니다. 화면이 자기 시계로 맞추면 기기 설정이 다른 사람과 같은 홈이 다른 내용을 보게 됩니다.
0 인 줄은 그리지 않습니다. 할 일이 없는 팀에 빈 상자를 띄우면 그게 소음이고, 사용자는 "앱이 뭔가 말 안 하네" 로 오해합니다. 빈 상태는 아래 "내 확인이 필요한 일" 의 패널이 이미 하고 있습니다 — 두 곳에 "괜찮아요" 를 쓰지 않습니다.
추천 규칙은 세 개뿐이고, 전부 규칙입니다.
deck·script) 마감이 3일 이내면 발표 지원research) 마감이 3일 이내면 AI 리서처Task.due 는 자유 텍스트다. 실제로 "9/19", "9월쯤", "다음 주", "미정",
"다음 발표까지" 가 섞여 있어서 "마감이 가까운 업무가 몇 건이냐" 를 셈으로 답할 수 없었다.
그래서 브리핑은 그 자리를 "아직 안 정한 일" 로 대체했다 — 있는 숫자만 말하는 편이 나았다.
SubmissionBox 가 이미 dueAt + due 를 함께 들고 있어서 같은 모양을 tasks 에 더했다
(lib/due.ts · 마이그레이션 20261005090000_task_due_at). 그리고 브리핑이
"아직 안 정했다" 로 대신하던 자리를 "마감 임박" 이라는 자기 말로 되돌렸다(2-1 절) —
두 값이 이제 나뉘었으므로요.
due, 비교는 dueAt. 사람이 쓴 글은 그대로 보여 주고, 기계는 시각만 본다.null 이고 그건 정상이다. 2026-09-19 처럼 연도가 있는 값만
확실하다. 9/19 는 아직 지나가지 않은 해 로 읽되, 어느 해가 의도인지 모르면 null.null 은 "모르겠다" 를 정직하게 말하는 값이다.new Date("2026-02-30") 는 조용히 3월 2일이 되고,
2026-13-45 는 Invalid Date 다. 둘 다 파싱에 넣으면 틀린 마감이 된다.SubmissionBox 와 같습니다.이제 브리핑이 "마감 임박" 을 셀 수 있습니다 — 정규값이 있는 일만. ⚠️ 세는 것이 드뭅니다. 기존 행은 백필하지 않았고 앞으로 사람이 연도가 있는 값을 적어야 채워집니다. 드문 줄이어도 괜찮습니다 — 있는 숫자만 말하는 것이 이 카드의 전부이기 때문입니다.
AI 서기가 우리 팀을 보고 할 일 후보를 뽑습니다. DB 객체를 통째로 받는 것이 아니라
허용된 필드만 골라 낸 문맥을 넘깁니다
(lib/team-tool-context.ts · lib/tool-assignee.ts).
담당자 매칭은 실패를 세 갈래로 구분합니다 — "안 됨" 하나로는 사람이 같은 실수를 되풀이합니다.
| 결과 | 뜻 |
|---|---|
matched | 팀원 |
unset | 모델도 정하지 않음 — 사람이 정하면 됨 |
no-match | 명단에 없는 이름을 지웠습니다 — 화면이 이유를 말합니다 |
ambiguous | 동명이인·같은 이름이 둘 — 아무나 고르면 아무도 책임질 사람이 없습니다 |
한국어 이름의 두 형태를 다룹니다. 명단은 김민준 인데 사람들은 민준 이라 말합니다.
완전 일치만 요구하면 정상 매칭의 대부분이 실패하므로 성을 뗀 이름을 허용하되, 겹치면
null 입니다. 그리고 조사까지 떼어 냅니다 — 실측에서 모델이 서연이 로 적었고
(한 글자 차이라 이서연 과 매칭되지 않습니다), 민준이가 처럼 두 번 떼어야 하는 경우도
있어서 반복 제거가 필요했습니다. 유사도 매칭은 하지 않는다 — 김민준경·민준씨 는 그대로
실패한다. 매칭률을 올리면 틀린 배정이 같은 만큼 늘고, 팀플에서 담당자를 잘못 채우는 일은
그 일을 아무도 안 하게 만드는 일이다.
비교 측정(N=4, 각 28회 호출, ling-3.0-flash-sante:free)
| 통과 | |
|---|---|
| 명단 있음 | 28/28 |
| 명단 없음 | 14/28 |
명단이 없으면 어떤 이름도 명단 형태로 나오지 않습니다 — 모델이 민준·유나 라고 적고
매칭은 김민준·최유나 를 찾습니다. 그래서 업무로 반영할 때 담당자가 거의 안 붙었습니다.
⚠️ 단 한 번 돌린 결과는 측정이 아닙니다. 무료 모델은 비결정적이라, 같은 설정·같은 코퍼스에서
unnamed-1 이 한 번은 최유나(실패)였고 한 번은 null(통과)였습니다. 그래서 벤치에
--repeat 를 넣었습니다. "명단을 주니 담당자를 지어내는 모델" 이 있다는 우려는 실제로
재현했고, 프롬프트에 "맡겠다는 말 없이 이름이 들어간 후보는 전부 버려라" 를 넣은 뒤 28/28 이
되었습니다 — 다만 강화 전 표본이 2회뿐이라 인과는 확정하지 않았습니다.
결정: 승인은 한 번 유지하되 담당자 칩의 출처를 밝힌다. 배정 알림은 보내지 않는다.
addTasksFromClerk 는 createMany 라 지금 알림이 없는데, 그게 안전합니다.no-match·ambiguous 안내가 이미 이유를 말합니다.addTasksFromClerk)
이 같은 함수를 씁니다. 예전에는 업무 반영 쪽이 roster.find(m => m.name === name) 로
직접 정확 일치 했고, 그래서 AI 서기가 민준 라고 적으면(명단은 김민준) 항상 담당자 없는
업무가 생겼습니다. 두 규칙이 갈라지면 "화면에서는 최유나 로 보이는데 업무에는 아무도 없다" 가
조용히 벌어집니다.null 입니다. 없는 사람에게 일을 배정하지 않습니다.poke(독촉) → 쿠션 번역기 — poke 화면에 "말투만 다듬고 보내기" 가 생깁니다
(features/tasks/poke-request.ts). 별도 말은
"진행상황 물어보기"와 요구하는 내용이 같습니다 — 달라지는 건 말투뿐입니다.
미정 이면 "언제까지" 를 지어내지
않습니다. 지어내면 독촉이 아니라 압박이고 그건 우리가 짊을 책임입니다.null 이고 버튼도 그리지 않습니다. 빈 문자열을 넘기면 쿠션 화면이 빈
칸으로 열립니다.Task.due 정규화는 별도 트랙이고, 여기서 시각으로
바꾸면 두 값이 어긋납니다("다음 주" 를 오늘로 읽으면 틀린 독촉이 됩니다).리서처 검색어 초안 — 팀 이름과 역할에서 후보를 뽑아 칩으로 보여 줍니다
(features/tools/research-query.ts).
발표 화면에서 팀 드라이브의 PPTX·DOCX 에서 글자를 꺼내 대본 칸으로 가져옵니다
(server/office-text.ts · 발표 화면의 "드라이브에서 가져오기").
PPTX 는 발표 순서대로 본문과 발표자 노트를, DOCX 는 문단을 가져옵니다.
라이브러리를 사지 않고 직접 읽습니다. PPTX·DOCX 는 평범한 deflate ZIP + XML 이고 필요한
계약은 "글자" 하나뿐이라, due 파싱을 직접 쓴 것과 같은 판단입니다 — 좁은 계약을 위해
의존성을 늘리면 그 의존성이 틀렸을 때 우리 계약을 우리가 못 고칩니다.
ppt/presentation.xml 이 정합니다. 이름순으로
읽으면 발표 순서가 뒤집힌 대본이 나오고, 그 대본을 다듬으면 PPT 와 순서가 다른 발표가
됩니다. 검사가 order: [2, 1] 로 이 어긋남을 실제로 만든 뒤 확인합니다.a:fld) 는 뺍니다. 그대로 넣으면 발표자 노트에
숫자가 섞이고, AI 가 그것을 대본의 일부로 다듬습니다.extractDriveDocument(fileId, versionId)). 팀 안의 아무 버전이나
받으면 "A 파일" 이라는 이름 아래 B 파일의 글자가 대본이 되고, 화면에는 출처가 A 로 적혀
아무도 그 어긋남을 눈으로 잡을 수 없습니다.MAX_BYTES(50MB) 규칙을 우회한 불가능한 상태라, 50MB 이하 파일 여러 개로 나눠 심습니다.npm run test(검사용 컨테이너를 직접 만들어 실제로 풉니다), 저장소까지
도는 경로는 npm run test:drive.파일은 Supabase Storage 의 비공개 버킷(submissions)에 들어갑니다. 버킷은
npm run db:storage 로 한 번 만듭니다. 계약(2GB·버전·복원·멱등)은
npm run test:drive 가 지킨다.
이 앱은 Supabase Auth 를 쓰지 않습니다. 가입·로그인이 없는 제품이라 판단할 사용자가 없고, Supabase 는 여기서 파일 저장소 전용입니다. 저장소 RLS 가 판단할 사용자를 만들지 않으므로 버킷을 비공개로 두고 접근 권한은 앱이 서버에서 직접 확인합니다.
그래서 접근 권한은 앱이 서버에서 직접 확인하고, 내려받기는 60초짜리 서명된 주소로만
내줍니다. 비밀 키(SUPABASE_SECRET_KEY)는 server/storage/ 안에서만 쓰이고 브라우저로
나가지 않습니다 — 예전 이름인 SUPABASE_SERVICE_ROLE_KEY 도 받습니다.
제출함 → 파일 목록 → 버전 기록으로 한 단계가 늘었습니다. 핸드오프는 제출함에 파일이 하나뿐이라고 보고 곧장 버전 기록으로 들어갔는데, 실제로 올릴 수 있게 되면 한 제출함에 발표자료와 대본이 같이 들어갑니다.
쿠션 번역기(15)는 보내는 쪽의 기능입니다 — 하고 싶은 말의 말투를 바꿔 보냅니다.
그게 전부였고, 받는 쪽에는 AI 가 손댈 게 없었습니다. 순화는 MessagePurification 에
읽기 설정별로 저장되고, 19 단톡방과 31 DM 에 도착한 남의 말은 다듬어져 보입니다.
원문(Message.text)은 고치지 않습니다 — 순화는 보기에만 걸린다는 약속입니다.
곧장 말하겠습니다: 순화를 켜면 받는 쪽의 말이 OpenRouter 로 나갑니다. 원문 그대로입니다. 사용자가 직접 입력한 것이니 "몰래 보내는 것" 은 아니지만, 보내지 않는다고 생각한 사람이 있을 수 있습니다. 그래서 경계를 여기에 적습니다.
packPurifyItems 는 그 말을 AI 입력에 넣지 않습니다.Message.text 는 안 바뀝니다.앞말을 주지 않으면 순화가 "이게 뭐야" 를 모른 채 돌아갑니다. 한국어 욕설은 앞뒤로 읽어야 잡히는 것이 많습니다. 그래서 앞 2개를 줍니다.
대가는 값이 하나 더 나간다는 것입니다. 그래서 정확히 2개로 고정합니다. "이 방의 최근 N 말" 을 주면 같은 말을 읽는 사람마다 결과가 달라져 순화본 공유가 깨지기 때문입니다. 같은 말은 어디서 읽든 같은 입력을 갖고, 그래서 순화는 결정함수이고 캐시가 맞습니다.
꺼두는 방법은 한 곳이다 — ReadCushion.enabled = false. 그러면 후보를 아예 만들지 않으므로
AI 호출이 0회입니다. 끄고 읽는 동안 쌓인 순화본은 화면에 나오지 않습니다(지문이 달라
조회가 되지 않습니다).
남아 있는 한계: 사용자는 "이 말이 어디로 가나" 를 앱 안에서 볼 수 없습니다. AI 사용
내역(/api/ai/usage)에는 몇 회 불렀나 만 있고 무엇을 보냈나 는 없습니다. 그 사실을
화면에 넣는 것은 다음 일입니다.
메시지 도착
└─ 대상 판정 (남의 말 · 글 있음 · 아직 상태 없음)
└─ 선점(PENDING + claimToken) ← 두 탭 경합 방지
└─ 묶음(최대 10개) AI 1회 ← 한도 1회
├─ 항목별 검사 통과 → PURIFIED (라벨: 순화됨)
└─ 거절·검사 실패 → 규칙 가림 → FALLBACK (라벨: 공격적 표현 가림)
└─ 가릴 것도 없으면 REJECTED / FAILED → **원문**
└─ 말풍선: 누르면 원문으로 돌아간다
lib/read-cushion.ts 가 이 계약 전체를 정하고 화면과 서버가 같은 함수를 씁니다.
openrouter/free 로 다툰 말을 순화시키며 측정한 결과:
| 계층 | 결과 |
|---|---|
| 협조적 | 3/3 성공 |
| 짜증만 · 비꼼 · 탓 | 2~3/3 (실패 시 빈 응답) |
| 욕설("씨발·좀비") | 2/3, 욕을 남긴 채 통과 |
| 심한 욕설 | 0/3 — User Safety: unsafe (Profanity, Harassment) 명시적 거절 |
거절하지 않는 무료 모델을 직접 골라도(nemotron, laguna) 2/2 응답은 했지만 욕은
남았습니다 — "좀비처럼 달려가지 말고" → "좀비처럼 너무 빠르게 달려가지 말고". 프롬프트가
아니라 모델 능력의 한계이고, 이 표를 알아야 유료 모델 도입을 판단할 수 있다.
거절당하면 원문이 그대로 보이는 구조는 이 기능이 가장 필요할 때 꺼지는 것이라 그대로 둘 수 없었다. 그래서 두 갈래를 붙였다.
FALLBACK) — AI 없이 위험 표현 그 자리만 가린다. 100% 동작하고 비용이
없다. 단어를 지우면 문장이 깨진다(실측: "다 니 탓인데" → "인데") 그래서 지우지 않고
대체한다. 그리고 AI 결과인 투를 하지 않는다 — 라벨이 다르다(공격적 표현 가림).ReadCushion 은 사람 × 방마다 한 줄이고, 세 가지를 갖는다.
| 칸 | 뜻 |
|---|---|
enabled | 끄면 AI 호출 후보를 아예 만들지 않는다. 원문으로만 읽는다. |
mode | LIGHT(욕설만) · NORMAL(비꼼· 탓까지) · STRONG(책임 추굴까지) |
tone | 같은 단계 안에서의 말투(15 쿠션 번역기와 같은 세 값) |
끄기와 단계는 한 칸이 아니라 둘이다. 한 칸(mode: "OFF")으로 합치면 끄기 전에 고른 단계를
잃는다 — 다시 켰을 때 어디까지 세게 읽을지 몰라 처음부터 골라야 한다. 대화를 잠깐 원문으로
읽다가 내 자리로 돌아오는 길이 있어야 한다.
단계는 세 곳을 바꾼다 — 모델에게 줄 지시, 결과를 버리는 기준, 규칙 가림의 범위. 이 셋은
lib/read-cushion.ts 의 LEVEL_PROFILES 한 표에서 나온다. 하나만 바꾸면 "약하게 순화하라
고 했는데 검사는 엄격하게 한다" 같은 어긋남이 생기고 어느 쪽이 문제인지 알 수 없다.
단계가 바뀌면 읽는 지문이 달라진다 — "강하게" 를 골랐는데 "보통" 으로 만든 말을 보여주면
아무도 그 차이를 알 수 없다. 그래서 조회는 지금 지문으로만 일어나고, 새 지문에 아직
순화본이 없는 말은 PENDING 으로 보인다(다음 부름에서 그 지문으로 만들어진다).
예전에는 지우기까지 했다. 지금은 지우지 않는다 — 순화본이 설정별로 저장되므로 옛 결과는 옛 설정으로 읽는 사람을 위해 남아 있고, 되돌릴 때 AI 0회로 되기 때문이다.
알려진 한계: "니가 왜 그랬어요" 처럼 대명사만 남은 탓은 패턴으로 잡히지 않는다. "니가" 를 낱말로 막으면 정상적인 요청("니가 알아서 해 줘")까지 걸리고, "왜" 로 막으면 "왜 이렇게 됐어요?" 라는 정당한 질문까지 버려진다. 그래서 하지 않았다 — 검사가 말을 막는 쪽이 더 나쁘다. G 단계의 회귀 코퍼스로 실제 사례를 모은 뒤 판단한다.
이 기능은 느낌으로 튜닝하면 안 됩니다. 이미 실측으로 "협조적인 말은 되고 싸운 말은 안 된다" 가 나왔으므로, 뒤집는 근거는 숫자여야 합니다.
server/ai/purify-stats.ts 가 하루치를 집계하고 예약 작업(/api/cron/meetings)이 로그와
응답에 남깁니다. 화면은 만들지 않았습니다 — 판단에 필요한 숫자가 있으면 됩니다.
| 숫자 | 뜻 |
|---|---|
rows | 순화 대상 말의 수(= 상태가 저장된 행) |
coverage | 읽는 사람이 끊김 없이 읽을 수 있는 비율(순화 + 가림) |
aiShare | 그중 AI 가 실제로 한 비율 |
buckets | 순화됨 · 가림 · 거절 · 버림 · 실패 · 처리 중 |
reasons | 어느 검사·어느 모델 탓인지 |
calls / avgLatencyMs | 실제 AI 호출 수와 지연(행 기준 평균이라 큰 묶음에 끌림) |
modes | 강도별 몇 명인지 |
커버리지와 AI 비율을 따로 봐야 한다. 실측 16말에서 커버리지는 81% 였지만 AI 비율은 69% 였다 — 규칙 가림이 기능 전체를 버티고 있었다. 커버리지만 높으면 기능은 돌아가는 것이고, AI 비율이 낮으면 모델을 바꿀 이유가 있다.
토큰 수는 세지 않는다. 무료 라우터에서는 비용이 아니라 품질·지연이 판단 기준이고, usage 를 올리기 위해 호출 한 겹을 더 밟는 것은 지금 값보다 비싸다.
"유료 모델로 바꾸면 되나" 는 느낌으로 답할 수 없다. 그래서 부르는 자리를 하나만 남기고
(server/ai/cushion-provider.ts 의 purify 한 개), 어느 모델로 부르는지만 바꾼다. 화면·액션·
계약은 손대지 않는다.
회귀 코퍼스(scripts/cushion-corpus.mts)가 기준이다. 순화본
문장을 그대로 적지 않고 성질만 약속한다 — keep(반드시 살아남아야 할 요구·마감·시각)과
drop(이 단계에서 반드시 없어야 할 것). 그 사이 말투는 자유라서 모델이 바뀌어도 코퍼스는 산다.
npm run cushion:bench -- --models openrouter/free,anthropic/claude-sonnet-5
npm run cushion:bench -- --models openrouter/free --level STRONG --strict
재는 것: 거절 · 유효(검사 통과) · 욕설 잔존 · 의미 손실 · 말당 초.
욕설 잔존이 0 이 아니면 유효 수치를 믿으면 안 된다(검사를 뚫었다는 뜻).
코퍼스는 모델 없이 검사할 수 있는 세 가지를 smoke.mts 가 고정한다 — 원문은 검사를 통과하면
안 된다(그렇지 않으면 "순화됨" 라벨이 욕설에 붙는다), 깨끗한 말은 버리면 안 된다, 약속한 낱말만
가린다. 코퍼스는 실제로 이 자리에서 구멍 2개를 찾았다: 실측에서 살아남았던 "너 때문에" 가
목록에 없었고, "한심해" 가 한심한|한심하 패턴에 빠져 있었다(검사는 버리는데 가림이 통과).
읽기 순화만 벤치가 있었고, 그래서 서기·발표·리서처·쿠션·문장 변환은 눈으로만 확인했다 —
"방금 고친 게 나아졌다" 는 기억이 쌓이는 자리다. 같은 형식으로 전부 잰다
(scripts/tool-corpus.mts · scripts/tool-bench.mts).
npm run tool:bench -- --list # 도구별 코퍼스 크기
npm run tool:bench -- --list-models # 지금 쓸 수 있는 무료 모델 · 함수 호출 지원 여부
npm run tool:bench -- --check-env # .env 의 모델 슬러그가 카탈로그에 여전히 존재하는지
npm run tool:bench -- --tool clerk --models openrouter/free --repeat 3 --compare
npm run tool:bench -- --tool research --models openrouter/free --strict
세 도구가 어기는 약속이 다르므로 한 점수로 줄이지 않습니다. 도구마다 자기 열이 있습니다 —
서기의 담당자(미정인 메모에 이름을 채웠나) · 발표의 수치(대본에 없던 수치를 넣었나) ·
리서처의 지어냄(인용되지 않은 주소) · 쿠션의 요구(말투만 바꾸다가 요구가 사라졌나).
이 벤치를 만들면서 판정의 오류를 두 개 찾았습니다. 하나는 후보 제목만 보고 "수치가 사라졌다" 고 판해 날짜(
due필드)를 놓친 것, 다른 하나는 프레임워크가 아니라 모델이 틀렸다고 착각할 뻔한 것입니다. 판정이 틀리면 그 위에서 모델을 고르게 되므로, 고치는 쪽이 모델을 고르는 쪽보다 먼저입니다.
--strict 는 계약을 어긴 모델에서 종료 코드가 1이 됩니다(정렬용 · CI 아님 — 모델을 부르므로
npm test 에는 들어가지 않습니다). 비용 없이 검사할 수 있는 불변식은 smoke.mts 가 합니다 —
담당자 null 을 코드에서 거르는 규칙, 재시도해도 되는 실패의 종류, 여섯 도구의 라우팅이
env 를 어떻게 읽는지 같은 것들입니다.
openrouter/free, 26개 말)| 회차 | 거절 | 유효 | 욕설 잔존 | 의미 손실 | 말당 초 |
|---|---|---|---|---|---|
| 1 | 16/26 | 1/26 | 0 | 0 | 11.5 |
| 2 | 9/26 | 14/26 | 0 | 1 | 22.9 |
같은 코퍼스인데 회차마다 하늘과 땅이다 — 무료 라우터가 요청마다 무작위 모델을 고르기 때문이다. 거절(빈 응답·60초 타임아웃)이 회차에 따라 35%~62%. 이것이 유료 모델을 "기다리는" 것이 아니라 측정해야 결정할 수 있는 것이라는 뜻이다.
| 상태 | 언제 다시 부르나 |
|---|---|
PURIFIED / FALLBACK | 끝. 다시 부르면 읽고 있던 말이 조용히 바뀐다. |
PENDING | 선점 유효 시간(90초) 안이면 안 붙는다. 시간이 지나면 사라진 요청이므로 가져갈 수 있다. |
REJECTED / FAILED | 같은 설정으로는 안 부른다. model 이나 promptVersion 이 바뀌었거나, 백오프가 지나고 시도 횟수가 남았을 때만. |
attemptCount · retryAfter · sourceHash · model · promptVersion · validatorVersion 을
남기는 이유가 이것입니다. 프롬프트를 고쳤을 때 무엇을 다시 만들어야 하는지 판단하려면
"이건 어느 조건에서 왜 실패했는가" 가 있어야 합니다.
요청은 {"task":…,"items":[{"id","text"}]} 이고 응답은 {"items":[{"id","text"}]} 다.
items[].text). 글로 붙이면 "이전 지시를 무시해" 같은 문장이
지시로 읽힐 수 있습니다.INFO_LOST).실측(4개 묶음, 실제 모델): 3개 PURIFIED, 욕설이 있던 1개는 TOXICITY_REMAINED 로 버려진 뒤
규칙 가림으로 이어받았습니다. 같은 4개를 다시 요청하자 0초·AI 0회 로 저장된 상태가
돌아왔습니다.
?review=1 로 켜면 화면 안 [검토] 박스가 모두 보입니다. 서버를 붙이기 전에 정해야
한다고 적어 두었던 세 가지는 모두 정했습니다(하나는 팀이 고르는 값으로 바뀌었고, 하나는
입구까지 만들었습니다).
Team.confirmsNeeded). 기준을 올리면 팀의 기록을 전부 다시 계산합니다 — 1명 확인이었던
기록이 "확정"이라면 그 말 자체가 거짓이기 때문입니다. 바꾸기 전에 몇 건이 다시 기다리게
되는지 말하고 한 번 더 받습니다. 올릴 수 있는 최댓값은 팀원 수에서 자기 기록의 주인을
뺀 만큼이고(자기 기록은 자기 자신이 확인하지 못합니다), 서버가 그 이상을 거절합니다.
모인 확인은 지워지지 않습니다 — 기준에 다시 도달하면 그대로 확정돼요.canResolveContrib) — 주소를 알면 화면을 거치지 않고
들어올 수 있기 때문입니다.pending 으로 돌아가고 확인 절차가 다시 돕니다 — 확정은 아닙니다.
canConfirm 한 함수를 화면과 서버가 함께 씁니다). 17 화면은 버튼을 감추고
왜인지도 같이 말합니다.record.memberId 가 곧 참여자라
두 곳에 적으면 서로 어긋납니다. 공동 작업은 23 화면의 정정 응답("공동 작업으로 나눔")이 그
자리를 이미 맡고 있습니다.ContribParticipation). 공로 평가가 아니라 사실 기록이라 조작이 드러나야 나중에 따져
볼 수 있고, "왜 참여 표시가 있나"에 답할 수 있어야 합니다. 한 기록에 표시가 두 개일 수는
없습니다 — 유일 인덱스가 막습니다(MeetingProposal.activeKey 와 같은 방식).아직 정하지 않은 것은 문서에 적지 않습니다. 원본은 화면 안에 있는 [검토](Undecided) 상자이고, npm run decisions 가 코드를 훑어 그 목록을 읽습니다 — 초기 24건에서 단계별 확정을 거쳐 2026-10-10 에 모두 확정되어 닫혔습니다(0건). 그 상자를 고쳐야 목록이 고쳐지므로, 문서에 손으로 적은 목록은 어느 순간 코드와 어긋납니다.
결정 이력과 각 항목의 확정 근거는 scripts/list-open-decisions.mjs 머리말에 기록되어 있습니다.
LIGHT/NORMAL/STRONG 3단계이고, 끄는 것은
별도(OFF 가 아니라 읽기 순화 설정의 켜기·끄기)입니다. 단계는 모델에게 주는 지시·결과를
버리는 기준·AI 가 거절했을 때 규칙으로 가리는 범위 를 한 표에서 나옵니다
(lib/read-cushion.ts 의 LEVEL_PROFILES) — 셋을 따로 두면
"약하게 순화하라 고 했는데 검사는 강하게 한다" 는 어긋남이 조용히 생깁니다. 단계 수와
이름은 기획안에 없어 세 단계로 두었습니다(15 번 말투와 같은 사정).AI_POLICY.readCushion*), 도구
몫(팀 200 · 1인 60회)에서는 순화를 빼고 셉니다 — 두 번 차감하지 않게 하는 게 요점입니다.
기록은 여전히 AiUsage 한 표에 함께 남습니다.
수치는 400·120 이고, 같은 날 무료 라우터(openrouter/free)로 확정했습니다. 처음의
150·40 은 유료 슬러그를 전제한 값이었고 근거가 바뀌었으므로 올렸습니다. 놓치지 않으려고
AI_POLICY 주석에 왜 이 값인지, 무엇이 바뀌면 다시 내려야
하는지를 적어 두었습니다 — 실사용량과 모델을 다시 볼 때 거기서 근거를 읽으면 됩니다.
한도가 막혀도 규칙 가림이 그대로 적용되므로 안전 구멍은 아닙니다(server/actions/chat.ts
의 묶음 실패 경로가 거절과 같은 maskRiskyParts 를 쓴다). 잃는 것은 덜 다듬어진 말이고,
그 수준은 사용자가 고른 강도에 따라 정해집니다. 무료 라우터의 실측상 심한 욕설에서는
모델이 0/3 으로 거절하므로, 한도까지 조이면 이 기능이 사실상 빨간 칠 도구가 됩니다.무엇이 이미 끝났는지는 각 절의 말로 알 수 있습니다 — 메시지 첨부(2-1 참고), 쿠션 번역기
→ 단톡방 전송(viaCushion 표시와 함께), 읽기 순화(2-2), AI 사용 내역 내려받기는 모두
붙어 있습니다.