제가 만든 것 — Hummo. 친구들과 YouTube Music을 같은 순간에 함께 들을 수 있게 해주는 크롬 확장 프로그램입니다. 한 명이 호스트가 되고, 나머지 사람들의 플레이어가 그 호스트를 따라갑니다 — 같은 트랙, 같은 위치에서 함께 재생하고 함께 멈춥니다, 실시간으로요. 공유 큐, 방 안 채팅, 자기 언어로 둘러볼 수 있는 공개 방, 일방향 팔로우, 그리고 3명을 넘기면 적용되는 구독 게이트까지 있습니다. 지금 Chrome Web Store에서 이용할 수 있습니다.
Hummo 설치하기 — Chrome Web Store →
자, 이제 이게 실제로 어떻게 만들어졌는지 이야기해보겠습니다.
먼저 솔직하게 말씀드리겠습니다. 저는 이 확장 프로그램 코드를 거의 한 줄도 직접 쓰지 않았습니다. 제가 실제로 한 일은 — 마음에 드는 확장 프로그램을 하나 찾아서, Claude에게 *"이런 기능을 원하는데, 어떻게 만들지?"*라고 묻고, 돌아온 결과물을 QA한 게 전부입니다. 며칠 뒤 그게 Chrome Web Store에 올라가 있었습니다.
build-in-public 글은 대부분 "이렇게 만들었습니다"라는 식으로 깔끔하게 정리되어 나옵니다. 제 경우는 그렇게 깔끔하지 않았습니다. 그래서 이 글은 실제 역할 분담을 있는 그대로, 내가 요청한 것(🗣️) → Claude가 한 것(🤖) 형식으로, 지저분한 부분까지 다 담아 풀어보겠습니다.
제가 만든 건 Hummo — 친구들과 YouTube Music을 같은 순간에 함께 듣는 확장 프로그램입니다.
📋 이 글은 빌드 키트입니다. 통째로 복사해서 여러분의 Claude(Claude Code)에 붙여넣고 *"이거 만들어줘"*라고 하면 — 똑같은 확장 프로그램을 다시 만들어낼 수 있습니다. 대부분은 그냥 평범한 말로 설명된 구조이고, Claude가 거기서부터 구현합니다. Claude가 추측할 수 없는 부분 — YouTube Music의 비공개 플레이어/DOM, 이벤트 프로토콜 — 만 실제 코드로 붙여넣었습니다.
Part 0 — 내가 본 것, 그리고 만들고 싶었던 것
어느 날 ListenTogether를 우연히 발견했습니다 — 친구들과 YouTube Music을 함께 들을 수 있게 해주는 확장 프로그램이었죠. "괜찮네. 근데 이걸 내 방식대로, 내가 원하는 기능을 넣어서 다시 만들어보고 싶다." 시작점은 그게 전부였습니다.
제가 가진 건 코드가 아니라 — 위시리스트였습니다.
- 친구들이 같은 노래를 같은 순간에 듣기 (실시간 동기화)
- 방을 만들고, 코드로 공유하고, 그 안에서 채팅하고, 공유 큐를 쓰기
- 3명을 넘기면 구독 게이트; 3명까지는 무료
- 공개 방 둘러보기 — 단 언어로 분리 (자기 언어로 된 방만 보임)
- ❤️ 방에 하트 달기 + 인기순 정렬
- 일방향 팔로우 + 내가 팔로우하는 사람이 방을 열면 핑 알림
- YouTube Music에서 바로 "이 페이지의 노래들 추가"를 큐에 넣기
- 페이지 위에서뿐 아니라 어디서든 툴바 아이콘으로 접근하기
이런 걸 실제로 어떻게 만드는지 — 실시간 동기화, 저작권, service worker — 저는 전혀 몰랐습니다. 그게 바로 Claude가 필요했던 이유입니다. 그 결과물이 Hummo입니다.
목차
- 핵심 아이디어 + 실시간 서버
- 확장 프로그램 코어 — 플레이어 제어, 세션, service worker
- UI와 언어
- 인프라 — Claude가 lavela를 추천했습니다
- 이름, 로고, 사이트 & Chrome Web Store 출시
1. 핵심 아이디어 + 실시간 서버
여기서 시작했습니다. 제가 만들고 싶었던 건 "친구들과 같은 노래를 같은 순간에 듣는" 확장 프로그램이었고 — 가장 먼저 존재해야 했던 건 화면에 보이는 UI가 아니라, 그 아래에 깔려 있는 공유 프로토콜과 실시간 서버였습니다. 확장 프로그램(클라이언트)은 결국 그 두 가지에 매달려 있는 셈이라, Claude는 순서대로 만들어 나갔습니다: 골격 → 공유 타입 → 서버. 저는 코드를 쓰지 않으니, 제가 던진 건 전부 무엇을이었습니다. 어떻게는 전적으로 Claude의 몫이었습니다.
오디오가 아니라 상태를 동기화한다 (이건 Claude의 아이디어였습니다)
🗣️ 내가 요청한 것
"누군가 허용하면, 다른 사람들의 방을 발견하고 슬쩍 들어갈 수 있으면 멋질 것 같아. (…) 이거 전체를 제대로 설계해줘."
저는 Claude에게 "오디오 말고 상태를 동기화해"라고 말한 적이 없습니다. 그냥 위처럼 "함께 듣기 앱을 제대로 설계해줘" 하고 던졌을 뿐이고 — 어떻게 동기화할지는 Claude가 내린 핵심 결정이었습니다.
🤖 Claude가 한 것 솔직히 이건 제가 요청한 게 아니라, Claude가 목표에서 끌어낸 것입니다. 저는 그저 "사람들이 함께 들을 수 있게 해줘"라고만 했는데, Claude가 반론을 제기했습니다: "실제 오디오를 스트리밍해서 돌리면 저작권과 대역폭이 지옥이 됩니다." 그래서 방향을 뒤집었습니다. 대신 재생 상태만 동기화합니다 — 트랙 ID, 위치(초 단위), 재생/일시정지, 그리고 타임스탬프. 이 네 가지 값이 서버가 나르는 전부입니다. 각 클라이언트는 자기 브라우저에서 자기 YouTube Music을 로컬로 재생하고, 서버의 권위 있는 상태에 자기 위치를 맞춥니다: "이 트랙, 이 위치, 지금 재생 중." 오디오는 단 한 바이트도 회선을 건너지 않습니다. 이 결정 하나가 저작권 문제를 통째로 피하고, 대역폭을 노래당 수십 바이트로 떨어뜨리며, 100명짜리 방조차 사실상 공짜로 굴릴 수 있게 만듭니다. 주말 빌드가 가능했던 이유가 바로 이겁니다. 그리고 이것 — 무엇을 동기화하느냐 — 이 다음 단계의 계약(프로토콜)이 되었습니다.
공유 프로토콜 (확장 프로그램과 서버가 둘 다 읽는 계약)
🗣️ 내가 요청한 것
"youtube music이잖아 — 꼼꼼히 살펴봐줘. 그리고 최종 결과물이 실제로 어떻게 생겼는지 스케치해줘."
저는 "공유 프로토콜"을 명세로 적어주지 않았습니다. 그냥 "꼼꼼히 검토하고 잘 설계해줘" 정도로 말했을 뿐이고 — "확장 프로그램과 서버가 어긋나면 동기화가 깨진다"는 깨달음, 그리고 그걸 막아주는 단 하나의 계약 파일은, 그 검토 과정에서 Claude가 어쩔 수 없이 깔아야 했던 기반이었습니다.
🤖 Claude가 한 것
Claude는 확장 프로그램과 서버가 둘 다 import하는 — 단일 진실 공급원 파일 하나를 만들었습니다. 핵심 결정은 이것입니다: 호스트는 자기 플레이어에서 세 가지 값만 올립니다, { trackId, positionSec, playing }(HostState), 그러면 서버가 자기 시계로 serverTs(전송 시각, ms)를 찍어서 방 전체에 SyncPacket으로 브로드캐스트합니다. 서버 시계가 유일한 진실이라, 게스트는 위치를 두고 우길 수 없습니다. 그 위에, 모든 이벤트(방 / 큐 / 채팅 / 친구 / 팔로우 / 발견 / 결제)의 payload와 ack 시그니처를 타입으로 못박았습니다 — 이벤트 이름 한 글자나 필드 하나만 틀려도 양쪽 다 컴파일은 되는데 런타임에서 동기화가 조용히 깨집니다. 타입으로 강제하면 그 부류의 버그 전체가 컴파일 타임에 죽습니다. 무료 상한 FREE_ROOM_CAPACITY = 3이나 "원격 모드"(remote = 동기화는 받되 조용히 있기) 같은 도메인 상수도 여기에 함께 있습니다.
/** * Realtime protocol shared between extension and server (single source of truth). * Both server and extension import this from @lt/shared. */ export interface Participant { id: string; name: string; isHost: boolean; remote: boolean; // remote mode (stays in sync but plays no audio) } export interface QueueItem { trackId: string; title: string; artist: string; by: string; // name of the participant who added it thumb?: string; // album thumbnail URL (if available) } export interface ChatMessage { name: string; text: string; ts: number; // server time (ms) } /** Queue-control permission mode (per room). Default open = keeps the current frictionless behavior */ export type QueuePerm = "host-only" | "propose" | "open"; /** Track suggestion submitted by a guest in propose mode (host accepts/rejects) */ export interface Proposal { id: string; item: QueueItem; byId: string; byName: string; ts: number; } /** Authoritative playback state sent by the host (position based on host clock) */ export interface HostState { trackId: string; positionSec: number; playing: boolean; } /** Sync packet the server broadcasts to everyone in the room. serverTs = send time based on the server clock (ms). */ export interface SyncPacket extends HostState { serverTs: number; } export interface RoomSnapshot { code: string; title: string; hostId: string; participants: Participant[]; queue: QueueItem[]; chat: ChatMessage[]; queuePerm: QueuePerm; proposals: Proposal[]; isPublic: boolean; tags: string[]; hostPro: boolean; // whether the host is subscribed (if false, capacity is 2) sync: SyncPacket | null; } /** client → server event payloads */ export interface ClientToServer { "room:create": (p: { title: string; name: string; queuePerm?: QueuePerm; isPublic?: boolean; tags?: string[]; lang?: string }, ack: (r: { code: string; selfId: string; snapshot: RoomSnapshot }) => void) => void; "room:join": (p: { code: string; name: string }, ack: (r: { ok: boolean; selfId?: string; isHost?: boolean; snapshot?: RoomSnapshot; error?: string }) => void) => void; "room:leave": (p: Record<string, never>, ack: (r: { ok: boolean }) => void) => void; // leave room (SW socket doesn't disconnect, so an explicit event is required) "room:close": (p: Record<string, never>, ack: (r: { ok: boolean; error?: string }) => void) => void; // host disbands the room "host:state": (p: HostState) => void; "queue:add": (p: QueueItem) => void; "queue:addMany": (p: { items: QueueItem[] }) => void; "queue:remove": (p: { trackId: string }) => void; "queue:reorder": (p: { order: string[] }) => void; "queue:clear": () => void; "chat:send": (p: { text: string }) => void; "presence:remote": (p: { remote: boolean }) => void; "presence:rename": (p: { name: string }, ack: (r: { ok: boolean; name?: string; error?: string }) => void) => void; "perm:set": (p: { queuePerm: QueuePerm }) => void; "queue:propose": (p: { item: QueueItem }) => void; "queue:approve": (p: { id: string }) => void; "queue:reject": (p: { id: string }) => void; // P2 discovery "room:setPublic": (p: { isPublic: boolean; tags?: string[] }, ack: (r: { ok: boolean; isPublic: boolean; error?: string }) => void) => void; "discover:list": (p: DirectoryQuery, ack: (r: { rooms: PublicRoomCard[]; seeded: boolean }) => void) => void; "discover:report": (p: { code: string; reason?: "spam" | "nsfw" | "other" }, ack: (r: { ok: boolean }) => void) => void; "room:heart": (p: { code: string }, ack: (r: { ok: boolean; hearted?: boolean; hearts?: number; error?: string }) => void) => void; // One-way follow (favorites) — separate from friends. When a followed user opens a room, you get notified. "follow:add": (p: { code: string }, ack: (r: { ok: boolean; error?: string }) => void) => void; "follow:remove": (p: { userId: string }, ack: (r: { ok: boolean }) => void) => void; "follow:list": (p: Record<string, never>, ack: (r: { following: FollowInfo[] }) => void) => void; // P3 accounts (full email/password accounts) "auth:signup": (p: { email: string; password: string; name: string }, ack: (r: AuthResult) => void) => void; "auth:login": (p: { email: string; password: string }, ack: (r: AuthResult) => void) => void; "auth:token": (p: { token: string }, ack: (r: AuthResult) => void) => void; "auth:logout": (p: { token: string }, ack: (r: { ok: boolean }) => void) => void; // Subscription (rooms with 3+ people). billing:checkout = lavela checkout URL (when configured); the demo upgrades to pro immediately "billing:checkout": (p: Record<string, never>, ack: (r: { ok: boolean; url?: string; plan?: "free" | "pro" }) => void) => void; // Optimistic upgrade after a real Stripe payment (ADR 003: no payment webhook → user self-confirms) "billing:confirm": (p: Record<string, never>, ack: (r: { ok: boolean; plan?: "free" | "pro" }) => void) => void; "billing:cancel": (p: Record<string, never>, ack: (r: { ok: boolean }) => void) => void; "friends:list": (p: Record<string, never>, ack: (r: { friends: FriendInfo[]; incoming: FriendRequestInfo[] }) => void) => void; "friends:request": (p: { code: string }, ack: (r: { ok: boolean; error?: string }) => void) => void; "friends:respond": (p: { requestId: string; accept: boolean }, ack: (r: { ok: boolean }) => void) => void; "friends:remove": (p: { userId: string }, ack: (r: { ok: boolean }) => void) => void; "room:invite": (p: { friendId: string }, ack: (r: { ok: boolean; error?: string }) => void) => void; "time:ping": (p: { t0: number }, ack: (r: { t0: number; tServer: number }) => void) => void; } /** server → client event payloads */ export interface ServerToClient { sync: (p: SyncPacket) => void; "queue:update": (p: { queue: QueueItem[] }) => void; presence: (p: { participants: Participant[]; hostId: string }) => void; "chat:msg": (p: ChatMessage) => void; "host:changed": (p: { hostId: string }) => void; "perm:update": (p: { queuePerm: QueuePerm }) => void; "proposal:update": (p: { proposals: Proposal[] }) => void; "queue:denied": (p: { action: string; reason: "perm" }) => void; "room:meta": (p: { isPublic: boolean; tags: string[] }) => void; "friends:changed": (p: Record<string, never>) => void; // friend/request/presence change → client re-fetches friends:list "follow:changed": (p: Record<string, never>) => void; // following list/presence change → re-fetch follow:list "follow:roomOpened": (p: { userId: string; name: string; code: string; title: string }) => void; // a followed user opened a room → badge notification "billing:updated": (p: { plan: "free" | "pro" }) => void; "room:invited": (p: { code: string; fromName: string; title: string }) => void; "room:closed": (p: { reason: string }) => void; } /** Free-plan room capacity (free up to 3 people; payment gate kicks in at the 4th) */ export const FREE_ROOM_CAPACITY = 3; // ── P2: public room discovery ── export interface PublicRoomCard { code: string; title: string; listeners: number; nowPlaying: { title: string; artist: string } | null; coverIds: string[]; // album-art trackIds for the cover (up to 4, 2x2 collage) tags: string[]; hostPro: boolean; // whether the host is subscribed (if false, limited to 2 people) hearts: number; // ❤️ number of likes (tied to the live room) heartedByMe: boolean; // whether the requesting user has hearted it createdAt: number; } export interface DirectoryQuery { q?: string; sort?: "active" | "new" | "hearts"; lang?: string; // requesting user's UI language (en/ko/ja) — only show rooms created in the same language } // ── P3: accounts/friends ── export interface FriendInfo { userId: string; name: string; online: boolean; roomCode: string | null; // room currently joined (if any, enables one-tap join) roomTitle: string | null; } export interface FriendRequestInfo { id: string; fromName: string; } /** One-way follow target info (same shape as FriendInfo, includes presence) */ export interface FollowInfo { userId: string; name: string; online: boolean; roomCode: string | null; roomTitle: string | null; } export interface AuthResult { ok: boolean; error?: string; userId?: string; friendCode?: string; name?: string; plan?: "free" | "pro"; token?: string; // session token (saved to localStorage → reconnect / login on another device) }
드리프트 보정 (작은 어긋남은 일부러 무시합니다)
🗣️ 내가 요청한 것
"youtube music이잖아 — 꼼꼼히 살펴봐줘. 그리고 최종 결과물이 실제로 어떻게 생겼는지 스케치해줘."
드리프트 보정도 제가 요청한 건 아니었습니다. 저는 그저 "꼼꼼히 검토하고, 사람들이 같은 노래를 함께 듣는 데모를 설계해줘"라고만 했는데 — "사람마다 시계도 다르고 네트워크 지연도 달라서, 그냥 두면 뒤처진다"는 문제와 그 보정 로직은 Claude의 것이었습니다, 목표를 구현하려면 그게 필요했으니까요.
🤖 Claude가 한 것
상태만 동기화하면 문제가 하나 남습니다: 클라이언트마다 시계가 다르고 네트워크 지연이 제각각이라, "서버가 보낸 위치"를 그대로 쓰면 늦게 받을수록 더 뒤처집니다. Claude는 이걸 NTP-lite 방식으로 메웠습니다. time:ping/ack 왕복으로 서버 시계 오프셋(offset = 서버 시계 − 클라이언트 시계)을 추정하는데, 결정적인 부분은 이것입니다: 여러 샘플을 모은 다음, 평균을 내지 않고 — RTT가 가장 낮은 샘플의 오프셋을 채택합니다(즉 가장 믿을 만한 샘플). 그게 min-RTT 샘플입니다. 이렇게 하면 지터에 강건해집니다. 재생 위치는 expectedPosition = 패킷의 위치 + 전송 이후 경과 시간(서버-현재 − serverTs)으로 계산되는데, playing일 때만, 그리고 음수로 떨어지지 않게 클램프해서 계산합니다. 그리고 마지막으로, 실제로 듣는 느낌을 지키는 가장 중요한 결정: 드리프트가 1.5초 임계값(DRIFT_THRESHOLD_SEC)을 넘을 때만 seek로 보정합니다. 0.2초 어긋날 때마다 seek를 하면 노래가 토막토막 끊겨서 망가지니까, 작은 드리프트는 일부러 흘려보냅니다. 이 로직은 DOM이나 소켓 의존성이 전혀 없는 순수 함수라, 유닛 테스트로 검증합니다.
실시간 서버 (방, 권위, 그리고 결제 게이트)
🗣️ 내가 요청한 것
"그러니까 3명 넘게 공유하고 싶으면 구독을 해야 하고, 구독 안 한 사람이 만든 방은 인원 상한을 표시해야 해." "무료 등급은 2명 말고 3명이어야 해." "잠깐, 생각해보니 방을 닫을 방법이 없네." / "그리고 방 닫는 건 — 호스트만 할 수 있어야 해."
여기 나오는 불변식들 — "권위는 오직 호스트에게서 나온다", "사용자당 방 하나" — 은 제가 요청한 게 아닙니다. 그 결제 게이트와 방 닫기를 안전하게 구현하려고 Claude가 깔아둔 것들입니다.
🤖 Claude가 한 것
Claude는 실시간 백엔드를 Socket.IO 위에 작성하고 몇 가지 불변식을 못박았습니다. 첫째, 권위는 오직 호스트에게서 나옵니다 — setHostState는 호스트가 보낸 상태만 받고(다른 사람이 보내면 무시), 거기에 서버 시계 serverTs를 찍어 그것을 권위 있는 패킷으로 삼습니다. 게스트가 위치를 두고 우길 수 없는 이유가 이겁니다. 둘째, 사용자당 방 하나 — 새 방에 들어가기 전에 기존 방에서 자동으로 나가게 만들어서, 소켓 하나가 두 방에 동시에 있을 수 없습니다. 셋째, 제가 요청한 결제 게이트 — 호스트가 PRO면 방은 무제한이고, 아니면 무료 상한인 3명에서 막히면서 4번째 사람부터 결제 쪽으로 유도합니다(그 상한에 부딪히는 순간이 바로 PRO 전환 모먼트입니다). 넷째, "방 닫기"는 호스트 전용으로 만들었고, 방을 닫으면 방 안 모두에게 room:closed를 브로드캐스트한 뒤 즉시 방을 삭제합니다.
여기서 Claude가 잡아낸 고약한 함정이 하나 있었습니다: 확장 프로그램의 service-worker 소켓은 탭을 닫아도 연결이 끊기지 않습니다. 그래서 disconnect 이벤트에만 의존하면, 나간 사람이 유령 참가자로 방에 남아 있게 됩니다. Claude는 별도 이벤트를 추가해서, 클라이언트가 명시적으로 room:leave를 쏘고 서버가 그를 즉시 제거하게 만들었습니다. 그리고 마지막으로, 비밀번호는 평문이나 단순 해시로 저장되지 않습니다 — scrypt(사용자별 랜덤 salt) + timingSafeEqual(타이밍 공격 차단)로 검증합니다.
🧪 내가 잡아낸 것 (QA)
"회원가입 눌렀는데 됐는지 안 됐는지조차 모르겠더라 — 그리고 이메일/닉네임 중복 체크도 없지?"
이건 출시 후 실제로 써보다가 발견했습니다. 회원가입 검증(이메일/닉네임 중복 체크)이 빠져 있었고 — Claude가 추적해서 바로 패치했습니다.
2. 익스텐션 코어 — 플레이어 제어, 세션, 그리고 서비스 워커
이 단계는 화면에 보이는 버튼에 관한 게 아니었습니다. 익스텐션을 애초에 가능하게 만드는 보이지 않는 배관 작업이었습니다. YouTube Music의 비공개 플레이어를 조종하는 다리, 소켓과 시계를 하나로 묶는 실시간 세션, 그리고 그 세션을 한 곳에 붙들어 두는 서비스 워커. 저는 이런 단어들을 알지도 못했습니다. 그냥 "이렇게 동작했으면 좋겠다"고 말했을 뿐이고, 구조는 매번 Claude가 결정했습니다.
툴바 아이콘에서도 제어할 수 있게 (= 아키텍처 리팩터링)
🗣️ 내가 요청한 것
"페이지 안에서뿐만 아니라 위쪽 익스텐션 아이콘에서도 제어할 수 있으면 좋겠어요."
(서비스 워커에 세션을 하나만 두는 구조는 제가 요청한 게 아닙니다. "아이콘에서, 어디서든 제어"가 실제로 동작해야 하게 되자 Claude가 도출해 낸 ADR 004 설계입니다.)
🤖 Claude가 한 것
솔직히 이 한 줄이 작업 중반에 구조 전체를 뜯어고치게 만들었습니다. 처음엔 소켓과 룸 상태가 페이지 안의 패널에 살고 있었습니다. 그런데 팝업(툴바 아이콘)이 같은 세션에 붙어야 하게 되자 그 구조가 더는 말이 되지 않았습니다. 팝업은 닫는 순간 파괴되고, 그러면 곡 동기화도 죽기 때문입니다. Claude는 이걸 ADR 004로 다시 설계했습니다. 핵심 결정은 세션(소켓 + 동기화 루프 + 전체 상태)을 정확히 하나만, 서비스 워커 안에 호스팅하고, 팝업과 페이지 안 패널을 둘 다 포트로 그 세션에 붙는 얇은 뷰로 만드는 것이었습니다. 팝업 쪽은 메서드 호출을 서비스 워커로 전달하는 릴레이(SessionProxy)를 쓰고, 페이지 쪽은 재생 에이전트(RemoteAdapter)가 되어 워커가 보내는 재생 명령을 실제 플레이어로 그대로 흘려보냅니다. 둘 다 같은 SessionLike 인터페이스를 구현하기 때문에, 세션 코드는 한 줄도 안 바꾸고 두 군데에 꽂힙니다. 덤으로, 서비스 워커에는 localStorage가 없어서, 기존의 토큰/룸 읽기 코드를 살려 두려고 Claude는 chrome.storage.local(키 "lt")에서 메모리 캐시를 미리 채워 두고 그 위에 동기 localStorage 폴리필을 깔았습니다.
★ 이걸 다시 만든다면, 그 우회로를 똑같이 겪을 필요 없이 그냥 "세션은 서비스 워커에 하나만 호스팅 + 뷰는 포트로 붙는 얇은 클라이언트"에서 처음부터 시작하면 됩니다. 불변 조건: 세션 인스턴스는 정확히 하나, 팝업/패널은 절대 상태를 직접 들고 있지 않고 포트를 통해 관찰만 함, 그리고 서비스 워커 콜드 스타트에서 살아남으려면 포트 핸들러는 뭔가 하기 전에 세션-준비 프로미스를 await 해야 함.
YouTube Music 플레이어 제어하기 (★ 추측으로는 불가능한 영역)
🗣️ 내가 요청한 것
이건 제가 한 번도 요청한 적이 없습니다. "재생/일시정지/seek 컨트롤을 달아줘" 같은 스펙을 넘긴 적이 없습니다. "친구와 같은 곡을 같은 초에 듣는다"라는 상위 목표가 실제로 동작하게 하려고 Claude가 플레이어를 직접 조종할 수밖에 없었던 부분입니다.
🤖 Claude가 한 것
재생 제어는 document.getElementById("movie_player")가 반환하는 객체의 비공개 메서드를 통해서만 동작합니다(playVideo, pauseVideo, seekTo, loadVideoById, getVideoData 등). 문제는, 콘텐츠 스크립트가 기본적으로 "격리된 세계(isolated world)"에서 돌기 때문에 그 페이지 객체를 건드릴 수 없다는 점입니다. 그래서 Claude는 매니페스트에 world: "MAIN"을 선언한 두 번째 스크립트를 주입했습니다. 페이지 자체의 컨텍스트에서 도는 건 이 코드뿐입니다. 격리된 세계와 MAIN 세계는 오직 window.postMessage로만 대화합니다. MAIN 세계 쪽은 양방향으로 동작합니다. (1) {__lt:"cmd"}를 받아 플레이어를 조종하고, (2) 1초에 한 번 현재 트랙 / 위치 / 길이 / 재생 상태를 읽어서 {__lt:"state"}로 격리된 세계에 다시 브로드캐스트합니다. getPlayerState() 매핑(1 = 재생 중, 2 = 일시정지, 0 = 종료)이 곡이 끝났는지를 판단하는 근거입니다. 이런 비공개 API 이름과 메시지 형태는 Claude가 추측하면 딱 환각을 일으킬 만한 종류라서, 실제 코드로 남깁니다.
/** * MAIN world script (runs in the page context). * The content script (isolated world) cannot call YT Music's internal `movie_player` API. * Here we control/read the state of movie_player and communicate with the isolated world via postMessage. * * isolated → MAIN : { __lt:"cmd", cmd:"loadTrack|play|pause|seek", arg } * MAIN → isolated : { __lt:"state", state:{ trackId,title,artist,positionSec,playing } } */ interface MoviePlayer { loadVideoById(id: string): void; playVideo(): void; pauseVideo(): void; seekTo(seconds: number, allowSeekAhead?: boolean): void; getCurrentTime(): number; getDuration(): number; getPlayerState(): number; // 1=playing, 2=paused, 3=buffering, 5=cued, 0=ended getVideoData(): { video_id: string; title: string; author: string }; } function player(): MoviePlayer | null { return document.getElementById("movie_player") as unknown as MoviePlayer | null; } window.addEventListener("message", (e: MessageEvent) => { const d = e.data; if (e.source !== window || !d || d.__lt !== "cmd") return; const p = player(); if (!p) return; try { if (d.cmd === "loadTrack") p.loadVideoById(String(d.arg)); else if (d.cmd === "play") p.playVideo(); else if (d.cmd === "pause") p.pauseVideo(); else if (d.cmd === "seek") p.seekTo(Number(d.arg), true); } catch { /* player not ready yet, etc. — ignore */ } }); setInterval(() => { const p = player(); if (!p || typeof p.getVideoData !== "function") return; let data: { video_id: string; title: string; author: string }; try { data = p.getVideoData(); } catch { return; } if (!data?.video_id) return; window.postMessage( { __lt: "state", state: { trackId: data.video_id, title: data.title ?? "", artist: data.author ?? "", positionSec: p.getCurrentTime?.() ?? 0, durationSec: p.getDuration?.() ?? 0, playing: p.getPlayerState?.() === 1, ended: p.getPlayerState?.() === 0, }, }, "*", ); }, 1000);
그러고 나서 이 YT Music 의존성을 어댑터 하나 뒤에 가둬 뒀습니다. MAIN 세계가 1초마다 보내는 상태를 캐시해 둬서 getState()가 동기적으로 즉시 반환되게 하고, 재생/일시정지/seek/loadTrack은 postMessage로 명령을 밀어냅니다. ★ 결정적인 함정 하나: 이 모듈은 (세션 엔진을 거쳐) 서비스 워커 번들에도 딸려 들어가기 때문에, window 접근을 모듈 최상위에 절대 두면 안 되고, 전부 createYtMusicAdapter() 팩토리 안에 넣어야 합니다. 그러지 않으면 서비스 워커가 로드되는 순간 죽습니다.
/** * ★ Adapter that isolates the YT Music dependency. * Actual playback control/state goes through the movie_player API in the MAIN world (main-world/index.ts). * This file, living in the isolated world, only communicates with the MAIN world via postMessage. * * - Reading state: cache the "state" message MAIN sends every second → getState() returns the cache (synchronous) * - Control: loadTrack/play/pause/seek → "cmd" message to MAIN */ export interface PlaybackState { trackId: string; title: string; artist: string; positionSec: number; durationSec: number; playing: boolean; ended: boolean; } export interface YtMusicAdapter { getState(): PlaybackState | null; play(): void; pause(): void; seek(sec: number): void; loadTrack(trackId: string): Promise<void>; onStateChange(cb: () => void): () => void; } // ⚠️ All window access (listeners/postMessage) must stay inside createYtMusicAdapter(). // Since this module is also included in the service worker bundle (via LtSession), touching window at the module top level breaks the SW load. (ADR 004) export function createYtMusicAdapter(): YtMusicAdapter { let latest: PlaybackState | null = null; const subscribers = new Set<() => void>(); window.addEventListener("message", (e: MessageEvent) => { const d = e.data; if (e.source !== window || !d || d.__lt !== "state") return; latest = d.state as PlaybackState; for (const fn of subscribers) fn(); }); const sendCmd = (cmd: string, arg?: unknown): void => { window.postMessage({ __lt: "cmd", cmd, arg }, "*"); }; return { getState: () => latest, play: () => sendCmd("play"), pause: () => sendCmd("pause"), seek: (sec: number) => sendCmd("seek", sec), loadTrack: async (trackId: string) => sendCmd("loadTrack", trackId), onStateChange: (cb: () => void) => { subscribers.add(cb); return () => subscribers.delete(cb); }, }; }
"이 페이지의 곡 추가" 되살리기
🗣️ 내가 요청한 것
"YouTube Music에 있는 것 같은 버튼이 있어야 해요. 위쪽 익스텐션 아이콘을 누르면 '이 페이지의 곡 추가'가 없어야 하고요. 근데 YouTube Music 안에서 버튼을 누르면 같은 창에 '이 페이지의 곡 추가'가 있어야 해요."
🤖 Claude가 한 것
이건 두 조각으로 갈라졌습니다. 먼저, 페이지에 현재 렌더링된 트랙 행들을 긁어 오는 스크레이퍼. ★ 함정: YT Music은 가상 스크롤을 쓰기 때문에, 화면에 보이는 행만 실제로 DOM에 존재합니다. 그래서 맨 위로 스크롤한 다음 한 화면씩 자동으로 스크롤하면서 Map에 쌓고, 개수가 더 안 늘고 바닥에 닿으면(연속 4번 카운트가 안정되면) 멈춥니다. 의도적으로 best-effort 수집입니다. 둘째, 곡의 "⋮" 컨텍스트 메뉴에 "Hummo 큐에 추가" 항목을 끼워 넣는 인젝터. MutationObserver가 Polymer 메뉴(tp-yt-paper-listbox#items)가 나타날 때마다 항목을 슬쩍 끼워 넣고, videoId는 메뉴 안의 링크에서 뽑아냅니다. 제목과 아티스트는 메뉴 자체에는 없어서, 캡처 단계에서 pointerdown을 가로채 당신이 ⋮를 누른 원래 행을 기억해 뒀다가 거기서 읽어 옵니다. 룸이 없으면 항목이 회색으로(비활성) 표시되며 "먼저 룸을 만들거나 참여하세요"라고 안내합니다. 이 셀렉터들은 살아 있는 YT Music DOM에 빡빡하게 묶여 있고 추측이 불가능해서, 실제 코드로 남깁니다.
/** * Scrapes the track rows rendered on the current YT Music page (playlist/album, etc.). * ⚠️ YT Music uses virtual scrolling → only the rows loaded on screen are captured. Long lists must be scrolled to load more. * The full list (including unrendered rows) requires the InnerTube API — follow-up (M03). */ import type { QueueItem } from "@lt/shared"; export function scrapePageTracks(by: string): QueueItem[] { const rows = document.querySelectorAll("ytmusic-responsive-list-item-renderer"); const items: QueueItem[] = []; const seen = new Set<string>(); rows.forEach((row) => { const link = row.querySelector<HTMLAnchorElement>('a[href*="watch?v="]'); const id = link?.getAttribute("href")?.match(/[?&]v=([^&]+)/)?.[1]; if (!id || seen.has(id)) return; seen.add(id); const title = row.querySelector(".title")?.textContent?.trim() ?? ""; const artist = row.querySelector(".secondary-flex-columns yt-formatted-string")?.textContent?.trim() ?? ""; const thumb = row.querySelector("img")?.getAttribute("src") ?? ""; items.push({ trackId: id, title, artist, by, thumb }); }); return items; } const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); /** Best-guess for the YT Music main scroll container (the element the virtual list scrolls within) */ function findScroller(): HTMLElement { const cands = [ document.querySelector<HTMLElement>("ytmusic-app-layout #contentContainer"), document.querySelector<HTMLElement>("#contentContainer"), document.scrollingElement as HTMLElement | null, document.documentElement, ].filter(Boolean) as HTMLElement[]; return cands.find((el) => el.scrollHeight > el.clientHeight + 50) ?? document.documentElement; } /** * Auto-scrolls the page and collects all tracks (accumulating rows before the virtual scroll unmounts them). * It's not an exact "full" list (InnerTube is the proper way), but it captures most of even long lists. */ export async function scrapeAllPageTracks(by: string, onProgress?: (n: number) => void): Promise<QueueItem[]> { const map = new Map<string, QueueItem>(); const collect = () => { for (const it of scrapePageTracks(by)) if (!map.has(it.trackId)) map.set(it.trackId, it); }; const el = findScroller(); // Start from the top in case of repeated runs (if a previous scrape left us at the bottom, the virtual list renders only some rows → too few / 0 captured) el.scrollTop = 0; window.scrollTo(0, 0); await sleep(250); let stable = 0; for (let i = 0; i < 300 && stable < 4; i++) { const before = map.size; collect(); onProgress?.(map.size); const top = el.scrollTop; const step = Math.max(700, el.clientHeight * 0.85); el.scrollTop = top + step; window.scrollBy(0, step); await sleep(300); collect(); const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 8 || el.scrollTop === top; stable = map.size === before && atBottom ? stable + 1 : 0; } return [...map.values()]; }
/** * Injects an "Add to Hummo queue" item into the YT Music "⋮" context menu. * * Based on the actual DOM (verified on live YT Music, 2026-06-16): * menu = <tp-yt-paper-listbox id="items" class="...ytmusic-menu-popup-renderer"> * items = <tp-yt-paper-item> / <ytmusic-menu-service-item-renderer>, etc. * videoId = extracted from the menu's 'a[href*="watch?v="]' (e.g. "Start radio") * * ⚠️ title/artist aren't directly in the menu, so we read them best-effort from the * original list row that the ⋮ was clicked on. */ export interface MenuSong { trackId: string; title: string; artist: string; thumb: string; } const LISTBOX = "tp-yt-paper-listbox#items.ytmusic-menu-popup-renderer"; const ITEM_FLAG = "data-lt-item"; interface InjectorOpts { hasRoom: () => boolean; onAdd: (song: MenuSong) => void; } export function installMenuInjector(opts: InjectorOpts): () => void { // Remember the original row the ⋮ was clicked on (source of title/artist). Captured in the capture phase. let lastRow: Element | null = null; const onPointerDown = (e: Event) => { const t = e.target as Element | null; const row = t?.closest?.( "ytmusic-responsive-list-item-renderer,ytmusic-list-item-renderer,ytmusic-player-queue-item,ytmusic-two-row-item-renderer", ); if (row) lastRow = row; }; document.addEventListener("pointerdown", onPointerDown, true); const observer = new MutationObserver(() => { document.querySelectorAll<HTMLElement>(LISTBOX).forEach((box) => injectInto(box, opts, () => lastRow)); }); observer.observe(document.documentElement, { childList: true, subtree: true }); return () => { document.removeEventListener("pointerdown", onPointerDown, true); observer.disconnect(); }; } function injectInto(listbox: HTMLElement, opts: InjectorOpts, getRow: () => Element | null): void { if (listbox.querySelector(`[${ITEM_FLAG}]`)) return; // already injected const song = extractSong(listbox, getRow()); const enabled = opts.hasRoom() && !!song; const item = document.createElement("tp-yt-paper-item"); item.setAttribute(ITEM_FLAG, "1"); item.setAttribute("role", "menuitem"); item.setAttribute("tabindex", "-1"); item.className = "style-scope ytmusic-menu-popup-renderer"; item.style.cssText = "display:flex;align-items:center;gap:16px;padding:0 16px;min-height:40px;box-sizing:border-box;" + `cursor:${enabled ? "pointer" : "default"};opacity:${enabled ? "1" : "0.4"}`; item.innerHTML = `<div style="width:18px;height:18px;flex-shrink:0;display:flex;align-items:center;justify-content:center">🎵</div>` + `<span style="font-size:14px;white-space:normal;overflow-wrap:break-word;min-width:0">${ enabled ? "Add to Hummo queue" : "Create or join a room first" }</span>`; if (enabled && song) { item.addEventListener("click", (e) => { e.stopPropagation(); opts.onAdd(song); // Close the menu (esc) document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); }); } listbox.insertBefore(item, listbox.firstChild); } function extractSong(listbox: HTMLElement, row: Element | null): MenuSong | null { const link = listbox.querySelector<HTMLAnchorElement>('a[href*="watch?v="]'); const href = link?.getAttribute("href") ?? ""; const trackId = href.match(/[?&]v=([^&]+)/)?.[1] ?? ""; if (!trackId) return null; // title/artist are best-effort from the original row (TODO: refine selectors against live) const title = row?.querySelector(".title")?.textContent?.trim() ?? row?.querySelector("yt-formatted-string.title")?.textContent?.trim() ?? ""; const artist = row?.querySelector(".secondary-flex-columns yt-formatted-string")?.textContent?.trim() ?? row?.querySelector(".subtitle")?.textContent?.trim() ?? ""; const thumb = row?.querySelector("img")?.getAttribute("src") ?? ""; return { trackId, title, artist, thumb }; }
🧪 내가 잡은 것 (QA)
"'이 페이지의 곡 추가'가 수집한 곡 0개에서 멈춰 있어요."
원인: 스크레이프 요청이 서비스 워커를 거쳐 왕복(에이전트 → SW → 에이전트)하면서 타임아웃에 걸렸고, 결과가 끝내 돌아오지 않았습니다. 수정: 페이지 안 패널은 어차피 DOM 접근 권한이 있으니, 서비스 워커를 거치지 않고 scrapeAllPageTracks를 페이지 안에서 로컬로 돌리도록 바꿨습니다.
"Uncaught Error: Extension context invalidated가 떠요."
원인: 익스텐션을 리로드하면 옛날 콘텐츠 스크립트가 죽은 상태로 남아서 이미 죽은 chrome.runtime에 계속 재연결을 시도합니다. 수정: 최초 연결과 재연결 경로 양쪽에 if (!chrome.runtime?.id) return 가드를 넣어서, 컨텍스트가 무효화되면 재연결을 멈추고 에러 도배를 끊었습니다(탭을 새로고침하면 새 스크립트가 붙습니다).
3. UI와 언어
여기는 사용자가 실제로 보는 모든 걸 만든 구간입니다. 화면은 두 곳에 떠야 합니다. 팝업(툴바 아이콘)과 YouTube Music 안의 페이지 패널, 그리고 둘은 똑같이 보여야 했습니다. 그래서 화면 코드는 하나로 두고, 그 위에 한국어 / 일본어 / 영어 사전을 얹었습니다. 여기서 제가 결정한 건 거의 다 무엇을 보여줄 것인가에 관한 것이었습니다. 하트와 정렬, 일방향 팔로우 핑, 자기 언어의 룸만 보이는 둘러보기 탭, 무료는 3명까지, 그런 제품적 판단들. 그리고 맨 마지막에, 가입 폼이 너무 죽어 있게 느껴져서 즉시 검증과 로딩 상태를 추가하게 했습니다.
화면 하나, 두 집: 팝업 + 페이지 안
🗣️ 내가 요청한 것
이건 제가 요청한 게 아닙니다. 제가 원한 건 "친구·팀원과 YouTube Music을 동기화해서 듣는 익스텐션"뿐이었고, 같은 화면이 두 곳(팝업과 페이지 안)에 렌더링되면서도 똑같이 보여야 한다는 건 그 목표를 구현하면서 Claude가 내린 설계 결정이었습니다.
🤖 Claude가 한 것
화면을 두 번 만드는 대신, Claude는 이걸 단일 렌더 함수로 통합했습니다. mountApp(root, session, opts) 하나가 팝업과 페이지 안 패널을 둘 다 그리고, 분기되는 건 opts.mode가 "popup"이냐 "page"냐 뿐입니다. 핵심 결정 두 가지. (1) 이 함수는 서버 연결을 직접 들고 있지 않습니다. 세션은 바깥에서 주입됩니다. 그래서 팝업은 서비스 워커의 세션을 메시지로 중계하는 프록시를 넘기고, 페이지 안 버전은 자기 세션을 넘기고, 양쪽에 같은 화면이 렌더링됩니다. (2) 모드 차이는 딱 두 군데에만 삽니다. "이 페이지의 곡 추가" 버튼(벌크 스크레이퍼)은 페이지 안(mode === "page")에서만 나타나고, 재렌더가 필요할 때 팝업은 그냥 창을 리로드하지만 페이지 안 버전은 YouTube Music의 DOM을 절대 건드리지 않고 패널만 다시 마운트합니다. 스타일은 외부 CSS 없이 하나의 큰 인라인 <style> 블록에 욱여넣은 다크 테마입니다. 배경 #0b0b0e, 텍스트 #f1f1f3, 강조색은 Hummo 오렌지 #ff5722, 패널 폭 312px. 팝업은 그 같은 화면을 가져다가 덮어쓰기만 합니다. 떠다니는 버블을 숨기고, 패널을 창 가득 늘립니다. 파일은 1,000줄이 넘고 일부러 안 쪼갰습니다. 모든 화면이 하나의 클로저 안에서 같은 상태 변수(screen, 탭들, 인증 모드 등)와 같은 템플릿을 공유해야 하기 때문입니다.
하트 & 정렬 / 일방향 팔로우 + 배지 핑 / 언어별 격리 둘러보기
🗣️ 내가 요청한 것
"룸마다 추천 같은 거? 하트 누를 수 있는 게 있으면 좋겠어요. 그럼 인기순, 인원 많은 순, 그런 걸로 정렬할 수 있고요. 채팅이 있으니까 알림도 있어야 하나? 내가 즐겨찾기한 유저가 룸을 만들면 핑이 오는 것처럼요." "둘러보기도 언어 설정에 따라 다르게 동작해야 할 것 같아요. 내 언어로 설정한 유저가 만든 룸만 둘러보기에 떠야 해요."
(일방향 팔로우를 상호 친구와 분리한 건 제가 명시한 게 아닙니다. "내가 팔로우한 유저가 룸을 만들면 핑을 줘"를 구현하면서 Claude가 도달한 모델입니다.)
🤖 Claude가 한 것 Claude는 메인 화면을 세 탭으로 갈랐습니다 (친구 / 팔로잉 / 둘러보기).
- 하트 & 정렬: 둘러보기 탭은 정렬 상태(
browseSort)를 들고 있고 인원 많은 순(active), 새 룸(new), 하트 많은 순(hearts) 사이를 토글합니다. 어떤 룸이든 하트를 누를 수 있고, 그 개수가 정렬 키가 됩니다. - 일방향 팔로우 + 핑: 친구는 상호적이고 서로 수락해야 하지만, 팔로우는 일방향입니다. 상대 동의 없이 팔로우하고, 그 사람이 룸을 열면 핑을 받습니다. 핵심 결정은 그 핑을 화면 안 목록이 아니라 익스텐션 아이콘의 배지 숫자로 드러낸 것이었습니다. 팝업을 열면(또는 다시 보면) 읽음 처리되고 배지가 리셋됩니다.
- 언어별 격리 둘러보기: 둘러보기의 룸들은 클라이언트에서 필터링되는 게 아닙니다. 서버가 같은 언어의 룸만 내려보냅니다. 한국어로 설정한 유저는 한국어 유저가 만든 룸만 보고, 일본어는 일본어만 보는 식입니다. 발견용 화면에서 "내 언어로 된 룸이 없다"는 콜드 스타트 느낌을 피하려고, 빈 상태에서는 언어별 힌트 문구(
langScope)를 보여줍니다.
다듬기 패스
🗣️ 내가 요청한 것
"버튼이랑 클릭되는 것들이 좀 어색해요. 애니메이션이 아예 없어서요. 꼼꼼하게 한번 봐줄래요?" "지금은 셔플이 그냥 적용되는데, 다시 누르면 원래대로 돌려놓게는 못 하나요?" "여기 닉네임 설정이랑 계정 로그아웃이 있을 이유가 없어요. 그리고 닉네임 저장을 눌러도 '저장됨!' 같은 게 없어서 됐는지 알 수가 없어요. 무료는 3명까지로 해주세요."
🤖 Claude가 한 것
세 가지 수정. (1) 죽어 보이던 인터랙션에 hover/active 트랜지션을 인라인 <style>에 추가해서 클릭이 실제로 뭔가 되는 느낌이 들게 했습니다. CSS만, 새 컴포넌트는 없습니다. (2) 셔플을 토글로 만들었습니다. 처음 누르면 원래 큐 순서를 따로 챙겨 두고, 다시 누르면 그 보관본에서 복원합니다. 핵심은 매번 다시 셔플하는 게 아니라 "원래대로 돌려놓기"였으니, 원래 순서를 붙들고 있어야 했습니다. (3) 룸 설정 화면을 정리했습니다. 닉네임/로그아웃을 거기서 빼서(메인 설정으로 이동) 옮기고, 무료 정원을 FREE_ROOM_CAPACITY = 3으로 고정했습니다. 4번째 사람부터는 참여/생성이 결제 게이트에 걸리고 "무료 룸은 3명까지" 식의 메시지가 뜹니다. 그 3은 딱 한 곳에 정의됩니다. 서버와 UI가 둘 다 같은 상수를 import 합니다.
가입 즉시 검증 + 로딩 (v0.1.1)
🤖 Claude가 한 것 이건 가입/로그인 폼이 서버 왕복을 기다리는 동안 피드백이 전혀 없던 걸 고친 패치입니다. 두 가지가 들어갔습니다.
- 즉시 검증: 제출 시 서버에 뭔가 닿기 전에 클라이언트가 먼저 검사하고, 통과 못 하면 즉시 멈춥니다. 순서는 고정입니다. ① 이메일이 정규식에 안 맞으면
err_bad_email, ② 비밀번호가 4자 미만이면err_short_pw, ③ 가입인데 닉네임이 비어 있으면err_no_nick. 이메일 정규식은 서버와 글자 하나까지 똑같아야 합니다(/^[^\s@]+@[^\s@]+\.[^\s@]+$/). 클라이언트가 통과시킨 걸 서버가 거부하는 상황은 피해야 하니까요. 이메일과 닉네임은 trim 하고, 비밀번호는 있는 그대로 받습니다. - 로딩 + 중복 제출 가드:
authBusy플래그가 막아 줍니다. 검증을 통과하면 요청이 나가기 전에 버튼이 disabled로 잠기고 레이블이"…"로 바뀝니다.finally블록이 무조건 플래그를 비우고 버튼의 원래 레이블을 복원합니다. 성공이든, 실패든, 예외든 똑같이요. 이게 없으면 한 번 실패한 폼이 영영 잠긴 채로 남기 때문입니다. 성공 시에는 직접 화면을 전환하지 않습니다(세션이 로그인 상태로 뒤집히면 앱이 알아서 메인으로 라우팅합니다).
🧪 내가 잡은 것 (QA)
"가입을 눌렀는데 됐는지도 모르겠고, 이메일/닉네임 체크도 없는 것 같은데요?" (런칭 후 실제 사용 중 발견) 원인은 폼이 검증도 로딩도 없이 그냥 전부 서버로 던지면서 아무 말도 안 했던 것입니다. 사용자는 자기가 눌렀는지조차 알 수 없었습니다. 위의 즉시 검증 + 로딩 상태로 패치했습니다 (v0.1.1).
공유 i18n — 사전 하나, 두 컨텍스트
🤖 Claude가 한 것
en/ko/ja 사전과 t("key", { n }) 보간 외에는 아무것도 없는 가벼운 i18n 레이어입니다. 외부 라이브러리 없이 그냥 키→문자열 사전에 {param} 치환을 더한 것이고, UI는 오직 이 t()를 통해서만 문자열을 가져옵니다. 서버는 에러를 코드로만 던지고(err_room_full, err_email_taken 등) UI가 그 코드를 현지화합니다. 그래서 서버의 에러 텍스트조차 전부 이 사전 안에 삽니다. 이 레이어가 존재하는 진짜 이유는 로케일이 어디에 저장되느냐에 있습니다. 흔히 쓰는 localStorage는 origin별로 분리되어 있어서, 팝업(익스텐션의 origin)과 페이지 안 패널(youtube.com의 origin)이 서로 다른 저장소를 읽습니다. 언어가 갈라진 게 바로 이것 때문이었습니다. 로케일을 **chrome.storage.local(키 lt:locale)**에 두면 익스텐션 안의 모든 컨텍스트가 한 값을 공유합니다. 마운트 전에 그 공유 값을 await 해야 양쪽이 같은 언어로 떠오르고, 한쪽에서 언어를 바꾸면 chrome.storage 변경 이벤트가 다른 쪽을 즉시 새로고침합니다(팝업은 리로드, 페이지 안은 재마운트). 기본값은 브라우저 언어에서 감지하되 사전에 없으면 영어로 폴백하고, 옛 localStorage 값은 딱 한 번만 마이그레이션합니다.
🧪 내가 잡은 것 (QA)
"YouTube Music에서의 언어랑 툴바 팝업에서의 언어가 다른 것 같은데요?" 원인은 로케일을
localStorage에 저장한 것이었습니다. origin이 다르니 팝업과 페이지 안 패널이 서로 다른 저장소를 읽고 있었습니다. 저장소를chrome.storage.local로 옮겨서 두 컨텍스트가 한 값을 공유하게 고쳤습니다.
"룸을 만들면 갑자기 메인 화면으로 튀는데, 왜 그런 거죠?" 원인은 현재 화면(
screen)을 세션 상태에서 파생하지 않고 따로 들고 있었던 것이었습니다. 세션 뷰가 새로고침될 때마다 화면이 초기 상태로 되돌아갔습니다.screen을 세션 상태에서 파생되도록 바꿔서, 룸을 만들면 그 룸 화면에 그대로 머물게 했습니다.
4. 인프라 — Claude가 lavela를 추천했습니다
이 파트는 딱 하나에 관한 이야기입니다. 실시간 서버를 어디서 돌릴 것인가, 그리고 결제를 어떻게 붙일 것인가. 전체 빌드 과정에서 가장 무서웠던 부분이었습니다. WebSocket 서버를 어디서 돌리는지, TLS는 어떻게 처리하는지, 배포 파이프라인이라는 게 대체 뭔지 — 하나도 몰랐습니다. 그런데 정작 제가 여기서 직접 한 일은 두 가지뿐이었습니다. 콘솔에 가입하고, MCP 서버를 등록하는 한 줄을 붙여넣은 것. 나머지는 Claude가 알아서 세워줬습니다. 컴퓨트 비용은 한 달에 몇 달러 수준이었고, DevOps는 단 한 번도 건드리지 않았습니다.
인프라 세우기 — lavela 연결부터 컴퓨트, 호스팅, 결제까지
🗣️ 제가 요청한 것
"자, 이제 결제 다시 붙일 시간이다." / "실시간 서버도 띄우자."
요청은 이게 전부였습니다 — "결제 붙이고, 실시간 서버 띄우고, 쉽게 해줘." 정밀한 인프라 스펙 같은 건 없었습니다. 서버를 어디서 어떻게 돌리는지 전혀 몰랐고, 그냥 쉽기를 바랐을 뿐입니다. 바로 그 지점에서 Claude가 lavela를 추천했습니다. 그러니 아래에 나오는 것들 — 컴퓨트 티어 선택, scale-to-zero, optimistic confirm — 어느 것도 제가 지시한 게 아닙니다. "쉽게, 그냥 띄워줘"를 실제로 굴러가게 하려고 Claude가 끼워 맞춘 경로입니다.
🤖 Claude가 한 것
Claude가 추천한 건 lavela였습니다. 핵심 아이디어는 *"DevOps를 통째로 대체한다"*는 것입니다 — 제가 서버를 돌리는 대신, lavela를 Claude Code에 MCP 서버로 연결해두면, 거기서부터 Claude가 tool call을 통해 컴퓨트, 호스팅, 결제를 직접 처리합니다.
그래서 제가 한 일은 딱 두 가지였습니다. lavela 콘솔(https://console.lavela.dev)에 가입하고, 터미널에서 한 줄을 실행한 것.
npx @lavela/cli connect
실행하면 브라우저가 열리면서 lavela로 이동합니다 → 로그인(이미 되어 있으면 건너뜀) → "Authorize & connect" 클릭. 그러면 CLI가 토큰을 곧장 Claude Code 설정에 써넣습니다 — 토큰은 브라우저 URL에도, 히스토리에도, 채팅에도 나타나지 않습니다(보안상 이게 핵심입니다). Claude Code 세션을 새로 열면 lavela가 자동 로드되고(이미 열려 있으면 /mcp → Reconnect), 거기서부터는 "deploy my app with lavela" 한마디면 됩니다.
왜 굳이 CLI냐고요? Claude Code 내장
/mcpAuthenticate 플로우가 macOS localhost/IPv6 콜백 버그에 걸려 넘어지기 때문에, CLI가 자체 127.0.0.1 loopback으로 이걸 우회합니다.
연결이 살아나는 순간 mcp__lavela__* 툴을 Claude 안에서 바로 호출할 수 있게 되고, 거기서부터 Claude가 전부 알아서 세팅했습니다.
Claude가 밟은 경로와 호출한 콜들은 이렇습니다.
- 시작점:
create_project로 프로젝트를 만들고, 반환된projectId를 이후 모든 호출에 붙였습니다. 상태 확인은 항상get_status로 처리했는데, 이건 read-only에 무료라 폴링 비용이 들지 않습니다. - 실시간 서버 → 컴퓨트 (
provision_compute_from_repo): 레포를 가리켜주니 lavela가 그걸로 서버 측에서 이미지를 빌드했고 — 로컬 Docker도, 신경 쓸 레지스트리도 없이 — TLS까지 포함해서 세워주고 라이브 HTTPS URL을 돌려줬습니다. 비용은 배포 전에compute_estimate(read-only, 무료)로 확인했는데 — 월 ~$3.32, 실제로 청구되는 가격이고, scale-to-zero 덕분에 유휴 상태 서버는 거의 $0입니다. 몇 달러짜리 실시간 서버라니. Claude는 함정들도 짚어줬습니다. 배포된 서버에는 로컬.env가 없으니 환경변수는set_compute_secret으로 주입해야 하고, 유휴 시 머신이 멈추기 때문에(scale-to-zero / autostop) 첫 요청은 cold-start 지연을 맞습니다(멈춰 있으면compute_lifecycle로 깨웁니다). - 정적 사이트 → 호스팅 (
provision_hosting): 랜딩과 법적 고지 페이지들을 브랜디드.lavela.dev서브도메인에 자동 TLS와 함께 올렸습니다. 커스텀 도메인 필요 없이 그냥 띄워서 굴러갔습니다. 갱신은redeploy로 처리합니다. - 결제 → Stripe (
connect_stripe→create_checkout):connect_stripe가 온보딩 URL을 발급하고, KYC를 마치면charges_enabled=true로 뒤집힌 다음,create_checkout(구독, 월 $2.99)가 Stripe Checkout URL을 돌려주는데 이걸 서버에LT_CHECKOUT_URL로 꽂아넣습니다. 그리고 정작 중요한 부분: lavela는 Hummo가 버는 돈에서 0%를 가져갑니다 — $2.99 전액이 Stripe Connect를 통해 온전히 제게 정산되고, lavela는 제 매출이 아니라 자기가 돌리는 컴퓨트에서 돈을 법니다. 결제 확인은 webhook 인프라를 통째로 건너뛰고 optimistic confirm으로 갔습니다(Checkout이 새 탭에서 열리고, 사용자가 "결제 완료"를 누르면 PRO가 적용됨) — 데모의 타임박스에 맞추려고 내린 결정입니다.
Dockerfile 자체는 Claude가 쓴 슬림한 컨테이너였습니다. 빌드 컨텍스트를 레포 루트로 잡고, @lt/server와 그 워크스페이스 의존(@lt/shared)만 설치하고, 빌드 스텝을 건너뛴 채 tsx로 바로 실행합니다. (함정: 컨텍스트를 server/로 좁히면 워크스페이스 의존이 깨집니다.) 어느 쪽이든 lavela가 그걸로 이미지를 빌드해서 배포했고 — 저는 명령 한 줄을 실행하고 승인했을 뿐입니다.
도메인은 일부러 안 샀습니다. lavela는 도메인을 팔지 않고, 이미 가지고 있는 도메인을 연결해주기만 합니다(provision_domain). 데모에는 .lavela.dev 서브도메인으로 충분해서 그냥 썼습니다. 연간 도메인 비용: 0원.
🧪 제가 잡은 것 (QA)
"재배포했는데 사이트가 아직 옛날 버전을 보여주고 있어."
원인은 고정돼버린 브랜디드 도메인 alias였습니다. 재배포를 해도 .lavela.dev는 계속 옛 배포를 가리키고, raw URL만 새 빌드를 서빙하고 있었습니다. Claude는 URL에 ?cb=$(date +%s)로 캐시버스트를 시도했는데도 옛 콘텐츠가 떴고 → 그래서 CDN 캐시를 배제하고 alias 쪽으로 범위를 좁혔습니다. list_deployments로 확인해보니 current(새 해시)와 등록된 배포의 url(옛 해시)이 일치하지 않았습니다. 이런 종류는 클라이언트가 다시 요청한다고 해결되는 게 아닙니다 — 플랫폼이 최신 배포를 promote 해줘야 풀립니다 — 그래서 제가 신고하니까 풀렸습니다(그 이후로는 재배포할 때마다 ~30초 안에 정상적으로 갱신됐습니다). 신생 플랫폼이라 가끔 날카로운 모서리가 있긴 합니다 — 하지만 "내가 직접 서버를 돌린다"에는 발끝에도 못 미치는 수준입니다.
5. 이름, 로고, 사이트 & Chrome Web Store 출시
이 시점에 모든 게 작동은 했습니다 — 그런데 이름이 사실상 경쟁사 이름이었습니다. 작업용 가제는 "ListenTogether"였는데, 이건 제가 벤치마킹하던 제품(listentogether.now)을 철자 하나 안 틀리고 그대로 가져온 거라, 전체가 그냥 클론처럼 읽혔습니다. 그래서 이 마지막 구간은 새 이름을 고르고, 로고를 만들고, 랜딩 + 법적 페이지와 스토어 에셋을 생성하고, Chrome Web Store에 제출해서 "심사 중"까지 가는 일이었습니다. 이 며칠은 코드를 거의 건드리지 않았습니다 — 전부 브랜딩, 에셋, 컴플라이언스였습니다.
리브랜딩 (이름 & 로고)
🗣️ 제가 요청한 것
"이름 베끼기 싫어. 새 이름 만들고 싶어 — 로고도 새로." "그냥 다른 이름으로 갈까?"
제가 한 건 "벤치마크 이름 베끼는 거 그만하고 새 이름 만들자, 로고도"를 툭 던진 게 전부입니다. 110개 후보, whois 조회, 상표 검색, 여러 라운드의 프로세스 — 그건 제가 요청한 게 아닙니다. Claude가 "남이 이미 쓰고 있는 이름을 실제로 쳐낸다"는 목표에서 도출한 것입니다.
🤖 Claude가 한 것 Claude는 이름을 한 방에 맞히려 하지 않았습니다 — 네이밍을 여러 라운드의 병렬 에이전트로 돌렸습니다. 3라운드에 걸쳐 병렬 에이전트로 후보 ~110개를 생성한 뒤, 각각을 삼중 필터에 통과시켰습니다: (1) 도메인 가용성(whois/RDAP 조회), (2) 상표 검색, (3) 글로벌 발음 가능성(영어 그리고 비영어권 시장에서 어떻게 읽히는지). 핵심 결정은 이거였습니다 — 포인트는 "예쁜 이름"을 고르는 게 아니라 남이 이미 쓰고 있는 이름을 제거하는 것이었습니다. 실제로 쳐낸 것들 — Tempa(유명한 덥스텝 레이블), Synca(60개국에서 파는 안마의자 브랜드), Curo(호주 사이니지 회사 + 미국 금융 회사), Choira(이미 존재하는 실시간 음악 협업 앱), Soneo(팟캐스트 앱), Unira(앱스토어에 이미 올라온 의료 앱). 여기서 얻은 교훈: 짧고 예쁜 이름은 .com/.app에서 거의 다 임자가 있고, 상표에서도 충돌합니다. 살아남은 건 Hummo였습니다(hum = 음악을 "하는" 가장 보편적이고 언어에 구애받지 않는 방식). 로고는 주황 그라데이션 타일 + 흰색 5바 이퀄라이저인데, 이퀄라이저 바들이 U자로 휘어져 미소이자 음파로 동시에 읽힙니다 — "함께 흥얼거리는 소리가 미소로 바뀐다"는 발상입니다. 결국 커스텀 도메인은 끝까지 안 샀고 — lavela 서브도메인을 그대로 썼습니다.
사이트 & 에셋
🗣️ 제가 요청한 것
"깔끔한 사이트 하나 만들고, 확장 프로그램 리스팅용 이미지도 — 아이콘, 스크린샷, 프로모 타일." "잠깐 — 약관/법적 페이지가 다국어가 아닌 것 같은데? 사이트에 빠진 게 있는지 꼼꼼히 좀 훑어봐 줄래?"
제 요청은 그 정도로 느슨했습니다 — "깔끔한 사이트랑 리스팅 이미지 만들어줘"(평소에 보기 좋다고 생각하던 사이트 하나를 참고용으로 같이 건넸습니다), 그리고 한 번 보고 나서 "약관이 다국어가 아닌 것 같으니 꼼꼼히 훑어봐 줘." 언어 선택기, 비제휴 고지, 7차원 감사, 병렬 에셋 생성 — 그 구체적인 것들은 제가 스펙을 적은 게 아니라 Claude가 그 막연한 "꼼꼼히 훑어봐"를 풀어낸 것입니다.
🤖 Claude가 한 것 랜딩은 다크/액센트 마케팅 페이지로 빌드됐고(Claude가 제가 참고로 보여준 마케팅 사이트에서 구조를 빌려왔습니다), **법적 페이지 3개(개인정보 / 약관 / 지원)**가 붙었습니다. 핵심 결정 하나: 법적 페이지를 다국어로 만들었습니다 — en/ko/ja + 언어 선택기. 논리는 명확했습니다 — Chrome Web Store 심사자는 영어 사용자일 가능성이 높고, 법적 페이지가 한 언어에 박혀 있으면 그게 곧 즉시 벽이 됩니다. 또 푸터에 비제휴 고지("YouTube/Google과 제휴 관계 아님")를 넣어서 저작권이나 제휴 혼동을 미리 차단했습니다. 스토어 에셋은 에이전트들이 병렬로 생성했는데, 동일한 디자인 시스템 아래 한 번에 — 128×128 아이콘, 1280×800 스크린샷 5장, 작은 440×280 프로모 타일, 1400×560 마키, 1200×630 OG 이미지. SVG로 디자인하고 PNG로 렌더링. 제출 직전에는 7차원 감사를 돌렸는데(이것도 병렬 에이전트), 법적 사항, 다국어 커버리지, CTA, 접근성(WCAG), SEO, 비제휴 고지 같은 축을 적대적으로 점검하고 문제를 자동으로 끄집어냈습니다.
🧪 제가 잡은 것 (QA)
솔직히 이 단계의 버그들은 감사 에이전트가 저 대신 잡아줬습니다. 감사는 "랜딩의 연락처 이메일에 MX 레코드가 없어서 메일이 바운스된다"를 (블로커로) 짚어냈고, 동시에 "모든 설치 CTA가 죽은 #install 앵커를 가리킨다", "법적 페이지 3개가 한국어에 박혀 있어서 영어 사용자 심사자에게 벽이 된다"까지 잡아냈습니다 → 전부 수정. 스토어 PNG도 한 건이었습니다. sharp가 RGBA(알파 채널 포함)로 내보냈는데, Web Store는 알파 없는 24-bit를 원합니다. 그래서 리젝당하기 직전에 flatten으로 알파를 벗겨냈습니다 — 다만 아이콘은 원래 투명해야 하니까, 그건 flatten에서 제외했습니다.
Web Store 제출
🗣️ 제가 요청한 것
"일단 제출부터 하자. 어떻게 하는지 알려줘."
제가 한 말은 "일단 제출하자, 방법 알려줘"가 전부였습니다. 권한을 storage + alarms로 깎아내리고, host permission을 단 하나 music.youtube.com으로 좁히고, single purpose를 "재생 상태만 동기화한다"로 못 박은 것 — 그건 제가 요청한 게 아닙니다. "오디오는 절대 전송하지 않고 상태만 동기화한다"는 이 앱의 최상위 설계에서 필연적으로 떨어져 나온 것이고, Claude가 그걸 끝까지 밀고 나간 것입니다.
🤖 Claude가 한 것
제출의 핵심은 #1 리젝 사유인 "권한 과다"와 "저작권 의심"을 미리 차단하는 것이었습니다. 그래서 매니페스트 권한을 최소로 깎았습니다 — storage와 alarms만, 그리고 host permission은 단 하나 music.youtube.com(<all_urls>, tabs, scripting 같은 넓은 권한은 전부 뺐습니다). privacy 탭에는 single purpose를 *"재생 상태만 동기화하며, 오디오를 스트리밍/녹음/재배포하지 않는다"*로 명시해 저작권 우려를 선제적으로 무력화했고, remote code는 "No"(정적 번들만, eval 없음, 외부 스크립트 없음)였습니다. privacy URL은 제출 전에 실제로 살아 있는 200을 반환하는지 확인했습니다. 빌드는 pnpm --filter @lt/extension build로 뽑았고, zip을 만들 때 manifest.json이 zip의 루트에 오도록 신경 썼습니다(dist 폴더 통째로 zip 하면 한 단계 더 중첩되어서 리젝당합니다). 폼 필드가 워낙 많아서, "Claude, 제출 체크리스트 만들어줘" 한 줄로 구체적인 값들을 표로 받아서 그냥 채워 넣었습니다.
출시 이후
출시 자체는 무탈하게 지나갔고(스토어 라이브: chromewebstore.google.com/detail/hummo/naepcidlgdlddijiglpelaigimblcida), 딱 한 순간 심장이 철렁한 게 있었습니다. 게시한 뒤, 제 브라우저의 확장 프로그램이 "추가 권한이 필요하다"는 다이얼로그를 띄우며 멈춰버린 것입니다. 속이 덜컥 내려앉았는데 — 알고 보니 업데이트 시 host permission을 다시 승인하라고 묻는 일회성 프롬프트였고, 신규 설치에는 아예 뜨지 않습니다. 업데이트를 배포하려면 manifest.version만 올리고 새 zip을 업로드하면 되고 — 새 권한을 추가하지만 않으면 — 기존 사용자에게는 재승인 프롬프트 없이 조용히 업데이트됩니다.
끝 — 저는 엔지니어가 아니었습니다
이 일에서 제 역할은 분명했습니다. 무엇을 만들지 정하고, 무엇이 망가졌는지 잡아내고(QA), 취향으로 방향을 잡는 것. 엔지니어링은 Claude가 했습니다.
그러니 "AI가 내 앱을 만들었다"는 절반만 사실입니다. AI가 가져간 건 구현입니다. 하지만 무엇을 만들지, 무엇이 잘못됐는지, 언제 멈출지는 여전히 제 몫이었습니다. 이제 벽은 구현이 아닙니다 — 판단과 취향입니다. 갇혀 있기에는 훨씬 더 나은 벽입니다.
지금 당장 누군가와 뭔가를 같이 듣고 싶다면, Hummo를 설치하세요. 같은 곡, 같은 초.
그리고 — 이 글 전체를 당신의 Claude에 붙여넣어 보세요. 어쩌면 당신만의 확장 프로그램이 나올지도 모릅니다.
댓글
아직 댓글이 없어요 — 첫 댓글을 남겨보세요.