Next.js 13 앱 라우터와 Claude Files API를 활용한 PDF 기반 지식정보시스템 구축 가이드 1
최근 수정 시각: 2025-09-04 14:48:16
개요
Next.js 13 앱 라우터와 Claude Files API를 결합하여 PDF 문서 기반의 지식정보시스템을 구축하는 방법을 단계별로 설명합니다. 이 시스템은 PDF 업로드, 문서 요약, AI 기반 Q\&A, 검색 및 하이라이팅 기능을 제공하며, Google OAuth를 통한 인증과 SQLite 데이터베이스를 사용합니다.[1][2][3]
Claude Files API란?
Claude Files API는 Anthropic에서 제공하는 파일 업로드 및 관리 서비스입니다. 한 번 업로드한 파일을 여러 번 재사용할 수 있어 효율적이며, 특히 PDF 문서 분석에 강력한 기능을 제공합니다.[1][3]
주요 특징
- 파일 업로드: PDF, DOCX, TXT 등 다양한 형식 지원 (최대 30MB)
- 파일 참조: 고유한
file_id
를 통한 반복 사용 - 멀티모달 분석: 텍스트, 이미지, 표 등 포함된 문서 분석
- 코드 실행 연동: 데이터 가공 및 시각화 가능
프로젝트 구조
pdf-knowledge-system/
├── app/
│ ├── api/
│ │ ├── auth/
│ │ │ └── [...nextauth]/
│ │ │ └── route.ts
│ │ ├── claude/
│ │ │ ├── upload/
│ │ │ │ └── route.ts
│ │ │ ├── analyze/
│ │ │ │ └── route.ts
│ │ │ ├── summary/
│ │ │ │ └── route.ts
│ │ │ └── search/
│ │ │ └── route.ts
│ │ └── documents/
│ │ └── route.ts
│ ├── components/
│ │ ├── PDFViewer.tsx
│ │ ├── DocumentList.tsx
│ │ ├── SearchInterface.tsx
│ │ └── GoogleAuth.tsx
│ ├── dashboard/
│ │ └── page.tsx
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── lib/
│ ├── database.ts
│ ├── claude-client.ts
│ ├── auth-config.ts
│ └── utils.ts
├── public/
│ └── uploads/
├── prisma/
│ └── schema.prisma
└── package.json
데이터베이스 설계
SQLite 스키마 설계
// lib/database.ts
import sqlite3 from 'sqlite3';
import { open, Database } from 'sqlite';
export interface DatabaseTables {
users: {
id: string;
email: string;
name: string;
image?: string;
created_at: string;
};
documents: {
id: string;
user_id: string;
file_id: string; // Claude Files API file_id
filename: string;
file_path: string;
file_size: number;
mime_type: string;
upload_date: string;
status: 'processing' | 'ready' | 'error';
};
summaries: {
id: string;
document_id: string;
summary_text: string;
created_at: string;
};
qa_history: {
id: string;
document_id: string;
user_id: string;
question: string;
answer: string;
created_at: string;
};
search_index: {
id: string;
document_id: string;
content: string;
page_number: number;
};
}
let db: Database | null = null;
export async function initDatabase(): Promise<Database> {
if (!db) {
db = await open({
filename: './knowledge_system.db',
driver: sqlite3.Database,
});
// 테이블 생성
await db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
image TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
file_id TEXT NOT NULL,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
mime_type TEXT NOT NULL,
upload_date DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'processing',
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS summaries (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
summary_text TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS qa_history (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
user_id TEXT NOT NULL,
question TEXT NOT NULL,
answer TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES documents(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
document_id,
content,
page_number
);
`);
}
return db;
}
Google OAuth 인증 설정
NextAuth.js 설정
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import { initDatabase } from '@/lib/database';
const handler = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
callbacks: {
async signIn({ user, account }) {
if (account?.provider === 'google') {
const db = await initDatabase();
// 사용자 정보 저장/업데이트
await db.run(`
INSERT INTO users (id, email, name, image)
VALUES (?, ?, ?, ?)
ON CONFLICT(email) DO UPDATE SET
name = excluded.name,
image = excluded.image
`, [user.id, user.email, user.name, user.image]);
return true;
}
return false;
},
async session({ session, token }) {
return session;
},
},
pages: {
signIn: '/auth/signin',
},
secret: process.env.NEXTAUTH_SECRET,
});
export { handler as GET, handler as POST };
Claude Client 설정
// lib/claude-client.ts
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY!,
});
export class ClaudeClient {
// Files API를 사용한 PDF 업로드
async uploadFile(fileBuffer: Buffer, filename: string): Promise<string> {
const formData = new FormData();
formData.append('file', new Blob([fileBuffer]), filename);
const response = await fetch('https://api.anthropic.com/v1/files', {
method: 'POST',
headers: {
'x-api-key': process.env.ANTHROPIC_API_KEY!,
'anthropic-version': '2023-06-01',
'anthropic-beta': 'files-api-2025-04-14',
},
body: formData,
});
if (!response.ok) {
throw new Error(`파일 업로드 실패: ${response.statusText}`);
}
const data = await response.json();
return data.file_id;
}
// PDF 문서 요약
async summarizeDocument(fileId: string): Promise<string> {
const message = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 2000,
messages: [{
role: 'user',
content: [{
type: 'document',
source: {
type: 'file',
file_id: fileId,
},
}, {
type: 'text',
text: '이 문서의 주요 내용을 한국어로 요약해주세요. 핵심 포인트를 3-5개의 불릿포인트로 정리하고, 전체적인 내용을 2-3문단으로 설명해주세요.',
}],
}],
}, {
headers: {
'anthropic-beta': 'files-api-2025-04-14',
},
});
return message.content[0](#fn0).type === 'text' ? message.content.text : '';
}
// PDF 기반 Q&A
async answerQuestion(fileId: string, question: string): Promise<string> {
const message = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1500,
messages: [{
role: 'user',
content: [{
type: 'document',
source: {
type: 'file',
file_id: fileId,
},
}, {
type: 'text',
text: `다음 질문에 대해 문서를 기반으로 답변해주세요:\n\n${question}\n\n문서에서 관련 정보를 찾아 구체적으로 답변하고, 해당 내용이 문서의 어느 부분에서 나왔는지도 언급해주세요.`,
}],
}],
}, {
headers: {
'anthropic-beta': 'files-api-2025-04-14',
},
});
return message.content[0](#fn0).type === 'text' ? message.content.text : '';
}
// 문서에서 특정 키워드 검색 및 하이라이팅
async searchInDocument(fileId: string, searchQuery: string): Promise<{
results: Array<{
page: number;
content: string;
highlight: string;
}>;
summary: string;
}> {
const message = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 2000,
messages: [{
role: 'user',
content: [{
type: 'document',
source: {
type: 'file',
file_id: fileId,
},
}, {
type: 'text',
text: `문서에서 "${searchQuery}"와 관련된 내용을 찾아주세요.
다음 JSON 형식으로 응답해주세요:
{
"results": [
{
"page": 페이지번호,
"content": "관련 내용의 전체 문맥",
"highlight": "검색어가 포함된 핵심 문장"
}
],
"summary": "검색 결과 전체 요약"
}`,
}],
}],
}, {
headers: {
'anthropic-beta': 'files-api-2025-04-14',
},
});
try {
const responseText = message.content[0](#fn0).type === 'text' ? message.content.text : '';
return JSON.parse(responseText);
} catch {
return {
results: [],
summary: '검색 결과를 파싱하는 중 오류가 발생했습니다.',
};
}
}
}
export const claudeClient = new ClaudeClient();
API Routes 구현
PDF 업로드 API
// app/api/claude/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { claudeClient } from '@/lib/claude-client';
import { initDatabase } from '@/lib/database';
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession();
if (!session?.user?.email) {
return NextResponse.json({ error: '인증이 필요합니다.' }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: '파일이 없습니다.' }, { status: 400 });
}
// 파일 검증
if (!file.type.includes('pdf')) {
return NextResponse.json({ error: 'PDF 파일만 업로드 가능합니다.' }, { status: 400 });
}
if (file.size > 30 * 1024 * 1024) { // 30MB 제한
return NextResponse.json({ error: '파일 크기는 30MB 이하여야 합니다.' }, { status: 400 });
}
// 로컬 저장
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${uuidv4()}_${file.name}`;
const filePath = join(process.cwd(), 'public/uploads', filename);
await writeFile(filePath, buffer);
// Claude Files API 업로드
const fileId = await claudeClient.uploadFile(buffer, file.name);
// 데이터베이스에 저장
const db = await initDatabase();
const documentId = uuidv4();
await db.run(`
INSERT INTO documents (id, user_id, file_id, filename, file_path, file_size, mime_type)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [
documentId,
session.user.email,
fileId,
file.name,
`/uploads/${filename}`,
file.size,
file.type,
]);
return NextResponse.json({
success: true,
documentId,
fileId,
message: '파일이 성공적으로 업로드되었습니다.',
});
} catch (error) {
console.error('파일 업로드 오류:', error);
return NextResponse.json(
{ error: '파일 업로드 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
문서 요약 API
// app/api/claude/summary/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { claudeClient } from '@/lib/claude-client';
import { initDatabase } from '@/lib/database';
import { v4 as uuidv4 } from 'uuid';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession();
if (!session?.user?.email) {
return NextResponse.json({ error: '인증이 필요합니다.' }, { status: 401 });
}
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: 'documentId가 필요합니다.' }, { status: 400 });
}
const db = await initDatabase();
// 문서 정보 조회
const document = await db.get(`
SELECT * FROM documents
WHERE id = ? AND user_id = ?
`, [documentId, session.user.email]);
if (!document) {
return NextResponse.json({ error: '문서를 찾을 수 없습니다.' }, { status: 404 });
}
// 기존 요약이 있는지 확인
const existingSummary = await db.get(`
SELECT * FROM summaries WHERE document_id = ?
`, [documentId]);
if (existingSummary) {
return NextResponse.json({
success: true,
summary: existingSummary.summary_text,
cached: true,
});
}
// Claude API를 통한 요약 생성
const summary = await claudeClient.summarizeDocument(document.file_id);
// 요약 저장
const summaryId = uuidv4();
await db.run(`
INSERT INTO summaries (id, document_id, summary_text)
VALUES (?, ?, ?)
`, [summaryId, documentId, summary]);
// 문서 상태 업데이트
await db.run(`
UPDATE documents SET status = 'ready' WHERE id = ?
`, [documentId]);
return NextResponse.json({
success: true,
summary,
cached: false,
});
} catch (error) {
console.error('요약 생성 오류:', error);
return NextResponse.json(
{ error: '요약 생성 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
Q\&A API
// app/api/claude/analyze/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { claudeClient } from '@/lib/claude-client';
import { initDatabase } from '@/lib/database';
import { v4 as uuidv4 } from 'uuid';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession();
if (!session?.user?.email) {
return NextResponse.json({ error: '인증이 필요합니다.' }, { status: 401 });
}
const { documentId, question } = await request.json();
if (!documentId || !question) {
return NextResponse.json(
{ error: 'documentId와 question이 필요합니다.' },
{ status: 400 }
);
}
const db = await initDatabase();
// 문서 정보 조회
const document = await db.get(`
SELECT * FROM documents
WHERE id = ? AND user_id = ?
`, [documentId, session.user.email]);
if (!document) {
return NextResponse.json({ error: '문서를 찾을 수 없습니다.' }, { status: 404 });
}
// Claude API를 통한 답변 생성
const answer = await claudeClient.answerQuestion(document.file_id, question);
// Q&A 기록 저장
const qaId = uuidv4();
await db.run(`
INSERT INTO qa_history (id, document_id, user_id, question, answer)
VALUES (?, ?, ?, ?, ?)
`, [qaId, documentId, session.user.email, question, answer]);
return NextResponse.json({
success: true,
answer,
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('Q&A 처리 오류:', error);
return NextResponse.json(
{ error: '질문 처리 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
검색 API
// app/api/claude/search/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { claudeClient } from '@/lib/claude-client';
import { initDatabase } from '@/lib/database';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession();
if (!session?.user?.email) {
return NextResponse.json({ error: '인증이 필요합니다.' }, { status: 401 });
}
const { documentId, searchQuery } = await request.json();
if (!documentId || !searchQuery) {
return NextResponse.json(
{ error: 'documentId와 searchQuery가 필요합니다.' },
{ status: 400 }
);
}
const db = await initDatabase();
// 문서 정보 조회
const document = await db.get(`
SELECT * FROM documents
WHERE id = ? AND user_id = ?
`, [documentId, session.user.email]);
if (!document) {
return NextResponse.json({ error: '문서를 찾을 수 없습니다.' }, { status: 404 });
}
// SQLite FTS 검색 먼저 시도
const ftsResults = await db.all(`
SELECT document_id, content, page_number
FROM search_index
WHERE document_id = ? AND content MATCH ?
ORDER BY rank
`, [documentId, searchQuery]);
// Claude API를 통한 고급 검색
const claudeResults = await claudeClient.searchInDocument(
document.file_id,
searchQuery
);
return NextResponse.json({
success: true,
ftsResults,
claudeResults,
query: searchQuery,
});
} catch (error) {
console.error('검색 처리 오류:', error);
return NextResponse.json(
{ error: '검색 처리 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
프론트엔드 컴포넌트
PDF 뷰어 컴포넌트
// app/components/PDFViewer.tsx
'use client';
import { useState, useEffect } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import dynamic from 'next/dynamic';
// PDF.js worker 설정
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`;
interface PDFViewerProps {
filePath: string;
onPageChange?: (pageNumber: number) => void;
}
const PDFViewer: React.FC<PDFViewerProps> = ({ filePath, onPageChange }) => {
const [numPages, setNumPages] = useState<number>(0);
const [pageNumber, setPageNumber] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
setNumPages(numPages);
setLoading(false);
setError(null);
};
const onDocumentLoadError = (error: Error) => {
console.error('PDF 로드 오류:', error);
setError('PDF 파일을 불러올 수 없습니다.');
setLoading(false);
};
const changePage = (newPageNumber: number) => {
setPageNumber(newPageNumber);
onPageChange?.(newPageNumber);
};
const goToPrevPage = () => {
if (pageNumber > 1) {
changePage(pageNumber - 1);
}
};
const goToNextPage = () => {
if (pageNumber < numPages) {
changePage(pageNumber + 1);
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-96">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900"></div>
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-96">
<div className="text-red-500">{error}</div>
</div>
);
}
return (
<div className="pdf-viewer">
<div className="pdf-controls mb-4 flex justify-between items-center">
<button
onClick={goToPrevPage}
disabled={pageNumber <= 1}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:bg-gray-300"
>
이전 페이지
</button>
<span className="page-info">
페이지 {pageNumber} / {numPages}
</span>
<button
onClick={goToNextPage}
disabled={pageNumber >= numPages}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:bg-gray-300"
>
다음 페이지
</button>
</div>
<div className="pdf-document border rounded-lg overflow-hidden">
<Document
file={filePath}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={<div>PDF 로딩 중...</div>}
>
<Page
pageNumber={pageNumber}
renderTextLayer={false}
renderAnnotationLayer={false}
className="max-w-full"
/>
</Document>
</div>
</div>
);
};
// SSR 방지를 위한 동적 임포트
export default dynamic(() => Promise.resolve(PDFViewer), {
ssr: false,
loading: () => <div>PDF 뷰어 로딩 중...</div>,
});
검색 인터페이스 컴포넌트
// app/components/SearchInterface.tsx
'use client';
import { useState } from 'react';
interface SearchResult {
page: number;
content: string;
highlight: string;
}
interface SearchInterfaceProps {
documentId: string;
onResultSelect?: (page: number) => void;
}
export default function SearchInterface({ documentId, onResultSelect }: SearchInterfaceProps) {
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState('');
const handleSearch = async () => {
if (!searchQuery.trim()) return;
setLoading(true);
try {
const response = await fetch('/api/claude/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
documentId,
searchQuery,
}),
});
if (!response.ok) {
throw new Error('검색에 실패했습니다.');
}
const data = await response.json();
if (data.success) {
setResults(data.claudeResults.results || []);
setSummary(data.claudeResults.summary || '');
}
} catch (error) {
console.error('검색 오류:', error);
alert('검색 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !loading) {
handleSearch();
}
};
return (
<div className="search-interface space-y-4">
<div className="search-input-group">
<div className="flex space-x-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="문서에서 검색하고 싶은 내용을 입력하세요..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={loading}
/>
<button
onClick={handleSearch}
disabled={loading || !searchQuery.trim()}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300"
>
{loading ? '검색 중...' : '검색'}
</button>
</div>
</div>
{summary && (
<div className="search-summary bg-blue-50 p-4 rounded-lg">
<h3 className="font-semibold text-blue-800 mb-2">검색 결과 요약</h3>
<p className="text-blue-700">{summary}</p>
</div>
)}
{results.length > 0 && (
<div className="search-results space-y-3">
<h3 className="font-semibold">검색 결과 ({results.length}건)</h3>
{results.map((result, index) => (
<div
key={index}
className="result-item p-4 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer"
onClick={() => onResultSelect?.(result.page)}
>
<div className="flex justify-between items-start mb-2">
<span className="text-sm text-blue-600 font-medium">
페이지 {result.page}
</span>
</div>
<div className="highlight-text mb-2">
<strong className="text-gray-800">{result.highlight}</strong>
</div>
<div className="context-text text-sm text-gray-600">
{result.content.length > 200
? `${result.content.substring(0, 200)}...`
: result.content
}
</div>
</div>
))}
</div>
)}
{results.length === 0 && searchQuery && !loading && (
<div className="text-center text-gray-500 py-8">
검색 결과가 없습니다.
</div>
)}
</div>
);
}
문서 대시보드
// app/dashboard/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import PDFViewer from '@/app/components/PDFViewer';
import SearchInterface from '@/app/components/SearchInterface';
interface Document {
id: string;
filename: string;
file_path: string;
upload_date: string;
status: string;
}
interface QAItem {
question: string;
answer: string;
created_at: string;
}
export default function Dashboard() {
const { data: session, status } = useSession();
const router = useRouter();
const [documents, setDocuments] = useState<Document[]>([]);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [summary, setSummary] = useState('');
const [qaHistory, setQaHistory] = useState<QAItem[]>([]);
const [question, setQuestion] = useState('');
const [loading, setLoading] = useState({
documents: false,
summary: false,
qa: false,
});
// 문서 목록 로드
const loadDocuments = async () => {
setLoading(prev => ({ ...prev, documents: true }));
try {
const response = await fetch('/api/documents');
if (response.ok) {
const data = await response.json();
setDocuments(data.documents);
}
} catch (error) {
console.error('문서 로드 오류:', error);
} finally {
setLoading(prev => ({ ...prev, documents: false }));
}
};
// 문서 요약 로드
const loadSummary = async (documentId: string) => {
setLoading(prev => ({ ...prev, summary: true }));
try {
const response = await fetch('/api/claude/summary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId }),
});
if (response.ok) {
const data = await response.json();
setSummary(data.summary);
}
} catch (error) {
console.error('요약 로드 오류:', error);
} finally {
setLoading(prev => ({ ...prev, summary: false }));
}
};
// Q&A 처리
const handleQuestion = async () => {
if (!selectedDoc || !question.trim()) return;
setLoading(prev => ({ ...prev, qa: true }));
try {
const response = await fetch('/api/claude/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documentId: selectedDoc.id,
question,
}),
});
if (response.ok) {
const data = await response.json();
setQaHistory(prev => [{
question,
answer: data.answer,
created_at: data.timestamp,
}, ...prev]);
setQuestion('');
}
} catch (error) {
console.error('Q&A 오류:', error);
} finally {
setLoading(prev => ({ ...prev, qa: false }));
}
};
// 문서 선택 시 요약 로드
useEffect(() => {
if (selectedDoc) {
loadSummary(selectedDoc.id);
setQaHistory([]);
}
}, [selectedDoc]);
// 초기 문서 목록 로드
useEffect(() => {
if (status === 'authenticated') {
loadDocuments();
} else if (status === 'unauthenticated') {
router.push('/api/auth/signin');
}
}, [status, router]);
if (status === 'loading') {
return <div className="flex justify-center items-center h-screen">로딩 중...</div>;
}
return (
<div className="dashboard-container max-w-7xl mx-auto p-6">
<div className="header mb-6">
<h1 className="text-3xl font-bold text-gray-800 mb-2">PDF 지식정보시스템</h1>
<p className="text-gray-600">업로드된 PDF 문서를 분석하고 질문하세요.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* 문서 목록 사이드바 */}
<div className="lg:col-span-1 bg-white rounded-lg shadow p-4">
<h2 className="text-lg font-semibold mb-4">문서 목록</h2>
<div className="documents-list space-y-2 max-h-96 overflow-y-auto">
{documents.map((doc) => (
<div
key={doc.id}
onClick={() => setSelectedDoc(doc)}
className={`document-item p-3 rounded cursor-pointer transition-colors ${
selectedDoc?.id === doc.id
? 'bg-blue-100 border-blue-300 border'
: 'hover:bg-gray-50 border border-gray-200'
}`}
>
<div className="font-medium text-sm truncate">{doc.filename}</div>
<div className="text-xs text-gray-500 mt-1">
{new Date(doc.upload_date).toLocaleDateString()}
</div>
<div className={`text-xs mt-1 ${
doc.status === 'ready' ? 'text-green-600' : 'text-yellow-600'
}`}>
{doc.status === 'ready' ? '분석완료' : '처리중'}
</div>
</div>
))}
</div>
</div>
{/* 메인 컨텐츠 */}
<div className="lg:col-span-3 space-y-6">
{selectedDoc ? (
<>
{/* PDF 뷰어 */}
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">{selectedDoc.filename}</h3>
<PDFViewer
filePath={selectedDoc.file_path}
onPageChange={setCurrentPage}
/>
</div>
{/* 탭 인터페이스 */}
<div className="bg-white rounded-lg shadow">
<div className="border-b">
<nav className="flex space-x-8 px-4">
{['요약', '질문답변', '검색'].map((tab) => (
<button
key={tab}
className="py-4 px-1 border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300"
>
{tab}
</button>
))}
</nav>
</div>
{/* 문서 요약 */}
<div className="p-6">
<h3 className="text-lg font-semibold mb-4">문서 요약</h3>
{loading.summary ? (
<div className="animate-pulse space-y-2">
<div className="h-4 bg-gray-300 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 rounded w-1/2"></div>
<div className="h-4 bg-gray-300 rounded w-5/6"></div>
</div>
) : summary ? (
<div className="prose max-w-none">
<pre className="whitespace-pre-wrap text-gray-700">{summary}</pre>
</div>
) : (
<div className="text-gray-500">요약을 생성하는 중...</div>
)}
</div>
{/* Q&A 섹션 */}
<div className="p-6 border-t">
<h3 className="text-lg font-semibold mb-4">질문 답변</h3>
<div className="question-input-group mb-6">
<div className="flex space-x-2">
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="문서에 대해 궁금한 것을 질문해보세요..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={loading.qa}
onKeyPress={(e) => e.key === 'Enter' && handleQuestion()}
/>
<button
onClick={handleQuestion}
disabled={loading.qa || !question.trim()}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300"
>
{loading.qa ? '처리 중...' : '질문'}
</button>
</div>
</div>
{/* Q&A 히스토리 */}
<div className="qa-history space-y-4 max-h-96 overflow-y-auto">
{qaHistory.map((qa, index) => (
<div key={index} className="qa-item border-l-4 border-blue-500 pl-4">
<div className="question mb-2">
<span className="font-medium text-blue-700">Q: </span>
<span className="text-gray-800">{qa.question}</span>
</div>
<div className="answer mb-2">
<span className="font-medium text-green-700">A: </span>
<div className="text-gray-700 whitespace-pre-wrap">{qa.answer}</div>
</div>
<div className="timestamp text-xs text-gray-500">
{new Date(qa.created_at).toLocaleString()}
</div>
</div>
))}
</div>
</div>
{/* 검색 섹션 */}
<div className="p-6 border-t">
<h3 className="text-lg font-semibold mb-4">문서 검색</h3>
<SearchInterface
documentId={selectedDoc.id}
onResultSelect={(page) => setCurrentPage(page)}
/>
</div>
</div>
</>
) : (
<div className="bg-white rounded-lg shadow p-8 text-center">
<div className="text-gray-500">
<svg className="mx-auto h-12 w-12 text-gray-400 mb-4" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
<h3 className="text-lg font-medium text-gray-900 mb-2">문서를 선택하세요</h3>
<p className="text-gray-500">좌측 목록에서 분석할 PDF 문서를 선택해주세요.</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}
환경 설정 및 배포
환경 변수 설정
# .env.local
NEXTAUTH_SECRET=your-nextauth-secret-here
NEXTAUTH_URL=http://localhost:3000
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
ANTHROPIC_API_KEY=your-anthropic-api-key
DATABASE_URL=./knowledge_system.db
package.json 의존성
{
"name": "pdf-knowledge-system",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.24.3",
"@types/node": "^20.8.0",
"@types/react": "^18.2.25",
"@types/react-dom": "^18.2.11",
"next": "^13.5.4",
"next-auth": "^4.23.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-pdf": "^7.5.1",
"sqlite": "^5.0.1",
"sqlite3": "^5.1.6",
"typescript": "^5.2.2",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/uuid": "^9.0.5",
"autoprefixer": "^10.4.16",
"eslint": "^8.51.0",
"eslint-config-next": "^13.5.4",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5"
}
}
성능 최적화 및 보안 고려사항
1. 파일 업로드 최적화
// 파일 압축 및 검증
const validateAndCompressFile = (file: File): Promise<Buffer> => {
return new Promise((resolve, reject) => {
if (file.size > 30 * 1024 * 1024) {
reject(new Error('파일 크기가 너무 큽니다.'));
return;
}
const reader = new FileReader();
reader.onload = () => {
resolve(Buffer.from(reader.result as ArrayBuffer));
};
reader.onerror = () => reject(new Error('파일 읽기 실패'));
reader.readAsArrayBuffer(file);
});
};
2. 레이트 리미팅
// lib/rate-limiter.ts
import { NextRequest } from 'next/server';
const rateLimitMap = new Map();
export function rateLimit(request: NextRequest, limit: number = 5): boolean {
const ip = request.ip || 'anonymous';
const currentTime = Date.now();
const windowTime = 60 * 1000; // 1분
if (!rateLimitMap.has(ip)) {
rateLimitMap.set(ip, { count: 1, resetTime: currentTime + windowTime });
return true;
}
const userLimit = rateLimitMap.get(ip);
if (currentTime > userLimit.resetTime) {
userLimit.count = 1;
userLimit.resetTime = currentTime + windowTime;
return true;
}
if (userLimit.count >= limit) {
return false;
}
userLimit.count++;
return true;
}
3. 에러 처리 및 로깅
// lib/logger.ts
export class Logger {
static info(message: string, data?: any) {
console.log(`[INFO] ${new Date().toISOString()}: ${message}`, data);
}
static error(message: string, error?: any) {
console.error(`[ERROR] ${new Date().toISOString()}: ${message}`, error);
}
static warn(message: string, data?: any) {
console.warn(`[WARN] ${new Date().toISOString()}: ${message}`, data);
}
}
전체 요약
이 블로그에서 구현한 PDF 기반 지식정보시스템의 주요 특징:
핵심 기능
- PDF 업로드 및 관리: 로컬 저장과 Claude Files API 연동
- AI 기반 문서 요약: Claude 3.5 Sonnet을 활용한 한국어 요약
- 대화형 Q\&A: 문서 내용 기반 실시간 질의응답
- 고급 검색 기능: SQLite FTS5와 Claude API 결합 검색
- Google OAuth 인증: 안전한 사용자 관리
기술 스택
- 프론트엔드: Next.js 13 앱 라우터, React, TypeScript
- 백엔드: Next.js API Routes, SQLite with FTS5
- AI/ML: Claude Files API, Anthropic SDK
- 인증: NextAuth.js with Google Provider
- PDF 처리: react-pdf, PDF.js
아키텍처 특징
- 확장 가능한 구조: 모듈화된 API 설계
- 성능 최적화: 파일 캐싱, 레이트 리미팅, 동적 임포트
- 보안 강화: 인증 기반 접근 제어, 파일 검증
- 사용자 경험: 반응형 디자인, 실시간 피드백
이 시스템을 통해 조직의 문서 지식을 효율적으로 관리하고 AI의 도움으로 필요한 정보를 빠르게 찾을 수 있습니다.[4][5]