계획서(open-prep)의 10일치를 이틀에 돌렸습니다. 커밋 13개.
그런데 계획에 없던 게 더 컸습니다 — 고친 것의 절반이 "코드는 있는데 조용히
죽어 있던 것"이었습니다.
이 서비스는 알림도, 웹 푸시도, 요약도, 기억도 전부 코드가 있는 채로 안 돌고 있었습니다.
전부 에러를 삼키고 있어서 아무도 몰랐습니다. 지금은 다 돕니다.
| 무엇 | 상태 | |
|---|---|---|
| 1일차 | 복잡도 라우터 제거 | ✅ |
| 2일차 | 알림 배선 복구 + 웹 푸시 가드 | ✅ |
| 추가 | .env 웹 번들 노출 차단 |
✅ |
| 3일차 | 선발화 스케줄 티커 | ✅ |
| 4일차 | keep-alive 심박 | ✅ |
| 6~7일차 | 도달 3층 | ✅ (이메일 키만 대기) |
| 추가 | 웹 푸시(크롬) | ✅ |
| 8~9일차 | 한국어 로컬 검색 + 지어내기 차단 | ✅ |
| 10일차 | 다자방 턴 규칙 | ✅ |
| 추가 | 프롬프트 다이어트 · 모델 통일 · 기억 원장 | ✅ |
이게 이번 작업의 진짜 내용입니다. 하나하나가 "있다고 믿었는데 없던 것"입니다.
| 무엇 | 어떻게 죽어 있었나 | 어떻게 알았나 |
|---|---|---|
| 알림 발송 | FCM 발송기가 구 함수에만 있고 앱은 신 함수를 호출 → 배포 이래 0건 | notifications 0행 |
| 웹 푸시 | 서비스워커·웹앱·VAPID 키가 하나도 없었음 | 웹에서 알림 0건 |
| 요약 | fal 키 401 → 조용히 실패 | 상담 906건 중 요약 18건 |
| 기억 추출 | 같은 fal 키 401 | 원장이 안 쌓임 |
| 상담 조회 | 없는 컬럼(team_id)을 select → 쿼리 통째 거절 |
userId·summary가 전부 undefined |
| 1:1 기억 주입 | 코드가 0줄 | 프롬프트 계측 |
| 1:N 기억 주입 | RPC·테이블이 없어 항상 빈손 | DB 조회 |
| 복잡도 라우터 | 헤비를 0% 포착, 87%를 헤비로 밀어올림 | 실사용 145건 재생 |
| fast path | fal 401 + try/catch 없음 → "안녕"이 500 | 실호출 |
관통하는 원인 하나: 실패를 삼키고 있었습니다.
catch {}, 에러 무시, 조용한 폴백. 그래서 "동작한다고 믿는 상태"가 몇 달 유지됐습니다.
이번에 고친 코드는 전부 실패하면 소리를 내게 바꿨습니다.
reminders.evidence_utterance가 NOT NULL입니다. 사용자가 자기 입으로 말한 원문이
없으면 리마인드 행이 아예 안 생깁니다. "약속한 적 없는 알림은 잔소리라 즉사"를
프롬프트가 아니라 스키마가 강제합니다 — 프롬프트는 흘리지만 CHECK는 안 흘립니다.
pg_cron 1분 잡 하나가 때가 된 것만 집습니다. 상태는 pending → firing → sent/failed로,
보내지도 않고 sent로 적는 자리를 안 만들었습니다.
Supabase 무료 플랜은 1주 무활동이면 멈추고 pg_cron도 같이 죽습니다(자동 재개 없음).
클라우드플레어 워커가 하루 한 번 깨우고, 잡이 없거나 꺼져 있으면 그 자리에서 다시 겁니다.
등록 여부가 아니라 실제로 돌았는지를 봅니다.
실측으로 잡을 통째로 지워보고 되살아나는 것까지 확인했습니다.
Firebase 웹 앱을 새로 만들고(기존엔 twitter_clone이라는 엉뚱한 이름뿐), VAPID 키를
발급하고, 서비스워커를 붙였습니다. 알림센터 도달 확인.
배우면서 알게 된 것: 탭이 열려 있으면 비활성이어도 메시지는 서비스워커가 아니라
페이지로 갑니다. 서비스워커 배경 핸들러는 페이지가 하나도 없을 때만 돕니다.
그래서 "배경 탭에 띄워둔 채 다른 일 하는" 가장 흔한 상황에 아무것도 안 보였습니다.
카카오 로컬(무료) 연결. 그리고 크루가 답에 쓴 상호를 [출처]로 인용하게 하고,
그걸 검색 결과와 결정론으로 대조합니다. 틀리면 재생성하고, 그래도 안 되면
정직하게 말하되 찾은 게 있으면 그 목록은 그대로 줍니다(내용 0으로 안 만듦).
| 검색 0건인 질문 | 전 | 후 |
|---|---|---|
| 기장 일광 국숫집 | '일광당'을 주소·가격까지 지어냄 | "정확히 일치하는 정보가 없네요" + 상호 0개 |
LLM은 "다음에 누가 말해야 하나"를 못 맞춥니다(다음 화자 예측 46%, 랜덤보다 낮음).
그래서 순서를 계산시키지 않고, 사람의 턴테이킹 규칙을 씁니다 — 현재 화자가 다음 화자를
지목하고 아무도 지목 안 하면 끝.
실측: 복합 질문은 3턴, "고마워 잘 볼게"는 2턴 만에 자발적 종료.
기억이 크루별로만 있어서 크루를 바꾸면 나를 다시 몰랐습니다.
설계가 말한 "비서가 나가도 회사 문서는 남는다"의 정반대였습니다.
user_memory_entries가 스키마로 두 가지를 강제합니다:
source_consultation_id NOT NULL — 대화 없이는 기억이 생길 수 없습니다.kind는 결정/기준/결과 셋뿐 — 감정·성격이 쌓일 자리를 안 뒀습니다.부산로컬가이드에게 "8살 딸 하윤이랑 다녀 / 시끄러운 데 질색 / 주차되는 데만 /
다음 달 부산 이사" → 원장에 4건 기록.
한 번도 대화한 적 없는 가계부여왕에게 "이번 달 예산 짤 건데":
"이번 달 예산은 다음 달 부산 이사비까지 포함해서 짜야 해요."
"하윤이와 함께 이동하고 주차 가능한 조용한 곳을 찾는 조건이면…"
설명을 한 글자도 다시 안 했는데 압니다.
먼저 계측을 넣고 실측했습니다. 6턴에 5,172자였고 히스토리가 3,353자(65%)였습니다.
규칙과 기억이 그 뒤에 묻히는 구조였고, 검색 결과를 넣어줘도 크루가 지어낸 게
이것 때문이었습니다.
| 전(8턴) | 후 | |
|---|---|---|
| 히스토리 | 2,948자 | 1,929자 (−35%) |
| 총 프롬프트 | 4,759자 | 3,740자 (−21%) |
두 가지를 했습니다 — 최근 6개만 원문(이전은 한 줄 요약), 히스토리 안의 발화도
300자에서 자름. 크루 답변 하나가 900~1,000자라 6개만 담아도 3,000자가 됩니다.
맥락 잇는 데 답변 전문은 필요 없습니다. 이제 ~2,200자에서 평평해집니다.
같은 프롬프트를 모델만 바꿔 재봤습니다.
| 모델 | 턴당 | 지어내기 방지 | 지목 규칙 |
|---|---|---|---|
| grok-4.1-fast-non-reasoning | 1.12원 | ✅ | ✅ |
| gemini-3.1-flash-lite | 1.89원 | ✅ | ✅ |
| gpt-5.6-luna | 1.51원 | ✅ | ✅ |
| gpt-5-mini | 2.17원 | ⚠️ 빈 응답 | ⚠️ |
| gemini-3.6-flash | 5.25원 | ⚠️ 위반 | ⚠️ |
비싼 게 나은 게 아니었습니다. 4배 비싼 모델이 규칙을 어겼습니다.
5.6 계열에서 luna가 제일 쌉니다(sol·terra는 10배). 컨텍스트 105만.
→ 203개 크루 전부 gpt-5.6-luna로 통일. 개성은 모델이 아니라 프롬프트가 만듭니다.
앞으로 이걸 계속 재도록 모델 비교 경로를 넣어뒀습니다.
바로 할 수 있는 것
- 채팅 표현 트랙 6건 — 크루 발화가 900~1,000자라 말풍선 문법과 안 맞습니다
- 설계서 갱신 — 기억 층은 문서와 실제가 많이 달랐습니다
형이 정해야 하는 것
- 위임(헤비 미션 분해)을 1호에 넣을 것인가. 지금 앱은 위임을 호출하는데 엣지 함수가
안 받아서 조용히 아무 일도 안 일어납니다. 2호로 미룬다면 그 호출을 없애거나
정직한 안내를 돌려줘야 합니다.
- 이메일 공급자 — 아이폰·웹 사용자에게 닿는 마지막 층. Resend 무료(가입 필요) 또는
Cloudflare(Workers 유료 $5/월). 층은 이미 짜여 있고 키만 넣으면 삽니다.
아직 못 하는 것 (정직하게)
- 아이폰 사파리는 홈 화면에 추가해야만 알림 권한을 줍니다. 그 전까지는 인앱에만 남습니다.
- 네이버 검색 키가 없어 블로그·카페 글은 못 가져옵니다(장소는 카카오로 됩니다).
- 상담 요약은 이제 돌지만, 쌓인 906건은 여전히 대부분 비어 있습니다.