본문으로 건너뛰기
강홍재/ James
← Work
EMBA2026 · Solo Builder· Started(First Commit date)

스파크 AI 창업 점집

QR 하나로 참가자 자기 폰에서 돌아가는 상주 인원 없는 부스 웹앱 - GPU 한 대와 밀 수 없는 행사 시간이라는 제약을 AI 문제가 아니라 신뢰성 문제로 놓고 풀었다.

  • Next.js 15
  • Self-hosted LLM
  • Fallback Design
  • Live Event
  • QA

Setup

Problem

학교 오리엔테이션 행사의 부스에서 동아리를 알리고 가입을 받아야 했는데, 부스에는 기기도 프린터도 상주 인원도 둘 수 없었다. 남은 자원은 참가자가 이미 손에 들고 있는 폰과, 집에서 돌리는 맥미니 한 대뿐이었다. 이 조건에서 진짜 질문은 "AI로 뭘 만들까"가 아니었다. 행사 시간은 고정이고 밀 수 없다. 부스 앞에서 30초 안에 끝나는 것을 목표로 잡았는데, 그 30초가 한 번 막히면 다음 사람은 그냥 지나간다. 즉 이건 모델 품질 문제가 아니라 가용성 문제다. 그래서 만들기 전에 "이게 언제 안 될까"를 먼저 나열했다. GPU 한 대에 동시 요청이 몰리는 경우, 모델이 메모리에서 내려가 첫 참가자가 콜드 스타트를 뒤집어쓰는 경우, 터널이 끊기는 경우, 캠퍼스 공유 IP 때문에 레이트리밋 하나가 부스 전체를 막는 경우, 그리고 행사 중에 노트북을 열 수 없는 경우.

Context

상용 AI API를 쓰지 않고 자체 맥미니의 Ollama와 gemma3:27b 비전 모델 단독으로 갔다. 이유는 API 사용료였다. 대신 콜드 스타트와 동시성이라는 다른 문제를 떠안기로 하고, 웜업과 직렬 큐로 그 값을 갚았다. 배포처는 처음에 Cloudflare Workers로 잡았다가 Vercel로 철회했다. 워커 서브도메인에 개인 계정명이 들어가고, 주소를 바꾸면 다른 워커 주소까지 깨지는 구조였기 때문이다. 인쇄물도 제약이었다. QR은 한 번 인쇄하면 고칠 수 없으니, 배포 주소를 먼저 확정하고 그 주소로 QR을 굽는 순서를 강제했다. 그 뒤로는 뒤쪽 터널 주소가 바뀌어도 환경변수만 고치면 되고 인쇄물은 그대로 유효하다. 작업은 5일, 커밋 40개로 끝냈다. 행사 날짜가 고정이라 범위를 늘릴 자리가 없었다.

Users

학교 오리엔테이션 행사에 온 부스 참가자와 동아리 운영진이다. 참가자는 자기 폰으로 QR을 찍고 체험하고, 운영진은 /stats 대시보드와 /draw 추첨, /api/health 점검으로 부스를 본다. 행사 당일 실제로 가동됐다. 다만 참여 인원은 지표로 계측하지 않았다 - 로깅이 AI 성공 케이스만 남기 때문에 질문 모드로 넘어간 참가자는 아예 집계되지 않는다. 그래서 이 프로젝트에는 내세울 참여 수치가 없고, 없는 대로 둔다.

Hypothesis

GPU 한 대와 상주 인원 0명이라는 제약에서도, 폴백과 큐잉과 복구 절차를 코드보다 먼저 설계하면 부스 체험은 행사 시간 내내 멈추지 않는다.

Build

What I did
  • QR 딥링크로 모드를 분기시키는 진입 구조 - /?m=face 셀카 관상, /?m=idea 아이디어 감정, /?m=about 소개. /poster에서 인쇄용 포스터 5종을 배포 주소 기준 QR을 런타임 생성해 렌더한다
  • 셀카를 브라우저에서 768px JPEG로 리사이즈해 전송하고, 자체 운영 gemma3:27b 비전 모델이 12개 유형 중 하나로 판정 - 관상 포인트 3개, 스탯 4종, 행운 아이템. 사진은 저장도 로깅도 없이 요청 스코프에서 폐기한다
  • 아이디어 한 줄을 시장성·독창성·실현가능성 3축 점수와 S~D 등급, 강점·리스크 2개씩, 이번 주에 할 수 있는 첫걸음으로 감정
  • AI 실패 시 5문항 질문 모드로 자동 전환 - 서버도 네트워크도 없이 클라이언트만으로, AI와 같은 12유형 체계를 시드 기반 결정적 방식으로 판정한다
  • 무의존성 Node 게이트웨이(315줄)에 FIFO 직렬화 큐(동시 처리 1건) - 대기 중 이탈한 클라이언트는 큐에서 빼고 abort를 상류로 전파해, 포기된 요청이 GPU를 계속 점유하지 않게 했다
  • 결과를 Canvas로 공유 카드 이미지로 그려 Web Share API로 넘기고, 미지원 브라우저는 다운로드 폴백
  • 운영 도구 3종 - /stats(60초 자동 갱신, 유형 분포 집계), /draw(대상 기수 필터·전화번호 중복 병합·번호 마스킹·당첨자 자동 제외 가중 추첨), /api/health(상태 3값 확인 + 모델 웜업)
Product decisions
  • 상용 AI API 대신 자체 맥미니 Ollama 단독으로 갔다 - 이유는 API 사용료. 대신 콜드 스타트와 동시성이라는 다른 문제를 떠안았고, 웜업과 직렬 큐로 갚기로 했다. 비용을 아낀 게 아니라 문제를 바꿔 산 것에 가깝다
  • 배포처를 Cloudflare Workers에서 Vercel로 철회했다 - 워커 서브도메인에 개인 계정명이 들어가고, 주소를 바꾸면 다른 워커 주소까지 깨지기 때문이다
  • 검증까지 통과한 손금 모드를 최종 제외하고 관상·아이디어 2모드로 좁혔다 - 남은 시간을 세 번째 모드가 아니라 두 모드의 폴백과 운영 절차에 쓰기로 한 선택이다. 노트에 남긴 근거는 되살릴 경우를 위한 git 이력 참조뿐이고, 그 이상은 기록해 두지 않았다
  • 외부 구글폼·오픈채팅·인스타 링크를 전부 제거하고 앱 안에서 4필드 입력만으로 가입이 끝나게 했다 - 부스 앞 30초 안에 앱 밖으로 나가는 순간이 있으면 거기서 끊긴다
  • 포스터에 배포 주소를 노출하지 않고 QR로만 진입하게 했다 - 정식 도메인이 아니기 때문이다. 대신 QR을 배포 주소 기준으로 먼저 확정하고 인쇄하는 순서를 강제해, 인쇄물이 뒤쪽 인프라 변경에 흔들리지 않게 했다
  • 관상 결과는 유형과 한줄평만 익명으로 남기고 사진은 어떤 경우에도 수집하지 않는다 - 첫 화면에 붙인 "저장 없이 즉시 폐기" 고지를 문구가 아니라 코드로 지키기 위한 결정이다
QA considerations
  • GPU 한 대에 동시 요청이 몰리면 전부 느려지다 함께 타임아웃되지 않는가 - 노트에 남은 기록으로는 4대를 동시에 붙였을 때 1대만 성공했다(원본 로그가 남아 있지 않아 재현 검증은 못 했다). 게이트웨이에 FIFO 직렬화 큐(동시 처리 1건)를 넣고, 대기 중 이탈한 클라이언트는 큐에서 제거하고 abort를 상류로 전파해 포기된 요청이 GPU를 계속 물고 있는 혼잡 자기증폭을 끊었다
  • 계층별 타임아웃이 어긋나면 어느 층이 먼저 끊는지 알 수 없다 - 클라이언트 85초 / 배포 함수 maxDuration 90초 / 모델 호출 70초 + 재시도 30초 / 웜업 50초 / 게이트웨이 프록시 90초로 정렬했다. 터널이 헤더만 흘리고 바디가 멈추는 경우까지 잡으려고 응답 바디 읽기까지 타임아웃에 포함하는 fetchJsonWithTimeout을 따로 만들었다
  • 모델이 메모리에서 내려가면 첫 참가자가 콜드 스타트를 뒤집어쓴다 - /api/health가 keep_alive:-1 웜업 요청으로 모델을 상주시키고, 행사 아침 점검을 ollama_reachable·model_loaded·join_ready 세 값 확인으로 문서화했다
  • 캠퍼스 공유 IP(NAT) 뒤에서 IP만으로 레이트리밋을 걸면 부스 전체가 한도를 나눠 쓰게 된다 - IP + UA + 기기ID(localStorage UUID) 조합을 키로 쓰고 관상 8회/분, 아이디어 6회/분, 가입 5회/분으로 뒀다. 만료 키만 정리하도록 고쳐, UA가 바뀔 때 정상 사용자 카운터가 리셋되던 문제도 함께 잡았다
  • LLM 출력을 그대로 믿으면 화면에서 무엇이 깨지는가 - 타입 검증, 문자열 길이 자르기, 점수 범위 클램핑, 등급 화이트리스트(S~D), 빈 배열 폴백 패딩, 코드펜스 제거 후 JSON 추출을 서버에서 건다. 파싱 실패일 때만 1회 재시도하고 연결 실패는 재시도하지 않는다 - AI가 죽었을 때 폴백이 늦어지면 안 되기 때문이다. 프롬프트 쪽에서도 외모 비하와 놀림 금지, 존재하지 않는 경쟁 서비스명 생성 금지(할루시네이션 규칙은 별도 커밋으로 추가), 얼굴이 없는 사진도 거부하지 말 것을 규칙으로 박았고, 무의미 입력(5자 미만, 같은 글자 반복)은 GPU에 보내기 전에 400으로 끊는다
  • 개인정보가 어디까지 새는가 - 사진은 저장·로깅 없이 폐기하고 첫 화면에 고지, 게이트웨이 로그는 전화번호 마스킹, 명단 CSV는 키 인증, 추첨 화면은 프로젝터에 띄우는 상황을 가정해 번호 마스킹, CSV 수식 주입(=, +, -, @) 이스케이프, 손상된 JSONL 줄은 건너뛰고 나머지 명단을 살린다
  • 게이트웨이 프로세스가 죽으면 AI와 가입이 동시에 멈춘다 - 자동 재시작 루프와 uncaughtException 핸들러로 버티게 하고, 터널이 죽었을 때의 약 5분 복구 절차와 노트북 없이 폰만으로 대응하는 비상 절차를 코드와 같은 저장소에 런북으로 뒀다

Outcome

Metrics
  • 커밋 40개, 2026-08-18 ~ 2026-08-22(KST), 단독 저자
  • 주요 소스 합계 약 3,156줄, API 라우트 6개, 유형 12종·질문 모드 5문항·인쇄용 포스터 변형 5종
  • 타임아웃 사슬 - 클라이언트 85초 / 배포 함수 90초 / 모델 호출 70초 + 재시도 30초 / 웜업 50초 / 게이트웨이 프록시 90초
  • 레이트리밋 - 관상 8회/분, 아이디어 6회/분, 가입 5회/분, 키는 IP + UA + 기기ID 조합
  • 자동화 테스트 0개. 테스트 프레임워크를 붙이지 않았다. 대신 AI_PROVIDER=off 경로와 mock 게이트웨이로 AI 없이 전체 플로우를 수동으로 돌려 확인했다
  • 문서화된 코드 리뷰 2회 - 1차 8건 수정, 2차 적대적 검증에서 7건 수정·3건 기각
  • 행사 당일 가동은 확인했지만 참여 인원은 지표로 계측하지 않았다(로깅이 AI 성공 케이스만 남아 질문 모드 폴백은 집계되지 않음) - 측정 전. 노트에 남긴 부하 실측치도 원본 로그가 없어 재현 검증 전
Result / Learning

5일 만에 만들어 행사 당일 가동했다. 가장 분명한 결과는 기능이 아니라 아키텍처가 실측 때문에 바뀐 대목이다. 27B 비전 모델에 동시 요청을 붙이면 전부 느려지고 대부분 타임아웃된다는 것을 먼저 확인했다 - 노트에 남은 기록으로는 4대 중 1대만 성공했다. 그래서 게이트웨이를 "빠르게 처리하는 서버"가 아니라 "한 번에 하나만 처리하는 줄"로 다시 설계했다. 무인 부스에서는 예측 가능한 대기가 예측 불가능한 실패보다 낫다는 게 그 결정의 전부다. 두 번째로 남은 건 폴백이 기능이 아니라 계약이라는 것이다. 질문 모드는 서버도 네트워크도 없이 클라이언트만으로 AI와 같은 12유형 체계를 결정적으로 판정한다. 백엔드가 통째로 죽어도 참가자 화면에서는 결과 카드가 나온다. 대신 가입 신청과 아이디어 수집은 터널이 살아야 동작한다는 한계를 README에 그대로 적어 뒀다 - 무엇이 안 죽는지를 말하려면 무엇이 죽는지도 같이 적어야 한다. 그리고 반대편으로 배운 게 하나 더 있다. 사전 리스크 목록을 잘 쓰는 것과 그 목록을 끝까지 실행하는 것은 다른 일이다. 행사 전 항목은 다 돌았는데 행사 후 항목은 돌지 않았다.

Retrospective
  • 런북에 적어둔 행사 후 철수 절차(터널 종료, 수집 데이터 정리)를 실행하지 않았다. 2주 뒤에 확인했을 때 게이트웨이가 그대로 살아 있었다. 장애 대응 절차는 세 겹으로 써 두고 정작 가장 단순한 마무리 절차를 안 돌린 셈이다. 게이트웨이 자체에는 인증도 레이트리밋도 없고 관리 API를 막는 경로 화이트리스트만 있는데, 그 설계는 "행사 기간에만 켠다"는 전제에 기대고 있었다. 철수를 안 하면서 그 전제가 깨졌다. 다음부터 종료 시각이 있는 작업은 체크리스트 항목이 아니라 알림이 붙은 작업으로 관리한다.
  • 자동화 테스트 0개. 날짜가 고정이라 테스트 프레임워크를 붙일 시간을 폴백 경로와 런북에 썼고, 5일짜리 일회성 부스에서 그 우선순위 자체가 틀렸다고는 보지 않는다. 다만 폴백 판정 로직(시드 기반 12유형)과 LLM 응답 정규화는 입출력이 고정된 순수 함수라 테스트 비용이 거의 없었다. 여기까지 안 붙인 건 판단이 아니라 게으름이었다.
  • 규모가 작다. 부스 하나, 행사 하루다. 여기서 나온 로그 수치를 성과로 쓸 수 없고 쓰지 않는다. 이 프로젝트에서 가져갈 수 있는 건 숫자가 아니라 "실패 모드를 먼저 세고 그에 맞춰 구조를 바꿨다"는 절차뿐이다.
Tech stack
  • Next.js 15 (App Router)
  • React 19
  • TypeScript 5
  • Vercel (route별 maxDuration)
  • Node.js 무의존성 게이트웨이
  • 자체 호스팅 Ollama + gemma3:27b (비전)
  • Cloudflare Tunnel
  • Canvas API + Web Share API
  • qrcode (런타임 QR 생성)
  • JSONL 파일 저장