ShareAux 개발기 #1 — 쓸만한 게 없어서 직접 만들어보기
아니 왜 이런게.. 없지?
디스코드에서 친구들이랑 음악 봇으로 같이 듣곤 했습니다. 근데 봇이 자꾸 끊기고, 음질도 들쭉날쭉하고, 대기열 관리도 불편했어요. Spotify 함께 듣기는 전원 구독이 전제고요.
어느 날 "그냥 내가 만들면 안 되나?" 하는 생각이 들었습니다. 방을 하나 만들고, 음악을 검색해서 틀면, 접속한 모두가 같은 노래를 같은 타이밍에 듣는 서비스. 내 서버에 올리면 구독료도 없고, 봇처럼 불안정하지도 않을거고요.
AI 에이전트를 바탕으로 제가 몰랐거나 경험이 부족한 스택에 대해서도 충분히 도움을 받을 수 있었고 모르면 물어보면서 한다는 생각에 바로 실제 구현으로 돌입했습니다.
첫 번째 갈림길: WebSocket vs HLS
만들겠다고 마음먹고 제일 먼저 부딪힌 질문은 "오디오를 어떻게 보내지?"였습니다.
처음엔 HLS를 생각했어요. 영상 스트리밍의 표준이니까요. 근데 조금만 파보면 HLS는 세그먼트 단위(2~6초)로 잘라서 보내는 방식이라, 사람마다 듣는 시점이 몇 초씩 다를 수밖에 없습니다. "같은 순간을 듣는다"가 핵심인데, 이러면 의미가 없죠.
그리고 결정적으로 — 셀프호스팅이라 CDN을 쓸 일이 없습니다. CDN 없는 HLS는 그냥 지연만 큰 HTTP 스트리밍이에요. WebSocket 대비 장점이 하나도 없었습니다.
WebSocket으로 방향을 틀었습니다. 서버에서 오디오를 실시간으로 쏘고, 받는 쪽에서 바로 재생하면 지연이 1~2초. 100명 방이어도 서버 아웃바운드 2MB/s 정도니까 홈서버로 충분합니다.
돌이켜보면 이 판단은 맞았어요. 다만 "WebSocket으로 바이너리를 직접 쏜다"는 결정이 이후 모든 복잡성의 시작이기도 했습니다.
두 번째 갈림길: socket.io vs raw ws
NestJS를 쓰기로 했으니 socket.io가 자연스러운 선택이었습니다. 근데 오디오 바이너리를 직접 다뤄야 하는 상황에서 socket.io의 추상화가 오히려 방해였어요. 모든 메시지에 이벤트 이름이 붙고, 내부 프로토콜로 감싸지고, 바이너리 처리도 깔끔하지 않았습니다.
raw ws 라이브러리로 HTTP upgrade를 직접 처리하기로 했습니다. 코드가 길어지는 대신, 첫 바이트 하나로 메시지 종류를 구분하는 바이너리 프로토콜을 자유롭게 설계할 수 있었어요.
결과적으로는 재연결, heartbeat, 인증을 전부 직접 구현해야 한다는 상황 때문에 커스터마이징 하는 것이 오히려 좋은 방향이었습니다. 귀찮았지만 후회는 없어요. 오디오 + 채팅 + 시스템 이벤트를 하나의 연결로 처리하는 구조가 깔끔하게 나왔으니까요.
전체 구조가 잡히기까지
클라이언트 (Next.js)
├── REST API ──→ NestJS ──→ PostgreSQL
└── WebSocket ←→ 방 게이트웨이
├── 0x01: 오디오 (바이너리)
├── 0x02: 채팅 (JSON)
└── 0x03: 시스템 이벤트 (JSON)
서버 내부
├── yt-dlp → YouTube 오디오 URL 추출
├── ffmpeg → fMP4 AAC 실시간 변환
└── Gemini → 가사 번역 (선택)
설계 원칙은 세 가지였습니다.
서버가 다 한다. 클라이언트는 재생만. 음원 검색, URL 추출, 포맷 변환 전부 서버 몫. 나중에 음원 소스를 바꿔도 클라이언트를 건드릴 필요가 없습니다.
외부 의존 최소화. docker compose up 한 번이면 전체가 뜨도록. 필수 외부 의존은 YouTube뿐이고, AI 번역은 없어도 동작합니다.
단일 포트 노출. Caddy로 하나의 포트만 열고, 내부에서 /api, /ws, /*를 라우팅. 외부 프록시 설정이 단순해집니다. 셀프호스트에서는 굳이 여러 서비스가 각각 포트를 개방할 필요도 없고 세팅도 좀 복잡합니다..
엔터프라이즈를 지향한 건 아니지만 유지보수를 꾸준히 가져가기 위한 선택은 필요했어요
NestJS는 꽤 복잡한 모듈과 비즈니스로직이 들어갈 걸 예상하고 골랐습니다. Swagger 자동 생성 → orval로 클라이언트 타입 연동까지 되는 것도 컸습니다.
Next.js + React 19는 Server Components로 번들을 줄이고, zustand(UI) + react-query(서버 데이터)로 상태를 나누는 구조가 마음에 들었습니다. React 19 Compiler 덕에 useMemo/useCallback 지옥에서 벗어난 것도 좋았고요.
오디오 포맷은 선택의 여지가 없었습니다. 브라우저 MSE가 받아들이는 건 사실상 fMP4뿐이고, Safari까지 커버하려면 AAC. 끝.
돌이켜보면
좋았던 점: 단일 WebSocket 연결로 모든 실시간 통신을 처리하는 구조가 깔끔하게 나왔습니다. 모바일에서 배터리도 덜 먹고, 재연결 로직도 한 곳에서 관리됩니다.
아쉬웠던 점: 초기에 "작게 시작하자"를 못 지켰어요. 처음부터 권한 시스템, AutoDJ, 가사 번역까지 스코프에 넣어버려서 MVP가 나오기까지 시간이 오래 걸렸습니다.
다시 한다면: MVP를 "방 만들기 + 음악 재생 + 채팅"으로 잡고 2주 안에 배포했을 겁니다. 나머지는 쓰면서 붙이는 게 맞았어요.