~/tools/paper-revision을 만드는 일 자체는 오히려 쉬운 축에 속했다. 진짜 오래 걸린
건 로컬호스트에서 되던 걸 프로덕션에서도 똑같이 동작하게 만드는 일이었고, 버그
대부분은 AI 로직이 아니라 그 주변을 감싼 "배관"에 있었다. 실제로 터진 것들은 다음과
같다.
침묵의 502: SSE 연결에는 심장박동이 필요하다
BYOK로 등록한 Anthropic API 키로 리뷰가 길어지면 가끔 100초쯤 지나 실패하곤 했다 — 에러 메시지도 없이, 그냥 연결이 끊겨버렸다. Vercel 로그에는 깔끔한 502만 찍힐 뿐, 실제 원인을 가리키는 건 아무것도 없었다.
진짜 문제는 이랬다: 모델은 생성 도중 한동안 조용해질 수 있다(눈에 보이는 첫 토큰이 나오기 전, 내부적으로 긴 "사고" 과정을 거치는 구간). 그리고 응답 스트림이 너무 오랫동안 바이트를 전혀 보내지 않으면, 클라이언트와 모델 사이 어딘가에 있는 중간 계층이 연결을 유휴 상태로 판단하고 끊어버린다. 해결책은 심장박동(heartbeat)이었다 — 일정한 간격으로 스트림에 눈에 보이지 않는 문자를 하나씩 써 넣어서, 바이트가 끊기지 않고 계속 흐르게 만드는 것이다:
const HEARTBEAT_MS = 15_000
const HEARTBEAT_CHAR = '' // 폭이 0인 공백 문자, 렌더링된 마크다운에서는 보이지 않음
const result = await Promise.race([pending, heartbeat])
if (result === 'heartbeat') {
controller.enqueue(encoder.encode(HEARTBEAT_CHAR))
continue
}이 문제를 추적하는 과정에서 그 뒤에 숨어 있던 더 심각한 버그도 함께 드러났다: abort
핸들러가 이름이 AbortError인 예외를 전부 붙잡아서 사용자가 의도적으로 취소한
것으로 처리하고 있었다 — 네트워크가 스스로 죽어서 발생한 예외까지 포함해서 말이다.
진짜 실패들이 소리 없이 삼켜지고 있었던 것이다. 이를 고치려면, 에러의 이름을
곧이곧대로 믿기 전에 사용자가 실제로 "abort"를 눌렀는지부터 먼저 추적해야 했다.
BYOK(자체 API 키 사용), 함정은 빼고
무료 요금제는 속도 제한(rate limit)이 걸린 호스팅 모델 위에서 돌아간다. BYOK 사용자는 자기 키로 비용을 내는 대신 이 제한을 완전히 건너뛰어야 한다 — 다만 첫 버전은 그 제한을 건너뛰지 못했고, BYOK 요청도 무료 요금제의 할당량을 똑같이 갉아먹고 있었다. 이 문제는 "분명 제 키를 썼는데도 여전히 속도 제한에 걸려요"라는 솔직한 버그 리포트 덕분에 드러났다.
BYOK에서 얻은 또 다른 교훈은 사후 대응이 아니라 사전 예방에 관한 것이었다: 요청이 실수로 가장 비싼 모델에 걸리는 일이 없도록 허용 모델 목록을 합리적인 가격대로 제한하고, provider와 상관없이 출력 토큰 수에도 상한을 두는 것이다. 이렇게 한다고 사용자가 자기 돈을 헤프게 쓰는 걸 막을 수는 없지만, 적어도 우리 코드가 그 원인이 되는 일은 막을 수 있다.
Google 검색이 끝내 보여주지 않았던 favicon
사이트의 favicon은 모든 브라우저 탭에서 멀쩡하게 나타났지만, Google 검색 결과의
URL 옆에는 절대 나타나지 않았다. 원인은 민망할 정도로 간접적이었다: Next.js의 범용
아이콘 컨벤션은 파일 URL에 콘텐츠 해시를 붙이는데(/icon.png?d72260d...), 이
해시는 파일의 바이트가 바뀔 때마다 함께 바뀐다. Google 자체 가이드라인에도 바로 이
실패 유형이 명시되어 있다: "favicon URL은 안정적이어야 한다 — URL을 자주 바꾸지
마라." 그 아이콘을 수정할 때마다, Google이 기존 URL에 쌓아 온 신뢰가 조용히
초기화되고 있었던 것이다.
Next.js는 딱 하나의 파일명에 대해서만 예외를 둔다 — favicon.ico는 콘텐츠 해시를
아예 붙이지 않고 영구적으로 고정된 URL을 받는다. 같은 이미지를 최소한의 ICO
컨테이너로 감싸서 파일명을 이걸로 바꾼 게 수정의 전부였다. 이 김에 도메인 설정도
함께 점검할 가치가 있었다: favicon과 관련해서 Google은 서로 다른 호스트명을 완전히
별개의 "사이트"로 취급하기 때문에, www.와 기본으로 제공되는 *.vercel.app 도메인
모두 하나의 정규 호스트명으로 영구 리다이렉트되도록 설정했다.
딱 한 번만 렌더링되는 Cloudflare Turnstile
Turnstile의 암묵적(implicit) 렌더링 모드는 스크립트가 처음 로드될 때 딱 한 번만
페이지에서 .cf-turnstile 요소를 스캔한다. 페이지를 이동할 때마다 스크립트를 매번
새로 불러오는 전통적인 멀티 페이지 사이트라면 이 문제는 겉으로 드러나지 않는다 —
하지만 이건 SPA이고, 진행 중인 리뷰가 중간에 날아가지 않도록 리뷰 폼의 상태는
클라이언트 사이드 네비게이션을 넘어서도 일부러 유지되게 만들어져 있다. 페이지를
벗어났다가 돌아오면, 위젯의 마운트 지점은 완전히 새로운 DOM 노드가 되어버리고, 이미
로드된 스크립트는 그 노드를 다시 스캔하지 않는다. 토큰은 조용히 undefined가
되는데 — 이후 공유 엔드포인트에 서버 사이드 검증을 추가하면서 — 페이지를 벗어났다
돌아온 뒤의 모든 공유 시도가 눈에 보이는 설명 하나 없이 실패하게 되었다.
해결책은 Turnstile의 명시적(explicit) 렌더링 API였다: 스크립트는 한 번만 로드하고,
한 번뿐인 자동 스캔에 의존하는 대신 마운트될 때마다 직접 turnstile.render()를,
언마운트될 때마다 turnstile.remove()를 호출하는 것이다.
Windows 개발 환경, Linux 프로덕션 환경
서로 다른 두 버그가 사실은 같은 원인에서 나왔다: Windows에서 개발하는 것과
Vercel의 Linux 런타임에 배포하는 것은 같은 환경이 아니고, PDF·이미지 라이브러리야
말로 바로 그 차이가 드러나는 지점이다. pdf-parse는 프로덕션에서 DOMMatrix
에러를 내며 죽었는데 로컬에서는 한 번도 재현되지 않았다 — 서버리스 환경에 안전한
PDF 라이브러리(unpdf)로 바꾸자 문제가 완전히 사라졌다. 별개로, next/og의 이미지
생성 기능은 폰트를 불러오다가 죽었는데, 원인을 추적해보니 공백이 포함된 Windows
파일 경로를 라이브러리의 URL 파싱 로직이 처리하지 못해서였다. 로컬 npm run dev
기준으로는 두 버그 모두 존재하지 않는 것이나 마찬가지였지만, 배포할 때마다 100%
재현되었다.
보안 리뷰가 실제로 잡아내는 것들
코드베이스를 처음부터 끝까지 훑어보니, 눈에 띄지는 않지만 실제로 존재하는 문제 몇 가지가 나왔다: 봇 검증이 아예 없는 스냅샷 공유 엔드포인트 — 기술적으로는 누구나 공개 URL에 임의의 텍스트를 게시할 수 있는 상태였다; 업로드된 파일을 파싱할 수 있는지 확인하기 전에 먼저 차감되는 속도 제한 — 형식이 잘못된 업로드 하나가, 애초에 실행될 일도 없었던 리뷰를 위해 사용자의 할당량을 그냥 태워버리고 있었다; 압축 항목이 선언한 크기를 확인하기도 전에 그 항목부터 압축을 해제하는 zip 처리 로직 — 이건 zip 폭탄(zip-bomb) 취약점의 전형적인 형태 그 자체였다; 그리고 페이지를 벗어났다가 돌아와서 공유 버튼을 다시 누르면 조용히 중복 스냅샷을 만들어버리는 문제 — "이미 링크가 있는가"를 나타내는 상태가, 방금 막 버려졌다가 새로 생성된 컴포넌트 안에 들어 있었기 때문이다.
이 중 어느 것도 특이한 버그는 아니었다. 그저 누군가 정상 동작 경로(happy path)가 도는지만 확인하는 대신, 어디서 문제가 생길 수 있는지를 염두에 두고 실제로 코드를 읽었을 때만 드러나는 종류의 버그였을 뿐이다.
마치며
이 목록에 있는 모든 버그는 일단 배포됐고, 잘 동작했고, 다 끝난 것처럼 보였다 —
특정 입력값, 특정 페이지 이동, 혹은 특정 호스팅 환경이 그렇지 않다는 걸 증명하기
전까지는. 화려하지 않아도 꼼꼼하게 점검한 인프라가, 프로덕션에서 한 번도 검증된
적 없는 영리한 인프라보다 낫다. exit 0.