
Next.js 15와 OpenRouter를 사용한 AI 기반 메모리 매치 게임 구축
요약
Next.js 15와 OpenRouter를 활용하여 LLM이 모든 게임 콘텐츠를 동적으로 생성하는 AI 기반 메모리 매치 게임 구축 방법을 소개합니다. 서버 액션과 모델 폴백 체인을 통해 안정적인 AI 콘텐츠 생성 아키텍처를 구현하는 과정을 다룹니다.
핵심 포인트
- Next.js 15 App Router를 통한 보안 중심의 AI 호출 구조 설계
- OpenRouter를 활용한 모델 라우팅 및 무료 모델 폴백 체인 구현
- 잘린 JSON 응답을 복구하는 에러 핸들링 및 프롬프트 최적화 전략
- 하드코딩 없이 LLM만으로 테마, 카드, 힌트를 생성하는 동적 게임 엔진 구축
모든 카드, 테마, 힌트가 LLM에 의해 생성되어 하드코딩된 콘텐츠가 전혀 없는 프로덕션 레디 메모리 게임을 구축하는 방법.
기본 전제
전통적인 메모리 매치 게임은 동물, 국기, 이모지 등 고정된 카드 세트를 사용합니다. 세 번째 플레이부터는 지루해집니다.
만약 매번의 게임 세션이 독특하다면 어떨까요? '우주 속 카와이 디저트' 같은 테마를 입력하면 LLM이 8쌍을 디자인하고, 힌트를 작성하며, 그라디언트를 선택하고, 보드 이름을 몇 초 만에 결정합니다.
이것이 바로 AI 메모리 매치입니다. OpenRouter가 채팅 사이드바가 아닌 게임 엔진 역할을 하는 Next.js 15 앱입니다.
아키텍처 개요
flowchart LR
subgraph Frontend
A[React 19 Client]
...
이 앱은 엄격한 분리 원칙을 따릅니다:
- 클라이언트(Client) — 렌더링, 애니메이션, 게임 상태, LocalStorage
- 서버(Server) — 모든 LLM 호출 (API 키는 절대 브라우저에 노출되지 않음)
- OpenRouter — 자동 폴백을 갖춘 모델 라우팅
기술 선택 및 이유
| 선택 | 이유 |
|---|---|
| Next.js 15 App Router | 보안 AI 호출을 위한 Server Actions + API routes; Vercel에서 원클릭 배포 |
| ... |
export const JSON_SYSTEM_PROMPT =
"당신은 게임 콘텐츠 생성기입니다. 오직 유효한 JSON만 반환하세요. " +
"마크다운(markdown), 코드 펜스(code fences), 추론 과정(reasoning traces), 사고 사슬(chain-of-thought)을 포함하지 마세요. " +
...
카드 배치 프롬프트(Card batch prompts)는 잘림(truncation) 현상을 방지하기 위해 필드 길이를 짧게 유지합니다:
const userPrompt = `테마: "${themeInput}"
${pairCount}개의 고유한 쌍(${pairCount * 2}장의 카드)을 다음 pairIds를 사용하여 생성하세요: ${pairIds.join(", ")}
각 쌍: 동일한 pairId, 2장의 카드, 유사하지만 서로 다른 imagePrompts.
...
모델 폴백 체인 (Model fallback chain)
모델은 작동이 중단될 수 있습니다. 무료 티어(Free tiers)는 속도 제한(rate-limit)이 있습니다. 우리는 OpenRouter의 순위별 무료 컬렉션을 통해 반복 시도합니다:
export const FREE_TEXT_MODELS = [
"tencent/hy3:free",
"nvidia/nemotron-3-ultra-550b-a55b:free",
...
for (const model of modelsToTry) {
for (let attempt = 0; attempt < 2; attempt++) {
const response = await fetch(OPENROUTER_BASE + "/chat/completions", {
...
404/429/빈 응답(empty)이 발생하면 다음 모델로 넘어갑니다. 잘린 JSON(truncated JSON)의 경우, 복구(repair)를 시도합니다:
function repairTruncatedJson(json: string): string | null {
let attempt = json;
for (let i = 0; i < 6; i++) {
...
게임 상태 (Game State): 단순한 상태 머신 (State Machine)
GameApp.tsx는 네 가지 단계를 제어합니다:
type AppPhase = "landing" | "loading" | "playing" | "won";
stateDiagram-v2
landing --> loading : fetch /api/generate-game
loading --> playing : GameData 수신
...
더 명확한 타임아웃(timeouts) 처리를 위해 생성 과정에는 (단순한 Server Action이 아닌) API 라우트(API route)를 사용합니다:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 110_000);
...
해당 라우트는 Vercel Pro 배포를 위해 export const maxDuration = 120을 설정합니다.
게임플레이 (Gameplay): useGame 및 LLM 예산 (LLM Budget)
카드 뒤집기는 **순수 클라이언트 상태 (pure client state)**입니다. 네트워크나 LLM을 사용하지 않습니다.
const handleCardClick = useCallback((index: number) => {
// 뒤집기 로직, 일치 감지, 일치 시 폭죽 효과
setStats((prev) => ({ ...prev, moves: prev.moves + 1 }));
...
힌트 (Hints): 풀 우선, AI 후순위 (pool-first, AI-last)
게임 생성 시, LLM은 hintPool: string[] (5~8개의 힌트)를 생성합니다. 게임 플레이 중에는 다음과 같이 작동합니다:
| 트리거 (Trigger) | LLM 사용 여부 | 메커니즘 |
|---|---|---|
| 4회 미스 시마다 | 아니오 | 미리 생성된 배열에서 pickPoolHint() 호출 |
| ... |
// 자동 힌트 — useEffect 사용, setState 내부가 아님 (React Router 에러 방지)
useEffect(() => {
if (misses % HINT_MISS_THRESHOLD === 0 && misses !== lastAutoHintMissRef.current) {
...
우리는 전형적인 React 버그를 만났습니다: setStats 업데이터 내부에서 getHint() (서버 액션)를 호출하여 "Cannot update Router while rendering GameBoard" 에러가 발생했습니다. 힌트 로직을 useEffect로 옮겨 이를 해결했습니다.
Framer Motion을 이용한 3D 카드 뒤집기
<motion.div
className="card-inner relative h-full w-full"
animate={{ rotateY: isRevealed ? 180 : 0 }}
...
필수 CSS:
.card-perspective { perspective: 1000px; }
.card-inner { transform-style: preserve-3d; }
.card-face { backface-visibility: hidden; }
...
각 카드는 AI가 생성한 이미지 URL 또는 풍부한 이모지(rich emoji) + 그라데이션 폴백(fallback)을 보여줍니다.
지속성 계층 (Persistence Layer)
LocalStorage는 연속 기록(streaks), 테마 최고 기록, 로컬 리더보드를 관리합니다:
export function updateStreakOnWin(): PlayerProgress {
const today = new Date().toISOString().split("T")[0];
const yesterday = new Date(Date.now() - 86400000).toISOString().split("T")[0];
...
SessionStorage는 생성된 게임을 캐싱하여, 동일한 테마로 다시 플레이할 때 LLM 호출을 건너뜁니다:
cacheGame(`${theme}-${difficulty}-${enableImageGen}`, game);
Supabase 훅은 향후 글로벌 리더보드를 위해 스텁(stub) 처리되었습니다.
점수 공식 (Scoring Formula)
export function calculateScore(stats, totalPairs): number {
const base = totalPairs * 100;
const timeBonus = Math.max(0, 300 - stats.elapsedSeconds * 2);
...
속도와 정확도에 보상을 주며, 힌트 사용과 미스에는 페널티를 부여합니다.
배포 노트 (Deployment Notes)
# .env.local
OPENROUTER_API_KEY=sk-or-v1-...
NEXT_PUBLIC_APP_URL=https://your-app.vercel.app
| 플랫폼 (Platform) | 함수 타임아웃 (Function timeout) | 권장 사항 (Recommendation) |
|---|---|---|
| Vercel Hobby | 10s | 무료 모델을 사용하기에는 너무 짧음 |
| ... |
배운 점 (Lessons Learned)
setState업데이트 함수에 사이드 이펙트(side effects)를 절대 넣지 마세요 — 서버 액션 (server actions), 페치 (fetches), 라우터 업데이트 (router updates)는 이펙트 (effects)나 이벤트 핸들러 (event handlers)에 포함되어야 합니다.- LLM 호출을 배치 (Batch) 처리하세요 — 하나의 거대한 JSON 응답은 느리고 잘릴 위험이 있습니다; 병렬로 처리하는 작은 배치 (batches) 방식이 승리합니다.
- 힌트는 생성 시점에 풀링 (Pool) 하세요 — 게임 플레이가 즉각적이고 비용이 들지 않게 유지됩니다.
- 모델 폴백 (Model fallbacks)은 필수입니다 — 무료 티어는 불안정합니다; OpenRouter의 큐레이션 목록에서 순위를 매기세요.
- 긴 작업에는 API 라우트 (API routes) > 서버 액션 (Server Actions) — 명시적인 타임아웃 (timeouts) 및
maxDuration제어가 가능합니다.
다음 단계 (What's Next)
- Supabase 글로벌 리더보드 (leaderboard)
- 카드 생성 중 SSE 스트리밍 (cards appear as they're created; 카드가 생성되는 대로 나타남)
- AI가 생성한 보드를 공유하는 멀티플레이어 룸 (Multiplayer rooms)
- 오프라인 캐싱된 게임을 지원하는 PWA
직접 해보기 (Try It)
git clone <repo>
npm install && cp .env.example .env.local
npm run dev
_"neon samurai cyber gardens"_를 입력하고 모델들이 어떤 꿈을 꾸는지 확인해 보세요.
스크린샷 (Screenshots)
코드 및 더 많은 정보: https://www.dailybuild.xyz/project/194-memory-match
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기



