프로젝트 사례
My Kitchen Board
계정 없이 바로 쓰는 가족 식단 플래너이자 주방용 디스플레이입니다. 실제로 먹는 시간에 맞춰 한 주의 식단을 짜고, 그 주를 장바구니 하나로 합치고, 주방에 세워 둔 태블릿에 다음 끼니를 띄웁니다. Next.js 16과 Supabase 위에 올렸고, 로그인 전에도 IndexedDB 게스트 모드로 제품 전체가 동작합니다.
라이브 프로젝트 보기 →개요
My Kitchen Board는 가족 식단 플래너이자 주방 디스플레이입니다. 랜딩 페이지에는 "The week's meals. One happy kitchen." 한 줄을 걸었고, 그 아래 약속은 다른 플래너들보다 오히려 작습니다. "조금만 계획하면, '오늘 뭐 먹지?'가 훨씬 줄어듭니다."
현재 베타이며, 무료이고, 계정은 일부러 선택 사항으로 두었습니다.
문제
식단 앱은 보통 실제 주방이 지키지 않는 세 가지를 전제합니다. 저녁은 저녁 시간에 먹는다는 것, 온 가족이 같은 걸 먹는다는 것, 그리고 방문자가 무언가 동작하는 걸 보기도 전에 회원가입을 해 줄 거라는 것입니다.
실제 집은 이렇게 생겼습니다. 한 아이는 7시, 다른 아이는 7시 30분에 아침을 먹고, 저녁 6시에 올라간 두 가지 요리가 합쳐서 "저녁 한 끼"이며, 매주 식단은 대체로 비슷하고, 장은 한 사람이 다 봅니다. My Kitchen Board는 깔끔한 격자가 아니라 이 모양에 맞춰 만들었습니다.
핵심 설계 결정
1) 가입이 아니라 게스트가 먼저
플래너는 IndexedDB에 담긴 샘플 주간으로 열립니다. 수정하고, 비우고, 계정 없이 계속 쓸 수 있습니다. 랜딩에서 이 말을 두 번 하는데, 방문자가 가장 먼저 넘어야 할 벽이기 때문입니다. "계정 필요 없음 · 이 기기에 저장됩니다."
구글 로그인은 진입 조건이 아니라 하나의 구체적인 확장으로 제시합니다. 휴대폰과 태블릿에서 같은 주방을 보는 것, 그 용도입니다.
2) 실제 시간에, 실제 사람에게
끼니는 가정 기준 시간의 임의 시각에 잡히고, 서로 겹칠 수 있으며, 가족 구성원의 어떤 조합에든 붙습니다. 계정이 없고 앞으로도 만들지 않을 구성원도 포함됩니다. 저녁 6시에 두 가지 요리가 있으면 충돌이 아니라 저녁 한 상으로 읽힙니다.
3) 정말로 합산되는 장바구니
한 주가 목록 하나로 접힙니다. 중복 재료는 합쳐지고, 수량은 계획한 인분에 맞춰 조정되며, 각 줄은 출처를 그대로 들고 있습니다. 어느 레시피 버전이, 어느 일정에서 이 재료를 더했는지까지 남습니다. 수량은 decimal.js로 정규화합니다. 무게는 그램, 부피는 밀리리터, 개수는 단위로 통일해서 1.5인분으로 조정해도 값이 슬그머니 어긋나지 않습니다.
4) 이력을 잃지 않는 레시피
레시피 버전은 불변입니다. 레시피를 고치면 새 버전이 생기고, 이미 짜 둔 계획은 그때 보고 계획했던 원문을 계속 가리킵니다. 확정된 일정은 의도적으로 최신 버전에 자동으로 붙지 않습니다. 지난 화요일 저녁은 그때 그대로 남아야 하니까요.
5) 조리대 위에 놓일 화면
디스플레이 화면은 주방에 세워 둔 태블릿을 전제로 만들었습니다. 오늘 끼니가 시간순으로 놓이고, 인분은 이미 조정되어 있고, 조리 단계에는 타이머가 들어 있습니다. 계획 화면과는 읽는 거리가 다르기 때문에, 레이아웃도 따로 잡았습니다.
6) 대부분의 주는 반복되니까
1회, 매일, N일마다, 매주, N주마다, 요일 지정까지 반복 규칙을 지원합니다. 매주 화요일 같은 메뉴를 쉰두 번이 아니라 한 번만 입력하면 됩니다.
기술 구현
사용 기술
| 영역 | 구현 | 비고 |
|---|---|---|
| 프레임워크 | Next.js 16 (App Router) + React 19.2 | TypeScript, React Compiler 적용 |
| 데이터(게스트) | IndexedDB (idb) | 네트워크 없이 제품 전체 동작 |
| 데이터(클라우드) | Supabase Postgres + Auth | 마이그레이션 93개, 가구 단위 RLS |
| 서버 상태 | TanStack React Query | 게스트·클라우드 저장소에 같은 훅 사용 |
| 수량 | decimal.js | 그램 / 밀리리터 / 개수로 정규화 |
| 시간 | Luxon | 브라우저가 아니라 가정의 타임존 기준 |
| 사진 | Cloudflare R2 (aws4fetch) | 서명 접근, 고아 파일 정리 스크립트 |
| 테스트 | Vitest + Playwright | 도메인 로직 단위 테스트, 플로우 E2E |
저장소는 둘, 도메인은 하나
packages/core에는 도메인과 데이터 계층을 순수 TypeScript로 담았습니다. 장보기, 반복, 인분, 조리, 열량, 동기화까지 125개 남짓한 모듈이 들어 있고, React도 Next도 등장하지 않습니다. 게스트 저장소와 Supabase 저장소가 같은 계약을 구현하기 때문에, 게스트 모드는 체험판이 아니라 제품 그 자체입니다. UI는 지금 어느 쪽과 이야기하는지 알지 못합니다.
일부러 둘로 나눈 CSP
nonce와 미리 렌더한 페이지는 공존할 수 없습니다. Next는 렌더 시점에 nonce를 찍기 때문에, 요청보다 먼저 만들어 둔 페이지는 브라우저가 실행을 거부하는 스크립트를 달고 도착합니다. 그래서 정책을 타협하는 대신 둘로 나눴습니다.
플래너 라우트는 src/proxy.ts에서 요청마다 nonce를 받아 동적으로 렌더합니다. 크롤러에 열려 있지 않고 데이터도 브라우저에서 가져오므로, 요청마다 렌더해도 잃는 것이 없습니다.
공개 레시피 페이지와 사이트맵은 고정 정책을 유지해 정적·캐시 가능한 상태로 둡니다. 그게 이 페이지들의 존재 이유이기 때문입니다.
테이블을 열지 않고 공개하기
레시피를 공개하면 세션 없이 /r/<title>-<id>에서 읽을 수 있습니다. 익명 접근은 오직 세 개의 DB 함수를 통해서만 이뤄지고, anon 역할에는 레시피 테이블 자체에 대한 권한이 없습니다. 그래야 그 함수들이 유일한 통로로 남습니다. 공개된 페이지에는 레시피만 실립니다. 작성자도, 가구 정보도, 댓글도 넘기지 않습니다.
설치되는 앱, 그리고 솔직한 서비스 워커
매니페스트와 모든 아이콘, 파비콘, 가로·세로 락업은 하나의 SVG 마스터에서 빌드 스크립트가 잘라 냅니다. 변형이 세 가지인 이유는 플랫폼마다 모서리를 누가 둥글릴지 의견이 다르기 때문입니다. 서비스 워커는 아무것도 캐시하지 않습니다. 주방의 기록은 이미 IndexedDB에 있고, 오래된 앱 셸은 살아 있는 앱과 구분되지 않습니다. 이 워커는 크롬이 설치를 제안하게 만들고, 오프라인 이동에서 브라우저 오류 대신 상황을 설명하는 화면을 내주기 위해 존재합니다.
이 프로젝트가 보여주는 역량
게스트 경로가 체험판이 아니라 제품 그 자체인, 오프라인 우선 제품을 설계한 경험
도메인 계층을 프레임워크에서 떼어 두 저장소가 그대로 공유하게 만드는 방식
레시피가 바뀐 뒤에도 과거의 계획이 그대로 남도록 불변성과 출처를 모델링한 사례
소수점 오차 없는 수량과 타임존이 맞는 일정을 스택 가장 아래에서 해결한 경험
CSP와 RLS, 공개 범위를 배포 체크리스트가 아니라 설계 결정으로 다루는 방식
Live: https://www.mykitchenboard.com/
Role: 1인 개발 (제품 기획 → 도메인 → 스키마·RLS → PWA → 브랜드)