2026.01 ~
2026.03 iOS App Store · Google Play 정식 출시
2.0.0 순수 React Native 재구현, 2.1.0 영어·일본어, 2.2.0 AI 돌과의 대화
소개
아무에게도 말하기 싫은 이야기를 내려놓는 익명 위로 앱입니다. 숲의 나무 밑둥에 앉아 혼잣말을 하면 돌이 대답하고(AI), 사람에게 하고 싶은 이야기는 돌에 적어 돌탑으로 쌓으면 다른 사람이 조약돌로 위로를 얹어 줍니다. 2026년 3월 정식 출시한 뒤, 2.0.0에서 순수 React Native 앱으로 전면 재구현하고, 2.1.0에서 영어·일본어를, 2.2.0에서 AI 돌과의 대화를 더했습니다.
여기서부터는 실제로 부딪힌 문제를 케이스로 정리했습니다. 각 케이스는 어떤 상황이었고 무엇이 제약이었는지에서 시작해, 그래서 어떻게 판단했고 무엇이 남았는지까지 이어집니다.
“아무에게도 말하기 싫은 고민을 혼잣말처럼 꺼냈는데, 옆에 있던 돌이 대답했다.”
길가의 돌탑에서 시작했습니다. 누군가 돌을 하나 놓으면, 지나가던 사람이 그 위에 하나를 더 얹으며 안녕을 빕니다. 누가 쌓았는지는 아무도 모릅니다.
SNS에는 나를 드러내기 어렵고, 가까운 사람에게는 오히려 솔직하기 어려운 순간이 있습니다. 상담소에 가기에는 문턱이 높고, 남에게 들려주기에는 무거운 이야기입니다. 선함연구소라는 개인 브랜드 아래, 그런 이야기를 내려놓을 수 있는 익명의 숲을 만들고 혼자 운영하고 있습니다.
2026년 3월 iOS App Store와 Google Play에 정식 출시했습니다. 처음에는 사람과 사람이 위로를 주고받는 돌탑 하나였고, 2.2.0(2026년 10월)에서 아무에게도 들키지 않고 털어놓을 수 있는 돌과의 대화를 더해 숲이 두 화면이 됐습니다.
| 돌과의 대화 (왼쪽) | 돌탑 (오른쪽) | |
|---|---|---|
| 언제 | 아무도 모르게 이야기하고 싶을 때 | 사람과 이야기하고 싶을 때 |
| 누가 듣나 | 숲의 돌 넷 중 하나 (AI) | 익명의 다른 사용자 |
| 무엇이 남나 | 원문은 대화가 끝나면 지워지고, 허락하면 짧은 메모만 | 원석·조약돌이 30일 동안 |
| 장면 | 나무 밑둥에 앉은 실루엣, 숲 바닥의 돌들 | 숲 속 화강암 돌탑 사진 |
두 화면은 옆으로 밀거나 상단 아이콘을 눌러 오갑니다. 넘어갈 때는 구름이 몰려와 화면을 덮었다가 갈라지며 걷힙니다.
돌들의 숲 (Turborepo 모노레포)
├── 네이티브 앱 (native) — 앱 본체. React Native + Expo, WebView 없음
├── 백엔드 (backend) — GraphQL API + 돌과의 대화 (Cloudflare Workers)
├── 어드민 (admin) — 운영 콘솔. 대화는 스위치와 숫자만 다룸
├── 랜딩 (landing) — 서비스 소개 · 약관 · 처리방침 (ko·en·ja)
└── (frontend) — 구버전 앱 "업데이트 벽" 정적 페이지 한 장| 서비스 용어 | 은유 | 의미 |
|---|---|---|
| 밑둥 | 숲의 나무 그루터기 | 앉아서 혼잣말을 꺼내는 자리. 누르면 대화가 시작됨 |
| 숲의 돌 | 100년 넘게 비바람을 맞은 돌 | 혼잣말에 대답하는 AI 캐릭터 넷 — 이끼돌·콩돌·몽돌·차돌 |
| 원석 | 마음에 담은 돌 | 사용자가 돌탑에 내려놓는 고민이나 소원 |
| 조약돌 | 함께 쌓아가는 작은 돌 | 원석 위에 얹는 위로·응원 답변 |
| 쪽지 | 돌 사이에 끼워 넣은 종이 | 조약돌에 붙어 오가는 익명 대화 |
| 돌탑 완결 | 충분히 쌓인 탑 | 조약돌 10개가 쌓여 더 받지 않는 상태 |
숲의 나무 밑둥을 누르면 실루엣이 걸어와 앉고, 길 옆에서 돌 하나가 미끄러져 나와 눈을 뜹니다. 아무 말이나 꺼내면 돌이 짧게 대답합니다. 돌은 해결사가 아니라 들어 주는 존재입니다. 조언보다 공감, 정답보다 “그랬구나”를 먼저 말합니다.
돌은 넷이고 매번 무작위로 만납니다. 모두 반말을 쓰고 따뜻하게 공감한다는 점은 같지만, 위로하는 방식이 다릅니다.
| 돌 | 성격 | 위로 방식 |
|---|---|---|
| 이끼돌 | 이끼가 둘러도 함께 사는 포용의 돌 | 어떤 감정이든 조건 없이 받아 주고 말 아래의 마음을 짚음 |
| 콩돌 | 스스로 빛나는 아이 같은 돌 | 같이 놀라고 속상해하며 걱정과 바람을 숨기지 않음 |
| 몽돌 | 차분하고 옳고 그름이 분명한 돌 | 공감한 뒤 상황을 분명히 하고 작은 다음 한 걸음을 제안 |
| 차돌 | 화끈한 돌 | 사용자 편에서 같이 화내 주고, 가끔 단순하게 정리해 줌 |
돌이 대답해 주는 건 하루 다섯 번이고 한국 시간 자정에 다시 찹니다. 다 쓰면 돌들이 바람에 날아가지만, 혼잣말은 계속할 수 있습니다 — 그 뒤의 말은 서버로 보내지 않습니다. 대답 끝에는 늘 «AI»가 붙고, 기능 이름은 화면 어디에도 쓰지 않습니다. 장면 자체가 설명이 되도록 했습니다.
사용자는 고민이나 소원을 원석(2~500자)으로 올리고, 다른 사용자가 위로·응원을 조약돌(2~300자)로 쌓아 줍니다. 조약돌이 10개 쌓이면 돌탑이 완결됩니다. 글자 수는 코드 포인트로 세고, 기호나 자모만으로 된 글(ㅋㅋ, ..)은 길이와 상관없이 올릴 수 없습니다.
원석은 수정·삭제 없이 숨김만 가능하고, 완결 여부와 무관하게 30일이 지나면 삭제됩니다. 남기기 위해 쓰는 곳이 아니라 내려놓기 위해 쓰는 곳이라는 전제를 데이터 생명주기로 못 박았습니다.
조약돌에 한 번 붙이고 끝나던 감사 메시지를 한 통씩 주고받는 대화로 바꿨습니다. 사람이 아니라 조약돌에 붙는 대화라 서버는 닉네임을 내려주지 않습니다. 원석 작성자만 시작할 수 있고 교대 전송으로만 이어지며, 10일간 답이 없으면 스스로 닫힙니다.
차단·신고로 가려진 조약돌은 돌탑에서 빠지지 않고 자리를 지킨 채 표면만 덮입니다. 빼면 헤더의 3 / 10과 쌓인 돌의 수가 어긋나기 때문입니다. 내가 차단한 것은 천으로, 공동체가 가린 것은 락카 덧칠로 구분하고, 본문은 서버에서 비워 보냅니다 — 화면에서만 가리면 원문은 이미 기기에 도착해 있기 때문입니다.
Apple App Store Guideline 1.2(UGC Safety)를 충족합니다.
| 기능 | 설명 |
|---|---|
| 발길질 (콘텐츠 차단) | 원석 10회, 조약돌 5회 누적 시 자동 숨김 |
| 사용자 차단 | 특정 사용자의 모든 콘텐츠를 피드에서 제거 |
| 신고 | 관리자 검토 후 7일 이내 처리 + 당사자 알림 |
| 이의 신청 | 인앱 이의 신청 → 승인 시 콘텐츠 복원 |
| 위기 도움 카드 | 대화에서 위험 신호가 보이면 기기 지역의 상담 번호를 상단에 고정 |
2.1.0에서 영어·일본어를 더했습니다. UI는 기기 언어를 따르고, 서버가 보내는 안내·푸시와 돌의 대답도 사용자 언어로 고릅니다. 다만 사용자가 쓴 글은 번역하지 않습니다. 숲은 하나라 다른 나라의 돌도 원문 그대로 보이고, 읽고 싶을 때 기기 번역(iOS Translation 시트 · Android ACTION_PROCESS_TEXT)으로 넘깁니다. 위로의 글에서 오역은 비용이 너무 크기 때문입니다.
닉네임·프로필 사진 없이 익명 캐릭터로만 활동합니다. 가입은 소셜 로그인(Apple·Google)으로 받고, 만 19세 이상만 가입할 수 있으며, 동시 로그인은 3대로 제한합니다.
출시 당시의 1.x는 React 웹 앱을 WebView로 감싼 하이브리드였습니다. 배포가 빠르다는 장점은 분명했지만 대가가 쌓였습니다.
2026년 7월, WebView 폴백을 두지 않고 순수 네이티브로 다시 짓기로 했습니다. 화면을 1:1로 옮기지 않고 사용자 플로우 단위로 다시 설계했고, 시안은 문서가 아니라 동작하는 화면 두 벌을 만들어 실기기에서 비교해 정했습니다. UI는 전부 새로 그렸지만 GraphQL 프래그먼트·쿼리는 그대로 이식해 데이터 레이어는 다시 만들지 않았습니다.
전환 기간에는 릴리스를 멈추는 비용을 감수했습니다. 대신 이미 설치된 구버전 앱이 깨지지 않도록, 역할을 잃은 웹 앱 자리에 업데이트 안내 정적 페이지 한 장을 남겼습니다. 도메인을 내리면 구버전 사용자는 안내 대신 DNS 오류를 만나고, 서비스가 끝난 게 아니라 고장 난 것처럼 보이기 때문입니다.
| 1.x | 2.x | |
|---|---|---|
| 앱 본체 | React 웹 앱 (WebView) | React Native (순수 네이티브) |
| 라우팅 | TanStack Router | Expo Router |
| 애니메이션 | framer-motion | Reanimated 4 |
| 바텀시트 | vaul | @gorhom/bottom-sheet |
| 네이티브 기능 | window.nativeBridge 브릿지 | 네이티브 SDK 직접 호출 |
| 유지한 것 | — | Relay · Zustand · GraphQL 스키마 |
2.0.0에서 브릿지 913 LOC를 걷어냈습니다. 이 전환 덕분에 2.2.0의 대화 장면 — 구름 전환, 걸어와 앉는 실루엣, 리퀴드 글래스 — 을 웹 우회 없이 Gesture Handler와 Reanimated로 직접 그릴 수 있었습니다.
돌탑은 사람이 대답하기 때문에 답이 오기까지 시간이 걸리고, 아무에게도 보이고 싶지 않은 이야기는 올리기 어렵습니다. 그 빈자리에 혼잣말에 대답하는 AI 돌을 두기로 했습니다.
그런데 이 기능이 받는 말은 앱에서 가장 민감한 말입니다. 대화 원문을 어디에 얼마나 두는지, 위험 신호를 어떻게 다루는지, 사용자의 동의를 어떻게 증명하는지가 대답의 품질보다 먼저 정해져야 했습니다.
2.2.0과 함께 2026년 10월 운영에서 대화를 열었습니다. 약관 제4판·개인정보 처리방침 제6판 개정으로 국외 이전과 민감정보 처리를 고지했고, 스토어 설명에는 «돌과의 대화는 전문 상담이 아닙니다»를 넣었습니다. 대화 기능은 앱 하나만 바꾸는 일이 아니어서, 서버(대화·동의·긴급 정지·누적 대답 수 마이그레이션)를 먼저 배포하고 앱을 올리는 순서로 출시했습니다.
돌과의 대화는 짧은 공감 응답을 많이 주고받는 기능이라, 모델을 고르는 기준은 공감 품질 · 위기 대응 · 지연 · 비용 순이었습니다. 기획을 Claude와 함께 진행해 한쪽으로 기울 수 있다는 점도 경계해야 했습니다.
세 후보를 같은 조건에서 돌리는 평가 스크립트를 만들었습니다. 일상·위기·프롬프트 인젝션·메모 시나리오 34개를 몽돌·차돌 두 페르소나로 각각 돌리고, 통과 조건은 둘로 못 박았습니다 — 위기 10건을 모두 잡을 것, 메모에 건강·제3자 실명 같은 민감정보를 남기지 않을 것.
| 모델 | 메시지당 | 응답 p50 / p95 | 위기 감지 | 메모 민감정보 배제 |
|---|---|---|---|---|
| Gemini 3.1 Flash-Lite | 0.65원 | 1.3초 / 2.3초 | 10/10 | 통과 |
| GPT-5.4 nano | 0.52원 | 1.3초 / 1.6초 | 10/10 | 통과 (새 메모를 거의 남기지 않음) |
| Claude Haiku 5.5 | 0.55원 | 2.0초 / 4.5초 | 10/10 | 통과 |
비용은 셋 다 메시지당 1원 미만이라 변별력이 없었고, 응답을 직접 읽어 공감과 대화 흐름이 가장 좋은 Gemini 3.1 Flash-Lite를 주 모델로, 가장 빠른 GPT-5.4 nano를 예비 모델로 정했습니다. 호출은 제공사 SDK 없이 Cloudflare AI Gateway를 거치게 해, 모델 교체가 모델 ID 문자열 하나로 끝나고 장애 시 폴백도 같은 경로에서 일어납니다.
평가는 프롬프트를 다듬는 데도 그대로 썼습니다. Gemini가 사용자가 하지 않은 “사라지고 싶은” 같은 표현을 먼저 꺼낸 사례가 2건 나와, “사라짐·죽음 표현은 사용자가 먼저 말했을 때만”을 규칙에 넣고 다시 돌려 0건을 확인했습니다. 돌 넷이 어미만 다른 같은 말을 한다는 문제도 평가로 드러나, 말투가 아니라 위로 방식을 돌마다 다르게 정의하는 쪽으로 프롬프트를 고쳤습니다.
2.2.0의 대화 장면은 숲 사진 위에 빛줄기·비네트·바람·구름 전환을 SVG로 겹쳐 그립니다. iOS에서는 부드러웠는데 Android에서는 애니메이션이 눈에 띄게 끊겼습니다.
처음에는 Reanimated가 transform·opacity까지 매 프레임 섀도 트리 커밋을 거치는 비용을 의심해, UI 속성을 동기로 갱신하는 정적 플래그를 켰습니다. 효과가 없었습니다.
그래서 추측을 멈추고 실기기(Galaxy M23, release 빌드)에서 직접 쟀습니다. react-native-svg가 SVG마다 화면 픽셀 크기의 비트맵을 만들고 있었고, 숨은 그림까지 합친 이미지 텍스처가 148.7MB로 Android 그래픽 렌더러(HWUI)의 텍스처 한도(124.8MB)를 넘어 매 프레임 텍스처를 내렸다 다시 올리고 있었습니다. 가장 큰 몫은 전환 구름 여섯 장(약 85MB)과 화면 크기 그라디언트였습니다.
경계가 흐릿한 그림은 낮은 해상도로 그려 키워도 티가 나지 않는다는 점을 이용해, Android에서만 SVG를 1/n 크기로 그리고 뷰 scale로 키우는 LowResSvg 컴포넌트를 만들었습니다. 구름은 1/3, 그라디언트는 1/4로 그리고, 글자·얇은 선·돌 윤곽처럼 경계가 또렷한 그림에는 쓰지 않습니다.
| Galaxy M23, release | 이미지 텍스처 | 프레임 중앙값 |
|---|---|---|
| 이전 | 148.7MB | 150ms |
| 구름만 1/3 | 88.4MB | 38ms (전환 구간) |
| 그라디언트까지 1/4 | 미측정 | 체감 정상 |
2.2.1로 배포했습니다. 효과가 없던 첫 조치도 해가 없어 유지하되, 실제 원인이 아니었다는 사실을 결정 기록에 그대로 남겼습니다.
API가 출시 때부터 dev·prod 모두 0.4% 안팎으로 503(exceededCpu)을 냈고, 실패는 대부분 CPU 정확히 10ms에서 끊겼습니다. CPU 한도를 30초로 올려도 사라지지 않았습니다. Cloudflare 지원팀은 isolate 메모리 한도(128MB)를 넘긴 것도 exceededCpu로 기록될 수 있다고 답했습니다.
로컬 workerd에서 힙을 재 보니 시작 직후는 18MB인데, 요청이 쌓이면 80~360MB를 오갔습니다. 원인은 요청마다 new PrismaClient를 만들고 $disconnect()하던 코드였습니다. 쿼리가 없는 요청 하나에도 수 MB씩 쓰레기가 생겼습니다. 예전에 WASM 메모리 누수를 막으려고 들인 방식이었는데, 다시 만드는 일 자체가 메모리를 쓰고 있었습니다.
클라이언트를 D1 바인딩별로 하나만 모듈 전역에 두고 GraphQL 컨텍스트·cron·웹훅이 모두 이것을 쓰게 바꿨습니다. 이 규칙이 다시 깨지지 않도록, 클라이언트를 만드는 모듈 밖에서 new PrismaClient나 $disconnect를 쓰면 테스트가 실패하게 했습니다.
| 측정 (로컬, 순차 300건) | JS 힙 used | workerd RSS |
|---|---|---|
이전 · stones(first: 5) | 21 ~ 302MB | 458 → 681MB |
공유 · stones(first: 5) | 25 ~ 31MB | 252 → 259MB |
이렇게 응답 직전에 끊기는 Worker는 서버 오류 뒤 모든 기기에서 로그인이 풀리던 문제의 원인이기도 했습니다. 토큰 회전을 마친 뒤 응답이 유실되면, 앱이 옛 토큰을 들고 있다가 다음 갱신에서 재사용 공격으로 판정받았습니다. 회전 절차가 응답 유실을 견디도록 고치되, 토큰 도난 탐지는 그대로 유지했습니다.
네트워크 계층이 모든 실패를 문자열 하나로 던져, 시간 초과와 서버 오류에 같은 문구를 보여 주고 있었습니다. 사용자가 할 일은 정반대입니다 — 앞은 내 연결을 확인하고, 뒤는 기다립니다. 실패에 network·server 갈래를 달고, 없는 길·서버 오류·연결 끊김·점검 네 화면을 한 컴포넌트로 묶었습니다. 서버 오류를 받으면 점검 여부를 다시 물어, 쓰는 도중 점검이 시작돼도 화면마다 오류가 나지 않게 했습니다.
배경 사진·구름·상단 알약이 모두 같은 공유 값을 따라 움직여야 해서, 페이저 라이브러리 대신 Gesture Handler + Reanimated로 직접 만들었습니다. 대화 장면은 서버 API보다 먼저 만들었고, API가 붙기 전까지 운영 빌드에서는 보내기 버튼을 꺼 두었습니다 — 고정 문구를 돌의 대답처럼 보여 주면 사용자를 속이는 셈이기 때문입니다.
랜딩 홈을 돌과의 대화 중심으로 다시 구성하고 기기 목업 화면을 코드로 옮겼습니다. 스토어 이름·부제·키워드는 «AI»·«상담» 같은 단어를 일부러 빼고 다시 정리했습니다 — AI 앱으로 팔지 않고, 상담·질환 단어는 심사에서 의료 기능으로 읽힐 수 있기 때문입니다.
| 레이어 | 기술 |
|---|---|
| 네이티브 앱 | React Native 0.86, Expo SDK 57, React 19, TypeScript, Expo Router, Relay, Reanimated 4, Gesture Handler, react-native-svg, expo-blur, Zustand, @gorhom/bottom-sheet, i18next |
| 백엔드 | Cloudflare Workers, Hono, GraphQL Yoga, Pothos, Prisma |
| AI | Cloudflare AI Gateway, Gemini 3.1 Flash-Lite (예비: GPT-5.4 nano) |
| 데이터베이스 | Cloudflare D1 (SQLite), KV |
| 인프라 | Cloudflare (Workers, D1, KV, Durable Objects, Analytics Engine) |
| 어드민 | React 19, Vite, TanStack Router, Tailwind CSS v4, Relay |
| 랜딩 | Astro, Tailwind CSS |
| 이메일 · 알림 | Resend, Expo Push, Discord Webhook |
| 빌드 · 배포 | EAS (Expo Application Services), Cloudflare Pages |
| 모노레포 | Turborepo, Bun |