MyStory/Consensus_Verifiers

MeshProof: 두 모델이 합의할 때까지 수렴하는 QA 시스템(3)

LupyLaon 2025. 8. 24. 16:29

MeshProof: 두 모델이 합의할 때까지 수렴하는 QA 시스템

consensus-verifiers로 만드는 신뢰형 LLM 파이프라인

(3/3) 프론트엔드와 사용자 경험 — 복잡함을 숨기고 신뢰를 보여주기


🎯 이번 편에서 다룰 내용

2편에서 강력한 백엔드 엔진을 구축했다면, 이번에는 사용자가 실제로 어떻게 경험하게 되는지에 초점을 맞춥니다. 복잡한 다중 AI 검증 과정을 어떻게 직관적이고 신뢰할 수 있는 인터페이스로 변환했는지 살펴보겠습니다.


🏗️ 프론트엔드 아키텍처

기술 스택과 설계 원칙

// 기술 스택
- React 19 + TypeScript     // 타입 안전성과 최신 기능
- Vite 7                   // 빠른 개발 서버와 HMR  
- React Router             // SPA 라우팅
- 표준 CSS + CSS Variables // 간단하지만 강력한 스타일링

프로젝트 구조 설계

src/
├── components/              # 재사용 컴포넌트
│   ├── Header.tsx          # 네비게이션 + 로그아웃
│   ├── SocialLoginButtons.tsx  # OAuth 버튼들
│   ├── DebugViewer.tsx     # 검증 과정 시각화
│   ├── LoadingIndicator.tsx # 단계별 로딩 표시
│   └── TrustIndicator.tsx  # 신뢰도 시각화
├── lib/
│   └── api.ts              # API 클라이언트 + CSRF 자동 처리
├── pages/                  # 페이지 컴포넌트  
│   ├── Home.tsx           # 메인 질문-답변 인터페이스
│   ├── Login.tsx          # 로그인 페이지
│   ├── MyPage.tsx         # API 키 관리
│   └── ...
├── styles/                # 스타일 시트
│   ├── global.css         # 전역 스타일 + CSS Variables
│   └── fonts.css          # 브랜드 타이포그래피
└── types/                 # 공통 타입 정의
    └── api.ts             # 백엔드 API 응답 타입

핵심 설계 원칙

  1. 즉시 시작 가능: 로그인 없이도 BYOK로 바로 사용 가능
  2. 투명성: 원한다면 전체 검증 과정을 확인할 수 있음
  3. 신뢰도 시각화: 답변의 신뢰도를 직관적으로 표현
  4. 점진적 개선: 기본 기능 → 로그인 → 키 저장 → 고급 설정

🎨 메인 인터페이스: 질문에서 답변까지

듀얼 에디터 레이아웃

// Home.tsx - 메인 인터페이스
const Home: React.FC = () => {
    const [question, setQuestion] = useState('');
    const [result, setResult] = useState<OrchestrationResult | null>(null);
    const [isLoading, setIsLoading] = useState(false);
    const [loadingStage, setLoadingStage] = useState<ProcessingStage>('idle');

    return (
        <div className="home-container">
            <div className="editor-layout">
                {/* 좌측: 질문 입력 */}
                <div className="question-panel">
                    <h2>질문을 입력하세요</h2>
                    <textarea
                        value={question}
                        onChange={(e) => setQuestion(e.target.value)}
                        placeholder="예: 더해서 5가 나오는 정수 2개의 쌍은? 출처와 함께 설명해주세요."
                        className="question-editor"
                        disabled={isLoading}
                    />
                    
                    <div className="question-actions">
                        <FileUploadButton onFileLoaded={setQuestion} />
                        <SubmitButton 
                            question={question}
                            onSubmit={handleSubmit}
                            isLoading={isLoading}
                        />
                    </div>
                </div>

                {/* 우측: 검증된 답변 */}
                <div className="result-panel">
                    <h2>검증된 답변</h2>
                    {isLoading ? (
                        <LoadingIndicator stage={loadingStage} />
                    ) : result ? (
                        <ResultDisplay result={result} />
                    ) : (
                        <EmptyState />
                    )}
                </div>
            </div>
        </div>
    );
};

CSS Grid 기반 반응형 레이아웃

/* global.css */
.editor-layout {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 2rem;
    min-height: 70vh;
    max-width: 1200px;
    margin: 0 auto;
}

/* 모바일에서는 세로 배치 */
@media (max-width: 768px) {
    .editor-layout {
        grid-template-columns: 1fr;
        gap: 1.5rem;
    }
    
    .question-panel, .result-panel {
        min-height: 300px;
    }
}

.question-editor {
    width: 100%;
    min-height: 200px;
    padding: 1rem;
    border: 2px solid var(--color-border);
    border-radius: 8px;
    font-family: var(--font-body);
    font-size: 1rem;
    line-height: 1.5;
    resize: vertical;
    transition: border-color 0.2s ease;
}

.question-editor:focus {
    outline: none;
    border-color: var(--color-primary);
    box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}

🔄 단계별 로딩과 사용자 피드백

AI 모델 호출과 검증 과정은 시간이 걸리므로, 사용자가 무엇이 일어나고 있는지 명확히 알 수 있게 해야 합니다.

처리 단계 시각화

type ProcessingStage = 
    | 'idle' 
    | 'calling_models' 
    | 'verifying' 
    | 'reaching_consensus'
    | 'complete';

const LoadingIndicator: React.FC<{ stage: ProcessingStage }> = ({ stage }) => {
    const getStageInfo = (stage: ProcessingStage) => {
        switch (stage) {
            case 'calling_models':
                return { 
                    text: 'GPT와 Claude가 답변을 생성하고 있습니다...', 
                    progress: 25,
                    icon: '🤖'
                };
            case 'verifying':
                return { 
                    text: '검증기들이 답변을 분석하고 있습니다...', 
                    progress: 60,
                    icon: '🔍'
                };
            case 'reaching_consensus':
                return { 
                    text: '최종 답변을 결정하고 있습니다...', 
                    progress: 85,
                    icon: '⚖️'
                };
            default:
                return { text: '처리 중...', progress: 10, icon: '⏳' };
        }
    };

    const info = getStageInfo(stage);

    return (
        <div className="loading-container">
            <div className="loading-icon">{info.icon}</div>
            <div className="loading-text">{info.text}</div>
            <div className="progress-bar">
                <div 
                    className="progress-fill" 
                    style={{ width: `${info.progress}%` }}
                />
            </div>
            <div className="progress-text">{info.progress}%</div>
        </div>
    );
};

실시간 상태 업데이트

const handleSubmit = async () => {
    setIsLoading(true);
    setLoadingStage('calling_models');
    
    try {
        // 단계별 상태 업데이트를 위한 타이머들
        const stageTimers = [
            setTimeout(() => setLoadingStage('verifying'), 2000),
            setTimeout(() => setLoadingStage('reaching_consensus'), 4000),
        ];

        const result = await callOrchestrate({
            q: question,
            debug: true,  // 항상 디버그 정보 요청
            keys: currentApiKeys,
            opts: { useOpenAI: true, useClaude: true }
        });

        // 타이머들 정리
        stageTimers.forEach(timer => clearTimeout(timer));
        
        setLoadingStage('complete');
        setResult(result);
        
        // 완료 후 잠깐 대기 후 아이들 상태로
        setTimeout(() => setLoadingStage('idle'), 500);
        
    } catch (error) {
        console.error('Orchestration failed:', error);
        setError(error.message);
    } finally {
        setIsLoading(false);
    }
};

🎯 신뢰도 시각화: 복잡한 정보를 직관적으로

사용자가 답변을 얼마나 신뢰해야 할지 한눈에 알 수 있게 하는 것이 핵심입니다.

신뢰도 표시기 컴포넌트

interface TrustIndicatorProps {
    confidence: number;          // 0-1 범위
    consensusType: string;       // 'agreement', 'winner_selected'  
    verificationScore: number;   // 검증기 평균 점수
    agreementType?: 'equivalent' | 'different';
}

const TrustIndicator: React.FC<TrustIndicatorProps> = ({
    confidence,
    consensusType,
    verificationScore,
    agreementType
}) => {
    // 종합 신뢰도 계산
    const overallTrust = (confidence + verificationScore) / 2;
    
    const getTrustLevel = () => {
        if (overallTrust >= 0.9) return { 
            level: 'high', 
            color: '#10b981', 
            text: '매우 신뢰함',
            bgColor: '#d1fae5'
        };
        if (overallTrust >= 0.7) return { 
            level: 'medium', 
            color: '#f59e0b', 
            text: '신뢰함',
            bgColor: '#fef3c7'
        };
        return { 
            level: 'low', 
            color: '#ef4444', 
            text: '주의 필요',
            bgColor: '#fee2e2'
        };
    };

    const trust = getTrustLevel();
    const percentage = Math.round(overallTrust * 100);

    return (
        <div className="trust-indicator" style={{ backgroundColor: trust.bgColor }}>
            <div className="trust-main">
                <div className="trust-circle" style={{ borderColor: trust.color }}>
                    <span className="trust-percentage">{percentage}%</span>
                </div>
                <div className="trust-details">
                    <div className="trust-label" style={{ color: trust.color }}>
                        {trust.text}
                    </div>
                    <div className="trust-breakdown">
                        <span>답변 신뢰도: {Math.round(confidence * 100)}%</span>
                        <span>검증 점수: {Math.round(verificationScore * 100)}%</span>
                    </div>
                </div>
            </div>
            
            <div className="consensus-info">
                {agreementType === 'equivalent' ? (
                    <div className="consensus-badge agreement">
                        ✅ 두 AI 모델 합의 달성
                    </div>
                ) : (
                    <div className="consensus-badge selection">
                        🏆 최적 답변 선택됨
                    </div>
                )}
            </div>
        </div>
    );
};

결과 표시 컴포넌트

const ResultDisplay: React.FC<{ result: OrchestrationResult }> = ({ result }) => {
    const [showDebug, setShowDebug] = useState(false);

    return (
        <div className="result-container">
            <TrustIndicator 
                confidence={result.meta?.confidence || 0}
                consensusType={result.meta?.consensusType || 'unknown'}
                verificationScore={result.debug?.averageScore || 0}
                agreementType={result.meta?.agreement}
            />

            <div className="answer-content">
                <h3>최종 답변</h3>
                <div className="answer-text">
                    {result.content}
                </div>
            </div>

            {result.sources && result.sources.length > 0 && (
                <div className="sources-section">
                    <h4>참고 출처</h4>
                    <ul className="sources-list">
                        {result.sources.map((url, index) => (
                            <li key={index}>
                                <a href={url} target="_blank" rel="noopener noreferrer">
                                    {url}
                                </a>
                            </li>
                        ))}
                    </ul>
                </div>
            )}

            <button 
                className="debug-toggle"
                onClick={() => setShowDebug(!showDebug)}
            >
                🔍 검증 과정 {showDebug ? '숨기기' : '보기'}
            </button>

            {showDebug && result.debug && (
                <DebugViewer debugData={result.debug} />
            )}
        </div>
    );
};

🔍 디버그 뷰어: 투명성의 구현

사용자가 원한다면 전체 검증 과정을 상세히 볼 수 있게 하여 시스템에 대한 신뢰를 높입니다.

검증 과정 시각화

const DebugViewer: React.FC<{ debugData: DebugData }> = ({ debugData }) => {
    const [activeTab, setActiveTab] = useState<'verification' | 'consensus' | 'raw'>('verification');

    return (
        <div className="debug-viewer">
            <div className="debug-tabs">
                <button 
                    className={activeTab === 'verification' ? 'active' : ''}
                    onClick={() => setActiveTab('verification')}
                >
                    검증 결과
                </button>
                <button 
                    className={activeTab === 'consensus' ? 'active' : ''}
                    onClick={() => setActiveTab('consensus')}
                >
                    합의 과정
                </button>
                <button 
                    className={activeTab === 'raw' ? 'active' : ''}
                    onClick={() => setActiveTab('raw')}
                >
                    원본 데이터
                </button>
            </div>

            <div className="debug-content">
                {activeTab === 'verification' && (
                    <VerificationResults 
                        gptResults={debugData.gptVerification}
                        claudeResults={debugData.claudeVerification}
                    />
                )}
                {activeTab === 'consensus' && (
                    <ConsensusDetails details={debugData.consensusDetails} />
                )}
                {activeTab === 'raw' && (
                    <RawDataView data={debugData} />
                )}
            </div>
        </div>
    );
};

const VerificationResults: React.FC<{
    gptResults: VerificationResult;
    claudeResults: VerificationResult;
}> = ({ gptResults, claudeResults }) => (
    <div className="verification-results">
        <div className="model-results">
            <h4>GPT 검증 결과</h4>
            <ModelVerificationCard results={gptResults} modelName="GPT" />
        </div>
        
        <div className="model-results">
            <h4>Claude 검증 결과</h4>
            <ModelVerificationCard results={claudeResults} modelName="Claude" />
        </div>
    </div>
);

const ModelVerificationCard: React.FC<{
    results: VerificationResult;
    modelName: string;
}> = ({ results, modelName }) => (
    <div className={`model-card ${results.ok ? 'passed' : 'failed'}`}>
        <div className="card-header">
            <span className="model-name">{modelName}</span>
            <span className={`status-badge ${results.ok ? 'pass' : 'fail'}`}>
                {results.ok ? '✅ 통과' : '❌ 실패'}
            </span>
            <span className="overall-score">
                {Math.round((results.score || 0) * 100)}점
            </span>
        </div>
        
        <div className="verifier-details">
            {results.details?.map((detail, index) => (
                <VerifierResult key={index} detail={detail} />
            ))}
        </div>
    </div>
);

const VerifierResult: React.FC<{ detail: VerifyResult }> = ({ detail }) => (
    <div className={`verifier-result ${detail.ok ? 'pass' : 'fail'}`}>
        <div className="verifier-header">
            <span className="verifier-name">{detail.category}</span>
            <span className="verifier-score">
                {Math.round((detail.score || 0) * 100)}/100
            </span>
        </div>
        
        {detail.evidence && detail.evidence.length > 0 && (
            <div className="evidence">
                <strong>검증된 증거:</strong>
                <ul>
                    {detail.evidence.map((item, i) => (
                        <li key={i}>{item}</li>
                    ))}
                </ul>
            </div>
        )}
        
        {detail.notes && detail.notes.length > 0 && (
            <div className="notes">
                {detail.notes.map((note, i) => (
                    <div key={i} className="note">{note}</div>
                ))}
            </div>
        )}
    </div>
);

🔐 API 클라이언트와 보안 처리

CSRF 토큰 자동 처리

// lib/api.ts
class ApiClient {
    private csrfToken: string | null = null;

    async request<T>(
        endpoint: string,
        options: RequestInit = {}
    ): Promise<T> {
        // 수정 요청 전에 CSRF 토큰 확보
        if (this.isMutatingRequest(options.method)) {
            await this.ensureCsrfToken();
        }

        const config: RequestInit = {
            ...options,
            credentials: 'include',  // JWT 쿠키 포함
            headers: {
                'Content-Type': 'application/json',
                ...options.headers,
                ...(this.csrfToken && this.isMutatingRequest(options.method) && {
                    'X-CSRF-TOKEN': this.csrfToken
                })
            }
        };

        const response = await fetch(`/api${endpoint}`, config);
        
        if (!response.ok) {
            await this.handleErrorResponse(response);
        }

        return await response.json();
    }

    private async ensureCsrfToken(): Promise<void> {
        if (this.csrfToken) return;

        try {
            await fetch('/api/csrf', { credentials: 'include' });
            this.csrfToken = this.getCookieValue('XSRF-TOKEN');
        } catch (error) {
            console.error('Failed to get CSRF token:', error);
        }
    }

    private getCookieValue(name: string): string | null {
        const value = `; ${document.cookie}`;
        const parts = value.split(`; ${name}=`);
        if (parts.length === 2) {
            return parts.pop()?.split(';').shift() || null;
        }
        return null;
    }

    private isMutatingRequest(method?: string): boolean {
        return ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method?.toUpperCase() || '');
    }

    private async handleErrorResponse(response: Response): Promise<never> {
        const errorData = await response.json().catch(() => ({}));
        const message = errorData.error || `HTTP ${response.status}: ${response.statusText}`;
        
        // 인증 오류 처리
        if (response.status === 401) {
            window.location.href = '/login';
        }
        
        throw new Error(message);
    }
}

// 전역 인스턴스와 편의 함수들
const apiClient = new ApiClient();

export const callOrchestrate = (request: OrchestrationRequest) =>
    apiClient.request<OrchestrationResult>('/orchestrate', {
        method: 'POST',
        body: JSON.stringify(request)
    });

export const updateApiKeys = (keys: { openaiKey?: string; anthropicKey?: string }) =>
    apiClient.request<{ success: boolean }>('/auth/me/keys', {
        method: 'PUT',
        body: JSON.stringify(keys)
    });

export const getCurrentUser = () =>
    apiClient.request<User>('/auth/me');

에러 처리와 사용자 피드백

const ErrorBoundary: React.FC<{ children: React.ReactNode }> = ({ children }) => {
    const [hasError, setHasError] = useState(false);
    const [error, setError] = useState<Error | null>(null);

    useEffect(() => {
        const handleError = (event: ErrorEvent) => {
            setHasError(true);
            setError(new Error(event.message));
        };

        window.addEventListener('error', handleError);
        return () => window.removeEventListener('error', handleError);
    }, []);

    if (hasError) {
        return (
            <div className="error-boundary">
                <h2>오류가 발생했습니다</h2>
                <p>{error?.message}</p>
                <button 
                    onClick={() => {
                        setHasError(false);
                        setError(null);
                        window.location.reload();
                    }}
                    className="retry-button"
                >
                    다시 시도
                </button>
            </div>
        );
    }

    return <>{children}</>;
};

// 네트워크 에러 핸들링
const useErrorHandler = () => {
    const [error, setError] = useState<string | null>(null);

    const handleApiError = (error: any) => {
        if (error?.message?.includes('401')) {
            setError('로그인이 필요합니다. 다시 로그인해주세요.');
        } else if (error?.message?.includes('403')) {
            setError('권한이 없습니다. API 키를 확인해주세요.');
        } else if (error?.message?.includes('429')) {
            setError('요청이 너무 많습니다. 잠시 후 다시 시도해주세요.');
        } else {
            setError(error?.message || '알 수 없는 오류가 발생했습니다.');
        }
    };

    const clearError = () => setError(null);

    return { error, handleApiError, clearError };
};

🔑 인증과 키 관리 인터페이스

소셜 로그인 버튼

const SocialLoginButtons: React.FC = () => {
    // OAuth는 프록시를 우회하여 직접 백엔드 접속
    const getOAuthUrl = (provider: 'google' | 'kakao') => {
        const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8787';
        return `${baseUrl}/oauth2/authorize/${provider}`;
    };

    return (
        <div className="social-login-section">
            <div className="divider">
                <span>또는</span>
            </div>
            
            <div className="social-buttons">
                <a 
                    href={getOAuthUrl('google')} 
                    className="social-button google-button"
                >
                    <img src="/google-icon.svg" alt="Google" width="20" height="20" />
                    Google로 로그인
                </a>
                
                <a 
                    href={getOAuthUrl('kakao')} 
                    className="social-button kakao-button"
                >
                    <img src="/kakao-icon.svg" alt="Kakao" width="20" height="20" />
                    카카오로 로그인
                </a>
            </div>
        </div>
    );
};

API 키 관리 페이지

const MyPage: React.FC = () => {
    const [user, setUser] = useState<User | null>(null);
    const [keys, setKeys] = useState({
        openaiKey: '',
        anthropicKey: ''
    });
    const [existingKeys, setExistingKeys] = useState({
        hasOpenAI: false,
        hasAnthropic: false
    });
    const [isSaving, setIsSaving] = useState(false);
    const { error, handleApiError, clearError } = useErrorHandler();

    useEffect(() => {
        // OAuth 로그인 성공 후 리다이렉트 처리
        const urlParams = new URLSearchParams(window.location.search);
        if (urlParams.get('login') === 'success') {
            // URL 정리
            window.history.replaceState({}, '', '/mypage');
            showToast('로그인 성공!', 'success');
        }

        loadUserInfo();
    }, []);

    const loadUserInfo = async () => {
        try {
            const userInfo = await getCurrentUser();
            setUser(userInfo);
            
            // 기존 키 보유 상태 확인 (실제 키 값은 보안상 반환하지 않음)
            const keyStatus = await apiClient.request<{
                hasOpenAI: boolean;
                hasAnthropic: boolean;
            }>('/auth/me/keys');
            setExistingKeys(keyStatus);
            
        } catch (error) {
            handleApiError(error);
        }
    };

    const handleSaveKeys = async () => {
        if (!keys.openaiKey && !keys.anthropicKey) {
            setError('최소 하나의 API 키는 입력해야 합니다.');
            return;
        }

        setIsSaving(true);
        clearError();

        try {
            await updateApiKeys(keys);
            
            // 성공 후 상태 업데이트
            setKeys({ openaiKey: '', anthropicKey: '' });
            if (keys.openaiKey) setExistingKeys(prev => ({ ...prev, hasOpenAI: true }));
            if (keys.anthropicKey) setExistingKeys(prev => ({ ...prev, hasAnthropic: true }));
            
            showToast('API 키가 안전하게 저장되었습니다.', 'success');
        } catch (error) {
            handleApiError(error);
        } finally {
            setIsSaving(false);
        }
    };

    return (
        <div className="mypage-container">
            <h1>내 정보</h1>
            
            {user && (
                <div className="user-info-card">
                    <h2>계정 정보</h2>
                    <div className="info-row">
                        <span className="label">이메일:</span>
                        <span className="value">{user.email}</span>
                    </div>
                    <div className="info-row">
                        <span className="label">가입 방법:</span>
                        <span className="value">
                            {user.provider === 'local' ? '이메일' : user.provider}
                        </span>
                    </div>
                </div>
            )}

            <div className="api-keys-section">
                <h2>API 키 관리</h2>
                <div className="security-notice">
                    🔒 API 키는 AES-256-GCM으로 암호화되어 안전하게 저장됩니다.
                </div>

                {error && (
                    <div className="error-message">
                        ❌ {error}
                        <button onClick={clearError} className="close-error">×</button>
                    </div>
                )}

                <div className="key-input-group">
                    <label htmlFor="openai-key">
                        OpenAI API 키
                        {existingKeys.hasOpenAI && (
                            <span className="key-status saved">✅ 저장됨</span>
                        )}
                    </label>
                    <input
                        id="openai-key"
                        type="password"
                        value={keys.openaiKey}
                        onChange={(e) => setKeys(prev => ({ ...prev, openaiKey: e.target.value }))}
                        placeholder={existingKeys.hasOpenAI ? "새 키 입력 시 기존 키 교체" : "sk-..."}
                        className="key-input"
                    />
                </div>

                <div className="key-input-group">
                    <label htmlFor="anthropic-key">
                        Anthropic API 키
                        {existingKeys.hasAnthropic && (
                            <span className="key-status saved">✅ 저장됨</span>
                        )}
                    </label>
                    <input
                        id="anthropic-key"
                        type="password"
                        value={keys.anthropicKey}
                        onChange={(e) => setKeys(prev => ({ ...prev, anthropicKey: e.target.value }))}
                        placeholder={existingKeys.hasAnthropic ? "새 키 입력 시 기존 키 교체" : "sk-ant-..."}
                        className="key-input"
                    />
                </div>

                <button
                    onClick={handleSaveKeys}
                    disabled={isSaving || (!keys.openaiKey && !keys.anthropicKey)}
                    className="save-keys-button"
                >
                    {isSaving ? '저장 중...' : 'API 키 저장'}
                </button>
            </div>
        </div>
    );
};

📁 파일 업로드와 편의 기능

파일에서 질문 로딩

const FileUploadButton: React.FC<{ onFileLoaded: (content: string) => void }> = ({
    onFileLoaded
}) => {
    const fileInputRef = useRef<HTMLInputElement>(null);
    const [isProcessing, setIsProcessing] = useState(false);

    const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
        const file = event.target.files?.[0];
        if (!file) return;

        setIsProcessing(true);

        try {
            const reader = new FileReader();
            
            reader.onload = (e) => {
                const content = e.target?.result as string;
                onFileLoaded(content);
                showToast(`파일 "${file.name}"에서 질문을 로딩했습니다.`, 'success');
            };

            reader.onerror = () => {
                showToast('파일 읽기에 실패했습니다.', 'error');
            };

            // 파일 타입별 처리
            if (file.type.startsWith('text/') || file.name.endsWith('.md')) {
                reader.readAsText(file, 'UTF-8');
            } else if (file.type === 'application/json') {
                reader.readAsText(file, 'UTF-8');
            } else {
                showToast('지원하지 않는 파일 형식입니다. (지원: .txt, .md, .json)', 'error');
            }
        } catch (error) {
            console.error('File processing error:', error);
            showToast('파일 처리 중 오류가 발생했습니다.', 'error');
        } finally {
            setIsProcessing(false);
            // 파일 입력 초기화
            if (fileInputRef.current) {
                fileInputRef.current.value = '';
            }
        }
    };

    return (
        <div className="file-upload-container">
            <input
                ref={fileInputRef}
                type="file"
                accept=".txt,.md,.json"
                style={{ display: 'none' }}
                onChange={handleFileSelect}
                disabled={isProcessing}
            />
            <button
                type="button"
                onClick={() => fileInputRef.current?.click()}
                disabled={isProcessing}
                className="file-upload-button"
            >
                {isProcessing ? '처리 중...' : '📁 파일에서 불러오기'}
            </button>
        </div>
    );
};

🎨 스타일링과 브랜딩

CSS Variables 기반 디자인 시스템

/* styles/global.css */
:root {
    /* 브랜드 컬러 */
    --color-primary: #3b82f6;
    --color-primary-hover: #2563eb;
    --color-success: #10b981;
    --color-warning: #f59e0b;
    --color-error: #ef4444;
    
    /* 뉴트럴 톤 */
    --color-neutral-50: #f9fafb;
    --color-neutral-100: #f3f4f6;
    --color-neutral-500: #6b7280;
    --color-neutral-900: #111827;
    
    /* 웜 톤 테마 */
    --color-warm-50: #fefdf8;
    --color-warm-100: #fef7e7;
    --color-warm-200: #fdecc0;
    
    /* 타이포그래피 */
    --font-logo: 'Anurati', sans-serif;        /* 로고 전용 */
    --font-header: 'PrintClearly', sans-serif; /* 헤더/CTA */
    --font-body: 'Roboto', sans-serif;         /* 본문 */
    
    /* 스페이싱 */
    --spacing-xs: 0.25rem;
    --spacing-sm: 0.5rem;
    --spacing-md: 1rem;
    --spacing-lg: 1.5rem;
    --spacing-xl: 2rem;
    --spacing-2xl: 3rem;
    
    /* 경계선 */
    --color-border: #e5e7eb;
    --color-border-focus: var(--color-primary);
    
    /* 그림자 */
    --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
    --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
    --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}

/* 다크 모드 지원 (선택사항) */
@media (prefers-color-scheme: dark) {
    :root {
        --color-neutral-50: #1f2937;
        --color-neutral-900: #f9fafb;
        --color-border: #374151;
        --color-warm-50: #1c1917;
        --color-warm-100: #292524;
    }
}

접근성과 반응형 디자인

/* 포커스 상태 일관성 */
.focusable {
    transition: all 0.2s ease;
}

.focusable:focus {
    outline: none;
    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
    border-color: var(--color-border-focus);
}

/* 버튼 시스템 */
.btn-primary {
    background: var(--color-primary);
    color: white;
    border: none;
    padding: var(--spacing-md) var(--spacing-lg);
    border-radius: 6px;
    font-weight: 500;
    font-family: var(--font-header);
    cursor: pointer;
    transition: background-color 0.2s ease;
}

.btn-primary:hover:not(:disabled) {
    background: var(--color-primary-hover);
}

.btn-primary:disabled {
    background: var(--color-neutral-500);
    cursor: not-allowed;
}

/* 반응형 타이포그래피 */
.text-logo {
    font-family: var(--font-logo);
    font-size: clamp(1.5rem, 4vw, 2.5rem);
    font-weight: 400;
}

.text-heading {
    font-family: var(--font-header);
    font-size: clamp(1.25rem, 3vw, 1.875rem);
    font-weight: 600;
}

/* 애니메이션 */
.fade-in {
    animation: fadeIn 0.3s ease-in-out;
}

@keyframes fadeIn {
    from { opacity: 0; transform: translateY(10px); }
    to { opacity: 1; transform: translateY(0); }
}

.loading-pulse {
    animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.7; }
}

🚀 성능 최적화와 사용자 경험

코드 스플리팅

// main.tsx - 페이지별 지연 로딩
import { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const Login = lazy(() => import('./pages/Login'));
const MyPage = lazy(() => import('./pages/MyPage'));
const Register = lazy(() => import('./pages/Register'));

const LoadingFallback = () => (
    <div className="loading-fallback">
        <div className="spinner"></div>
        <p>페이지 로딩 중...</p>
    </div>
);

const App = () => (
    <ErrorBoundary>
        <Router>
            <Header />
            <main className="main-content">
                <Suspense fallback={<LoadingFallback />}>
                    <Routes>
                        <Route path="/" element={<Home />} />
                        <Route path="/login" element={<Login />} />
                        <Route path="/register" element={<Register />} />
                        <Route path="/mypage" element={<MyPage />} />
                    </Routes>
                </Suspense>
            </main>
        </Router>
    </ErrorBoundary>
);

메모이제이션과 최적화

// 검증 결과 표시 컴포넌트 최적화
const VerificationResults = React.memo<{
    results: VerificationResult[];
}>(({ results }) => {
    const processedResults = useMemo(() => {
        return results.map(result => ({
            ...result,
            percentage: Math.round((result.score || 0) * 100),
            statusIcon: result.ok ? '✅' : '❌',
            statusColor: result.ok ? 'var(--color-success)' : 'var(--color-error)'
        }));
    }, [results]);

    return (
        <div className="verification-results">
            {processedResults.map((result, index) => (
                <VerificationCard key={result.category || index} result={result} />
            ))}
        </div>
    );
});

// API 결과 캐싱 (간단한 버전)
const useApiCache = <T,>() => {
    const cache = useRef(new Map<string, { data: T; timestamp: number }>());
    const CACHE_TTL = 5 * 60 * 1000; // 5분

    const getCached = (key: string): T | null => {
        const cached = cache.current.get(key);
        if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
            return cached.data;
        }
        return null;
    };

    const setCached = (key: string, data: T) => {
        cache.current.set(key, { data, timestamp: Date.now() });
    };

    return { getCached, setCached };
};

🎭 실제 사용 시나리오

시나리오 1: 익명 사용자 (BYOK)

const AnonymousFlow = () => {
    const [showKeyModal, setShowKeyModal] = useState(false);
    const [tempKeys, setTempKeys] = useState({ openai: '', anthropic: '' });

    const handleSubmitWithoutLogin = () => {
        if (!tempKeys.openai && !tempKeys.anthropic) {
            setShowKeyModal(true);
            return;
        }

        // BYOK로 오케스트레이션 실행
        callOrchestrate({
            q: question,
            keys: tempKeys,
            debug: true
        });
    };

    return (
        <>
            <button onClick={handleSubmitWithoutLogin}>
                답변 받기 (API 키 직접 사용)
            </button>
            
            {showKeyModal && (
                <ApiKeyModal 
                    onSubmit={(keys) => {
                        setTempKeys(keys);
                        setShowKeyModal(false);
                        // 자동으로 질문 실행
                    }}
                    onCancel={() => setShowKeyModal(false)}
                />
            )}
        </>
    );
};

시나리오 2: 로그인 사용자 (저장된 키)

const LoggedInFlow = () => {
    const [user, setUser] = useState<User | null>(null);
    const [hasKeys, setHasKeys] = useState(false);

    useEffect(() => {
        checkUserAndKeys();
    }, []);

    const handleSubmitWithLogin = async () => {
        if (!hasKeys) {
            // 키가 없으면 마이페이지로 안내
            showToast('먼저 API 키를 저장해주세요.', 'warning');
            window.location.href = '/mypage';
            return;
        }

        // 저장된 키 사용하여 실행 (keys 파라미터 생략)
        const result = await callOrchestrate({
            q: question,
            debug: true
            // keys는 서버에서 자동으로 사용자 저장 키 사용
        });

        setResult(result);
    };

    return (
        <button 
            onClick={handleSubmitWithLogin}
            disabled={!hasKeys}
        >
            {hasKeys ? '답변 받기' : 'API 키 설정 필요'}
        </button>
    );
};

시나리오 3: 복잡한 질문의 전체 플로우

const ComplexQuestionDemo = () => {
    const complexQuestion = `한국의 2024년 수출 통계를 분석하고, 
주요 수출 품목 5개와 각각의 전년 대비 증감률을 알려주세요. 
신뢰할 수 있는 정부 출처와 함께 설명해주세요.`;

    const expectedFlow = {
        1: "GPT와 Claude가 동시에 답변 생성",
        2: "FactCitationVerifier가 출처 링크 검증",
        3: "정부 도메인(kostat.go.kr) 가산점 적용", 
        4: "날짜 표기 부족으로 재시도 요청",
        5: "보강된 답변으로 재검증",
        6: "Judge AI가 두 답변 비교",
        7: "최종 답변 선택 및 신뢰도 표시"
    };

    return (
        <div className="demo-scenario">
            <h3>복잡한 질문 처리 시나리오</h3>
            <div className="demo-question">
                <strong>질문:</strong> {complexQuestion}
            </div>
            <div className="expected-flow">
                <strong>예상 처리 과정:</strong>
                <ol>
                    {Object.entries(expectedFlow).map(([step, description]) => (
                        <li key={step}>{description}</li>
                    ))}
                </ol>
            </div>
            <button onClick={() => setQuestion(complexQuestion)}>
                이 질문으로 테스트하기
            </button>
        </div>
    );
};

🔮 향후 개선 계획

실시간 스트리밍 (계획)

// 미래의 스트리밍 인터페이스 구상
const useOrchestrationStream = () => {
    const [status, setStatus] = useState<ProcessingStatus>('idle');
    const [partialResults, setPartialResults] = useState<PartialResult[]>([]);
    const [currentStep, setCurrentStep] = useState<string>('');

    const startOrchestration = async (question: string) => {
        const eventSource = new EventSource('/api/orchestrate/stream');
        
        eventSource.onmessage = (event) => {
            const data = JSON.parse(event.data);
            
            switch (data.type) {
                case 'status_update':
                    setStatus(data.status);
                    setCurrentStep(data.step);
                    break;
                case 'partial_result':
                    setPartialResults(prev => [...prev, data.result]);
                    break;
                case 'verification_result':
                    // 검증 결과를 실시간으로 표시
                    break;
                case 'complete':
                    setStatus('complete');
                    eventSource.close();
                    break;
            }
        };
    };

    return { status, partialResults, currentStep, startOrchestration };
};

고급 UI 컴포넌트 (계획)

// 검증 과정 타임라인 컴포넌트
const ProcessingTimeline: React.FC<{ steps: ProcessingStep[] }> = ({ steps }) => (
    <div className="processing-timeline">
        {steps.map((step, index) => (
            <div key={index} className={`timeline-step ${step.status}`}>
                <div className="step-indicator">
                    {step.status === 'completed' ? '✓' : 
                     step.status === 'running' ? '⟳' : 
                     step.status === 'failed' ? '✗' : '○'}
                </div>
                <div className="step-content">
                    <h4>{step.title}</h4>
                    <p>{step.description}</p>
                    {step.duration && (
                        <span className="step-duration">{step.duration}ms</span>
                    )}
                </div>
            </div>
        ))}
    </div>
);

// 검증기별 상세 분석 차트
const VerificationChart: React.FC<{ results: VerificationResult[] }> = ({ results }) => {
    const chartData = results.map(result => ({
        name: result.category,
        score: Math.round((result.score || 0) * 100),
        passed: result.ok
    }));

    return (
        <div className="verification-chart">
            {/* D3.js 또는 Chart.js로 구현 예정 */}
        </div>
    );
};

🎉 마무리: 사용자 중심 설계의 힘

MeshProof 프론트엔드를 통해 배운 가장 중요한 교훈은 **"복잡한 기술을 어떻게 사용자에게 단순하고 직관적으로 전달하느냐"**의 중요성입니다.

핵심 성과들

  1. 복잡함의 추상화: 다중 AI 검증이라는 복잡한 과정을 "질문 → 답변" 인터페이스로 단순화
  2. 투명성과 신뢰: 원한다면 전체 검증 과정을 확인할 수 있는 디버그 뷰어 제공
  3. 점진적 개선: 익명 사용 → 로그인 → 키 저장의 자연스러운 사용자 여정
  4. 시각적 신뢰도: 복잡한 점수 계산을 직관적인 신뢰도 표시기로 변환

기술적 성취들

  • CSRF 자동 처리: 보안을 위해 복잡한 토큰 관리가 필요하지만, 사용자는 전혀 모르게 처리
  • 에러 복구: API 실패나 네트워크 오류 상황에서도 자연스러운 사용자 경험 유지
  • 성능 최적화: 코드 스플리팅과 메모이제이션으로 빠른 로딩과 부드러운 인터랙션
  • 접근성: 키보드 네비게이션, 스크린 리더 지원, 고대비 색상 등

앞으로의 발전 방향

MeshProof는 "AI의 답변을 더 신뢰할 수 있게 만드는" 미션을 사용자 경험 측면에서도 지속적으로 발전시킬 예정입니다:

  • 📊 실시간 피드백: 검증 과정을 스트리밍으로 실시간 표시
  • 🎨 개인화: 사용자별 선호하는 검증기나 AI 모델 설정
  • 📱 모바일 최적화: PWA 기술을 활용한 모바일 네이티브 경험
  • 🤝 협업 기능: 팀 단위로 검증된 답변을 공유하고 활용

🔗 프로젝트 리소스

  • GitHub Repository: [MeshProof 소스코드]
  • 라이브 데모: [실제 작동하는 데모 사이트]
  • API 문서: [개발자용 API 문서]

*3부작 시리즈를 통해 MeshProof의 전체 여정을 살펴봤습니다.
1편의 문제 정의와 설계, 2편의 기술적 구현, 그리고 3편의 사용자 경험까지.

더 신뢰할 수 있는 AI 시대를 만들어가는 여정에 여러분도 함께해 주세요!*