디스코드 주문 데스크
작은 온라인 서비스 가게의 주문 접수를 디스코드 안에서 돌리는 봇: 티켓 스레드, 구글 시트 장부, 웹 API
- 기간
- 2026년 5월 – 지금
- 대상
- 디스코드로 주문을 받는 작은 온라인 서비스 가게
- 역할
- 1인 개발: 설계, 개발, 배포, 운영
- 기술
- TypeScript, Bun, discord.js 14, Google Sheets API, SQLite, @napi-rs/canvas, Docker, Cloudflare Workers
- 규모
- 커밋 464개, 앱 코드 약 3.9만 줄, 테스트 약 2.2만 줄 (1,650개 이상 통과), 일회성 복구·검증 스크립트 약 5.1만 줄
- 외주 작업, neoworks
다른 언어: english
어떤 프로젝트인가요
온라인으로 서비스를 파는 작은 가게가 모든 주문을 디스코드로 받아요. 서버는 두 개인데 백오피스는 하나를 같이 써요. 손님이 버튼을 누르고 짧은 양식을 채우면, 봇이 그 손님만 보는 비공개 티켓 스레드를 열어요. 주문은 직원들이 일하는 구글 시트에 행으로 들어가요. 봇은 주문마다 대기 순번과 예상 대기 시간을 계산하고, 대기 현황 보드 이미지를 계속 갱신하고, 직원이 스레드에서든 가게 웹사이트에서든 주문을 시작, 수정, 마감, 취소할 수 있게 해 줘요.
5월에 시작할 때는 채널 정리 도구 몇 개, 견적 계산기 두 개, 시트에 행 하나를 추가하는 명령어 하나가 전부였어요. 요청이 하나씩 들어오면서 이 가게의 주문 데스크가 됐어요.
어려웠던 부분
이 중에 테스트에서 드러난 건 거의 없어요. 전부 실제 물량이 몰릴 때 드러났어요.
디스코드는 서버당 활성 스레드를 1,000개까지만 허용해요. 티켓 하나가 스레드 하나이고, 스레드는 7일 동안 활성으로 남아 있었어요. 어느 날 저녁, 제가 만든 일괄 공지 기능이 열린 티켓마다 메시지를 하나씩 넣으면서 700개 넘는 스레드의 유휴 타이머를 한꺼번에 리셋했어요. 서버가 한도에 닿았고, 약 10.5시간 동안 새 티켓이 전부 실패했어요. 지금은 티켓이 24시간 유휴 상태면 자동으로 보관되고, 스위퍼가 30분마다 돌아요. 티켓을 만들기 직전에 한도가 가까우면 비상 스윕이 먼저 유휴 3일, 2일, 1일 순서로 사다리를 내려가요. 보관만 하고 삭제는 하지 않아서, 오래된 티켓에 누가 글을 쓰면 다시 열려요.
구글 시트가 같은 행을 두 번 내줘요. values.append는 마지막 행을 읽고 그 뒤에 삽입하기 때문에, 두 요청이 동시에 들어오면 같은 행 번호를 받아요. 테스트용 시트에서 동시에 6건을 넣어 보니 행 번호가 2, 4, 4, 4, 3, 3으로 돌아왔고, 3개 행의 수식 칸이 영영 빈 채로 남았어요. 지금은 탭마다 쓰기 잠금 하나를 거치고, 행을 찾아서 쓰는 전체 과정 동안 그 잠금을 쥐고 있어요.
한 시간에 티켓 95개. 평소에는 2~15개예요. 시트가 30분 동안 429를 돌려줬어요. 한도는 분당 읽기 60번인데, 신청 한 건이 시트를 14번 읽고 있었어요. 지금은 읽기가 짧은 캐시와 single-flight를 거치고, 작은 범위들은 요청 하나로 합쳤고, 429가 나면 지터를 섞어서 물러나요.
버튼 클릭이 시간 초과로 죽었어요. 디스코드는 봇이 클릭에 3초 안에 답해야 해요. 30초마다 도는 상태 스윕이 열린 초안마다 장부 전체를 다시 훑고 있었어요. 한 번 돌 때마다 약 2,230만 행을 방문했어요. 그동안 봇이 최대 5.6초씩 멈췄고, 그 사이에 들어온 클릭은 전부 죽었어요. 스윕마다 색인을 한 번만 만들게 해서 60ms로 줄였고, 예전 방식과 새 방식을 나란히 돌려 결과가 같다는 걸 테스트로 못 박았어요.
티켓 번호가 뒤로 갔어요. 다음 주문 번호는 살아 있는 채널과 활성 스레드 중 가장 큰 번호로 정하고 있었어요. 그런데 보관된 스레드는 거기에 안 보이고, 제 스위퍼가 스레드를 많이 보관하고 있었어요. 그래서 주문 번호 11개가 두 번씩 나갔고, 초안 두 개가 장부의 같은 행을 차지했고, 실제 취소 한 건이 55초 뒤에 쌍둥이 초안에 의해 되돌려졌어요. 지금 티켓 번호는 서버마다 저장된 최고 기록이고, 올라가기만 해요.
88초, 359초짜리 멈춤. 제 모니터는 스왑 탓이라고 했어요. 그런데 컨테이너는 스왑이 꺼져 있었고, 카운터를 보니 익명 페이지 refault는 0번, 파일 페이지 refault는 102만 번이었어요. 호스트가 메모리 압박 속에서 봇 자신의 코드 페이지를 내쫓고 있었고, 그 코드를 다시 실행하려면 바쁜 디스크에서 다시 읽어 와야 했어요. 컨테이너에 메모리 예약을 걸어서 고쳤고, 모니터도 둘을 구분하게 고쳤어요.
시트가 더 이상 데이터베이스가 아니에요
9월에 주문을 SQLite로 옮기고, 시트는 사본이 됐어요. 대기열 열은 원래 append가 매번 건드리는 그 탭에 있는 배열 수식 8,172칸이었어요. 행마다 자기 위의 모든 행을 훑어서, 비용이 행 수의 제곱으로 늘어났어요. 지금은 함수 하나가 대기 순번과 대기 시간을 계산하고, 봇이 20초마다 그 열들을 값으로 다시 써요. 전환 전에 실제 3,798행을 비교해서 불일치 0건을 확인했어요.
그 밖에 들어 있는 것들
- 뒤에서 마저 끝나는 시트 쓰기를 디스크에 남기는 저널. 이게 생기기 전에는 재기동 한 번에 밀려 있던 쓰기 250건이 사라졌어요.
- 가게 웹사이트용 웹 API: 결과를 바로 돌려주고, 요청 id로 재시도를 안전하게 만들고, 오류 코드는 고정이에요. 공개 진입점은 요청을 그대로 넘기기만 하는 Cloudflare Worker예요.
- 새 주문은 웹사이트로도 보내는데, 접수를 절대 붙잡지 않아요. await 없이, 3초 타임아웃, 동시에 최대 8건.
- 대기 현황과 주문 순위 보드는 @napi-rs/canvas로 png를 그려서 같은 메시지를 계속 수정해요.
지금은
두 서버 모두에서 운영 중이고 매일 쓰여요. 주문 DB에는 주문 약 5,900건과 웹사이트에서 온 명령 약 4,900건이 있고, 큰 서버의 티켓 번호는 7,500 가까이 와 있어요. 커밋 대부분이 같은 흐름을 따라요. 가게의 제보, 실제 프로세스에서의 측정, 수정과 회귀 테스트예요. 스크립트 5.1만 줄도 대부분 그 측정이에요. 재현, dry run, 그리고 실제 시트를 읽기만 하는 확인이에요.