
Vercel AI SDK를 사용하여 경기 로그로부터 AI 피드백을 스트리밍 생성하기
요약
Vercel AI SDK와 Claude를 활용하여 축구 경기 로그를 지도자용 피드백 문장으로 변환하는 스트리밍 구현 방법을 소개합니다. Next.js 환경에서 데이터 변환, 시스템 프롬프트 설계, BYOK 방식의 API 키 관리 및 스트리밍 출력 과정을 다룹니다.
핵심 포인트
- Vercel AI SDK의 streamText를 이용한 실시간 피드백 생성
- 구조화된 경기 데이터를 LLM이 이해하기 쉬운 텍스트로 정형화
- 사용자 API 키를 직접 사용하는 BYOK(Bring Your Own Key) 설계 방식
- 일관된 출력을 위한 시스템 프롬프트 및 포맷 지정 전략
서론
축구 팀의 경기 로그(선발 명단, 교체, 출전 시간)는 그대로 두면 숫자와 텍스트의 나열일 뿐이라서, 지도자가 읽더라도 즉시 복기용으로 사용하기 어렵습니다.
이 기사에서는 Vercel AI SDK와 Claude를 사용하여, 경기 로그를 '좋았던 점·개선하고 싶은 점·다음 단계를 위한 조언'이라는 지도자용 피드백 문장으로 변환하는 기능을 구현합니다. 다음과 같은 흐름으로 진행합니다.
- 경기 로그를 AI가 읽기 쉬운 텍스트로 변환하기
- 경기 형식에 따른 시스템 프롬프트 (System Prompt) 설계하기
streamText를 사용하여 피드백을 스트리밍 생성하기- 생성 결과를 DB에 저장하고 클라이언트 측에서 순차적으로 표시하기
이 기능은 API 키를 사용자 스스로 등록하게 하는 BYOK (Bring Your Own Key) 방식으로 설계되었습니다. 또한, 무료 플랜에는 월간 생성 횟수 제한을 두고 있습니다. 이 기사에서는 그 설계에 대해서도 함께 해설합니다.
전제
이 기사에서는 다음과 같은 구성을 상정합니다.
src/
├─ app/
│ └─ api/
...
Next.js App Router, TypeScript, Drizzle ORM을 전제로 합니다. 인증은 Better Auth를 사용하고 있지만, 세션 취득 부분은 다른 인증 라이브러리로 교체해도 생각하는 방식은 동일합니다.
패키지 설치하기
Vercel AI SDK 본체와 Anthropic 프로바이더를 설치합니다.
npm install ai @ai-sdk/anthropic
API 키를 BYOK 방식으로 관리하기
이 기능에서는 Anthropic의 이용 요금을 서비스 측에서 대신 부담하는 것이 아니라, 사용자 자신의 API 키를 사용하는 설계로 하고 있습니다. 설정 화면에서 등록받은 키를 DB의 user 테이블에 저장합니다.
// actions/api-key.ts
"use server";
export async function updateAnthropicApiKey(apiKey: string) {
...
sk-ant-로 시작하는지 확인하는 간이 체크이지만, 명백히 다른 값이 혼입되는 것은 방지할 수 있습니다.
참고로, 이 키는 현재 DB에 그대로 평문으로 저장하고 있습니다. 개인 개발 범위에서는 감수하고 있지만, 여러 사람이 사용하는 서비스로서 본番 운용을 하는 경우에는 암호화하여 저장하거나 시크릿 관리 서비스(Secret Management Service) 측에 맡기는 설계로 해야 합니다. 이 기사에서는 구현을 단순화하기 위해 평문 저장을 사용하고 있다는 점은 솔직하게 밝혀둡니다.
경기 로그를 AI용 텍스트로 변환하기
DB에서 취득한 경기 데이터(선발 명단, 교체 이벤트, 출전 시간)는 구조화된 객체이므로, 그대로 프롬프트에 전달하면 정보를 읽기가 어려워집니다. 먼저, 사람이 읽는 기사와 같은 자연스러운 일본어 텍스트로 변환합니다.
// lib/match-log.ts
export function buildMatchLogText(input: MatchLogInput): string {
const { match, players, events, starters } = input;
...
포인트는 생데이터를 그대로 전달하는 것이 아니라 '제목 + 불렛 포인트' 형태로 텍스트를 정형화한 후 LLM에 전달하는 것입니다. 모델 측의 해석 차이를 줄일 수 있고, 사람이 디버깅 시 로그를 그대로 읽어도 내용을 파악할 수 있습니다.
시스템 프롬프트 설계하기
출력 포맷이 안정되지 않으면 매번 다른 구성의 피드백이 돌아와 실제 운용에 견디기 어렵습니다. 제목·글자 수·톤(Tone)을 시스템 프롬프트 측에서 명시적으로 지정합니다.
// lib/ai-prompts.ts
const FORMAT_LABELS: Record<string, string> = {
"5v5": "풋살 (5인제)",
...
마지막의 "로그가 적은 경우에는 얻을 수 있는 정보의 범위 내에서 작성할 것"이라는 문구는 실제로 운용해 보면서 추가한 것입니다. 경기 시작 직후 등 로그가 거의 없는 상태에서 생성하면, 정보가 없음에도 단정적인 내용을 작성해 버리는 경우가 있었기 때문에 폴백(Fallback) 지시를 명시하고 있습니다.
스트리밍으로 생성하는 Route Handler 만들기
피드백 생성은 응답이 길고 수 초가 걸리기 때문에, streamText로 스트리밍配信합니다.
// app/api/ai/feedback/route.ts
import { streamText } from "ai";
import { createAnthropic } from "@ai-sdk/anthropic";
...
여기서 의식하고 있는 점이 두 가지 있습니다.
첫 번째는 모델 호출(Model Call)보다 먼저 이용 제한 체크를 수행하는 것입니다. 사용자가 BYOK(Bring Your Own Key)를 통해 자신의 API 키를 사용한다고 하더라도, 무료 플랜의 횟수 제한을 초과했는데 모델을 먼저 호출해 버리면 불필요한 API 호출과 대기 시간이 발생합니다. 인증 → 플랜 확인 → API 키 확인 → 경기 데이터 가져오기 → 생성, 이라는 순서를 준수합니다.
두 번째는 getMatchData(matchId) 내에서 "이 사용자가 이 경기에 접근할 수 있는가"에 대한 권한 체크를 수행하고 있다는 점입니다. matchId는 클라이언트로부터 전달되는 값이므로, 이를 그대로 신뢰하지 않고 반드시 서버 측에서 소유자 확인을 거친 후 처리를 진행합니다.
클라이언트 측에서 스트림(Stream) 받기
Vercel AI SDK에는 useChat나 useCompletion과 같은 React Hooks도 준비되어 있지만, 이번 기능에서는 "스트림 완료 후 DB 저장 완료를 기다렸다가 편집 모드로 전환하기", "무료 플랜의 남은 횟수를 UI에 표시하기"와 같은 독자적인 상태 관리(State Management)를 하고 싶었기 때문에, fetch를 직접 호출하여 ReadableStream을 수동으로 읽는 방식을 사용했습니다.
const res = await fetch("/api/ai/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
...
reader.read()를 루프로 호출하며, 도착한 청크(Chunk)를 TextDecoder로 디코딩하면서 state에 반영하기만 하는 심플한 구현입니다. 커서의 깜빡임 애니메이션을 더하는 것만으로도 체감 대기 시간은 상당히 짧게 느껴집니다.
생성 후 편집 및 저장 가능하게 만들기
AI의 생성 결과는 그대로 공개하거나 공유하는 것을 전제로 하지 않고, "초안(Draft)"으로 다룰 수 있도록 구성했습니다. 생성 후에는 일반적인 텍스트 편집으로 전환할 수 있도록 하고, 저장은 Server Action으로 수행합니다.
// actions/feedback.ts
"use server";
export async function updateFeedback(matchId: string, content: string) {
...
생성용 Route Handler와 저장용 Server Action 모두에서 동일한 "1경기당 1건, 덮어쓰기 저장" 로직을 사용합니다. AI가 생성한 내용도, 수동으로 편집한 내용도 취급 방식은 동일한 matchFeedback 레코드입니다.
자주 하는 실수
toDataStreamResponse()와 toTextStreamResponse()를 혼동하는 경우
Vercel AI SDK의 streamText에는 응답을 스트림으로 변환하는 방법이 여러 가지가 있습니다. useChat 등 표준 Hooks를 사용하는 전제라면 toDataStreamResponse()가 편리하지만, 이는 독자적인 데이터 스트림 형식(메타데이터 포함)으로 반환되기 때문에, 이 글과 같이 클라이언트 측에서 ReadableStream을 수동으로 텍스트로서 읽는 구현과 조합하면 불필요한 제어 문자가 섞여 표시가 깨지게 됩니다. 클라이언트 측의 수신 방식에 맞춰 toTextStreamResponse()와 같은 플레인 텍스트(Plain Text) 형식을 선택해야 합니다.
이용 제한 체크를 생성 후에 수행하는 경우
"생성한 후, DB 저장 시점에 한꺼번에 제한 체크를 한다"는 구현을 하면, 상한을 초과한 사용자의 요청이라도 실제로는 모델 호출이 발생하게 됩니다. BYOK 방식이라 하더라도 API 이용료는 사용자 부담이므로 실질적인 피해는 작지만, 불필요한 레이턴시(Latency)와 실패 시의 UX 악화로 이어지기 때문에 모델 호출 전에 체크하는 순서를 따릅니다.
matchId를 그대로 신뢰하여 DB를 조작하는 경우
클라이언트로부터 받은 matchId
를 사용하여 DB를 직접 업데이트하는 대신, 반드시 "해당 사용자가 해당 리소스에 접근할 수 있는지"를 확인하는 함수를 거치도록 합니다. 생성용 Route Handler와 편집 저장용 Server Action 모두에서 동일한 권한 체크 함수를 호출하도록 하면, 체크 누락을 방지하기 쉬워집니다.
요약
Vercel AI SDK로 스트리밍 생성 기능을 구현할 경우, 기본적인 흐름은 다음과 같습니다.
- 구조화된 데이터는 프롬프트(Prompt)에 전달하기 전에 읽기 쉬운 텍스트로 변환한다
- 시스템 프롬프트(System Prompt)로 출력 포맷(제목, 글자 수, 톤)을 고정한다
- 인증, 이용 제한, 권한 체크는 모델 호출 전에 완료한다
- 클라이언트의 수신 방식(hooks 또는 수동 fetch)에 따라
toDataStreamResponse()와toTextStreamResponse()를 구분하여 사용한다 - 생성 결과는 AI에만 맡기지 말고, 사람이 편집하여 저장할 수 있는 동선을 마련한다
LLM을 기능에 통합할 때는 "모델이 어떻게 말하게 할 것인가"뿐만 아니라, "어떤 데이터를 어떻게 정제하여 전달할 것인가", "생성 전에 어떤 체크를 완료해 둘 것인가"에 대한 설계가 사용자 경험의 안정성을 크게 좌우합니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기