화조당(花鳥堂) — AI 사주 명리 서비스 2026.06.08

크레딧이 마이너스가 된 날, 그리고 유나의 목소리

TOCTOU 버그로 크레딧이 -20이 됐다. 그 사이 유나는 롱폼 영상으로 말하기 시작했고, 측정을 깔자 현실이 보였다.

크레딧이 마이너스가 된 날

크레딧이 마이너스가 된 날, 그리고 유나의 목소리

5월 30일 아침, 화조당 대시보드를 열었더니 한 사용자의 크레딧 잔액이 -20이었다.

마이너스 크레딧. 결제 시스템에서 절대 있어서는 안 되는 숫자다. 처음엔 눈을 의심했다. 크레딧 차감 로직에 if (balance < cost) return 가드가 분명히 있었으니까. 로그를 뒤졌다. 두 건의 요청이 거의 동시에 들어왔고, 둘 다 잔액 확인을 통과한 뒤 각각 차감을 실행했다. 전형적인 TOCTOU(Time-of-Check to Time-of-Use) 경쟁 조건이었다.

시간  요청A          요청B
─────────────────────────────
t1    잔액확인: 10    
t2                   잔액확인: 10
t3    차감: 10→0     
t4                   차감: 0→-10

두 요청이 각각 "잔액 10이니까 괜찮다"고 판단한 뒤, 순서대로 차감해버린 거다. 트래픽이 적어서 한 번도 터지지 않다가, 하필 이 타이밍에 동시 요청이 들어왔다.

해결 패턴은 선예약 후정산(reserve → refund)으로 잡았다. 잔액 확인과 차감을 원자적으로 묶는 대신, 먼저 크레딧을 예약(락)하고, 작업이 실패하면 환불하는 구조다. 데이터베이스 레벨에서 UPDATE credits SET balance = balance - cost WHERE balance >= cost로 한 문장에 처리하니 경쟁 조건이 원천 차단됐다. 단순하지만 확실한 방법이다.

같은 날 카카오 OAuth 이메일 미수집 문제도 추적했다. 카카오 로그인으로 들어온 사용자 중 이메일이 null인 케이스가 있었다. 카카오는 이메일 동의를 별도 스코프로 받아야 하는데, 동의 화면에서 이메일 제공을 선택 해제한 사용자가 빠져나간 거였다. 작은 것들이 쌓여서 시스템의 구멍이 된다.

유나가 말하기 시작하다

화조당 캐릭터 유나의 립싱크 및 롱폼 비디오 렌더링

5월 31일, 완전히 다른 종류의 작업에 몰입했다. 유나 롱폼 토크 영상 조립 엔진 make_episode.mjs를 구현한 날이다.

화조당의 AI 캐릭터 유나가 숏폼으로만 존재하던 걸, 긴 호흡의 영상으로 확장하고 싶었다. 구조는 이렇다:

  1. 시나리오 JSON 작성 — 각 신(scene)마다 유형을 지정
  2. talk 신 — 유나의 립싱크 영상. TTS 음성에 맞춰 입이 움직인다
  3. broll 신 — 자료화면. 유나가 말하는 동안 보여줄 시각 자료
  4. concat — 전체를 하나의 영상으로 이어붙이기

가장 까다로웠던 건 싱크 드리프트 문제였다. 영상 클립을 이어붙일 때 프레임 단위의 미세한 오차가 누적되면, 30분짜리 영상 후반부에서 음성과 입 모양이 어긋난다. 각 클립의 오디오·비디오 스트림을 정밀하게 맞추고, concat 과정에서 타임스탬프를 재계산하는 로직을 넣었다. 결과: 싱크 드리프트 0. 34개 신을 이어붙여도 마지막 신까지 입과 소리가 정확히 맞는다.

이 엔진이 완성되면서 유나 콘텐츠의 가능성이 완전히 달라졌다. 숏폼 30초짜리가 아니라, 하나의 주제를 깊이 있게 다루는 영상을 만들 수 있게 된 거다.

첫 롱폼 4K, 그리고 현실 직면

6월 1일, 드디어 첫 롱폼 4K 영상이 완성됐다. '십신(十神)' 주제, 34개 신으로 구성된 영상이다. make_episode.mjs가 시나리오 JSON을 읽고, 각 신을 렌더링하고, 하나로 이어붙이는 전 과정이 자동화되어 돌아갔다. 완성된 영상을 처음 끝까지 재생했을 때의 감각은 꽤 특별했다. 유나가 진짜로 "말하고 있다"는 느낌.

그 영상에서 숏폼 5개도 추출했다. 롱폼 하나를 만들면 숏폼은 자연스럽게 파생된다. 이게 콘텐츠 파이프라인의 힘이다.

하지만 같은 날 데이터를 들여다보면서 현실과 마주했다.

방문 데이터를 분석했다. 누적 1,398건. 나쁘지 않아 보이는 숫자다. 그런데 세부를 까보니 49%가 본인 트래픽이었다. 개발하면서 접속하고, 테스트하면서 접속하고, 글 쓰면서 접속한 것들이 절반. 실질 외부 방문은 700건 남짓.

병목은 전환율이 아니라 트래픽 자체다.

유료 전환율을 최적화하는 게 의미가 없는 단계였다. 전환할 사람 자체가 충분하지 않으니까. 이 깨달음이 이후 방향 전환의 기점이 됐다. 크레딧과 결제를 다듬는 것보다, 사람들이 찾아올 이유를 만드는 게 먼저다. 그 이유가 바로 콘텐츠였다.

측정을 깔다, 그리고 장애

6월 2일, UTM 측정 인프라를 배포했다. 어디서 유입이 오는지, 어떤 채널이 효과적인지 추적하려면 측정부터 해야 한다. utm_source, utm_medium, utm_campaign 파라미터를 파싱해서 각 방문의 출처를 기록하는 구조를 넣었다.

배포 후 점검하는데 카카오 로그인이 안 됐다. 에러 로그를 따라가보니 이틀 전 커밋이 원인이었다. OAuth 콜백 경로를 수정하면서 카카오 쪽 리다이렉트 URI가 어긋난 거였다. 이틀 동안 아무도 모르고 있었다.

트래픽이 적으면 장애도 늦게 발견된다.

이게 작은 서비스의 함정이다. 사용자가 적으면 버그 리포트도 없다. 에러 모니터링 알림을 걸어놔도, 아무도 그 기능을 안 쓰면 알림이 울릴 일이 없다. 결국 자기가 직접 모든 경로를 수시로 테스트해야 한다. 인원이 한 명인 팀의 숙명이다.

콘텐츠의 목소리를 다듬다

6월 3일, Threads 콘텐츠를 전면 정비했다.

그동안 쌓아둔 Threads용 콘텐츠를 살펴보니 두 가지 문제가 보였다. 첫째, 실세계 시점 앵커가 있는 글들. "오늘", "어제", "이번 주"처럼 작성 시점에 묶인 표현이 들어간 글 10행을 삭제했다. 이런 글은 발행 타이밍이 어긋나면 어색해진다. 콘텐츠는 시점에 독립적이어야 한다.

둘째, AI 톤 문제. pending 상태로 대기 중인 1,949행의 콘텐츠를 humanize 처리했다. AI가 생성한 텍스트 특유의 패턴 — 과도한 구조화, 불필요한 접속사, 추상적 미사여구 — 을 걸러내고 자연스러운 톤으로 다듬는 작업이다. 이건 단순히 문체를 고치는 게 아니라, 유나라는 캐릭터의 목소리를 일관되게 만드는 과정이다.

5일의 피봇

이 5일을 돌아보면 명확한 전환점이 보인다.

  • 5/30: 결제 시스템 버그를 고쳤지만, 정작 결제할 사용자가 없다는 걸 깨달았다
  • 5/31: 유나가 롱폼으로 말할 수 있게 됐다 — 콘텐츠 무기가 생겼다
  • 6/01: 데이터가 현실을 보여줬다 — 병목은 트래픽
  • 6/02: 측정 인프라를 깔았고, 작은 서비스의 취약함도 확인했다
  • 6/03: 콘텐츠의 톤과 품질을 다듬었다

유료 전환에 집중하던 방향에서 콘텐츠 중심으로 피봇했다. 사람들이 화조당에 올 이유를 먼저 만들어야 한다. 유나의 롱폼 영상이 그 이유가 될 수 있을까. 아직 모른다. 하지만 최소한 유나는 이제 제대로 된 목소리를 갖게 됐고, 그 목소리를 측정할 도구도 갖추게 됐다.

크레딧이 마이너스가 된 건 기술적 실수였지만, 그 실수가 방향을 재점검하게 만들었다. 고칠 버그가 있다는 건 다행한 일이지만, 고칠 버그를 발견해줄 사용자가 없다는 건 더 큰 문제다.

화조당(花鳥堂) — AI 사주 명리 서비스 프로젝트로