김초엽의 《양면의 조개껍데기》에 〈비구름을 따라서〉라는 단편이 있다. 거기 이런 문장이 나온다.
막을 건너온 사소한 물건들, 그 너머에 존재하는 것은 거대한 세계와 사람들. 할 수 있는 것은 오직 상상하는 일뿐.
이 문장을 읽고 한참 멍때렸다. 어딘가에서 넘어온 사소한 물건 하나로 그 너머의 세계를 통째로 상상한다는 발상이 좋았다. 그래서 그걸 매일 하는 작은 웹 게임으로 만들었다. 이름은 Nova Pouch, 우리말로는 차원의 주머니다. 기획 문서를 커밋한 게 3월 2일 밤이고, 첫 프론트엔드 커밋이 3월 3일 아침이다. 거기서 2주 동안 55개의 커밋이 쌓였다.

당신은 만드는 사람이 아니라 기록하는 사람이다
설계 문서 맨 첫 줄에 이렇게 적어뒀다. “이 물건들은 이미 어딘가에 존재합니다. 당신은 그 세계를 엿볼 뿐입니다.” 이게 이 게임의 세계관 전제다. 현실과 평행하는 무수한 세계가 실재하고, 그 세계에서는 우리에겐 말도 안 되는 물건이 지극히 당연하게 쓰인다. 주머니는 그 세계들의 파편을 담고 있는 통로다.
그래서 플레이어의 정체성을 발명가가 아니라 기록자(Archivist)로 잡았다. 주머니에서 파편을 뽑는 건 무작위로 어떤 세계의 단서를 손에 쥐는 것이고, 글을 쓰는 건 없던 걸 지어내는 게 아니라 이미 존재하는 세계를 기술(describe)하는 것이다. 사소하지만 이 프레임 하나가 게임 전체의 카피를 바꿨다. “만들어보세요”는 “어떤 세계가 보이시나요?”가 되고, “완성!”은 “세계가 기록되었습니다”가 됐다. 동사가 만들기에서 기록하기로 넘어간 셈이다.
세 주머니에도 세계관을 얹었다. 빨강은 물질계, 파랑은 성질계, 초록은 규율계에서 흘러나온 파편이다. 비주얼 테마는 “차원의 관측소(Dimensional Observatory)” — 딥 미드나잇 배경에 주머니마다 네뷸라 레드/에테르 블루/오로라 그린을 줬다. 기록 텍스트는 산세리프가 아니라 명조로 흘려서, 기록자가 남기는 서사라는 느낌을 노렸다.
파편 세 개, 세계 하나
규칙은 단순하다. 주머니가 세 개 있다. 빨간 주머니에선 물건이 나온다(우산, 신발, 시계, 열쇠). 파란 주머니에선 속성이 나온다(투명한, 날아다니는, 노래하는). 초록 주머니에선 제약이 나온다(물에 녹는, 밤에만 작동하는, 한 번만 쓸 수 있는). 각 주머니에서 하나씩 뽑으면 “밤에만 작동하는 / 노래하는 / 시계” 같은 조합이 나오고, 미션은 이 이상한 조합이 그냥 당연한 일상인 세계를 상상해서 짧게 글로 쓰는 것이다. 시계가 노래로 시간을 알려주고 밤에만 움직이는 도시는 어떤 곳일까. 정답은 없고, 남이 쓴 걸 보면 같은 조합인데도 전혀 다른 세계가 나온다.


파편은 지금 세 주머니 합쳐서 158개다(물건 53, 속성 54, 제약 51). 처음 설계할 땐 주머니마다 20개씩이었는데 쓰다 보니 계속 늘었다. 코드 주석에는 아직도 “50-token registry”라고 적혀 있는데, 그건 옛날에 붙여둔 이름이고 실제로는 세 배쯤 불어난 셈이다. 이런 게 개인 프로젝트의 정직한 흔적이라고 생각한다.
Wordle을 대놓고 설계도로 놓았다
세계관은 김초엽에게서 왔지만, 게임의 골격은 솔직히 Wordle에서 그대로 훔쳤다. 데일리 퍼즐 구현 문서 제목이 아예 “Wordle UX Pattern Implementation”이고, 작업을 두 단계로 나눴다. Phase 1은 “Make It Daily”, Phase 2는 “Make It Viral”이었다.
Make It Daily 쪽은 Wordle이 데일리 퍼즐로서 갖춘 장치들을 하나씩 옮기는 일이었다. 오늘이 며칠째인지 알려주는 Day 넘버, 오늘 걸 끝냈으면 다음 자정까지 잠기는 완료 게이트, 그때 뜨는 카운트다운 타이머. Make It Viral 쪽은 결과 공유였다. Wordle의 초록/노랑 이모지 그리드처럼 원탭으로 복사되는 텍스트 한 덩어리를 만들었다.
✦ Nova Pouch #189 (2026-03-03)
🥢 젓가락📳 진동하는✋ 한 손으로만 쓸 수 있는
(내가 쓴 세계 + 링크)Wordle 그리드의 핵심이 “정답은 안 보여주면서 ‘나 오늘 이거 했다’는 자랑할 수 있다”는 건데, Nova Pouch에는 애초에 정답이 없어서 감출 게 없다. 대신 오늘의 파편 세 개와 데이 넘버가 그 역할을 한다. 같은 날 이걸 본 사람은 같은 파편을 뽑게 되니까, “이 조합으로 너는 뭘 썼는데?”가 자연스러운 대화가 된다.
오늘의 파편은 모두에게 같아야 한다
데일리 퍼즐이니까 핵심은 “오늘 뽑히는 세 파편이 전 세계 모든 접속자에게 동일해야 한다”는 것이다. 이걸 어떻게 보장할지가 초기 설계의 가장 큰 논점이었는데, 뒤에 이야기할 멀티 AI 기획에서 여러 설계안이 같은 결론을 냈다. 서버가 매일 랜덤을 내려주는 대신, 날짜 문자열만 있으면 누구나 같은 결과를 계산해내는 결정론적 해시를 쓰고, 그걸 오프라인 폴백으로 항상 켜두자는 것이었다.
function hashWithPrime(input: string, prime: number): number { let hash = 0; for (const char of input) { hash = (hash + char.charCodeAt(0) * prime) % 2_147_483_647; } return hash;}
// 날짜 + 주머니마다 다른 소수를 곱해서 서로 다른 인덱스를 뽑는다red = redPool[hashWithPrime(`${d}:red`, 31) % redPool.length];blue = bluePool[hashWithPrime(`${d}:blue`, 37) % bluePool.length];green = greenPool[hashWithPrime(`${d}:green`, 41) % greenPool.length];이 hashWithPrime은 원래 서버(jiun-api)에 있던 함수를 클라이언트가 글자 하나 안 틀리게 포팅한 것이다. 함수 위 주석에 “Port of server hashWithPrime(). Must match exactly.”라고 박아뒀다. 주머니마다 소수를 31, 37, 41로 다르게 준 건, 같은 날짜에 세 주머니가 우연히 같은 패턴으로 움직이지 않게 하려는 것이다.
날짜는 KST(UTC+9) 자정 기준으로 넘어가고, Day #1은 2025년 8월 27일 — 《양면의 조개껍데기》 초판 1쇄 발매일이다. 발매일 에포크는 처음부터 정해뒀는데, 시간대는 아니었다. TypeScript + Vite로 마이그레이션한 3월 4일 0시 35분 커밋까지는 “오늘”이 UTC 기준이었고, 43분 뒤인 1시 18분 커밋에서야 KST로 고쳤다. 한국 사람이 쓰는 서비스인데 오전 9시에 날짜가 바뀌면 이상하니까. 게임을 켜면 뜨는 “Day #NNN”의 1일이 그 책의 발매일이라는 건 나만 아는 작은 이스터에그다.
데일리는 하루 한 번이다. 오늘 세계를 완성하면 nova-pouch-daily-completion 키에 완료 기록이 남고, 화면은 다음 퍼즐까지 남은 시간을 세는 카운트다운으로 바뀐다. “하루에 한 번, 짧게”라는 리듬이 이 게임의 전부라, 오늘 걸 끝냈으면 내일까지는 기다리게 만드는 편이 맞다고 봤다. 대신 더 놀고 싶은 사람을 위해 프리 플레이는 따로 무제한으로 열어뒀다. 프리 플레이에서는 추첨 가중치를 4 - difficulty로 줘서 어려운 파편일수록 덜 나오게 했다. 파편마다 1~3의 난이도가 붙어 있고(“우산”은 1, “시간을 되돌리는”은 3), 세 파편의 난이도 합이 3 이하면 별 하나(Very Easy), 7 이상이면 별 다섯(Very Hard)이다. 매번 초고난도 조합만 나오면 글쓰기가 숙제가 되니까, 대체로는 만만한 걸 주고 가끔 어려운 걸 섞는 쪽이 놀기 좋았다.
서버가 나보다 뒤처졌던 이야기
원래는 데일리 파편을 서버에서 받아오는 게 기본 경로였다. 서버도 똑같은 hashWithPrime을 돌리니까 결과가 같을 거라고 믿었는데, 기술 설계안 리뷰에서 이미 “Token pool mismatch problem”이라는 제목으로 경고가 나와 있었다. 클라이언트 레지스트리는 주머니마다 20개씩으로 불려둔 상태인데, 서버 풀은 10개짜리 옛 배열에 멈춰 있었던 것이다. 같은 해시 함수라도 풀 크기가 다르면 % length 결과가 어긋난다.
고민하다가 그냥 네트워크 경로를 껐다. 지금 daily 계산은 무조건 로컬 레지스트리에서 한다. 코드에는 “서버 풀이 최신으로 맞춰지면 다시 켜라”는 주석을 남겨뒀는데, 아직 안 켰다. 단일 소스가 하나뿐이면 어긋날 데가 없다는 게, 돌아보니 데일리 퍼즐 같은 것에는 오히려 안전한 선택이었다.
프레임워크 없이, XSS는 리뷰로
이번엔 의도적으로 프레임워크를 안 썼다. React도, 상태관리 라이브러리도 없다. 순수 TypeScript + Vite로, DOM API만으로 그린다. 상태는 직접 만든 pub/sub 상태머신으로 굴린다. IDLE → DRAWING → REVIEW → WRITING → COMPLETE를 오가고, 렌더러는 현재 상태를 읽어서 idempotent하게 다시 그린다. 주머니를 뽑을 때 흔들리고(shake) → 카드가 떠오르고(rise) → 뒤집히고(flip) → 텍스트가 뜨는(reveal) 애니메이션도 CSS transform과 Web Animations API로만 만들었다. src 기준 5,600줄 정도인데, 프레임워크가 없으니 매 줄이 다 내가 쓴 줄이라 어디서 뭐가 도는지는 확실히 안다.
다만 “프레임워크 없이 처음부터 안전하게 짰다”고 쓰면 거짓말이다. 기록 목록을 그리는 renderHistory()는 원래 템플릿 리터럴로 HTML 문자열을 만들어서 innerHTML에 통째로 꽂는 코드였고, 사용자가 쓴 글(userStory)이 이스케이프 없이 들어갔다. localStorage에 <img src=x onerror=...>를 심어두면 기록 화면을 열 때 그대로 실행되는, 공유 기기에서 특히 위험한 저장형 XSS다. 이걸 잡아준 건 뒤에서 이야기할 페르소나 코드 리뷰였다. 보안 리뷰가 C1(머지 블로커)으로 올렸고, 다섯 리뷰어 전원이 독립적으로 같은 곳을 짚었다. 그 지적을 받고 나서야 document.createElement + textContent 기반 DOM 렌더링으로 다시 썼다. 지금도 정적인 마크업 조립에는 innerHTML을 쓰지만, 사용자가 쓴 텍스트가 닿는 경로는 텍스트 노드나 escapeHtml을 거치게 해뒀다. i18n의 줄바꿈 처리도 같은 이유로 <br> 문자열 치환이 아니라 텍스트 노드 + <br> 엘리먼트로 바꾼 것이다.
작은 인터랙션에도 신경을 좀 썼다. 뽑기는 모바일 오터치를 막으려고 pointerup 기준으로 확정하고, 애니메이션이 도는 동안은 버튼을 잠가서 연타로 상태가 꼬이는 걸 막는다. 공개된 파편 텍스트는 aria-live로 스크린리더가 읽어주고, prefers-reduced-motion을 켠 사람에겐 애니메이션을 확 줄인다. 하루 한 번 잠깐 들어와서 파편을 뽑는 게임이라 그 잠깐이 매끄러운 게 제일 중요했다.
아키텍처를 두 번 갈아엎었다
솔직히 여기서 좀 헤맸다. 처음엔 GitHub Pages에 올라가는 정적 SPA였다. 그런데 “남이 쓴 세계를 읽는 커뮤니티 피드”랑 로그인이 붙으니 백엔드가 필요해졌고, 3월 11일에 Bun + Hono 풀스택 앱으로 이행해서 Kubernetes에 올렸다. MongoDB에 기록을 저장하고, JWT로 세션을 굴리고, 공유용 OG 카드까지 서버에서 그려서 내려주는 구조였다. 같은 날 밤 CI도 GitHub Actions에서 Gitea Actions로 옮겼고, 다음 날 arm64/amd64 멀티아치 빌드까지 얹었다.
OAuth도 요요를 탔다. 3월 5일에 구글·깃허브·카카오·네이버·X 다섯 개를 한 번에 붙였다가, 나흘 뒤 관리가 감당이 안 돼서 구글·깃허브만 남기고 뺐고, 3월 13일에 나머지 셋을 다시 붙였다. 이 왕복은 커밋 로그에 고스란히 남아 있다.
그러다 3월 16일 새벽, 닷새 만에 다시 정적 SPA로 돌아왔다. Nova Pouch만을 위한 백엔드를 따로 운영하는 대신, API 호출은 내가 공용으로 쓰는 jiun-api로 넘겼다. 게임 본체는 어차피 로컬에서 다 도니까 서버는 피드/기록 공유 같은 “있으면 좋은” 기능만 담당하게 했다. 오프라인 우선(offline-first) 설계라 API가 죽어도 파편 뽑고 글 쓰고 저장하는 핵심 루프는 localStorage만으로 멀쩡히 돈다.
이 왕복이 뻘짓이었냐면, 반쯤은 맞다. 처음부터 “게임은 로컬, 서버는 옵션”으로 선을 그었으면 K8s까지 갔다 올 일은 없었다. 다만 한 바퀴 돌고 나서야 어디까지가 진짜 서버가 필요한 부분인지 몸으로 알게 된 것도 사실이다.
멀티 AI로 기획하고, 페르소나로 리뷰했다
이 프로젝트에서 나한테 제일 재미있었던 건 만드는 과정 자체였다. 혼자 기획하는 대신, 라운드마다 Claude·Codex·Gemini에게 서로 다른 관점의 설계안을 병렬로 시키고 그걸 합치는 방식으로 굴렸다. 기획 라운드에서는 UX·기술·성능·아키텍처를 각각 맡은 설계안을 따로 받아서 하나의 통합 설계서로 머지했다. 앞에서 나온 결정론적 해시 폴백처럼 여러 관점이 독립적으로 같은 결론에 닿으면 그건 웬만하면 맞는 결정이었고, 갈리는 지점은 그 자체가 논점이 됐다. “차원의 관측소”라는 비주얼 테마도 Gemini가 낸 “우주의 서고(Cosmic Archive)” 안을 세계관에 맞게 다듬은 것이다.
리뷰 라운드에서는 페르소나를 씌운 리뷰어들을 붙였다. 보안, 아키텍처, 코드 품질, 성능, 프론트엔드 — 각자 자기 도메인만 파고들게 했다. 앞서 나온 renderHistory XSS(C1)가 대표적이다. 리뷰어 다섯이 전부 독립적으로 짚어서 “5/5 합의”로 올라온, 논쟁의 여지가 없는 머지 블로커였다. 코드 품질 리뷰어는 새로 붙인 네이버 로그인 버튼이 초록 배경(#03C75A)에 흰 글씨라 명도 대비가 2.25:1 — WCAG 기준 4.5:1의 절반밖에 안 된다고 잡았고, 프로덕트 리뷰어는 그 다섯 개 OAuth 버튼을 두고 “실제로 끝까지 연결 안 된 가짜 문(fake door)이면 첫 로그인에서 이탈한다”고 CRITICAL을 매겼다. 3월 13일 밤 “contrast, provider gating, code dedup” 커밋이 그 리뷰의 직접적인 결과물이다. 혼자였으면 “붙였으니 됐지”하고 넘어갔을 것들이다.
곁다리들
내가 쓴 세계는 Canvas 2D로 이미지 카드로 그려서 트위터나 기본 공유로 내보낸다. 텍스트만 링크로 보내는 것보다 파편 세 개가 박힌 카드가 훨씬 그럴듯해 보인다. 한때는 이 카드를 서버 OG 이미지로 그렸는데, 정적 SPA로 돌아오면서 클라이언트 Canvas로 되돌렸다. 첫 화면의 주머니 일러스트와 로고, 아이템 그림들은 이미지 생성 모델로 여러 컨셉을 뽑아보다가 페이퍼컷(paper-cut) 느낌의 시안이 마음에 들어서 그걸 기준으로 맞췄다.
책 속 설정을 참조한 “녹색의 세계” 프리셋에는 존재하지 않는 날짜 4월 31일을 일부러 살려뒀다. 파서가 자꾸 5월 1일로 정규화해버려서 날짜를 수동으로 파싱하는 커밋까지 넣었는데, 실재하지 않는 세계니까 달력에 없는 날이 어울린다고 생각했다. 언어는 data-i18n 속성 기반으로 한국어/영어를 전부 지원하고, 기록은 버전 필드를 달아 저장해서 스키마가 바뀌어 복원에 실패하면 설정만 살리고 기록은 백업 후 초기화한다. 새로고침 한 번에 남이 쓴 글까지 날려먹지 않으려는 안전장치다.
마무리
기술적으로 대단한 프로젝트는 아니다. 해시 하나, 상태머신 하나, DOM 렌더링. 재미있었던 건 오히려 세계관을 잡고, Wordle의 뼈대를 빌려오고, 여러 AI에게 서로 다른 눈으로 보게 해서 내 판단을 검증받는 과정이었다. 그리고 파편 목록을 채우는 일. “감정을 읽는”, “사용할 때마다 작아지는” 같은 조각을 하나씩 적으면서, 이게 다른 조각이랑 만나면 어떤 세계가 될까 혼자 상상하는 시간이 좋았다.
만들고 나서 며칠은 나도 그냥 유저로 접속해서 오늘의 파편을 뽑고 세 줄쯤 끄적인다. 정답이 없는 게 이 게임의 유일한 규칙이라, 매일 뽑히는 파편이 나한테도 매일 다른 낙서를 시킨다. 사소한 물건 하나로 그 너머의 세계를 상상하는 일 — 결국 원래 그 문장이 하려던 것도 이거였던 것 같다.
궁금하면 nova-pouch.jiun.dev에서 오늘의 파편을 한번 뽑아보시길.
스택: TypeScript(무프레임워크), Vite 6, DOM API, Canvas 2D, localStorage
참고
- Nova Pouch — Play
- 김초엽, 《양면의 조개껍데기》 중 〈비구름을 따라서〉
Comments
Loading comments...