문서를 수정하려면 로그인이 필요합니다.

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]


메모장 링크

연결된 문서:

문서를 수정하려면 로그인이 필요합니다.