A wine cellar that remembers what you used to believe
과거의 믿음까지 기억하는 와인 셀러
Cellar는 와인 보유 현황뿐 아니라 특정 시점의 구매·소비 기록과 음용 시기에 관한 판단을 재현하는 앱입니다. 이벤트와 날짜가 붙은 주장을 별도로 저장하고, 독립 검증과 실측을 곁들인 AI 개발 과정을 통해 시간에 따른 상태 계산과 Sanity 플랫폼의 제약을 설명합니다.
- 주제
AI 요약
Cellar는 “지금 무엇을 갖고 있나?”가 아니라 “1999년 6월 1일에는 어떤 와인이 있었나?”, “2018년에는 언제 마셔야 한다고 생각했나?”를 묻는 와인 컬렉션 앱입니다. 현재 상태를 병 문서의 필드에 덮어쓰면 과거 상태를 복원할 수 없습니다. 그래서 구매와 소비는 날짜가 있는 이벤트로, 음용 시기는 작성자와 날짜가 붙은 평가(assessment)로 저장합니다. 평가를 수정하거나 지우지 않고, 특정 시점까지 존재한 주장 가운데 어떤 판단이 우선하는지 규칙으로 계산합니다.
이벤트와 주장을 조합한 시간 모델
병은 날짜 T 이전에 소비 이벤트가 있으면 그 시점에 소비된 것으로 봅니다. 음용 시기 판단은 작성자별 우선순위로 해석합니다. 본인의 시음 기록이 생산자 정보를 앞서고, 생산자 정보는 평론가 평가보다 우선합니다. 같은 등급에서는 더 최근에 작성한 주장이 선택됩니다. 날짜를 과거로 옮기면 아직 구매하지 않은 병이 사라지고 소비 기록이 되돌아갑니다. 현재로 옮기면 각 병의 음용 시기도 그때 유효한 주장에 따라 달라집니다.
앱은 Sanity Studio, Sanity App, 공개 웹 페이지 세 부분으로 구성합니다. Studio는 데이터를 작성하고 평가 주장을 검토하는 곳입니다. App은 이벤트 로그를 읽어 상태를 계산하며, 순수 TypeScript 패키지가 Sanity와 무관하게 계산 로직을 맡습니다. 공개 페이지도 같은 화면을 쓰지만 계정 없이 읽기만 합니다. 번들에 토큰을 넣지 않고 공개 데이터셋의 읽기 권한만 허용합니다. 별도의 CRUD 화면을 만들지 않아 병 추가와 소비 기록은 Studio에서 처리합니다.
542병 기록을 세 시점으로 계산하면 1999년 6월 1일에는 4병이 있었고, 모두 1993년산 Château Mouton Rothschild였습니다. 그중 2병은 이미 열었습니다. 2026년 9월 23일에는 248병 가운데 166병이 음용 중이고 33병은 음용 기간이 지났습니다. 2035년 7월 23일에는 같은 248병 중 182병이 기간을 넘긴 상태로 계산됩니다. 세 화면은 날짜별 보유 상태, 12개월 안에 음용 기간이 끝나는 병, 적기에 마실 수 있었지만 열지 않아 현재 기간을 넘긴 병을 보여줍니다. 음용이 임박한 목록에는 어떤 등급의 누구의 주장이 판단 근거가 됐는지도 표시합니다.
AI 개발 과정에서 검증을 설계하다
개발자는 Claude Code와 WebStorm을 사용했고 Sanity MCP 서버를 연결해 최신 문서를 확인하게 했습니다. 다만 기획 대화는 별도 Claude 채팅 앱에서 진행했고, 갈림길마다 사람이 결정을 내린 뒤 구현 단계에 전달했습니다. 따라서 “개발자 한 명과 에이전트 한 개”가 만든 결과라고 부르기는 어렵다고 설명합니다. 구현에 앞서 콘텐츠 모델, 시간 계산 명세, 빌드 계획, 데이터 시드 계획, 아키텍처 결정 기록(ADR) 등 기획 문서 14개를 작성했습니다.
프로젝트에서 가장 효과가 컸던 지시는 “명세와 현재 플랫폼이 맞지 않는 부분을 찾고, 코드는 쓰지 말고 멈추라”는 것이었습니다. 첫 단계에서 Sanity가 시스템 필드로 예약한 밑줄 접두 필드를 명세가 사용한 문제와, 검증 규칙이 문서의 현재 상태만 볼 수 있는데 과거 상태를 요구한 문제가 드러났습니다. 후자는 그대로 구현하면 승인 상태로 바꾸는 동작 자체가 검증에 막힐 수 있었습니다. 네 번째 단계에서도 충돌 10건이 발견됐고 그중 2건은 명세 오류였습니다.
검증 기준도 통과를 꾸미기 어렵게 만들었습니다. App SDK 검증에서는 Content Lake에서 App SDK, CELLAR_QUERY, toCellarSnapshot, bottleState()를 거쳐 화면에 표시되는 전체 경로를 지정하고, 별도 패키지 호출이나 기존 테스트만으로 통과 처리하지 못하게 했습니다. 모델은 브라우저 화면을 볼 수 없으므로 실행 명령과 URL, 사람이 확인해야 할 결과를 제시하게 했습니다. 배포 전 기대 로그를 먼저 정하고, 테스트 기대값은 구현 전에 만들어 오라클로 사용했습니다. 테스트가 실패할 때 모델이 픽스처를 바꿔 결과를 맞추지 못하도록 한 조치입니다.
실제 사례에서는 모델이 문서를 잘못 읽거나 검증을 허술하게 만든 일도 있었습니다. Sanity 미리보기가 참조를 따라가지 못한다고 잘못 판단했지만, 문서를 다시 확인한 뒤 오류를 인정하고 한 줄로 고쳤습니다. 반대로 테스트가 아무것도 측정하지 않는데 통과한 사례도 있었습니다. 제안된 평가가 상태를 바꾸지 않는다는 테스트가 있었지만, 해당 픽스처는 평가를 승인해도 최신성 규칙 때문에 결과가 달라지지 않았습니다. 승인하면 적어도 하나의 상태가 바뀌어야 한다는 짝 테스트를 추가해 문제를 찾았고, 나머지 테스트도 같은 결함이 있는지 점검했습니다.
실측으로 기능과 데이터 모델을 조정하다
현재 상태를 미리 계산해 저장하는 Function은 구현하지 않았습니다. 542병 전체를 다시 계산하는 데 0.07밀리초가 걸렸고 날짜를 움직일 때도 프레임 예산의 절반 미만을 사용했습니다. 더 큰 병목은 계산보다 데이터 전송입니다. 현재 장부는 211.9KB이며 병 100배 규모에서는 20.7MB, 150만 병에서는 572.6MB로 추산합니다. 계산 시간은 각각 약 7밀리초와 200밀리초입니다. 약 5만 병부터 전체 데이터 조회가 먼저 부담이 될 것으로 봤고, 해결책은 생산자·빈티지·검색 조건으로 조회 범위를 좁히는 것입니다.
다만 미리 계산한 필드가 유용할 상황도 있습니다. “음용 기간이 지난 병을 모두 보여 달라” 같은 조회를 GROQ로 구현하면 우선순위와 최신성 규칙을 두 번째 언어로 다시 작성해야 합니다. 명세에서는 가능하지만 읽기 어렵고, 등급을 추가할 때마다 고쳐야 한다고 판단했습니다. 결국 데모에서 파생 필드를 읽지 않았고 실제 작성도 끝나지 않아 Function은 제외했습니다. 과거의 모든 날짜에 맞는 상태를 미리 저장하는 방식도 현실적인 캐시가 아닙니다. 저장한 상태에는 계산 기준 날짜가 따라야 하기 때문입니다.
평가 주장은 proposed, accepted, rejected 세 상태를 가집니다. 승인된 주장만 음용 시기를 결정하며 거절된 주장도 기록에서 삭제하지 않습니다. 상태 필드는 읽기 전용으로 두고 문서 작업 두 가지로만 상태를 바꿉니다. 모델이 시음 기록에서 평가를 제안하는 기능은 12개 실제 기록으로 확인했습니다. 정확한 연도 대신 음용 시기의 방향을 검증했고 12개 모두 맞았습니다. 제안마다 근거가 된 문구를 인용하게 해 사람이 판단 근거를 확인하도록 했습니다. 기록에 시기 신호가 없으면 빈 평가를 만들지 않고 제안 자체를 생략합니다. 제안 상태에서는 병 수가 바뀌지 않았고, 승인을 누른 뒤 예측한 다섯 병의 상태가 바뀌는 흐름도 스테이징 데이터로 확인했습니다.
문서와 접근 제어에서 확인한 제약
Sanity App SDK 앱은 조직 구성원만 볼 수 있었고, 로그아웃 사용자는 질의 전에 로그인 화면으로 이동했습니다. 정적 호스팅에서도 SDK가 지원하는 인증 경계를 우회할 수 없었습니다. 그래서 공개 페이지는 App SDK 대신 공개 데이터셋을 일반 클라이언트로 읽습니다. 화면과 계산 로직은 공유하므로 데이터 접근 부분만 바꿨습니다. 하지만 공개 데이터셋도 CORS 검사를 받습니다. GitHub Pages 도메인을 허용 목록에 추가하기 전에는 브라우저 요청이 403으로 실패했습니다. curl은 Origin 헤더를 보내지 않아 앞선 확인에서 CORS 문제가 드러나지 않았습니다. 실제 페이지를 열기까지 데이터셋 ACL, 조직 멤버십, Origin 허용 설정을 각각 다뤄야 했습니다.
저자는 문서와 타입 정의가 충돌할 때 설치된 타입 파일이 답을 빨리 주는 경우도 기록합니다. Prompt 액션에 schemaId가 필요하다는 문서와 그렇지 않다는 문서가 맞서자 타입 정의에서 필수 필드를 확인해 불필요한 프로덕션 스키마 배포 계획을 취소했습니다. Functions 번들링도 문서가 설명한 프로젝트 유형과 이 저장소 조합이 달라 작은 테스트를 배포했습니다. 로컬 워크스페이스 패키지는 번들에 포함하고 레지스트리 의존성은 외부화하는 동작을 확인했습니다. 아무 작업도 하지 않는 Function을 배포했는데도 만료일 없는 editor 역할 로봇 토큰이 만들어진 점도 함께 보고합니다.
글의 결론은 AI 생성 코드의 신뢰성을 명세만으로 보장한다는 주장이 아닙니다. 명세는 의견 충돌을 드러내고, 독립된 오라클과 실측이 그 충돌을 검증하게 합니다. 날짜 계산 속성 테스트는 데모 날짜 다섯 개가 모두 통과하던 1월 31일에서 3월 1일 오류를 찾았습니다. 17,167개 입력을 확인한 테스트, 배포 번들 검사, 브라우저에서 사람이 직접 읽은 수치도 검증에 쓰였습니다. 이벤트와 날짜가 붙은 주장을 따로 저장하는 모델은 현재 상태뿐 아니라 과거에 무엇이 사실이었고 무엇을 믿었는지 알아야 하는 박물관 소장품 귀속이나 당시 정책에 따른 인사 판단에도 적용할 수 있습니다. 다만 대부분의 시스템은 현재 상태만 묻기 때문에, 시간에 따른 역사적 진실이 요구사항인지 우연히 생긴 필요인지 먼저 판단해야 한다고 설명합니다.
원문: dev.to / 번역·요약: Trawling