01
경찰과 도둑
Cops and Robbers- 출시 · 운영 중
2025.10 ~
프론트엔드 (앱 · 웹)
위치 기반 실시간 멀티플레이어 모바일 게임
AWARD -2026 세종 창업 아이디어리그 대상 (SW중심대학사업단 주최)
오프라인 술래잡기 '경찰과 도둑'을 위치 공유와 실시간 동기화로 옮긴 모바일 게임입니다. 프론트엔드의 실시간 통신과 게임 내부 이벤트를 담당해, STOMP 기반 WebSocket으로 로비와 게임의 실시간 채팅과 게임 시스템을 연결했습니다. v3에서는 이 기반을 함께 뛸 사람을 모으는 커뮤니티로 넓혔습니다.
끊겼다 다시 연결된 뒤의 게임 상태
야외에서 뛰며 하는 게임이라, 화면을 끄거나 앱을 전환하면 iOS가 WebSocket 연결을 끊어 그사이 체포나 탈옥 이벤트를 놓치는 문제가 있었습니다. 연결이 끊기면 재연결 화면을 띄우고 자동 재연결 중에도 직접 다시 연결할 수 있게 했지만, 다시 연결된 뒤에도 끊긴 사이의 변화는 반영되지 않았습니다. 그래서 재연결 직후 서버에서 참가자 상태와 도둑의 마지막 위치를 다시 받아 화면을 맞추고, 그 사이에 들어온 실시간 이벤트는 덮어쓰지 않도록 했습니다. 자동 재연결이 모두 실패해 끊긴 채 남는 경우에는, 앱을 켜 둔 상태에서 푸시 알림이 도착하면 네트워크가 살아 있다고 보고 연결을 다시 시도하게 했습니다. 백그라운드에서 연결을 유지하는 작업은 팀원이, 끊긴 뒤의 복구는 제가 맡았습니다.
앱 심사 없이 갱신되는 웹 정본 문서
이용약관 같은 법적 문서 4종이 앱과 웹에 한 벌씩 있어서, 고칠 때마다 사람이 양쪽을 맞춰야 했습니다. 한국어와 일본어, 영어까지 지원하면 관리할 문서는 12벌이 됩니다. 그래서 웹을 단일 정본으로 정하고 앱은 웹뷰로 열도록 바꿨습니다. 문서를 고쳐도 앱 심사를 다시 받지 않아도 되게 만들었습니다. 앱용 화면은 사이트 레이아웃을 타지 않는 Route Handler로 분리해 HTML을 111KB에서 27KB로 줄이고 JS를 0개로 만들었습니다. 웹 분석 스크립트도 빼서 앱 웹뷰 트래픽이 웹 통계에 섞이지 않도록 했습니다. 폰트는 문서에 쓰인 글자만 남겨 760KB를 52KB로 줄이고, 파일명에 내용 해시를 넣어 영구 캐싱했습니다. 팀원 크레딧 화면도 같은 패턴으로 옮겨, 인원이 바뀌어도 웹 배포만으로 앱에 반영되도록 구현했습니다.
설치 없이 체험하는 웹 데모
밖에서 뛰는 게임이라 스크린샷으로는 재미가 전달되지 않고, 직접 해보려면 설치부터 해야 했습니다. 그래서 설치 전에 브라우저에서 바로 체험할 수 있는 데모를 소개 웹사이트에 만들었습니다. 앱 화면을 실측값 그대로 재현한 폰 프레임 안에서, 방 참여부터 QR 체포까지 4개 코스를 끝까지 해볼 수 있습니다. 화면과 말풍선 안내, 진행 단계는 하나의 장면 데이터에서 나옵니다. 셋이 어긋나지 않고, 코스를 늘릴 때도 데이터만 추가하면 되도록 만들었습니다. 사이트의 테마 전환(라이트=경찰, 다크=도둑)은 데모의 진영 선택으로 이어집니다. iOS 사파리에는 요소 전체 화면 API가 없어서, 모바일 전체 화면은 CSS 오버레이로 구현했습니다.
카톡에서 열리지 않던 초대 링크
'카톡에서 초대 링크를 누르면 앱이 안 열린다'는 제보를 받고 직접 재현해 보니, 설치 직후 처음 앱을 열 때만 실패했습니다. 웹과 앱을 나눠 확인하며 두 곳을 고쳤습니다. 웹에서는 인앱 브라우저를 빠져나가 설치된 앱을 바로 열고, 앱이 없을 때만 스토어로 안내하게 했습니다. 앱은 첫 실행이 늦어지면 들어온 링크를 놓치고 있어서, 늦더라도 목적지를 기억해 두었다가 열리도록 바꿨습니다.
검색되지 않는 이름 - 노션으로 쓰고 동기화로 읽는 팀 블로그
'경찰과 도둑'은 누구나 아는 놀이 이름이라 검색 결과에서 서비스가 밀린다고 판단해, 검색 유입을 만들 팀 블로그를 제안하고 개발했습니다. 처음에는 마크다운으로 글을 쓰는 에디터를 직접 만들어 팀에 보여줬는데, 따로 만든 페이지의 처음 보는 화면에서 글을 쓰기 어렵다는 이야기가 나왔습니다. 글을 쓰는 사람이 개발자, 마케터, 디자이너로 다양했고 모두에게 익숙한 도구는 노션이었기에, 평소 쓰던 방식으로 써야 글이 꾸준히 쌓인다고 보고 노션을 CMS로 바꿨습니다. 노션으로 옮긴 직후에는 화면을 열 때마다 노션을 직접 읽었습니다. 노션 이미지 주소는 약 1시간 뒤 만료되어 요청마다 새 주소를 받아 원본을 내려받고 변환해야 했고, 캐시가 있으면 0.1초도 걸리지 않던 이미지가 캐시가 비어 있으면 2초 넘게 걸리기도 했습니다. 방문자가 적어 캐시는 자주 비었습니다. 그래서 발행할 때 한 번만 노션을 읽어 본문은 Supabase에, 이미지는 R2에 옮기고, 화면은 저희 저장소만 읽으면서 ISR로 캐시하도록 바꿨습니다. 이미 팀이 쓰던 사이트라 기존 화면을 유지한 채 동기화를 붙이고, 데이터가 쌓이는 것을 확인한 뒤 화면을 옮겼습니다. 발행은 개발 도구를 몰라도 누를 수 있게 어드민 버튼으로 만들고, 6시간마다 도는 자동 동기화로 보완했습니다.
서울게임타운 부스 - 세계관을 체험하는 오프라인 빌드
인디게임 전시회 서울게임타운에 부스 참가를 직접 신청해 성사시키고, 현장에서 유저들의 경험을 직접 들었습니다. 위치 기반 오프라인 게임이라는 특성에 맞춰 행사용 이벤트 게임 로직과 미니게임을 새로 만들고, 웹캠 촬영부터 Canvas 프레임 합성, Vercel Blob 업로드, QR 발급까지 전부 브라우저에서 처리되는 포토부스를 구현했습니다. Upstash Redis 정렬 집합 기반 리더보드로 닉네임별 최고 점수를 관리해, 방문자가 서비스 세계관을 간접 체험할 수 있게 부스를 구성했습니다. 하루 동안 300명이 다녀갔고, 가입자가 60명 늘었습니다.
일본으로 넓히며, 지표에서 막힌 곳 찾기
출시 이후에는 어드민 페이지를 만들고 GA4와 스토어 지표로 운영과 성장을 지켜봤습니다. 일본어 블로그는 한국어 글을 번역해 올리지 않고, 노션에 언어 속성을 두어 목록을 나눈 뒤 각 언어로 글을 따로 썼습니다. 일본어 스토어 페이지를 연 5월부터 3개월 동안 일본 App Store 노출은 2,520회로 한국(2,483회)과 비슷해졌지만, 노출된 사람 중 페이지를 연 비율은 일본 5.6%, 한국 15.6%로 3분의 1 수준이었습니다. 같은 시기 앱 안에서는 공지사항과 자동으로 만들어지는 닉네임이 일본 사용자에게도 한국어로 나오고 있었습니다. 이 지표와 조사를 팀 발표로 정리해 두 곳의 국제화부터 제안했고, 공지 다국어는 설계와 백엔드 구현에 직접 참여했습니다.
서비스마다 흩어져 있던 운영 지표
행사를 열거나 앱을 업데이트한 뒤 사용자가 실제로 늘었는지 보려면 인스타그램, GA4, App Store Connect, Google Play, 게임 서버를 하나씩 열어 봐야 했고, 서비스마다 필요한 권한도 달랐습니다. 9월 말 사업계획서에 넣을 지표를 서비스마다 내려받아 손으로 맞춰 보고 나서야, 서울게임타운에서 가입한 사람들이 이후 4주 동안 연 게임이 4판뿐이었다는 것을 알았습니다. 이런 확인을 행사 때마다 손으로 반복할 수는 없다고 판단해, 매일 아침 지표를 한 번 모아 저희 데이터베이스에 쌓고 어드민 지표 화면은 그 기록을 읽는 구조로 만들었습니다. 화면을 열 때마다 각 서비스에서 바로 가져오지 않은 것은 App Store와 인스타그램은 일정 기간이 지나면 지난 데이터를 다시 받을 수 없고, 서비스마다 며칠에 걸쳐 값이 확정되며, Google Play는 설치 통계를 API로 제공하지 않았기 때문입니다. 가입이 하루 몇 명인 규모에서는 행사 효과를 나중에 통계로 추정하기 어려워서, QR과 링크에 붙인 꼬리표(UTM)가 스토어까지 이어지게 해 어느 경로로 들어왔는지 처음부터 나눠 볼 수 있게 했습니다. 기간 비교도 처음에는 바로 앞의 같은 길이 기간과만 했는데, 예를 들어 행사가 있던 5일을 그 앞 5일과 비교하면 두 기간에 낀 주말 수가 달라질 수 있어 주말에 몰리는 지표를 공정하게 비교하기 어려웠습니다. 그래서 이전 기간, 요일을 맞춘 기간, 직접 고른 기간 중에서 비교 대상을 고를 수 있게 하고, 지금 어떤 날짜끼리 비교하는지 화면에 항상 표시했습니다. 매주 월요일 아침에는 주요 지표가 팀 디스코드로 전달됩니다.
이름과 다른 것을 세고 있던 지표들
지표를 한곳에 모으고 보니, 이름만 보고는 무엇을 세는지 알 수 없는 지표가 많았습니다. 스토어에서 앱을 본 사람 수만 해도 App Store의 '노출'은 이미 앱을 설치한 사람까지 세고, Google Play의 '방문자'는 앱이 없는 사람만 셉니다. 둘을 더하면 어느 쪽 의미도 아닌 값이 되어 두 스토어는 나란히 보여 주고, 방문에서 설치까지의 단계별 비율도 단계마다 세는 대상이 달라 계산하지 않았습니다. 사이트의 '다운로드 버튼 클릭'도 99번 중 79번은 버튼을 누른 것이 아니라, 행사 QR이나 링크트리 링크로 들어와 자동으로 스토어로 넘어간 방문이었습니다. 이 방문을 빼고 계산하자 사이트 방문 중 스토어로 넘어간 비율인 사이트 전환율은 19.6%가 아니라 3.8%로, 실제보다 다섯 배 넘게 높게 보고 있었습니다. 지표 이름도 실제로 일어난 일에 맞춰 '스토어로 이동'으로 바꿨습니다. 앱의 게임 시작 기록도 4주에 311번이었지만 같은 기간 방 생성은 79번이어서, 앱 코드를 따라가 보니 한 판에 참가자 수만큼 기록이 남고 있었습니다. 그래서 판 수는 이 기록이 아니라 백엔드의 게임 기록에서 따로 세도록 바꿨습니다.
Flutter / Dart / STOMP over WebSocket / Riverpod / Next.js / Notion API / Supabase / Cloudflare R2 / Vercel Blob / Upstash Redis / GitHub Actions / GA4














