서아프리카에서 AI 독서 앱을 출시하며 마주한 5가지 장벽 (그리고 과거의 나에게 해주고 싶은 말)
요약
서아프리카에서 AI 독서 앱 Readium을 출시하며 겪은 실전 개발 경험담입니다. LLM 스트리밍 구현 시 발생하는 버퍼링 문제와 Node.js 환경에서의 IPv6 DNS 이슈 등 프로덕션 배포 시 마주하는 기술적 장벽과 해결책을 다룹니다.
핵심 포인트
- SSE 스트리밍은 전체 요청 경로의 속성이므로 모든 계층의 버퍼링을 비활성화해야 함
- 스트리밍 검증 시 curl -N 명령어를 사용하여 토큰 전달 방식을 확인 권장
- Node.js undici의 IPv6 우선순위 문제로 인한 fetch 타임아웃 해결 방법 제시
- 프로덕션 환경에서는 IPv4를 강제하는 node:https 사용 고려
저는 부르키나파소 와가두구의 구강악안면외과 의사이자, 의대 시절부터 코딩을 해온 독학 개발자입니다. 저녁 시간과 주말을 이용해 저는 Readium을 출시했습니다. Readium은 어떤 언어로든 책을 읽으면서 Claude와 대화할 수 있는 상용 AI 독서 앱입니다. Claude와 AI 페어 프로그래밍(AI-paired)을 통해 구축했으며, 제가 직접 검토하고 배포했습니다.
대부분의 "AI 앱을 출시했다"는 글들은 순탄한 과정만을 다룹니다. 스타터 키트를 복제하고, LLM(대규모 언어 모델)을 연결하고, Vercel에 배포하는 식이죠. 제가 마주한 장벽들은 그런 곳에 있지 않았습니다. 그것들은 라이브러리들 사이의 빈 공간에 있었습니다.
그중 다섯 가지와, 몇 주 전의 저 자신에게 해주고 싶은 말을 소개합니다.
장벽 1 — LLM과 브라우저 사이의 접점에서 SSE 스트리밍이 깨짐
저는 OpenRouter가 스트림을 반환하기만 하면 스트리밍이 "그냥 작동할 것"이라고 가정했습니다. 실제로 작동합니다. 하지만 서버 측 핸들러(handler), 리버스 프록시(reverse proxy), 또는 브라우저 코드 중 어딘가에서 경로를 따라 버퍼(buffer)가 생성되기 전까지는 말이죠.
체인에는 버퍼링이 스트리밍을 조용히 망가뜨릴 수 있는 지점이 최소 세 곳 있습니다:
- LLM API (그 자체로는 문제없음)
- 귀하의 Node 서버 측 핸들러 (청크(chunks)를 누적하는 대신 그대로 전달한다면 문제없음)
- 리버스 프록시 / CDN (기본적으로 전체 응답을 버퍼링하는 경우가 많음)
실패 모드는 항상 동일합니다. UI는 마치 LLM이 느린 것처럼 보입니다. 하지만 실제로는 그렇지 않습니다. OpenRouter와 브라우저 사이 어딘가에서, 연결이 닫힐 때까지 바이트(bytes)가 억류되었다가 한꺼번에 쏟아지는 것입니다.
과거의 나에게 해주고 싶은 말: 스트리밍은 LLM의 기능이 아니라, 전체 요청 경로(request path)의 속성입니다. 만약 오리진(origin)을 대상으로 curl -N을 실행했을 때 토큰이 한 글자씩 도착하는 것을 볼 수 없다면, 당신은 스트리밍을 구현한 것이 아니라 스트리밍인 척하는 느린 방식을 구현한 것입니다. 핸들러에서 Cache-Control: no-transform과 X-Accel-Buffering: no 헤더를 설정하고, 그 앞의 모든 계층에서 응답 버퍼링을 비활성화한 뒤, UI를 믿기 전에 curl -N으로 검증하세요.
장벽 2 — 특정 호스트에서 fetch가 영원히 멈춤 (그리고 해결책은 당신이 생각하는 곳에 있지 않음)
외부 API로부터 데이터를 가져오는 프록시 라우트(proxy route)가 하나 있었습니다. 로컬에서는 잘 작동했고, 스테이징(staging) 환경에서도 잘 작동했습니다. 그런데 프로덕션(production)에 배포하자마자 해당 라우트가 약 60초 동안 멈춰 있다가 타임아웃(timeout)이 발생했습니다. 에러도 없었고, 로그도 없었습니다. 그저 침묵뿐이었습니다.
저는 이틀 동안 제 코드를 탓하며 시간을 보냈습니다. 이틀이나 말이죠.
버그는 Node.js의 내장 fetch 구현체인 undici에 있었습니다. 원격 호스트의 DNS가 IPv6와 IPv4 레코드를 모두 반환할 때, undici는 IPv6를 선택하고 TCP 연결을 연 뒤 기다립니다. 만약 컨테이너와 해당 IPv6 주소 사이의 경로가 끊겨 있다면(VPS 네트워크에서는 흔히 발생하는 일입니다), 타임아웃이 발생하지 않고 undici는 그저 그 상태로 멈춰 있게 됩니다.
해결책은 fetch를 우회하여 더 낮은 수준(lower-level)의 node:https를 사용하고, family: 4를 설정하여 IPv4를 강제하는 것입니다:
import https from "node:https";
https.get(url, { family: 4, timeout: 10_000 }, (res) => {
// ...
});
과거의 저에게 해주고 싶은 말은 이렇습니다. 프로덕션 환경에서 네트워크 호출이 아무런 에러 없이 멈춰 있고 로컬에서는 잘 작동한다면, 코드를 의심하기 전에 IPv6를 먼저 의심하세요. 이는 undici가 Node 18부터 기본값이 된 이후, JS 생태계 전반에서 발생해 온 실제적이고 문서화되지 않은 프로덕션 이슈입니다.
장벽 3 — 플랫폼은 "발행됨(Published)"이라고 표시하지만, 기능적으로는 빈 영수증만 제공함
Gumroad에 두 개의 제품을 라이브 상태로 올려두었습니다. 대시보드에는 제품들이 '발행됨(Published)'으로 표시되어 있었습니다. 저는 거의 출시 포스트를 작성하러 넘어갈 뻔했습니다.
발표하기 전 마지막으로 API 감사 (API audit)를 수행했습니다. 내부적으로 제품들은 file_info: {}, covers: [], custom_receipt: "", 그리고 published: False를 반환하고 있었습니다. 실제 구매자에게 첨부된 파일이 전혀 없었습니다. 대시보드 UI는 '발행됨(Published)'을 보여주고 있었지만, 내부의 플래그 (flag)는 조용히 뒤집혀 있었던 것입니다.
알고 보니 /v2/products/{id}에 대해 설명을 수정하는 모든 PUT 요청이 업로드된 파일, 커스텀 영수증 템플릿, 그리고 발행 플래그를 모두 지워버리고 있었습니다. 알림도, 이메일도, 경고도 없었습니다. 만약 유료 고객이 그 시간 동안 제품을 구매했다면, 그들은 29달러를 지불하고 말 그대로 아무것도 다운로드하지 못했을 것입니다.
해결하는 데는 5분이 걸렸습니다. 하지만 "이게 왜 문서에 없는 거지"라고 고민하는 시간은 주말 내내 걸렸습니다.
과거의 나에게 해주고 싶은 말: 제품은 기능적으로는 고장 난 상태이면서도 출시 가능한 모든 UI 신호를 통과할 수 있습니다. 출시 전날에는 반드시 엔드 투 엔드 (end-to-end) 체크(API 감사 또는 직접 구매 테스트)를 수행하세요. 그리고 파괴적일 수 있다는 사실을 인지하지 못한 채 진행한 그 어떤 "무해한" 수정 이후에도 반드시 확인하십시오.
장벽 4 — 표지에는 "80페이지"라고 적혀 있었지만, pandoc은 계속 83페이지로 만들었다
저는 전자책 표지를 SVG로 디자인하면서 "80페이지"를 가시적인 디자인 요소로 넣었습니다. 하지만 최종 빌드(build)가 나왔을 때는 83페이지가 되어 있었습니다. pandoc이 프론트매터 (frontmatter)를 추가하고, LaTeX가 제목 페이지 (titlepage)를 추가하면서 둘 다 몰래 끼어든 것입니다.
다시 80페이지로 맞추기 위해 마크다운 (markdown)을 조작해 보기도 하고, LaTeX 여백을 조정해 보기도 했습니다. 세 번의 반복 끝에 저는 81페이지, 그다음엔 84페이지, 그다음엔 82페이지가 되었습니다. 표지는 매번 재빌드할 때마다 치러야 하는 세금이 되어버렸습니다.
해결책은 SVG에서 한 줄을 수정하는 것이었습니다. "80페이지"를 "현장 매뉴얼 (field manual)"로 바꿨습니다. 이제 페이지 수의 변동에도 표지가 안정적으로 유지되었습니다. 저는 마케팅 문구를 "80페이지"에서 "83페이지"로 업데이트하고 다음 단계로 넘어갔습니다.
과거의 나에게 해주고 싶은 말: 표지에 취약한 사실을 적지 마세요. 표지는 당신의 포지셔닝 (positioning)을 압축해서 보여줘야 하는 것이지, 재빌드할 때마다 변하는 숫자에 당신을 얽매이게 해서는 안 됩니다.
장벽 5 — 제품을 만들고 나서야, 어떻게 발견되어야 하는지 모른다는 사실을 깨달았다
제 주변 인맥을 제외한 그 누구도 제품에 대해 듣기 전까지, 제품은 몇 주 동안이나 라이브 상태로 방치되어 있었습니다.
저는 "엔지니어링 (engineering)" (안전하고, 가치 있으며, 내가 잘 아는 것)과 "마케팅 (marketing)" (오글거리고, 판매 목적이며, 내가 잘 모르는 것)을 분리해 두었습니다. 그래서 저는 기능만 계속 출시할 뿐, 공개적으로 글을 쓰는 일은 전혀 하지 않았습니다. 루프 (loop)의 절반인 배포 (distribution) 단계가 아예 존재하지 않았던 것입니다.
이번 주, Indie Hackers의 한 비기술직 창업자가 제가 결코 잊을 수 없는 새로운 관점 (reframe)을 제시해 주었습니다. 그녀는 이렇게 적었습니다:
"엔지니어링이 충분히 정직하다면, 그 둘은 같은 것입니다."
그녀의 말은 제가 "나의 기술적 결정에 대해 글을 쓰는 것"과 "내 제품을 마케팅하는 것" 사이에 그어두었던 경계가, 엔지니어링을 진실하게 기록할 때는 실제로 존재하지 않는다는 뜻이었습니다. 저는 몇 달 동안 이 둘을 별개의 트랙으로 취급하며, 하나는 "문서화 (documenting)" (안전하고, 가치 있는 것)라고 부르고, 다른 하나는 "판매 (selling)" (오글거리는 것)라고 불렀습니다. 그 잘못된 이분법이 저를 침묵하게 만들었던 것입니다.
이 글은 제가 그 관점을 공개적으로 테스트해 보는 과정입니다.
과거의 저에게 해주고 싶은 말은 이렇습니다: 엔지니어링 위에 별도의 마케팅 트랙을 추가할 필요는 없습니다. 당신이 이미 알고 있는 것을, 정직하게, 관심 있는 사람들이 읽게 될 곳에 발행하면 됩니다.
실제로 제가 다르게 행동했을 것들
만약 제가 지난 몇 주를 다시 보낼 수 있다면, 정확히 두 가지만 다르게 할 것입니다:
- 기술적인 공개 포스트를 마지막이 아닌, 첫 주부터 작성할 것입니다.
- 제품에 변경 사항이 생길 때마다 — 설령 제가 생각하기에 절대 망가질 리 없는 변경 사항이라 할지라도 — 직접 제 제품을 구매해 볼 것입니다.
고통스러웠던 부분들을 포함한 그 외의 모든 과정은 성장에 필수적인 지지대(load-bearing)였습니다.
저는 제가 배운 것들을 "Building AI-Native Reading Apps"라는 이름의 현장 매뉴얼로 엮었습니다. 이 매뉴얼은 83페이지 분량이며, OpenRouter 스트리밍 (streaming), undici IPv4 수정 (fix), 개체 추출 (entity extraction), Gutenberg 대량 수집 (bulk ingestion), 그리고 위에서 언급한 나머지 장벽들을 코드 수준의 상세한 내용으로 다루는 10개의 장으로 구성되어 있습니다. Claude와 함께 AI를 활용하여 작성하였으며, 제가 직접 검토하고 검증했습니다. 만약 당신이 자신만의 AI 앱을 출시하고 있으며 이 모든 내용을 한곳에서 확인하고 싶다면 다음 링크를 참조하세요: limack.gumroad.com/l/ai-reading-apps. 1장 샘플(SSE 스트리밍 파이프라인 (SSE streaming pipeline))은 해당 페이지에서 무료로 제공됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기