우리는 Next.js로 Full-Stack AI Music Agent를 구축했습니다 — 배운 점
Source: Dev.to
우리는 Next.js 로 풀스택 AI 음악 에이전트를 만들었습니다 – 배운 점들
소개
우리는 Next.js, OpenAI, 그리고 Spotify API 를 결합해 사용자가 텍스트 프롬프트만으로 맞춤형 음악을 생성하고 스트리밍할 수 있는 AI 기반 음악 에이전트를 만들었습니다. 이 글에서는 프로젝트를 진행하면서 마주한 기술적 도전과, 실제 서비스에 적용하면서 얻은 교훈을 공유합니다.
핵심 기능
| 기능 | 설명 |
|---|---|
| 프롬프트 기반 음악 생성 | 사용자가 “느긋한 저녁 재즈” 같은 텍스트를 입력하면 AI가 해당 분위기에 맞는 트랙을 추천합니다. |
| 실시간 스트리밍 | Spotify SDK 를 이용해 브라우저에서 바로 음악을 재생합니다. |
| 사용자 맞춤형 플레이리스트 | 생성된 트랙을 자동으로 사용자 계정에 저장하고, 나중에 재생할 수 있도록 합니다. |
| 다중 모델 지원 | OpenAI gpt-4o-mini 와 gpt-4o 를 상황에 맞게 전환해 비용을 최적화했습니다. |
아키텍처 개요
graph LR
A[Next.js Frontend] --> B[API Routes (Node.js)]
B --> C[OpenAI Service]
B --> D[Spotify Service]
C --> E[Prompt → Music Metadata]
D --> F[Spotify OAuth & Playback]
E --> G[Cache (Redis)]
F --> G
- Next.js: 서버 사이드 렌더링(SSR)과 API 라우트를 한 프로젝트 안에서 관리합니다.
- OpenAI: 프롬프트를 받아 음악 메타데이터(장르, BPM, 분위기 등)를 생성합니다.
- Spotify: 실제 트랙을 검색하고, 사용자 계정에 추가하거나 재생합니다.
- Redis: 동일한 프롬프트에 대한 응답을 캐시해 API 호출 비용을 절감합니다.
구현 세부 사항
1. 프롬프트 처리
// /pages/api/generate.ts
import { OpenAI } from "openai";
export default async function handler(req, res) {
const { prompt } = req.body;
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const completion = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
temperature: 0.7,
});
res.status(200).json({ result: completion.choices[0].message.content });
}
- 온디맨드 모델 선택: 프롬프트 길이가 150 토큰 이하이면
gpt-4o-mini를, 그 이상이면gpt-4o로 전환했습니다. - 온도(temperature) 를 0.7 로 설정해 다양성을 확보했습니다.
2. Spotify 인증 흐름
// /components/SpotifyLogin.tsx
import { useEffect } from "react";
export default function SpotifyLogin() {
useEffect(() => {
const redirectUri = `${window.location.origin}/api/spotify/callback`;
const clientId = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID;
const scopes = [
"user-read-playback-state",
"user-modify-playback-state",
"playlist-modify-public",
].join(" ");
const authUrl = `https://accounts.spotify.com/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent(
redirectUri
)}&scope=${encodeURIComponent(scopes)}`;
window.location.href = authUrl;
}, []);
return null;
}
- PKCE 를 사용해 토큰을 안전하게 교환했습니다.
- 토큰은 HTTP‑Only 쿠키 로 저장해 XSS 공격을 방어했습니다.
3. 캐시 전략
// /lib/cache.ts
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
export async function getCachedPrompt(prompt: string) {
return await redis.get(`prompt:${prompt}`);
}
export async function setCachedPrompt(prompt: string, result: string) {
await redis.set(`prompt:${prompt}`, result, "EX", 60 * 60 * 24); // 24h TTL
}
- 동일한 프롬프트에 대해 24시간 동안 캐시를 유지해 비용을 30% 절감했습니다.
- 캐시 미스 시에만 OpenAI API 를 호출하도록 로직을 구성했습니다.
배운 점
| 교훈 | 상세 내용 |
|---|---|
| 비용 관리 | gpt-4o-mini 로 대부분의 요청을 처리하고, 복잡한 프롬프트에만 gpt-4o 를 사용해 월간 비용을 $150 이하로 유지했습니다. |
| 인증 복잡성 | Spotify PKCE 흐름은 초기 설정이 까다롭지만, 보안 측면에서 강력합니다. 토큰 갱신 로직을 별도 서비스로 분리하는 것이 유지보수에 도움이 됩니다. |
| SSR vs CSR | 음악 메타데이터는 서버에서 미리 받아 SSR 로 전달하면 초기 로드 타임이 30% 감소했습니다. 하지만 재생 컨트롤은 클라이언트 전용이므로 CSR 로 구현했습니다. |
| 에러 핸들링 | 외부 API(Spotify, OpenAI) 가 일시적으로 실패할 경우 재시도 백오프와 사용자 친화적인 오류 메시지 를 제공해야 합니다. |
| 접근성 | 키보드 네비게이션과 ARIA 라벨을 추가해 시각 장애인도 프롬프트 입력 및 재생 제어가 가능하도록 했습니다. |
마주한 도전 과제와 해결책
-
프롬프트 품질 관리
- 문제: 사용자가 입력한 텍스트가 모호하거나 부적절할 경우 AI 가 엉뚱한 결과를 반환했습니다.
- 해결: 프론트엔드에서 프롬프트 검증(길이, 금지어 필터링)과 자동 보완(예: “밝은 팝” → “밝고 경쾌한 팝 음악”)을 적용했습니다.
-
Spotify 트랙 매칭 정확도
- 문제: AI 가 생성한 메타데이터와 Spotify 검색 결과가 일치하지 않아 빈 결과가 나왔습니다.
- 해결: 다중 검색 전략(검색어 + 장르 + BPM 범위)과 유사도 스코어를 도입해 가장 일치하는 트랙을 선택했습니다.
-
실시간 재생 지연
- 문제: 사용자가 “재생” 버튼을 눌렀을 때 2~3초 정도 딜레이가 발생했습니다.
- 해결: Web Playback SDK 를 미리 초기화하고, 프리로드(다음 트랙을 미리 버퍼링)하도록 구현했습니다.
앞으로의 로드맵
- 음성 프롬프트: Whisper API 를 이용해 음성 입력을 텍스트 프롬프트로 변환하고, 손쉬운 음악 탐색 경험 제공.
- 다중 플랫폼 지원: iOS/Android 네이티브 앱으로 확장해 모바일에서도 동일한 AI 음악 에이전트를 사용할 수 있게 함.
- 커뮤니티 피드백 루프: 사용자가 생성된 플레이리스트에 평점을 매기면, 모델이 해당 피드백을 학습해 더 정교한 추천을 제공하도록 설계.
결론
Next.js 와 최신 AI 모델, 그리고 Spotify API 를 결합하면 몇 주 안에 프로덕션 수준의 AI 음악 에이전트를 만들 수 있습니다. 핵심은 비용 최적화, 안정적인 인증 흐름, 그리고 사용자 경험(UX) 중심의 오류 처리 입니다. 여러분도 이 레시피를 참고해 자신만의 AI 기반 음악 서비스를 구축해 보세요!
The Stack
| 구성 요소 | 기술 |
|---|---|
| 프레임워크 | Next.js 16 (App Router) |
| 인증 | Clerk |
| 결제 | Stripe |
| 오디오 | Web Audio API + WaveSurfer.js |
| AI | 다중 음악 AI 제공자를 오케스트레이션하는 맞춤형 에이전트 |
| i18n | next-intl (32개 언어) |
| 상태 | Zustand + TanStack Query |
| UI | Radix primitives + Tailwind |
| 호스팅 | Vercel + S3‑compatible object storage |
Source: …
Lesson 1: 스트리밍 AI 응답을 위해 데이터 흐름 재고하기
사용자가 “재즈 피아노가 들어간 로‑파이 비트를 만들어줘” 라고 말하면, AI 에이전트는 단순히 텍스트만 반환하는 것이 아니라 곡을 생성하고, 커버 아트를 만들고, 메타데이터를 추출하며, 진행 상황 업데이트를 UI에 스트리밍합니다—모두 하나의 대화 턴 안에서 이루어집니다.
전체 응답이 끝날 때까지 기다렸다가 렌더링하는 순진한 접근법은 통하지 않습니다. 음악 생성에는 30–120 초가 걸리기 때문입니다. 스트리밍이 필요합니다.
우리가 배운 점
fetch기반 Server‑Sent Events (SSE) – WebSocket이 아니라 SSE를 사용합니다. 대화형 AI 인터페이스에서는 SSE가 더 간단하고 Vercel의 서버리스 모델과 완벽히 호환됩니다. WebSocket은 지속적인 연결과 별도의 인프라 레이어가 필요합니다.
// Simplified streaming pattern
const response = await fetch('/api/agent', {
method: 'POST',
body: JSON.stringify({ message: userInput }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// Parse SSE events: text deltas, resource creation, progress updates
processStreamEvents(chunk);
}
-
스트림 중 상태 관리 – 에이전트가 스트림 중에 새로운 오디오 리소스를 생성하면 다음을 수행해야 합니다:
- 채팅 메시지를 업데이트 (텍스트 추가)
- 새 리소스를 리소스 패널에 추가
- 새로운 오디오에 대한 파형을 렌더링 트리거
- 크레딧 잔액 업데이트
이 모든 작업은 오디오 재생 끊김을 일으키는 재렌더링 없이 부드럽게 이루어져야 합니다.
다르게 할 점: 스트리밍을 처음부터 고려한 상태 관리 설계를 해야 합니다. 우리는 처음에 간단한 useState만 사용했지만, 활성 스트림 중에 발생하는 연쇄 재렌더링을 피하기 위해 Zustand 스토어 + refs로 리팩터링했습니다.
Source: …
Lesson 2: 브라우저 오디오 처리는 생각보다 더 어렵다
스튜디오에는 실시간 마스터링 체인—EQ, 컴프레션, 스테레오 폭, 리미터—이 포함되어 있으며, 모두 Web Audio API를 통해 브라우저에서 실행됩니다. 사용자는 마스터링 설정을 조정하고 실시간으로 변화를 들어볼 수 있으며, 마스터된 MP3를 내보낼 수 있습니다.
실시간 vs. 오프라인 렌더링
목표: 실시간 재생과 오프라인 렌더링이 동일한 출력을 생성하도록 한다.
// The mastering pipeline (simplified)
async function renderMasteredBuffer(
audioUrl: string,
settings: MasteringSettings
): Promise {
const offlineCtx = new OfflineAudioContext(
2, // stereo
sampleRate * duration,
sampleRate
);
// Build the same effect chain used in real‑time playback
const source = offlineCtx.createBufferSource();
const eq = createParametricEQ(offlineCtx, settings.eq);
const compressor = createCompressor(offlineCtx, settings.compression);
const limiter = createLimiter(offlineCtx, settings.limiter);
source.connect(eq).connect(compressor).connect(limiter).connect(offlineCtx.destination);
source.start(0);
return offlineCtx.startRendering();
}
주의점: OfflineAudioContext와 일반 AudioContext는 필터 주파수나 파라미터 램프가 완전히 동일하지 않을 경우 미묘하게 다른 결과를 낼 수 있습니다. 우리는 모든 공유 상수를 하나의 TypeScript 파일에 모아 비트‑단위로 동일한 결과를 보장했습니다.
브라우저에서 MP3 인코딩
우리는 lamejs(JavaScript LAME 포트)를 사용해 AudioBuffer를 클라이언트 측에서 MP3로 인코딩합니다. 이를 통해 서버로의 왕복 전송을 피할 수 있습니다. 하지만 lamejs는 CPU 사용량이 많아 3분짜리 곡을 인코딩하면 메인 스레드가 2~3초 정도 차단될 수 있습니다.
해결책: 청크 단위로 처리하고 이벤트 루프에 양보한다.
async function encodeToMp3(audioBuffer: AudioBuffer): Promise {
const mp3encoder = new lamejs.Mp3Encoder(2, audioBuffer.sampleRate, 192);
const chunks: Int8Array[] = [];
const blockSize = 1152;
for (let i = 0; i 0) chunks.push(mp3buf);
// Yield to prevent UI freeze
if (i % (blockSize * 100) === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
const end = mp3encoder.flush();
if (end.length > 0) chunks.push(end);
return new Blob(chunks, { type: 'audio/mp3' });
}
Lesson 3: Vercel에서 파일 업로드는 숨겨진 제한이 있습니다
Vercel 서버리스 함수는 4.5 MB 본문 크기 제한을 적용합니다. 마스터된 오디오 파일 하나가 5–10 MB 정도 된다는 것을 알게 되면 괜찮아 보이던 제한이 문제가 됩니다.
우리의 첫 번째 접근 방식은 client → Next.js API route → object storage였습니다. 실제 오디오 파일을 사용할 경우 즉시 실패했습니다.
해결책: 사전 서명된 URL을 사용한 클라이언트‑직접‑스토리지 업로드
1. Client requests a signed upload URL from our API (tiny JSON payload)
2. Client uploads the file dire
(나머지 흐름은 평소와 같이 진행됩니다: 클라이언트가 파일을 스토리지 엔드포인트에 PUT하고, 업로드가 완료되면 백엔드에 알립니다.)
Lesson 3 – Vercel의 Body‑Size 제한을 우회하여 대용량 업로드 수행
Vercel의 4.5 MB 요청 제한보다 큰 파일을 업로드해야 할 때 가장 간단한 패턴은 서명된 URL을 이용한 direct‑to‑object‑storage 업로드입니다.
- 클라이언트가 API 라우트에 서명된 업로드 URL을 요청합니다.
- 클라이언트가 파일을 스토리지 서비스(S3, Cloudflare R2 등)로 직접 업로드합니다.
- 클라이언트가 생성된 공개 URL을 API에 다시 전송합니다(작은 JSON 페이로드).
- API가 파일 메타데이터와 함께 데이터베이스를 업데이트합니다.
모든 단계가 4.5 MB 제한 이하이므로, 무거운 파일 전송이 Vercel을 완전히 우회합니다.
// Upload flow that bypasses Vercel's body limit
export async function uploadFileToStorageFromClient({
file,
filename,
key,
}: {
file: Blob;
filename: string;
key: string;
}): Promise {
// Step 1: Get signed URL (tiny request)
const tokenResp = await fetch('/api/upload/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, filename, contentType: file.type }),
});
const { uploadUrl, publicUrl } = await tokenResp.json();
// Step 2: Upload directly to object storage (no Vercel in the middle)
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
return { url: publicUrl };
}
이 패턴은 Vercel에서 미디어가 많은 앱을 운영할 때 필수적입니다.
Lesson 4 – i18n 규모는 제품 결정이며, 기술적인 것이 아닙니다
Gliss는 32개 언어를 지원합니다 (3개나 5개가 아니라). 아래는 i18n 설정입니다:
// routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: SUPPORTED_LOCALE_CODES, // 32 locales
defaultLocale: 'en',
localePrefix: 'as-needed', // No /en prefix for English
});
localePrefix: 'as-needed'를 사용하면 / → /en 리디렉션에서 약 790 ms가 제거되어 Lighthouse 점수를 높일 수 있습니다.
실용적인 교훈
- 초기 번역 단계에 AI를 활용하고, 이후 원어민이 검수하도록 합니다. 순수 AI 번역은 음악 용어에서 당혹스러운 실수를 할 수 있습니다.
- 산업 용어는 영어 그대로 유지합니다 (예: “mastering”, “stems”, “BPM”, “MIDI”). 전 세계 뮤지션들이 이 용어들을 사용합니다.
- RTL 언어(아랍어, 히브리어, 우르두어, 페르시아어)는 레이아웃 테스트가 필요합니다. 단순 번역만으로는 충분하지 않으며, Flex 레이아웃이 깨질 수 있으니 철저히 테스트하세요.
- 동적으로 번역하지 마세요. 모든 번역을 빌드 시점에 로드합니다.
next-intl의 서버 컴포넌트를 사용하면 불필요하게 번역 번들을 클라이언트에 전달하지 않을 수 있습니다.
Lesson 5 – Content Security Policy Will Break Everything You Love
적절한 CSP 헤더를 추가하면 피할 수 없이 “두더지 잡기”와 같은 하루가 시작됩니다. 모든 외부 스크립트, 폰트, 분석 픽셀, 인증 위젯은 명시적인 허가가 필요합니다:
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://your-auth-provider.com https://*.yourdomain.com",
"connect-src 'self' https://*.yourdomain.com https: blob: data: wss:",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' data: https://fonts.gstatic.com",
"media-src 'self' https: blob: data:",
"worker-src 'self' blob:",
].join('; ')
media-src에 있는 blob:와 data: 항목은 오디오 앱에 필수적입니다 — Web Audio API가 재생을 위해 blob URL을 생성하고, OfflineAudioContext가 data URI로 렌더링합니다.
어쨌든 적용하세요. CSP는 결제와 사용자 데이터를 다루는 프로덕션 앱에서는 협상할 수 없는 요소입니다.
Lesson 6 – Next.js로 번들 크기 최적화
Our initial bundle shipped the entirety of react‑icons, which is massive. Enabling Next.js’s optimizePackageImports gave us a big win:
experimental: {
optimizePackageImports: [
'react-icons/si',
'react-icons/fa6',
'react-icons/md',
'react-icons/lu',
'lucide-react',
'@clerk/nextjs',
],
},
이것은 Next.js가 해당 패키지들을 더 적극적으로 트리‑쉐이킹하도록 지시합니다.
react-icons만으로도 번들에서 약 200 KB를 절감했습니다.
Other wins
inlineCss: true– 별도의 CSS 요청을 없애고, 최초 페인트 시간을 단축합니다.next/dynamic을 사용해 무거운 뷰어(MIDI 뷰어, 파형 렌더러)를 지연 로드합니다.
우리가 다르게 할 일
- 스트리밍 아키텍처부터 시작하세요. 스트리밍을 요청‑응답 모델에 뒤늦게 적용하는 것은 고통스럽습니다.
- 첫날부터 S3‑호환 직접 업로드를 사용하세요. 바이너리 파일을 API 레이어를 통해 라우팅하지 마세요.
- 첫날에 CSP를 설정하세요. 나중에 추가하면 이미 삽입한 모든 서드파티 통합을 디버깅해야 합니다.
- i18n 인프라에 일찍 투자하세요. 파이프라인이 자동화돼 있으면 32번째 언어를 추가하는 것은 쉽지만, 하드코딩된 문자열이 곳곳에 있는 상태에서 2번째 언어를 추가하는 것은 악몽입니다.
- 오디오 파이프라인을 먼저
OfflineAudioContext로 구축, 그 다음 실시간으로 포팅하세요. 오프라인 렌더링을 올바르게 하면 실시간 버전도 정확하게 동작합니다.
Try It
If you want to see all of this in action, check out Gliss. You can generate a song from a text description, master it in your browser, and export — no account required for your first few creations.
The music‑AI space is moving incredibly fast. If you’re building anything with audio in the browser, we hope these lessons save you the debugging time we spent.
What’s the hardest technical challenge you’ve hit building with audio in the browser? We’d love to hear about it in the comments.