← 목록으로

42개 채널 동시 출시 자동화, LaunchPad 개발기가 들려주는 버그와의 사투

2026. 7. 12.

42개 채널 동시 출시 자동화, LaunchPad 개발기가 들려주는 버그와의 사투

제품을 출시하는 건 마치 여러 언어를 동시에 통역하는 일과 같습니다. Hacker News는 마케팅 냄새만 나도 가차 없이 깎아내리고, X(구 트위터)는 280자라는 엄격한 제약이 있죠. 플레이 스토어는 제목 30자, 짧은 설명 80자에 불과하고, 크롬 웹 스토어는 키워드를 남발한 이름을 단칼에 거부합니다. 같은 제품인데도 채널마다 15가지가 넘는 방언으로 말해야 하는 셈이죠.

매번 제가 직접 확장 프로그램을 출시하면서 이런 '번역 비용'을 치르는 게 너무 힘들어서, 결국 **LaunchPad**를 만들었습니다. 딱 한 번 제품 개요를 작성하면, 42개 채널에 맞춰 각각의 특성과 실제 글자 수 제한을 지키는 '네이티브' 게시물을 자동으로 만들어줍니다. 지난 주말 Product Hunt에도 성공적으로 데뷔했고요. 오늘은 이 프로젝트를 진행하면서 예상보다 훨씬 까다로웠던 부분들을 자세히 이야기해 보려고 합니다.

스택 구성 엿보기

LaunchPad는 AWS Amplify 위에 React 18과 Vite로 프런트엔드를 구성했고, 백엔드는 Lambda, DynamoDB, API Gateway를 Cognito 뒤에 배치했습니다. 모든 인프라는 Terraform으로 관리하고 있죠. 여기서 특히 흥미로운 선택은 바로 AI 레이어인데, 서버측 AI가 아예 없다는 점입니다. 초안 생성은 크롬 내장 Gemini Nano (무료, 온디바이스, 기기 외부로 데이터 유출 없음)를 사용하거나, 사용자의 OpenAI/Claude API 키를 브라우저에서 직접 호출하는 방식으로 작동합니다. 제 서버는 프롬프트, 초안, 그리고 사용자의 API 키를 단 한 번도 보지 않습니다. 이는 프라이버시 기능이자 동시에 비즈니스 모델이 되기도 하는데, 무료 사용자당 한계 비용이 거의 0에 수렴해서 첫 출시는 완전 무료로 제공할 수 있게 된 것이죠. 제가 실무에서 다른 SaaS 제품의 보안 설계를 검토할 때마다 항상 데이터 주권과 프라이버시가 핵심 이슈였는데, LaunchPad는 이러한 고민을 아예 아키텍처 레벨에서 해결하고자 노력했습니다.

AI 초안을 통째로 날려버린 버그

에디터는 TipTap (ProseMirror 기반)을 사용했습니다. AI가 초안을 생성하는 동안, 각 섹션을 setEditable(false)로 토글하여 잠갔죠. 그런데 이상하게도 초안이 자꾸 사라지는 버그가 발생했습니다. AI가 작업을 마치면 성공 토스트 메시지는 뜨는데, 에디터는 텅 비어 있는 겁니다.

원인을 찾는 데 꼬박 하루 저녁이 걸렸습니다. 알고 보니 TipTap v3는 프로그램적으로 변경해도 update 이벤트를 발생시킨다는 것이었습니다. setEditable()setContent() 모두 사용자가 직접 키보드를 누른 것처럼 onUpdate를 발생시켰죠. 즉, 에디터를 잠그는 바로 그 행위가 onChange('') – 빈 문자열로 "수동 편집"이 일어난 것으로 인식되어, 새로 생성된 초안을 스토어에서 덮어쓰고, 출처 메타데이터를 manual로 변경한 뒤, 손상된 상태를 자동 저장하고 있었던 겁니다.

해결책은 프로그램적인 변경을 표시하는 ref를 사용하는 것이었습니다.

const applyingExternal = useRef(false);

onUpdate: ({ editor }) => {
  if (applyingExternal.current) return; // 우리가 변경한 것이지 사용자가 변경한 게 아니다
  onChange(htmlToMd(editor.getHTML()));
},

useEffect(() => {
  applyingExternal.current = true;
  editor.setEditable(!readOnly);
  applyingExternal.current = false;
}, [readOnly]);

여기서 얻은 교훈은 명확합니다. 대부분의 리치 텍스트 에디터의 change 이벤트는 "문서가 변경되었는가?"에 답할 뿐, "사용자가 변경했는가?"에 답해주지는 않습니다. 에디터 상태를 스토어에 동기화한다면, 이 두 번째 질문에 대한 답은 개발자가 직접 찾아야 합니다. 제가 다른 프로젝트에서 유사한 리치 텍스트 에디터 버그를 경험했을 때도, 프로그램적인 변경과 사용자 입력을 구분하는 것이 정말 중요했습니다.

로컬 편집 내용을 덮어버리는 오래된 응답

두 번이나 — 한 번은 프로젝트에서, 다른 한 번은 환경설정에서 — 똑같은 형태의 버그를 만났습니다. 컴포넌트 마운트 시점에 GET 요청이 발생하고, 사용자가 로컬에서 변경을 한 다음, 그 변경 이후에 GET 응답이 도착해서 상태를 통째로 덮어쓰는 문제였습니다. 방금 사용자가 추가한 새로운 커스텀 채널이 흔적도 없이 사라지는 경험을 하게 되는 거죠. 이 버그는 e2e 테스트에서만 간헐적으로 실패하는 현상으로 나타났는데, 테스트를 재시도 루프에 넣어버릴까 생각하다가 그 간헐적인 실패 자체가 버그라는 것을 깨달았습니다.

해결책은 락(lock) 대신 버전 카운터를 사용하는 것입니다.

loadPrefs: async () => {
  const ver = get()._prefsVer;
  const prefs = await api.getPrefs();
  if (get()._prefsVer !== ver) return; // 로컬 쓰기가 경합에서 이겼으니, 서버 응답은 폐기
  set((s) => ({ prefs: { ...s.prefs, ...prefs } }));
},
updatePrefs: (fields) => {
  set((s) => ({ _prefsVer: s._prefsVer + 1, prefs: { ...s.prefs, ...fields } }));
},

만약 로컬 상태가 진실의 원천(Source of Truth)이라면, 서버 응답은 사용자의 행동이 일어나는 순간 효력을 잃는 단순한 제안일 뿐입니다. 이런 비동기 상태 관리 문제는 프런트엔드 개발자들이 가장 흔하게 마주치지만, 동시에 가장 간과하기 쉬운 함정 중 하나라고 생각해요.

모델은 글자 수를 셀 수 없다: 글자 수 제한의 함정

각 채널 섹션에는 실제 글자 수 제한이 있습니다. HN 제목은 80자 이내, 플레이 스토어 짧은 설명은 80자 이내, 앱 스토어 키워드는 100자 이내 등이죠. 그런데 온디바이스의 작은 모델들은 이런 제한을 계속해서 초과했고, 어떤 프롬프트 문구를 사용해도 고쳐지지 않았습니다. 언어 모델이 글자 수를 안정적으로 세는 능력 자체가 없기 때문입니다.

결과적으로 적용된 방법은 이렇습니다. 프롬프트에 제한을 명시합니다. ("HARD LIMIT … 단어가 아닌 글자 수로 계산 — 해시태그 먼저 삭제"). 그리고 AI가 생성한 결과물은 실제 코드로 글자 수를 측정합니다. 만약 길이를 초과하면, 초과된 초안과 실제 길이를 다시 모델에게 주면서 최대 두 번까지 교정 재시도를 합니다. 가장 짧은 시도를 선택하고, 그래도 여전히 길이를 초과한다면 사용자에게 솔직하게 알립니다. "제목이 여전히 제한을 초과합니다. 수동으로 줄여주세요." 라고 메시지를 띄우고 카운터를 빨간색으로 표시하는 거죠. 사용자들은 글자 수를 못 세는 모델은 용서하지만, 셀 수 있는 척하는 도구는 용서하지 않습니다.

한 탭에서 돌아가는 분산 시스템, 대량 생성

"42개 채널 모두 생성"이라고 들으면 간단한 for-loop처럼 들릴 겁니다. 하지만 이 for-loop 방식은 네 가지 측면에서 실패했습니다.

  1. 하나의 429 에러가 전체 실행을 중단시킴. 9번째 채널에서 발생한 속도 제한(rate limit) 에러가 10번부터 42번까지 모든 채널의 생성을 포기하게 만들었습니다. 이제는 어떤 채널이든 상관없이 모두 실행되고, 결과는 모아서 "28개 초안 생성 완료 · 1개 실패 (속도 제한) · 2개 건너뜀"과 같은 보고서로 보여줍니다. 실패한 채널은 이전 초안과 출처 메타데이터를 그대로 유지하죠.
  2. 끝까지 아무것도 저장되지 않음. 자동 저장 기능은 디바운스(debounce)되어 있었는데, 42개 채널에 걸쳐 지속적으로 스토어에 쓰는 작업이 디바운스 타이머를 영원히 리셋시켜 버렸습니다. 40번째 채널에서 리로드하면 모든 작업이 날아가는 겁니다. 이제는 각 채널마다 작업이 완료될 때마다 데이터가 플러시됩니다.
  3. 빠져나갈 방법이 없음. "취소" 버튼은 현재 섹션이 완료된 후에 작업을 중단시키고, 지금까지 완료된 작업은 보존합니다 ("취소 — 작성된 내용은 유지"). 왜냐하면 대안적인 "모든 것을 버려라"라는 취소의 의미는 아무도 원하지 않기 때문이죠.
  4. 토스트 스팸. 작업이 한창 진행 중일 때 에디터를 벗어나면 각 채널의 성공 토스트 메시지가 앱 내내 쫓아다녔습니다. 대량 생성 시에는 개별 토스트는 조용히 묵살되고, 마지막에 단 한 번의 보고서로 알려줍니다.

이런 문제들은 실제 사용자가 실제 API 키를 가지고 42개 채널 생성 기능을 사용하기 전까지는 전혀 나타나지 않았습니다. 실제 사용자 시나리오에서만 발견되는 이런 엣지 케이스들은 저를 포함한 많은 개발자들을 종종 좌절시키곤 하죠.

결제 버그: 이메일은 당신의 신분증이 아니다

결제 서비스(Lemon Squeezy)의 웹훅은 구매자를 이메일로 식별했기에, 이메일을 사용자 계정에 매핑하여 유료 플랜으로 업그레이드했습니다. 테스트했을 때는 잘 작동했고, 그대로 배포했죠.

그런데 제가 직접 테스트 결제를 해보니 아무것도 업그레이드되지 않는 겁니다. 이유는 간단했습니다. 하나의 이메일로 여러 계정을 만들 수 있다는 점이었습니다. 제 이메일 주소는 Cognito에 두 번 존재했습니다. 하나는 구글 페더레이티드 아이덴티티(실제 로그인 계정)이고, 다른 하나는 네이티브 비밀번호 사용자(e2e 테스트 계정)였죠. Limit: 1로 조회했더니 DynamoDB가 먼저 반환하는 계정(잘못된 계정)이 업그레이드되었던 겁니다. 여러 방식으로 로그인했던 실제 사용자라면, 결제를 했어도 계속 무료 플랜에 머무르는 황당한 상황이 발생했을 겁니다.

해결책은 부끄러울 정도로 간단합니다. 결제자의 이메일과 일치하는 모든 계정을 업데이트하는 것이죠. 하지만 이 버그의 종류는 개발자라면 꼭 기억해야 합니다. 이메일은 연락처 정보이지, 기본 키(Primary Key)가 될 수 없다는 사실이죠. 이메일을 식별자로 사용하는 것이 얼마나 위험한지는 제가 실무에서 겪었던 수많은 보안 감사와 데이터 모델링 경험을 통해 뼈저리게 느꼈던 부분입니다.

덧붙여, subscription_cancelled갱신되지 않을 것이라는 의미이지, 만료되었다는 뜻이 아닙니다. 취소 시점에 바로 다운그레이드하면 고객이 이미 비용을 지불한 한 달치 사용 권한을 뺏는 셈이 됩니다. expired 이벤트가 도착할 때까지 현재 등급을 유지해야 합니다.

반나절을 날린 CSS 두 줄

모바일에서 가로 스크롤을 막기 위해 순진하게 추가했던 html, bodyoverflow-x: hidden 속성은 position: sticky모든 곳에서 조용히 비활성화시켰습니다. 스펙을 준수하는 해결책은 스크롤 컨테이너를 생성하지 않고 클리핑하는 overflow-x: clip이었습니다. 제 스티키 상단 바는 아무도 눈치채지 못하는 사이에 두 번의 배포 동안 고장 나 있었습니다.

그리고 같은 상단 바에 적용한 backdrop-filter는 **컨테이닝 블록(containing block)**을 생성하여, 그 안에 있는 모든 position: fixed 자식 요소들이 상단 바의 경계 안에 갇히게 만들었습니다. 제 ⌘K 커맨드 팔레트가 헤더 안에서 검은색 줄처럼 렌더링되고 외부 클릭을 삼켜버려서, 결국 document.body로 포털(portal) 처리해야만 했습니다. CSS는 간단해 보여도 숨겨진 복병이 많아서, 디버깅하다 보면 저도 모르게 반나절이 훌쩍 지나갈 때가 많습니다.

회귀 테스트 스위트가 실제 제품이다

제가 가장 강력하게 옹호하고 싶은 것은 특정 기능이 아닙니다. 바로 운영 환경을 대상으로 실행되는 21개의 Playwright 테스트 스위트입니다. 이 스위트는 모든 배포 시 릴리즈 게이트 역할을 하며, 매일 밤 cron을 통해 실행됩니다. 이번 달에 사용자가 보고한 모든 버그는 영구적인 감시병이 되었습니다. 보안 헤더가 제대로 전달되는지, 딥 링크가 실제 200 응답을 반환하는지 (Amplify의 SPA 폴백은 명시적인 리라이트를 추가하지 않으면 모든 경로에서 HTTP 404를 조용히 반환합니다 – 렌더링은 잘 되지만 링크 미리보기를 망치죠), 390px 너비에서 가로 오버플로우가 없는지, 스크롤에도 스티키 바가 잘 작동하는지, 그리고 OpenAI/Claude 파이프라인 전체가 네트워크 계층에서 API 호출을 가로채어 인증 헤더, 모델, 바디 형태를 검증하며 API 사용 비용은 전혀 발생시키지 않는지 등 모든 것을 확인합니다.

라이브 사이트를 대상으로 한 UI 회귀 테스트 스위트는 사용자보다 먼저 스타일시트 누락 같은 문제를 잡아내는 순간까지는 과하다고 느껴질 수 있습니다. 하지만 제 스위트는 배포 첫 주 만에 그 가치를 증명했습니다. 자동화된 테스트 스위트는 단순한 부수적인 작업이 아니라, 실제 서비스의 안정성을 보장하는 핵심 자산임을 이 프로젝트를 통해 다시 한번 확신하게 되었습니다.

LaunchPad, 지금 만나보세요

LaunchPad는 현재 **launch.tapdot.org**에서 이용 가능합니다. 가입 없이도 하나의 개요가 어떻게 변신하는지 보고 싶으시다면, **샘플 출시 데모**를 둘러보실 수 있습니다. 첫 출시는 클라우드 엔진(개인 키 사용 시)을 포함해 모든 기능이 무료입니다.

각 42개 채널별 프롬프트의 "네이티브 보이스" 지침은 수동으로 정교하게 튜닝되어 있는데, Hacker News에서 성공하는 글과 묻히는 글의 차이가 바로 그 섬세한 단어들에 달려있다고 생각합니다. 혹시 LaunchPad에서 지원하지 않는 채널에 출시하신다면, 알려주세요. 아마 다음 주까지는 추가될 겁니다. 이 제품의 대부분은 사용자 요청을 듣고 몇 시간 이내에 만들어졌으니까요.

Prompt API, BYO-key 아키텍처, 또는 회귀 테스트 설정에 대해 궁금한 점이 있으시면 댓글로 편하게 질문해주세요!


원문: https://dev.to/mohanvenkatakrishnan/building-launchpad-one-product-brief-42-launch-channels-and-the-bugs-that-almost-sank-it-3ii7 수집일: 2026-07-12 01:22:23