42개 채널, 하나의 브리핑: LaunchPad 개발하며 마주한 예상치 못한 버그와 생존 전략
2026. 7. 14.
42개 채널, 하나의 브리핑: LaunchPad 개발하며 마주한 예상치 못한 버그와 생존 전략
제품을 세상에 내놓는다는 건 단순히 코드를 배포하는 것 이상의 문제입니다. 사실상 '번역'에 가깝죠. 예를 들어, Hacker News는 마케팅 냄새만 나도 바로 비난 댓글이 달리곤 합니다. X(구 트위터)는 280자라는 엄격한 글자 수 제한이 있고요. 플레이스토어는 제목 30자, 짧은 설명 80자라는 제약을 두는 반면, 크롬 웹 스토어는 키워드를 남발한 이름을 가차 없이 거절합니다. 같은 제품인데, 15가지가 넘는 다른 '방언'으로 말해야 하는 셈이죠.
이런 제약 때문에 매번 확장 프로그램을 런칭할 때마다 시간을 허비하곤 했습니다. 그래서 직접 **LaunchPad**를 만들었죠. 제품 브리핑을 한 번만 작성하면, LaunchPad가 42개 채널에 맞춰 고유한 '목소리'와 실제 글자 수 제한을 지키는 홍보 게시물을 자동으로 생성해 줍니다. 지난 주말 Product Hunt에 성공적으로 런칭했고요. 지금부터는 개발 과정에서 생각보다 훨씬 까다로웠던 부분들에 대한 이야기를 풀어볼까 합니다.
기술 스택 맛보기
주요 기술 스택은 React 18과 Vite 조합에 AWS Amplify를 프런트엔드로, 백엔드는 Lambda, DynamoDB, API Gateway를 Cognito 뒤에 배치했습니다. 모든 인프라는 Terraform으로 관리하고 있고요. 여기서 흥미로운 선택은 바로 AI 레이어입니다. 서버 측 AI가 없다는 점이 핵심이죠. 텍스트 생성은 두 가지 방식으로 이루어집니다. 하나는 Chrome 브라우저에 내장된 Gemini Nano(Prompt API)를 활용하는 방식인데, 이건 무료이고 기기 내에서만 작동하며 어떤 정보도 외부로 나가지 않습니다. 다른 하나는 사용자가 직접 자신의 OpenAI/Claude API 키를 등록하여 브라우저에서 직접 호출하는 방식입니다. 제 서버는 프롬프트, 생성된 초안, 심지어 사용자 키까지도 전혀 보지 못합니다. 이는 곧 강력한 개인 정보 보호 기능이자, 동시에 비즈니스 모델이 됩니다. 무료 사용자 한 명당 발생하는 변동 비용이 거의 없기 때문에, 첫 런칭 시에는 이 모든 기능을 무료로 제공할 수 있는 것이죠.
AI 초안을 통째로 날려버린 버그
에디터로는 TipTap(ProseMirror 기반)을 사용했습니다. AI가 초안을 생성하는 동안, 각 섹션을 setEditable(false)로 토글해서 잠금 처리하죠. 그런데 AI가 작업을 마쳤다는 성공 토스트 메시지가 뜨자마자 에디터가 텅 비어버리는 현상이 발생하기 시작했습니다. 초안이 통째로 사라지는 황당한 버그였죠.
원인을 찾는 데 꼬박 하루 저녁을 바쳐야 했습니다. TipTap v3는 프로그램적인 변경에도 update 이벤트를 발생시킨다는 것이 문제였습니다. setEditable()이나 setContent() 같은 메서드를 호출해도 사용자의 키 입력과 똑같은 onUpdate 이벤트가 트리거되었던 겁니다. 결국 섹션을 '잠그는' 행위 자체가 onChange('')라는 빈 문자열의 "수동 편집"을 유발했고, 이는 방금 AI가 생성한 초안을 스토어에서 덮어쓰고, '수동'이라는 출처 메타데이터를 찍어버린 뒤, 손상된 상태를 자동 저장해 버린 거죠.
해결책은 직접 생성한 뮤테이션을 표시하는 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]);
제가 실무에서 텍스트 에디터를 다룰 때마다 이런 미묘한 이벤트 처리 때문에 골머리를 앓았던 경험이 한두 번이 아닙니다. 특히 WYSIWYG 에디터는 사용자 인터랙션과 코드상의 변경을 구분하는 게 정말 중요하더라고요. 이 버그를 통해 얻은 교훈은 명확합니다. 대부분의 리치 텍스트 에디터의 변경 이벤트는 "문서가 변경되었는가?"에 답할 뿐, "사용자가 변경했는가?"에 답하지 않는다는 겁니다. 만약 에디터 상태를 스토어에 동기화한다면, 두 번째 질문에 대한 답은 직접 찾아야 합니다.
로컬 편집 내용을 날려버리는 오래된 응답
두 번이나 똑같은 형태의 버그를 만났습니다. 한 번은 프로젝트 목록에서, 다른 한 번은 환경 설정에서였죠. 컴포넌트 마운트 시점에 GET 요청이 발생하고, 그 사이에 사용자가 로컬에서 변경을 가합니다. 그리고 나중에 GET 응답이 도착하여 전체 상태를 덮어쓰는 바람에, 사용자가 막 추가한 커스텀 채널 같은 새 정보가 그냥… 증발해버리는 현상이었죠. 이 문제는 e2e 테스트가 스위트 내에서만 간헐적으로 실패하는 방식으로 드러났고, 저는 처음엔 재시도 루프를 돌려 해결하려다가 그 간헐적인 실패 자체가 버그였음을 깨달았습니다.
해결책은 잠금(lock)이 아닌 버전 카운터(version counter)를 사용하는 것이었습니다.
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)이라면, 서버 응답은 사용자가 행동하는 순간 효력을 잃는 제안일 뿐입니다.
글자 수 제한: AI 모델은 숫자를 못 센다
각 채널 섹션에는 실제 글자 수 제한이 적용됩니다. HN 제목은 80자 이하, 플레이스토어 짧은 설명은 80자 이하, 앱스토어 키워드는 100자 이하 같은 식이죠. 하지만 작은 온디바이스 모델들은 끊임없이 이 제한을 넘겨버렸고, 아무리 프롬프트 문구를 조절해도 해결되지 않았습니다. 언어 모델이 글자 수를 정확히 세는 건 신뢰할 수 있는 기능이 아니기 때문입니다.
결국 다음과 같이 구현했습니다. "HARD LIMIT … 단어가 아닌 글자 수를 세세요 — 먼저 해시태그를 제거하세요"와 같이 프롬프트에 제한을 명시합니다. 그리고 실제 코드로 출력물의 길이를 측정하고, 최대 두 번까지 수정 재시도를 합니다. 이 과정에서 너무 길게 생성된 초안은 다시 모델에 피드백하며 정확한 길이를 알려주죠. 가장 짧은 시도를 최종 결과로 선택합니다. 만약 그래도 제한을 넘어선다면, "제목이 여전히 제한을 초과합니다. 수동으로 줄여주세요"와 같이 솔직하게 알려주고 카운터를 빨간색으로 표시합니다. 사용자는 숫자를 못 세는 모델은 용서하지만, 할 수 있는 척하는 도구는 용서하지 않습니다.
대량 생성은 한 탭 안의 분산 시스템
"42개 모두 생성"이라고 하면 단순히 for-loop를 돌리는 것처럼 들릴 겁니다. 하지만 이 for-loop 방식은 네 가지 측면에서 처참하게 실패했습니다.
- 하나의 429 에러가 전체 실행을 중단시켰습니다. 9번 채널에서 발생한 Rate Limit 때문에 10번부터 42번 채널까지의 생성이 모두 중단된 것이죠. 이제는 모든 채널이 결과와 관계없이 실행되며, 결과는 한데 모여
28개 초안 작성 · 1개 실패 (Rate Limit) · 2개 건너뜀과 같은 보고서로 표시됩니다. 실패한 채널은 이전 초안과 출처 메타데이터를 그대로 유지하죠. - 모든 작업이 끝날 때까지 저장되지 않았습니다. 자동 저장은 디바운스 처리되어 있었는데, 42개 채널에 걸친 연속적인 스토어 쓰기 작업이 디바운스를 영원히 초기화해 버렸습니다. 그 결과 40번 채널에서 앱을 새로고침하면 모든 작업이 날아갔죠. 이제는 각 채널 작업을 마칠 때마다 즉시 영구 저장합니다.
- 빠져나갈 방법이 없었습니다. '취소' 버튼을 누르면 현재 섹션 작업만 중단하고 완료된 작업은 유지하도록 변경했습니다("취소 — 작성된 내용은 유지"). 왜냐하면 '모든 걸 버린다'는 취소의 의미는 누구도 원하지 않는 바였기 때문입니다.
- 토스트 스팸. 작업이 진행되는 중에 에디터에서 벗어나면, 각 채널의 성공 토스트가 앱을 따라다니며 스팸처럼 떠올랐습니다. 이제 대량 생성 중에는 개별 토스트 알림을 비활성화하고, 모든 작업이 끝난 후 단 한 번의 보고서로 결과를 알려줍니다.
이 문제들 중 단 하나도 실제 사용자가 불안정한 API 키를 이용해 실제 42개 채널 생성을 실행하기 전까지는 드러나지 않았습니다.
결제 버그: 이메일은 당신의 신분증이 아니다
결제 서비스(Lemon Squeezy)의 웹훅은 구매자를 이메일로 식별했기 때문에, 저는 이메일을 사용자 계정에 연결하고 플랜을 업그레이드했습니다. 테스트해 보니 잘 작동해서 배포했죠.
그런데 제 테스트 결제가 완료되었는데도 아무것도 업그레이드되지 않는 겁니다. 이유는 간단했습니다. 하나의 이메일로 여러 계정을 만들 수 있다는 것이었죠. 제 이메일 주소는 Cognito에 두 번 존재했습니다. 하나는 Google 연동 로그인(실제 제 계정)이고, 다른 하나는 일반 비밀번호 로그인(e2e 테스트 계정)이었습니다. Limit: 1으로 조회했기 때문에 DynamoDB가 먼저 반환한 계정, 즉 잘못된 계정이 업그레이드된 겁니다. 실제로 두 가지 방식으로 로그인했던 사용자는 결제하고도 계속 무료 플랜에 머무르는 문제가 발생할 수 있었죠.
해결책은 부끄러울 정도로 간단합니다. 결제자의 이메일과 일치하는 모든 계정을 업데이트하는 것이죠. 제가 비슷한 결제 시스템을 구축할 때 이메일을 유니크 키로 생각하고 덤볐다가, 고객이 다른 소셜 로그인으로 재접속하면서 문제가 터진 적이 있었죠. 그때 이메일이 그저 '연락처'일 뿐이라는 걸 뼈저리게 배웠습니다. 이 버그의 종류는 개발자라면 꼭 내재화해야 할 교훈입니다. 이메일은 연락처 정보이지, 기본 키(primary key)가 아닙니다.
이참에 한 가지 더. subscription_cancelled 이벤트는 '만료'가 아니라 '갱신하지 않을 예정'을 의미합니다. 취소 시점에 바로 등급을 낮추면 고객이 이미 지불한 한 달 치 서비스를 빼앗는 것이 됩니다. expired 이벤트가 도착할 때까지는 해당 등급을 유지해야 합니다.
반나절씩 잡아먹은 CSS 코드 두 줄
모바일 가로 스크롤 방지를 위해 순진하게 추가했던 html, body의 overflow-x: hidden 속성이 position: sticky를 모든 곳에서 조용히 비활성화시켰습니다. 스펙을 준수하는 해결책은 스크롤 컨테이너를 생성하지 않고 클리핑하는 overflow-x: clip이었습니다. 제 Sticky 상단 바는 아무도 눈치채지 못하는 두 번의 배포 동안 고장 나 있었죠.
그리고 바로 그 상단 바에 적용한 backdrop-filter는 Containing Block을 생성했습니다. 그 결과 position: fixed 속성을 가진 자식 요소가 이 바의 박스 안에 갇혀버렸죠. 제 ⌘K 커맨드 팔레트는 헤더 내부에 검은색 줄처럼 렌더링되었고, 외부 클릭을 삼켜버렸습니다. 결국 document.body로 포털 처리해야만 했습니다.
회귀 테스트 스위트가 진짜 제품이다
제가 가장 강력하게 옹호하고 싶은 것은 어떤 특정 기능이 아닙니다. 바로 프로덕션 환경을 대상으로 실행되는 21개의 Playwright 테스트 스위트입니다. 이 스위트는 모든 배포 시 릴리즈 게이트 역할을 하고, 매일 밤 cron을 통해 실행됩니다. 이번 달에 사용자가 보고한 모든 버그는 영구적인 감시자가 되었죠. 보안 헤더가 제대로 전달되는지, 딥 링크가 실제 200 응답을 반환하는지(Amplify의 SPA 폴백은 명시적인 재작성을 추가하지 않으면 모든 경로에 HTTP 404를 조용히 제공합니다. 렌더링은 잘 되지만 링크 언퍼러를 망가뜨리죠), 390px 너비에서 가로 오버플로우가 없는지, 스크롤에도 스티키 바가 잘 유지되는지, 그리고 OpenAI/Claude 파이프라인 전체가 네트워크 레이어에서 API 호출을 가로채어 인증 헤더, 모델, 본문 형태를 API 비용 없이 검증하는지 등 다양한 부분을 테스트합니다.
라이브 사이트를 대상으로 하는 UI 회귀 테스트 스위트는 과하다고 느껴질 수 있습니다. 하지만 사용자가 발견하기 전에 스타일시트 누락을 처음으로 잡아냈을 때 그 가치를 여실히 깨달았죠. 제 스위트는 첫 주 만에 그 값어치를 충분히 했습니다.
현재 LaunchPad는
LaunchPad는 지금 바로 **launch.tapdot.org**에서 만나볼 수 있습니다. 회원가입 없이 하나의 브리핑이 어떤 결과물로 변하는지 보고 싶다면 **샘플 런칭 데모**를 둘러볼 수 있습니다. 첫 런칭 시에는 클라우드 엔진을 포함한 모든 기능이 무료입니다(물론 개인 키를 사용할 경우에도요).
특히 채널 프롬프트에 대한 피드백을 진심으로 받고 싶습니다. 42개의 채널 각각에는 수동으로 튜닝된 "원어민 목소리" 지침이 포함되어 있고, 성공적인 Show HN과 그렇지 못한 Show HN의 차이는 바로 그 단어들에 달려있습니다. 만약 LaunchPad가 다루지 않는 채널에 런칭하셨다면 저에게 알려주세요. 아마 다음 주까지는 해당 채널이 추가될 겁니다. 사실 이 제품의 대부분은 사용자 요청을 들은 지 몇 시간 안에 구현되었습니다.
Prompt API, BYO-key(Bring Your Own Key) 아키텍처, 또는 회귀 테스트 설정에 대해 궁금한 점이 있다면 언제든지 댓글로 질문해 주세요. 성심껏 답변드리겠습니다.
원문: https://dev.to/mohanvenkatakrishnan/building-launchpad-one-product-brief-42-launch-channels-and-the-bugs-that-almost-sank-it-3ii7 수집일: 2026-07-14 01:12:43