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 응답 타입
핵심 설계 원칙
- 즉시 시작 가능: 로그인 없이도 BYOK로 바로 사용 가능
- 투명성: 원한다면 전체 검증 과정을 확인할 수 있음
- 신뢰도 시각화: 답변의 신뢰도를 직관적으로 표현
- 점진적 개선: 기본 기능 → 로그인 → 키 저장 → 고급 설정
🎨 메인 인터페이스: 질문에서 답변까지
듀얼 에디터 레이아웃
// 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 프론트엔드를 통해 배운 가장 중요한 교훈은 **"복잡한 기술을 어떻게 사용자에게 단순하고 직관적으로 전달하느냐"**의 중요성입니다.
핵심 성과들
- 복잡함의 추상화: 다중 AI 검증이라는 복잡한 과정을 "질문 → 답변" 인터페이스로 단순화
- 투명성과 신뢰: 원한다면 전체 검증 과정을 확인할 수 있는 디버그 뷰어 제공
- 점진적 개선: 익명 사용 → 로그인 → 키 저장의 자연스러운 사용자 여정
- 시각적 신뢰도: 복잡한 점수 계산을 직관적인 신뢰도 표시기로 변환
기술적 성취들
- CSRF 자동 처리: 보안을 위해 복잡한 토큰 관리가 필요하지만, 사용자는 전혀 모르게 처리
- 에러 복구: API 실패나 네트워크 오류 상황에서도 자연스러운 사용자 경험 유지
- 성능 최적화: 코드 스플리팅과 메모이제이션으로 빠른 로딩과 부드러운 인터랙션
- 접근성: 키보드 네비게이션, 스크린 리더 지원, 고대비 색상 등
앞으로의 발전 방향
MeshProof는 "AI의 답변을 더 신뢰할 수 있게 만드는" 미션을 사용자 경험 측면에서도 지속적으로 발전시킬 예정입니다:
- 📊 실시간 피드백: 검증 과정을 스트리밍으로 실시간 표시
- 🎨 개인화: 사용자별 선호하는 검증기나 AI 모델 설정
- 📱 모바일 최적화: PWA 기술을 활용한 모바일 네이티브 경험
- 🤝 협업 기능: 팀 단위로 검증된 답변을 공유하고 활용
🔗 프로젝트 리소스
- GitHub Repository: [MeshProof 소스코드]
- 라이브 데모: [실제 작동하는 데모 사이트]
- API 문서: [개발자용 API 문서]
*3부작 시리즈를 통해 MeshProof의 전체 여정을 살펴봤습니다.
1편의 문제 정의와 설계, 2편의 기술적 구현, 그리고 3편의 사용자 경험까지.
더 신뢰할 수 있는 AI 시대를 만들어가는 여정에 여러분도 함께해 주세요!*
'MyStory > Consensus_Verifiers' 카테고리의 다른 글
| MeshProof: 두 모델이 합의할 때까지 수렴하는 QA 시스템(2) (2) | 2025.08.24 |
|---|---|
| MeshProof: 두 모델이 합의할 때까지 수렴하는 QA 시스템 (0) | 2025.08.24 |