iiny짱 돌림판
질문과 선택지만 넣으면 일러스트 속 캐릭터가 돌림판을 돌려 정해 주는 웹 앱입니다. 그림과 결과가 어긋나지 않게 하고 가입 없이도 내 것만 보이게 한 과정을 정리했습니다
점심 메뉴나 커피 살 사람처럼 반복해서 정하는 작은 일을 질문과 선택지만 써서 맡기는 돌림판입니다. 일러스트 속 캐릭터 iiny짱이 돌림판을 돌리고 결과를 말풍선으로 알려 줍니다. 계정이 없고 화면은 빌드 도구 없는 HTML과 JavaScript입니다. 작은 앱이지만 정하는 일을 맡기는 도구여서 두 가지를 먼저 정했습니다. 멈춘 칸과 결과가 달라서는 안 되고 남이 내 돌림판을 볼 수 없어야 합니다.
한 장의 일러스트를 레이어로 나누기
출발점은 완성된 일러스트 한 장이었습니다. 윙크한 얼굴에 돌림판까지 그려진 그림이라 그대로는 눈도 팔도 움직일 수 없습니다. 처음에는 두 마디 팔을 역기구학으로 움직이는 벡터 대역으로 기능부터 만들었습니다. 그림 수준이 맞지 않아 원본을 레이어로 나누는 방식으로 바꿨고 대역과 역기구학 코드는 걷었습니다. 원본 좌표(1254×1254)에서 손으로 잰 윤곽으로 팔과 손과 토끼 손잡이를 떼어 내고 그 자리를 메운 바탕을 만듭니다. 윙크한 눈은 반대쪽 뜬 눈의 얼굴축 대칭으로 바꿔 양눈이 뜬 얼굴을 만들고 깜빡임용 감은 눈 둘을 따로 둡니다. 도구는 Pillow와 numpy뿐입니다. 팔 레이어는 세로 띠 단위로 변형해서 손이 돌림판 테두리를 따라 당기고 밀고 몸에 붙는 선의 가중치를 0으로 두어 이음매가 보이지 않습니다.
머리카락과 리본과 옷의 흔들림에서는 조각을 오려 겹치는 방식을 버렸습니다. 잔상과 구멍과 이음매가 생기기 때문입니다. 바탕 그림 자체를 WebGL 섀이더로 휘게 합니다. 오른쪽 머리와 왼쪽 머리와 리본과 보타이와 옷자락과 숨쉬기 여섯 부위마다 시작이 0이고 끝이 1인 가중치 맵을 두었고 두 사인파를 섞은 파동이 시작에서 끝으로 흐릅니다. 세기는 쉴 때 가볍고 돌릴 때 조금 더 큽니다. 결과가 나온 직후에는 한 번 출렁입니다. 글씨와 랩탑과 가리키는 손과 눈 자리에는 가중치의 상한을 두었고 테스트가 그 값을 확인합니다. 시간차 스크린샷으로 잰 쉬는 동안의 움직임은 머리 끝에서만 나타났고 글씨와 랩탑과 얼굴은 0이었습니다. WebGL을 못 쓰는 브라우저에는 정지한 그림이 그대로 남습니다.
창이 넓으면 정사각형 일러스트의 양옆이 비어서 좌우 배경이 따로 필요했습니다. 처음에는 가장자리 색을 이어 절차적으로 그렸는데 반투명한 안개처럼 보였습니다. 그래서 원본 가장자리를 문맥으로 삼아 Stable Diffusion XL 인페인팅이 좌우를 걸음마다 넓히는 방식으로 바꿨습니다. 문맥에 있던 손글씨는 미리 지워 가짜 글자를 막았고 이음매 색은 원본 가장자리에 맞췄습니다. 다음 판에서는 배치를 먼저 코드로 그려 가이드로 주고 인페인팅이 그 위에 디테일을 입힙니다. 왼쪽은 열람실이고 오른쪽은 칠판 끝과 책장입니다. 마음에 드는 그림이 한 번에 나오지 않아서 여섯 번째 판까지 고르고 다시 만들기를 반복했습니다.
그림과 결과가 어긋나지 않게
돌림판은 그림과 결과가 같아야 쓸모가 있습니다. 한식 칸에 멈췄는데 결과가 중식이면 도구로서 끝입니다. 그래서 칸의 크기와 포인터 아래의 판정과 추첨이 같은 누적 비율표 한 곳을 읽습니다. 선택지마다 비율(%)을 줄 수 있고 그 비율이 곧 칸의 각도라서 칸이 넓은 선택지가 정확히 그만큼 뽑힙니다.
멈추는 방식도 결과가 먼저입니다. 두 번째로 Space를 누르면 당첨 칸을 먼저 정하고 그 칸 안에서 양끝 12%를 뺀 자리를 목표 각도로 잡습니다. 현재 각속도에서 일정한 감속으로 정확히 그 각도에 서도록 거리와 시간을 계산하고 최소 세 바퀴와 3.5초를 보장합니다. 속도가 끊기지 않아서 꺾여 멈추는 어색함이 없습니다. 물리 오차와 관계없이 분포는 설정한 비율을 따릅니다. 비율대로 2만 번을 돌린 분포를 카이제곱으로 확인하는 테스트가 이 계산을 지킵니다.
처음에는 이 추첨을 브라우저가 했습니다. 그러면 결과를 고를 길이 열려 있어서 추첨을 서버로 옮겼습니다. 두 번째 Space가 서버의 추첨 호출을 부르고 그 호출이 곧 저장입니다. 서버는 48비트 암호 난수로 같은 비율을 같은 순서로 누적해 칸을 고르고 결과를 한 트랜잭션에 기록합니다. 호출하는 쪽이 결과를 지정할 길은 없습니다. 요청마다 붙는 번호가 같으면 처음 결과를 돌려주므로 응답을 잃고 다시 보낸 요청이 두 번 뽑지 않습니다. 오프라인에서는 브라우저가 뽑고 검증 안 됨으로 표시해 두었다가 연결이 돌아오면 대기열이 보냅니다.
로그인 없이 내 것만 보이게
가입 절차를 두면 점심 메뉴를 정하는 일에는 너무 무겁습니다. 그래서 첫 응답이 서버가 서명한 무작위 쿠키를 주고 DB에는 그 쿠키의 sha256만 둡니다. 모든 조회와 변경이 그 값으로 걸러지고 남의 돌림판에는 없는 것과 같은 404가 돌아갑니다. DB가 유출돼도 남의 브라우저 행세를 할 수 없습니다. 대가는 분명합니다. 쿠키를 지우면 그 데이터에 다시 닿을 길이 없고 새로 입력해야 합니다. 이 한계는 화면 아래에 계속 표시합니다.
접속 IP와 브라우저 정보는 결과에 남기지 않습니다. 쓰는 곳 없이 개인정보만 쌓이는 칸이어서 저장을 멈추고 이미 있던 값도 지웠습니다. 서명 비밀이 약하면 서버가 기동을 거부합니다. 모든 응답은 같은 출처만 허용하는 CSP를 달고 나갑니다. 인라인 스크립트와 스타일을 걷어 내고 글꼴은 자체 호스팅합니다. 상태를 바꾸는 요청은 JSON과 같은 출처만 받고 IP별 시간당 쓰기 한도가 있습니다. 보안 점검과 코드 리뷰를 하루 사이에 두 번씩 돌려 나온 권고를 반영했습니다.
구경 주소
돌림판을 만들면 추측할 수 없는 구경 주소가 함께 생깁니다. 주소를 받은 사람은 같은 돌림판이 돌아가는 모습을 읽기 전용 화면으로 실시간으로 봅니다. 만든 사람의 화면이 상태와 회전과 결과와 커서와 편집 과정을 이벤트 묶음으로 서버에 보내면 서버가 구경 연결 모두에 SSE로 중계합니다. 회전은 계획을 함께 보내 구경 화면도 같은 칸에서 멈춥니다.
서버는 이벤트를 모양과 길이로만 거르고 모르는 종류는 버립니다. 글은 평문으로 나르고 구경 화면이 textContent로만 쓰므로 만든 사람이 보낸 임의의 JSON이 구경꾼에게 흘러가지 않습니다. 중계 상태는 서버의 메모리에만 있어서 서버를 다시 띄우면 처음부터입니다. 지금 보는 사람 수는 그림 속 칠판에 손글씨로 적힙니다. 결과 고정은 되돌릴 수 없고 고정하면 만든 사람도 다시 돌리거나 고칠 수 없습니다. 서버가 423으로 거절합니다.
Cloudflare로 옮기기
처음에는 Node 서버 하나가 돌았습니다. 공개하려고 Cloudflare로 옮기면서 앱 몸통을 런타임에 중립인 모듈로 떼어 냈습니다. 라우팅과 쿠키 소유와 CSRF와 속도 제한과 구경 중계가 그 모듈이고 파일·os·http·sqlite 같은 Node 전용 모듈은 들이지 않습니다. 껍데기는 Node용과 Worker용 둘이고 Worker 쪽은 Durable Object 하나가 SQLite로 같은 앱을 돌립니다. Fetch의 요청과 응답을 Node의 req·res 꼴로 옮기는 어댑터가 SSE 스트림까지 맡습니다. 정적 파일은 Static Assets가 내고 보안 헤더는 Worker가 붙입니다. 옮긴 날 테스트 272건과 로컬 workerd 스모크 테스트가 통과했습니다.
이 구성의 한계는 아직 재지 않았습니다. 모든 요청이 Durable Object 하나로 가므로 이용이 늘면 병목이 될 수 있습니다. 지금 규모에서는 문제가 보이지 않았을 뿐 어디까지 버티는지는 모릅니다.