30분 만에 TCG 가격 트래커 만들기
이 튜토리얼에서는 트레이딩 카드를 검색하고 TCGPlayer와 eBay의 실시간 가격을 컨디션별·등급별로 확인할 수 있는 완전한 카드 가격 트래커를 만들어 보겠습니다. 튜토리얼을 마치면 실제 시장 데이터를 기반으로 하는 Next.js 애플리케이션이 완성됩니다. 차트, 에러 처리, 캐싱, 그리고 가격 알림 같은 고급 기능으로 확장할 수 있는 구조까지 갖추게 됩니다.
무엇을 만드나요?
다음 기능을 갖춘 Next.js 앱입니다:
- 지원되는 모든 게임에서 TCG API로 카드를 검색하는 검색창
- 컨디션별(NM~Damaged) TCGPlayer 및 eBay 데이터를 보여주는 가격 카드
- PSA, BGS, CGC 등급 가격 표시
/history엔드포인트를 활용한 과거 가격 차트- API 요청 한도를 유지하기 위한 캐싱
- Tailwind CSS를 사용한 깔끔하고 반응형인 UI
사전 준비
- Node.js 18+ 설치
- 무료 TCG API 키 (tcgpricelookup.com에서 가입)
- 기본 JavaScript/React 지식
- async/await 및 REST API에 대한 기본 이해
1단계: 프로젝트 설정
npx create-next-app@latest price-tracker
cd price-tracker
프롬프트에서 TypeScript, Tailwind CSS, App Router를 선택하세요. 그런 다음 가격 기록 섹션을 위한 차트 라이브러리를 설치합니다:
npm install recharts
프로젝트 루트에 .env.local 파일을 만드세요:
TCG_API_KEY=your_api_key_here
2단계: API 헬퍼 만들기
TCG API와 통신하는 유틸리티 함수를 담은 lib/tcg-api.ts를 만드세요:
const API_BASE = "https://api.tcgpricelookup.com/v1";
function getHeaders() {
return {
Authorization: `Bearer ${process.env.TCG_API_KEY}`,
"Content-Type": "application/json",
};
}
export async function searchCards(query: string, game?: string) {
const params = new URLSearchParams({ q: query });
if (game) params.set("game", game);
const res = await fetch(`${API_BASE}/cards/search?${params}`, {
headers: getHeaders(),
next: { revalidate: 300 }, // Cache for 5 minutes (Next.js App Router)
});
if (!res.ok) {
throw new Error(`Search failed: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function getCardPrices(cardId: string) {
const res = await fetch(`${API_BASE}/cards/${cardId}/prices`, {
headers: getHeaders(),
next: { revalidate: 300 },
});
if (!res.ok) {
throw new Error(`Price fetch failed: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function getCardHistory(cardId: string, days = 90) {
const params = new URLSearchParams({ days: String(days) });
const res = await fetch(`${API_BASE}/cards/${cardId}/history?${params}`, {
headers: getHeaders(),
next: { revalidate: 3600 }, // History changes less often — cache 1 hour
});
if (!res.ok) {
throw new Error(`History fetch failed: ${res.status} ${res.statusText}`);
}
return res.json();
}
3단계: API 응답 구조 파악하기
UI를 만들기 전에 API가 반환하는 데이터 구조를 정확히 파악해 두면 좋습니다. 카드 검색 결과의 구조는 다음과 같습니다:
{
"data": [
{
"id": "pkmn-sv3-054",
"name": "Charizard ex",
"game": "pokemon",
"set": "Obsidian Flames",
"number": "054/197",
"rarity": "Double Rare",
"imageUrl": "https://cdn.tcgpricelookup.com/images/pkmn-sv3-054.jpg"
}
],
"meta": {
"total": 1,
"page": 1,
"perPage": 20
}
}
단일 카드의 가격 응답은 다음과 같습니다:
{
"cardId": "pkmn-sv3-054",
"updatedAt": "2026-02-08T14:32:00Z",
"tcgplayer": {
"nearMint": { "market": 38.50, "low": 35.00, "mid": 39.00, "high": 55.00 },
"lightlyPlayed": { "market": 32.00, "low": 28.00, "mid": 33.00, "high": 45.00 },
"moderatelyPlayed":{ "market": 24.00, "low": 20.00, "mid": 25.00, "high": 35.00 },
"heavilyPlayed": { "market": 16.00, "low": 12.00, "mid": 17.00, "high": 25.00 },
"damaged": { "market": 8.00, "low": 5.00, "mid": 9.00, "high": 14.00 }
},
"ebay": {
"nearMint": {
"avg1Day": 40.25,
"avg7Day": 39.80,
"avg30Day": 41.10
},
"lightlyPlayed": {
"avg1Day": 33.00,
"avg7Day": 32.50,
"avg30Day": 33.80
}
},
"graded": {
"psa": {
"10": { "avg1Day": 185.00, "avg7Day": 178.00, "avg30Day": 182.00 },
"9": { "avg1Day": 75.00, "avg7Day": 72.00, "avg30Day": 74.00 },
"8": { "avg1Day": 48.00, "avg7Day": 46.00, "avg30Day": 47.50 }
},
"bgs": {
"10": { "avg1Day": 220.00, "avg7Day": 215.00, "avg30Day": 218.00 },
"9.5":{ "avg1Day": 110.00, "avg7Day": 108.00, "avg30Day": 109.00 }
},
"cgc": {
"10": { "avg1Day": 130.00, "avg7Day": 128.00, "avg30Day": 131.00 }
}
}
}
이 구조를 미리 파악해 두면 컴포넌트를 만들 때 시행착오를 크게 줄일 수 있습니다.
4단계: 검색 컴포넌트 만들기
components/CardSearch.tsx를 만드세요:
"use client";
import { useState } from "react";
interface CardResult {
id: string;
name: string;
game: string;
set: string;
number: string;
imageUrl: string;
}
export function CardSearch({ onSelect }: { onSelect: (id: string) => void }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<CardResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (!res.ok) throw new Error("Search request failed");
const data = await res.json();
setResults(data.data ?? []);
} catch (err) {
setError("Search failed. Please try again.");
setResults([]);
} finally {
setLoading(false);
}
}
return (
<div className="w-full max-w-xl">
<form onSubmit={handleSearch} className="flex gap-2 mb-4">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a card..."
className="flex-1 border rounded px-3 py-2"
/>
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{loading ? "Searching..." : "Search"}
</button>
</form>
{error && <p className="text-red-600 text-sm mb-4">{error}</p>}
<ul className="space-y-2">
{results.map((card) => (
<li
key={card.id}
onClick={() => onSelect(card.id)}
className="flex items-center gap-3 p-3 border rounded cursor-pointer hover:bg-gray-50"
>
<img src={card.imageUrl} alt={card.name} className="w-10 h-14 object-contain" />
<div>
<p className="font-semibold">{card.name}</p>
<p className="text-sm text-gray-500">{card.set} · #{card.number}</p>
</div>
</li>
))}
</ul>
</div>
);
}
5단계: 컨디션별 가격 데이터 표시하기
컨디션별 가격 분석을 렌더링하는 components/PriceTable.tsx를 만드세요:
interface ConditionPrices {
market: number;
low: number;
mid: number;
high: number;
}
interface EbayPrices {
avg1Day: number;
avg7Day: number;
avg30Day: number;
}
const CONDITIONS = [
{ key: "nearMint", label: "Near Mint" },
{ key: "lightlyPlayed", label: "Lightly Played" },
{ key: "moderatelyPlayed", label: "Moderately Played" },
{ key: "heavilyPlayed", label: "Heavily Played" },
{ key: "damaged", label: "Damaged" },
] as const;
export function PriceTable({ tcgplayer, ebay }: { tcgplayer: any; ebay: any }) {
return (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="text-left p-2">Condition</th>
<th className="text-right p-2">TCG Market</th>
<th className="text-right p-2">eBay 1-Day</th>
<th className="text-right p-2">eBay 7-Day</th>
<th className="text-right p-2">eBay 30-Day</th>
</tr>
</thead>
<tbody>
{CONDITIONS.map(({ key, label }) => {
const tcg: ConditionPrices = tcgplayer[key] ?? {};
const eb: EbayPrices = ebay[key] ?? {};
return (
<tr key={key} className="border-t">
<td className="p-2">{label}</td>
<td className="text-right p-2">${tcg.market?.toFixed(2) ?? "—"}</td>
<td className="text-right p-2">${eb.avg1Day?.toFixed(2) ?? "—"}</td>
<td className="text-right p-2">${eb.avg7Day?.toFixed(2) ?? "—"}</td>
<td className="text-right p-2">${eb.avg30Day?.toFixed(2) ?? "—"}</td>
</tr>
);
})}
</tbody>
</table>
);
}
6단계: 등급 카드 가격 표시하기
등급 카드의 경우 PSA, BGS, CGC 별도 섹션을 보여줍니다. components/GradedPrices.tsx를 만드세요:
const GRADERS = ["psa", "bgs", "cgc"] as const;
const COMMON_GRADES = ["10", "9.5", "9", "8"] as const;
export function GradedPrices({ graded }: { graded: any }) {
return (
<div className="grid grid-cols-3 gap-4 mt-6">
{GRADERS.map((grader) => {
const data = graded[grader];
if (!data) return null;
return (
<div key={grader} className="border rounded p-3">
<h3 className="font-bold uppercase mb-2">{grader}</h3>
{COMMON_GRADES.map((grade) => {
const prices = data[grade];
if (!prices) return null;
return (
<div key={grade} className="flex justify-between text-sm py-1 border-t">
<span>{grader.toUpperCase()} {grade}</span>
<span className="font-medium">${prices.avg30Day?.toFixed(2) ?? "—"}</span>
</div>
);
})}
</div>
);
})}
</div>
);
}
7단계: 가격 기록 차트 추가하기
history 엔드포인트는 일별 가격 스냅샷을 반환합니다. Recharts로 트렌드를 시각화하세요:
"use client";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
} from "recharts";
interface HistoryPoint {
date: string;
tcgMarket: number;
ebayAvg: number;
}
export function PriceHistoryChart({ history }: { history: HistoryPoint[] }) {
return (
<div className="mt-8">
<h2 className="text-lg font-semibold mb-4">Price History (90 Days)</h2>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={history}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => `$${v}`} />
<Tooltip formatter={(v: number) => `$${v.toFixed(2)}`} />
<Legend />
<Line
type="monotone"
dataKey="tcgMarket"
stroke="#3b82f6"
name="TCGPlayer Market"
dot={false}
/>
<Line
type="monotone"
dataKey="ebayAvg"
stroke="#f59e0b"
name="eBay 30-Day Avg"
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
차트를 보면 TCGPlayer와 eBay 가격이 수렴하는지, 발산하는지, 또는 함께 움직이는지 즉시 확인할 수 있습니다. 매수 또는 매도 여부를 결정할 때 유용한 신호가 됩니다.
8단계: 에러 처리 및 안정성 확보
프로덕션 앱에는 탄탄한 에러 처리가 필요합니다. 구현할 만한 몇 가지 패턴을 소개합니다:
요청 한도 감지. 플랜의 일일 한도를 초과하면 API가 429 상태를 반환합니다. 명시적으로 잡아서 도움이 되는 메시지를 표시하세요:
if (res.status === 429) {
throw new Error("Daily API limit reached. Resets at midnight UTC.");
}
그레이스풀 디그레이데이션. 특정 컨디션에서 eBay 데이터를 사용할 수 없는 경우(거래량이 낮은 일부 컨디션에서 API가 null을 반환) 앱이 충돌하는 대신 대시를 표시합니다. 위 컴포넌트의 null 체크가 이를 처리합니다.
지수 백오프를 이용한 재시도. 일시적인 오류에는 간단한 재시도 래퍼가 도움이 됩니다:
async function fetchWithRetry(url: string, options: RequestInit, retries = 2): Promise<Response> {
try {
const res = await fetch(url, options);
if (res.status >= 500 && retries > 0) {
await new Promise((r) => setTimeout(r, 500 * (3 - retries)));
return fetchWithRetry(url, options, retries - 1);
}
return res;
} catch (err) {
if (retries > 0) {
await new Promise((r) => setTimeout(r, 500));
return fetchWithRetry(url, options, retries - 1);
}
throw err;
}
}
9단계: 캐싱 전략
무료 티어는 하루 200개의 요청을 제공하므로 캐싱이 중요합니다. 몇 가지 접근법을 소개합니다:
Next.js 내장 캐시. fetch()의 next: { revalidate: N } 옵션은 응답을 Next.js 데이터 캐시에 저장합니다. 가격 데이터의 경우 300초(5분)가 적당한 기본값입니다. 변경 빈도가 낮은 기록 데이터는 1시간 이상 캐시해도 됩니다.
브라우저를 위한 인메모리 캐시. 클라이언트 사이드 요청의 경우 간단한 Map을 사용하면 세션 내 중복 요청을 방지할 수 있습니다:
const cache = new Map<string, { data: any; timestamp: number }>();
const TTL = 5 * 60 * 1000; // 5 minutes
async function getCachedPrices(cardId: string) {
const cached = cache.get(cardId);
if (cached && Date.now() - cached.timestamp < TTL) {
return cached.data;
}
const data = await getCardPrices(cardId);
cache.set(cardId, { data, timestamp: Date.now() });
return data;
}
멀티 유저 앱을 위한 Redis. 많은 사용자가 같은 인기 카드를 검색하는 앱을 만든다면 공유 Redis 캐시가 API 호출을 대폭 줄여줍니다. 카드 ID와 컨디션을 키로 원시 API 응답을 캐시하세요.
TCGPlayer vs eBay 가격 비교
나란히 비교하는 기능은 만들 수 있는 가장 유용한 기능 중 하나입니다. UI에서 강조할 만한 패턴들을 소개합니다:
- eBay가 TCGPlayer보다 높은 경우 — 높은 수요를 의미합니다. TCGPlayer에서 카드가 시장이 받아들이는 가격보다 낮게 책정되어 있을 수 있습니다.
- TCGPlayer가 eBay보다 높은 경우 — TCGPlayer 시장이 하락 조정에 느리게 반응하는 것일 수 있습니다. 카드가 경쟁 포맷에서 퇴출된 후 흔히 나타납니다.
- 좁은 스프레드 (5~10% 이내) — 시장이 유동적이고 효율적임을 의미합니다. 두 플랫폼이 가치에 동의하고 있습니다.
- 등급 카드의 넓은 스프레드 (예: PSA 10이 TCGPlayer에서 $500이지만 eBay에서 $380에 팔리는 경우) — 강한 매도 신호입니다.
TCG Price Lookup은 코드 한 줄 없이 이러한 비교를 즉시 제공합니다. 빠른 조회에는 TCG Price Lookup이, 대량 분석이나 포트폴리오 모니터링에는 직접 만든 트래커가 더 적합합니다.
10단계: 모든 것 연결하기
app/page.tsx에 메인 페이지를 만드세요:
"use client";
import { useState } from "react";
import { CardSearch } from "@/components/CardSearch";
import { PriceTable } from "@/components/PriceTable";
import { GradedPrices } from "@/components/GradedPrices";
import { PriceHistoryChart } from "@/components/PriceHistoryChart";
export default function HomePage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [prices, setPrices] = useState<any>(null);
const [history, setHistory] = useState<any[]>([]);
async function handleSelect(cardId: string) {
setSelectedId(cardId);
const [priceData, historyData] = await Promise.all([
fetch(`/api/prices/${cardId}`).then((r) => r.json()),
fetch(`/api/history/${cardId}`).then((r) => r.json()),
]);
setPrices(priceData);
setHistory(historyData.data ?? []);
}
return (
<main className="max-w-3xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">TCG Price Tracker</h1>
<CardSearch onSelect={handleSelect} />
{prices && (
<>
<PriceTable tcgplayer={prices.tcgplayer} ebay={prices.ebay} />
<GradedPrices graded={prices.graded} />
<PriceHistoryChart history={history} />
</>
)}
</main>
);
}
다음 단계
기본 트래커가 작동하면 다음과 같이 확장할 수 있습니다:
웹훅을 통한 가격 알림. Business 플랜에는 실시간 가격 변동을 푸시하는 WebSocket 피드가 포함되어 있습니다. 이메일이나 푸시 알림과 결합하면 카드가 목표 가격에 도달했을 때 사용자에게 알릴 수 있습니다.
포트폴리오 추적. 인증(NextAuth.js 추천)을 추가하고 사용자가 수량과 구매 가격으로 카드를 저장할 수 있게 하세요. 컬렉션 전체 가치와 미실현 손익을 계산하세요.
등급 카드 분석. 등급 가격 데이터를 활용해 "등급 계산기"를 만드세요. 카드의 원본 가격을 입력하고 등급 기관을 선택하면 등급별 예상 수익에서 등급 수수료를 뺀 결과를 보여줍니다. 카드 제출 여부를 고민하는 수집가에게 실질적인 도움이 됩니다.
멀티 게임 비교. API는 8개 이상의 게임을 지원하므로 Pokemon, MTG, One Piece의 가격 트렌드를 동시에 비교하는 대시보드를 만들 수 있습니다. TCG 시장 전반에 투자를 배분하려는 투자자에게 유용합니다.
판매 가격 예측. 30일 eBay 평균과 가격 기록 엔드포인트를 결합해 간단한 선형 트렌드 예측을 만드세요. 재무 조언은 아니지만 유용한 시각화 도구가 됩니다.
스프레드시트로 내보내기. CSV 내보내기 버튼을 추가해 사용자가 가격 스냅샷을 Google Sheets로 가져와 직접 분석할 수 있게 하세요.
무료 티어는 하루 200개의 요청을 제공합니다. 여기서 설명한 전체 앱을 만들고 테스트하기에 충분한 양입니다. 규모를 늘리거나 등급 가격 및 가격 기록이 필요할 때는 Pro 플랜으로 업그레이드하면 하루 10,000개의 요청을 사용할 수 있습니다. 개발 중에는 TCG Price Lookup을 직접 사용해 앱이 가져오는 가격을 수동으로 확인할 수도 있습니다.
즐거운 개발 되세요!