Search ondelva

이틀 만에 URL 단축기를 만들었다

전날 밤 11시에 저장소를 만들고 다음날 저녁에 snipp.cc가 떴다. 무료 티어의 하루 KV 쓰기 1,000건이 기능 목록을 대신 잘라줬다.

Share

nomadLab에 작은 도구를 하나씩 붙이고 있는데 이번 차례는 URL 단축기다. 어제 밤 11시에 저장소를 만들었고 오늘 저녁에 snipp.cc가 떴다.

이름은 Snip. snip.cc는 이미 팔려 있어서 p를 하나 더 붙였다.

Snip 홈. 긴 주소를 넣는 칸 하나가 화면의 전부다.

도메인을 따로 산 이유는 짧아서가 아니라 평판 격리다. nomadlab.cc를 데려가지 않게 하려고 떼어 놨는데, 무료 단축기는 예외 없이 피싱에 쓰이고 도메인이 차단 목록에 오르면 그 호스트의 링크가 전부 같이 죽는다.

그래서 남용 대응이 v1의 필수 범위가 됐고 나중 과제로 미룰 수가 없었다.

무료 한도가 대신 잘라낸 기능 목록

Cloudflare Workers + KV 위에 올렸다. 무료 티어가 주는 것은 요청 100,000/일, KV 읽기 100,000/일, 그리고 KV 쓰기 1,000/일.

병목은 쓰기다. 링크 하나 만드는 게 쓰기 1회니까 하루 새 링크 1,000개가 무료 운영의 상한이고 여기서 기능 몇 개가 알아서 떨어져 나갔다.

클릭 통계는 안 만든다. 클릭마다 KV에 쓰면 링크 하나가 하루 1,000클릭을 받는 순간 계정 전체의 예산이 끝난다. 만료가 없는 것도 여기서 나왔는데 안 쓰인 링크를 회수하려면 마지막 접근 시각을 적어야 해서 같은 이유로 탈락이었다.

캐시도 안 깐다. 캐시 히트도 요청으로 집계돼서 한도를 1도 안 늘려주는데, 302를 캐시하면 죽인 링크가 TTL 만료까지 계속 산다. Cache API 대신 KV 읽기에 cacheTtl만 준다.

한도를 넘으면 서비스가 선다. 무료 플랜은 자동 업그레이드가 안 돼서 청구서가 오는 일이 없고 쓰기가 초과되면 그 작업이 실패하고 요청이 넘으면 Error 1027이다. 그래서 한도에 닿으면 생성만 429로 막고 리다이렉트는 살리기로 했다. 이미 뿌려진 링크가 죽는 쪽이 새 링크를 못 만드는 것보다 훨씬 나쁘다.

문제는 오늘 쓰기를 얼마나 썼는지 요청 시점에 읽을 방법이 문서에 없다는 것이었다. 직접 세려면 카운터를 KV에 써야 하는데 그 쓰기가 바로 아끼려던 자원이다. 결국 put()이 실패하면 그때 429로 바꾼다. 한도를 넘었다는 사실을 넘어보고 나서 아는 셈이다.

살아 있는 리미터를 죽었다고 보고했다

IP당 60초에 5건짜리 rate limit 바인딩을 붙이고 확인 절차를 이렇게 적어뒀다. 60초 안에 6번을 보내서 6번째가 429면 살아 있는 것.

실측하니 6번이 전부 통과했다. 죽은 줄 알았다.

카운터가 머신별로 캐시되고 비동기로 동기화되기 때문이다. 틀린 건 내 확인 절차였는데 문서에도 permissive하고 eventually consistent하니 정확히 세는 용도로 쓰지 말라고 적혀 있다.

한 주소에서 40연발을 보내니 첫 429가 27번째에 나왔고 40번 중 7번만 막혔다. 82%가 통과했다. 방지책이 아니라 과속방지턱이었다.

82%면 1,200번쯤에서 그날의 1,000건이 끝나니까 한 사람이 30초로 그날의 서비스를 닫을 수 있었다.

층이 늘어서 줄어든 코드

rate와 daily는 애초에 다른 단위였다. 창을 아무리 조여도 “하루 N건”이 안 나오고, 나오게 조이면 1건에 86초라 도구를 못 쓴다.

그래서 둘로 나눴다. 존의 rate limiting rule이 /api/links에 주소당 10초 1건을 걸고 Durable Object가 주소당 하루 50건을 센다. 존 규칙은 워커보다 앞에서 막아서 요청 수 예산을 지킨다. DO는 실제 KV 쓰기 직전에 세니까 여기서 지켜지는 건 쓰기 예산이다.

5건/60초 바인딩은 지웠다. 위가 10초를 잡고 아래가 하루를 잡으면 그 사이에서 표현할 창이 없다. 층이 늘면서 코드가 줄었다.

WAF를 처음에 기각했던 사유가 틀렸다는 것도 이때 알았다. Method 필드가 없어서 POST만 겨냥할 수 없다고 적어두고 폐기했는데, 생성이 /api/links 전용 경로라 Path 하나면 충분했다. Path는 무료 존에서 쓸 수 있는 필드다. 문서를 읽고 결론을 내린 뒤에 그 결론을 다시 안 읽은 게 원인이었다.

Durable Objects가 2025년 4월부터 무료 플랜에서 된다는 것도 이때 확인했다. 하루라는 창을 적을 수 있는 수단은 이것 하나뿐이었다.

두 층은 정확도가 다르다. DO는 강한 일관성이라 로컬에서 51번째가 정확히 429였고 존 규칙은 연속 3회를 보내면 2번째가 통과하고 3번째가 막혔다. 지키지 못할 정밀도를 화면에 적으면 그 문장이 곧 버그 리포트가 되니까 화면 문구도 다르게 썼다. 하루치는 all 50 of its links for today처럼 숫자를 그대로 박고 10초 쪽은 about ten seconds로 얼버무린다.

리다이렉트 경로에는 아무것도 안 건다. NAT 하나 뒤에서 여럿이 같은 링크를 여는 게 정상인데 그걸 막으면 서비스가 끝난다. 이 설정에서 제일 위험한 오조작인 것 같다.

설정이 거짓말을 했다

커스텀 도메인을 붙이고 홈 소스를 봤더니 </body> 앞에 저장소에는 없는 cloudflareinsights.com 스크립트가 붙어 있었다.

curl 기본 요청으로는 안 보이고 브라우저 UA에 Accept: text/html을 붙일 때만 나온다. 분석 태그를 안 붙이겠다고 문서에 적어둔 게 그대로 깨졌고 페이지뷰마다 나가는 비콘이 요청 예산까지 먹는다.

더 이상한 건 설정 조회 쪽이었다. 존 설정의 rum은 내내 off를 돌려주는데 주입은 계속됐다. 같은 값 off로 명시적으로 PATCH를 보내니 그제서야 꺼지더라. 아무래도 설정된 적 없는 기본 상태가 off로 보고되면서 실제 동작은 켜져 있었던 것 같다.

도메인을 새로 붙일 때마다 이 짓을 한다고 봐야겠다. 그래서 확인은 설정 조회 말고 응답 본문을 직접 grep하는 것으로 적어뒀다.

홈에 몇 명이 왔다 갔는지는 결국 Umami를 한 줄 붙여서 본다. 홈은 정적 자산이라 워커 대시보드에 한 줄도 안 남기 때문인데, <script> 한 줄은 저장소에 보이고 지우면 지워진다는 게 컸다.

curl을 알아야 쓸 수 있는 약속

계정이 없으니 자기 링크를 죽이는 수단은 삭제 키 하나뿐이다. 결과 화면에서 딱 한 번 보여주고 잃어버리면 복구 경로가 없다는 것도 그 자리에 적었다.

결과 화면. 짧은 링크와 삭제 키가 입력 칸이 있던 자리에 그대로 나온다.

그런데 처음엔 화면에 Authorization: Bearer 헤더를 붙여 DELETE를 보내라고 안내만 해뒀다. 실수로 공유한 링크를 되돌릴 수 있다는 게 이 키의 존재 이유인데, 그걸 쓰려면 curl을 알아야 했으니 약속을 반만 지켰다.

짧은 링크와 키를 받는 폼을 카드 아래에 뒀다. 엔드포인트는 안 늘렸고 기존 DELETE /{slug}를 그대로 부른다. API 안내는 그 폼 밑에 접어서 남겼다.

링크 내리기 카드. 짧은 링크와 키를 넣으면 지워진다.

한 번은 이 폼을 FAQ와 같은 아코디언으로 만들었다가 되돌렸다. 접힌 줄로 나란히 놓으니 질문 항목 하나로 읽히더라. 이건 이 서비스의 두 번째 기능이다.

하나뿐인 화면에 자를 댔다

링크가 만들어질 때 세로 가운데 정렬 때문에 화면이 171px 위로 튀었다. idle이 200px이고 result가 562px인데 두 상태를 같은 중심에 놓을 방법은 없다. 가운데 정렬을 버렸다.

짧은 링크에서 https://를 뺀 것도 재보고 내린 결정이다. 스킴이 붙은 23자는 24px Geist Mono에서 339.84px인데 320px 화면의 가용폭이 238px이라, 카드 여백을 0으로 만들어도 한 줄에 안 들어간다. 지금 남는 여유가 11.44px이라 슬러그가 한 글자만 늘어도 줄이 깨진다. 그래서 그 숫자를 문서에 적어뒀다.

404도 고쳤다. 처음엔 오류 한 줄이었는데, 거기 떨어지는 사람은 대부분 Snip을 처음 본다. 남이 준 링크를 눌렀더니 죽어 있었을 뿐이라, 오류 문장만 두면 자기가 어디에 떨어진 건지도 모르고 나간다. 마크와 워드마크, 왜 안 되는지 한 줄, 직접 만들 수 있다는 링크 셋만 뒀다. 죽은 링크 하나 보는 데 폰트 70KB를 받게 할 수는 없으니 서체는 시스템 스택으로 갔다.

못 막는 것과 남은 것

목적지 검사는 어휘적이라 이름을 해석하지 않는다. 그래서 192-168-1-1.nip.io 같은 와일드카드 DNS는 통과한다. 해석하려면 생성마다 서브리퀘스트가 붙고 DNS rebinding에는 어차피 무력해서 받아들이기로 하고 문서에 적었다. 워커가 목적지를 fetch하지 않아서 우리 인프라에 대한 SSRF는 아니다. 방문자 브라우저에서 그의 LAN으로 가는 링크다.

주소를 돌리는 분산 소각도 두 층 다 못 막는다. 그 선을 넘으려면 Turnstile인데 그러면 문서화된 API를 프로그램으로 쓰는 길이 막힌다. 남용이 실제로 그 모양으로 오면 그때 다시 보기로 했다.

3개월 뒤에 하루 생성 10건이 꾸준한지를 본다. 미달이면 새 링크 생성을 닫고 기존 리다이렉트만 유지하다가 내린다. 안 쓰이면 내린다는 게 nomadLab의 전제다.

쓸 일이 있으면 snipp.cc에 있다. 계정도 설치도 없다.

여튼 이틀 걸렸는데 코드는 600줄이 안 된다. 나머지 시간은 전부 “이건 왜 안 하는가”를 문서에 적는 데 썼다.

Keep reading