· Nacho Coll · Tutorials · 16 분 소요
IPFS + React: 탈중앙화 파일 업로드 컴포넌트 구축
파일을 IPFS에 핀하는 React 드래그 앤 드롭 파일 업로드 컴포넌트를 구축하세요. 안전한 브라우저 업로드를 위한 서명된 토큰 사용 완전 튜토리얼.

React와 IPFS로 탈중앙화 애플리케이션을 구축하는 것이 이렇게 간단했던 적이 없습니다. 이 종합 튜토리얼에서는 브라우저에서 직접 IPFS에 파일을 핀하는 프로덕션 준비된 파일 업로드 컴포넌트를 만드는 방법을 배웁니다. IPFS.NINJA의 서명된 토큰을 사용하여 프론트엔드 코드에서 API 키를 노출하지 않고 안전한 업로드를 보장합니다.
이 가이드를 마치면 업로드 진행 상황, 오류 처리 및 TanStack Query를 사용한 적절한 상태 관리를 갖춘 완전히 기능하는 드래그 앤 드롭 컴포넌트를 갖게 됩니다.

왜 React IPFS 업로드 컴포넌트를 구축해야 합니까?
AWS S3와 같은 전통적인 클라우드 스토리지 서비스는 데이터를 중앙 집중화하고 공급업체 잠금을 만듭니다. IPFS(InterPlanetary File System)를 사용하면 파일이 피어 투 피어 네트워크 전체에 분산되어 더 탄력적이고 검열 저항적입니다.
이 접근 방식의 주요 이점:
- 탈중앙화 스토리지: 파일이 여러 노드에 분산
- 콘텐츠 주소 지정: 각 파일은 고유한 CID(콘텐츠 식별자)를 받음
- 불변 링크: 업로드된 후에는 CID를 변경하지 않고 파일을 수정할 수 없음
- 전 세계 접근성: 모든 IPFS 게이트웨이에서 파일에 접근 가능
- 안전한 업로드: API 키를 노출하는 대신 서명된 토큰 사용
IPFS 개념이 처음이라면 코드에 뛰어들기 전에 IPFS 피닝이란 무엇인가에 대한 가이드를 확인하세요.
사전 요구 사항
시작하기 전에 다음을 확인하세요:
- 기본 React 지식
- Node.js 16+ 설치됨
- IPFS.NINJA 계정(여기에서 가입)
- 최신 React 패턴(hooks, async/await) 이해
프로젝트 설정
먼저 새 React 프로젝트를 만들고 필요한 종속성을 설치합시다:
npx create-react-app ipfs-upload-demo
cd ipfs-upload-demo
npm install @tanstack/react-query react-dropzone lucide-react다음 라이브러리를 사용합니다:
- @tanstack/react-query: 상태 관리 및 캐싱용
- react-dropzone: 드래그 앤 드롭 기능용
- lucide-react: 아이콘용
서명된 토큰 이해
컴포넌트를 구축하기 전에 API 키 대신 서명된 토큰을 사용하는 이유를 이해하는 것이 중요합니다:
// ❌ 절대 이렇게 하지 마세요 - API 키를 노출함
const response = await fetch('https://api.ipfs.ninja/upload/new', {
headers: {
'X-Api-Key': 'bws_your_secret_key_here' // 이건 브라우저에서 보임!
}
});
// ✅ 대신 서명된 토큰을 사용
const response = await fetch('https://api.ipfs.ninja/upload/new', {
headers: {
'Authorization': `Signed ${signedToken}` // 클라이언트 측 사용에 안전
}
});서명된 토큰은 서버 측에서 생성하여 프론트엔드로 전달하는 시간 제한 자격 증명입니다. 계정 설정이나 다른 민감한 작업에 액세스하는 데 사용할 수 없습니다.
백엔드 토큰 엔드포인트 만들기
먼저, 서명된 토큰을 생성하는 간단한 백엔드 엔드포인트를 만듭니다. 이것은 Next.js API 라우트, Express 엔드포인트 또는 서버리스 함수일 수 있습니다:
// api/upload-token.js (Next.js API 라우트 예제)
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const response = await fetch('https://api.ipfs.ninja/upload/tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.IPFS_NINJA_API_KEY // 서버 측 전용!
},
body: JSON.stringify({
expiresIn: '1h',
maxUploads: 10,
description: 'React upload component token'
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Token generation failed');
}
res.json({ token: data.token });
} catch (error) {
console.error('Token generation error:', error);
res.status(500).json({ error: 'Failed to generate upload token' });
}
}업로드 훅 구축
TanStack Query를 사용하여 업로드 로직을 관리하는 커스텀 훅을 만들어 봅시다:
// hooks/useIPFSUpload.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
const uploadToIPFS = async ({ file, token }) => {
// JSON 업로드를 위해 파일을 base64로 변환
const base64Content = await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.readAsDataURL(file);
});
const response = await fetch('https://api.ipfs.ninja/upload/new', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Signed ${token}`
},
body: JSON.stringify({
content: base64Content,
description: `Uploaded via React: ${file.name}`,
metadata: {
originalName: file.name,
size: file.size,
type: file.type,
uploadedAt: new Date().toISOString()
}
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Upload failed');
}
const data = await response.json();
return {
...data,
originalFile: {
name: file.name,
size: file.size,
type: file.type
}
};
};
export const useIPFSUpload = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: uploadToIPFS,
onSuccess: (data) => {
// 업로드된 파일 데이터를 캐시
queryClient.setQueryData(['uploads'], (oldData = []) => [
...oldData,
data
]);
}
});
};업로드 토큰 가져오기
백엔드에서 서명된 토큰을 가져오는 또 다른 훅을 만듭니다:
// hooks/useUploadToken.js
import { useQuery } from '@tanstack/react-query';
const fetchUploadToken = async () => {
const response = await fetch('/api/upload-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to fetch upload token');
}
const data = await response.json();
return data.token;
};
export const useUploadToken = () => {
return useQuery({
queryKey: ['upload-token'],
queryFn: fetchUploadToken,
staleTime: 30 * 60 * 1000, // 30분
retry: 3
});
};업로드 컴포넌트 만들기
이제 드래그 앤 드롭 기능이 있는 메인 업로드 컴포넌트를 구축합시다:
// components/IPFSUploader.jsx
import React, { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, CheckCircle, XCircle, Loader2, Copy, ExternalLink } from 'lucide-react';
import { useIPFSUpload } from '../hooks/useIPFSUpload';
import { useUploadToken } from '../hooks/useUploadToken';
const IPFSUploader = () => {
const [uploadedFiles, setUploadedFiles] = useState([]);
const uploadMutation = useIPFSUpload();
const { data: token, isLoading: isTokenLoading, error: tokenError } = useUploadToken();
const onDrop = useCallback(async (acceptedFiles) => {
if (!token) {
console.error('No upload token available');
return;
}
for (const file of acceptedFiles) {
try {
const result = await uploadMutation.mutateAsync({ file, token });
setUploadedFiles(prev => [...prev, result]);
} catch (error) {
console.error('Upload failed:', error);
}
}
}, [token, uploadMutation]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: 10 * 1024 * 1024, // 10MB 제한
multiple: true
});
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
};
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
if (isTokenLoading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin" />
<span className="ml-2">Initializing uploader...</span>
</div>
);
}
if (tokenError) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<XCircle className="w-5 h-5 text-red-500" />
<span className="ml-2 text-red-700">Failed to initialize uploader</span>
</div>
</div>
);
}
return (
<div className="max-w-2xl mx-auto p-6">
{/* 업로드 드롭존 */}
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${isDragActive
? 'border-blue-400 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
>
<input {...getInputProps()} />
<Upload className="w-12 h-12 mx-auto text-gray-400 mb-4" />
{isDragActive ? (
<p className="text-blue-600">Drop the files here...</p>
) : (
<div>
<p className="text-gray-600 mb-2">
Drag & drop files here, or click to select
</p>
<p className="text-sm text-gray-500">
Max file size: 10MB
</p>
</div>
)}
</div>
{/* 업로드 진행 상황 */}
{uploadMutation.isPending && (
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center">
<Loader2 className="w-5 h-5 animate-spin text-blue-500" />
<span className="ml-2 text-blue-700">Uploading to IPFS...</span>
</div>
</div>
)}
{/* 업로드 오류 */}
{uploadMutation.isError && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<XCircle className="w-5 h-5 text-red-500" />
<span className="ml-2 text-red-700">
{uploadMutation.error?.message || 'Upload failed'}
</span>
</div>
</div>
)}
{/* 업로드된 파일 목록 */}
{uploadedFiles.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-4">Uploaded Files</h3>
<div className="space-y-3">
{uploadedFiles.map((file, index) => (
<div key={index} className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<p className="font-medium text-gray-900">
{file.originalFile.name}
</p>
<p className="text-sm text-gray-500">
{formatFileSize(file.originalFile.size)} • {file.sizeMB}MB on IPFS
</p>
<div className="mt-2">
<p className="text-xs text-gray-600">CID:</p>
<div className="flex items-center space-x-2 mt-1">
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{file.cid}
</code>
<button
onClick={() => copyToClipboard(file.cid)}
className="text-gray-400 hover:text-gray-600"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
<a
href={file.uris.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700"
>
<ExternalLink className="w-5 h-5" />
</a>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default IPFSUploader;App 컴포넌트 설정
마지막으로 앱을 TanStack Query 공급자로 감쌉니다:
// App.js
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import IPFSUploader from './components/IPFSUploader';
import './App.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 5 * 60 * 1000, // 5분
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<div className="App">
<header className="bg-white shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-6">
<h1 className="text-3xl font-bold text-gray-900">
IPFS File Uploader
</h1>
<p className="text-gray-600 mt-2">
Upload files to IPFS with drag-and-drop simplicity
</p>
</div>
</header>
<main className="max-w-4xl mx-auto px-4 py-8">
<IPFSUploader />
</main>
</div>
</QueryClientProvider>
);
}
export default App;CSS 스타일 추가
컴포넌트를 세련되게 보이게 하는 기본 스타일을 추가합니다:
/* App.css */
.App {
min-height: 100vh;
background-color: #f9fafb;
}
/* 적절한 파일 입력 스타일링 보장 */
input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}고급 기능 및 최적화
파일 유형 검증
파일 유형 제한으로 드롭존 향상:
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
maxSize: 10 * 1024 * 1024,
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'],
'application/pdf': ['.pdf'],
'text/plain': ['.txt'],
},
multiple: true
});
// 파일 거부 오류 표시
{fileRejections.length > 0 && (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-yellow-800 font-medium">Some files were rejected:</p>
<ul className="mt-2 text-sm text-yellow-700">
{fileRejections.map(({ file, errors }) => (
<li key={file.path}>
{file.path}: {errors.map(e => e.message).join(', ')}
</li>
))}
</ul>
</div>
)}업로드 진행 상황 추적
더 나은 사용자 경험을 위해 개별 파일 업로드 진행 상황을 추적하고 싶을 수 있습니다. IPFS.NINJA API는 진행 콜백을 직접 지원하지 않지만 진행 상황을 시뮬레이션하거나 파일별 업로드 상태를 표시할 수 있습니다:
const [uploadingFiles, setUploadingFiles] = useState(new Set());
const onDrop = useCallback(async (acceptedFiles) => {
if (!token) return;
for (const file of acceptedFiles) {
const fileId = `${file.name}-${file.size}`;
setUploadingFiles(prev => new Set([...prev, fileId]));
try {
const result = await uploadMutation.mutateAsync({ file, token });
setUploadedFiles(prev => [...prev, result]);
} catch (error) {
console.error('Upload failed:', error);
} finally {
setUploadingFiles(prev => {
const next = new Set(prev);
next.delete(fileId);
return next;
});
}
}
}, [token, uploadMutation]);오류 처리 모범 사례
프로덕션 준비된 컴포넌트를 위한 포괄적인 오류 처리 구현:
const getErrorMessage = (error) => {
if (error.message.includes('401')) {
return 'Upload token expired. Please refresh the page.';
}
if (error.message.includes('413')) {
return 'File too large. Maximum size is 10MB.';
}
if (error.message.includes('429')) {
return 'Too many uploads. Please wait a moment and try again.';
}
return error.message || 'Upload failed. Please try again.';
};컴포넌트 테스트
업로드 기능을 테스트하려면:
- 개발 서버 시작:
npm start - 업로드 영역에 일부 파일 드래그
- 파일이 업로드되고 CID가 표시되는지 확인
- 외부 링크 아이콘을 클릭하여 IPFS URL이 작동하는지 확인
- 오류 시나리오 테스트(큰 파일, 네트워크 문제 등)
배포 고려 사항
React IPFS 업로더를 배포할 때:
환경 변수
IPFS.NINJA API 키를 안전하게 저장:
# .env.local (Next.js용)
IPFS_NINJA_API_KEY=bws_your_api_key_here토큰 갱신 전략
장기 실행 세션을 위한 자동 토큰 갱신 구현:
export const useUploadToken = () => {
return useQuery({
queryKey: ['upload-token'],
queryFn: fetchUploadToken,
staleTime: 25 * 60 * 1000, // 25분 (토큰은 30분에 만료)
refetchOnWindowFocus: true,
retry: 3
});
};CORS 구성
백엔드가 프론트엔드 도메인의 요청을 허용하는지 확인합니다.
성능 최적화
대용량 파일이나 여러 업로드로 더 나은 성능을 위해:
- base64 변환에 Web Workers 사용
- 동시 업로드를 제한하기 위해 업로드 큐 구현
- 업로드 전 이미지에 대한 파일 압축 추가
- 불필요한 요청을 피하기 위해 업로드 토큰을 적절히 캐시
다른 솔루션과 비교
IPFS.NINJA를 사용한 우리의 접근 방식은 대안에 비해 여러 가지 이점을 제공합니다. 다른 IPFS 피닝 서비스를 평가하고 있다면 IPFS.NINJA vs Pinata의 종합 비교와 최고의 IPFS 피닝 서비스 가이드를 확인하세요.
IPFS 업로드 방법에 대한 더 넓은 이해를 위해 IPFS 업로드 API 튜토리얼과 IPFS에 파일을 업로드하는 방법 가이드를 참조하세요.
다음 단계
이제 완전히 기능하는 React IPFS 업로드 컴포넌트가 있습니다! 확장할 수 있는 몇 가지 방법은 다음과 같습니다:
- 파일 미리보기 기능 추가
- 배치 작업 구현(삭제, 공유)
- 업로드 분석 및 메트릭 만들기
- 앱의 인증 시스템과 통합 추가
- 폴더 구성 기능 구축
- 파일 검색 및 필터링 구현
React의 컴포넌트 아키텍처와 IPFS의 탈중앙화 스토리지의 조합은 탄력적이고 사용자 친화적인 애플리케이션을 구축하기 위한 강력한 가능성을 만듭니다.
핀하기 시작할 준비가 되셨나요? 무료 계정 만들기 — 50개 파일, 1 GB 스토리지, 월 2 GB 대역폭. 신용 카드가 필요하지 않습니다.
