<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Lupy Now crossing the Rubicon!</title>
    <link>https://lupylaon.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 6 Apr 2026 10:53:02 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>LupyLaon</managingEditor>
    <item>
      <title>MeshProof: 두 모델이 합의할 때까지 수렴하는 QA 시스템(3)</title>
      <link>https://lupylaon.tistory.com/25</link>
      <description>&lt;h1&gt;MeshProof: 두 모델이 합의할 때까지 수렴하는 QA 시스템&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;consensus-verifiers로 만드는 신뢰형 LLM 파이프라인&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;(3/3) 프론트엔드와 사용자 경험 &amp;mdash; 복잡함을 숨기고 신뢰를 보여주기&lt;/h2&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  이번 편에서 다룰 내용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2편에서 강력한 백엔드 엔진을 구축했다면, 이번에는 &lt;b&gt;사용자가 실제로 어떻게 경험하게 되는지&lt;/b&gt;에 초점을 맞춥니다. 복잡한 다중 AI 검증 과정을 어떻게 직관적이고 신뢰할 수 있는 인터페이스로 변환했는지 살펴보겠습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 프론트엔드 아키텍처&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기술 스택과 설계 원칙&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 기술 스택
- React 19 + TypeScript     // 타입 안전성과 최신 기능
- Vite 7                   // 빠른 개발 서버와 HMR  
- React Router             // SPA 라우팅
- 표준 CSS + CSS Variables // 간단하지만 강력한 스타일링
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프로젝트 구조 설계&lt;/h3&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;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 응답 타입
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 설계 원칙&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;즉시 시작 가능&lt;/b&gt;: 로그인 없이도 BYOK로 바로 사용 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;투명성&lt;/b&gt;: 원한다면 전체 검증 과정을 확인할 수 있음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;신뢰도 시각화&lt;/b&gt;: 답변의 신뢰도를 직관적으로 표현&lt;/li&gt;
&lt;li&gt;&lt;b&gt;점진적 개선&lt;/b&gt;: 기본 기능 &amp;rarr; 로그인 &amp;rarr; 키 저장 &amp;rarr; 고급 설정&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  메인 인터페이스: 질문에서 답변까지&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;듀얼 에디터 레이아웃&lt;/h3&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;// Home.tsx - 메인 인터페이스
const Home: React.FC = () =&amp;gt; {
    const [question, setQuestion] = useState('');
    const [result, setResult] = useState&amp;lt;OrchestrationResult | null&amp;gt;(null);
    const [isLoading, setIsLoading] = useState(false);
    const [loadingStage, setLoadingStage] = useState&amp;lt;ProcessingStage&amp;gt;('idle');

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

                {/* 우측: 검증된 답변 */}
                &amp;lt;div className=&quot;result-panel&quot;&amp;gt;
                    &amp;lt;h2&amp;gt;검증된 답변&amp;lt;/h2&amp;gt;
                    {isLoading ? (
                        &amp;lt;LoadingIndicator stage={loadingStage} /&amp;gt;
                    ) : result ? (
                        &amp;lt;ResultDisplay result={result} /&amp;gt;
                    ) : (
                        &amp;lt;EmptyState /&amp;gt;
                    )}
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CSS Grid 기반 반응형 레이아웃&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;/* 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);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  단계별 로딩과 사용자 피드백&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 모델 호출과 검증 과정은 시간이 걸리므로, 사용자가 무엇이 일어나고 있는지 명확히 알 수 있게 해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;처리 단계 시각화&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;type ProcessingStage = 
    | 'idle' 
    | 'calling_models' 
    | 'verifying' 
    | 'reaching_consensus'
    | 'complete';

const LoadingIndicator: React.FC&amp;lt;{ stage: ProcessingStage }&amp;gt; = ({ stage }) =&amp;gt; {
    const getStageInfo = (stage: ProcessingStage) =&amp;gt; {
        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 (
        &amp;lt;div className=&quot;loading-container&quot;&amp;gt;
            &amp;lt;div className=&quot;loading-icon&quot;&amp;gt;{info.icon}&amp;lt;/div&amp;gt;
            &amp;lt;div className=&quot;loading-text&quot;&amp;gt;{info.text}&amp;lt;/div&amp;gt;
            &amp;lt;div className=&quot;progress-bar&quot;&amp;gt;
                &amp;lt;div 
                    className=&quot;progress-fill&quot; 
                    style={{ width: `${info.progress}%` }}
                /&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;div className=&quot;progress-text&quot;&amp;gt;{info.progress}%&amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실시간 상태 업데이트&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const handleSubmit = async () =&amp;gt; {
    setIsLoading(true);
    setLoadingStage('calling_models');
    
    try {
        // 단계별 상태 업데이트를 위한 타이머들
        const stageTimers = [
            setTimeout(() =&amp;gt; setLoadingStage('verifying'), 2000),
            setTimeout(() =&amp;gt; setLoadingStage('reaching_consensus'), 4000),
        ];

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

        // 타이머들 정리
        stageTimers.forEach(timer =&amp;gt; clearTimeout(timer));
        
        setLoadingStage('complete');
        setResult(result);
        
        // 완료 후 잠깐 대기 후 아이들 상태로
        setTimeout(() =&amp;gt; setLoadingStage('idle'), 500);
        
    } catch (error) {
        console.error('Orchestration failed:', error);
        setError(error.message);
    } finally {
        setIsLoading(false);
    }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  신뢰도 시각화: 복잡한 정보를 직관적으로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 답변을 얼마나 신뢰해야 할지 한눈에 알 수 있게 하는 것이 핵심입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;신뢰도 표시기 컴포넌트&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;interface TrustIndicatorProps {
    confidence: number;          // 0-1 범위
    consensusType: string;       // 'agreement', 'winner_selected'  
    verificationScore: number;   // 검증기 평균 점수
    agreementType?: 'equivalent' | 'different';
}

const TrustIndicator: React.FC&amp;lt;TrustIndicatorProps&amp;gt; = ({
    confidence,
    consensusType,
    verificationScore,
    agreementType
}) =&amp;gt; {
    // 종합 신뢰도 계산
    const overallTrust = (confidence + verificationScore) / 2;
    
    const getTrustLevel = () =&amp;gt; {
        if (overallTrust &amp;gt;= 0.9) return { 
            level: 'high', 
            color: '#10b981', 
            text: '매우 신뢰함',
            bgColor: '#d1fae5'
        };
        if (overallTrust &amp;gt;= 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 (
        &amp;lt;div className=&quot;trust-indicator&quot; style={{ backgroundColor: trust.bgColor }}&amp;gt;
            &amp;lt;div className=&quot;trust-main&quot;&amp;gt;
                &amp;lt;div className=&quot;trust-circle&quot; style={{ borderColor: trust.color }}&amp;gt;
                    &amp;lt;span className=&quot;trust-percentage&quot;&amp;gt;{percentage}%&amp;lt;/span&amp;gt;
                &amp;lt;/div&amp;gt;
                &amp;lt;div className=&quot;trust-details&quot;&amp;gt;
                    &amp;lt;div className=&quot;trust-label&quot; style={{ color: trust.color }}&amp;gt;
                        {trust.text}
                    &amp;lt;/div&amp;gt;
                    &amp;lt;div className=&quot;trust-breakdown&quot;&amp;gt;
                        &amp;lt;span&amp;gt;답변 신뢰도: {Math.round(confidence * 100)}%&amp;lt;/span&amp;gt;
                        &amp;lt;span&amp;gt;검증 점수: {Math.round(verificationScore * 100)}%&amp;lt;/span&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
            
            &amp;lt;div className=&quot;consensus-info&quot;&amp;gt;
                {agreementType === 'equivalent' ? (
                    &amp;lt;div className=&quot;consensus-badge agreement&quot;&amp;gt;
                        ✅ 두 AI 모델 합의 달성
                    &amp;lt;/div&amp;gt;
                ) : (
                    &amp;lt;div className=&quot;consensus-badge selection&quot;&amp;gt;
                          최적 답변 선택됨
                    &amp;lt;/div&amp;gt;
                )}
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과 표시 컴포넌트&lt;/h3&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;const ResultDisplay: React.FC&amp;lt;{ result: OrchestrationResult }&amp;gt; = ({ result }) =&amp;gt; {
    const [showDebug, setShowDebug] = useState(false);

    return (
        &amp;lt;div className=&quot;result-container&quot;&amp;gt;
            &amp;lt;TrustIndicator 
                confidence={result.meta?.confidence || 0}
                consensusType={result.meta?.consensusType || 'unknown'}
                verificationScore={result.debug?.averageScore || 0}
                agreementType={result.meta?.agreement}
            /&amp;gt;

            &amp;lt;div className=&quot;answer-content&quot;&amp;gt;
                &amp;lt;h3&amp;gt;최종 답변&amp;lt;/h3&amp;gt;
                &amp;lt;div className=&quot;answer-text&quot;&amp;gt;
                    {result.content}
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;

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

            &amp;lt;button 
                className=&quot;debug-toggle&quot;
                onClick={() =&amp;gt; setShowDebug(!showDebug)}
            &amp;gt;
                  검증 과정 {showDebug ? '숨기기' : '보기'}
            &amp;lt;/button&amp;gt;

            {showDebug &amp;amp;&amp;amp; result.debug &amp;amp;&amp;amp; (
                &amp;lt;DebugViewer debugData={result.debug} /&amp;gt;
            )}
        &amp;lt;/div&amp;gt;
    );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  디버그 뷰어: 투명성의 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 원한다면 전체 검증 과정을 상세히 볼 수 있게 하여 시스템에 대한 신뢰를 높입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;검증 과정 시각화&lt;/h3&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;const DebugViewer: React.FC&amp;lt;{ debugData: DebugData }&amp;gt; = ({ debugData }) =&amp;gt; {
    const [activeTab, setActiveTab] = useState&amp;lt;'verification' | 'consensus' | 'raw'&amp;gt;('verification');

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

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

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

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

const VerifierResult: React.FC&amp;lt;{ detail: VerifyResult }&amp;gt; = ({ detail }) =&amp;gt; (
    &amp;lt;div className={`verifier-result ${detail.ok ? 'pass' : 'fail'}`}&amp;gt;
        &amp;lt;div className=&quot;verifier-header&quot;&amp;gt;
            &amp;lt;span className=&quot;verifier-name&quot;&amp;gt;{detail.category}&amp;lt;/span&amp;gt;
            &amp;lt;span className=&quot;verifier-score&quot;&amp;gt;
                {Math.round((detail.score || 0) * 100)}/100
            &amp;lt;/span&amp;gt;
        &amp;lt;/div&amp;gt;
        
        {detail.evidence &amp;amp;&amp;amp; detail.evidence.length &amp;gt; 0 &amp;amp;&amp;amp; (
            &amp;lt;div className=&quot;evidence&quot;&amp;gt;
                &amp;lt;strong&amp;gt;검증된 증거:&amp;lt;/strong&amp;gt;
                &amp;lt;ul&amp;gt;
                    {detail.evidence.map((item, i) =&amp;gt; (
                        &amp;lt;li key={i}&amp;gt;{item}&amp;lt;/li&amp;gt;
                    ))}
                &amp;lt;/ul&amp;gt;
            &amp;lt;/div&amp;gt;
        )}
        
        {detail.notes &amp;amp;&amp;amp; detail.notes.length &amp;gt; 0 &amp;amp;&amp;amp; (
            &amp;lt;div className=&quot;notes&quot;&amp;gt;
                {detail.notes.map((note, i) =&amp;gt; (
                    &amp;lt;div key={i} className=&quot;note&quot;&amp;gt;{note}&amp;lt;/div&amp;gt;
                ))}
            &amp;lt;/div&amp;gt;
        )}
    &amp;lt;/div&amp;gt;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  API 클라이언트와 보안 처리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CSRF 토큰 자동 처리&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// lib/api.ts
class ApiClient {
    private csrfToken: string | null = null;

    async request&amp;lt;T&amp;gt;(
        endpoint: string,
        options: RequestInit = {}
    ): Promise&amp;lt;T&amp;gt; {
        // 수정 요청 전에 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 &amp;amp;&amp;amp; this.isMutatingRequest(options.method) &amp;amp;&amp;amp; {
                    '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&amp;lt;void&amp;gt; {
        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&amp;lt;never&amp;gt; {
        const errorData = await response.json().catch(() =&amp;gt; ({}));
        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) =&amp;gt;
    apiClient.request&amp;lt;OrchestrationResult&amp;gt;('/orchestrate', {
        method: 'POST',
        body: JSON.stringify(request)
    });

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

export const getCurrentUser = () =&amp;gt;
    apiClient.request&amp;lt;User&amp;gt;('/auth/me');
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;에러 처리와 사용자 피드백&lt;/h3&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;const ErrorBoundary: React.FC&amp;lt;{ children: React.ReactNode }&amp;gt; = ({ children }) =&amp;gt; {
    const [hasError, setHasError] = useState(false);
    const [error, setError] = useState&amp;lt;Error | null&amp;gt;(null);

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

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

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

    return &amp;lt;&amp;gt;{children}&amp;lt;/&amp;gt;;
};

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

    const handleApiError = (error: any) =&amp;gt; {
        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 = () =&amp;gt; setError(null);

    return { error, handleApiError, clearError };
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  인증과 키 관리 인터페이스&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;소셜 로그인 버튼&lt;/h3&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;const SocialLoginButtons: React.FC = () =&amp;gt; {
    // OAuth는 프록시를 우회하여 직접 백엔드 접속
    const getOAuthUrl = (provider: 'google' | 'kakao') =&amp;gt; {
        const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8787';
        return `${baseUrl}/oauth2/authorize/${provider}`;
    };

    return (
        &amp;lt;div className=&quot;social-login-section&quot;&amp;gt;
            &amp;lt;div className=&quot;divider&quot;&amp;gt;
                &amp;lt;span&amp;gt;또는&amp;lt;/span&amp;gt;
            &amp;lt;/div&amp;gt;
            
            &amp;lt;div className=&quot;social-buttons&quot;&amp;gt;
                &amp;lt;a 
                    href={getOAuthUrl('google')} 
                    className=&quot;social-button google-button&quot;
                &amp;gt;
                    &amp;lt;img src=&quot;/google-icon.svg&quot; alt=&quot;Google&quot; width=&quot;20&quot; height=&quot;20&quot; /&amp;gt;
                    Google로 로그인
                &amp;lt;/a&amp;gt;
                
                &amp;lt;a 
                    href={getOAuthUrl('kakao')} 
                    className=&quot;social-button kakao-button&quot;
                &amp;gt;
                    &amp;lt;img src=&quot;/kakao-icon.svg&quot; alt=&quot;Kakao&quot; width=&quot;20&quot; height=&quot;20&quot; /&amp;gt;
                    카카오로 로그인
                &amp;lt;/a&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;API 키 관리 페이지&lt;/h3&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;const MyPage: React.FC = () =&amp;gt; {
    const [user, setUser] = useState&amp;lt;User | null&amp;gt;(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(() =&amp;gt; {
        // OAuth 로그인 성공 후 리다이렉트 처리
        const urlParams = new URLSearchParams(window.location.search);
        if (urlParams.get('login') === 'success') {
            // URL 정리
            window.history.replaceState({}, '', '/mypage');
            showToast('로그인 성공!', 'success');
        }

        loadUserInfo();
    }, []);

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

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

        setIsSaving(true);
        clearError();

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

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

            &amp;lt;div className=&quot;api-keys-section&quot;&amp;gt;
                &amp;lt;h2&amp;gt;API 키 관리&amp;lt;/h2&amp;gt;
                &amp;lt;div className=&quot;security-notice&quot;&amp;gt;
                      API 키는 AES-256-GCM으로 암호화되어 안전하게 저장됩니다.
                &amp;lt;/div&amp;gt;

                {error &amp;amp;&amp;amp; (
                    &amp;lt;div className=&quot;error-message&quot;&amp;gt;
                        ❌ {error}
                        &amp;lt;button onClick={clearError} className=&quot;close-error&quot;&amp;gt;&amp;times;&amp;lt;/button&amp;gt;
                    &amp;lt;/div&amp;gt;
                )}

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

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

                &amp;lt;button
                    onClick={handleSaveKeys}
                    disabled={isSaving || (!keys.openaiKey &amp;amp;&amp;amp; !keys.anthropicKey)}
                    className=&quot;save-keys-button&quot;
                &amp;gt;
                    {isSaving ? '저장 중...' : 'API 키 저장'}
                &amp;lt;/button&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  파일 업로드와 편의 기능&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;파일에서 질문 로딩&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const FileUploadButton: React.FC&amp;lt;{ onFileLoaded: (content: string) =&amp;gt; void }&amp;gt; = ({
    onFileLoaded
}) =&amp;gt; {
    const fileInputRef = useRef&amp;lt;HTMLInputElement&amp;gt;(null);
    const [isProcessing, setIsProcessing] = useState(false);

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

        setIsProcessing(true);

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

            reader.onerror = () =&amp;gt; {
                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 (
        &amp;lt;div className=&quot;file-upload-container&quot;&amp;gt;
            &amp;lt;input
                ref={fileInputRef}
                type=&quot;file&quot;
                accept=&quot;.txt,.md,.json&quot;
                style={{ display: 'none' }}
                onChange={handleFileSelect}
                disabled={isProcessing}
            /&amp;gt;
            &amp;lt;button
                type=&quot;button&quot;
                onClick={() =&amp;gt; fileInputRef.current?.click()}
                disabled={isProcessing}
                className=&quot;file-upload-button&quot;
            &amp;gt;
                {isProcessing ? '처리 중...' : '  파일에서 불러오기'}
            &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
    );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  스타일링과 브랜딩&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CSS Variables 기반 디자인 시스템&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;/* 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;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;접근성과 반응형 디자인&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;/* 포커스 상태 일관성 */
.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; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  성능 최적화와 사용자 경험&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드 스플리팅&lt;/h3&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;// main.tsx - 페이지별 지연 로딩
import { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() =&amp;gt; import('./pages/Home'));
const Login = lazy(() =&amp;gt; import('./pages/Login'));
const MyPage = lazy(() =&amp;gt; import('./pages/MyPage'));
const Register = lazy(() =&amp;gt; import('./pages/Register'));

const LoadingFallback = () =&amp;gt; (
    &amp;lt;div className=&quot;loading-fallback&quot;&amp;gt;
        &amp;lt;div className=&quot;spinner&quot;&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;p&amp;gt;페이지 로딩 중...&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
);

const App = () =&amp;gt; (
    &amp;lt;ErrorBoundary&amp;gt;
        &amp;lt;Router&amp;gt;
            &amp;lt;Header /&amp;gt;
            &amp;lt;main className=&quot;main-content&quot;&amp;gt;
                &amp;lt;Suspense fallback={&amp;lt;LoadingFallback /&amp;gt;}&amp;gt;
                    &amp;lt;Routes&amp;gt;
                        &amp;lt;Route path=&quot;/&quot; element={&amp;lt;Home /&amp;gt;} /&amp;gt;
                        &amp;lt;Route path=&quot;/login&quot; element={&amp;lt;Login /&amp;gt;} /&amp;gt;
                        &amp;lt;Route path=&quot;/register&quot; element={&amp;lt;Register /&amp;gt;} /&amp;gt;
                        &amp;lt;Route path=&quot;/mypage&quot; element={&amp;lt;MyPage /&amp;gt;} /&amp;gt;
                    &amp;lt;/Routes&amp;gt;
                &amp;lt;/Suspense&amp;gt;
            &amp;lt;/main&amp;gt;
        &amp;lt;/Router&amp;gt;
    &amp;lt;/ErrorBoundary&amp;gt;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;메모이제이션과 최적화&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// 검증 결과 표시 컴포넌트 최적화
const VerificationResults = React.memo&amp;lt;{
    results: VerificationResult[];
}&amp;gt;(({ results }) =&amp;gt; {
    const processedResults = useMemo(() =&amp;gt; {
        return results.map(result =&amp;gt; ({
            ...result,
            percentage: Math.round((result.score || 0) * 100),
            statusIcon: result.ok ? '✅' : '❌',
            statusColor: result.ok ? 'var(--color-success)' : 'var(--color-error)'
        }));
    }, [results]);

    return (
        &amp;lt;div className=&quot;verification-results&quot;&amp;gt;
            {processedResults.map((result, index) =&amp;gt; (
                &amp;lt;VerificationCard key={result.category || index} result={result} /&amp;gt;
            ))}
        &amp;lt;/div&amp;gt;
    );
});

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

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

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

    return { getCached, setCached };
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  실제 사용 시나리오&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오 1: 익명 사용자 (BYOK)&lt;/h3&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;const AnonymousFlow = () =&amp;gt; {
    const [showKeyModal, setShowKeyModal] = useState(false);
    const [tempKeys, setTempKeys] = useState({ openai: '', anthropic: '' });

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

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

    return (
        &amp;lt;&amp;gt;
            &amp;lt;button onClick={handleSubmitWithoutLogin}&amp;gt;
                답변 받기 (API 키 직접 사용)
            &amp;lt;/button&amp;gt;
            
            {showKeyModal &amp;amp;&amp;amp; (
                &amp;lt;ApiKeyModal 
                    onSubmit={(keys) =&amp;gt; {
                        setTempKeys(keys);
                        setShowKeyModal(false);
                        // 자동으로 질문 실행
                    }}
                    onCancel={() =&amp;gt; setShowKeyModal(false)}
                /&amp;gt;
            )}
        &amp;lt;/&amp;gt;
    );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오 2: 로그인 사용자 (저장된 키)&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const LoggedInFlow = () =&amp;gt; {
    const [user, setUser] = useState&amp;lt;User | null&amp;gt;(null);
    const [hasKeys, setHasKeys] = useState(false);

    useEffect(() =&amp;gt; {
        checkUserAndKeys();
    }, []);

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

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

        setResult(result);
    };

    return (
        &amp;lt;button 
            onClick={handleSubmitWithLogin}
            disabled={!hasKeys}
        &amp;gt;
            {hasKeys ? '답변 받기' : 'API 키 설정 필요'}
        &amp;lt;/button&amp;gt;
    );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오 3: 복잡한 질문의 전체 플로우&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const ComplexQuestionDemo = () =&amp;gt; {
    const complexQuestion = `한국의 2024년 수출 통계를 분석하고, 
주요 수출 품목 5개와 각각의 전년 대비 증감률을 알려주세요. 
신뢰할 수 있는 정부 출처와 함께 설명해주세요.`;

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

    return (
        &amp;lt;div className=&quot;demo-scenario&quot;&amp;gt;
            &amp;lt;h3&amp;gt;복잡한 질문 처리 시나리오&amp;lt;/h3&amp;gt;
            &amp;lt;div className=&quot;demo-question&quot;&amp;gt;
                &amp;lt;strong&amp;gt;질문:&amp;lt;/strong&amp;gt; {complexQuestion}
            &amp;lt;/div&amp;gt;
            &amp;lt;div className=&quot;expected-flow&quot;&amp;gt;
                &amp;lt;strong&amp;gt;예상 처리 과정:&amp;lt;/strong&amp;gt;
                &amp;lt;ol&amp;gt;
                    {Object.entries(expectedFlow).map(([step, description]) =&amp;gt; (
                        &amp;lt;li key={step}&amp;gt;{description}&amp;lt;/li&amp;gt;
                    ))}
                &amp;lt;/ol&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;button onClick={() =&amp;gt; setQuestion(complexQuestion)}&amp;gt;
                이 질문으로 테스트하기
            &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
    );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  향후 개선 계획&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실시간 스트리밍 (계획)&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// 미래의 스트리밍 인터페이스 구상
const useOrchestrationStream = () =&amp;gt; {
    const [status, setStatus] = useState&amp;lt;ProcessingStatus&amp;gt;('idle');
    const [partialResults, setPartialResults] = useState&amp;lt;PartialResult[]&amp;gt;([]);
    const [currentStep, setCurrentStep] = useState&amp;lt;string&amp;gt;('');

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

    return { status, partialResults, currentStep, startOrchestration };
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;고급 UI 컴포넌트 (계획)&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 검증 과정 타임라인 컴포넌트
const ProcessingTimeline: React.FC&amp;lt;{ steps: ProcessingStep[] }&amp;gt; = ({ steps }) =&amp;gt; (
    &amp;lt;div className=&quot;processing-timeline&quot;&amp;gt;
        {steps.map((step, index) =&amp;gt; (
            &amp;lt;div key={index} className={`timeline-step ${step.status}`}&amp;gt;
                &amp;lt;div className=&quot;step-indicator&quot;&amp;gt;
                    {step.status === 'completed' ? '✓' : 
                     step.status === 'running' ? '⟳' : 
                     step.status === 'failed' ? '✗' : '○'}
                &amp;lt;/div&amp;gt;
                &amp;lt;div className=&quot;step-content&quot;&amp;gt;
                    &amp;lt;h4&amp;gt;{step.title}&amp;lt;/h4&amp;gt;
                    &amp;lt;p&amp;gt;{step.description}&amp;lt;/p&amp;gt;
                    {step.duration &amp;amp;&amp;amp; (
                        &amp;lt;span className=&quot;step-duration&quot;&amp;gt;{step.duration}ms&amp;lt;/span&amp;gt;
                    )}
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        ))}
    &amp;lt;/div&amp;gt;
);

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

    return (
        &amp;lt;div className=&quot;verification-chart&quot;&amp;gt;
            {/* D3.js 또는 Chart.js로 구현 예정 */}
        &amp;lt;/div&amp;gt;
    );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마무리: 사용자 중심 설계의 힘&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MeshProof 프론트엔드를 통해 배운 가장 중요한 교훈은 **&quot;복잡한 기술을 어떻게 사용자에게 단순하고 직관적으로 전달하느냐&quot;**의 중요성입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 성과들&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;복잡함의 추상화&lt;/b&gt;: 다중 AI 검증이라는 복잡한 과정을 &quot;질문 &amp;rarr; 답변&quot; 인터페이스로 단순화&lt;/li&gt;
&lt;li&gt;&lt;b&gt;투명성과 신뢰&lt;/b&gt;: 원한다면 전체 검증 과정을 확인할 수 있는 디버그 뷰어 제공&lt;/li&gt;
&lt;li&gt;&lt;b&gt;점진적 개선&lt;/b&gt;: 익명 사용 &amp;rarr; 로그인 &amp;rarr; 키 저장의 자연스러운 사용자 여정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;시각적 신뢰도&lt;/b&gt;: 복잡한 점수 계산을 직관적인 신뢰도 표시기로 변환&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기술적 성취들&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;CSRF 자동 처리&lt;/b&gt;: 보안을 위해 복잡한 토큰 관리가 필요하지만, 사용자는 전혀 모르게 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러 복구&lt;/b&gt;: API 실패나 네트워크 오류 상황에서도 자연스러운 사용자 경험 유지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성능 최적화&lt;/b&gt;: 코드 스플리팅과 메모이제이션으로 빠른 로딩과 부드러운 인터랙션&lt;/li&gt;
&lt;li&gt;&lt;b&gt;접근성&lt;/b&gt;: 키보드 네비게이션, 스크린 리더 지원, 고대비 색상 등&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;앞으로의 발전 방향&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MeshProof는 &lt;b&gt;&quot;AI의 답변을 더 신뢰할 수 있게 만드는&quot;&lt;/b&gt; 미션을 사용자 경험 측면에서도 지속적으로 발전시킬 예정입니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  &lt;b&gt;실시간 피드백&lt;/b&gt;: 검증 과정을 스트리밍으로 실시간 표시&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;개인화&lt;/b&gt;: 사용자별 선호하는 검증기나 AI 모델 설정&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;모바일 최적화&lt;/b&gt;: PWA 기술을 활용한 모바일 네이티브 경험&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;협업 기능&lt;/b&gt;: 팀 단위로 검증된 답변을 공유하고 활용&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  프로젝트 리소스&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;GitHub Repository&lt;/b&gt;: [MeshProof 소스코드]&lt;/li&gt;
&lt;li&gt;&lt;b&gt;라이브 데모&lt;/b&gt;: [실제 작동하는 데모 사이트]&lt;/li&gt;
&lt;li&gt;&lt;b&gt;API 문서&lt;/b&gt;: [개발자용 API 문서]&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;*3부작 시리즈를 통해 MeshProof의 전체 여정을 살펴봤습니다.&lt;br /&gt;1편의 &lt;b&gt;문제 정의와 설계&lt;/b&gt;, 2편의 &lt;b&gt;기술적 구현&lt;/b&gt;, 그리고 3편의 &lt;b&gt;사용자 경험&lt;/b&gt;까지.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;더 신뢰할 수 있는 AI 시대&lt;/b&gt;를 만들어가는 여정에 여러분도 함께해 주세요!*&lt;/p&gt;</description>
      <category>MyStory/Consensus_Verifiers</category>
      <author>LupyLaon</author>
      <guid isPermaLink="true">https://lupylaon.tistory.com/25</guid>
      <comments>https://lupylaon.tistory.com/25#entry25comment</comments>
      <pubDate>Sun, 24 Aug 2025 16:29:40 +0900</pubDate>
    </item>
    <item>
      <title>MeshProof: 두 모델이 합의할 때까지 수렴하는 QA 시스템(2)</title>
      <link>https://lupylaon.tistory.com/24</link>
      <description>&lt;h1&gt;MeshProof: 두 모델이 합의할 때까지 수렴하는 QA 시스템&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;consensus-verifiers로 만드는 신뢰형 LLM 파이프라인&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;(2/3) 검증기와 오케스트레이션 엔진 deep dive &amp;mdash; 핵심 구현 해부하기&lt;/h2&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  이번 편에서 다룰 내용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1편에서 MeshProof의 전체 개념을 살펴봤다면, 이번에는 &lt;b&gt;실제 코드 레벨에서 어떻게 구현&lt;/b&gt;되는지 깊이 파헤쳐보겠습니다. 특히 각 검증기의 내부 알고리즘과 오케스트레이션 엔진의 핵심 로직을 중심으로 설명합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  MathSumVerifier: 수학 문제의 정교한 검증&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수학 문제는 명확한 정답이 있기 때문에 가장 확실한 합의가 가능한 영역입니다. MathSumVerifier는 다양한 표현 방식을 정규화하여 동치성을 판단합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 감지와 타겟 추출&lt;/h3&gt;
&lt;pre class=&quot;zephir&quot;&gt;&lt;code&gt;export function MathSumVerifier(): Verifier {
    // 수학 문제 패턴 정의
    const PATTERNS = [
        /더해서\s*(-?\d+)\s*가 나오/i,      // &quot;더해서 5가 나오는&quot;
        /합이\s*(-?\d+)\s*(?:이|가)?/i,     // &quot;합이 10이 되는&quot;  
        /sum(?:s)?\s*(?:to|=)\s*(-?\d+)/i   // &quot;sum to 15&quot;
    ];

    function getTarget(question: string): number | null {
        for (const regex of PATTERNS) {
            const match = question.match(regex);
            if (match) return Number(match[1]);
        }
        return null;
    }

    return {
        name: &quot;math.sum&quot;,
        supports: (question: string): boolean =&amp;gt; getTarget(question) !== null,
        verify: (question: string, candidate: Answer): VerifyResult =&amp;gt; {
            // 구현 상세...
        }
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정수쌍 추출의 복잡성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 AI가 정수쌍을 표현하는 방식은 매우 다양합니다:&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;function extractPairs(text: string): Array&amp;lt;[number, number]&amp;gt; {
    const pairs: Array&amp;lt;[number, number]&amp;gt; = [];

    // 패턴 1: (x,y) 튜플 형식
    for (const m of text.matchAll(/\((-?\d+)\s*[,;]\s*(-?\d+)\)/g)) {
        pairs.push([Number(m[1]), Number(m[2])]);
    }

    // 패턴 2: &quot;x + y&quot; 덧셈 표현
    for (const m of text.matchAll(/(-?\d+)\s*\+\s*(-?\d+)/g)) {
        pairs.push([Number(m[1]), Number(m[2])]);
    }

    // 패턴 3: &quot;x와 y&quot;, &quot;x, y&quot; (단, 괄호 안은 제외)
    for (const m of text.matchAll(/(?&amp;lt;!\()(-?\d+)\s*(?:와|,)\s*(-?\d+)(?!\))/g)) {
        pairs.push([Number(m[1]), Number(m[2])]);
    }

    return pairs;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정규화와 동치 판단&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 핵심적인 부분은 서로 다른 표현을 동일한 형태로 정규화하는 것입니다:&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// orchestrator.run.ts에서 사용되는 정규화 함수
function canonicalizePairs(text: string): string {
    const pairs = extractPairs(text);
    
    // 각 쌍을 (min, max) 형태로 정규화
    const normalized = pairs.map(([a, b]) =&amp;gt; [Math.min(a, b), Math.max(a, b)] as [number, number]);
    
    // 중복 제거
    const unique = Array.from(new Set(normalized.map(([a,b]) =&amp;gt; `${a},${b}`)))
        .map(s =&amp;gt; s.split(&quot;,&quot;).map(Number) as [number, number]);
    
    // 정렬하여 순서 무관하게 만들기
    unique.sort((p, q) =&amp;gt; (p[0] - q[0]) || (p[1] - q[1]));
    
    return JSON.stringify(unique);
}

// 실제 사용 예시
const gptAnswer = &quot;(1,4), (2,3)&quot;;
const claudeAnswer = &quot;(2,3), (4,1)&quot;;

console.log(canonicalizePairs(gptAnswer));   // &quot;[[1,4],[2,3]]&quot;  
console.log(canonicalizePairs(claudeAnswer)); // &quot;[[1,4],[2,3]]&quot;
// &amp;rarr; 동일! 합의 달성
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;검증 로직 완전 구현&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;verify: (question: string, candidate: Answer): VerifyResult =&amp;gt; {
    const target = getTarget(question);
    if (target === null) return { ok: true, score: 1 };

    const fullText = `${candidate.answer} ${candidate.reasoning ?? &quot;&quot;}`;
    const pairs = extractPairs(fullText);

    if (pairs.length === 0) {
        return { 
            ok: false, 
            score: 0.2, 
            notes: [&quot;정수 쌍을 추출하지 못함&quot;] 
        };
    }

    // 모든 쌍이 올바른 합을 갖는지 검증
    const incorrect = pairs.filter(([a, b]) =&amp;gt; a + b !== target);
    
    if (incorrect.length &amp;gt; 0) {
        return {
            ok: false,
            score: 0.2,
            notes: incorrect.map(([a, b]) =&amp;gt; `합 불일치: ${a}+${b} &amp;ne; ${target}`)
        };
    }

    return { ok: true, score: 1 };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  FactCitationVerifier: 출처 검증의 정교함&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 확인이 중요한 질문에서는 단순히 링크만 있으면 되는 것이 아닙니다. &lt;b&gt;링크의 품질, 접근성, 날짜 표기&lt;/b&gt; 등을 종합적으로 평가해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설정 가능한 검증 옵션&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;export type FactCitationOptions = {
    minLinks?: number;                 // 최소 링크 수 (기본 1)
    requireDeepLinks?: boolean;        // 루트 도메인만 링크면 감점
    minDeepLinks?: number;             // 깊은 링크 최소 개수
    requireDatesInReasoning?: boolean; // YYYY-MM-DD 날짜 필수
    weights?: DomainWeights;           // 도메인별 가중치
};

export type DomainWeights = {
    whitelistBoost?: Record&amp;lt;string, number&amp;gt;;    // 신뢰할 수 있는 도메인 가산점
    blacklistPenalty?: Record&amp;lt;string, number&amp;gt;;  // 의심스러운 도메인 감점
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;URL 추출과 정제&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;function extractUrls(text: string): string[] {
    const urlRegex = /\bhttps?:\/\/[^\s)&amp;gt;\]&quot;'`]+/gi;
    const evidence = new Set&amp;lt;string&amp;gt;();

    // URL 추출 후 후행 구두점 제거
    for (const match of text.matchAll(urlRegex)) {
        const cleanUrl = match[0].replace(/[),.]+$/g, &quot;&quot;);
        evidence.add(cleanUrl);
    }

    return Array.from(evidence);
}

// 깊은 링크 판단
function isDeepLink(url: string): boolean {
    try {
        const { pathname } = new URL(url);
        return pathname !== &quot;/&quot; &amp;amp;&amp;amp; pathname.length &amp;gt; 1;
    } catch {
        return false;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;도메인 가중치 시스템&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 운영에서는 도메인의 신뢰도에 따라 점수를 차등 부여합니다:&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// orchestrator.run.ts에서 실제 사용 중인 가중치
const registry = new VerifierRegistry()
    .use(FactCitationVerifier({
        minLinks: 2,
        requireDeepLinks: true,
        minDeepLinks: 1,
        requireDatesInReasoning: true,
        weights: {
            whitelistBoost: {
                &quot;stat.kita.net&quot;: 0.25,        // 무역통계 사이트
                &quot;unipass.customs.go.kr&quot;: 0.25, // 관세청 통합포털
                &quot;motie.go.kr&quot;: 0.25,          // 산업통상자원부
                &quot;kostat.go.kr&quot;: 0.25,         // 통계청
                &quot;bok.or.kr&quot;: 0.2,             // 한국은행
                &quot;kita.net&quot;: 0.15,             // 무역협회
            },
            blacklistPenalty: {
                &quot;medium.com&quot;: 0.2,            // 개인 블로그
                &quot;blogspot.com&quot;: 0.2,          // 개인 블로그  
                &quot;tistory.com&quot;: 0.15,          // 개인 블로그
                &quot;reddit.com&quot;: 0.15,           // 커뮤니티
            }
        }
    }));
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;복합적 점수 계산&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FactCitationVerifier의 점수는 여러 요소를 종합합니다:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function calculateScore(urls: string[], options: FactCitationOptions): number {
    // 1. 기본 통과 여부 (0.6 가중치)
    const basePass = urls.length &amp;gt;= (options.minLinks || 1) ? 1 : 0;
    
    // 2. 깊은 링크 비율 (0.2 가중치)  
    const deepCount = urls.filter(isDeepLink).length;
    const deepRatio = urls.length ? (deepCount / urls.length) : 0;
    
    // 3. 도메인 가중치 (0.2 가중치)
    const domainWeight = calculateDomainScore(urls, options.weights);
    
    const finalScore = 0.6 * basePass + 0.2 * deepRatio + 0.2 * domainWeight;
    
    return Math.max(0, Math.min(1, finalScore));
}

function calculateDomainScore(urls: string[], weights?: DomainWeights): number {
    if (!weights) return 0.5; // 중립
    
    const hostnames = urls.map(url =&amp;gt; {
        try { return new URL(url).hostname.toLowerCase(); }
        catch { return &quot;&quot;; }
    }).filter(Boolean);
    
    let boost = 0;
    for (const hostname of new Set(hostnames)) {
        if (weights.whitelistBoost?.[hostname]) {
            boost += weights.whitelistBoost[hostname];
        }
        if (weights.blacklistPenalty?.[hostname]) {
            boost -= weights.blacklistPenalty[hostname];
        }
    }
    
    // -0.5 ~ +0.5로 클램프 후 0~1로 변환
    boost = Math.max(-0.5, Math.min(0.5, boost));
    return 0.5 + boost;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;날짜 표기 검증&lt;/h3&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;const dateRegex = /\b(20\d{2}|19\d{2})[-/.](0[1-9]|1[0-2])([-/.](0[1-9]|[12]\d|3[01]))\b/g;

function hasDateInReasoning(text: string): boolean {
    return dateRegex.test(text);
}

// 실제 검증에서의 사용
if (requireDatesInReasoning &amp;amp;&amp;amp; !hasDateInReasoning(reasoning)) {
    return {
        ok: false,
        score: Math.min(score, 0.6), // 날짜 없으면 최대 0.6점
        notes: [...notes, &quot;reasoning에 자료 기준일(YYYY-MM-DD) 표기 필요&quot;]
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Judge System: AI 기반 동치성 판정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수학 문제가 아닌 일반적인 질문에서는 두 답변이 본질적으로 같은 내용인지, 아니면 어느 쪽이 더 나은지 판단하기 위해 별도의 Judge AI를 활용합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Judge 시스템 아키텍처&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;type Verdict = {
    relation: &quot;equivalent&quot; | &quot;different&quot;;  // 동치 여부
    winner: &quot;A&quot; | &quot;B&quot; | &quot;tie&quot;;             // 승자 (different일 때)
    rationale: string;                     // 판단 근거
};

export async function judgeOpenAI(
    question: string,
    answerA: string,
    answerB: string,
    opts?: { apiKey?: string; model?: string }
): Promise&amp;lt;Verdict&amp;gt; {
    const apiKey = opts?.apiKey ?? process.env.OPENAI_API_KEY!;
    const model = opts?.model ?? process.env.OPENAI_JUDGE_MODEL ?? &quot;gpt-5&quot;;
    
    const client = new OpenAI({ apiKey });
    // 구현 상세...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;엄격한 프롬프트 엔지니어링&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Judge의 신뢰성을 높이기 위해서는 명확하고 일관된 지시가 중요합니다:&lt;/p&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;const systemPrompt = `You are a strict judge. Output a JSON object with keys:
relation: &quot;equivalent&quot;|&quot;different&quot;
winner: &quot;A&quot;|&quot;B&quot;|&quot;tie&quot;  
rationale: short string`;

const userPrompt = `Question:
${question}

Answer A:
${answerA}

Answer B:
${answerB}

Decide semantic relation and pick a better answer if different. Return ONLY JSON.`;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안전한 JSON 파싱&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI의 응답이 항상 완벽한 JSON은 아니므로 fallback 로직이 필요합니다:&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// GPT-5는 temperature 미지원 &amp;rarr; 조건부 파라미터 설정
const isGpt5 = /gpt-5/i.test(model);
const completionParams: any = {
    model,
    messages: [
        { role: &quot;system&quot;, content: systemPrompt },
        { role: &quot;user&quot;, content: userPrompt }
    ],
    response_format: { type: &quot;json_object&quot; }
};

if (!isGpt5) {
    completionParams.temperature = 0.2; // 일관성을 위해 낮은 temperature
}

const response = await client.chat.completions.create(completionParams);
const rawContent = response.choices?.[0]?.message?.content ?? &quot;{}&quot;;

// 안전한 파싱과 fallback
try {
    const verdict = JSON.parse(rawContent) as Verdict;
    if (verdict &amp;amp;&amp;amp; verdict.relation &amp;amp;&amp;amp; verdict.winner) {
        return verdict;
    }
} catch (error) {
    console.warn(&quot;Judge JSON parsing failed:&quot;, error);
}

// fallback: 보수적 판정
return { 
    relation: &quot;different&quot;, 
    winner: &quot;tie&quot;, 
    rationale: &quot;fallback due to parsing error&quot; 
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Orchestration Engine: 복잡한 조율의 핵심&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오케스트레이션 엔진은 MeshProof의 뇌 역할을 합니다. 병렬 호출, 검증, 재시도, 합의 판단까지의 전체 과정을 관리합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;병렬 모델 호출과 에러 처리&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;export async function orchestrate(
    question: string,
    debug = false,
    keys?: { openai?: string; anthropic?: string },
    opts?: { useOpenAI?: boolean; useClaude?: boolean }
): Promise&amp;lt;FinalOut&amp;gt; {
    let gpt: Answer | undefined;
    let claude: Answer | undefined;
    let gptError: any, claudeError: any;

    // 병렬로 두 모델 호출 (실패해도 중단되지 않음)
    const [gptResult, claudeResult] = await Promise.allSettled([
        opts?.useOpenAI ? askOpenAI(question, { apiKey: keys?.openai }) : null,
        opts?.useClaude ? askAnthropic(question, { apiKey: keys?.anthropic }) : null
    ]);

    // 결과 처리
    if (gptResult?.status === 'fulfilled' &amp;amp;&amp;amp; gptResult.value) {
        gpt = gptResult.value;
    } else if (gptResult?.status === 'rejected') {
        gptError = gptResult.reason;
    }

    if (claudeResult?.status === 'fulfilled' &amp;amp;&amp;amp; claudeResult.value) {
        claude = claudeResult.value;
    } else if (claudeResult?.status === 'rejected') {
        claudeError = claudeResult.reason;
    }

    // 둘 다 실패하면 에러
    if (!gpt &amp;amp;&amp;amp; !claude) {
        const gptMsg = gptError?.message || &quot;OpenAI unavailable&quot;;
        const claudeMsg = claudeError?.message || &quot;Anthropic unavailable&quot;;
        throw new Error(`Both providers failed. openai=${gptMsg} | anthropic=${claudeMsg}`);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;검증과 재시도 메커니즘&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출처가 부족한 답변에 대해서는 자동으로 재시도를 수행합니다:&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// 재시도 필요성 판단
function needsCitationsRetry(verification?: any, answer?: Answer): boolean {
    if (!verification || !answer) return false;
    
    const citationDetail = verification.details?.find((d: any) =&amp;gt; d.category === &quot;fact.citation&quot;);
    if (!citationDetail) return false;

    const urls: string[] = citationDetail.evidence ?? [];
    const deepLinks = urls.filter(url =&amp;gt; {
        try {
            const pathname = new URL(url).pathname;
            return pathname &amp;amp;&amp;amp; pathname !== &quot;/&quot;;
        } catch {
            return false;
        }
    });

    const hasDate = /\b(20\d{2}|19\d{2})[-/.](0[1-9]|1[0-2])([-/.](0[1-9]|[12]\d|3[01]))\b/
        .test(`${answer.reasoning ?? &quot;&quot;} ${answer.answer ?? &quot;&quot;}`);

    return (!citationDetail.ok) || 
           (urls.length &amp;lt; 2) || 
           (deepLinks.length &amp;lt; 1) || 
           !hasDate;
}

// 재시도용 프롬프트 생성
function buildRetrySuffix(): string {
    return &quot;\n\n[중요 지시]\n&quot; +
        &quot;- reasoning에 최신 출처를 **최소 2개** 포함하세요.\n&quot; +
        &quot;- **홈페이지 루트 링크 금지**, 통계표/보도자료 등 **상세 페이지(Deep link)** URL을 사용하세요.\n&quot; +
        &quot;- 각 출처 옆에 **발표/게시 날짜(YYYY-MM-DD)**를 표기하세요.\n&quot; +
        &quot;- 최종 출력은 JSON 형식 유지: { answer, reasoning, confidence }&quot;;
}

// 실제 재시도 실행
if (gpt &amp;amp;&amp;amp; needsCitationsRetry(gptVerification, gpt)) {
    const retryQuestion = question + buildRetrySuffix();
    try {
        gpt = await askOpenAI(retryQuestion, { apiKey: keys?.openai });
        gptVerification = await registry.verifyAndAggregate(question, gpt);
        trace.push({ round: &quot;retry-gpt&quot;, gpt, verifiers: { gpt: gptVerification } });
    } catch (error) {
        if (debug) console.error(&quot;[gpt:retry:error]&quot;, error);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;합의 판단과 최종 선택&lt;/h3&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;// 1) 한쪽만 성공한 경우
if (gpt &amp;amp;&amp;amp; !claude) {
    return {
        content: gpt.answer,
        sources: extractSources(gpt.reasoning, gpt.answer),
        meta: { picked: &quot;gpt&quot;, confidence: gpt.confidence },
        ...(debug ? { debug: { trace } } : {})
    };
}

// 2) 수학 문제: 세트 동치 비교
if (/(더해서|합이|sum)/i.test(question)) {
    const gptCanonical = canonicalizePairs(`${gpt.answer} ${gpt.reasoning ?? &quot;&quot;}`);
    const claudeCanonical = canonicalizePairs(`${claude.answer} ${claude.reasoning ?? &quot;&quot;}`);
    
    if (gptCanonical !== &quot;[]&quot; &amp;amp;&amp;amp; gptCanonical === claudeCanonical) {
        const pick = (gptVerification.score &amp;gt;= claudeVerification.score) ? &quot;gpt&quot; : &quot;claude&quot;;
        const chosen = pick === &quot;gpt&quot; ? gpt : claude;
        
        return {
            content: chosen.answer,
            sources: extractSources(gpt.reasoning, gpt.answer, claude.reasoning, claude.answer),
            meta: { picked: pick, agreement: &quot;equivalent&quot;, confidence: chosen.confidence },
            ...(debug ? { debug: { trace } } : {})
        };
    }
}

// 3) 일반 문제: Judge AI 판정
const verdict = await judgeOpenAI(
    question,
    `${gpt.answer}\n\n${gpt.reasoning ?? &quot;&quot;}`,
    `${claude.answer}\n\n${claude.reasoning ?? &quot;&quot;}`,
    { apiKey: keys?.openai }
);

if (verdict.relation === &quot;equivalent&quot;) {
    const pick = (gptVerification.score &amp;gt;= claudeVerification.score) ? &quot;gpt&quot; : &quot;claude&quot;;
    const chosen = pick === &quot;gpt&quot; ? gpt : claude;
    
    return {
        content: chosen.answer,
        sources: extractSources(chosen.reasoning, chosen.answer),
        meta: { picked: pick, agreement: &quot;equivalent&quot;, confidence: chosen.confidence }
    };
}

// 4) 서로 다른 경우: 점수와 Judge 판정을 종합
const gptScore = (gptVerification.score ?? 0) + (gpt.confidence ?? 0);
const claudeScore = (claudeVerification.score ?? 0) + (claude.confidence ?? 0);

let final: Answer, picked: &quot;gpt&quot; | &quot;claude&quot;;

if (verdict.winner === &quot;A&quot;) {
    final = gpt; picked = &quot;gpt&quot;;
} else if (verdict.winner === &quot;B&quot;) {
    final = claude; picked = &quot;claude&quot;;
} else {
    // tie인 경우 점수로 결정
    final = gptScore &amp;gt;= claudeScore ? gpt : claude;
    picked = gptScore &amp;gt;= claudeScore ? &quot;gpt&quot; : &quot;claude&quot;;
}

return {
    content: final.answer,
    sources: extractSources(final.reasoning, final.answer),
    meta: { picked, agreement: &quot;different&quot;, confidence: final.confidence }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ Security Layer: 생산 환경을 위한 보안&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JWT 기반 인증 시스템&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// JWT 토큰 생성과 검증
class AuthManager {
    private static readonly JWT_SECRET = process.env.JWT_SECRET!;
    private static readonly JWT_EXPIRES_IN = '24h';

    static generateToken(user: { id: number; email: string }): string {
        return jwt.sign(
            { 
                id: user.id, 
                email: user.email,
                iat: Math.floor(Date.now() / 1000)
            },
            AuthManager.JWT_SECRET,
            { expiresIn: AuthManager.JWT_EXPIRES_IN }
        );
    }

    static verifyToken(token: string): { id: number; email: string } | null {
        try {
            return jwt.verify(token, AuthManager.JWT_SECRET) as { id: number; email: string };
        } catch {
            return null;
        }
    }
}

// 인증 미들웨어
export const authRequired = (req: Request, res: Response, next: NextFunction) =&amp;gt; {
    const token = req.cookies.cv_token;
    
    if (!token) {
        return res.status(401).json({ error: 'Authentication required' });
    }

    const user = AuthManager.verifyToken(token);
    if (!user) {
        res.clearCookie('cv_token');
        return res.status(401).json({ error: 'Invalid token' });
    }

    req.user = user;
    next();
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CSRF Double-Submit Cookie 보호&lt;/h3&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;// server.ts에서 CSRF 보호 구현
app.use((req, res, next) =&amp;gt; {
    const existingToken = req.cookies[&quot;XSRF-TOKEN&quot;];
    const token = existingToken || crypto.randomBytes(16).toString(&quot;hex&quot;);
    
    // 토큰이 없으면 쿠키에 설정
    if (!existingToken) {
        res.cookie(&quot;XSRF-TOKEN&quot;, token, {
            httpOnly: false,      // 클라이언트에서 읽을 수 있어야 함
            sameSite: &quot;lax&quot;,
            secure: process.env.NODE_ENV === &quot;production&quot;,
            path: &quot;/&quot;,
        });
    }
    
    // POST/PUT/DELETE 요청에서는 헤더 검증
    if ([&quot;POST&quot;, &quot;PUT&quot;, &quot;PATCH&quot;, &quot;DELETE&quot;].includes(req.method)) {
        const headerToken = req.get(&quot;X-CSRF-TOKEN&quot;);
        if (!headerToken || headerToken !== token) {
            return res.status(403).json({ error: &quot;CSRF validation failed&quot; });
        }
    }
    
    next();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AES-256-GCM 키 암호화&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// 사용자 API 키를 안전하게 저장
class KeyVault {
    private static readonly ALGORITHM = 'aes-256-gcm';
    private readonly masterKey: Buffer;

    constructor() {
        this.masterKey = crypto.scryptSync(
            process.env.ENCRYPTION_PASSWORD!,
            process.env.ENCRYPTION_SALT!,
            32
        );
    }

    encrypt(plaintext: string): EncryptedData {
        const iv = crypto.randomBytes(16);
        const cipher = crypto.createCipher(KeyVault.ALGORITHM, this.masterKey);
        cipher.setAutoPadding(true);
        
        let encrypted = cipher.update(plaintext, 'utf8', 'hex');
        encrypted += cipher.final('hex');
        
        const authTag = cipher.getAuthTag();

        return {
            encrypted,
            iv: iv.toString('hex'),
            authTag: authTag.toString('hex')
        };
    }

    decrypt(encryptedData: EncryptedData): string {
        const decipher = crypto.createDecipher(KeyVault.ALGORITHM, this.masterKey);
        decipher.setAuthTag(Buffer.from(encryptedData.authTag, 'hex'));
        
        let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
        decrypted += decipher.final('utf8');
        
        return decrypted;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Rate Limiting&lt;/h3&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;import rateLimit from &quot;express-rate-limit&quot;;

// API 키 관련 엔드포인트에 더 엄격한 제한
const keysLimiter = rateLimit({ 
    windowMs: 60_000,  // 1분
    max: 10,           // 최대 10회
    message: { error: &quot;Too many key management requests&quot; }
});

app.use(&quot;/api/auth/me/keys&quot;, keysLimiter);

// 일반 API에는 더 관대한 제한
const generalLimiter = rateLimit({
    windowMs: 60_000,  // 1분
    max: 100,          // 최대 100회
});

app.use(&quot;/api/&quot;, generalLimiter);
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Provider System: AI 모델 호출의 정교함&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OpenAI Provider: Responses API vs Chat Completions&lt;/h3&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;export async function askOpenAI(
    question: string,
    opts?: {
        apiKey?: string;
        model?: string;
        useResponses?: boolean;           // Responses API 사용 여부
        reasoningEffort?: &quot;minimal&quot; | &quot;medium&quot; | &quot;high&quot;;
        verbosity?: &quot;low&quot; | &quot;medium&quot; | &quot;high&quot;;
    }
): Promise&amp;lt;Answer&amp;gt; {
    const model = opts?.model ?? process.env.OPENAI_MODEL ?? &quot;gpt-5&quot;;
    const useResponses = opts?.useResponses ?? (process.env.OPENAI_USE_RESPONSES === &quot;1&quot;);
    
    // Responses API 사용 (권장)
    if (useResponses) {
        const payload: any = {
            model,
            input: [
                { role: &quot;system&quot;, content: SYSTEM_PROMPT },
                { role: &quot;user&quot;, content: buildUserPrompt(question) },
            ],
        };
        
        // 추론 설정 (Extended Thinking 등)
        if (opts?.reasoningEffort) {
            payload.reasoning = { effort: opts.reasoningEffort };
        }
        if (opts?.verbosity) {
            payload.text = { verbosity: opts.verbosity };
        }
        
        const response = await client.responses.create(payload);
        return parseResponseOutput(response);
    }
    
    // Chat Completions API (호환성)
    const messages = [
        { role: &quot;system&quot;, content: SYSTEM_PROMPT },
        { role: &quot;user&quot;, content: buildUserPrompt(question) },
    ];
    
    const response = await client.chat.completions.create({
        model,
        messages,
        response_format: { type: &quot;json_object&quot; },
        // GPT-5는 temperature 미지원 &amp;rarr; 조건부 설정
        ...(!/gpt-5/i.test(model) &amp;amp;&amp;amp; { temperature: 0.7 })
    });
    
    return parseChatOutput(response);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Anthropic Provider: Extended Thinking 지원&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;export async function askAnthropic(
    question: string,
    opts?: {
        apiKey?: string,
        model?: string,
        thinkingBudget?: number;    // Extended Thinking 예산
        maxTokens?: number;
    }
): Promise&amp;lt;Answer&amp;gt; {
    const model = opts?.model ?? &quot;claude-sonnet-4-20250514&quot;;
    const thinkingBudget = Math.max(0, Number(opts?.thinkingBudget ?? 0));
    let maxTokens = Math.max(1, Number(opts?.maxTokens ?? 800));
    
    // thinking budget보다 max_tokens가 작으면 자동 보정
    if (thinkingBudget &amp;gt; 0 &amp;amp;&amp;amp; maxTokens &amp;lt;= thinkingBudget) {
        const headroom = Number(process.env.ANTHROPIC_OUTPUT_HEADROOM || 512);
        maxTokens = thinkingBudget + headroom;
        
        if (process.env.NODE_ENV !== &quot;production&quot;) {
            console.warn(`max_tokens auto-bumped to ${maxTokens} (budget=${thinkingBudget})`);
        }
    }

    const client = new Anthropic({ apiKey: opts?.apiKey ?? process.env.ANTHROPIC_API_KEY! });

    const params: any = {
        model,
        system: SYSTEM_PROMPT,
        max_tokens: maxTokens,
        messages: [{ role: &quot;user&quot;, content: buildUserPrompt(question) }]
    };

    // Extended Thinking 설정
    if (thinkingBudget &amp;gt; 0) {
        params.thinking = { 
            type: &quot;enabled&quot;, 
            budget_tokens: thinkingBudget 
        };
    }
    
    // Claude Sonnet 4는 temperature 미지원
    
    const response = await client.messages.create(params);
    return parseAnthropicOutput(response);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  디버깅과 관찰성&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;상세한 추적 로그&lt;/h3&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;type TraceEntry = {
    round: number | string;
    gpt?: Answer;
    claude?: Answer;
    verifiers?: {
        gpt?: VerificationResult;
        claude?: VerificationResult;
    };
    consensus?: ConsensusResult;
    verdict?: Verdict;
};

// orchestrate 함수에서 각 단계별로 추적
const trace: TraceEntry[] = [];

// 1차 호출 결과
trace.push({ 
    round: 1, 
    gpt, 
    claude, 
    verifiers: { gpt: gptVerification, claude: claudeVerification } 
});

// 재시도가 있었다면
if (gptRetried) {
    trace.push({ 
        round: &quot;retry-gpt&quot;, 
        gpt: retriedGptAnswer, 
        verifiers: { gpt: retriedGptVerification } 
    });
}

// Judge 판정 결과
if (verdict) {
    trace.push({ verdict });
}

// 최종 결과에 디버그 정보 포함
return {
    content: finalAnswer.answer,
    sources: extractSources(...),
    meta: { picked, agreement, confidence },
    ...(debug ? { debug: { trace } } : {})
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 메트릭&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 각 단계별 소요 시간 측정
const startTime = Date.now();
const timings: Record&amp;lt;string, number&amp;gt; = {};

timings.modelCall = Date.now() - startTime;

const verifyStart = Date.now();
// 검증 로직...
timings.verification = Date.now() - verifyStart;

const judgeStart = Date.now();  
// 판정 로직...
timings.judgment = Date.now() - judgeStart;

if (debug) {
    console.log(&quot;Performance metrics:&quot;, timings);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  다음 편 예고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3편&lt;/b&gt;에서는 &lt;b&gt;React 기반 프론트엔드와 전체 사용자 경험&lt;/b&gt;을 다룹니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  &lt;b&gt;UI/UX 설계&lt;/b&gt;: 복잡한 검증 과정을 직관적으로 표현하기&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;실시간 피드백&lt;/b&gt;: 로딩 상태와 프로그레스 표시&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;디버그 뷰어&lt;/b&gt;: 전체 검증 과정의 시각화&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;프론트엔드 보안&lt;/b&gt;: CSRF 토큰 자동 처리&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;최적화와 성능&lt;/b&gt;: 코드 스플리팅과 캐싱 전략&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 질문&lt;/b&gt;: &quot;복잡한 백엔드 프로세스를 사용자가 쉽고 직관적으로 사용할 수 있게 만들려면?&quot;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2편에서 MeshProof의 핵심 엔진을 해부해봤습니다.&lt;br /&gt;다음 편에서는 이 모든 기능을 사용자가 실제로 어떻게 경험하게 되는지 살펴보겠습니다.&lt;/p&gt;</description>
      <category>MyStory/Consensus_Verifiers</category>
      <author>LupyLaon</author>
      <guid isPermaLink="true">https://lupylaon.tistory.com/24</guid>
      <comments>https://lupylaon.tistory.com/24#entry24comment</comments>
      <pubDate>Sun, 24 Aug 2025 16:25:38 +0900</pubDate>
    </item>
    <item>
      <title>MeshProof: 두 모델이 합의할 때까지 수렴하는 QA 시스템</title>
      <link>https://lupylaon.tistory.com/23</link>
      <description>&lt;h1&gt;MeshProof: 두 모델이 합의할 때까지 수렴하는 QA 시스템&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;consensus-verifiers로 만드는 신뢰형 LLM 파이프라인&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;(1/3) 왜 &quot;두 모델 합의(Consensus)&quot;인가 &amp;mdash; 문제 정의 &amp;rarr; 설계&lt;/h2&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  한 문장 요약&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;MeshProof&lt;/b&gt;는 GPT와 Claude가 서로의 답을 검증하고 합의할 때까지 수렴시켜 &lt;b&gt;신뢰 가능한 최종 답&lt;/b&gt;을 만드는 QA 시스템입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  문제 정의&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;현재 LLM의 한계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제점 설명 예시&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;틀린 확신&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Hallucination으로 잘못된 정보를 확신있게 전달&lt;/td&gt;
&lt;td&gt;&quot;2024년 노벨물리학상은 김철수가 수상했습니다&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;형식 오류&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;요청한 형태와 다른 응답 형식&lt;/td&gt;
&lt;td&gt;JSON 요청했는데 일반 텍스트로 응답&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;검증 불가&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;답변의 근거나 신뢰도를 알 수 없음&lt;/td&gt;
&lt;td&gt;&quot;이 정보가 정확한가?&quot; &amp;rarr; 별도 확인 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;출처 부족&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;중요한 사실 확인이 필요한데 링크나 날짜 없음&lt;/td&gt;
&lt;td&gt;&quot;한국 수출 통계&quot; &amp;rarr; 출처나 기준일 미표기&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사용자가 원하는 것&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ &lt;b&gt;정확성&lt;/b&gt;: 검증된 올바른 정보&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;설명가능성&lt;/b&gt;: 단순한 답이 아닌 &lt;b&gt;검증 가능한 이유&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;신뢰도&lt;/b&gt;: 답변에 대한 정량적 신뢰 지표&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;추적가능성&lt;/b&gt;: 출처와 근거가 명확한 답변&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  핵심 가설&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 중복성 (Redundancy)&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 동시에 두 모델 호출
const [gptAnswer, claudeAnswer] = await Promise.all([
    askOpenAI(question),
    askAnthropic(question)
]);

// 결과가 같으면 신뢰도 ⬆
if (isConsensus(gptAnswer, claudeAnswer)) {
    return { confidence: 0.95, agreed: true };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이질적인 두 모델&lt;/b&gt;이 &lt;b&gt;같은 결론&lt;/b&gt;이면 신뢰도가 오른다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 규칙 검증 (Verification)&lt;/h3&gt;
&lt;pre class=&quot;fsharp&quot;&gt;&lt;code&gt;const registry = new VerifierRegistry()
    .use(MathSumVerifier())           // 수학 계산 정확성
    .use(FactCitationVerifier({       // 출처 검증
        minLinks: 2,                  // 최소 2개 링크
        requireDeepLinks: true,       // 상세 페이지 필요
        requireDatesInReasoning: true // 날짜 표기 필수
    }))
    .use(BasicQualityVerifier())      // 기본 품질
    .use(FormatRegexVerifier());      // 형식 검증

// 점수 산출: 0.6 * 기본통과 + 0.2 * 깊은링크비율 + 0.2 * 도메인가중치
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;**도메인 전용 검증기(Verifier)**로 답을 정량 평가하면 품질이 오른다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 합의 (Consensus)&lt;/h3&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;// 수학 문제: 정수쌍 정규화로 세트 동치 비교
function canonicalizePairs(text: string): string {
    // &quot;(1,4), (2,3)&quot; &amp;rarr; &quot;[[1,4],[2,3]]&quot;
    // &quot;(2,3), (1,4)&quot; &amp;rarr; &quot;[[1,4],[2,3]]&quot;  (동일)
}

// 일반 문제: OpenAI Judge 활용
const verdict = await judgeOpenAI(question, gptAnswer, claudeAnswer);
// &amp;rarr; { relation: &quot;equivalent&quot;|&quot;different&quot;, winner: &quot;A&quot;|&quot;B&quot;|&quot;tie&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다르면 &lt;b&gt;정규화 비교&lt;/b&gt; 또는 &lt;b&gt;Judge AI 판정&lt;/b&gt;으로 최종 답 결정.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 시스템 아키텍처&lt;/h2&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;graph TB
    A[React Frontend&amp;lt;br/&amp;gt;Vite + TypeScript] --&amp;gt; B[Express.js API Server&amp;lt;br/&amp;gt;Node.js + TypeScript]
    
    B --&amp;gt; C[Authentication&amp;lt;br/&amp;gt;JWT Cookie + CSRF]
    B --&amp;gt; D[Key Vault&amp;lt;br/&amp;gt;AES-256-GCM Encryption]
    B --&amp;gt; E[Orchestrator Engine]
    
    E --&amp;gt; F[OpenAI Provider&amp;lt;br/&amp;gt;gpt-5 + Responses API]
    E --&amp;gt; G[Anthropic Provider&amp;lt;br/&amp;gt;claude-sonnet-4]
    E --&amp;gt; H[OpenAI Judge&amp;lt;br/&amp;gt;답변 동치성 판정]
    
    B --&amp;gt; I[Verifier Registry&amp;lt;br/&amp;gt;플러그인 시스템]
    I --&amp;gt; J[math.sum&amp;lt;br/&amp;gt;정수쌍 합계 검증]
    I --&amp;gt; K[fact.citation&amp;lt;br/&amp;gt;출처 링크 검증]
    I --&amp;gt; L[basic.quality&amp;lt;br/&amp;gt;답변 품질 검증]
    I --&amp;gt; M[format.regex&amp;lt;br/&amp;gt;형식 검증]
    
    B --&amp;gt; N[(SQLite Database&amp;lt;br/&amp;gt;사용자/암호화키)]
    
    style E fill:#fff3e0
    style I fill:#e8f5e8
    style F fill:#e3f2fd
    style G fill:#fce4ec
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 데이터 타입&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// 표준화된 답변 스키마
type Answer = {
    answer: string;              // 최종 답변
    reasoning?: string;          // 근거 (출처 링크 포함)
    confidence?: number;         // 0~1 자체 신뢰도
    assumptions?: string[];      // 전제 조건들
};

// 검증 결과
type VerifyResult = {
    ok: boolean;                 // 통과 여부
    score?: number;              // 0~1 정량 점수
    notes?: string[];            // 상세 메모
    evidence?: string[];         // 추출된 링크들
    category?: string;           // 검증기 분류
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⚖️ 합의 전략 (도메인별 실제 구현)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  수학 문제: 정수쌍 정규화&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// 질문: &quot;더해서 5가 나오는 정수 2개의 쌍은?&quot;
function canonicalizePairs(text: string): string {
    const pairs: Array&amp;lt;[number, number]&amp;gt; = [];
    
    // (x,y) 패턴 추출
    for (const m of text.matchAll(/\((-?\d+)\s*[,;]\s*(-?\d+)\)/g)) {
        const a = Number(m[1]), b = Number(m[2]);
        pairs.push([Math.min(a, b), Math.max(a, b)]);
    }
    
    // &quot;x + y&quot; 패턴 추출  
    for (const m of text.matchAll(/(-?\d+)\s*\+\s*(-?\d+)/g)) {
        const a = Number(m[1]), b = Number(m[2]);
        pairs.push([Math.min(a, b), Math.max(a, b)]);
    }
    
    // 중복 제거 후 정렬
    const uniq = Array.from(new Set(pairs.map(([a,b]) =&amp;gt; `${a},${b}`)))
        .map(s =&amp;gt; s.split(&quot;,&quot;).map(Number) as [number, number]);
    uniq.sort((p, q) =&amp;gt; (p[0] - q[0]) || (p[1] - q[1]));
    
    return JSON.stringify(uniq);
}

// 예시 비교
GPT: &quot;(1,4), (2,3)&quot;     &amp;rarr; &quot;[[1,4],[2,3]]&quot;
Claude: &quot;(2,3), (1,4)&quot;  &amp;rarr; &quot;[[1,4],[2,3]]&quot;  ✅ 동치!
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  일반 지식: Judge AI 판정&lt;/h3&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;const judgePrompt = `You are a strict judge. Output a JSON object with keys:
relation: &quot;equivalent&quot;|&quot;different&quot;  
winner: &quot;A&quot;|&quot;B&quot;|&quot;tie&quot;
rationale: short string`;

// 실제 판정 예시
{
  &quot;relation&quot;: &quot;different&quot;,
  &quot;winner&quot;: &quot;B&quot;, 
  &quot;rationale&quot;: &quot;Answer B provides more comprehensive analysis with concrete examples&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  출처 검증: 실제 링크 품질 평가&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;const FactCitationVerifier = ({
    minLinks: 2,                    // 최소 2개 링크 필요
    requireDeepLinks: true,         // 루트 도메인 금지
    minDeepLinks: 1,               // 깊은 링크 최소 1개
    requireDatesInReasoning: true,  // YYYY-MM-DD 날짜 필수
    weights: {
        whitelistBoost: {
            &quot;kostat.go.kr&quot;: 0.25,     // 통계청 가산점
            &quot;bok.or.kr&quot;: 0.2,         // 한국은행 가산점
            &quot;kita.net&quot;: 0.15          // 무역협회 가산점
        },
        blacklistPenalty: {
            &quot;medium.com&quot;: 0.2,        // 개인 블로그 감점
            &quot;tistory.com&quot;: 0.15       // 개인 블로그 감점
        }
    }
});

// 점수 계산: 0.6 * 기본통과 + 0.2 * 깊은링크비율 + 0.2 * 도메인가중치
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  오케스트레이션 플로우&lt;/h2&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;flowchart TD
    A[질문 입력] --&amp;gt; B[병렬 모델 호출]
    B --&amp;gt; C[GPT 답변] 
    B --&amp;gt; D[Claude 답변]
    
    C --&amp;gt; E[검증기 실행]
    D --&amp;gt; F[검증기 실행]
    
    E --&amp;gt; G{수학 문제?}
    F --&amp;gt; G
    
    G --&amp;gt;|Yes| H[정수쌍 정규화 비교]
    G --&amp;gt;|No| I[Judge AI 판정]
    
    H --&amp;gt; J{세트 동치?}
    I --&amp;gt; K{동치 판정?}
    
    J --&amp;gt;|Yes| L[✅ 합의 달성&amp;lt;br/&amp;gt;즉시 반환]
    K --&amp;gt;|equivalent| L
    
    J --&amp;gt;|No| M[재시도 필요?]
    K --&amp;gt;|different| N[승자 선택]
    
    M --&amp;gt;|Yes| O[출처 보강 재요청]
    M --&amp;gt;|No| N
    
    O --&amp;gt; B
    N --&amp;gt; P[최종 답변 반환]
    L --&amp;gt; P
    
    style L fill:#c8e6c9
    style P fill:#fff3e0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 정지 조건 (우선순위)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순위 조건 행동 신뢰도&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;1순위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;  &lt;b&gt;수학 세트 동치&lt;/b&gt;&amp;lt;br/&amp;gt;canonicalizePairs(A) === canonicalizePairs(B)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;즉시 정지&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;최고 (95%+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;2순위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;  &lt;b&gt;Judge: equivalent&lt;/b&gt;&amp;lt;br/&amp;gt;verdict.relation === &quot;equivalent&quot;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;검증점수 높은 쪽&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;높음 (85-95%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;3순위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;  &lt;b&gt;Judge: 명확한 승자&lt;/b&gt;&amp;lt;br/&amp;gt;verdict.winner !== &quot;tie&quot;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;승자 선택&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;중간 (70-85%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;4순위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;  &lt;b&gt;재시도 조건&lt;/b&gt;&amp;lt;br/&amp;gt;출처 부족 시 1회 재요청&lt;/td&gt;
&lt;td&gt;&lt;b&gt;보강 후 재판정&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;개선됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;5순위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;⚖️ &lt;b&gt;점수 기반 선택&lt;/b&gt;&amp;lt;br/&amp;gt;verifierScore + confidence&lt;/td&gt;
&lt;td&gt;&lt;b&gt;높은 점수 선택&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;낮음 (50-70%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  실제 동작 예시&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Case 1: 수학 문제 (세트 동치 합의)&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;  질문: &quot;더해서 5가 나오는 정수 2개의 쌍은?&quot;

  GPT: {
  answer: &quot;(1,4), (2,3)&quot;,
  reasoning: &quot;1+4=5, 2+3=5 입니다.&quot;,
  confidence: 0.95
}

  Claude: {
  answer: &quot;(2,3), (1,4)&quot;, 
  reasoning: &quot;기본적인 정수쌍입니다. 2+3=5, 1+4=5&quot;,
  confidence: 0.90
}

  정규화:
GPT   &amp;rarr; &quot;[[1,4],[2,3]]&quot;
Claude &amp;rarr; &quot;[[1,4],[2,3]]&quot;  ✅ 동치!

  결과: 즉시 합의 달성 (신뢰도: 95%)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Case 2: 출처 부족으로 재시도&lt;/h3&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;  질문: &quot;2024년 한국 수출 통계는?&quot;

  GPT (1차): {
  answer: &quot;2024년 한국 수출은 전년 대비 증가했습니다&quot;,
  reasoning: &quot;무역 관련 데이터 분석 결과입니다&quot;  ❌ 출처 없음
}

  검증 결과: minLinks 미달, 날짜 표기 없음

  재시도 요청: 
&quot;[중요 지시]
- reasoning에 최신 출처를 최소 2개 포함하세요
- 홈페이지 루트 링크 금지, 상세 페이지(Deep link) URL 사용
- 발표/게시 날짜(YYYY-MM-DD) 표기하세요&quot;

  GPT (2차): {
  answer: &quot;2024년 한국 수출은 6,835억 달러로 전년 대비 8.9% 증가&quot;,
  reasoning: &quot;관세청 수출입 통계(2024-12-01): https://unipass.customs.go.kr/stats/2024/export-stats, 
             KOTRA 보고서(2024-11-28): https://kita.net/trade-report/2024-export-analysis&quot;,
  confidence: 0.88
}

✅ 검증 통과: 깊은 링크 2개, 날짜 표기 완료
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Case 3: Judge 개입 (서로 다른 관점)&lt;/h3&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;  질문: &quot;블록체인 기술의 미래 전망은?&quot;

  GPT: &quot;기술적 혁신과 확장성 개선이 핵심...&quot;
  Claude: &quot;규제 환경과 사회적 수용성이 더 중요...&quot;

⚖️ Judge 판정: {
  &quot;relation&quot;: &quot;different&quot;,
  &quot;winner&quot;: &quot;B&quot;,
  &quot;rationale&quot;: &quot;Claude's answer covers broader perspective including regulatory aspects&quot;
}

  결과: Claude 답변 선택 (신뢰도: 78%)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 기술적 특징&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;플러그인형 검증 시스템&lt;/h3&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;// 새로운 검증기 추가가 간단
const registry = new VerifierRegistry()
    .use(MathSumVerifier())
    .use(FactCitationVerifier({ 
        weights: {
            whitelistBoost: { &quot;stat.kita.net&quot;: 0.25 }  // 도메인별 가중치
        }
    }))
    .use(BasicQualityVerifier())
    .use(FormatRegexVerifier(/^\d{4}-\d{2}-\d{2}$/));  // 날짜 형식 강제

// 모든 검증기 실행 후 집계
const result = await registry.verifyAndAggregate(question, answer);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안전한 키 관리 시스템&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// AES-256-GCM 암호화로 API 키 저장
app.put(&quot;/api/auth/me/keys&quot;, authRequired, async (req, res) =&amp;gt; {
    const { openaiKey, anthropicKey } = req.body;
    const encryptedKeys = await encryptKeys({ openaiKey, anthropicKey });
    await saveUserKeys(req.user.id, encryptedKeys);
    res.json({ success: true });
});

// BYOK (Bring Your Own Key) 또는 저장된 키 자동 사용
app.post(&quot;/api/orchestrate&quot;, async (req, res) =&amp;gt; {
    const keys = req.body.keys || await loadUserKeys(req.user?.id);
    const result = await orchestrate(req.body.q, req.body.debug, keys);
    res.json(result);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  다음 편 예고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2편&lt;/b&gt;에서는 MeshProof의 &lt;b&gt;핵심 검증기와 오케스트레이션 엔진&lt;/b&gt; 구현을 깊이 파헤칩니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  &lt;b&gt;MathSumVerifier&lt;/b&gt;: 정수쌍 추출과 정규화 알고리즘&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;FactCitationVerifier&lt;/b&gt;: URL 추출과 도메인 가중치 시스템&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;Judge System&lt;/b&gt;: OpenAI 기반 답변 동치성 판정 로직&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;Orchestration Engine&lt;/b&gt;: 재시도와 합의 수렴 메커니즘&lt;/li&gt;
&lt;li&gt; ️ &lt;b&gt;Security&lt;/b&gt;: JWT + CSRF + 키 암호화 완전 구현&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 질문&lt;/b&gt;: &quot;어떻게 서로 다른 AI 모델들을 정교하게 검증하고 안전하게 조율할 수 있을까?&quot;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MeshProof는 단순한 AI 답변 비교가 아닙니다.&lt;br /&gt;&lt;b&gt;정량적 검증과 체계적 합의&lt;/b&gt;를 통해 더 신뢰할 수 있는 지식을 만들어내는 엔지니어링 시스템입니다.&lt;/p&gt;</description>
      <category>MyStory/Consensus_Verifiers</category>
      <category>합의 #AI #답변 #IT #프로젝트</category>
      <author>LupyLaon</author>
      <guid isPermaLink="true">https://lupylaon.tistory.com/23</guid>
      <comments>https://lupylaon.tistory.com/23#entry23comment</comments>
      <pubDate>Sun, 24 Aug 2025 16:14:14 +0900</pubDate>
    </item>
    <item>
      <title>2편: 핵심 기능 구현과 성과 - 사용자 중심의 현대적 인터페이스 완성</title>
      <link>https://lupylaon.tistory.com/22</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;  핵심 기능별 구현 심화&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 스마트 수정 모드 시스템&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Forms의 경직된 입력 방식을 개선해 &lt;b&gt;섹션별 독립적인 수정 모드&lt;/b&gt;를 구현했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1750207076813&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 수정 모드 관리 시스템
class EditModeManager {
    enableEditMode(section) {
        // 원본 데이터 백업
        this.saveOriginalData(section);
        
        // UI 상태 전환
        this.toggleUIElements(section, true);
        
        // 실시간 변경 감지
        this.bindChangeDetection(section);
    }
    
    // 변경사항 실시간 감지
    bindChangeDetection(section) {
        const inputs = document.querySelectorAll(`#${section}-fields input`);
        inputs.forEach(input =&amp;gt; {
            input.addEventListener('input', () =&amp;gt; {
                this.updateSaveButtonState(section);
            });
        });
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  섹션별 독립적 수정&lt;/li&gt;
&lt;li&gt;  자동 변경사항 감지&lt;/li&gt;
&lt;li&gt;↩️ 원본 데이터 복원 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 지능형 상품 검색 시스템&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 복잡했던 부분인 상품 검색을 &lt;b&gt;3단계 필터링&lt;/b&gt;으로 혁신했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1750207094197&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 상품 검색의 핵심 - 단계별 네비게이션
function selectToolAndSwitchTab(toolName, element) {
    // 선택 상태 저장
    window.RentalApp.currentFilters.selectedTool = toolName;
    window.RentalApp.currentFilters.selectedModel = null;
    
    // 다음 단계로 자동 전환
    switchToTabAndSearch('model');
    
    showNotification(`툴구분 &quot;${toolName}&quot;이 선택되었습니다. 모델명을 선택해주세요.`, 'info');
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 흐름 최적화:&lt;/p&gt;
&lt;pre id=&quot;code_1750207103885&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 즉시 렌더링으로 사용자 경험 개선
function performTabSearchImmediately(tabName, searchQuery = null) {
    const results = filterProducts(searchQuery, tabName);
    
    // 비동기 처리 없이 즉시 렌더링
    renderSearchResultsImmediately(tabName, results);
    updateResultCountImmediately(results.length);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 데이터 수집 및 검증 시스템&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡한 폼 데이터를 체계적으로 관리하는 &lt;b&gt;FormDataCollector&lt;/b&gt;를 구현했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1750207116852&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 포괄적 데이터 수집 시스템
class FormDataCollector {
    getAllFormData() {
        return {
            customer: this.getCustomerData(),
            orderer: this.getOrdererData(),
            contractor: this.getContractorData(),
            // ... 기타 섹션들
            
            // 상품 정보는 다중 소스에서 수집
            selectedProduct: this.getSelectedProductInfo(),
            
            // 메타데이터 포함
            metadata: {
                submittedAt: new Date().toISOString(),
                completionRate: this.calculateCompletionRate()
            }
        };
    }
    
    // 데이터 품질 검증
    validateFormData(data) {
        const errors = [];
        
        if (!data.customer.name) errors.push('고객명은 필수입니다.');
        if (!data.customer.phone) errors.push('연락처는 필수입니다.');
        
        return {
            isValid: errors.length === 0,
            errors: errors
        };
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  성능 최적화 실전 적용&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 메모리 누수 방지&lt;/h4&gt;
&lt;pre id=&quot;code_1750207134453&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 이벤트 리스너 정리 시스템
window.addEventListener('beforeunload', () =&amp;gt; {
    if (window.AlertManager?.destroy) {
        window.AlertManager.destroy();
    }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 배치 DOM 업데이트&lt;/p&gt;
&lt;pre id=&quot;code_1750207146724&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 리플로우 최소화를 위한 배치 처리
function batchDOMUpdate(updates) {
    return new Promise(resolve =&amp;gt; {
        requestAnimationFrame(() =&amp;gt; {
            updates.forEach(update =&amp;gt; update());
            resolve();
        });
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  사용자 경험 혁신&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;스마트 주소 입력&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오 주소 API와 일괄 적용을 결합한 혁신적 UX:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;javascript&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;// 주소 검색 + 일괄 적용 시스템
handleAddressComplete(data, section) {
    const address = this.formatAddress(data);
    this.setAddressFields(section, data.zonecode, address);
    
    // 일괄 적용 모드일 때 자동 확산
    if (window.RentalApp.batchApplyMode &amp;amp;&amp;amp; section === 'customer') {
        this.applyToAllSections(data.zonecode, address);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;실시간 피드백 시스템&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;javascript&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;arcade&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;// 사용자 행동에 즉각 반응하는 UI
function showBatchApplyIndicator() {
    SECTIONS.forEach(section =&amp;gt; {
        updateStatus(section, 'applying');
    });
}

function showCompletionFeedback() {
    if (hasData()) {
        SECTIONS.forEach(section =&amp;gt; {
            updateStatus(section, 'copied');
        });
        showToastMessage('정보가 일괄 적용되었습니다 ✓', 'success');
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  프로젝트 성과&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Before vs After 비교&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이전 Oracle Forms 시스템:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;❌ 동일 정보 5번 반복 입력&lt;/li&gt;
&lt;li&gt;❌ 복잡한 상품 검색 (500+ 항목 나열)&lt;/li&gt;
&lt;li&gt;❌ 모바일 지원 불가&lt;/li&gt;
&lt;li&gt;❌ 수정 시 전체 페이지 새로고침&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개선된 웹 시스템:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ 일괄 적용으로 1번 입력으로 완료&lt;/li&gt;
&lt;li&gt;✅ 3단계 필터링으로 직관적 상품 선택&lt;/li&gt;
&lt;li&gt;✅ 완전 반응형 지원&lt;/li&gt;
&lt;li&gt;✅ 섹션별 독립적 수정&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;정량적 성과&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;입력 시간 70% 단축&lt;/b&gt; (평균 15분 &amp;rarr; 4.5분)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;오입력 80% 감소&lt;/b&gt; (일괄 적용 효과)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상품 검색 시간 85% 단축&lt;/b&gt; (단계별 필터링)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Oracle Forms에서 현대적 웹 애플리케이션으로의 전환은 단순한 기술 교체가 아니라 &lt;b&gt;사용자 경험의 혁신&lt;/b&gt;이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡했던 업무 프로세스가 직관적이고 효율적으로 바뀌면서, 실제 업무 현장에서 긍정적인 피드백을 받고 있습니다. 특히 일괄 적용 기능과 스마트 상품 검색은 사용자들이 가장 만족해하는 부분입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로도 지속적인 사용자 피드백을 반영해 더욱 발전된 시스템으로 개선해나갈 예정입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;관련 기술 스택:&lt;/b&gt; Spring Boot, MyBatis, Thymeleaf, JavaScript ES6+, Oracle Database, 카카오 주소 API&lt;/p&gt;</description>
      <category>MyStory/Technology_Preview</category>
      <author>LupyLaon</author>
      <guid isPermaLink="true">https://lupylaon.tistory.com/22</guid>
      <comments>https://lupylaon.tistory.com/22#entry22comment</comments>
      <pubDate>Wed, 18 Jun 2025 09:40:12 +0900</pubDate>
    </item>
    <item>
      <title>1편: 레거시 시스템 현대화 - Oracle Forms에서 모던 웹으로의 여정</title>
      <link>https://lupylaon.tistory.com/21</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;  프로젝트 배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 많은 기업들이 오래된 Oracle Forms 기반 시스템의 한계를 느끼고 있습니다. 사용자 경험이 떨어지고, 모바일 지원이 어려우며, 확장성에 제약이 있죠. 저희도 렌탈 주문관리 시스템에서 이런 문제들을 겪고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주요 문제점들:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  모바일/반응형 지원 불가&lt;/li&gt;
&lt;li&gt;  복잡하고 직관적이지 않은 UI&lt;/li&gt;
&lt;li&gt;  중복 입력으로 인한 비효율성&lt;/li&gt;
&lt;li&gt;  비효율적인 상품 검색 방식&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  해결 전략 수립&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 사용자 경험 개선&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Forms의 복잡한 입력 과정을 분석해보니, 주문자&amp;middot;계약자&amp;middot;설치자&amp;middot;결제자 정보가 대부분 동일한데도 매번 따로 입력해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 아이디어: 일괄 적용 시스템&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1750206871084&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 고객 정보 일괄 적용 핵심 로직
function applyToAllSections(customerData) {
    const sections = ['orderer', 'contractor', 'installer', 'payer'];
    
    sections.forEach(section =&amp;gt; {
        applyToSection(section, customerData);
    });
    
    showToastMessage('고객 정보가 일괄 적용되었습니다 ✓', 'success');
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 상품 검색 개선&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존의 단순 목록 방식에서 &lt;b&gt;단계별 필터링 시스템&lt;/b&gt;으로 개선했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;검색 플로우: 툴구분 &amp;rarr; 모델명 &amp;rarr; 상품명&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1750206889740&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 단계별 상품 필터링 시스템
function filterProducts(searchQuery, tabType) {
    let results = [...allProducts];
    
    switch (tabType) {
        case 'tool':
            // 툴구분별 그룹화
            return createToolGroups(results);
        case 'model':
            // 선택된 툴의 모델명 필터링
            if (selectedTool) {
                results = results.filter(p =&amp;gt; p.filter1 === selectedTool);
            }
            return createModelGroups(results);
        case 'product':
            // 최종 상품 목록
            return applyAllFilters(results);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt; ️ 아키텍처 설계&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;모듈화된 JavaScript 구조&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능과 유지보수성을 고려해 모듈별로 분리했습니다:&lt;/p&gt;
&lt;pre id=&quot;code_1750206903772&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 의존성 체크 시스템
function checkDependencies() {
    const requiredModules = [
        'AlertManager', 'CustomerInfo', 'ProductSearch', 
        'AddressSearch', 'FormDataCollector'
    ];
    
    const missingModules = requiredModules.filter(module =&amp;gt; 
        typeof window[module] === 'undefined'
    );
    
    if (missingModules.length &amp;gt; 0) {
        throw new Error(`필수 모듈 누락: ${missingModules.join(', ')}`);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;백엔드 API 설계&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RESTful API로 데이터 처리를 분리했습니다:&lt;/p&gt;
&lt;pre id=&quot;code_1750206916068&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
public class RentalController {
    
    @PostMapping(&quot;/api/rental/salesman/search&quot;)
    public ResponseEntity&amp;lt;?&amp;gt; getSalesmanInfo(@RequestBody SalesmanSearchDto searchDto) {
        Map&amp;lt;String, Object&amp;gt; result = rentalService.getSalesmanBySearchDto(searchDto);
        return ResponseEntity.ok(result);
    }
    
    @GetMapping(&quot;/api/rental/codes/{codeType}&quot;)
    public ResponseEntity&amp;lt;?&amp;gt; getCodes(@PathVariable String codeType) {
        String codeId = mapCodeTypeToCodeId(codeType);
        Map&amp;lt;String, Object&amp;gt; result = rentalService.getCodesByCodeId(codeId);
        return ResponseEntity.ok(result);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  성능 최적화 전략&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 디바운싱으로 검색 최적화&lt;/h4&gt;
&lt;pre id=&quot;code_1750206931100&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 검색 입력 디바운싱
function debouncedSearch(tabName, searchValue) {
    clearTimeout(searchDebounceTimer);
    showSearchingIndicator(tabName);
    
    searchDebounceTimer = setTimeout(() =&amp;gt; {
        performTabSearchOptimized(tabName, searchValue);
    }, 300);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 가상화된 리스트 렌더링&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대량의 상품 데이터를 효율적으로 처리하기 위해 가상 스크롤링을 구현했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  UI/UX 개선사항&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;상태 표시 시스템&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 섹션의 입력 상태를 시각적으로 표현:&lt;/p&gt;
&lt;pre id=&quot;code_1750207033679&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.status-indicator {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    transition: all 0.3s ease;
}

.status-filled { background-color: #4CAF50; }
.status-copied { background-color: #2196F3; }
.status-editing { 
    background-color: #FF9800;
    animation: pulse 1s infinite;
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>MyStory/Technology_Preview</category>
      <author>LupyLaon</author>
      <guid isPermaLink="true">https://lupylaon.tistory.com/21</guid>
      <comments>https://lupylaon.tistory.com/21#entry21comment</comments>
      <pubDate>Wed, 18 Jun 2025 09:37:34 +0900</pubDate>
    </item>
    <item>
      <title>Node.js 인증 시스템 구축기 (2편) - 보안 강화와 고급 기능</title>
      <link>https://lupylaon.tistory.com/20</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 다층 보안 시스템&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1편에서 다룬 JWT 토큰 시스템을 기반으로, 이번 편에서는 &lt;b&gt;실전에서 꼭 필요한 보안 기능들&lt;/b&gt;과 &lt;b&gt;확장 가능한 고급 기능들&lt;/b&gt;을 살펴보겠습니다. 현대적인 웹 애플리케이션은 단순한 인증을 넘어 &lt;b&gt;다양한 보안 위협에 대한 종합적인 대응&lt;/b&gt;이 필요합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Rate Limiting - API 남용 방지&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전역 Rate Limiting&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// index.js에서 전역 Rate Limiting 설정
const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
    windowMs: 15 * 60 * 1000,    // 15분 시간 창
    max: 100,                    // IP당 최대 100회 요청
    message: {
        success: false,
        message: '너무 많은 요청입니다. 잠시 후 다시 시도해주세요.'
    },
    standardHeaders: true,        // `RateLimit-*` 헤더 추가
    legacyHeaders: false,        // `X-RateLimit-*` 헤더 비활성화
});

app.use(limiter);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;로그인 전용 Rate Limiting&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 엔드포인트에는 &lt;b&gt;더욱 엄격한 제한&lt;/b&gt;을 적용합니다:&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// authRoutes.js에서 로그인 전용 리미터
const loginLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,    // 15분
    max: 5,                      // 최대 5회 시도
    message: { 
        success: false,
        message: '너무 많은 로그인 시도. 15분 후에 다시 시도하세요.'
    },
    skipSuccessfulRequests: true,  // 성공한 요청은 카운트에서 제외
});

// 로그인 라우트에 적용
router.post('/login', loginValidator, loginLimiter, authController.login);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;계정별 브루트포스 방지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IP 기반 제한과 함께 &lt;b&gt;계정별 잠금 시스템&lt;/b&gt;도 구현했습니다:&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// User 모델에 브루트포스 방지 필드
const UserSchema = new mongoose.Schema({
    loginAttempts: { type: Number, default: 0 },
    lockUntil: { type: Number, default: 0 },
    // ... 기타 필드
});

// 로그인 실패 처리 로직
if (!isMatch) {
    user.loginAttempts += 1;
    
    //   5회 실패 시 30분 잠금
    if (user.loginAttempts &amp;gt;= 5) {
        user.lockUntil = Date.now() + 30 * 60 * 1000;
        user.loginAttempts = 0;
        
        logger.security('계정 잠금 발생', {
            userId: user._id,
            email: user.email,
            ip: req.ip,
            attempts: user.loginAttempts
        });
    }
    
    await user.save();
    return res.status(401).json({
        success: false,
        message: '이메일 또는 비밀번호가 올바르지 않습니다.'
    });
}

// ✅ 로그인 성공 시 잠금 해제
user.loginAttempts = 0;
user.lockUntil = undefined;
await user.save();
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  CSRF 보호 - 크로스사이트 요청 위조 방지&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;선택적 CSRF 보호&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 엔드포인트에 CSRF 보호를 적용하면 사용성이 떨어질 수 있어, &lt;b&gt;선택적 보호 전략&lt;/b&gt;을 채택했습니다:&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// index.js에서 CSRF 설정
const csrf = require('csurf');

const csrfProtection = csrf({
    cookie: {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict'
    }
});

//   CSRF 토큰 제공 엔드포인트
app.get('/api/csrf-token', csrfProtection, (req, res) =&amp;gt; {
    res.json({ csrfToken: req.csrfToken() });
});

//  ️ 민감한 작업에만 CSRF 보호 적용
// router.post('/register', csrfProtection, registerValidator, authController.register);
// router.post('/login', csrfProtection, loginValidator, loginLimiter, authController.login);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CSRF 에러 처리&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 전역 에러 핸들러에서 CSRF 에러 처리
app.use((err, req, res, next) =&amp;gt; {
    if (err.code === 'EBADCSRFTOKEN') {
        return res.status(403).json({ 
            message: 'CSRF 토큰이 유효하지 않습니다.',
            hint: 'CSRF 토큰을 /api/csrf-token에서 받아서 X-CSRF-TOKEN 헤더에 포함하세요.'
        });
    }
    // 기타 에러 처리...
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  CORS 설정 - 안전한 크로스 오리진 요청&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동적 Origin 검증&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// index.js에서 CORS 설정
const allowedOrigins = [
    'http://localhost:3000',
    'http://localhost:8080',
    'http://localhost:8081',
    process.env.CLIENT_URL
].filter(Boolean);

app.use(cors({
    origin: function (origin, callback) {
        //   개발 환경에서는 origin이 undefined일 수 있음
        if (!origin &amp;amp;&amp;amp; process.env.NODE_ENV === 'development') {
            return callback(null, true);
        }

        if (allowedOrigins.includes(origin)) {
            callback(null, true);
        } else {
            console.log('CORS 차단된 도메인:', origin);
            callback(new Error('CORS 정책에 의해 차단됨'));
        }
    },
    credentials: true,                              // 쿠키 포함 요청 허용
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-TOKEN']
}));
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  소셜 로그인 구현 - Passport.js 전략&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Passport 전략 설정&lt;/h3&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// config/passport.js
const passport = require('passport');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const KakaoStrategy = require('passport-kakao').Strategy;
const NaverStrategy = require('passport-naver').Strategy;

//   JWT 전략
const jwtOptions = {
    jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
    secretOrKey: process.env.JWT_SECRET
};

passport.use(new JwtStrategy(jwtOptions, async (payload, done) =&amp;gt; {
    try {
        const user = await User.findById(payload.id);
        return user ? done(null, user) : done(null, false);
    } catch (error) {
        return done(error, false);
    }
}));

//   카카오 로그인 전략
passport.use(new KakaoStrategy({
    clientID: process.env.KAKAO_CLIENT_ID,
    callbackURL: process.env.KAKAO_CALLBACK_URL
}, async (accessToken, refreshToken, profile, done) =&amp;gt; {
    try {
        // 기존 사용자 찾기
        let user = await User.findOne({ kakaoId: profile.id });

        if (!user) {
            //   새 사용자 생성
            user = await User.create({
                name: profile.displayName || profile.username,
                email: profile._json.kakao_account.email,
                kakaoId: profile.id,
                termsAgree: true    // 소셜 로그인 시 약관 동의 처리
            });
        }

        return done(null, user);
    } catch (error) {
        return done(error, false);
    }
}));

//   네이버 로그인 전략
passport.use(new NaverStrategy({
    clientID: process.env.NAVER_CLIENT_ID,
    clientSecret: process.env.NAVER_CLIENT_SECRET,
    callbackURL: process.env.NAVER_CALLBACK_URL
}, async (accessToken, refreshToken, profile, done) =&amp;gt; {
    try {
        let user = await User.findOne({ naverId: profile.id });

        if (!user) {
            user = await User.create({
                name: profile.displayName,
                email: profile.emails[0].value,
                naverId: profile.id,
                termsAgree: true
            });
        }

        return done(null, user);
    } catch (error) {
        return done(error, false);
    }
}));
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;소셜 로그인 콜백 처리&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// authController.js에서 카카오 콜백 처리
exports.kakaoCallback = async (req, res) =&amp;gt; {
    try {
        const user = req.user;

        //   토큰 생성
        const accessToken = generateAccessToken(user._id);
        const refreshToken = generateRefreshToken(user._id);

        //   리프레시 토큰 DB 저장
        await RefreshToken.create({
            user: user._id,
            token: refreshToken,
            ip: req.ip,
            userAgent: req.get('User-Agent') || 'Unknown',
            expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
        });

        //   HTTP-only 쿠키 설정
        res.cookie('refreshToken', refreshToken, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'strict',
            maxAge: 7 * 24 * 60 * 60 * 1000
        });

        //   프론트엔드로 리다이렉트 (토큰과 함께)
        res.redirect(`${process.env.CLIENT_URL}/?token=${accessToken}&amp;amp;social=kakao`);
        
    } catch (error) {
        logger.logError('카카오 로그인 콜백 오류', error, {
            ip: req.ip,
            userAgent: req.headers['user-agent']
        });
        res.redirect(`${process.env.CLIENT_URL}/login?error=kakao_login_failed`);
    }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 포괄적인 유효성 검증&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;강력한 비밀번호 정책&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// middleware/validators.js
const passwordStrengthCheck = (value) =&amp;gt; {
    // 최소 8자, 대소문자, 숫자, 특수문자 각 1개 이상
    const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&amp;amp;])[A-Za-z\d@$!%*?&amp;amp;]{8,}$/;
    
    if (!strongPasswordRegex.test(value)) {
        throw new Error('비밀번호는 최소 8자 이상이며, 대문자, 소문자, 숫자, 특수문자를 각각 하나 이상 포함해야 합니다.');
    }
    
    //   일반적인 패턴 차단
    const commonPatterns = ['123456', 'password', 'qwerty', 'admin', '111111'];
    if (commonPatterns.some(pattern =&amp;gt; value.toLowerCase().includes(pattern))) {
        throw new Error('흔히 사용되는 비밀번호 패턴이 포함되어 있습니다.');
    }
    
    return true;
};

//   금지어 검사
const forbiddenWordsCheck = (value) =&amp;gt; {
    const forbiddenWords = ['비속어', '욕설', 'badword'];
    
    if (forbiddenWords.some(word =&amp;gt; value.toLowerCase().includes(word))) {
        throw new Error('부적절한 단어가 포함되어 있습니다.');
    }
    
    return true;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;회원가입 유효성 검증&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const registerValidator = [
    body('name')
        .notEmpty().withMessage('이름은 필수 입력사항입니다.')
        .isLength({ min: 2, max: 50}).withMessage('이름은 2~50자 사이여야 합니다.')
        .trim()
        .escape()                           // HTML 이스케이프
        .custom(forbiddenWordsCheck),

    body('email')
        .notEmpty().withMessage('이메일은 필수 입력사항입니다.')
        .isEmail().withMessage('유효한 이메일 형식이 아닙니다.')
        .normalizeEmail(),                  // 이메일 정규화

    body('password')
        .notEmpty().withMessage('비밀번호는 필수 입력사항입니다.')
        .isLength({ min: 8 }).withMessage('비밀번호는 최소 8자 이상이어야 합니다.')
        .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&amp;amp;])/)
        .withMessage('비밀번호는 대문자, 소문자, 숫자, 특수문자를 각각 하나 이상 포함해야 합니다.')
        .custom(passwordStrengthCheck),

    body('confirmPassword')
        .custom((value, { req }) =&amp;gt; {
            if (value !== req.body.password) {
                throw new Error('비밀번호와 비밀번호 확인이 일치하지 않습니다.');
            }
            return true;
        }),

    body('phone')
        .optional()
        .isMobilePhone('ko-KR').withMessage('유효한 한국 전화번호 형식이 아닙니다.'),

    body('termsAgree')
        .isBoolean()
        .custom(value =&amp;gt; {
            if (value !== true) {
                throw new Error('서비스 이용을 위해 약관에 동의해야 합니다.');
            }
            return true;
        }),

    validate  // 유효성 검사 결과 확인 미들웨어
];
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;로그인 이메일 정규화&lt;/h3&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;const loginValidator = [
    body('email')
        .notEmpty().withMessage('이메일은 필수 입력사항입니다.')
        .isEmail().withMessage('유효한 이메일 형식이 아닙니다.')
        .normalizeEmail({
            gmail_remove_dots: false,           // Gmail의 점 제거 안함
            gmail_remove_subaddress: true,      // +부분 제거
            all_lowercase: true,                // 소문자 변환
            gmail_convert_googlemaildotcom: true // googlemail.com &amp;rarr; gmail.com
        }),

    body('password')
        .notEmpty().withMessage('비밀번호는 필수 입력사항입니다.'),

    validate
];
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Winston을 활용한 구조화된 로깅&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;로그 레벨별 파일 분리&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// utils/logger.js
const winston = require('winston');
const { format, transports } = winston;
require('winston-daily-rotate-file');

//   로그 디렉토리 생성
const logDir = 'logs';
if (!fs.existsSync(logDir)) {
    fs.mkdirSync(logDir);
}

//   커스텀 로그 포맷
const logFormat = format.printf(({ level, message, timestamp, ...meta }) =&amp;gt; {
    return `${timestamp} [${level.toUpperCase()}]: ${message} ${
        Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''
    }`;
});

const logger = winston.createLogger({
    level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
    format: format.combine(
        format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
        format.errors({ stack: true }),
        logFormat
    ),
    defaultMeta: { service: 'guardian-auth' },
    transports: [
        //  ️ 콘솔 출력
        new transports.Console({
            format: format.combine(format.colorize(), logFormat)
        }),

        //   일반 로그 (일별 순환)
        new transports.DailyRotateFile({
            filename: path.join(logDir, 'application-%DATE%.log'),
            datePattern: 'YYYY-MM-DD',
            maxSize: '20m',
            maxFiles: '14d',
            level: 'info'
        }),

        // ❌ 에러 로그 (장기 보관)
        new transports.DailyRotateFile({
            filename: path.join(logDir, 'error-%DATE%.log'),
            datePattern: 'YYYY-MM-DD',
            maxSize: '20m',
            maxFiles: '30d',
            level: 'error'
        }),

        //   보안 이벤트 전용 로그
        new transports.DailyRotateFile({
            filename: path.join(logDir, 'security-%DATE%.log'),
            datePattern: 'YYYY-MM-DD',
            maxSize: '20m',
            maxFiles: '90d',
            level: 'info',
            format: format.combine(
                format.timestamp(),
                format.json(),
                format((info) =&amp;gt; info.security ? info : false)()
            )
        })
    ]
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;보안 이벤트 로깅&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 전용 로깅 메서드
logger.logError = (message, error, meta = {}) =&amp;gt; {
    logger.error(message, {
        error: {
            message: error.message,
            stack: error.stack
        },
        ...meta
    });
};

logger.security = (message, meta = {}) =&amp;gt; {
    logger.info(message, { security: true, ...meta });
};

// 사용 예시 - 의심스러운 토큰 사용
logger.security('유효하지 않은 리프레시 토큰 사용', {
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    tokenFound: !!storedToken,
    tokenRevoked: storedToken ? storedToken.isRevoked : false,
    timestamp: new Date().toISOString()
});

// 사용 예시 - 계정 생성
logger.security('사용자 계정 생성 성공', {
    userId: user._id.toString(),
    email,
    ip: req.ip,
    userAgent: req.headers['user-agent']
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  보안 헤더와 미들웨어 설정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Helmet을 통한 보안 헤더&lt;/h3&gt;
&lt;pre class=&quot;scilab&quot;&gt;&lt;code&gt;// index.js에서 Helmet 설정
const helmet = require('helmet');

//  ️ 보안 헤더 설정 (가장 먼저 적용)
app.use(helmet({
    contentSecurityPolicy: {
        directives: {
            defaultSrc: [&quot;'self'&quot;],
            styleSrc: [&quot;'self'&quot;, &quot;'unsafe-inline'&quot;],
            scriptSrc: [&quot;'self'&quot;],
            imgSrc: [&quot;'self'&quot;, &quot;data:&quot;, &quot;https:&quot;]
        }
    },
    hsts: {
        maxAge: 31536000,
        includeSubDomains: true,
        preload: true
    }
}));
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;요청 로깅 미들웨어&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// 상세한 요청 로깅
app.use((req, res, next) =&amp;gt; {
    const startTime = Date.now();
    
    console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
    console.log('Origin:', req.headers.origin);
    console.log('User-Agent:', req.headers['user-agent']);
    
    res.on('finish', () =&amp;gt; {
        const duration = Date.now() - startTime;
        logger.info('요청 완료', {
            method: req.method,
            path: req.path,
            statusCode: res.statusCode,
            duration: `${duration}ms`,
            ip: req.ip
        });
    });
    
    next();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  확장 가능한 에러 처리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;중앙화된 에러 핸들링&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 전역 에러 핸들러
app.use((err, req, res, next) =&amp;gt; {
    console.error('서버 에러:', err);

    // CSRF 토큰 에러
    if (err.code === 'EBADCSRFTOKEN') {
        return res.status(403).json({ 
            message: 'CSRF 토큰이 유효하지 않습니다.',
            hint: 'CSRF 토큰을 /api/csrf-token에서 받아서 X-CSRF-TOKEN 헤더에 포함하세요.'
        });
    }

    // Mongoose 유효성 검증 에러
    if (err.name === 'ValidationError') {
        const errors = Object.values(err.errors).map(e =&amp;gt; e.message);
        return res.status(400).json({
            success: false,
            message: '입력 데이터가 유효하지 않습니다.',
            errors
        });
    }

    // MongoDB 중복 키 에러
    if (err.code === 11000) {
        return res.status(400).json({
            success: false,
            message: '이미 사용 중인 값입니다.'
        });
    }

    // 기본 에러 응답
    res.status(500).json({
        success: false,
        message: '서버 내부 오류가 발생했습니다.',
        error: process.env.NODE_ENV === 'development' ? err.message : '내부 서버 오류'
    });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  성능 최적화와 모니터링&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;데이터베이스 인덱스&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// User 모델에서 성능 최적화
UserSchema.index({ email: 1 });                    // 이메일 검색 최적화
UserSchema.index({ kakaoId: 1 }, { sparse: true }); // 소셜 ID 검색
UserSchema.index({ naverId: 1 }, { sparse: true });
UserSchema.index({ lastLocation: '2dsphere' });     // 위치 기반 검색
UserSchema.index({ createdAt: 1 });                 // 시간순 정렬

// RefreshToken 모델
RefreshTokenSchema.index({ token: 1 });             // 토큰 검색 최적화
RefreshTokenSchema.index({ user: 1 });              // 사용자별 토큰 조회
RefreshTokenSchema.index({ expiresAt: 1 });         // 만료 토큰 정리
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 인증 시스템 구축을 통해 다음과 같은 &lt;b&gt;현대적 보안 요구사항&lt;/b&gt;을 모두 만족하는 시스템을 완성했습니다:&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 구현된 보안 기능&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;다층 인증 시스템&lt;/b&gt;: JWT + Refresh Token&lt;/li&gt;
&lt;li&gt;&lt;b&gt;브루트포스 방지&lt;/b&gt;: Rate Limiting + 계정 잠금&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CSRF 보호&lt;/b&gt;: 선택적 토큰 검증&lt;/li&gt;
&lt;li&gt;&lt;b&gt;소셜 로그인&lt;/b&gt;: 안전한 OAuth 구현&lt;/li&gt;
&lt;li&gt;&lt;b&gt;입력 검증&lt;/b&gt;: 포괄적 유효성 검사&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보안 로깅&lt;/b&gt;: 구조화된 이벤트 추적&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  확장성 고려사항&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;모듈화된 구조&lt;/b&gt;: 기능별 파일 분리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;환경별 설정&lt;/b&gt;: 개발/운영 환경 대응&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성능 최적화&lt;/b&gt;: 데이터베이스 인덱싱&lt;/li&gt;
&lt;li&gt;&lt;b&gt;모니터링&lt;/b&gt;: 상세한 로깅과 에러 추적&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실전 적용 팁&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;보안 정책 수립&lt;/b&gt;: 비밀번호 강도, 토큰 만료 시간 등&lt;/li&gt;
&lt;li&gt;&lt;b&gt;모니터링 체계&lt;/b&gt;: 보안 이벤트 알림 시스템 구축&lt;/li&gt;
&lt;li&gt;&lt;b&gt;정기 보안 점검&lt;/b&gt;: 로그 분석과 취약점 점검&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용자 교육&lt;/b&gt;: 안전한 비밀번호 사용 가이드&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 체계적인 접근을 통해 &lt;b&gt;안전하고 확장 가능한 인증 시스템&lt;/b&gt;을 구축할 수 있으며, 실제 서비스에서 요구되는 다양한 보안 요구사항을 효과적으로 대응할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;보안은 한 번 구축하고 끝나는 것이 아니라 지속적으로 개선해야 하는 영역&lt;/b&gt;입니다. 새로운 위협에 대응하고 사용자 경험을 개선하기 위해 꾸준한 업데이트와 모니터링이 필요합니다.&lt;/p&gt;</description>
      <category>MyStory/SafeLink Project</category>
      <author>LupyLaon</author>
      <guid isPermaLink="true">https://lupylaon.tistory.com/20</guid>
      <comments>https://lupylaon.tistory.com/20#entry20comment</comments>
      <pubDate>Mon, 9 Jun 2025 16:40:21 +0900</pubDate>
    </item>
    <item>
      <title>Node.js 인증 시스템 구축기 (1편) - 아키텍처와 핵심 기술</title>
      <link>https://lupylaon.tistory.com/19</link>
      <description>&lt;h1&gt;Node.js 인증 시스템 구축기 (1편) - 아키텍처와 핵심 기술&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  프로젝트 개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현대적인 웹 애플리케이션에서 &lt;b&gt;안전하고 확장 가능한 인증 시스템&lt;/b&gt;은 필수입니다. 이번 프로젝트에서는 Node.js와 Express를 기반으로 다음과 같은 기능을 갖춘 완전한 인증 시스템을 구축했습니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;JWT 기반 토큰 인증&lt;/b&gt; (Access Token + Refresh Token)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;소셜 로그인&lt;/b&gt; (카카오, 네이버)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;강화된 보안 기능&lt;/b&gt; (브루트포스 방지, CSRF 보호, Rate Limiting)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;포괄적인 유효성 검증&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;구조화된 로깅 시스템&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 기술 스택&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 기술&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Runtime&lt;/b&gt;: Node.js&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Framework&lt;/b&gt;: Express.js&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Database&lt;/b&gt;: MongoDB + Mongoose ODM&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Authentication&lt;/b&gt;: JWT (jsonwebtoken)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Password Hashing&lt;/b&gt;: bcrypt&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;보안 라이브러리&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;passport.js&lt;/b&gt;: 소셜 로그인 구현&lt;/li&gt;
&lt;li&gt;&lt;b&gt;helmet&lt;/b&gt;: HTTP 보안 헤더 설정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;express-rate-limit&lt;/b&gt;: API 요청 제한&lt;/li&gt;
&lt;li&gt;&lt;b&gt;csurf&lt;/b&gt;: CSRF 공격 방지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;express-validator&lt;/b&gt;: 입력 데이터 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기타 도구&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;winston&lt;/b&gt;: 구조화된 로깅&lt;/li&gt;
&lt;li&gt;&lt;b&gt;cors&lt;/b&gt;: Cross-Origin 요청 관리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;dotenv&lt;/b&gt;: 환경 변수 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  프로젝트 구조와 파일 역할&lt;/h2&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;├── controllers/
│   └── authController.js      # 인증 관련 비즈니스 로직
├── middleware/
│   ├── authMiddleware.js      # JWT 토큰 검증 미들웨어
│   └── validators.js          # 입력 데이터 유효성 검증
├── models/
│   ├── User.js               # 사용자 데이터 모델
│   └── RefreshToken.js       # 리프레시 토큰 모델
├── routes/
│   └── authRoutes.js         # 인증 관련 라우트 정의
├── config/
│   ├── db.js                 # MongoDB 연결 설정
│   └── passport.js           # Passport 전략 설정
├── utils/
│   ├── jwtUtils.js           # JWT 토큰 유틸리티
│   └── logger.js             # 로깅 설정
├── .env                      # 환경 변수
└── index.js                  # 메인 서버 파일
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 파일별 역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  authController.js&lt;/b&gt; - 인증의 심장부&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;회원가입, 로그인, 로그아웃 처리&lt;/li&gt;
&lt;li&gt;토큰 갱신 및 소셜 로그인 콜백 처리&lt;/li&gt;
&lt;li&gt;보안 이벤트 로깅&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; ️ authMiddleware.js&lt;/b&gt; - 보안 관문&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JWT 토큰 검증 및 사용자 인증&lt;/li&gt;
&lt;li&gt;토큰 만료 처리 및 에러 응답&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  User.js &amp;amp; RefreshToken.js&lt;/b&gt; - 데이터 모델&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자 정보 및 토큰 관리&lt;/li&gt;
&lt;li&gt;비밀번호 암호화 및 검증 로직&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  JWT 토큰 시스템 - 이중 보안 구조&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Access Token + Refresh Token 패턴&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시스템의 핵심은 &lt;b&gt;이중 토큰 구조&lt;/b&gt;입니다. 이는 보안성과 사용자 경험을 모두 만족시키는 현대적인 접근 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Access Token 생성 (1시간 수명)
const generateAccessToken = (userId) =&amp;gt; {
    return jwt.sign(
        { id: userId },
        process.env.JWT_SECRET,
        {
            algorithm: 'HS256',
            expiresIn: '1h',                    // 짧은 수명
            issuer: 'guardian-app',
            audience: 'guardian-users',
            subject: userId.toString(),
            jwtid: crypto.randomBytes(16).toString('hex')
        }
    );
};

// Refresh Token 생성 (7일 수명)
const generateRefreshToken = (userId) =&amp;gt; {
    return jwt.sign(
        { id: userId },
        process.env.REFRESH_TOKEN_SECRET,      // 다른 시크릿 키 사용
        {
            algorithm: 'HS256',
            expiresIn: '7d',                   // 긴 수명
            issuer: 'guardian-app',
            audience: 'guardian-users',
            subject: userId.toString(),
            jwtid: crypto.randomBytes(16).toString('hex')
        }
    );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;토큰 저장 전략&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Access Token&lt;/b&gt;: 클라이언트 메모리에 저장 (XSS 공격 시 피해 최소화) &lt;b&gt;Refresh Token&lt;/b&gt;: HTTP-only 쿠키로 저장 (XSS 공격으로부터 보호)&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// Refresh Token을 HTTP-only 쿠키로 설정
res.cookie('refreshToken', refreshToken, {
    httpOnly: true,                          // JavaScript 접근 차단
    secure: process.env.NODE_ENV === 'production',  // HTTPS에서만 전송
    sameSite: 'strict',                      // CSRF 공격 방지
    maxAge: 7 * 24 * 60 * 60 * 1000         // 7일
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  인증 플로우&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 회원가입 플로우&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;exports.register = async (req, res) =&amp;gt; {
    try {
        const { name, email, password, phone, termsAgree, notificationAgree } = req.body;

        //   이메일 중복 확인
        const existingUser = await User.findOne({ email });
        if (existingUser) {
            return res.status(400).json({
                success: false,
                message: '이미 사용 중인 이메일입니다.'
            });
        }

        //   사용자 생성 (비밀번호 자동 암호화)
        const user = await User.create({
            name, email, password, phone, termsAgree, notificationAgree
        });

        //   토큰 생성
        const accessToken = generateAccessToken(user._id);
        const refreshToken = generateRefreshToken(user._id);

        //   Refresh Token DB 저장
        await RefreshToken.create({
            user: user._id,
            token: refreshToken,
            ip: req.ip,
            userAgent: req.get('User-Agent') || 'Unknown',
            expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
        });

        //   쿠키 설정 및 응답
        res.cookie('refreshToken', refreshToken, { /* 쿠키 옵션 */ });
        res.status(201).json({
            success: true,
            token: accessToken,
            user: { /* 사용자 정보 */ }
        });
    } catch (error) {
        // 에러 처리 및 로깅
    }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 로그인 플로우 (브루트포스 방지 포함)&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;exports.login = async (req, res) =&amp;gt; {
    try {
        const { email, password } = req.body;
        const user = await User.findOne({ email });

        //   계정 잠금 확인
        if (user.lockUntil &amp;amp;&amp;amp; user.lockUntil &amp;gt; Date.now()) {
            return res.status(423).json({
                message: '계정이 일시적으로 잠겼습니다.'
            });
        }

        //   비밀번호 검증
        const isMatch = await user.matchPassword(password);
        if (!isMatch) {
            // 실패 횟수 증가
            user.loginAttempts += 1;
            
            // 5회 실패 시 30분 잠금
            if (user.loginAttempts &amp;gt;= 5) {
                user.lockUntil = Date.now() + 30 * 60 * 1000;
                user.loginAttempts = 0;
            }
            await user.save();
            
            return res.status(401).json({ /* 에러 응답 */ });
        }

        // ✅ 로그인 성공 시 잠금 해제
        user.loginAttempts = 0;
        user.lockUntil = undefined;
        await user.save();

        // 토큰 생성 및 응답...
    } catch (error) {
        // 에러 처리
    }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 토큰 갱신 플로우&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;exports.refreshToken = async (req, res) =&amp;gt; {
    try {
        //   쿠키에서 Refresh Token 추출
        const { refreshToken } = req.cookies;
        
        if (!refreshToken) {
            return res.status(401).json({ 
                message: '리프레시 토큰이 없습니다.' 
            });
        }

        //   토큰 검증
        const decoded = verifyToken(refreshToken, process.env.REFRESH_TOKEN_SECRET);

        //   DB에서 토큰 유효성 확인
        const storedToken = await RefreshToken.findOne({ 
            token: refreshToken,
            user: decoded.id
        });

        if (!storedToken || !storedToken.isValid()) {
            logger.security('유효하지 않은 리프레시 토큰 사용', {
                ip: req.ip,
                tokenFound: !!storedToken
            });
            return res.status(401).json({ 
                message: '유효하지 않은 리프레시 토큰입니다.' 
            });
        }

        //   새로운 Access Token 생성
        const newAccessToken = generateAccessToken(decoded.id);
        
        res.json({
            success: true,
            token: newAccessToken
        });
    } catch (error) {
        // 에러 처리
    }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 데이터 모델 설계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;User 모델 - 확장 가능한 설계&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;const UserSchema = new mongoose.Schema({
    // 기본 정보
    name: { type: String, required: true, trim: true },
    email: { type: String, required: true, unique: true, lowercase: true },
    
    // 소셜 로그인을 위한 조건부 비밀번호
    password: {
        type: String,
        required: function() {
            return !this.kakaoId &amp;amp;&amp;amp; !this.naverId;  // 소셜 로그인 시 불필요
        }
    },
    
    // 소셜 로그인 ID
    kakaoId: { type: String, sparse: true },
    naverId: { type: String, sparse: true },
    
    // 보안 관련
    loginAttempts: { type: Number, default: 0 },
    lockUntil: { type: Number, default: 0 },
    
    // 위치 정보 (GeoJSON)
    lastLocation: {
        type: { type: String, enum: ['Point'], default: 'Point' },
        coordinates: { type: [Number], default: [0, 0] }
    },
    
    // 비상 연락처
    emergencyContacts: [{
        name: String,
        phone: String,
        relationship: String
    }]
});

//   비밀번호 자동 암호화
UserSchema.pre('save', async function(next) {
    if (!this.isModified('password') || !this.password) return next();
    
    const salt = await bcrypt.genSalt(10);
    this.password = await bcrypt.hash(this.password, salt);
    next();
});

//   비밀번호 검증 메서드
UserSchema.methods.matchPassword = async function(enteredPassword) {
    if (!this.password) return false;
    return await bcrypt.compare(enteredPassword, this.password);
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RefreshToken 모델 - 토큰 관리&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;const RefreshTokenSchema = new mongoose.Schema({
    user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
    token: { type: String, required: true, unique: true },
    
    // 보안 추적
    ip: { type: String, required: true },
    userAgent: { type: String, required: true },
    
    // 토큰 상태
    isRevoked: { type: Boolean, default: false },
    expiresAt: { type: Date, required: true },
    
    // 자동 삭제 (TTL 인덱스)
    createdAt: { type: Date, default: Date.now, expires: '30d' }
});

// 토큰 유효성 검사 메서드
RefreshTokenSchema.methods.isValid = function() {
    return !this.isRevoked &amp;amp;&amp;amp; !this.isExpired();
};

RefreshTokenSchema.methods.isExpired = function() {
    return Date.now() &amp;gt;= this.expiresAt.getTime();
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  미들웨어를 통한 보안 강화&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JWT 인증 미들웨어&lt;/h3&gt;
&lt;pre class=&quot;sas&quot;&gt;&lt;code&gt;exports.protect = async (req, res, next) =&amp;gt; {
    let token;

    //   Authorization 헤더에서 Bearer 토큰 추출
    if (req.headers.authorization?.startsWith('Bearer')) {
        try {
            token = req.headers.authorization.split(' ')[1];
            
            //   토큰 검증
            const decoded = verifyToken(token, process.env.JWT_SECRET);
            
            //   사용자 정보 로드
            req.user = await User.findById(decoded.id).select('-password');
            
            if (!req.user) {
                return res.status(401).json({ 
                    message: '해당 사용자가 존재하지 않습니다.' 
                });
            }

            //  ️ 토큰 검증 (issuer, audience 확인)
            if (decoded.iss !== 'guardian-app' || decoded.aud !== 'guardian-users') {
                return res.status(401).json({ 
                    message: '유효하지 않은 토큰입니다.' 
                });
            }

            next();
        } catch (error) {
            // 토큰 만료와 기타 오류 구분
            if (error.name === 'TokenExpiredError') {
                return res.status(401).json({
                    message: '토큰이 만료되었습니다.',
                    code: 'TOKEN_EXPIRED'
                });
            }
            res.status(401).json({ message: '인증에 실패했습니다.' });
        }
    } else {
        res.status(401).json({ message: '인증 토큰이 없습니다.' });
    }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  핵심 장점&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;보안성&lt;/b&gt;: 이중 토큰 구조로 보안 강화&lt;/li&gt;
&lt;li&gt;&lt;b&gt;확장성&lt;/b&gt;: 소셜 로그인과 일반 로그인의 통합 설계&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용자 경험&lt;/b&gt;: 자동 토큰 갱신으로 끊김 없는 서비스&lt;/li&gt;
&lt;li&gt;&lt;b&gt;추적성&lt;/b&gt;: 상세한 로깅으로 보안 이벤트 추적&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유지보수성&lt;/b&gt;: 명확한 파일 구조와 역할 분리&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  다음 편 예고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2편에서는 더욱 심화된 보안 기능들을 다룰 예정입니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Rate Limiting과 브루트포스 방지&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CSRF 보호와 CORS 설정&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;소셜 로그인 구현 (Passport.js)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;포괄적인 유효성 검증&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Winston을 활용한 구조화된 로깅&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보안 모니터링과 이벤트 추적&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현대적인 웹 애플리케이션의 보안 요구사항을 모두 만족하는 &lt;b&gt;완전한 인증 시스템&lt;/b&gt;의 구축 과정을 계속해서 살펴보겠습니다!&lt;/p&gt;</description>
      <category>MyStory/SafeLink Project</category>
      <author>LupyLaon</author>
      <guid isPermaLink="true">https://lupylaon.tistory.com/19</guid>
      <comments>https://lupylaon.tistory.com/19#entry19comment</comments>
      <pubDate>Mon, 9 Jun 2025 16:39:29 +0900</pubDate>
    </item>
    <item>
      <title>SafeLink 프로젝트 소개 - 지진 재해 대응 통합 플랫폼</title>
      <link>https://lupylaon.tistory.com/18</link>
      <description>&lt;h1&gt;SafeLink 프로젝트 소개 - 지진 재해 대응 통합 플랫폼&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  SafeLink란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SafeLink&lt;/b&gt;는 일본의 지진 재해에 특화된 &lt;b&gt;종합 구조 요청 및 생존자 지원 플랫폼&lt;/b&gt;입니다. 지진과 같은 자연재해 상황에서 &lt;b&gt;생명을 구하는 것&lt;/b&gt;을 최우선 목표로, 첨단 기술을 활용해 구조 요청, 실종자 수색, 생존자 네트워크 구축을 통합적으로 지원하는 혁신적인 모바일 애플리케이션입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  프로젝트 배경과 필요성&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;일본의 지진 현실&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일본은 전 세계 지진의 약 &lt;b&gt;20%가 발생&lt;/b&gt;하는 지진 다발 국가입니다. 2011년 동일본 대지진, 2016년 구마모토 지진 등 대규모 재해 경험을 통해 다음과 같은 문제점들이 드러났습니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  &lt;b&gt;통신 인프라 마비&lt;/b&gt;: 기지국 파괴로 인한 인터넷/전화 연결 불가&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;실종자 수색 어려움&lt;/b&gt;: 넓은 지역에 흩어진 생존자들의 위치 파악 곤란&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;구조 요청 지연&lt;/b&gt;: 공식 구조팀에게 신속한 정보 전달 실패&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;생존자 간 소통 부재&lt;/b&gt;: 물자 공유, 상호 도움 체계 부족&lt;/li&gt;
&lt;li&gt;  &lt;b&gt;전력 공급 중단&lt;/b&gt;: 배터리 부족으로 인한 통신 기기 사용 불가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SafeLink의 솔루션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SafeLink는 이러한 &lt;b&gt;현실적 문제들을 기술로 해결&lt;/b&gt;하는 통합 플랫폼입니다. 인터넷이 끊어진 극한 상황에서도 작동하며, 생존자들이 서로 도우며 구조될 수 있는 &lt;b&gt;디지털 생명줄&lt;/b&gt; 역할을 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✨ 핵심 기능&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  음성 및 제스처 인식 구조 요청&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;Help!&quot;라고 외치거나 스마트폰을 흔들기만 하면 즉시 구조 신호 전송&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;  기능 상세
&amp;bull; 음성 명령어 인식: &quot;Help!&quot;, &quot;도와주세요!&quot;, &quot;たすけて!&quot; 등 다국어 지원
&amp;bull; 제스처 감지: 스마트폰 흔들기, 연속 탭핑 등 직관적 조작
&amp;bull; 긴급 상황 자동 감지: 가속도계, 자이로스코프 활용한 낙하/충격 감지
&amp;bull; 사용자 상태 자동 기록: &quot;건물 붕괴&quot;, &quot;부상&quot;, &quot;고립&quot; 등 상황 분류
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  오프라인 통신 기술&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;인터넷이 끊어져도 구조 신호 전송 가능&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;  기술 스택
&amp;bull; Bluetooth Mesh Network: 100-200m 범위 내 기기 간 연결
&amp;bull; LoRa (Long Range) 통신: 최대 15km 장거리 저전력 통신
&amp;bull; P2P 네트워크: 사용자 기기들이 중계기 역할 수행
&amp;bull; 데이터 압축: 최소한의 전력으로 최대 정보 전달
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실종자 검색 및 가족 연결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가족과 친구의 안전을 확인하고 재회할 수 있는 시스템&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;  주요 기능
&amp;bull; 가족/친구 사전 등록: 평상시 중요한 사람들의 정보 저장
&amp;bull; 근거리 실종자 탐지: Bluetooth 기반 100-200m 반경 수색
&amp;bull; 이웃 네트워크 메시지: 지역 내 안부 확인 및 정보 공유
&amp;bull; 자동 위치 추적: GPS + 기지국 + WiFi 다중 위치 측정
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  SOS 신호 공유 &amp;amp; 생존자 네트워크&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 가까운 사람이 가장 빠른 구조자가 되는 시스템&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;  네트워크 구조
&amp;bull; 우선순위 알림: 가장 가까운 사용자에게 구조 요청 우선 전송
&amp;bull; 계층적 구조 시스템: 민간 &amp;rarr; 자원봉사자 &amp;rarr; 공식 구조팀 순서로 확산
&amp;bull; 실시간 위치 공유: 구조자와 피구조자 간 정확한 위치 공유
&amp;bull; 구조 완료 피드백: 구조 상황 실시간 업데이트
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  위기 대응 커뮤니티&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;생존자들이 서로 돕는 상호부조 네트워크&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;  커뮤니티 기능
&amp;bull; 물자 공유 시스템: 음식, 의약품, 생필품 나눔 플랫폼
&amp;bull; 구호소 실시간 정보: 위치, 수용 인원, 물자 현황 업데이트
&amp;bull; 구조 요청 게시판: &quot;3명 고립&quot;, &quot;식수 부족&quot; 등 상세 상황 공유
&amp;bull; 기술 지원 네트워크: 의료진, 엔지니어 등 전문가 연결
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  스마트 전력 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;제한된 배터리로 최대한 오래 생존할 수 있는 시스템&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;⚡ 절전 기술
&amp;bull; 지진 감지 시 자동 절전 모드 활성화
&amp;bull; GPS 최적화: 간헐적 위치 업데이트로 배터리 절약
&amp;bull; 백그라운드 앱 자동 종료
&amp;bull; 화면 밝기 자동 조절 및 불필요한 기능 차단
&amp;bull; 근처 충전소 및 배터리 스테이션 안내
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 기술 스택 및 아키텍처&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;  프론트엔드 (웹 우선 개발)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Vue 3 + Composition API: 현대적이고 성능 최적화된 프론트엔드 프레임워크 &lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Vue Router 4&lt;/b&gt;: SPA 라우팅 및 네비게이션 관리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@vueuse/core&lt;/b&gt;: Vue 3 생태계 유틸리티 및 컴포지션 함수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Material-UI (MUI) + Emotion&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;: 구글 머티리얼 디자인 기반 일관성 있는 UI/UX&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;Lucide Vue Next&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;: 직관적이고 깔끔한 아이콘 시스템&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Axios&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;: RESTful API 통신 및 HTTP 요청 처리&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Vue-lazyload&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;: 이미지 지연 로딩으로 성능 최적화&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt; &lt;b&gt;Vue CLI 5&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;: 모던 빌드 도구 및 개발 환경&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt; &lt;b&gt;Jest + Vue Test Utils&lt;/b&gt;: 컴포넌트 단위 테스트 &lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  프론트엔드 (모바일 앱 개발 예정)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;React Native / Flutter&lt;/b&gt;: 크로스 플랫폼 네이티브 앱 개발&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TensorFlow Lite&lt;/b&gt;: 온디바이스 음성/제스처 인식 AI&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MapKit / Google Maps&lt;/b&gt;: 실시간 지도 및 위치 서비스&lt;/li&gt;
&lt;li&gt;&lt;b&gt;WebRTC&lt;/b&gt;: P2P 음성/영상 통신&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Socket.io Client&lt;/b&gt;: 실시간 양방향 통신&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt; ️ 백엔드 (서버 시스템)&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;  핵심 기술 스택
&amp;bull; Node.js + Express.js: 고성능 비동기 서버
&amp;bull; MongoDB + Mongoose: 확장 가능한 NoSQL 데이터베이스
&amp;bull; JWT (JSON Web Token): 안전한 사용자 인증
&amp;bull; Socket.io: 실시간 구조 신호 전송
&amp;bull; Redis: 세션 관리 및 캐싱
&amp;bull; Winston: 구조화된 로깅 시스템
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;인증 시스템&amp;nbsp;&lt;/h4&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;  보안 설계
&amp;bull; 이중 토큰 구조: Access Token (1시간) + Refresh Token (7일)
&amp;bull; 브루트포스 방지: 계정 잠금 + Rate Limiting
&amp;bull; 소셜 로그인: 카카오, 네이버 OAuth 연동
&amp;bull; CSRF 보호: 크로스사이트 요청 위조 방지
&amp;bull; 비밀번호 강화: bcrypt 해싱 + 복잡도 검증
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  통신 및 IoT 기술&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Bluetooth Low Energy (BLE)&lt;/b&gt;: 근거리 기기 탐지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LoRa/LoRaWAN&lt;/b&gt;: 장거리 저전력 통신&lt;/li&gt;
&lt;li&gt;&lt;b&gt;WebSocket&lt;/b&gt;: 실시간 데이터 스트리밍&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MQTT&lt;/b&gt;: IoT 기기 간 경량 메시징&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Progressive Web App (PWA)&lt;/b&gt;: 오프라인 동작 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  AI/ML 기술&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;음성 인식 (ASR)&lt;/b&gt;: 구조 요청 음성 명령어 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자연어 처리 (NLP)&lt;/b&gt;: 다국어 긴급 메시지 분석&lt;/li&gt;
&lt;li&gt;&lt;b&gt;컴퓨터 비전&lt;/b&gt;: 제스처 및 상황 인식&lt;/li&gt;
&lt;li&gt;&lt;b&gt;예측 모델링&lt;/b&gt;: 지진 발생 패턴 및 구조 우선순위 분석&lt;/li&gt;
&lt;li&gt;&lt;b&gt;추천 시스템&lt;/b&gt;: 최적 구조 경로 및 대피소 추천&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;☁️ 클라우드 및 인프라&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;AWS/Google Cloud&lt;/b&gt;: 확장 가능한 클라우드 인프라&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Docker + Kubernetes&lt;/b&gt;: 컨테이너 기반 배포&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CDN&lt;/b&gt;: 전국 분산 컨텐츠 전송&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Load Balancer&lt;/b&gt;: 고가용성 서비스 제공&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Disaster Recovery&lt;/b&gt;: 재해 상황 대응 백업 시스템&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 시스템 아키텍처&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  데이터 플로우&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;graph TB
    A[모바일 앱] --&amp;gt; B[API Gateway]
    B --&amp;gt; C[인증 서버]
    B --&amp;gt; D[구조 요청 서버]
    B --&amp;gt; E[실종자 검색 서버]
    B --&amp;gt; F[커뮤니티 서버]
    
    C --&amp;gt; G[MongoDB - 사용자 DB]
    D --&amp;gt; H[Redis - 실시간 데이터]
    E --&amp;gt; I[PostgreSQL - 위치 DB]
    F --&amp;gt; J[ElasticSearch - 검색 엔진]
    
    K[LoRa Gateway] --&amp;gt; L[IoT 데이터 처리]
    L --&amp;gt; H
    
    M[AI 모델 서버] --&amp;gt; N[음성/제스처 인식]
    N --&amp;gt; D
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  긴급상황 대응 플로우&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 지진 발생 감지 (가속도계/진도계)
2. 자동 절전 모드 활성화
3. 사용자 상태 확인 (음성/제스처)
4. 구조 신호 생성 및 전송
5. 근거리 사용자들에게 알림
6. 구조 팀 배치 및 경로 최적화
7. 실시간 상황 업데이트
8. 구조 완료 확인
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  사용자 인터페이스&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  UI/UX 설계 원칙&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;극한 상황에서의 직관적 사용성&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;원터치 구조 요청&lt;/b&gt;: 복잡한 메뉴 없이 즉시 도움 요청&lt;/li&gt;
&lt;li&gt;&lt;b&gt;시각적 상태 표시&lt;/b&gt;: 배터리, 연결 상태, 구조 진행 상황 한눈에 파악&lt;/li&gt;
&lt;li&gt;&lt;b&gt;음성 가이드&lt;/b&gt;: 시각 장애인 및 어둠 속에서도 사용 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;고대비 모드&lt;/b&gt;: 재해 상황의 열악한 시야 환경 대응&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  핵심 화면 구성&lt;/h3&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;  메인 대시보드
&amp;bull; 현재 안전 상태 (안전/주의/위험)
&amp;bull; 원터치 SOS 버튼 (화면 중앙 대형)
&amp;bull; 가족 안전 상태 확인
&amp;bull; 근처 구호소 정보

  긴급 상황 화면  
&amp;bull; 구조 요청 전송 상태
&amp;bull; 예상 구조 시간
&amp;bull; 구조자와의 실시간 채팅
&amp;bull; 위치 공유 On/Off

  커뮤니티 화면
&amp;bull; 지역 내 도움 요청/제공
&amp;bull; 물자 나눔 게시판
&amp;bull; 실종자 수색 네트워크
&amp;bull; 구호소 실시간 현황
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  혁신적 특징&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  기술적 혁신&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;하이브리드 통신&lt;/b&gt;: 인터넷 + Bluetooth + LoRa 다중 백업&lt;/li&gt;
&lt;li&gt;&lt;b&gt;AI 기반 상황 인식&lt;/b&gt;: 음성과 센서 데이터로 자동 상황 판단&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분산형 구조 네트워크&lt;/b&gt;: 중앙 집중식이 아닌 P2P 기반 구조 시스템&lt;/li&gt;
&lt;li&gt;&lt;b&gt;배터리 최적화&lt;/b&gt;: 재해 상황 특화 전력 관리 알고리즘&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  사회적 가치&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;생명 구조&lt;/b&gt;: 골든타임 내 신속한 구조 요청 및 대응&lt;/li&gt;
&lt;li&gt;&lt;b&gt;가족 재회&lt;/b&gt;: 흩어진 가족들의 안전 확인 및 재결합 지원&lt;/li&gt;
&lt;li&gt;&lt;b&gt;커뮤니티 회복&lt;/b&gt;: 생존자 간 상호부조로 사회 결속력 강화&lt;/li&gt;
&lt;li&gt;&lt;b&gt;재해 대비&lt;/b&gt;: 평상시 훈련 및 대비 체계 구축&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  향후 발전 계획&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  단계별 로드맵&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Phase 1: 핵심 기능 구현&lt;/b&gt; (현재)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본 구조 요청 시스템&lt;/li&gt;
&lt;li&gt;사용자 인증 및 관리&lt;/li&gt;
&lt;li&gt;실시간 통신 기반 구축&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Phase 2: AI 고도화&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;음성/제스처 인식 정확도 향상&lt;/li&gt;
&lt;li&gt;상황 인식 AI 모델 고도화&lt;/li&gt;
&lt;li&gt;예측 분석 시스템 도입&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Phase 3: 글로벌 확장&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다국가 재해 대응 시스템 통합&lt;/li&gt;
&lt;li&gt;국제 구조 기관과의 연동&lt;/li&gt;
&lt;li&gt;언어별 최적화 및 현지화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Phase 4: 스마트시티 통합&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;도시 인프라와의 연동&lt;/li&gt;
&lt;li&gt;IoT 센서 네트워크 확장&lt;/li&gt;
&lt;li&gt;예방 중심 재해 관리 시스템&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 프로젝트의 의의&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SafeLink는 단순한 앱이 아닌 &lt;b&gt;디지털 기술로 생명을 구하는 사회적 인프라&lt;/b&gt;입니다. 일본의 지진 경험을 바탕으로 개발된 이 시스템은 전 세계 재해 취약 지역에 적용 가능한 &lt;b&gt;보편적 솔루션&lt;/b&gt;으로 발전할 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SafeLink&lt;/b&gt;는 기술의 힘으로 재해 상황에서 **&quot;아무도 혼자 남겨두지 않는다&quot;**는 가치를 실현하는 프로젝트입니다.  &lt;/p&gt;</description>
      <category>MyStory/SafeLink Project</category>
      <category>일본 지진 어플</category>
      <category>지진</category>
      <category>지진 어플</category>
      <category>지진구호</category>
      <category>지진대피</category>
      <author>LupyLaon</author>
      <guid isPermaLink="true">https://lupylaon.tistory.com/18</guid>
      <comments>https://lupylaon.tistory.com/18#entry18comment</comments>
      <pubDate>Mon, 9 Jun 2025 16:37:30 +0900</pubDate>
    </item>
    <item>
      <title>레거시 코드 리팩토링기: 매장 정보 관리 시스템 개선 사례</title>
      <link>https://lupylaon.tistory.com/17</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웰스토리 DID(Digital Information Display) 프로젝트에 중간 투입되면서 겪은 흥미로운 경험을 공유하고자 합니다. 처음에는 제 방식대로 코드를 작성했다가, 팀의 기존 코딩 스타일과 아키텍처를 이해하고 이에 맞춰 전면 리팩토링을 진행한 과정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  기존 코드의 문제점들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 모델 클래스의 과도한 분산&lt;/h3&gt;
&lt;pre id=&quot;code_1749431532918&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;// 수정 전: 용도별로 분산된 3개의 모델
public class RestaurantSaveModel { /* 저장용 */ }
public class RestaurantQueryModel { /* 조회용 */ }  
public class RestaurantResponseModel { /* 응답용 */ }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동일한 매장 정보를 다루는데도 불구하고 3개의 서로 다른 모델 클래스가 존재했습니다. 이는 코드 중복과 유지보수성 저하를 야기했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. 수동 XSS 체크의 번거로움&lt;/h3&gt;
&lt;pre id=&quot;code_1749431568395&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 수정 전: 각 필드마다 수동으로 XSS 체크
model.RESTAURANT_CD = CommonProperties.getXssString(model.RESTAURANT_CD);
model.RESTAURANT_NM = CommonProperties.getXssString(model.RESTAURANT_NM);
model.BRANCH_NAME = CommonProperties.getXssString(model.BRANCH_NAME);
// ... 반복&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3. 복잡한 유효성 검사 로직&lt;/h3&gt;
&lt;pre id=&quot;code_1749431599874&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 수정 전: 컨트롤러에서 하드코딩된 유효성 검사
if (string.IsNullOrEmpty(model.BRANCH_NAME))
{
    resultModel.ERR_CODE = &quot;8001&quot;;
    resultModel.ERROR_MSG = &quot;지점명을 입력해주세요.&quot;;
    return Content(JsonConvert.SerializeObject(resultModel, Formatting.Indented));
}

if (!RestaurantBiz.IsValidPhoneNumber(model.CONTACT_NUMBER))
{
    resultModel.ERR_CODE = &quot;8001&quot;;
    resultModel.ERROR_MSG = &quot;올바른 전화번호 형식이 아닙니다.&quot;;
    return Content(JsonConvert.SerializeObject(resultModel, Formatting.Indented));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;4.&lt;span&gt; 인라인 SQL과 비즈니스 로직 혼재 &lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1749431627043&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 수정 전: Biz 레이어에서 직접 SQL 작성
const string sql = @&quot;UPDATE DID.CMS_AUTH_REST SET 
                    STORE_NM = :STORE_NM,
                    TEL_NO = :TEL_NO,
                    OPERATING_HOURS = :OPERATING_HOURS,
                    HOLIDAY_HOURS = :HOLIDAY_HOURS,
                    MOD_ID = :MOD_ID,
                    MOD_DTM = SYSDATE
                    WHERE RESTAURANT_CODE = :RESTAURANT_CODE&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt; ️ 리팩토링 과정&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1.&lt;span&gt;&lt;span&gt; 모델 통합 및 Data Annotations 적용 &lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1749431670922&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 수정 후: 통합된 모델과 선언적 유효성 검사
public class RestaurantModel
{
    [Display(Name = &quot;매장코드&quot;)]
    public string RESTAURANT_CODE { get; set; }
    
    [Required(ErrorMessage = &quot;지점명을 입력해주세요.&quot;)]
    [Display(Name = &quot;지점명&quot;)]
    public string STORE_NM { get; set; }
    
    [Required(ErrorMessage = &quot;연락처를 입력해주세요.&quot;)]
    [RegularExpression(@&quot;^(0\d{1,2}-\d{3,4}-\d{4}|01[016789]-\d{3,4}-\d{4}|070-\d{3,4}-\d{4}|1588-\d{4}|080-\d{3}-\d{4})$&quot;,
        ErrorMessage = &quot;올바른 전화번호 형식이 아닙니다. (예: 02-1234-5678, 010-1234-5678)&quot;)]
    [Display(Name = &quot;연락처&quot;)]
    public string TEL_NO { get; set; }
    
    [Required(ErrorMessage = &quot;평일 운영시간을 입력해주세요.&quot;)]
    [Display(Name = &quot;평일 운영시간&quot;)]
    public string OPERATING_HOURS { get; set; }
    
    [Required(ErrorMessage = &quot;휴일 운영시간을 입력해주세요.&quot;)]
    [Display(Name = &quot;휴일 운영시간&quot;)]
    public string HOLIDAY_HOURS { get; set; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개선점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;3개 모델을 2개로 통합 (조회용: RestaurantInfoModel, 저장용: RestaurantModel)&lt;/li&gt;
&lt;li&gt;Data Annotations를 통한 선언적 유효성 검사&lt;/li&gt;
&lt;li&gt;정규표현식을 통한 전화번호 형식 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2.&lt;span&gt;&lt;span&gt;&lt;span&gt; 저장 프로시저 도입 &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1749431700794&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 수정 후: 저장 프로시저 사용
public static ResultModel SaveRestaurantInfo(RestaurantModel model)
{
    OracleParameter[] param =
    {
        new OracleParameter(&quot;P_RESTAURANT_CODE&quot;, model.RESTAURANT_CODE),
        new OracleParameter(&quot;P_STORE_NM&quot;, model.STORE_NM),
        new OracleParameter(&quot;P_TEL_NO&quot;, model.TEL_NO),
        new OracleParameter(&quot;P_OPERATING_HOURS&quot;, model.OPERATING_HOURS),
        new OracleParameter(&quot;P_HOLIDAY_HOURS&quot;, model.HOLIDAY_HOURS),
        new OracleParameter(&quot;P_REG_ID&quot;, model.REG_ID),
        new OracleParameter(&quot;CUR&quot;, OracleDbType.RefCursor) { Direction = ParameterDirection.Output }
    };
    
    var result = OracleHelper.ExecuteDataset(CommonProperties.ConnectionString, 
        CommandType.StoredProcedure, 
        &quot;DID.PKG_CMS_RESTAURANT_MNG.PKG_CMS_RESTAURANT_UPDATE&quot;, param);
    
    return Util.ConvertDataTable&amp;lt;ResultModel&amp;gt;(result.Tables[0])[0];
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개선점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존 팀의 저장 프로시저 패턴 준수&lt;/li&gt;
&lt;li&gt;SQL Injection 방지&lt;/li&gt;
&lt;li&gt;데이터베이스 로직과 비즈니스 로직 분리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3.&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt; ModelState를 활용한 유효성 검사 &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1749431728363&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 수정 후: 간결한 컨트롤러 로직
[HttpPost]
public ActionResult SaveRestaurantInfo(RestaurantModel model)
{
    try
    {
        // ModelState 유효성 검사
        if (!ModelState.IsValid)
        {
            var errorMsg = ModelState.Values
                .SelectMany(v =&amp;gt; v.Errors)
                .Select(e =&amp;gt; e.ErrorMessage)
                .FirstOrDefault();
            
            return Json(new ResultModel
            {
                ERR_CODE = &quot;8001&quot;,
                ERROR_MSG = &quot;입력 데이터가 유효하지 않습니다: &quot; + errorMsg
            });
        }

        // 비즈니스 로직 실행
        ResultModel result = RestaurantBiz.SaveRestaurantInfo(model);
        return Json(result);
    }
    catch (Exception ex)
    {
        return Json(new ResultModel
        {
            ERR_CODE = &quot;8999&quot;,
            ERROR_MSG = &quot;매장 정보 저장 중 예기치 못한 오류가 발생했습니다: &quot; + ex.Message
        });
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;4.&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt; 통합 엔드포인트 설계 &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1749431757331&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 수정 후: 키오스크용과 관리자용 통합
public static DataSet GetRestaurantInfo(RestaurantInfoModel model)
{
    OracleParameter[] param =
    {
        new OracleParameter(&quot;P_RESTAURANT_CODE&quot;, model.RESTAURANT_CODE),
        new OracleParameter(&quot;CUR&quot;, OracleDbType.RefCursor) { Direction = ParameterDirection.Output }
    };
    return OracleHelper.ExecuteDataset(CommonProperties.ConnectionString, 
        CommandType.StoredProcedure, 
        &quot;DID.PKG_CMS_RESTAURANT_MNG.PR_USER_RESTAURANT_SELECT&quot;, param);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  개선 결과&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정량적 개선&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;코드 라인 수&lt;/b&gt;: 약 40% 감소&lt;/li&gt;
&lt;li&gt;&lt;b&gt;모델 클래스&lt;/b&gt;: 3개 &amp;rarr; 2개로 통합&lt;/li&gt;
&lt;li&gt;&lt;b&gt;엔드포인트&lt;/b&gt;: 3개 &amp;rarr; 2개로 통합&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유효성 검사 코드&lt;/b&gt;: 90% 감소&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정성적 개선&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;가독성&lt;/b&gt;: Data Annotations로 인한 직관적인 유효성 검사 규칙&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유지보수성&lt;/b&gt;: 중앙화된 모델과 저장 프로시저&lt;/li&gt;
&lt;li&gt;&lt;b&gt;확장성&lt;/b&gt;: 새로운 필드 추가 시 최소한의 코드 변경&lt;/li&gt;
&lt;li&gt;&lt;b&gt;일관성&lt;/b&gt;: 기존 팀 코딩 스타일과의 일치&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  얻은 교훈&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 팀 코딩 스타일의 중요성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중간에 프로젝트에 투입될 때는 &lt;b&gt;기존 아키텍처와 코딩 스타일을 먼저 파악&lt;/b&gt;하는 것이 중요합니다. 처음에는 제 방식대로 작성했다가, 팀의 기존 패턴을 이해하고 이에 맞춰 전면 수정하게 되었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 점진적 리팩토링의 효과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 번에 모든 것을 바꾸려 하지 않고, &lt;b&gt;기존 구조를 이해한 후 단계적으로 개선&lt;/b&gt;하는 것이 더 효과적이었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 선언적 프로그래밍의 장점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Data Annotations를 활용한 선언적 유효성 검사는 &lt;b&gt;코드의 가독성과 유지보수성을 크게 향상&lt;/b&gt;시켰습니다.&lt;/p&gt;</description>
      <category>MyStory/Deployment and management</category>
      <author>LupyLaon</author>
      <guid isPermaLink="true">https://lupylaon.tistory.com/17</guid>
      <comments>https://lupylaon.tistory.com/17#entry17comment</comments>
      <pubDate>Mon, 9 Jun 2025 10:16:39 +0900</pubDate>
    </item>
    <item>
      <title>RiskGuardian 프로젝트: 기업 리스크 관리 플랫폼 개발기</title>
      <link>https://lupylaon.tistory.com/16</link>
      <description>&lt;h1&gt;RiskGuardian 프로젝트: 기업 리스크 관리 플랫폼 개발기&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1358&quot; data-origin-height=&quot;901&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/silMu/btsNEcR4TZA/sZie8ffxKTAovU2ehBKvok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/silMu/btsNEcR4TZA/sZie8ffxKTAovU2ehBKvok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/silMu/btsNEcR4TZA/sZie8ffxKTAovU2ehBKvok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsilMu%2FbtsNEcR4TZA%2FsZie8ffxKTAovU2ehBKvok%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1358&quot; height=&quot;901&quot; data-origin-width=&quot;1358&quot; data-origin-height=&quot;901&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  프로젝트 소개&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RiskGuardian&lt;/b&gt;(리스크 가디언)은 기업의 재무적 위험을 실시간으로 모니터링하고 맞춤형 대응 방안을 제공하는 플랫폼입니다. 환율, 금리, 원자재 가격 등 다양한 외부 경제 지표를 수집하여 기업별 위험도를 분석하고, 적절한 대응 가이드를 제공함으로써 기업들이 경제적 위기 상황에 선제적으로 대응할 수 있도록 지원합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;지금 우리 회사 리스크 총 점수: 78점 (위험)&quot; - RiskGuardian 대시보드에서 보여주는 실시간 알림&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 컨셉&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실시간 리스크 모니터링 + 기업 맞춤 위험 대조 분석 + 즉각 대응 가이드&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1268&quot; data-origin-height=&quot;656&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cHvHXQ/btsNEn67kgB/qdNHSN0IHTejTJuenkAMXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cHvHXQ/btsNEn67kgB/qdNHSN0IHTejTJuenkAMXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cHvHXQ/btsNEn67kgB/qdNHSN0IHTejTJuenkAMXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcHvHXQ%2FbtsNEn67kgB%2FqdNHSN0IHTejTJuenkAMXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1268&quot; height=&quot;656&quot; data-origin-width=&quot;1268&quot; data-origin-height=&quot;656&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 사용 기술 스택&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영역기술설명&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;백엔드&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Python FastAPI&lt;/td&gt;
&lt;td&gt;빠르고 효율적인 API 서버 구축&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;데이터베이스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;MongoDB(NoSQL)&lt;/td&gt;
&lt;td&gt;유연한 데이터 구조 지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;외부 API&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;ExchangeRate-API, Trading Economics API&lt;/td&gt;
&lt;td&gt;환율, 금리, 원자재 데이터 수집&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;알림 서비스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Slack API, 카카오 알림톡, SMTP&lt;/td&gt;
&lt;td&gt;다양한 채널을 통한 알림 제공&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;AI/ML&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;시계열 분석, 이상 탐지&lt;/td&gt;
&lt;td&gt;리스크 패턴 감지 및 예측&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  개발 배경: 왜 RiskGuardian이 필요했나?&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1333&quot; data-origin-height=&quot;662&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/m6g0f/btsNC8wn77z/PXkp1bY3RQ0aAEZ3NZ2Qt1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/m6g0f/btsNC8wn77z/PXkp1bY3RQ0aAEZ3NZ2Qt1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/m6g0f/btsNC8wn77z/PXkp1bY3RQ0aAEZ3NZ2Qt1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fm6g0f%2FbtsNC8wn77z%2FPXkp1bY3RQ0aAEZ3NZ2Qt1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1333&quot; height=&quot;662&quot; data-origin-width=&quot;1333&quot; data-origin-height=&quot;662&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 글로벌 경제의 불확실성이 증가하면서 기업들은 환율 변동, 금리 인상, 원자재 가격 폭등 등 다양한 리스크에 노출되고 있습니다. 특히 중소기업이나 스타트업의 경우 이러한 리스크를 실시간으로 모니터링하고 대응하는 데 필요한 리소스가 부족한 현실입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;IMF가 오면 기업들이 이런 솔루션에 기꺼이 투자할 것입니다. 생존이 걸린 문제니까요.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RiskGuardian은 이러한 문제를 해결하기 위해 기획되었습니다. 경제 지표를 실시간으로 수집하고 기업별 맞춤형 리스크를 분석하여, 위기 상황에서도 적절한 대응을 할 수 있도록 지원하는 솔루션으로서 개발을 시작했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  개발 과정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 환경 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 초기 단계에서는 다음과 같은 환경 설정을 진행했습니다:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;mel&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;py -m venv riskguardian-env
riskguardian-env\Scripts\activate
pip install fastapi uvicorn motor pymongo python-dotenv pydantic&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 환경 설정을 통해 FastAPI 기반의 백엔드 서버를 구축하고, MongoDB를 데이터베이스로 활용하며, 환경 변수 관리와 데이터 검증 기능을 구현했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 프로젝트 구조 설계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RiskGuardian 프로젝트는 모듈성과 확장성을 고려하여 다음과 같은 구조로 설계했습니다:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;dockerfile&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;riskguardian/
├── app/
│   ├── __init__.py
│   ├── main.py          # FastAPI 애플리케이션 엔트리 포인트
│   ├── config.py        # 환경 설정
│   ├── models/          # 데이터 모델 정의
│   ├── routes/          # API 엔드포인트
│   ├── services/        # 비즈니스 로직
│   └── db/              # 데이터베이스 연결 및 초기화
├── .riskguardian-env    # 가상 환경
├── .env                 # 환경 변수
└── requirements.txt     # 의존성 관리&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 도메인 별로 코드를 모듈화하여 유지보수가 용이하고, 새로운 기능 추가 시 확장성이 좋도록 설계했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  데이터베이스 설계: MongoDB 활용하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MongoDB를 활용한 데이터베이스 설계는 RiskGuardian의 핵심 요소 중 하나입니다. NoSQL의 유연한 스키마를 활용하여 다양한 형태의 리스크 데이터를 효율적으로 저장하고 조회할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;컬렉션 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컬렉션설명주요 필드&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;companies&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;기업 정보&lt;/td&gt;
&lt;td&gt;기업명, 산업, 매출, 수출비율 등&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;risk_alerts&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;리스크 알림&lt;/td&gt;
&lt;td&gt;알림 유형, 수준, 메시지, 권장 조치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;risk_monitoring&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;리스크 모니터링 데이터&lt;/td&gt;
&lt;td&gt;환율, 금리, 유가, 원자재 가격, 글로벌 리스크 점수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;action_guides&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;액션 가이드&lt;/td&gt;
&lt;td&gt;리스크 유형, 수준, 조치 사항 목록, 기한&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MongoDB 초기화 코드&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;python&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;python&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;async def init_mongodb():
    &quot;&quot;&quot;MongoDB 데이터베이스 초기화 및 필요한 collection 생성&quot;&quot;&quot;
    try:
        client = AsyncIOMotorClient(MONGODB_URL)
        db = client[DB_NAME]
        
        # 필요한 컬렉션 리스트
        required_collections = [
            &quot;companies&quot;,
            &quot;risk_alerts&quot;,
            &quot;risk_monitoring&quot;,
            &quot;action_guides&quot;
        ]
        
        # 컬렉션 생성 및 인덱스 설정
        for collection_name in required_collections: 
            if collection_name not in exsiting_collections:
                await db.create_collection(collection_name)
                logger.info(f&quot;컬렉션 생성됨: {collection_name}&quot;)
                
                # 각 컬렉션에 맞는 인덱스 생성
                # ...
    
        # 샘플 데이터 삽입
        await insert_sample_data(db)
        
    except Exception as e:
        logger.error(f&quot;MongoDB 초기화 중 오류 발생: {e}&quot;)
        raise&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  핵심 기능 구현&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. RESTful API 설계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RiskGuardian의 API는 RESTful 원칙을 따라 설계되었으며, 주요 엔드포인트는 다음과 같습니다:&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;회사별 리스크 알림 조회 API 예시&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;python&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;python&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;@router.get(
    &quot;/company/{company_id}&quot;, 
    response_description=&quot;특정 회사의 리스크 알림 조회&quot;,
    response_model=List[RiskAlertModel],
    summary=&quot;회사별 리스크 알림 조회&quot;,
    description=&quot;특정 회사에 대한 모든 리스크 알림을 조회합니다.&quot;
)
async def get_company_risk_alerts(
    company_id: str = Path(..., description=&quot;조회할 회사의 ID&quot;),
    alert_type: Optional[str] = Query(None, description=&quot;알림 유형으로 필터링&quot;),
    alert_level: Optional[str] = Query(None, description=&quot;알림 수준으로 필터링&quot;),
    days: Optional[int] = Query(None, description=&quot;최근 n일 데이터만 필터링&quot;, ge=1)
):
    # 필터 조건 구성
    filter_query = {&quot;companyId&quot;: ObjectId(company_id)}
    if alert_type:
        filter_query[&quot;alertType&quot;] = alert_type
    if alert_level:
        filter_query[&quot;alertLevel&quot;] = alert_level
    if days:
        filter_query[&quot;createdAt&quot;] = {&quot;$gte&quot;: datetime.now() - timedelta(days=days)}
        
    alerts = await db.db[&quot;risk_alerts&quot;].find(filter_query).sort(&quot;createdAt&quot;, -1).to_list(1000)
    return alerts&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 데이터 모델링: Pydantic 활용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FastAPI와 Pydantic을 활용한 강력한 타입 검증 및 데이터 변환 기능은 RiskGuardian의 신뢰성을 높이는 요소입니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;회사 정보 모델 예시&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;python&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;class CompanyModel(BaseModel):
    id: Optional[PyObjectId] = Field(default=None, alias=&quot;_id&quot;)
    name: str = Field(..., description=&quot;회사 이름&quot;, examples=[&quot;테크기업 A&quot;])
    industry: str = Field(..., description=&quot;산업 분야&quot;, examples=[&quot;IT&quot;])
    revenue: float = Field(..., description=&quot;매출액(원)&quot;, examples=[5000000000])
    exportRatio: float = Field(..., description=&quot;수출 비율(0-1)&quot;, examples=[0.7], ge=0, le=1)
    mainCurrency: str = Field(..., description=&quot;주요 거래 통화&quot;, examples=[&quot;USD&quot;])
    mainRawMaterial: str = Field(..., description=&quot;주요 원자재&quot;, examples=[&quot;반도체&quot;])
    createdAt: datetime = Field(default_factory=datetime.now, description=&quot;생성 시간&quot;)
    updatedAt: datetime = Field(default_factory=datetime.now, description=&quot;수정 시간&quot;)
    
    # Pydantic V2 방식 설정
    model_config = ConfigDict(
        populate_by_name=True,
        arbitrary_types_allowed=True,
        json_encoders={ObjectId: str},
        # 예제 데이터...
    )&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 리스크 추세 분석 기능&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 기간 동안의 환율, 금리, 원자재 가격 등의 변화를 분석하여 기업이 직면한 리스크의 추세를 파악할 수 있는 기능을 구현했습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;python&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;applescript&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;@router.get(
    &quot;/trends/{company_id}&quot;,
    response_description=&quot;회사별 리스크 추세 데이터&quot;,
    summary=&quot;회사별 리스크 추세 분석&quot;,
    description=&quot;특정 회사의 리스크 지표 추세를 분석합니다.&quot;
)
async def get_risk_trends(
    company_id: str = Path(..., description=&quot;조회할 회사의 ID&quot;),
    days: int = Query(30, description=&quot;조회할 기간(일)&quot;, ge=1, le=365)
):
    # 데이터 조회 및 분석...
    
    # 추세 요약 계산
    if len(monitoring_data) &amp;gt; 1:
        first = monitoring_data[0]
        last = monitoring_data[-1]
        
        result[&quot;summary&quot;] = {
            &quot;period&quot;: f&quot;{days}일&quot;,
            &quot;exchangeRateChange&quot;: last[&quot;exchangeRate&quot;] - first[&quot;exchangeRate&quot;],
            &quot;interestRateChange&quot;: last[&quot;interestRate&quot;] - first[&quot;interestRate&quot;],
            &quot;oilPriceChange&quot;: last[&quot;oilPrice&quot;] - first[&quot;oilPrice&quot;],
            &quot;rawMaterialPriceChange&quot;: last[&quot;rawMaterialPrice&quot;] - first[&quot;rawMaterialPrice&quot;],
            &quot;globalRiskScoreChange&quot;: last[&quot;globalRiskScore&quot;] - first[&quot;globalRiskScore&quot;]
        }
    
    return result&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 알림 시스템&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환율 급등, 금리 인상 등 중요한 리스크 이벤트 발생 시 Slack, 카카오톡, 이메일 등 다양한 채널을 통해 즉시 알림을 제공합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  사용자 화면 구성 (UI/UX)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RiskGuardian의 사용자 인터페이스는 다음과 같은 주요 화면으로 구성되어 있습니다:&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 대시보드&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실시간 회사 리스크 총점수 표시&lt;/li&gt;
&lt;li&gt;주요 경제지표 변화 그래프&lt;/li&gt;
&lt;li&gt;가장 중요한 리스크 알림 표시&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 회사 등록 화면&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;회사 기본 정보 입력&lt;/li&gt;
&lt;li&gt;주요 매출 비율, 수입 통화, 주요 원자재 입력&lt;/li&gt;
&lt;li&gt;리스크 민감도 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 리스크 매칭 리포트&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;당신은 환율 상승에 80% 노출&quot;&lt;/li&gt;
&lt;li&gt;&quot;금리 인상에 30% 노출&quot;&lt;/li&gt;
&lt;li&gt;주요 리스크 요인 시각화&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 즉시 대응 가이드&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기한이 있는 액션 아이템 제공&lt;/li&gt;
&lt;li&gt;리스크 유형별 맞춤형 대응 전략&lt;/li&gt;
&lt;li&gt;과거 유사 사례 및 성공적 대응 방법 제시&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  비즈니스 모델&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RiskGuardian의 수익 모델은 다음과 같이 설계되었습니다:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지 표시&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;무료 플랜&lt;/b&gt;: 기본적인 리스크 알림까지만 제공&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유료 플랜&lt;/b&gt;(월 5만원~10만원):
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;회사 맞춤 리스크 분석 리포트&lt;/li&gt;
&lt;li&gt;전문가 대응 컨설팅&lt;/li&gt;
&lt;li&gt;상세 리스크 추세 분석&lt;/li&gt;
&lt;li&gt;다양한 채널을 통한 알림 서비스&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  머신러닝과 AI 활용 계획&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RiskGuardian은 다음과 같은 AI/머신러닝 기술을 활용하여 서비스를 고도화할 계획입니다:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지 표시&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목적적용 기술설명&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;리스크 조짐 사전 감지&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;시계열 분석, 이상 탐지&lt;/td&gt;
&lt;td&gt;비정상적 패턴을 자동으로 감지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;기업 맞춤형 대응 제안&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;ChatGPT API, 추천 시스템&lt;/td&gt;
&lt;td&gt;맞춤형 대응 시나리오 자동 제시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;리스크 점수 예측&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;XGBoost, RNN&lt;/td&gt;
&lt;td&gt;미래 특정 기간의 리스크 점수 예측&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;자동 보고서 생성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;LLM (GPT API)&lt;/td&gt;
&lt;td&gt;맞춤형 주간/월간 분석 리포트 자동 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  결론 및 향후 계획&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RiskGuardian 프로젝트는 현대 기업들이 직면한 경제적 리스크를 효과적으로 관리할 수 있는 플랫폼으로 개발되었습니다. FastAPI와 MongoDB를 활용한 확장 가능한 아키텍처, 실시간 데이터 모니터링, 맞춤형 알림 및 대응 가이드 제공 등의 핵심 기능을 통해 기업들은 경제적 불확실성에 더욱 효과적으로 대응할 수 있게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지 표시&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;향후 개발 계획&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;머신러닝 모델 고도화&lt;/b&gt;: 더 정확한 리스크 예측 및 패턴 인식&lt;/li&gt;
&lt;li&gt;&lt;b&gt;추가 데이터 소스 통합&lt;/b&gt;: 뉴스 데이터, 소셜 미디어 감성 분석 등&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용자 인터페이스 개선&lt;/b&gt;: 더 직관적이고 인터랙티브한 대시보드&lt;/li&gt;
&lt;li&gt;&lt;b&gt;산업별 특화 모듈&lt;/b&gt;: 제조업, IT, 유통업 등 산업별 맞춤형 분석&lt;/li&gt;
&lt;li&gt;&lt;b&gt;글로벌 확장&lt;/b&gt;: 다양한 국가 및 통화에 대한 지원 확대&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RiskGuardian은 계속해서 진화하며 기업의 리스크 관리를 더욱 효과적으로 지원할 것입니다. 이 프로젝트를 통해 기업들이 경제적 리스크에 선제적으로 대응하고, 불확실한 시장 환경에서도 안정적인 성장을 이어나갈 수 있기를 기대합니다.&lt;/p&gt;</description>
      <category>MyStory/RiskGuardian Project</category>
      <category>기업분석 #개인프로젝트 #경제리스크 #위기대응</category>
      <author>LupyLaon</author>
      <guid isPermaLink="true">https://lupylaon.tistory.com/16</guid>
      <comments>https://lupylaon.tistory.com/16#entry16comment</comments>
      <pubDate>Tue, 29 Apr 2025 11:07:14 +0900</pubDate>
    </item>
  </channel>
</rss>